@intentius/chant-lexicon-k8s 0.58.0 → 0.60.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 (61) 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/codegen/docs.d.ts.map +1 -1
  4. package/dist/composites/cron-schedule.d.ts +26 -0
  5. package/dist/composites/cron-schedule.d.ts.map +1 -0
  6. package/dist/composites/cron-workload.d.ts +1 -1
  7. package/dist/composites/cron-workload.d.ts.map +1 -1
  8. package/dist/composites/operator-stack.d.ts +1 -1
  9. package/dist/composites/operator-stack.d.ts.map +1 -1
  10. package/dist/config-schema.d.ts +3 -0
  11. package/dist/config-schema.d.ts.map +1 -1
  12. package/dist/config.d.ts +22 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/deep-observe.d.ts.map +1 -1
  15. package/dist/describe-resources.d.ts.map +1 -1
  16. package/dist/effect-receipt-row.d.ts +164 -0
  17. package/dist/effect-receipt-row.d.ts.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/integrity.json +3 -3
  21. package/dist/lint/post-synth/wk8505.d.ts +3 -1
  22. package/dist/lint/post-synth/wk8505.d.ts.map +1 -1
  23. package/dist/manifest.json +1 -1
  24. package/dist/okf/index.md +1 -1
  25. package/dist/okf/rules/WK8505.md +2 -2
  26. package/dist/okf/types/Kustomization.md +1 -1
  27. package/dist/op/activities/index.d.ts +6 -0
  28. package/dist/op/activities/index.d.ts.map +1 -1
  29. package/dist/op/activities/kubectl.d.ts.map +1 -1
  30. package/dist/plugin.d.ts.map +1 -1
  31. package/dist/receipt-store.d.ts +113 -0
  32. package/dist/receipt-store.d.ts.map +1 -0
  33. package/dist/rules/wk8505.ts +4 -2
  34. package/dist/serializer.d.ts.map +1 -1
  35. package/dist/subscribe-changes.d.ts +84 -0
  36. package/dist/subscribe-changes.d.ts.map +1 -0
  37. package/package.json +3 -3
  38. package/src/api/fake-cluster.ts +17 -4
  39. package/src/codegen/docs.ts +7 -0
  40. package/src/composites/composites.test.ts +59 -1
  41. package/src/composites/cron-schedule.ts +47 -0
  42. package/src/composites/cron-workload.ts +4 -1
  43. package/src/composites/operator-stack.ts +3 -4
  44. package/src/config-schema.ts +5 -0
  45. package/src/config.ts +23 -0
  46. package/src/deep-observe.ts +26 -7
  47. package/src/describe-resources.ts +31 -7
  48. package/src/effect-receipt-row.test.ts +285 -0
  49. package/src/effect-receipt-row.ts +268 -0
  50. package/src/index.ts +22 -0
  51. package/src/lint/audit-catalog.ts +1 -1
  52. package/src/lint/post-synth/wk8505.ts +4 -2
  53. package/src/op/activities/index.ts +14 -0
  54. package/src/op/activities/kubectl.test.ts +38 -0
  55. package/src/op/activities/kubectl.ts +15 -0
  56. package/src/plugin.ts +15 -0
  57. package/src/receipt-store.test.ts +380 -0
  58. package/src/receipt-store.ts +290 -0
  59. package/src/serializer.ts +92 -1
  60. package/src/subscribe-changes.test.ts +368 -0
  61. package/src/subscribe-changes.ts +210 -0
@@ -68,6 +68,7 @@ import {
68
68
  import { operationFor } from "./api/operation-surface";
69
69
  import { resolveK8sOwnerChain } from "./api/owner-chain";
70
70
  import { gvkToTypeName } from "./spec/parse";
71
+ import { observeReceiptRows, receiptRowsFor } from "./receipt-store";
71
72
 
72
73
  function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
73
74
  const out: Record<string, unknown> = {};
@@ -357,11 +358,26 @@ export async function describeResources(
357
358
  // looked for where it lives".
358
359
  const queried: Record<string, string> = {};
359
360
 
360
- const declared: Declared[] = [...options.entities].map(([entityName, entity]) => ({
361
- entityName,
362
- entityType: entity.entityType,
363
- props: entity.props,
364
- }));
361
+ // Effect receipt rows (#2074) are read by their own leg below: the applier
362
+ // never wrote them (#1832), and their declaration carries no props, so the
363
+ // generic sweep has no `metadata.name` to query by and would report a hole
364
+ // where the receipt leg has a real answer. Their addresses come from the
365
+ // build output's receipt comment, which is the serializer's one rendering of
366
+ // the derivation (./effect-receipt-row.ts).
367
+ const receiptRows = receiptRowsFor(options.entityNames, options.buildOutput);
368
+
369
+ const declared: Declared[] = [...options.entities]
370
+ .filter(([entityName]) => !receiptRows.has(entityName))
371
+ .map(([entityName, entity]) => ({
372
+ entityName,
373
+ entityType: entity.entityType,
374
+ props: entity.props,
375
+ }));
376
+
377
+ // A whole-lexicon failure below is a hole for the receipts too: nobody
378
+ // looked at those either, and a connect that never happened proves nothing
379
+ // about a receipt's presence.
380
+ const everyName = [...declared.map((d) => d.entityName), ...receiptRows.keys()];
365
381
 
366
382
  // Connect first. The binding check lives here, so a bound-but-mismatched
367
383
  // context throws before any resource is read — core turns that into
@@ -374,7 +390,7 @@ export async function describeResources(
374
390
  return observation(
375
391
  {},
376
392
  unobservedAll(
377
- declared.map((d) => d.entityName),
393
+ everyName,
378
394
  "read-failed",
379
395
  MISSING_CLIENT_DETAIL,
380
396
  options.entities,
@@ -386,7 +402,7 @@ export async function describeResources(
386
402
  return observation(
387
403
  {},
388
404
  unobservedAll(
389
- declared.map((d) => d.entityName),
405
+ everyName,
390
406
  outcome.kind === "unobserved" ? outcome.reason : "read-failed",
391
407
  outcome.kind === "unobserved" ? outcome.detail : undefined,
392
408
  options.entities,
@@ -507,6 +523,14 @@ export async function describeResources(
507
523
 
508
524
  await addRuntimeChildren(client, resources, unobserved, options.owned, declared);
509
525
 
526
+ // The receipt leg last, so its answers are the ones that stand for the
527
+ // receipt entities, because nothing above ever looked at one.
528
+ if (receiptRows.size > 0) {
529
+ const receiptObs = await observeReceiptRows(client, receiptRows);
530
+ Object.assign(resources, receiptObs.resources);
531
+ Object.assign(unobserved, receiptObs.unobserved);
532
+ }
533
+
510
534
  return observation(resources, unobserved, queried);
511
535
  }
512
536
 
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The k8s effect-receipt materialization row (#2074, epic #1703): the
3
+ * declaration, the ConfigMap address derivation, what the serializer renders,
4
+ * and the #1833 guards over the row.
5
+ *
6
+ * The shape mirrors lexicons/aws/src/effect-receipt-row.test.ts, which covers
7
+ * the SSM row, so the two rows can be read against each other.
8
+ */
9
+
10
+ import { describe, it, expect } from "vitest";
11
+ import * as ts from "typescript";
12
+ import { loadAll } from "js-yaml";
13
+ import {
14
+ EffectReceipt,
15
+ receiptConfigMapName,
16
+ receiptConfigMapRef,
17
+ receiptNamespaceFrom,
18
+ isEffectReceiptObject,
19
+ parseReceiptComment,
20
+ renderReceiptComment,
21
+ EFFECT_RECEIPTS_COMMENT_MARKER,
22
+ K8S_EFFECT_RECEIPT_ENTITY_TYPE,
23
+ RECEIPT_DATA_KEY,
24
+ RECEIPT_DEFAULT_NAMESPACE,
25
+ RECEIPT_LABEL_KEY,
26
+ RECEIPT_UNRESOLVED_VALUE_NOTE,
27
+ } from "./effect-receipt-row";
28
+ import { k8sSerializer } from "./serializer";
29
+ import {
30
+ EXISTENCE_EXPECTATION,
31
+ isEffectReceipt,
32
+ receiptExpectation,
33
+ EFFECT_RECEIPT_MARKER,
34
+ } from "@intentius/chant/effect-receipt";
35
+ import { receiptCheckInput } from "@intentius/chant/op/receipt-store";
36
+ import { coreReceiptChecks, RECEIPT_PLAIN_STORE_CHECK_ID } from "@intentius/chant/lint/receipt-checks";
37
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
38
+ import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
39
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
40
+ import { cor022ReceiptLeafRule } from "@intentius/chant/lint/rules/cor022-receipt-leaf";
41
+ import type { LintContext } from "@intentius/chant/lint/rule";
42
+
43
+ /** A minimal deploy-time reference, for the placeholder-value case. */
44
+ const someRef = { [INTRINSIC_MARKER]: true as const, toJSON: () => ({ ref: "other" }) };
45
+
46
+ const ownership = { stack: "demo", env: "dev" };
47
+
48
+ function serializeReceipts(
49
+ receipts: Map<string, Declarable>,
50
+ marker?: { stack: string; env?: string },
51
+ config?: Record<string, unknown>,
52
+ ): string {
53
+ const out = k8sSerializer.serialize(new Map(), [], {
54
+ ...(marker ? { ownership: marker } : {}),
55
+ ...(config ? { config } : {}),
56
+ receipts,
57
+ });
58
+ return typeof out === "string" ? out : out.primary;
59
+ }
60
+
61
+ function receiptRows(output: string) {
62
+ return parseReceiptComment(output);
63
+ }
64
+
65
+ describe("EffectReceipt (k8s materialization row)", () => {
66
+ it("declares under the k8s lexicon with the real resource kind, carrying the marker", () => {
67
+ const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
68
+ expect(r.lexicon).toBe("k8s");
69
+ expect(r.entityType).toBe(K8S_EFFECT_RECEIPT_ENTITY_TYPE);
70
+ expect(r.entityType).toBe("K8s::Core::ConfigMap");
71
+ expect(isEffectReceipt(r)).toBe(true);
72
+ });
73
+
74
+ it("is accepted by the effect() step's receiptCheckInput, expectation stamped when static", () => {
75
+ const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { v: 1 } });
76
+ const input = receiptCheckInput(r);
77
+ expect(input.receipt.effect).toBe("db-seed");
78
+ expect(input.expectation).toBe(receiptExpectation(r));
79
+ });
80
+
81
+ it("validates the effect as a name segment at declaration", () => {
82
+ expect(() => EffectReceipt("bad", { effect: "db/seed", flavor: "existence" })).toThrow(/DNS-1123/);
83
+ expect(() => EffectReceipt("bad", { effect: "DbSeed", flavor: "existence" })).toThrow(/DNS-1123/);
84
+ expect(() => EffectReceipt("bad", { effect: "", flavor: "existence" })).toThrow(/non-empty/);
85
+ });
86
+ });
87
+
88
+ describe("receiptConfigMapName", () => {
89
+ it("derives chant-receipt.<stack>.<env>.<effect>", () => {
90
+ expect(receiptConfigMapName("demo", "dev", "db-seed")).toBe("chant-receipt.demo.dev.db-seed");
91
+ });
92
+
93
+ it("refuses a segment that is not a DNS-1123 label, so the identity stays unambiguous", () => {
94
+ expect(() => receiptConfigMapName("a.b", "dev", "seed")).toThrow(/stack/);
95
+ expect(() => receiptConfigMapName("demo", "", "seed")).toThrow(/env/);
96
+ expect(() => receiptConfigMapName("demo", "dev", "Seed")).toThrow(/effect/);
97
+ });
98
+
99
+ it("refuses a name over Kubernetes' 253-character ceiling", () => {
100
+ expect(() => receiptConfigMapName("a".repeat(63), "b".repeat(63), "c".repeat(63))).not.toThrow();
101
+ expect(() => receiptConfigMapName("a".repeat(64), "dev", "seed")).toThrow(/DNS-1123/);
102
+ });
103
+
104
+ it("addresses the receipt in the project's namespace, `default` when none is set", () => {
105
+ expect(receiptConfigMapRef("demo", "dev", "db-seed")).toEqual({
106
+ name: "chant-receipt.demo.dev.db-seed",
107
+ namespace: RECEIPT_DEFAULT_NAMESPACE,
108
+ });
109
+ expect(receiptConfigMapRef("demo", "dev", "db-seed", "chant-system").namespace).toBe("chant-system");
110
+ expect(receiptNamespaceFrom({ k8s: { receipts: { namespace: "chant-system" } } })).toBe("chant-system");
111
+ expect(receiptNamespaceFrom(undefined)).toBe("default");
112
+ expect(receiptNamespaceFrom({ k8s: {} })).toBe("default");
113
+ });
114
+ });
115
+
116
+ describe("k8sSerializer receipt rows", () => {
117
+ it("renders each receipt as a ConfigMap row at the derived address, expectation under data", () => {
118
+ const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { v: 1 } });
119
+ const rows = receiptRows(serializeReceipts(new Map([["seeded", seeded]]), ownership));
120
+ expect(rows.seeded).toEqual({
121
+ kind: "ConfigMap",
122
+ namespace: "default",
123
+ name: "chant-receipt.demo.dev.db-seed",
124
+ data: { [RECEIPT_DATA_KEY]: receiptExpectation(seeded) },
125
+ });
126
+ });
127
+
128
+ it("takes the namespace from k8s.receipts.namespace", () => {
129
+ const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
130
+ const output = serializeReceipts(new Map([["seeded", seeded]]), ownership, {
131
+ k8s: { receipts: { namespace: "chant-system" } },
132
+ });
133
+ expect(receiptRows(output).seeded.namespace).toBe("chant-system");
134
+ });
135
+
136
+ it("keeps the receipt out of the documents, the only thing an applier applies", () => {
137
+ const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
138
+ const output = serializeReceipts(new Map([["seeded", seeded]]), ownership);
139
+ const documents = loadAll(output).filter((d) => d && typeof d === "object");
140
+ expect(documents).toEqual([]);
141
+ expect(output).toContain(EFFECT_RECEIPTS_COMMENT_MARKER);
142
+ expect(receiptRows(output).seeded).toBeDefined();
143
+ });
144
+
145
+ it("leaves a real manifest applyable with the comment appended", () => {
146
+ const configMap = {
147
+ [DECLARABLE_MARKER]: true as const,
148
+ lexicon: "k8s",
149
+ entityType: "K8s::Core::ConfigMap",
150
+ props: { metadata: { name: "app-config" }, data: { a: "1" } },
151
+ };
152
+ const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
153
+ const out = k8sSerializer.serialize(
154
+ new Map<string, Declarable>([["appConfig", configMap as unknown as Declarable]]),
155
+ [],
156
+ { ownership, receipts: new Map<string, Declarable>([["seeded", seeded]]) },
157
+ );
158
+ const output = typeof out === "string" ? out : out.primary;
159
+ const documents = loadAll(output).filter((d): d is Record<string, unknown> => !!d && typeof d === "object");
160
+ expect(documents).toHaveLength(1);
161
+ expect((documents[0].metadata as { name: string }).name).toBe("app-config");
162
+ expect(receiptRows(output).seeded.name).toBe("chant-receipt.demo.dev.db-seed");
163
+ });
164
+
165
+ it("renders the existence expectation for an existence receipt", () => {
166
+ const r = EffectReceipt("booted", { effect: "bootstrap", flavor: "existence" });
167
+ const rows = receiptRows(serializeReceipts(new Map([["booted", r]]), ownership));
168
+ expect(rows.booted.data[RECEIPT_DATA_KEY]).toBe(EXISTENCE_EXPECTATION);
169
+ });
170
+
171
+ it("renders the placeholder note, never a placeholder digest, for reference inputs", () => {
172
+ const r = EffectReceipt("wired", { effect: "wire-up", flavor: "hash", inputs: { target: someRef } });
173
+ const rows = receiptRows(serializeReceipts(new Map([["wired", r]]), ownership));
174
+ expect(rows.wired.data[RECEIPT_DATA_KEY]).toBe(RECEIPT_UNRESOLVED_VALUE_NOTE);
175
+ });
176
+
177
+ it("errors when no ownership marker resolves", () => {
178
+ const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
179
+ expect(() => serializeReceipts(new Map([["seeded", r]]))).toThrow(/ownership/);
180
+ });
181
+
182
+ it("errors when ownership resolves no env, because the segment is explicit and never guessed", () => {
183
+ const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
184
+ expect(() => serializeReceipts(new Map([["seeded", r]]), { stack: "demo" })).toThrow(/env/);
185
+ });
186
+
187
+ it("emits no receipt block when the context carries no receipts", () => {
188
+ const out = k8sSerializer.serialize(new Map(), [], { ownership });
189
+ const output = typeof out === "string" ? out : out.primary;
190
+ expect(output).not.toContain(EFFECT_RECEIPTS_COMMENT_MARKER);
191
+ expect(parseReceiptComment(output)).toEqual({});
192
+ });
193
+
194
+ it("round-trips the block deterministically, and reads an output that has none as none", () => {
195
+ const rows = {
196
+ b: { kind: "ConfigMap" as const, namespace: "default", name: "chant-receipt.demo.dev.b", data: { expectation: "x" } },
197
+ a: { kind: "ConfigMap" as const, namespace: "default", name: "chant-receipt.demo.dev.a", data: { expectation: "y" } },
198
+ };
199
+ const line = renderReceiptComment(rows);
200
+ expect(line.indexOf('"a"')).toBeLessThan(line.indexOf('"b"'));
201
+ expect(parseReceiptComment(`${line}\n`)).toEqual(rows);
202
+ expect(parseReceiptComment("apiVersion: v1\nkind: Namespace\n")).toEqual({});
203
+ expect(parseReceiptComment(`${EFFECT_RECEIPTS_COMMENT_MARKER}not json\n`)).toEqual({});
204
+ });
205
+ });
206
+
207
+ describe("the receipt label", () => {
208
+ it("recognizes a live receipt ConfigMap by its label alone", () => {
209
+ expect(isEffectReceiptObject({ [RECEIPT_LABEL_KEY]: "db-seed" })).toBe(true);
210
+ expect(isEffectReceiptObject({ [RECEIPT_LABEL_KEY]: "" })).toBe(false);
211
+ expect(isEffectReceiptObject({ "app.kubernetes.io/managed-by": "chant" })).toBe(false);
212
+ expect(isEffectReceiptObject(undefined)).toBe(false);
213
+ });
214
+ });
215
+
216
+ describe("#1833's plain-store guard over the k8s row", () => {
217
+ const check = coreReceiptChecks().find((c) => c.id === RECEIPT_PLAIN_STORE_CHECK_ID)!;
218
+
219
+ function runCheck(entities: Map<string, Declarable>) {
220
+ const ctx: PostSynthContext = {
221
+ outputs: new Map(),
222
+ entities,
223
+ buildResult: { outputs: new Map(), entities, warnings: [], errors: [], sourceFileCount: 0 },
224
+ };
225
+ return check.check(ctx);
226
+ }
227
+
228
+ it("passes the factory's row, a plain ConfigMap", () => {
229
+ const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
230
+ expect(runCheck(new Map([["seeded", r]]))).toEqual([]);
231
+ });
232
+
233
+ it("fails a Secret-kind fixture, the same way it fails SSM SecureString", () => {
234
+ const sneaky = {
235
+ [DECLARABLE_MARKER]: true as const,
236
+ [EFFECT_RECEIPT_MARKER]: true as const,
237
+ lexicon: "k8s",
238
+ entityType: "K8s::Core::Secret",
239
+ name: "sneaky",
240
+ effect: "db-seed",
241
+ flavor: "existence" as const,
242
+ inputs: {},
243
+ };
244
+ const diagnostics = runCheck(new Map([["sneaky", sneaky as unknown as Declarable]]));
245
+ expect(diagnostics).toHaveLength(1);
246
+ expect(diagnostics[0].checkId).toBe(RECEIPT_PLAIN_STORE_CHECK_ID);
247
+ expect(diagnostics[0].severity).toBe("error");
248
+ expect(diagnostics[0].message).toMatch(/K8s::Core::Secret/);
249
+ });
250
+ });
251
+
252
+ describe("#1833's leaf rule over a ConfigMap receipt", () => {
253
+ /** The k8s source fixture: the row's own factory, referenced the way COR022
254
+ * refuses. Recognition is by factory name, so the rule fires here exactly as
255
+ * it fires on the aws SSM row. */
256
+ function lintContext(code: string): LintContext {
257
+ const sourceFile = ts.createSourceFile("infra.ts", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
258
+ return { sourceFile, entities: [], filePath: "infra.ts", lexicon: "k8s" };
259
+ }
260
+
261
+ it("fires when a ConfigMap derives a value from the receipt", () => {
262
+ const diags = cor022ReceiptLeafRule.check(
263
+ lintContext(`
264
+ import { EffectReceipt, ConfigMap } from "@intentius/chant-lexicon-k8s";
265
+ export const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
266
+ export const app = new ConfigMap({ data: { seededBy: seeded.effect } });
267
+ `),
268
+ );
269
+ expect(diags).toHaveLength(1);
270
+ expect(diags[0].ruleId).toBe("COR022");
271
+ expect(diags[0].severity).toBe("error");
272
+ expect(diags[0].message).toContain('"seeded" is an effect receipt');
273
+ });
274
+
275
+ it("passes when the receipt is only handed to the effect() step whole", () => {
276
+ const diags = cor022ReceiptLeafRule.check(
277
+ lintContext(`
278
+ import { EffectReceipt } from "@intentius/chant-lexicon-k8s";
279
+ export const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" });
280
+ export const migrate = effect(seeded, [shell({ run: "./seed.sh" })]);
281
+ `),
282
+ );
283
+ expect(diags).toEqual([]);
284
+ });
285
+ });
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The k8s effect-receipt materialization row (#2074, epic #1703): an effect
3
+ * receipt stored as a core `ConfigMap`, named
4
+ * `chant-receipt.<stack>.<env>.<effect>`, holding the expectation under
5
+ * `data.expectation`.
6
+ *
7
+ * Core's `EffectReceipt` factory (#1831) declares a receipt under the `chant`
8
+ * pseudo-lexicon, which no serializer claims. This module is the k8s
9
+ * materialization, built to the same shape as the aws SSM row (#1835,
10
+ * `lexicons/aws/src/effect-receipt-row.ts`): the {@link EffectReceipt} factory
11
+ * here produces core's declaration under `lexicon: "k8s"`, so the build
12
+ * partitions it to the k8s serializer, #1832's write-exclusion seam withholds
13
+ * it from the apply-bound entity set, and the serializer renders it for
14
+ * visibility through `SerializeContext.receipts` (see ./serializer.ts).
15
+ *
16
+ * Where the aws row parks its rendered rows in the CloudFormation template's
17
+ * `Metadata` (deliberately outside `Resources`, the section an applier writes
18
+ * from), this row parks them in a YAML COMMENT at the end of the manifest
19
+ * stream. Kubernetes YAML has no metadata channel outside the documents
20
+ * themselves, and a document is exactly what an applier applies: `loadAll`
21
+ * yields no object for a comment, so the receipt block is structurally
22
+ * unreachable from `applyManifest` while still riding the one build output the
23
+ * observation leg is handed. See {@link EFFECT_RECEIPTS_COMMENT_MARKER}.
24
+ *
25
+ * ## Name and namespace
26
+ *
27
+ * Path identity (epic decision 4): the ConfigMap name derives from the SAME
28
+ * ownership-block fields that stamp markers, `ownership.stack` and an explicit
29
+ * `ownership.env`, plus the receipt's `effect`. The separator is `.`, which no
30
+ * segment may contain, so `stack=a-b env=c` and `stack=a env=b-c` cannot
31
+ * produce the same name. The result is a valid RFC 1123 DNS subdomain, which
32
+ * is what a ConfigMap name has to be.
33
+ *
34
+ * The namespace is the project's `k8s.receipts.namespace`, and `default` when
35
+ * the project sets none, which is where every other namespace-less k8s read
36
+ * and write in this lexicon already lands. It is never invented from the stack
37
+ * or the environment: a namespace chant guessed would be a namespace chant has
38
+ * to create, and the receipt row creates nothing but the receipt.
39
+ *
40
+ * ## Ownership, and why the prune leaves it alone
41
+ *
42
+ * Every receipt ConfigMap carries chant's ownership marker labels plus
43
+ * {@link RECEIPT_LABEL_KEY}. The marker is what makes `chant kube get` and the
44
+ * observation classify it as chant's rather than foreign. The receipt label is
45
+ * what keeps `delete: "owned-only"` from pruning it: the receipt is owned and
46
+ * is never in any apply set (the effect step is its sole writer), which is the
47
+ * exact shape the owned-only sweep deletes. `pruneOrphans`
48
+ * (./op/activities/kubectl.ts) reports it `retained` instead, the same
49
+ * treatment a generated-once Secret gets (./secret-labels.ts) and for the same
50
+ * reason: destroying it silently converts at-least-once into a re-run nobody
51
+ * asked for, or worse, into never.
52
+ *
53
+ * Plain store (#1833's COR023): a ConfigMap is the plain half of the
54
+ * ConfigMap/Secret pair, and the entityType alone is what the guard reads. A
55
+ * receipt value is a witness (an existence marker or a `sha256:` digest),
56
+ * never a secret; materializing one into a `K8s::Core::Secret` fails COR023.
57
+ */
58
+
59
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
60
+ import {
61
+ EffectReceipt as CoreEffectReceipt,
62
+ EFFECT_RECEIPT_MARKER,
63
+ type EffectReceiptFlavor,
64
+ type EffectReceiptOptions,
65
+ } from "@intentius/chant/effect-receipt";
66
+
67
+ /** The entityType of the k8s materialization row, the real resource kind the
68
+ * receipt is stored as, which is what lint's plain-store guard checks. */
69
+ export const K8S_EFFECT_RECEIPT_ENTITY_TYPE = "K8s::Core::ConfigMap";
70
+
71
+ /** apiVersion/kind of the materialized row, for the client reads and writes. */
72
+ export const RECEIPT_CONFIGMAP_REF = { apiVersion: "v1", kind: "ConfigMap" } as const;
73
+
74
+ /**
75
+ * The line prefix the serializer renders receipt rows behind, and the
76
+ * observation leg reads them back from. A YAML comment: an applier's `loadAll`
77
+ * produces no document for it, so a receipt can never enter an apply set, and
78
+ * `kubectl apply -f` ignores it exactly as it ignores every other comment.
79
+ * The remainder of the line is one JSON object keyed by entity name.
80
+ */
81
+ export const EFFECT_RECEIPTS_COMMENT_MARKER = "# chant:effect-receipts ";
82
+
83
+ /** First segment of every receipt ConfigMap name. */
84
+ export const RECEIPT_NAME_PREFIX = "chant-receipt";
85
+
86
+ /** The `data` key the expectation is stored under. One key, the same single
87
+ * value the aws row puts in the SSM parameter's `Value`. */
88
+ export const RECEIPT_DATA_KEY = "expectation";
89
+
90
+ /** The label a live receipt ConfigMap carries, valued with the effect it
91
+ * witnesses. Recognition for the observation leg and, more importantly, the
92
+ * exclusion the owned-only prune keys on. */
93
+ export const RECEIPT_LABEL_KEY = "chant.intentius.io/effect-receipt";
94
+
95
+ /** Where receipts live when the project names no namespace. */
96
+ export const RECEIPT_DEFAULT_NAMESPACE = "default";
97
+
98
+ /**
99
+ * The rendered value of a hash-flavor receipt that still carries reference
100
+ * inputs at synthesis. References resolve at plan and at run, never at
101
+ * synthesis (epic decision 5), so the row carries this note instead of a
102
+ * digest hashed over placeholders.
103
+ */
104
+ export const RECEIPT_UNRESOLVED_VALUE_NOTE =
105
+ "unresolved at synthesis, reference inputs; the expectation resolves at plan and at run (chant #1703, decision 5)";
106
+
107
+ /** One segment of the receipt name. RFC 1123 DNS label, which is what each
108
+ * dot-separated piece of a ConfigMap name has to be, and the `.` separator is
109
+ * excluded from it so the identity stays unambiguous. */
110
+ const NAME_SEGMENT = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/;
111
+
112
+ /** Kubernetes' own ceiling on a DNS subdomain, which a ConfigMap name is. */
113
+ const MAX_NAME_LENGTH = 253;
114
+
115
+ function checkSegment(field: string, value: string): string {
116
+ if (value.length > 63 || !NAME_SEGMENT.test(value)) {
117
+ throw new Error(
118
+ `receipt name: ${field} "${value}" is not a valid DNS-1123 label, ` +
119
+ `use lowercase letters, digits and "-" (the segment becomes one dot-separated piece of ` +
120
+ `${RECEIPT_NAME_PREFIX}.<stack>.<env>.<effect>, which must be a valid ConfigMap name)`,
121
+ );
122
+ }
123
+ return value;
124
+ }
125
+
126
+ /**
127
+ * The ConfigMap name of one effect's receipt:
128
+ * `chant-receipt.<stack>.<env>.<effect>`, from the resolved ownership marker
129
+ * fields (epic decision 4). The single source of the name identity: the
130
+ * serializer's rendered row, the receipt store's reads and writes, and the
131
+ * observation leg all call this.
132
+ */
133
+ export function receiptConfigMapName(stack: string, env: string, effect: string): string {
134
+ const name = `${RECEIPT_NAME_PREFIX}.${checkSegment("stack", stack)}.${checkSegment("env", env)}.${checkSegment("effect", effect)}`;
135
+ if (name.length > MAX_NAME_LENGTH) {
136
+ throw new Error(
137
+ `receipt name: "${name}" is ${name.length} characters, over Kubernetes' ${MAX_NAME_LENGTH}-character ` +
138
+ `limit for a ConfigMap name. Shorten the stack, the environment, or the effect.`,
139
+ );
140
+ }
141
+ return name;
142
+ }
143
+
144
+ /** The project's receipt namespace: `k8s.receipts.namespace`, else `default`.
145
+ * The serializer reads it off `SerializeContext.config` and the store reads it
146
+ * off the project config, so both derive one namespace from one setting. */
147
+ export function receiptNamespaceFrom(config: Record<string, unknown> | undefined): string {
148
+ const k8s = config?.k8s as { receipts?: { namespace?: unknown } } | undefined;
149
+ const declared = k8s?.receipts?.namespace;
150
+ return typeof declared === "string" && declared.length > 0 ? declared : RECEIPT_DEFAULT_NAMESPACE;
151
+ }
152
+
153
+ /** Name and namespace of one effect's receipt ConfigMap. */
154
+ export interface ReceiptConfigMapRef {
155
+ name: string;
156
+ namespace: string;
157
+ }
158
+
159
+ /** The full address of one effect's receipt ConfigMap. */
160
+ export function receiptConfigMapRef(
161
+ stack: string,
162
+ env: string,
163
+ effect: string,
164
+ namespace: string = RECEIPT_DEFAULT_NAMESPACE,
165
+ ): ReceiptConfigMapRef {
166
+ return { name: receiptConfigMapName(stack, env, effect), namespace };
167
+ }
168
+
169
+ /** True when a live object's labels mark it a chant effect receipt. Any
170
+ * non-empty value counts: the label's presence is the claim, and a sweep must
171
+ * err on the side of keeping. */
172
+ export function isEffectReceiptObject(labels: Record<string, unknown> | undefined): boolean {
173
+ const value = labels?.[RECEIPT_LABEL_KEY];
174
+ return typeof value === "string" && value.length > 0;
175
+ }
176
+
177
+ /** One rendered receipt row, as the comment block carries it. */
178
+ export interface RenderedReceiptRow {
179
+ kind: typeof RECEIPT_CONFIGMAP_REF.kind;
180
+ namespace: string;
181
+ name: string;
182
+ data: Record<string, string>;
183
+ }
184
+
185
+ /**
186
+ * Render the receipt block the serializer appends to the manifest stream.
187
+ * Deterministic: one line, entity names sorted.
188
+ */
189
+ export function renderReceiptComment(rows: Record<string, RenderedReceiptRow>): string {
190
+ const sorted: Record<string, RenderedReceiptRow> = {};
191
+ for (const name of Object.keys(rows).sort()) sorted[name] = rows[name];
192
+ return `${EFFECT_RECEIPTS_COMMENT_MARKER}${JSON.stringify(sorted)}`;
193
+ }
194
+
195
+ /**
196
+ * Read the receipt rows back out of a build output. Returns an empty record
197
+ * for an output that carries no block, which is every project that declares no
198
+ * receipt. Never throws: an unparseable block is no block, and the observation
199
+ * leg reports the receipts it could not address as absent rather than
200
+ * inventing one.
201
+ */
202
+ export function parseReceiptComment(buildOutput: string): Record<string, RenderedReceiptRow> {
203
+ const start = buildOutput.lastIndexOf(EFFECT_RECEIPTS_COMMENT_MARKER);
204
+ if (start < 0) return {};
205
+ const end = buildOutput.indexOf("\n", start);
206
+ const line = buildOutput.slice(start + EFFECT_RECEIPTS_COMMENT_MARKER.length, end < 0 ? undefined : end);
207
+ try {
208
+ const parsed = JSON.parse(line) as unknown;
209
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
210
+ return parsed as Record<string, RenderedReceiptRow>;
211
+ } catch {
212
+ return {};
213
+ }
214
+ }
215
+
216
+ /**
217
+ * A k8s-materialized effect receipt: core's declaration shape (so
218
+ * `isEffectReceipt`, `effect(...)`, lint, and the plan engine all recognize it
219
+ * through the marker), under the k8s lexicon and the real resource kind.
220
+ */
221
+ export interface K8sEffectReceiptDeclaration extends Declarable {
222
+ readonly [EFFECT_RECEIPT_MARKER]: true;
223
+ readonly lexicon: "k8s";
224
+ readonly entityType: typeof K8S_EFFECT_RECEIPT_ENTITY_TYPE;
225
+ /** The receipt's own name (the export-level identity of the witness). */
226
+ readonly name: string;
227
+ /** The effect this receipt witnesses, the name's final segment. */
228
+ readonly effect: string;
229
+ /** How the receipt is compared: mere presence, or a digest of the inputs. */
230
+ readonly flavor: EffectReceiptFlavor;
231
+ /** The effect's inputs as recorded at synthesis (references as placeholders). */
232
+ readonly inputs: Readonly<Record<string, unknown>>;
233
+ }
234
+
235
+ /**
236
+ * Declare a k8s-materialized effect receipt. Same signature and semantics as
237
+ * core's `EffectReceipt` (#1831), whose factory validates and freezes the
238
+ * options, but the declaration lands in the k8s partition, so the k8s
239
+ * serializer renders the ConfigMap row and the receipt store
240
+ * (./receipt-store.ts) is its writer. Pass the returned const straight to the
241
+ * `effect(...)` op step.
242
+ */
243
+ export function EffectReceipt(name: string, options: EffectReceiptOptions): K8sEffectReceiptDeclaration {
244
+ // Fail at declaration, not at serialize: the effect is the name's final
245
+ // segment, and a name that cannot become a segment has no receipt address.
246
+ if (typeof options?.effect === "string" && options.effect.length > 0) {
247
+ checkSegment("effect", options.effect);
248
+ }
249
+ const core = CoreEffectReceipt(name, options);
250
+ const decl: K8sEffectReceiptDeclaration = {
251
+ [DECLARABLE_MARKER]: true,
252
+ [EFFECT_RECEIPT_MARKER]: true,
253
+ lexicon: "k8s",
254
+ entityType: K8S_EFFECT_RECEIPT_ENTITY_TYPE,
255
+ name: core.name,
256
+ effect: core.effect,
257
+ flavor: core.flavor,
258
+ // The same frozen structure the core factory built: intrinsic inputs stay
259
+ // live so discovery can stamp logical names onto attr-refs.
260
+ inputs: core.inputs,
261
+ };
262
+ // Declared fields immutable, object extensible for discovery's own
263
+ // symbol-keyed metadata, the same lock the core factory applies.
264
+ for (const key of Object.keys(decl)) {
265
+ Object.defineProperty(decl, key, { writable: false, configurable: false });
266
+ }
267
+ return decl;
268
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,28 @@ export { DEFAULT_LABELS_MARKER, DEFAULT_ANNOTATIONS_MARKER } from "./default-lab
27
27
  // Variables / label constants
28
28
  export { K8sLabels, K8sAnnotations } from "./variables";
29
29
 
30
+ // Effect receipts (#2074, epic #1703): the k8s materialization row, meaning the
31
+ // factory a project declares (core's shape under the k8s lexicon, carrying
32
+ // the marker) and the name/label constants. The ConfigMap-backed store itself
33
+ // lives at the `/receipt-store` subpath, off the build path (#1074).
34
+ export {
35
+ EffectReceipt,
36
+ receiptConfigMapName,
37
+ receiptConfigMapRef,
38
+ receiptNamespaceFrom,
39
+ isEffectReceiptObject,
40
+ K8S_EFFECT_RECEIPT_ENTITY_TYPE,
41
+ EFFECT_RECEIPTS_COMMENT_MARKER,
42
+ RECEIPT_NAME_PREFIX,
43
+ RECEIPT_DATA_KEY,
44
+ RECEIPT_LABEL_KEY,
45
+ RECEIPT_DEFAULT_NAMESPACE,
46
+ RECEIPT_UNRESOLVED_VALUE_NOTE,
47
+ parseReceiptComment,
48
+ renderReceiptComment,
49
+ } from "./effect-receipt-row";
50
+ export type { K8sEffectReceiptDeclaration, ReceiptConfigMapRef, RenderedReceiptRow } from "./effect-receipt-row";
51
+
30
52
  // Generated-once secret marker (#1830) — constants only; the store adapter
31
53
  // itself lives at the `/secret-store` subpath, off the build path (#1074).
32
54
  export { GENERATED_ONCE_LABEL_KEY, GENERATED_ONCE_LABEL_VALUE, isGeneratedOnce } from "./secret-labels";
@@ -45,7 +45,7 @@ export const k8sAuditCatalog: Record<string, RuleMeta> = {
45
45
  WK8502: auditRule("WK8502", "merge-worthy", "guidance", "Custom resource spec field has the wrong type or enum value", "Match the CRD schema: use the declared scalar type and one of the enum values.", { category: "correctness" }),
46
46
  WK8503: auditRule("WK8503", "merge-worthy", "guidance", "Workload consumes a Secret nothing in the output produces", "Produce the Secret in the same build (Secret, ExternalSecret, InfisicalSecret, cert-manager Certificate), or declare its provenance with declareSecret() if it exists out of band.", { category: "correctness" }),
47
47
  WK8504: auditRule("WK8504", "merge-worthy", "guidance", "committed-encrypted secret declaration does not resolve", "Point `file` at the committed ciphertext, make its metadata.name match the declaration, and re-run `sops -e -i` so every data/stringData value is ENC[...].", { authority: [K8S_SECRETS] }),
48
- WK8505: auditRule("WK8505", "report-only", "guidance", "committed-encrypted secret with no Flux decryption wiring", "Add decryption: \"sops\" to the FluxAppFor reconciling the path that carries the ciphertext sidecar, or ignore if that Kustomization is declared in a different build.", { category: "correctness" }),
48
+ WK8505: auditRule("WK8505", "report-only", "guidance", "committed-encrypted secret with no Flux decryption wiring", "Add decryption: \"sops\" to the FluxAppFor reconciling the path that carries the ciphertext sidecar, or ignore if that Kustomization is declared in a different build. This check sees one build root at a time, so it goes silent once the secret and the Kustomization live in different build roots (chant #1939).", { category: "correctness" }),
49
49
  };
50
50
 
51
51
  // Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).