@intentius/chant-lexicon-k8s 0.49.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/composites/index.d.ts +2 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/operator-stack.d.ts +183 -0
- package/dist/composites/operator-stack.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +35 -35
- package/dist/lint/post-synth/argo-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/crd-schema-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/k8s-helpers.d.ts +28 -5
- package/dist/lint/post-synth/k8s-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8005.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8006.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8041.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8042.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8101.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8102.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8103.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8104.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8105.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8201.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8202.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8203.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8204.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8205.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8207.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8208.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8209.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8301.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8302.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8303.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8304.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8305.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8306.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8401.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8402.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8403.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8404.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8405.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8406.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8407.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/op/builders.d.ts +69 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/rules/argo-helpers.ts +2 -6
- package/dist/rules/crd-schema-helpers.ts +4 -6
- package/dist/rules/k8s-helpers.ts +32 -23
- package/dist/rules/wk8005.ts +27 -32
- package/dist/rules/wk8006.ts +33 -38
- package/dist/rules/wk8041.ts +19 -24
- package/dist/rules/wk8042.ts +17 -22
- package/dist/rules/wk8101.ts +29 -34
- package/dist/rules/wk8102.ts +13 -18
- package/dist/rules/wk8103.ts +17 -22
- package/dist/rules/wk8104.ts +30 -35
- package/dist/rules/wk8105.ts +16 -21
- package/dist/rules/wk8201.ts +26 -31
- package/dist/rules/wk8202.ts +17 -22
- package/dist/rules/wk8203.ts +17 -22
- package/dist/rules/wk8204.ts +46 -51
- package/dist/rules/wk8205.ts +27 -32
- package/dist/rules/wk8207.ts +14 -19
- package/dist/rules/wk8208.ts +14 -19
- package/dist/rules/wk8209.ts +14 -19
- package/dist/rules/wk8301.ts +25 -30
- package/dist/rules/wk8302.ts +19 -24
- package/dist/rules/wk8303.ts +23 -26
- package/dist/rules/wk8304.ts +35 -40
- package/dist/rules/wk8305.ts +39 -42
- package/dist/rules/wk8306.ts +20 -25
- package/dist/rules/wk8401.ts +59 -64
- package/dist/rules/wk8402.ts +13 -18
- package/dist/rules/wk8403.ts +32 -37
- package/dist/rules/wk8404.ts +15 -21
- package/dist/rules/wk8405.ts +15 -18
- package/dist/rules/wk8406.ts +29 -34
- package/dist/rules/wk8407.ts +17 -22
- package/dist/serializer.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/composites/composites.test.ts +283 -0
- package/src/composites/index.ts +4 -0
- package/src/composites/operator-stack.ts +385 -0
- package/src/index.ts +11 -0
- package/src/lint/post-synth/argo-helpers.ts +2 -6
- package/src/lint/post-synth/crd-schema-helpers.ts +4 -6
- package/src/lint/post-synth/k8s-helpers.test.ts +26 -9
- package/src/lint/post-synth/k8s-helpers.ts +32 -23
- package/src/lint/post-synth/wk8005.ts +27 -32
- package/src/lint/post-synth/wk8006.ts +33 -38
- package/src/lint/post-synth/wk8041.ts +19 -24
- package/src/lint/post-synth/wk8042.ts +17 -22
- package/src/lint/post-synth/wk8101.ts +29 -34
- package/src/lint/post-synth/wk8102.ts +13 -18
- package/src/lint/post-synth/wk8103.ts +17 -22
- package/src/lint/post-synth/wk8104.ts +30 -35
- package/src/lint/post-synth/wk8105.ts +16 -21
- package/src/lint/post-synth/wk8201.ts +26 -31
- package/src/lint/post-synth/wk8202.ts +17 -22
- package/src/lint/post-synth/wk8203.ts +17 -22
- package/src/lint/post-synth/wk8204.ts +46 -51
- package/src/lint/post-synth/wk8205.ts +27 -32
- package/src/lint/post-synth/wk8207.ts +14 -19
- package/src/lint/post-synth/wk8208.ts +14 -19
- package/src/lint/post-synth/wk8209.ts +14 -19
- package/src/lint/post-synth/wk8301.ts +25 -30
- package/src/lint/post-synth/wk8302.ts +19 -24
- package/src/lint/post-synth/wk8303.ts +23 -26
- package/src/lint/post-synth/wk8304.ts +35 -40
- package/src/lint/post-synth/wk8305.ts +39 -42
- package/src/lint/post-synth/wk8306.ts +20 -25
- package/src/lint/post-synth/wk8401.ts +59 -64
- package/src/lint/post-synth/wk8402.ts +13 -18
- package/src/lint/post-synth/wk8403.ts +32 -37
- package/src/lint/post-synth/wk8404.ts +15 -21
- package/src/lint/post-synth/wk8405.ts +15 -18
- package/src/lint/post-synth/wk8406.ts +29 -34
- package/src/lint/post-synth/wk8407.ts +17 -22
- package/src/op/builders.test.ts +82 -0
- package/src/op/builders.ts +95 -0
- package/src/serializer.ts +9 -1
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OperatorStack composite (#1940, epic #1487) — the operating loop
|
|
3
|
+
* materialized in-cluster as a declared estate on gitops principles: one
|
|
4
|
+
* Namespace, one CronJob per hosted `ConvergeOp` tick, and RBAC scoped to
|
|
5
|
+
* what that tick can actually do. chant declares the loop that runs chant
|
|
6
|
+
* with its own k8s lexicon — the same estate discipline as any other app.
|
|
7
|
+
*
|
|
8
|
+
* ## Why a CronJob, not the operator daemon
|
|
9
|
+
*
|
|
10
|
+
* Issue #1485 ("chant operator: native durable ticks without Temporal")
|
|
11
|
+
* names cron, a systemd timer, a k8s CronJob, a CI schedule, and the
|
|
12
|
+
* `chant operator` daemon as interchangeable safe invokers of the same
|
|
13
|
+
* thing: one converge tick. A k8s CronJob invoking `chant run <name>` per
|
|
14
|
+
* tick is that issue's own "cron … invokes the tick" case, not a dependency
|
|
15
|
+
* on the daemon it proposes — #1485 is a separate, still-unmerged runtime
|
|
16
|
+
* (lease fencing, `chant operator status`, durable gate-as-fact semantics).
|
|
17
|
+
* `OperatorStack`'s container command is deliberately `chant run <name>`,
|
|
18
|
+
* the one-shot local tick `ConvergeOp` (#1484) already ships and tests
|
|
19
|
+
* against (`lexicons/temporal/src/composites/converge-op.ts`'s own doc:
|
|
20
|
+
* "one-shot runnable locally for a single tick"). When #1485 lands, a
|
|
21
|
+
* caller can override `command` to shell out to `chant operator tick`
|
|
22
|
+
* instead — this composite doesn't need to change for that; only the
|
|
23
|
+
* command a caller passes does.
|
|
24
|
+
*
|
|
25
|
+
* `concurrencyPolicy: "Forbid"` is the k8s-native analogue of
|
|
26
|
+
* `ConvergeOp`'s own Temporal schedule `overlap: "Skip"` policy — never
|
|
27
|
+
* queue a second tick behind one still running.
|
|
28
|
+
*
|
|
29
|
+
* ## RBAC derivation
|
|
30
|
+
*
|
|
31
|
+
* `ConvergeOp` adds no authority an environment did not already grant
|
|
32
|
+
* (#1484's Autonomy table). `OperatorStack` re-derives the same bound at the
|
|
33
|
+
* k8s RBAC layer, independently of the temporal lexicon (this module has no
|
|
34
|
+
* dependency on it — see the layering note below): for each hosted
|
|
35
|
+
* ConvergeOp, walk its `dispatchTargets` (the OpConfigs its rule table's
|
|
36
|
+
* `run()` actions may name) through `classifyOpVerbClass`
|
|
37
|
+
* (`packages/core/src/op/op-verb-class.ts`, #1954), then keep only the
|
|
38
|
+
* highest verb class this host's `dial` could ever actually free-run —
|
|
39
|
+
* exactly `convergeTick`'s own `verbClassAllowedToDispatch` gate and
|
|
40
|
+
* `TMP014`'s build-time refusal, restated as an RBAC ceiling:
|
|
41
|
+
*
|
|
42
|
+
* - `dial: "observe"` never dispatches (report-only) → read-only RBAC,
|
|
43
|
+
* regardless of what the rule table's targets could otherwise do.
|
|
44
|
+
* - `dial: "reconcile"` only free-runs a read-only target (TMP014 refuses a
|
|
45
|
+
* mutating dispatch under reconcile in v1) → read-only RBAC.
|
|
46
|
+
* - `dial: "apply"` free-runs read-only and mutating targets → RBAC gains
|
|
47
|
+
* create/update/patch, never delete.
|
|
48
|
+
* - A `dispatchTargets` entry that itself classifies `destructive` is
|
|
49
|
+
* refused outright, at construction — TMP014 already refuses a
|
|
50
|
+
* destructive `run()` target under any dial in v1 (the local dispatch
|
|
51
|
+
* executor can't honor its required gate), so a `destructive` target
|
|
52
|
+
* reaching this composite is either a config bypassing that build check
|
|
53
|
+
* or a target `OperatorStack` should never grant permission toward.
|
|
54
|
+
* `never delete`, unconditionally, in v1 — no verb class here ever grants
|
|
55
|
+
* `delete`/`deletecollection`, and no rule ever uses `"*"`.
|
|
56
|
+
*
|
|
57
|
+
* Each hosted ConvergeOp gets its own ServiceAccount + Role + RoleBinding
|
|
58
|
+
* (never one shared identity across differently-scoped loops) — a read-only
|
|
59
|
+
* loop and a mutating loop sharing a Namespace get RBAC as different as
|
|
60
|
+
* their own dials allow, never the union.
|
|
61
|
+
*
|
|
62
|
+
* ## Layering
|
|
63
|
+
*
|
|
64
|
+
* `lexicons/k8s` has no workspace dependency on `lexicons/temporal` (nor
|
|
65
|
+
* the reverse — see both packages' `package.json`), so this module doesn't
|
|
66
|
+
* import `ConvergeOpConfig`/`ConvergeRule` types. `OperatorStackConvergeHost`
|
|
67
|
+
* restates the handful of `ConvergeOp` fields this composite actually needs
|
|
68
|
+
* (`name`, `schedule`, `env`, `dial`) structurally; `dispatchTargets` takes
|
|
69
|
+
* plain `OpConfig`-shaped values from `@intentius/chant/op` (a dependency
|
|
70
|
+
* this lexicon already has via the `@intentius/chant` peer dependency),
|
|
71
|
+
* the same type `classifyOpVerbClass` itself takes.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
import { Composite, mergeDefaults } from "@intentius/chant";
|
|
75
|
+
import { classifyOpVerbClass } from "@intentius/chant/op";
|
|
76
|
+
import type { OpConfig, OpVerbClass } from "@intentius/chant/op";
|
|
77
|
+
import { Namespace, CronJob, ServiceAccount, Role, RoleBinding } from "../generated";
|
|
78
|
+
|
|
79
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Mirrors `ConvergeOp`'s own `ConvergeDial`
|
|
83
|
+
* (`lexicons/temporal/src/composites/converge-op.ts`) structurally — see
|
|
84
|
+
* this module's Layering doc for why it's restated rather than imported.
|
|
85
|
+
*/
|
|
86
|
+
export type OperatorDial = "observe" | "reconcile" | "apply";
|
|
87
|
+
|
|
88
|
+
/** One resource-kind grant: an API group plus the resource names within it. Verbs are never specified here — they come from the derived verb class, never authored per-rule (the one place a rule table could smuggle in `delete`). */
|
|
89
|
+
export interface OperatorRbacResourceRule {
|
|
90
|
+
/** `""` is the core API group. */
|
|
91
|
+
apiGroups: string[];
|
|
92
|
+
resources: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface OperatorStackConvergeHost {
|
|
96
|
+
/** ConvergeOp's own name (`ConvergeOpConfig.name`) — the CronJob, ServiceAccount, Role, and RoleBinding name stem for this host. */
|
|
97
|
+
name: string;
|
|
98
|
+
/** Cron expression driving the tick — the same string passed to `ConvergeOp`'s own `schedule`. */
|
|
99
|
+
schedule: string;
|
|
100
|
+
/** Environment this ConvergeOp converges (`ConvergeOpConfig.env`) — carried onto the container as `CHANT_CONVERGE_ENV`, for log/estate readability only (`chant run <name>` needs no `--env`: the target op already carries it). */
|
|
101
|
+
env: string;
|
|
102
|
+
/** Authority dial (`ConvergeOpConfig.dial`). @default "observe" */
|
|
103
|
+
dial?: OperatorDial;
|
|
104
|
+
/**
|
|
105
|
+
* OpConfigs for every op this ConvergeOp's rule table may `run()` — the
|
|
106
|
+
* sibling `*.op.ts` declarations its `run()` actions name. Used to derive
|
|
107
|
+
* least-privilege RBAC the same way `TMP014` derives its build-time
|
|
108
|
+
* refusals. Omit or leave empty when every rule only `report()`s; the
|
|
109
|
+
* host still gets read-only RBAC for its own observation.
|
|
110
|
+
*/
|
|
111
|
+
dispatchTargets?: Pick<OpConfig, "phases" | "onFailure">[];
|
|
112
|
+
/** RBAC resource kinds this host's ServiceAccount may act on (the estate being converged — there's no way to derive this generically from an OpConfig). @default DEFAULT_RESOURCE_RULES */
|
|
113
|
+
resources?: OperatorRbacResourceRule[];
|
|
114
|
+
/** Container command. @default `["chant", "run", <name>]` — see this module's doc on why, and #1485 for the future `chant operator`-shaped alternative. */
|
|
115
|
+
command?: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface OperatorStackConfig {
|
|
119
|
+
/** Stack name — also the Namespace name unless `namespace` is given. */
|
|
120
|
+
name: string;
|
|
121
|
+
/** Namespace hosting every CronJob. @default config.name */
|
|
122
|
+
namespace?: string;
|
|
123
|
+
/** Container image running the chant CLI. */
|
|
124
|
+
image: string;
|
|
125
|
+
/** ConvergeOps to host — one CronJob (+ ServiceAccount + Role + RoleBinding) per entry. Non-empty; host names must be unique. */
|
|
126
|
+
converge: OperatorStackConvergeHost[];
|
|
127
|
+
/** RBAC resource kinds granted by default when a host doesn't name its own `resources`. @default DEFAULT_RESOURCE_RULES */
|
|
128
|
+
defaultResources?: OperatorRbacResourceRule[];
|
|
129
|
+
/** @default 3 */
|
|
130
|
+
successfulJobsHistoryLimit?: number;
|
|
131
|
+
/** @default 1 */
|
|
132
|
+
failedJobsHistoryLimit?: number;
|
|
133
|
+
/** Additional labels applied to every resource. */
|
|
134
|
+
labels?: Record<string, string>;
|
|
135
|
+
/** Per-member-kind defaults for fine-grained overrides, applied to every host's member of that kind. */
|
|
136
|
+
defaults?: {
|
|
137
|
+
namespace?: Partial<Record<string, unknown>>;
|
|
138
|
+
serviceAccount?: Partial<Record<string, unknown>>;
|
|
139
|
+
role?: Partial<Record<string, unknown>>;
|
|
140
|
+
roleBinding?: Partial<Record<string, unknown>>;
|
|
141
|
+
cronJob?: Partial<Record<string, unknown>>;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Flat member shape: `namespace`, plus `serviceAccount_<host>`,
|
|
147
|
+
* `role_<host>`, `roleBinding_<host>`, `cronJob_<host>` for every entry in
|
|
148
|
+
* `converge` — a `Composite` member must itself be a `Declarable`
|
|
149
|
+
* (`packages/core/src/composite.ts`), so per-host resources are flat, keyed
|
|
150
|
+
* members rather than grouped under a nested `Record` (a plain object isn't
|
|
151
|
+
* a `Declarable`, so it can't itself be a composite member). A host named
|
|
152
|
+
* `"fountain-converge"` reads back as
|
|
153
|
+
* `result["cronJob_fountain-converge"]` (bracket notation — the key isn't a
|
|
154
|
+
* valid bare identifier).
|
|
155
|
+
*/
|
|
156
|
+
export interface OperatorStackResult {
|
|
157
|
+
namespace: InstanceType<typeof Namespace>;
|
|
158
|
+
[member: string]:
|
|
159
|
+
| InstanceType<typeof Namespace>
|
|
160
|
+
| InstanceType<typeof ServiceAccount>
|
|
161
|
+
| InstanceType<typeof Role>
|
|
162
|
+
| InstanceType<typeof RoleBinding>
|
|
163
|
+
| InstanceType<typeof CronJob>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── RBAC derivation ──────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/** Default RBAC resource kinds — the common workload surface a converge tick observes/acts on. Deliberately excludes `secrets`: a host that needs secret access opts in explicitly via its own `resources`. */
|
|
169
|
+
export const DEFAULT_RESOURCE_RULES: OperatorRbacResourceRule[] = [
|
|
170
|
+
{ apiGroups: [""], resources: ["pods", "services", "configmaps", "persistentvolumeclaims", "events"] },
|
|
171
|
+
{ apiGroups: ["apps"], resources: ["deployments", "statefulsets", "daemonsets", "replicasets"] },
|
|
172
|
+
{ apiGroups: ["batch"], resources: ["jobs", "cronjobs"] },
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
const READ_VERBS = ["get", "list", "watch"];
|
|
176
|
+
const MUTATE_VERBS = [...READ_VERBS, "create", "update", "patch"];
|
|
177
|
+
|
|
178
|
+
/** RBAC verbs for a derived class. Never returns `delete`/`deletecollection`/`"*"` — v1 has no path to a destructive dispatch (see this module's RBAC derivation doc). */
|
|
179
|
+
function rbacVerbsFor(verbClass: OpVerbClass): string[] {
|
|
180
|
+
return verbClass === "mutating" ? MUTATE_VERBS : READ_VERBS;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether `dial` ever actually free-runs a dispatch classified `verbClass` —
|
|
185
|
+
* restates `convergeTick`'s own `verbClassAllowedToDispatch`
|
|
186
|
+
* (`lexicons/temporal/src/op/activities/converge.ts`) so the RBAC ceiling
|
|
187
|
+
* this composite grants matches the ceiling the tick itself enforces at
|
|
188
|
+
* runtime, without importing across the lexicon boundary (see this module's
|
|
189
|
+
* Layering doc).
|
|
190
|
+
*/
|
|
191
|
+
function dialAllowsVerbClass(dial: OperatorDial, verbClass: OpVerbClass): boolean {
|
|
192
|
+
if (verbClass === "read-only") return true;
|
|
193
|
+
if (verbClass === "mutating") return dial === "apply";
|
|
194
|
+
return false; // destructive: never free-run under any dial in v1.
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Derive the highest verb class a host's ServiceAccount actually needs:
|
|
199
|
+
* the max, across `dispatchTargets`, of each target's own class — but only
|
|
200
|
+
* counting a target `dial` could ever actually dispatch (one it can't just
|
|
201
|
+
* gets reported, per `TMP014`/`convergeTick`, and needs no elevated grant).
|
|
202
|
+
* A `dispatchTargets` entry that classifies `destructive` is refused
|
|
203
|
+
* outright rather than silently ignored — see this module's RBAC
|
|
204
|
+
* derivation doc on why a destructive target reaching this composite is
|
|
205
|
+
* itself a refusal, not a no-op.
|
|
206
|
+
*/
|
|
207
|
+
export function deriveHostVerbClass(hostName: string, dial: OperatorDial, dispatchTargets: Pick<OpConfig, "phases" | "onFailure">[]): OpVerbClass {
|
|
208
|
+
let effective: OpVerbClass = "read-only";
|
|
209
|
+
for (const target of dispatchTargets) {
|
|
210
|
+
const verbClass = classifyOpVerbClass(target);
|
|
211
|
+
if (verbClass === "destructive") {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`OperatorStack host "${hostName}": a dispatchTargets entry classifies as destructive — ConvergeOp v1 refuses a destructive run() target under any dial ` +
|
|
214
|
+
`(TMP014; the local dispatch executor can't honor its required gate). Remove it from dispatchTargets, or remediate manually via a gated op.`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (!dialAllowsVerbClass(dial, verbClass)) continue; // dial refuses this dispatch — reported, not run; needs no elevated RBAC
|
|
218
|
+
if (verbClass === "mutating") effective = "mutating";
|
|
219
|
+
}
|
|
220
|
+
return effective;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Composite ────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Create an OperatorStack composite — a Namespace hosting one CronJob (+
|
|
227
|
+
* least-privilege ServiceAccount/Role/RoleBinding) per ConvergeOp, the
|
|
228
|
+
* in-cluster declared estate for the operating loop.
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* ```ts
|
|
232
|
+
* import { OperatorStack } from "@intentius/chant-lexicon-k8s";
|
|
233
|
+
*
|
|
234
|
+
* const stack = OperatorStack({
|
|
235
|
+
* name: "chant-operator",
|
|
236
|
+
* image: "ghcr.io/intentius/chant:0.49.0",
|
|
237
|
+
* converge: [
|
|
238
|
+
* { name: "fountain-observe", schedule: "*\/10 * * * *", env: "staging", dial: "observe" },
|
|
239
|
+
* { name: "fountain-converge", schedule: "*\/10 * * * *", env: "staging", dial: "apply", dispatchTargets: [fountainApplyOp] },
|
|
240
|
+
* ],
|
|
241
|
+
* });
|
|
242
|
+
* ```
|
|
243
|
+
*/
|
|
244
|
+
export const OperatorStack = Composite((props: OperatorStackConfig) => {
|
|
245
|
+
const {
|
|
246
|
+
name,
|
|
247
|
+
namespace: namespaceName = name,
|
|
248
|
+
image,
|
|
249
|
+
converge,
|
|
250
|
+
defaultResources = DEFAULT_RESOURCE_RULES,
|
|
251
|
+
successfulJobsHistoryLimit = 3,
|
|
252
|
+
failedJobsHistoryLimit = 1,
|
|
253
|
+
labels: extraLabels = {},
|
|
254
|
+
defaults: defs,
|
|
255
|
+
} = props;
|
|
256
|
+
|
|
257
|
+
if (converge.length === 0) {
|
|
258
|
+
throw new Error(`OperatorStack "${name}": at least one ConvergeOp to host is required — a namespace with no CronJob has nothing to converge.`);
|
|
259
|
+
}
|
|
260
|
+
const seen = new Set<string>();
|
|
261
|
+
for (const host of converge) {
|
|
262
|
+
if (!host.name || host.name.trim().length === 0) {
|
|
263
|
+
throw new Error(`OperatorStack "${name}": every hosted ConvergeOp needs a non-empty name.`);
|
|
264
|
+
}
|
|
265
|
+
if (seen.has(host.name)) {
|
|
266
|
+
throw new Error(`OperatorStack "${name}": duplicate hosted ConvergeOp name "${host.name}" — CronJob/ServiceAccount names would collide.`);
|
|
267
|
+
}
|
|
268
|
+
seen.add(host.name);
|
|
269
|
+
if (!host.schedule || host.schedule.trim().length === 0) {
|
|
270
|
+
throw new Error(`OperatorStack "${name}", host "${host.name}": schedule is required — an operator CronJob with no schedule never ticks.`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (!image || image.trim().length === 0) {
|
|
274
|
+
throw new Error(`OperatorStack "${name}": image is required — the CronJob has nothing to run.`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const commonLabels: Record<string, string> = {
|
|
278
|
+
"app.kubernetes.io/name": name,
|
|
279
|
+
"app.kubernetes.io/managed-by": "chant",
|
|
280
|
+
"app.kubernetes.io/component": "operator",
|
|
281
|
+
...extraLabels,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const namespace = new Namespace(mergeDefaults({
|
|
285
|
+
metadata: {
|
|
286
|
+
name: namespaceName,
|
|
287
|
+
labels: { ...commonLabels, "app.kubernetes.io/component": "namespace" },
|
|
288
|
+
},
|
|
289
|
+
}, defs?.namespace));
|
|
290
|
+
|
|
291
|
+
const result: Record<string, any> = { namespace };
|
|
292
|
+
|
|
293
|
+
for (const host of converge) {
|
|
294
|
+
const dial = host.dial ?? "observe";
|
|
295
|
+
const verbClass = deriveHostVerbClass(host.name, dial, host.dispatchTargets ?? []);
|
|
296
|
+
const resourceRules = host.resources ?? defaultResources;
|
|
297
|
+
const verbs = rbacVerbsFor(verbClass);
|
|
298
|
+
|
|
299
|
+
const saName = `${host.name}-sa`;
|
|
300
|
+
const roleName = `${host.name}-role`;
|
|
301
|
+
const bindingName = `${host.name}-binding`;
|
|
302
|
+
|
|
303
|
+
const hostLabels: Record<string, string> = {
|
|
304
|
+
...commonLabels,
|
|
305
|
+
"app.kubernetes.io/instance": host.name,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const serviceAccount = new ServiceAccount(mergeDefaults({
|
|
309
|
+
metadata: {
|
|
310
|
+
name: saName,
|
|
311
|
+
namespace: namespaceName,
|
|
312
|
+
labels: hostLabels,
|
|
313
|
+
},
|
|
314
|
+
}, defs?.serviceAccount));
|
|
315
|
+
result[`serviceAccount_${host.name}`] = serviceAccount;
|
|
316
|
+
|
|
317
|
+
const role = new Role(mergeDefaults({
|
|
318
|
+
metadata: {
|
|
319
|
+
name: roleName,
|
|
320
|
+
namespace: namespaceName,
|
|
321
|
+
labels: { ...hostLabels, "app.kubernetes.io/component": "rbac" },
|
|
322
|
+
},
|
|
323
|
+
rules: resourceRules.map((r) => ({ apiGroups: r.apiGroups, resources: r.resources, verbs })),
|
|
324
|
+
}, defs?.role));
|
|
325
|
+
result[`role_${host.name}`] = role;
|
|
326
|
+
|
|
327
|
+
const roleBinding = new RoleBinding(mergeDefaults({
|
|
328
|
+
metadata: {
|
|
329
|
+
name: bindingName,
|
|
330
|
+
namespace: namespaceName,
|
|
331
|
+
labels: { ...hostLabels, "app.kubernetes.io/component": "rbac" },
|
|
332
|
+
},
|
|
333
|
+
roleRef: {
|
|
334
|
+
apiGroup: "rbac.authorization.k8s.io",
|
|
335
|
+
kind: "Role",
|
|
336
|
+
name: roleName,
|
|
337
|
+
},
|
|
338
|
+
subjects: [
|
|
339
|
+
{ kind: "ServiceAccount", name: saName, namespace: namespaceName },
|
|
340
|
+
],
|
|
341
|
+
}, defs?.roleBinding));
|
|
342
|
+
result[`roleBinding_${host.name}`] = roleBinding;
|
|
343
|
+
|
|
344
|
+
const command = host.command ?? ["chant", "run", host.name];
|
|
345
|
+
|
|
346
|
+
const cronJob = new CronJob(mergeDefaults({
|
|
347
|
+
metadata: {
|
|
348
|
+
name: host.name,
|
|
349
|
+
namespace: namespaceName,
|
|
350
|
+
labels: { ...hostLabels, "app.kubernetes.io/component": "converge-tick" },
|
|
351
|
+
},
|
|
352
|
+
spec: {
|
|
353
|
+
schedule: host.schedule,
|
|
354
|
+
concurrencyPolicy: "Forbid",
|
|
355
|
+
successfulJobsHistoryLimit,
|
|
356
|
+
failedJobsHistoryLimit,
|
|
357
|
+
jobTemplate: {
|
|
358
|
+
spec: {
|
|
359
|
+
template: {
|
|
360
|
+
metadata: { labels: { ...hostLabels, "app.kubernetes.io/component": "converge-tick" } },
|
|
361
|
+
spec: {
|
|
362
|
+
serviceAccountName: saName,
|
|
363
|
+
restartPolicy: "OnFailure",
|
|
364
|
+
containers: [
|
|
365
|
+
{
|
|
366
|
+
name: host.name,
|
|
367
|
+
image,
|
|
368
|
+
command,
|
|
369
|
+
env: [
|
|
370
|
+
{ name: "CHANT_CONVERGE_ENV", value: host.env },
|
|
371
|
+
{ name: "CHANT_CONVERGE_DIAL", value: dial },
|
|
372
|
+
],
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
}, defs?.cronJob));
|
|
381
|
+
result[`cronJob_${host.name}`] = cronJob;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return result;
|
|
385
|
+
}, "OperatorStack");
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,15 @@ export { k8sSerializer } from "./serializer";
|
|
|
4
4
|
// Plugin
|
|
5
5
|
export { k8sPlugin } from "./plugin";
|
|
6
6
|
|
|
7
|
+
// Typed Op step-builder wrappers (chant #1288 Stage 2) — kubectlApply/
|
|
8
|
+
// waitForReady/ensureSecret — are deliberately NOT re-exported here: their
|
|
9
|
+
// types derive from the activities' own *Args interfaces, which name the API
|
|
10
|
+
// client and therefore sit behind the #1074 dynamic-import boundary this
|
|
11
|
+
// entry point must never reach, even type-only
|
|
12
|
+
// (examples/k8s-client-boundary.test.ts). Import them via the subpath
|
|
13
|
+
// `@intentius/chant-lexicon-k8s/op/builders`, the same way workers import
|
|
14
|
+
// `@intentius/chant-lexicon-k8s/op/activities`.
|
|
15
|
+
|
|
7
16
|
// The capability plugin core's loader discovers on this package (#1495 piece 2)
|
|
8
17
|
// — the kubectl-apply leaf a component composes, the way aws contributes
|
|
9
18
|
// cfn-deploy.
|
|
@@ -43,6 +52,7 @@ export {
|
|
|
43
52
|
AksWorkloadIdentityServiceAccount,
|
|
44
53
|
GkeFluentBitAgent, GkeOtelCollector, GkeExternalDnsAgent, AksExternalDnsAgent,
|
|
45
54
|
Model, resolveModelStorageUri,
|
|
55
|
+
OperatorStack, deriveHostVerbClass, DEFAULT_RESOURCE_RULES,
|
|
46
56
|
} from "./composites/index";
|
|
47
57
|
export type {
|
|
48
58
|
WebAppProps, WebAppResult, StatefulAppProps, StatefulAppResult, CronWorkloadProps, CronWorkloadResult,
|
|
@@ -83,6 +93,7 @@ export type {
|
|
|
83
93
|
GkeExternalDnsAgentProps, GkeExternalDnsAgentResult,
|
|
84
94
|
AksExternalDnsAgentProps, AksExternalDnsAgentResult,
|
|
85
95
|
ModelSource, ModelProps, ModelResult,
|
|
96
|
+
OperatorDial, OperatorRbacResourceRule, OperatorStackConvergeHost, OperatorStackConfig, OperatorStackResult,
|
|
86
97
|
} from "./composites/index";
|
|
87
98
|
|
|
88
99
|
// RBAC verb constants
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Excluded from check auto-discovery by the "helper" filename filter.
|
|
7
7
|
*/
|
|
8
8
|
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
9
|
-
import {
|
|
9
|
+
import { docsToManifests, type K8sManifest } from "./k8s-helpers";
|
|
10
10
|
|
|
11
11
|
/** The always-present, in-cluster Argo destination. */
|
|
12
12
|
export const IN_CLUSTER_SERVER = "https://kubernetes.default.svc";
|
|
@@ -17,11 +17,7 @@ export const CLUSTER_SECRET_TYPE_LABEL = "argocd.argoproj.io/secret-type";
|
|
|
17
17
|
|
|
18
18
|
/** All manifests across every lexicon output. */
|
|
19
19
|
export function allManifests(ctx: PostSynthContext): K8sManifest[] {
|
|
20
|
-
|
|
21
|
-
for (const [, output] of ctx.outputs) {
|
|
22
|
-
manifests.push(...parseK8sManifests(getPrimaryOutput(output)));
|
|
23
|
-
}
|
|
24
|
-
return manifests;
|
|
20
|
+
return docsToManifests(ctx);
|
|
25
21
|
}
|
|
26
22
|
|
|
27
23
|
/** Manifests of a given Argo kind. */
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { createRequire } from "module";
|
|
15
15
|
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
16
16
|
import type { CrdFieldSchema } from "../../spec/parse";
|
|
17
|
-
import {
|
|
17
|
+
import { docsToManifests, type K8sManifest } from "./k8s-helpers";
|
|
18
18
|
|
|
19
19
|
export type { CrdFieldSchema };
|
|
20
20
|
|
|
@@ -70,11 +70,9 @@ export function specSchemaFor(manifest: K8sManifest): CrdFieldSchema | undefined
|
|
|
70
70
|
/** Every manifest in the build that has a shipped spec schema, with that schema. */
|
|
71
71
|
export function customResources(ctx: PostSynthContext): Array<{ manifest: K8sManifest; schema: CrdFieldSchema }> {
|
|
72
72
|
const out: Array<{ manifest: K8sManifest; schema: CrdFieldSchema }> = [];
|
|
73
|
-
for (const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (schema) out.push({ manifest, schema });
|
|
77
|
-
}
|
|
73
|
+
for (const manifest of docsToManifests(ctx)) {
|
|
74
|
+
const schema = specSchemaFor(manifest);
|
|
75
|
+
if (schema) out.push({ manifest, schema });
|
|
78
76
|
}
|
|
79
77
|
return out;
|
|
80
78
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { makePostSynthCtx } from "@intentius/chant-test-utils";
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
+
docsToManifests,
|
|
4
5
|
extractContainers,
|
|
5
6
|
extractPodSpec,
|
|
6
7
|
WORKLOAD_KINDS,
|
|
7
8
|
} from "./k8s-helpers";
|
|
8
9
|
|
|
9
|
-
describe("
|
|
10
|
+
describe("docsToManifests", () => {
|
|
10
11
|
test("splits multi-doc YAML", () => {
|
|
11
12
|
const yaml = `
|
|
12
13
|
apiVersion: v1
|
|
@@ -19,7 +20,7 @@ kind: Deployment
|
|
|
19
20
|
metadata:
|
|
20
21
|
name: deploy
|
|
21
22
|
`;
|
|
22
|
-
const manifests =
|
|
23
|
+
const manifests = docsToManifests(makePostSynthCtx("k8s", yaml));
|
|
23
24
|
expect(manifests.length).toBe(2);
|
|
24
25
|
expect(manifests[0].kind).toBe("Service");
|
|
25
26
|
expect(manifests[1].kind).toBe("Deployment");
|
|
@@ -34,16 +35,15 @@ metadata:
|
|
|
34
35
|
data:
|
|
35
36
|
key: value
|
|
36
37
|
`;
|
|
37
|
-
const manifests =
|
|
38
|
+
const manifests = docsToManifests(makePostSynthCtx("k8s", yaml));
|
|
38
39
|
expect(manifests.length).toBe(1);
|
|
39
40
|
expect(manifests[0].kind).toBe("ConfigMap");
|
|
40
41
|
});
|
|
41
42
|
|
|
42
43
|
test("handles empty/invalid YAML gracefully", () => {
|
|
43
|
-
expect(
|
|
44
|
-
expect(
|
|
45
|
-
|
|
46
|
-
expect(parseK8sManifests("---\n---\n")).toEqual([]);
|
|
44
|
+
expect(docsToManifests(makePostSynthCtx("k8s", ""))).toEqual([]);
|
|
45
|
+
expect(docsToManifests(makePostSynthCtx("k8s", "---"))).toEqual([]);
|
|
46
|
+
expect(docsToManifests(makePostSynthCtx("k8s", "---\n---\n"))).toEqual([]);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
test("skips blank documents between separators", () => {
|
|
@@ -60,9 +60,26 @@ kind: Service
|
|
|
60
60
|
metadata:
|
|
61
61
|
name: s
|
|
62
62
|
`;
|
|
63
|
-
const manifests =
|
|
63
|
+
const manifests = docsToManifests(makePostSynthCtx("k8s", yaml));
|
|
64
64
|
expect(manifests.length).toBe(2);
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
test("falls back to parsing ctx.outputs when ctx.docs is absent (hand-rolled test contexts)", () => {
|
|
68
|
+
const ctx = {
|
|
69
|
+
outputs: new Map([["k8s", "apiVersion: v1\nkind: Pod\nmetadata:\n name: p\n"]]),
|
|
70
|
+
entities: new Map(),
|
|
71
|
+
buildResult: {
|
|
72
|
+
outputs: new Map([["k8s", "apiVersion: v1\nkind: Pod\nmetadata:\n name: p\n"]]),
|
|
73
|
+
entities: new Map(),
|
|
74
|
+
warnings: [],
|
|
75
|
+
errors: [],
|
|
76
|
+
sourceFileCount: 1,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
const manifests = docsToManifests(ctx);
|
|
80
|
+
expect(manifests.length).toBe(1);
|
|
81
|
+
expect(manifests[0].kind).toBe("Pod");
|
|
82
|
+
});
|
|
66
83
|
});
|
|
67
84
|
|
|
68
85
|
describe("extractContainers", () => {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared helpers for Kubernetes post-synthesis lint rules.
|
|
3
3
|
*
|
|
4
|
-
* Provides
|
|
5
|
-
* extraction logic that handles all common workload types.
|
|
4
|
+
* Provides the `ctx.docs` → `K8sManifest[]` bridge every check reads through,
|
|
5
|
+
* and container extraction logic that handles all common workload types.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { parseOutputDocs, type PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
9
9
|
export { getPrimaryOutput, getAdditionalFiles } from "@intentius/chant/lint/post-synth";
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -45,27 +45,36 @@ export interface K8sContainer {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
48
|
+
* Every parsed Kubernetes manifest across all lexicon outputs, via `ctx.docs`
|
|
49
|
+
* (chant #975) — the shared, parse-once view every k8s post-synth check
|
|
50
|
+
* reads, in place of the old per-check `parseK8sManifests(getPrimaryOutput(output))`
|
|
51
|
+
* splitter (chant #976). Two documents are dropped rather than passed through
|
|
52
|
+
* as a "manifest":
|
|
53
|
+
*
|
|
54
|
+
* - `d.error` — a document `parseOutputDocs` could not make sense of.
|
|
55
|
+
* - `d.file` — a document that came from a `SerializerResult.files` sidecar
|
|
56
|
+
* (a committed-encrypted secret's ciphertext, in this lexicon) rather than
|
|
57
|
+
* a lexicon's primary output. `parseK8sManifests` only ever saw
|
|
58
|
+
* `getPrimaryOutput(output)`, so a sidecar was invisible to every general
|
|
59
|
+
* manifest-consuming check — see `getAdditionalFiles`'s doc comment on why
|
|
60
|
+
* that separation is deliberate. Only `sops-helpers.ts`'s
|
|
61
|
+
* `resolveEncryptedSecretClaims` (WK8504/WK8503's producer join) reads
|
|
62
|
+
* `.files` explicitly; this bridge must keep it that way.
|
|
63
|
+
*
|
|
64
|
+
* `ctx.docs` is populated by every context chant itself builds (`chant
|
|
65
|
+
* build`, `@intentius/chant-test-utils`'s `createPostSynthContext` /
|
|
66
|
+
* `makePostSynthCtx*`) — see `PostSynthContext.docs`'s doc comment for why
|
|
67
|
+
* it is still optional at the type level. A handful of this lexicon's own
|
|
68
|
+
* hand-rolled `PostSynthContext` test literals (`post-synth.test.ts`'s
|
|
69
|
+
* `makeCtx`) predate that field and never set it; for those, fall back to
|
|
70
|
+
* parsing `ctx.outputs` directly with the same `parseOutputDocs` primitive,
|
|
71
|
+
* rather than silently returning no manifests.
|
|
50
72
|
*/
|
|
51
|
-
export function
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const trimmed = doc.trim();
|
|
57
|
-
if (trimmed === "" || trimmed === "---") continue;
|
|
58
|
-
try {
|
|
59
|
-
const parsed = parseYAML(trimmed);
|
|
60
|
-
if (typeof parsed === "object" && parsed !== null) {
|
|
61
|
-
manifests.push(parsed as K8sManifest);
|
|
62
|
-
}
|
|
63
|
-
} catch {
|
|
64
|
-
// Skip unparseable documents
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return manifests;
|
|
73
|
+
export function docsToManifests(ctx: PostSynthContext): K8sManifest[] {
|
|
74
|
+
const docs = ctx.docs ?? parseOutputDocs(ctx.outputs);
|
|
75
|
+
return docs
|
|
76
|
+
.filter((d) => !d.error && !d.file)
|
|
77
|
+
.map((d) => d.value as K8sManifest);
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
/**
|