@intentius/chant-lexicon-k8s 0.57.0 → 0.59.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/api/fake-cluster.d.ts +7 -6
- package/dist/api/fake-cluster.d.ts.map +1 -1
- package/dist/api/sweep-types.d.ts +1 -2
- package/dist/api/sweep-types.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/composites/cron-schedule.d.ts +26 -0
- package/dist/composites/cron-schedule.d.ts.map +1 -0
- package/dist/composites/cron-workload.d.ts +1 -1
- package/dist/composites/cron-workload.d.ts.map +1 -1
- package/dist/composites/operator-stack.d.ts +20 -19
- package/dist/composites/operator-stack.d.ts.map +1 -1
- package/dist/config-schema.d.ts +3 -0
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config.d.ts +26 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observe-hooks.d.ts +4 -4
- package/dist/deep-observe.d.ts +23 -16
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/effect-receipt-row.d.ts +164 -0
- package/dist/effect-receipt-row.d.ts.map +1 -0
- package/dist/export-resources.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +10 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8505.d.ts +3 -1
- package/dist/lint/post-synth/wk8505.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -1
- package/dist/okf/rules/WK8505.md +2 -2
- package/dist/okf/types/Kustomization.md +1 -1
- package/dist/op/activities/argo.d.ts +4 -4
- package/dist/op/activities/argo.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +15 -7
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +7 -7
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts +6 -5
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
- package/dist/op/builders.d.ts +13 -13
- package/dist/plugin.d.ts.map +1 -1
- package/dist/receipt-store.d.ts +113 -0
- package/dist/receipt-store.d.ts.map +1 -0
- package/dist/rules/wk8505.ts +4 -2
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-k8s-argo.md +27 -13
- package/dist/subscribe-changes.d.ts +84 -0
- package/dist/subscribe-changes.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/api/fake-cluster.ts +17 -4
- package/src/api/sweep-types.ts +1 -2
- package/src/codegen/docs.ts +7 -0
- package/src/composites/composites.test.ts +72 -3
- package/src/composites/cron-schedule.ts +47 -0
- package/src/composites/cron-workload.ts +4 -1
- package/src/composites/operator-stack.ts +25 -24
- package/src/config-schema.ts +5 -0
- package/src/config.ts +27 -4
- package/src/crd/crd-sources.ts +4 -4
- package/src/deep-observe-hooks.ts +4 -4
- package/src/deep-observe.test.ts +219 -46
- package/src/deep-observe.ts +54 -28
- package/src/describe-resources.ts +31 -7
- package/src/effect-receipt-row.test.ts +285 -0
- package/src/effect-receipt-row.ts +268 -0
- package/src/export-resources.ts +1 -1
- package/src/index.ts +22 -0
- package/src/lint/audit-catalog.ts +6 -2
- package/src/lint/audit-lineage.ts +127 -0
- package/src/lint/post-synth/wk8505.ts +4 -2
- package/src/op/activities/argo.test.ts +7 -8
- package/src/op/activities/argo.ts +4 -4
- package/src/op/activities/index.ts +23 -7
- package/src/op/activities/kubectl.test.ts +43 -4
- package/src/op/activities/kubectl.ts +67 -67
- package/src/op/activities/wait-for-ready.test.ts +2 -2
- package/src/op/activities/wait-for-ready.ts +7 -7
- package/src/op/builders.ts +13 -13
- package/src/plugin.ts +16 -1
- package/src/receipt-store.test.ts +380 -0
- package/src/receipt-store.ts +290 -0
- package/src/serializer.test.ts +26 -0
- package/src/serializer.ts +92 -1
- package/src/skills/chant-k8s-argo.md +27 -13
- package/src/subscribe-changes.test.ts +368 -0
- package/src/subscribe-changes.ts +210 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The k8s change signal, `LexiconPlugin.subscribeChanges` (chant #1981).
|
|
3
|
+
*
|
|
4
|
+
* Kubernetes is the one substrate chant reaches where a change stream is
|
|
5
|
+
* complete, trustworthy, and needs nothing deployed into the cluster being
|
|
6
|
+
* observed: the Watch API is served for every kind the API server serves, is
|
|
7
|
+
* `resourceVersion`-based so a reconnect has a defined resume point, and is
|
|
8
|
+
* authorized by the same read credentials `describeResources` already uses.
|
|
9
|
+
* Every cloud substrate fails on that last point. Subscribing to EventBridge,
|
|
10
|
+
* Cloud Asset Inventory or Event Grid means writing infrastructure into the
|
|
11
|
+
* account being watched, which inverts the property that makes a read-only
|
|
12
|
+
* watch safe to point at production. The verdict table in the operator guide
|
|
13
|
+
* records that per lexicon; this file is the one place it came out `yes`.
|
|
14
|
+
*
|
|
15
|
+
* ## What this is allowed to conclude: nothing
|
|
16
|
+
*
|
|
17
|
+
* A watch event never becomes an observation. The frames are read, and then
|
|
18
|
+
* discarded. The only thing that leaves this module is a no-argument
|
|
19
|
+
* `onChange()`, which wakes an operator tick that re-observes the estate from
|
|
20
|
+
* scratch through the ordinary read path. There is no code here that could
|
|
21
|
+
* turn a `DELETED` frame into a proposed `create`, because there is no channel
|
|
22
|
+
* from a frame to anything but a function call with no parameters.
|
|
23
|
+
*
|
|
24
|
+
* That is also why a missed event costs nothing. A `410 Gone`, a dropped
|
|
25
|
+
* connection, a subscription that never got established: all of them slow
|
|
26
|
+
* detection back to the operator's timer, and none of them make the estate
|
|
27
|
+
* read as clean.
|
|
28
|
+
*
|
|
29
|
+
* ## Scope
|
|
30
|
+
*
|
|
31
|
+
* One watch per (kind, namespace) the declared entities name. The kinds come
|
|
32
|
+
* from the same generated operation surface `describeResources` addresses
|
|
33
|
+
* entities through; the namespaces come from the declarations themselves,
|
|
34
|
+
* falling back to the client's own default for a namespaced entity that
|
|
35
|
+
* declares none. Nothing widens that: a project declaring three Deployments in
|
|
36
|
+
* one namespace opens one connection, not a cluster-wide firehose.
|
|
37
|
+
*/
|
|
38
|
+
import type { ChangeSubscription, SubscribeChangesOptions } from "@intentius/chant/lexicon";
|
|
39
|
+
import { type K8sConnector } from "./api/connect.js";
|
|
40
|
+
/**
|
|
41
|
+
* The most connections one subscription will hold open.
|
|
42
|
+
*
|
|
43
|
+
* A watch is a long-lived HTTP/2 stream against the API server, and one per
|
|
44
|
+
* (kind, namespace) is cheap right up until an estate declares eighty kinds
|
|
45
|
+
* across a dozen namespaces. Past this ceiling the honest move is to refuse
|
|
46
|
+
* the whole subscription and say so, rather than to open some arbitrary
|
|
47
|
+
* prefix of it: a partial watch is a signal that goes quiet for exactly the
|
|
48
|
+
* resources nobody chose to drop. The operator then runs on its timer, which
|
|
49
|
+
* is what it did before this existed.
|
|
50
|
+
*/
|
|
51
|
+
export declare const MAX_WATCHES = 32;
|
|
52
|
+
/** One thing to watch: a kind, and the namespace to watch it in. */
|
|
53
|
+
interface WatchTarget {
|
|
54
|
+
apiVersion: string;
|
|
55
|
+
kind: string;
|
|
56
|
+
/** Absent for a cluster-scoped kind. */
|
|
57
|
+
namespace?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The distinct (kind, namespace) pairs a declared estate implies.
|
|
61
|
+
*
|
|
62
|
+
* Deterministic order, so a refusal past {@link MAX_WATCHES} names the same
|
|
63
|
+
* scope every time and a test can assert on it.
|
|
64
|
+
*/
|
|
65
|
+
export declare function watchTargets(entities: Map<string, {
|
|
66
|
+
entityType: string;
|
|
67
|
+
props: Record<string, unknown>;
|
|
68
|
+
}>, defaultNamespace: string): {
|
|
69
|
+
targets: WatchTarget[];
|
|
70
|
+
unaddressable: string[];
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Open one watch per declared (kind, namespace) and report every event as a
|
|
74
|
+
* bare `onChange()`.
|
|
75
|
+
*
|
|
76
|
+
* Throws only for a failure that makes the whole subscription impossible: no
|
|
77
|
+
* entities in scope, a cluster binding that will not resolve, a scope past the
|
|
78
|
+
* ceiling. The operator turns that into one logged line and keeps polling.
|
|
79
|
+
* Once the subscription is live, nothing throws: a watch that dies reports
|
|
80
|
+
* through `onError` and the operator re-subscribes on its next round.
|
|
81
|
+
*/
|
|
82
|
+
export declare function subscribeChanges(options: SubscribeChangesOptions, connect?: K8sConnector): Promise<ChangeSubscription>;
|
|
83
|
+
export {};
|
|
84
|
+
//# sourceMappingURL=subscribe-changes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subscribe-changes.d.ts","sourceRoot":"","sources":["../src/subscribe-changes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAE5F,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAGvE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,KAAK,CAAC;AAE9B,oEAAoE;AACpE,UAAU,WAAW;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,EAC7E,gBAAgB,EAAE,MAAM,GACvB;IAAE,OAAO,EAAE,WAAW,EAAE,CAAC;IAAC,aAAa,EAAE,MAAM,EAAE,CAAA;CAAE,CAkCrD;AAOD;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,uBAAuB,EAChC,OAAO,GAAE,YAAkC,GAC1C,OAAO,CAAC,kBAAkB,CAAC,CAkF7B"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-k8s",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
4
4
|
"description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"@types/js-yaml": "^4.0.9"
|
|
77
77
|
},
|
|
78
78
|
"optionalDependencies": {
|
|
79
|
-
"@intentius/chant-k8s-client": "^0.
|
|
79
|
+
"@intentius/chant-k8s-client": "^0.59.0"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@intentius/chant": "*",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
},
|
|
85
85
|
"peerDependencies": {
|
|
86
86
|
"zod": "^4.3.6",
|
|
87
|
-
"@intentius/chant": "^0.
|
|
87
|
+
"@intentius/chant": "^0.59.0",
|
|
88
88
|
"typescript": "^5.9.3"
|
|
89
89
|
}
|
|
90
90
|
}
|
package/src/api/fake-cluster.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
17
17
|
import { apiResourceList, fakeKubeconfig, fakeRequestLayer, statusBody } from "@intentius/chant-k8s-client/testing";
|
|
18
|
-
import type { FakeRequestLayer, RecordedRequest } from "@intentius/chant-k8s-client/testing";
|
|
18
|
+
import type { FakeRequestLayer, FakeResponse, RecordedRequest } from "@intentius/chant-k8s-client/testing";
|
|
19
19
|
import { createK8sClient } from "@intentius/chant-k8s-client";
|
|
20
20
|
import type { ConnectedClient, ConnectOptions, K8sConnector } from "./connect";
|
|
21
21
|
import { operationTable } from "./operation-surface";
|
|
@@ -33,8 +33,12 @@ export interface FakeClusterOptions {
|
|
|
33
33
|
* CRD resolvable in a test without registering anything.
|
|
34
34
|
*/
|
|
35
35
|
serves?: readonly string[];
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Full control: return a response for a request, or undefined to fall
|
|
38
|
+
* through. A `stream` on the response is what a watch reads (chant #1981);
|
|
39
|
+
* see `fakeWatchStream` in the client's testing harness.
|
|
40
|
+
*/
|
|
41
|
+
respond?: (request: RecordedRequest) => FakeResponse | undefined;
|
|
38
42
|
/** Kubeconfig to hand the client. Defaults to a single-context one. */
|
|
39
43
|
kubeconfig?: string;
|
|
40
44
|
}
|
|
@@ -177,7 +181,16 @@ export function fakeCluster(options: FakeClusterOptions = {}): FakeCluster {
|
|
|
177
181
|
if (object) return { body: object };
|
|
178
182
|
|
|
179
183
|
if (isListPath(request.path, resources)) {
|
|
180
|
-
|
|
184
|
+
// A `resourceVersion` on every list: it is what a watch resumes from
|
|
185
|
+
// (chant #1981), and a list without one is not a shape any API server
|
|
186
|
+
// produces.
|
|
187
|
+
return {
|
|
188
|
+
body: {
|
|
189
|
+
kind: "List",
|
|
190
|
+
items: listPaths.get(request.path) ?? [],
|
|
191
|
+
metadata: { resourceVersion: "1" },
|
|
192
|
+
},
|
|
193
|
+
};
|
|
181
194
|
}
|
|
182
195
|
|
|
183
196
|
return { status: 404, body: statusBody(404, "NotFound", `${request.path} not found`) };
|
package/src/api/sweep-types.ts
CHANGED
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* It lives in its own module, with no imports of its own, because both
|
|
12
12
|
* consumers reach it from different directions — `../export-resources.ts`
|
|
13
|
-
* pulls in the whole import parser, and
|
|
14
|
-
* activity should not.
|
|
13
|
+
* pulls in the whole import parser, and the apply activity should not.
|
|
15
14
|
*/
|
|
16
15
|
export const DEFAULT_IMPORT_TYPES: readonly string[] = [
|
|
17
16
|
"K8s::Apps::Deployment",
|
package/src/codegen/docs.ts
CHANGED
|
@@ -87,6 +87,13 @@ The generated file includes:
|
|
|
87
87
|
- \`metadata.name\` auto-generated from export names (camelCase → kebab-case)
|
|
88
88
|
- Default labels and annotations injected from \`defaultLabels()\`/\`defaultAnnotations()\`
|
|
89
89
|
|
|
90
|
+
The lexicon also materializes [effect receipts](/chant/concepts/effect-receipts/) as
|
|
91
|
+
ConfigMaps named \`chant-receipt.<stack>.<env>.<effect>\` in \`k8s.receipts.namespace\`
|
|
92
|
+
(\`default\` when unset), holding the expectation under \`data.expectation\`. A receipt is
|
|
93
|
+
never a document in the manifest stream: the rows ride a trailing
|
|
94
|
+
\`# chant:effect-receipts\` comment, which \`kubectl apply\` ignores, because the
|
|
95
|
+
\`effect()\` step is a receipt's sole writer.
|
|
96
|
+
|
|
90
97
|
## Key conversions
|
|
91
98
|
|
|
92
99
|
| Chant (TypeScript) | YAML output | Rule |
|
|
@@ -340,6 +340,32 @@ describe("CronWorkload", () => {
|
|
|
340
340
|
expect((p(result.role).metadata as any).labels["app.kubernetes.io/component"]).toBe("rbac");
|
|
341
341
|
expect((p(result.roleBinding).metadata as any).labels["app.kubernetes.io/component"]).toBe("rbac");
|
|
342
342
|
});
|
|
343
|
+
|
|
344
|
+
// ── schedule validation (#2071 item 4) ────────────────────────────
|
|
345
|
+
|
|
346
|
+
test("accepts a valid five-field schedule", () => {
|
|
347
|
+
expect(() =>
|
|
348
|
+
CronWorkload({ name: "backup", image: "backup:1.0", schedule: "0 2 * * *" }),
|
|
349
|
+
).not.toThrow();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("refuses a 6-field (seconds) schedule, naming the field, the value, and the CronJob limit", () => {
|
|
353
|
+
expect(() =>
|
|
354
|
+
CronWorkload({ name: "backup", image: "backup:1.0", schedule: "0 0 2 * * *" }),
|
|
355
|
+
).toThrow(/schedule "0 0 2 \* \* \*" is 6-field cron.*five fields/s);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("refuses a malformed schedule", () => {
|
|
359
|
+
expect(() =>
|
|
360
|
+
CronWorkload({ name: "backup", image: "backup:1.0", schedule: "not a cron" }),
|
|
361
|
+
).toThrow(/schedule "not a cron" is not valid cron syntax/);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("refuses a blank schedule", () => {
|
|
365
|
+
expect(() =>
|
|
366
|
+
CronWorkload({ name: "backup", image: "backup:1.0", schedule: "" }),
|
|
367
|
+
).toThrow(/schedule is required/);
|
|
368
|
+
});
|
|
343
369
|
});
|
|
344
370
|
|
|
345
371
|
// ── AutoscaledService ──────────────────────────────────────────────
|
|
@@ -3868,7 +3894,7 @@ describe("OperatorStack", () => {
|
|
|
3868
3894
|
}
|
|
3869
3895
|
});
|
|
3870
3896
|
|
|
3871
|
-
test("observe-dial ConvergeOp with a mutating dispatch target still gets read-only RBAC — observe never dispatches, per
|
|
3897
|
+
test("observe-dial ConvergeOp with a mutating dispatch target still gets read-only RBAC — observe never dispatches, per OPS014/convergeTick", () => {
|
|
3872
3898
|
const result: any = OperatorStack({
|
|
3873
3899
|
name: "chant-operator",
|
|
3874
3900
|
image: "chant:latest",
|
|
@@ -3880,7 +3906,7 @@ describe("OperatorStack", () => {
|
|
|
3880
3906
|
}
|
|
3881
3907
|
});
|
|
3882
3908
|
|
|
3883
|
-
test("reconcile-dial ConvergeOp with a mutating dispatch target still gets read-only RBAC —
|
|
3909
|
+
test("reconcile-dial ConvergeOp with a mutating dispatch target still gets read-only RBAC — OPS014 only lets apply free-run a mutating dispatch", () => {
|
|
3884
3910
|
const result: any = OperatorStack({
|
|
3885
3911
|
name: "chant-operator",
|
|
3886
3912
|
image: "chant:latest",
|
|
@@ -3979,6 +4005,17 @@ describe("OperatorStack", () => {
|
|
|
3979
4005
|
expect(() => deriveHostVerbClass("x", "observe", [DESTRUCTIVE_OP])).toThrow(/destructive/);
|
|
3980
4006
|
});
|
|
3981
4007
|
|
|
4008
|
+
// #2192 — the refusal cites the rule that actually owns it (OPS014), and
|
|
4009
|
+
// gives OPS014's own reason. The old message argued from the local
|
|
4010
|
+
// executor's inability to honour a gate, which #2119 retired: a gated op
|
|
4011
|
+
// now ends the run `gated` rather than being refused, so the refusal stands
|
|
4012
|
+
// on the unattended-approval ground instead.
|
|
4013
|
+
test("deriveHostVerbClass: the destructive refusal names OPS014 and the unattended-approval reason", () => {
|
|
4014
|
+
expect(() => deriveHostVerbClass("x", "apply", [DESTRUCTIVE_OP])).toThrow(/OPS014/);
|
|
4015
|
+
expect(() => deriveHostVerbClass("x", "apply", [DESTRUCTIVE_OP])).toThrow(/unattended/);
|
|
4016
|
+
expect(() => deriveHostVerbClass("x", "apply", [DESTRUCTIVE_OP])).not.toThrow(/TMP014/);
|
|
4017
|
+
});
|
|
4018
|
+
|
|
3982
4019
|
// ── Build-time refusals ──────────────────────────────────────────────
|
|
3983
4020
|
|
|
3984
4021
|
test("refuses an empty converge array", () => {
|
|
@@ -4005,7 +4042,39 @@ describe("OperatorStack", () => {
|
|
|
4005
4042
|
image: "chant:latest",
|
|
4006
4043
|
converge: [{ name: "fountain-observe", schedule: "", env: "staging" }],
|
|
4007
4044
|
}),
|
|
4008
|
-
).toThrow(/schedule/);
|
|
4045
|
+
).toThrow(/schedule is required/);
|
|
4046
|
+
});
|
|
4047
|
+
|
|
4048
|
+
// ── schedule validation (#2071 item 4) ────────────────────────────
|
|
4049
|
+
|
|
4050
|
+
test("accepts a valid five-field schedule", () => {
|
|
4051
|
+
expect(() =>
|
|
4052
|
+
OperatorStack({
|
|
4053
|
+
name: "chant-operator",
|
|
4054
|
+
image: "chant:latest",
|
|
4055
|
+
converge: [{ name: "fountain-observe", schedule: "*/10 * * * *", env: "staging" }],
|
|
4056
|
+
}),
|
|
4057
|
+
).not.toThrow();
|
|
4058
|
+
});
|
|
4059
|
+
|
|
4060
|
+
test("refuses a host with a 6-field (seconds) schedule, naming the field, the value, and the CronJob limit", () => {
|
|
4061
|
+
expect(() =>
|
|
4062
|
+
OperatorStack({
|
|
4063
|
+
name: "chant-operator",
|
|
4064
|
+
image: "chant:latest",
|
|
4065
|
+
converge: [{ name: "fountain-observe", schedule: "0 */10 * * * *", env: "staging" }],
|
|
4066
|
+
}),
|
|
4067
|
+
).toThrow(/schedule "0 \*\/10 \* \* \* \*" is 6-field cron.*five fields/s);
|
|
4068
|
+
});
|
|
4069
|
+
|
|
4070
|
+
test("refuses a host with a malformed schedule", () => {
|
|
4071
|
+
expect(() =>
|
|
4072
|
+
OperatorStack({
|
|
4073
|
+
name: "chant-operator",
|
|
4074
|
+
image: "chant:latest",
|
|
4075
|
+
converge: [{ name: "fountain-observe", schedule: "not a cron", env: "staging" }],
|
|
4076
|
+
}),
|
|
4077
|
+
).toThrow(/schedule "not a cron" is not valid cron syntax/);
|
|
4009
4078
|
});
|
|
4010
4079
|
|
|
4011
4080
|
test("refuses a blank image", () => {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CronJob schedule validation (issue #2071 item 4).
|
|
3
|
+
*
|
|
4
|
+
* `OperatorStack` and `CronWorkload` both hand their `schedule` field
|
|
5
|
+
* straight to a k8s `CronJob`, which parses only five fields (minute hour
|
|
6
|
+
* day-of-month month day-of-week). Core's own cron parser
|
|
7
|
+
* (`isValidCronExpression`, `packages/core/src/op/cron.ts`, #2120) is
|
|
8
|
+
* deliberately more permissive: it also accepts the 6-field Quartz form
|
|
9
|
+
* (`second minute hour dom month dow`) that a `ConvergeOp`'s own `schedule`
|
|
10
|
+
* may legitimately carry, since the final word on cron syntax belongs to
|
|
11
|
+
* whichever scheduler runs the string. A k8s CronJob is that scheduler here,
|
|
12
|
+
* and it is stricter than core's parser, so a 6-field `ConvergeOp` schedule
|
|
13
|
+
* reused as-is on a CronJob fails at `kubectl apply`, not at build time.
|
|
14
|
+
*
|
|
15
|
+
* This module gives both composites one place to catch that at construction,
|
|
16
|
+
* reusing core's parser rather than a second copy of it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { isValidCronExpression } from "@intentius/chant/op";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate `schedule` for a k8s CronJob, throwing before a bad value ever
|
|
23
|
+
* reaches `kubectl apply`. `context` names the composite (and host, for
|
|
24
|
+
* `OperatorStack`) so the thrown message reads like the rest of that
|
|
25
|
+
* composite's build-time refusals, e.g. `OperatorStack "chant-operator",
|
|
26
|
+
* host "fountain-observe"` or `CronWorkload "backup"`.
|
|
27
|
+
*/
|
|
28
|
+
export function validateCronJobSchedule(context: string, schedule: string): void {
|
|
29
|
+
if (!schedule || schedule.trim().length === 0) {
|
|
30
|
+
throw new Error(`${context}: schedule is required. A CronJob with no schedule never ticks.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const fields = schedule.trim().split(/\s+/);
|
|
34
|
+
|
|
35
|
+
if (!isValidCronExpression(schedule)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`${context}: schedule "${schedule}" is not valid cron syntax. A Kubernetes CronJob takes five fields: minute hour day-of-month month day-of-week.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (fields.length === 6) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`${context}: schedule "${schedule}" is 6-field cron. A Kubernetes CronJob takes five fields (minute hour day-of-month month day-of-week) and has no seconds column. ` +
|
|
44
|
+
`A ConvergeOp schedule string in that form must be converted before it reaches a CronJob: drop the leading seconds field and keep the remaining five.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
import { Composite, mergeDefaults } from "@intentius/chant";
|
|
9
9
|
import { CronJob, ServiceAccount, Role, RoleBinding } from "../generated";
|
|
10
10
|
import type { ContainerSecurityContext } from "./security-context";
|
|
11
|
+
import { validateCronJobSchedule } from "./cron-schedule";
|
|
11
12
|
|
|
12
13
|
export interface CronWorkloadProps {
|
|
13
14
|
/** Workload name — used in metadata and labels. */
|
|
14
15
|
name: string;
|
|
15
16
|
/** Container image. */
|
|
16
17
|
image: string;
|
|
17
|
-
/** Cron schedule expression (e.g., "0 * * * *"). */
|
|
18
|
+
/** Cron schedule expression (e.g., "0 * * * *"). Validated at construction: a k8s CronJob takes five fields only, so a 6-field (seconds) form is refused (`./cron-schedule.ts`, #2071). */
|
|
18
19
|
schedule: string;
|
|
19
20
|
/** Command to run in the container. */
|
|
20
21
|
command?: string[];
|
|
@@ -92,6 +93,8 @@ export const CronWorkload = Composite((props: CronWorkloadProps) => {
|
|
|
92
93
|
defaults: defs,
|
|
93
94
|
} = props;
|
|
94
95
|
|
|
96
|
+
validateCronJobSchedule(`CronWorkload "${name}"`, schedule);
|
|
97
|
+
|
|
95
98
|
const saName = `${name}-sa`;
|
|
96
99
|
const roleName = `${name}-role`;
|
|
97
100
|
const bindingName = `${name}-binding`;
|
|
@@ -16,39 +16,40 @@
|
|
|
16
16
|
* (lease fencing, `chant operator status`, durable gate-as-fact semantics).
|
|
17
17
|
* `OperatorStack`'s container command is deliberately `chant run <name>`,
|
|
18
18
|
* the one-shot local tick `ConvergeOp` (#1484) already ships and tests
|
|
19
|
-
* against (`
|
|
19
|
+
* against (`packages/core/src/op/composites/converge-op.ts`'s own doc:
|
|
20
20
|
* "one-shot runnable locally for a single tick"). When #1485 lands, a
|
|
21
21
|
* caller can override `command` to shell out to `chant operator tick`
|
|
22
22
|
* instead — this composite doesn't need to change for that; only the
|
|
23
23
|
* command a caller passes does.
|
|
24
24
|
*
|
|
25
25
|
* `concurrencyPolicy: "Forbid"` is the k8s-native analogue of
|
|
26
|
-
* `ConvergeOp`'s own
|
|
27
|
-
*
|
|
26
|
+
* `ConvergeOp`'s own `schedule.overlap: "skip"` policy — never queue a
|
|
27
|
+
* second tick behind one still running.
|
|
28
28
|
*
|
|
29
29
|
* ## RBAC derivation
|
|
30
30
|
*
|
|
31
31
|
* `ConvergeOp` adds no authority an environment did not already grant
|
|
32
32
|
* (#1484's Autonomy table). `OperatorStack` re-derives the same bound at the
|
|
33
|
-
* k8s RBAC layer,
|
|
34
|
-
*
|
|
33
|
+
* k8s RBAC layer, from the Op configs alone (see the Layering note below for
|
|
34
|
+
* what this module imports and what it restates): for each hosted
|
|
35
35
|
* ConvergeOp, walk its `dispatchTargets` (the OpConfigs its rule table's
|
|
36
36
|
* `run()` actions may name) through `classifyOpVerbClass`
|
|
37
37
|
* (`packages/core/src/op/op-verb-class.ts`, #1954), then keep only the
|
|
38
38
|
* highest verb class this host's `dial` could ever actually free-run —
|
|
39
39
|
* exactly `convergeTick`'s own `verbClassAllowedToDispatch` gate and
|
|
40
|
-
* `
|
|
40
|
+
* `OPS014`'s build-time refusal, restated as an RBAC ceiling:
|
|
41
41
|
*
|
|
42
42
|
* - `dial: "observe"` never dispatches (report-only) → read-only RBAC,
|
|
43
43
|
* regardless of what the rule table's targets could otherwise do.
|
|
44
|
-
* - `dial: "reconcile"` only free-runs a read-only target (
|
|
44
|
+
* - `dial: "reconcile"` only free-runs a read-only target (OPS014 refuses a
|
|
45
45
|
* mutating dispatch under reconcile in v1) → read-only RBAC.
|
|
46
46
|
* - `dial: "apply"` free-runs read-only and mutating targets → RBAC gains
|
|
47
47
|
* create/update/patch, never delete.
|
|
48
48
|
* - A `dispatchTargets` entry that itself classifies `destructive` is
|
|
49
|
-
* refused outright, at construction —
|
|
50
|
-
* destructive `run()` target under any dial in v1 (
|
|
51
|
-
*
|
|
49
|
+
* refused outright, at construction — OPS014 already refuses a
|
|
50
|
+
* destructive `run()` target under any dial in v1 (a converge tick runs
|
|
51
|
+
* unattended, and a destructive dispatch needs a person's approval before
|
|
52
|
+
* it is attempted, not a gate read after the fact), so a `destructive` target
|
|
52
53
|
* reaching this composite is either a config bypassing that build check
|
|
53
54
|
* or a target `OperatorStack` should never grant permission toward.
|
|
54
55
|
* `never delete`, unconditionally, in v1 — no verb class here ever grants
|
|
@@ -61,11 +62,11 @@
|
|
|
61
62
|
*
|
|
62
63
|
* ## Layering
|
|
63
64
|
*
|
|
64
|
-
* `
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
65
|
+
* `OperatorStackConvergeHost` restates the handful of `ConvergeOp` fields
|
|
66
|
+
* this composite actually needs (`name`, `schedule`, `env`, `dial`)
|
|
67
|
+
* structurally rather than importing `ConvergeOpConfig`: the CronJob is
|
|
68
|
+
* built from a host description, not from an Op config, and a caller that
|
|
69
|
+
* has neither should still be able to describe one. `dispatchTargets` takes
|
|
69
70
|
* plain `OpConfig`-shaped values from `@intentius/chant/op` (a dependency
|
|
70
71
|
* this lexicon already has via the `@intentius/chant` peer dependency),
|
|
71
72
|
* the same type `classifyOpVerbClass` itself takes.
|
|
@@ -75,12 +76,13 @@ import { Composite, mergeDefaults } from "@intentius/chant";
|
|
|
75
76
|
import { classifyOpVerbClass } from "@intentius/chant/op";
|
|
76
77
|
import type { OpConfig, OpVerbClass } from "@intentius/chant/op";
|
|
77
78
|
import { Namespace, CronJob, ServiceAccount, Role, RoleBinding } from "../generated";
|
|
79
|
+
import { validateCronJobSchedule } from "./cron-schedule";
|
|
78
80
|
|
|
79
81
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
80
82
|
|
|
81
83
|
/**
|
|
82
84
|
* Mirrors `ConvergeOp`'s own `ConvergeDial`
|
|
83
|
-
* (`
|
|
85
|
+
* (`packages/core/src/op/composites/converge-op.ts`) structurally — see
|
|
84
86
|
* this module's Layering doc for why it's restated rather than imported.
|
|
85
87
|
*/
|
|
86
88
|
export type OperatorDial = "observe" | "reconcile" | "apply";
|
|
@@ -95,7 +97,7 @@ export interface OperatorRbacResourceRule {
|
|
|
95
97
|
export interface OperatorStackConvergeHost {
|
|
96
98
|
/** ConvergeOp's own name (`ConvergeOpConfig.name`) — the CronJob, ServiceAccount, Role, and RoleBinding name stem for this host. */
|
|
97
99
|
name: string;
|
|
98
|
-
/** Cron expression driving the tick — the same string passed to `ConvergeOp`'s own `schedule`. */
|
|
100
|
+
/** Cron expression driving the tick — the same string passed to `ConvergeOp`'s own `schedule`. Validated at construction: a 6-field (seconds) form, which `ConvergeOp` accepts, is refused here since a k8s CronJob takes five fields only (`./cron-schedule.ts`, #2071). */
|
|
99
101
|
schedule: string;
|
|
100
102
|
/** 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
103
|
env: string;
|
|
@@ -104,7 +106,7 @@ export interface OperatorStackConvergeHost {
|
|
|
104
106
|
/**
|
|
105
107
|
* OpConfigs for every op this ConvergeOp's rule table may `run()` — the
|
|
106
108
|
* sibling `*.op.ts` declarations its `run()` actions name. Used to derive
|
|
107
|
-
* least-privilege RBAC the same way `
|
|
109
|
+
* least-privilege RBAC the same way `OPS014` derives its build-time
|
|
108
110
|
* refusals. Omit or leave empty when every rule only `report()`s; the
|
|
109
111
|
* host still gets read-only RBAC for its own observation.
|
|
110
112
|
*/
|
|
@@ -183,7 +185,7 @@ function rbacVerbsFor(verbClass: OpVerbClass): string[] {
|
|
|
183
185
|
/**
|
|
184
186
|
* Whether `dial` ever actually free-runs a dispatch classified `verbClass` —
|
|
185
187
|
* restates `convergeTick`'s own `verbClassAllowedToDispatch`
|
|
186
|
-
* (`
|
|
188
|
+
* (`packages/core/src/op/activities/converge.ts`) so the RBAC ceiling
|
|
187
189
|
* this composite grants matches the ceiling the tick itself enforces at
|
|
188
190
|
* runtime, without importing across the lexicon boundary (see this module's
|
|
189
191
|
* Layering doc).
|
|
@@ -198,7 +200,7 @@ function dialAllowsVerbClass(dial: OperatorDial, verbClass: OpVerbClass): boolea
|
|
|
198
200
|
* Derive the highest verb class a host's ServiceAccount actually needs:
|
|
199
201
|
* the max, across `dispatchTargets`, of each target's own class — but only
|
|
200
202
|
* counting a target `dial` could ever actually dispatch (one it can't just
|
|
201
|
-
* gets reported, per `
|
|
203
|
+
* gets reported, per `OPS014`/`convergeTick`, and needs no elevated grant).
|
|
202
204
|
* A `dispatchTargets` entry that classifies `destructive` is refused
|
|
203
205
|
* outright rather than silently ignored — see this module's RBAC
|
|
204
206
|
* derivation doc on why a destructive target reaching this composite is
|
|
@@ -211,7 +213,8 @@ export function deriveHostVerbClass(hostName: string, dial: OperatorDial, dispat
|
|
|
211
213
|
if (verbClass === "destructive") {
|
|
212
214
|
throw new Error(
|
|
213
215
|
`OperatorStack host "${hostName}": a dispatchTargets entry classifies as destructive — ConvergeOp v1 refuses a destructive run() target under any dial ` +
|
|
214
|
-
`(
|
|
216
|
+
`(OPS014; a converge tick runs unattended, and a destructive dispatch needs a person's approval before it is attempted, not a gate the tick reads after it has already committed to the run). ` +
|
|
217
|
+
`Remove it from dispatchTargets, and run the destructive op yourself through its gate.`,
|
|
215
218
|
);
|
|
216
219
|
}
|
|
217
220
|
if (!dialAllowsVerbClass(dial, verbClass)) continue; // dial refuses this dispatch — reported, not run; needs no elevated RBAC
|
|
@@ -266,9 +269,7 @@ export const OperatorStack = Composite((props: OperatorStackConfig) => {
|
|
|
266
269
|
throw new Error(`OperatorStack "${name}": duplicate hosted ConvergeOp name "${host.name}" — CronJob/ServiceAccount names would collide.`);
|
|
267
270
|
}
|
|
268
271
|
seen.add(host.name);
|
|
269
|
-
|
|
270
|
-
throw new Error(`OperatorStack "${name}", host "${host.name}": schedule is required — an operator CronJob with no schedule never ticks.`);
|
|
271
|
-
}
|
|
272
|
+
validateCronJobSchedule(`OperatorStack "${name}", host "${host.name}"`, host.schedule);
|
|
272
273
|
}
|
|
273
274
|
if (!image || image.trim().length === 0) {
|
|
274
275
|
throw new Error(`OperatorStack "${name}": image is required — the CronJob has nothing to run.`);
|
package/src/config-schema.ts
CHANGED
|
@@ -28,6 +28,11 @@ export const k8sConfigSchema = z.strictObject({
|
|
|
28
28
|
roots: z.array(z.string()).optional(),
|
|
29
29
|
})
|
|
30
30
|
.optional(),
|
|
31
|
+
receipts: z
|
|
32
|
+
.strictObject({
|
|
33
|
+
namespace: z.string().optional(),
|
|
34
|
+
})
|
|
35
|
+
.optional(),
|
|
31
36
|
});
|
|
32
37
|
|
|
33
38
|
declare module "@intentius/chant/config" {
|
package/src/config.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* K8s environment → cluster binding — chant #1100.
|
|
3
3
|
*
|
|
4
4
|
* Every cloud lexicon binds an environment to a scope: AWS resolves `<env>`
|
|
5
|
-
* to a CloudFormation stack, Azure treats `<env>` as the resource group
|
|
6
|
-
*
|
|
5
|
+
* to a CloudFormation stack, Azure treats `<env>` as the resource group.
|
|
6
|
+
* Before this, k8s bound
|
|
7
7
|
* nothing — `describeResources` shelled out to `kubectl get` with no
|
|
8
8
|
* `--context`, so `chant lifecycle diff prod --live` read whichever cluster
|
|
9
9
|
* `kubectl config current-context` happened to point at.
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
* and the missing binding.
|
|
41
41
|
*
|
|
42
42
|
* `ChantConfig` uses `.passthrough()` in its Zod schema so the `k8s` key is
|
|
43
|
-
* accepted at runtime without core changes, exactly like `
|
|
44
|
-
* (see `lexicons/
|
|
43
|
+
* accepted at runtime without core changes, exactly like the `terraform` key
|
|
44
|
+
* (see `lexicons/terraform/src/config.ts`). The type side is the declaration
|
|
45
45
|
* merge at the bottom of this file — without it the snippet above compiles
|
|
46
46
|
* only until someone adds `satisfies ChantConfig`, which every example
|
|
47
47
|
* project does and which is the only thing type-checking the rest of the
|
|
@@ -111,6 +111,29 @@ export interface K8sChantConfig {
|
|
|
111
111
|
/** Kustomization directories to render into the build. */
|
|
112
112
|
roots?: string[];
|
|
113
113
|
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Effect receipt settings (#2074, epic #1703).
|
|
117
|
+
*
|
|
118
|
+
* `namespace` is where this project's receipt ConfigMaps live. The name is
|
|
119
|
+
* derived from the ownership fields (`chant-receipt.<stack>.<env>.<effect>`,
|
|
120
|
+
* see `./effect-receipt-row.ts`); the namespace is the one part of the
|
|
121
|
+
* address the ownership block cannot answer, so it is declared here. Unset,
|
|
122
|
+
* receipts land in `default`, the same namespace every other namespace-less
|
|
123
|
+
* k8s read and write in this lexicon falls through to. It is never derived
|
|
124
|
+
* from the stack or the environment: a guessed namespace is one chant would
|
|
125
|
+
* have to create, and the receipt row creates nothing but the receipt.
|
|
126
|
+
*
|
|
127
|
+
* ```ts
|
|
128
|
+
* k8s: {
|
|
129
|
+
* receipts: { namespace: "chant-system" },
|
|
130
|
+
* } satisfies K8sChantConfig
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
receipts?: {
|
|
134
|
+
/** Namespace the receipt ConfigMaps live in. Defaults to `default`. */
|
|
135
|
+
namespace?: string;
|
|
136
|
+
};
|
|
114
137
|
}
|
|
115
138
|
|
|
116
139
|
declare module "@intentius/chant/config" {
|
package/src/crd/crd-sources.ts
CHANGED
|
@@ -26,7 +26,7 @@ const KUBERAY_CRD_BASE = `https://raw.githubusercontent.com/ray-project/kuberay/
|
|
|
26
26
|
* Argo CD CRDs — argoproj.io/v1alpha1
|
|
27
27
|
*
|
|
28
28
|
* Produces (the `argoproj.io` group is mapped to the `Argo` namespace —
|
|
29
|
-
* see GROUP_NAMESPACE_OVERRIDES in
|
|
29
|
+
* see GROUP_NAMESPACE_OVERRIDES in group-namespace.ts):
|
|
30
30
|
* K8s::Argo::Application → apiVersion: argoproj.io/v1alpha1, kind: Application
|
|
31
31
|
* K8s::Argo::ApplicationSet → apiVersion: argoproj.io/v1alpha1, kind: ApplicationSet
|
|
32
32
|
* K8s::Argo::AppProject → apiVersion: argoproj.io/v1alpha1, kind: AppProject
|
|
@@ -221,7 +221,7 @@ const PROM_OPERATOR_CRD_BASE = `https://raw.githubusercontent.com/prometheus-ope
|
|
|
221
221
|
* also carries `ExternalArtifact` and `ArtifactGenerator` (experimental, and a
|
|
222
222
|
* separate `source.extensions.fluxcd.io` group), which are intentionally left
|
|
223
223
|
* out. All groups map to the `Flux` namespace (see GROUP_NAMESPACE_OVERRIDES in
|
|
224
|
-
*
|
|
224
|
+
* group-namespace.ts):
|
|
225
225
|
* K8s::Flux::GitRepository / OCIRepository / HelmRepository / HelmChart / Bucket
|
|
226
226
|
* → source.toolkit.fluxcd.io/v1
|
|
227
227
|
* K8s::Flux::Kustomization → kustomize.toolkit.fluxcd.io/v1
|
|
@@ -259,7 +259,7 @@ const FLUX_OPERATOR_INSTALL = `https://github.com/controlplaneio-fluxcd/flux-ope
|
|
|
259
259
|
* KubeMicroVM CRDs — lambda.aws.amazon.com/v1alpha1
|
|
260
260
|
*
|
|
261
261
|
* Produces (the group is mapped to the `KubeMicroVM` namespace — see
|
|
262
|
-
* GROUP_NAMESPACE_OVERRIDES in
|
|
262
|
+
* GROUP_NAMESPACE_OVERRIDES in group-namespace.ts):
|
|
263
263
|
* K8s::KubeMicroVM::MicroVM → kind: MicroVM
|
|
264
264
|
* K8s::KubeMicroVM::MicroVMImage → kind: MicroVMImage
|
|
265
265
|
* K8s::KubeMicroVM::MicroVMNetwork → kind: MicroVMNetwork
|
|
@@ -287,7 +287,7 @@ const KUBEMICROVM_VERSION = "1.0.11";
|
|
|
287
287
|
* k3s estate's GitOps surface is written in.
|
|
288
288
|
*
|
|
289
289
|
* Produces (both groups map to the `K3s` namespace — see
|
|
290
|
-
* GROUP_NAMESPACE_OVERRIDES in
|
|
290
|
+
* GROUP_NAMESPACE_OVERRIDES in group-namespace.ts):
|
|
291
291
|
* K8s::K3s::HelmChart → apiVersion: helm.cattle.io/v1, kind: HelmChart
|
|
292
292
|
* K8s::K3s::HelmChartConfig → apiVersion: helm.cattle.io/v1, kind: HelmChartConfig
|
|
293
293
|
* K8s::K3s::Addon → apiVersion: k3s.cattle.io/v1, kind: Addon
|
|
@@ -61,7 +61,8 @@ import { GENERATED_ONCE_LABEL_KEY } from "./secret-labels";
|
|
|
61
61
|
* Kubernetes-defaulted fields, per entity type, as index-erased property
|
|
62
62
|
* paths. Subtracted only where source never declared the property
|
|
63
63
|
* (`side === "live" && counterpart === "absent"`) — cdk-real-drift's default
|
|
64
|
-
* subtraction, same convention as AWS
|
|
64
|
+
* subtraction, same convention as the AWS and Azure tables
|
|
65
|
+
* (`AWS_SERVICE_DEFAULTS`, `AZURE_SERVICE_DEFAULTS`).
|
|
65
66
|
*
|
|
66
67
|
* Sparse and evidence-based rather than derived from the generated schema:
|
|
67
68
|
* the k8s OpenAPI spec this lexicon's codegen consumes
|
|
@@ -70,9 +71,8 @@ import { GENERATED_ONCE_LABEL_KEY } from "./secret-labels";
|
|
|
70
71
|
* actually expressible today. Widening this table is additive and needs no
|
|
71
72
|
* contract change.
|
|
72
73
|
*
|
|
73
|
-
* `spec.strategy` is listed whole
|
|
74
|
-
*
|
|
75
|
-
* only the leaf would still recurse into the object, and a nested default the
|
|
74
|
+
* `spec.strategy` is listed whole rather than as `spec.strategy.type`, because
|
|
75
|
+
* pruning only the leaf would still recurse into the object, and a nested default the
|
|
76
76
|
* table does not separately name (`rollingUpdate.maxSurge`/`maxUnavailable`,
|
|
77
77
|
* both `"25%"` when `spec.strategy` is omitted entirely) would leave behind an
|
|
78
78
|
* empty `strategy: {}` — a value distinct from no `strategy` key at all, and
|