@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.
Files changed (89) hide show
  1. package/dist/api/fake-cluster.d.ts +7 -6
  2. package/dist/api/fake-cluster.d.ts.map +1 -1
  3. package/dist/api/sweep-types.d.ts +1 -2
  4. package/dist/api/sweep-types.d.ts.map +1 -1
  5. package/dist/codegen/docs.d.ts.map +1 -1
  6. package/dist/composites/cron-schedule.d.ts +26 -0
  7. package/dist/composites/cron-schedule.d.ts.map +1 -0
  8. package/dist/composites/cron-workload.d.ts +1 -1
  9. package/dist/composites/cron-workload.d.ts.map +1 -1
  10. package/dist/composites/operator-stack.d.ts +20 -19
  11. package/dist/composites/operator-stack.d.ts.map +1 -1
  12. package/dist/config-schema.d.ts +3 -0
  13. package/dist/config-schema.d.ts.map +1 -1
  14. package/dist/config.d.ts +26 -4
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/deep-observe-hooks.d.ts +4 -4
  17. package/dist/deep-observe.d.ts +23 -16
  18. package/dist/deep-observe.d.ts.map +1 -1
  19. package/dist/describe-resources.d.ts.map +1 -1
  20. package/dist/effect-receipt-row.d.ts +164 -0
  21. package/dist/effect-receipt-row.d.ts.map +1 -0
  22. package/dist/export-resources.d.ts +1 -1
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/integrity.json +4 -4
  26. package/dist/lint/audit-catalog.d.ts.map +1 -1
  27. package/dist/lint/audit-lineage.d.ts +10 -0
  28. package/dist/lint/audit-lineage.d.ts.map +1 -0
  29. package/dist/lint/post-synth/wk8505.d.ts +3 -1
  30. package/dist/lint/post-synth/wk8505.d.ts.map +1 -1
  31. package/dist/manifest.json +1 -1
  32. package/dist/okf/index.md +1 -1
  33. package/dist/okf/rules/WK8505.md +2 -2
  34. package/dist/okf/types/Kustomization.md +1 -1
  35. package/dist/op/activities/argo.d.ts +4 -4
  36. package/dist/op/activities/argo.d.ts.map +1 -1
  37. package/dist/op/activities/index.d.ts +15 -7
  38. package/dist/op/activities/index.d.ts.map +1 -1
  39. package/dist/op/activities/kubectl.d.ts +7 -7
  40. package/dist/op/activities/kubectl.d.ts.map +1 -1
  41. package/dist/op/activities/wait-for-ready.d.ts +6 -5
  42. package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
  43. package/dist/op/builders.d.ts +13 -13
  44. package/dist/plugin.d.ts.map +1 -1
  45. package/dist/receipt-store.d.ts +113 -0
  46. package/dist/receipt-store.d.ts.map +1 -0
  47. package/dist/rules/wk8505.ts +4 -2
  48. package/dist/serializer.d.ts.map +1 -1
  49. package/dist/skills/chant-k8s-argo.md +27 -13
  50. package/dist/subscribe-changes.d.ts +84 -0
  51. package/dist/subscribe-changes.d.ts.map +1 -0
  52. package/package.json +3 -3
  53. package/src/api/fake-cluster.ts +17 -4
  54. package/src/api/sweep-types.ts +1 -2
  55. package/src/codegen/docs.ts +7 -0
  56. package/src/composites/composites.test.ts +72 -3
  57. package/src/composites/cron-schedule.ts +47 -0
  58. package/src/composites/cron-workload.ts +4 -1
  59. package/src/composites/operator-stack.ts +25 -24
  60. package/src/config-schema.ts +5 -0
  61. package/src/config.ts +27 -4
  62. package/src/crd/crd-sources.ts +4 -4
  63. package/src/deep-observe-hooks.ts +4 -4
  64. package/src/deep-observe.test.ts +219 -46
  65. package/src/deep-observe.ts +54 -28
  66. package/src/describe-resources.ts +31 -7
  67. package/src/effect-receipt-row.test.ts +285 -0
  68. package/src/effect-receipt-row.ts +268 -0
  69. package/src/export-resources.ts +1 -1
  70. package/src/index.ts +22 -0
  71. package/src/lint/audit-catalog.ts +6 -2
  72. package/src/lint/audit-lineage.ts +127 -0
  73. package/src/lint/post-synth/wk8505.ts +4 -2
  74. package/src/op/activities/argo.test.ts +7 -8
  75. package/src/op/activities/argo.ts +4 -4
  76. package/src/op/activities/index.ts +23 -7
  77. package/src/op/activities/kubectl.test.ts +43 -4
  78. package/src/op/activities/kubectl.ts +67 -67
  79. package/src/op/activities/wait-for-ready.test.ts +2 -2
  80. package/src/op/activities/wait-for-ready.ts +7 -7
  81. package/src/op/builders.ts +13 -13
  82. package/src/plugin.ts +16 -1
  83. package/src/receipt-store.test.ts +380 -0
  84. package/src/receipt-store.ts +290 -0
  85. package/src/serializer.test.ts +26 -0
  86. package/src/serializer.ts +92 -1
  87. package/src/skills/chant-k8s-argo.md +27 -13
  88. package/src/subscribe-changes.test.ts +368 -0
  89. 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.57.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.57.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.57.0",
87
+ "@intentius/chant": "^0.59.0",
88
88
  "typescript": "^5.9.3"
89
89
  }
90
90
  }
@@ -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
- /** Full control: return a response for a request, or undefined to fall through. */
37
- respond?: (request: RecordedRequest) => { status?: number; body?: unknown } | undefined;
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
- return { body: { kind: "List", items: listPaths.get(request.path) ?? [], metadata: {} } };
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`) };
@@ -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 a Temporal worker loading the apply
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",
@@ -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 TMP014/convergeTick", () => {
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 — TMP014 only lets apply free-run a mutating dispatch", () => {
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 (`lexicons/temporal/src/composites/converge-op.ts`'s own doc:
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 Temporal schedule `overlap: "Skip"` policy — never
27
- * queue a second tick behind one still running.
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, independently of the temporal lexicon (this module has no
34
- * dependency on it see the layering note below): for each hosted
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
- * `TMP014`'s build-time refusal, restated as an RBAC ceiling:
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 (TMP014 refuses a
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 — 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
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
- * `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
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
- * (`lexicons/temporal/src/composites/converge-op.ts`) structurally — see
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 `TMP014` derives its build-time
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
- * (`lexicons/temporal/src/op/activities/converge.ts`) so the RBAC ceiling
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 `TMP014`/`convergeTick`, and needs no elevated grant).
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
- `(TMP014; the local dispatch executor can't honor its required gate). Remove it from dispatchTargets, or remediate manually via a gated op.`,
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
- 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
+ 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.`);
@@ -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
- * Temporal looks up `temporal.profiles.<env>`. Before this, k8s bound
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 `temporal.profiles`
44
- * (see `lexicons/temporal/src/config.ts`). The type side is the declaration
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" {
@@ -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 crd/parser.ts):
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
- * crd/parser.ts):
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 crd/parser.ts):
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 crd/parser.ts):
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/Azure/Temporal's tables.
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, not as `spec.strategy.type`, for the same
74
- * reason Temporal's `TEMPORAL_SCHEDULE_DEFAULTS` lists `state` whole: pruning
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