diff --git a/docs/src/content/docs/concepts/effect-receipts.mdx b/docs/src/content/docs/concepts/effect-receipts.mdx index a01cbc012..c50b779db 100644 --- a/docs/src/content/docs/concepts/effect-receipts.mdx +++ b/docs/src/content/docs/concepts/effect-receipts.mdx @@ -28,7 +28,16 @@ export const dbMigration = EffectReceipt("dbMigration", { Two flavors. **Existence** — the receipt's presence is the witness; "will be created" means "will fire." **Hash** — the witness is a digest of the inputs, so changed inputs re-propose the effect: bump `version` and the next plan shows the migration firing again. -The declaration is core, but the storage is per lexicon. A materialization row turns the declaration into a real resource in the estate — the aws row materializes an `AWS::SSM::Parameter`, plain `String`, at `/chant-receipts///`. The path derives from the same ownership-block fields that stamp [ownership markers](/chant/configuration/config-file/#ownership), with the environment explicit, so receipt identity and resource identity come from one source. A lexicon that cannot observe its receipt type reports staleness as `unobserved`, loudly — never a wrong answer. +The declaration is core, but the storage is per lexicon. A materialization row turns the declaration into a real resource in the estate, and two of them exist today. + +| Lexicon | Materialized as | Address | Where the value lives | +|---------|-----------------|---------|-----------------------| +| aws | `AWS::SSM::Parameter`, plain `String` | `/chant-receipts///` | the parameter's `Value` | +| k8s | `K8s::Core::ConfigMap` | `chant-receipt...`, in `k8s.receipts.namespace` (`default` when unset) | `data.expectation` | + +Both addresses derive from the same ownership-block fields that stamp [ownership markers](/chant/configuration/config-file/#ownership), with the environment explicit, so receipt identity and resource identity come from one source. The separator differs only because the substrate's naming does: an SSM path is `/`-separated and a ConfigMap name is a DNS subdomain, and in each case the separator is a character no segment may contain, so two different identities can never render the same address. A lexicon that cannot observe its receipt type reports staleness as `unobserved`, loudly, and never as a wrong answer. + +A receipt is never in any apply set, which is exactly the shape a prune deletes. The k8s row therefore carries a `chant.intentius.io/effect-receipt` label beside the ownership marker, and `delete: "owned-only"` reports the receipt `retained` rather than sweeping it, the same treatment a generated-once Secret gets. Staleness is then an ordinary observation: the live value differs from the expected one, or the receipt is absent. [`lifecycle plan`](/chant/cli/lifecycle/#lifecycle-plan-env) renders that as an `effect` row — "effect will fire: db-migration" — the reviewable signal that an apply is about to run something, not just write something. @@ -74,6 +83,12 @@ A receipt is a readable parameter in your own account, not an entry in a tool's aws ssm get-parameter --name /chant-receipts/my-stack/prod/db-migration ``` +Or with the k8s row: + +```bash +kubectl get configmap chant-receipt.my-stack.prod.db-migration -o jsonpath='{.data.expectation}' +``` + Read-only IAM, no chant binary, no export step. Delete chant tomorrow and every receipt is still there, still legible, still telling you which effects ran against which inputs. The same walk-away-zero property the rest of the lifecycle model holds. ## Boundaries, kept deliberately diff --git a/lexicons/k8s/docs/src/content/docs/serialization.mdx b/lexicons/k8s/docs/src/content/docs/serialization.mdx index 232b44471..2186ecfd0 100644 --- a/lexicons/k8s/docs/src/content/docs/serialization.mdx +++ b/lexicons/k8s/docs/src/content/docs/serialization.mdx @@ -26,6 +26,13 @@ The generated file includes: - `metadata.name` auto-generated from export names (camelCase → kebab-case) - Default labels and annotations injected from `defaultLabels()`/`defaultAnnotations()` +The lexicon also materializes [effect receipts](/chant/concepts/effect-receipts/) as +ConfigMaps named `chant-receipt...` in `k8s.receipts.namespace` +(`default` when unset), holding the expectation under `data.expectation`. A receipt is +never a document in the manifest stream: the rows ride a trailing +`# chant:effect-receipts` comment, which `kubectl apply` ignores, because the +`effect()` step is a receipt's sole writer. + ## Key conversions | Chant (TypeScript) | YAML output | Rule | diff --git a/lexicons/k8s/src/codegen/docs.ts b/lexicons/k8s/src/codegen/docs.ts index d2ff6d469..cf1db29c3 100644 --- a/lexicons/k8s/src/codegen/docs.ts +++ b/lexicons/k8s/src/codegen/docs.ts @@ -87,6 +87,13 @@ The generated file includes: - \`metadata.name\` auto-generated from export names (camelCase → kebab-case) - Default labels and annotations injected from \`defaultLabels()\`/\`defaultAnnotations()\` +The lexicon also materializes [effect receipts](/chant/concepts/effect-receipts/) as +ConfigMaps named \`chant-receipt...\` in \`k8s.receipts.namespace\` +(\`default\` when unset), holding the expectation under \`data.expectation\`. A receipt is +never a document in the manifest stream: the rows ride a trailing +\`# chant:effect-receipts\` comment, which \`kubectl apply\` ignores, because the +\`effect()\` step is a receipt's sole writer. + ## Key conversions | Chant (TypeScript) | YAML output | Rule | diff --git a/lexicons/k8s/src/config-schema.ts b/lexicons/k8s/src/config-schema.ts index 966e7d757..4099bb20e 100644 --- a/lexicons/k8s/src/config-schema.ts +++ b/lexicons/k8s/src/config-schema.ts @@ -28,6 +28,11 @@ export const k8sConfigSchema = z.strictObject({ roots: z.array(z.string()).optional(), }) .optional(), + receipts: z + .strictObject({ + namespace: z.string().optional(), + }) + .optional(), }); declare module "@intentius/chant/config" { diff --git a/lexicons/k8s/src/config.ts b/lexicons/k8s/src/config.ts index fc3fafe94..beadb4722 100644 --- a/lexicons/k8s/src/config.ts +++ b/lexicons/k8s/src/config.ts @@ -111,6 +111,29 @@ export interface K8sChantConfig { /** Kustomization directories to render into the build. */ roots?: string[]; }; + + /** + * Effect receipt settings (#2074, epic #1703). + * + * `namespace` is where this project's receipt ConfigMaps live. The name is + * derived from the ownership fields (`chant-receipt...`, + * see `./effect-receipt-row.ts`); the namespace is the one part of the + * address the ownership block cannot answer, so it is declared here. Unset, + * receipts land in `default`, the same namespace every other namespace-less + * k8s read and write in this lexicon falls through to. It is never derived + * from the stack or the environment: a guessed namespace is one chant would + * have to create, and the receipt row creates nothing but the receipt. + * + * ```ts + * k8s: { + * receipts: { namespace: "chant-system" }, + * } satisfies K8sChantConfig + * ``` + */ + receipts?: { + /** Namespace the receipt ConfigMaps live in. Defaults to `default`. */ + namespace?: string; + }; } declare module "@intentius/chant/config" { diff --git a/lexicons/k8s/src/deep-observe.ts b/lexicons/k8s/src/deep-observe.ts index e5c112705..7a9e8267b 100644 --- a/lexicons/k8s/src/deep-observe.ts +++ b/lexicons/k8s/src/deep-observe.ts @@ -118,6 +118,7 @@ import { } from "./api/classify"; import { operationFor } from "./api/operation-surface"; import { k8sDeepNormalizationHooks } from "./deep-observe-hooks"; +import { observeReceiptRowsDeep, receiptRowsFor } from "./receipt-store"; // Re-exported so a dynamic importer of this module (plugin.ts's // `observeResourcesDeep`, a test) can get the reader and its hooks from one @@ -159,11 +160,23 @@ export async function observeResourcesDeepK8s( ): Promise { const { managedFieldsOf, isChantFieldManager } = await import("@intentius/chant-k8s-client"); - const declared = [...options.entities].map(([entityName, entity]) => ({ - entityName, - entityType: entity.entityType, - props: entity.props, - })); + // Effect receipts (#2074) are read by their own leg at the end: they carry + // no declared props, so every live path would be an unclaimed field (#2160) + // and their staleness is an `effect` row from the plan (#1832), never + // property drift. Reading them here keeps the deep read from calling a + // declared entity a hole; contributing an empty tree keeps it from calling + // one drift. + const receiptRows = receiptRowsFor(options.entityNames, options.buildOutput); + + const declared = [...options.entities] + .filter(([entityName]) => !receiptRows.has(entityName)) + .map(([entityName, entity]) => ({ + entityName, + entityType: entity.entityType, + props: entity.props, + })); + + const everyName = [...declared.map((d) => d.entityName), ...receiptRows.keys()]; let client; try { @@ -173,7 +186,7 @@ export async function observeResourcesDeepK8s( return deepObservation( {}, unobservedAll( - declared.map((d) => d.entityName), + everyName, "read-failed", MISSING_CLIENT_DETAIL, options.entities, @@ -185,7 +198,7 @@ export async function observeResourcesDeepK8s( return deepObservation( {}, unobservedAll( - declared.map((d) => d.entityName), + everyName, outcome.kind === "unobserved" ? outcome.reason : "read-failed", outcome.kind === "unobserved" ? outcome.detail : undefined, options.entities, @@ -267,5 +280,11 @@ export async function observeResourcesDeepK8s( } }); + if (receiptRows.size > 0) { + const receiptObs = await observeReceiptRowsDeep(client, receiptRows); + Object.assign(resources, receiptObs.resources); + Object.assign(unobserved, receiptObs.unobserved); + } + return deepObservation(resources, unobserved); } diff --git a/lexicons/k8s/src/describe-resources.ts b/lexicons/k8s/src/describe-resources.ts index 8284d30b6..0d06ec502 100644 --- a/lexicons/k8s/src/describe-resources.ts +++ b/lexicons/k8s/src/describe-resources.ts @@ -68,6 +68,7 @@ import { import { operationFor } from "./api/operation-surface"; import { resolveK8sOwnerChain } from "./api/owner-chain"; import { gvkToTypeName } from "./spec/parse"; +import { observeReceiptRows, receiptRowsFor } from "./receipt-store"; function pruneUndefined>(obj: T): Record { const out: Record = {}; @@ -357,11 +358,26 @@ export async function describeResources( // looked for where it lives". const queried: Record = {}; - const declared: Declared[] = [...options.entities].map(([entityName, entity]) => ({ - entityName, - entityType: entity.entityType, - props: entity.props, - })); + // Effect receipt rows (#2074) are read by their own leg below: the applier + // never wrote them (#1832), and their declaration carries no props, so the + // generic sweep has no `metadata.name` to query by and would report a hole + // where the receipt leg has a real answer. Their addresses come from the + // build output's receipt comment, which is the serializer's one rendering of + // the derivation (./effect-receipt-row.ts). + const receiptRows = receiptRowsFor(options.entityNames, options.buildOutput); + + const declared: Declared[] = [...options.entities] + .filter(([entityName]) => !receiptRows.has(entityName)) + .map(([entityName, entity]) => ({ + entityName, + entityType: entity.entityType, + props: entity.props, + })); + + // A whole-lexicon failure below is a hole for the receipts too: nobody + // looked at those either, and a connect that never happened proves nothing + // about a receipt's presence. + const everyName = [...declared.map((d) => d.entityName), ...receiptRows.keys()]; // Connect first. The binding check lives here, so a bound-but-mismatched // context throws before any resource is read — core turns that into @@ -374,7 +390,7 @@ export async function describeResources( return observation( {}, unobservedAll( - declared.map((d) => d.entityName), + everyName, "read-failed", MISSING_CLIENT_DETAIL, options.entities, @@ -386,7 +402,7 @@ export async function describeResources( return observation( {}, unobservedAll( - declared.map((d) => d.entityName), + everyName, outcome.kind === "unobserved" ? outcome.reason : "read-failed", outcome.kind === "unobserved" ? outcome.detail : undefined, options.entities, @@ -507,6 +523,14 @@ export async function describeResources( await addRuntimeChildren(client, resources, unobserved, options.owned, declared); + // The receipt leg last, so its answers are the ones that stand for the + // receipt entities, because nothing above ever looked at one. + if (receiptRows.size > 0) { + const receiptObs = await observeReceiptRows(client, receiptRows); + Object.assign(resources, receiptObs.resources); + Object.assign(unobserved, receiptObs.unobserved); + } + return observation(resources, unobserved, queried); } diff --git a/lexicons/k8s/src/effect-receipt-row.test.ts b/lexicons/k8s/src/effect-receipt-row.test.ts new file mode 100644 index 000000000..8c33d59c1 --- /dev/null +++ b/lexicons/k8s/src/effect-receipt-row.test.ts @@ -0,0 +1,285 @@ +/** + * The k8s effect-receipt materialization row (#2074, epic #1703): the + * declaration, the ConfigMap address derivation, what the serializer renders, + * and the #1833 guards over the row. + * + * The shape mirrors lexicons/aws/src/effect-receipt-row.test.ts, which covers + * the SSM row, so the two rows can be read against each other. + */ + +import { describe, it, expect } from "vitest"; +import * as ts from "typescript"; +import { loadAll } from "js-yaml"; +import { + EffectReceipt, + receiptConfigMapName, + receiptConfigMapRef, + receiptNamespaceFrom, + isEffectReceiptObject, + parseReceiptComment, + renderReceiptComment, + EFFECT_RECEIPTS_COMMENT_MARKER, + K8S_EFFECT_RECEIPT_ENTITY_TYPE, + RECEIPT_DATA_KEY, + RECEIPT_DEFAULT_NAMESPACE, + RECEIPT_LABEL_KEY, + RECEIPT_UNRESOLVED_VALUE_NOTE, +} from "./effect-receipt-row"; +import { k8sSerializer } from "./serializer"; +import { + EXISTENCE_EXPECTATION, + isEffectReceipt, + receiptExpectation, + EFFECT_RECEIPT_MARKER, +} from "@intentius/chant/effect-receipt"; +import { receiptCheckInput } from "@intentius/chant/op/receipt-store"; +import { coreReceiptChecks, RECEIPT_PLAIN_STORE_CHECK_ID } from "@intentius/chant/lint/receipt-checks"; +import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable"; +import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic"; +import type { PostSynthContext } from "@intentius/chant/lint/post-synth"; +import { cor022ReceiptLeafRule } from "@intentius/chant/lint/rules/cor022-receipt-leaf"; +import type { LintContext } from "@intentius/chant/lint/rule"; + +/** A minimal deploy-time reference, for the placeholder-value case. */ +const someRef = { [INTRINSIC_MARKER]: true as const, toJSON: () => ({ ref: "other" }) }; + +const ownership = { stack: "demo", env: "dev" }; + +function serializeReceipts( + receipts: Map, + marker?: { stack: string; env?: string }, + config?: Record, +): string { + const out = k8sSerializer.serialize(new Map(), [], { + ...(marker ? { ownership: marker } : {}), + ...(config ? { config } : {}), + receipts, + }); + return typeof out === "string" ? out : out.primary; +} + +function receiptRows(output: string) { + return parseReceiptComment(output); +} + +describe("EffectReceipt (k8s materialization row)", () => { + it("declares under the k8s lexicon with the real resource kind, carrying the marker", () => { + const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" }); + expect(r.lexicon).toBe("k8s"); + expect(r.entityType).toBe(K8S_EFFECT_RECEIPT_ENTITY_TYPE); + expect(r.entityType).toBe("K8s::Core::ConfigMap"); + expect(isEffectReceipt(r)).toBe(true); + }); + + it("is accepted by the effect() step's receiptCheckInput, expectation stamped when static", () => { + const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { v: 1 } }); + const input = receiptCheckInput(r); + expect(input.receipt.effect).toBe("db-seed"); + expect(input.expectation).toBe(receiptExpectation(r)); + }); + + it("validates the effect as a name segment at declaration", () => { + expect(() => EffectReceipt("bad", { effect: "db/seed", flavor: "existence" })).toThrow(/DNS-1123/); + expect(() => EffectReceipt("bad", { effect: "DbSeed", flavor: "existence" })).toThrow(/DNS-1123/); + expect(() => EffectReceipt("bad", { effect: "", flavor: "existence" })).toThrow(/non-empty/); + }); +}); + +describe("receiptConfigMapName", () => { + it("derives chant-receipt...", () => { + expect(receiptConfigMapName("demo", "dev", "db-seed")).toBe("chant-receipt.demo.dev.db-seed"); + }); + + it("refuses a segment that is not a DNS-1123 label, so the identity stays unambiguous", () => { + expect(() => receiptConfigMapName("a.b", "dev", "seed")).toThrow(/stack/); + expect(() => receiptConfigMapName("demo", "", "seed")).toThrow(/env/); + expect(() => receiptConfigMapName("demo", "dev", "Seed")).toThrow(/effect/); + }); + + it("refuses a name over Kubernetes' 253-character ceiling", () => { + expect(() => receiptConfigMapName("a".repeat(63), "b".repeat(63), "c".repeat(63))).not.toThrow(); + expect(() => receiptConfigMapName("a".repeat(64), "dev", "seed")).toThrow(/DNS-1123/); + }); + + it("addresses the receipt in the project's namespace, `default` when none is set", () => { + expect(receiptConfigMapRef("demo", "dev", "db-seed")).toEqual({ + name: "chant-receipt.demo.dev.db-seed", + namespace: RECEIPT_DEFAULT_NAMESPACE, + }); + expect(receiptConfigMapRef("demo", "dev", "db-seed", "chant-system").namespace).toBe("chant-system"); + expect(receiptNamespaceFrom({ k8s: { receipts: { namespace: "chant-system" } } })).toBe("chant-system"); + expect(receiptNamespaceFrom(undefined)).toBe("default"); + expect(receiptNamespaceFrom({ k8s: {} })).toBe("default"); + }); +}); + +describe("k8sSerializer receipt rows", () => { + it("renders each receipt as a ConfigMap row at the derived address, expectation under data", () => { + const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { v: 1 } }); + const rows = receiptRows(serializeReceipts(new Map([["seeded", seeded]]), ownership)); + expect(rows.seeded).toEqual({ + kind: "ConfigMap", + namespace: "default", + name: "chant-receipt.demo.dev.db-seed", + data: { [RECEIPT_DATA_KEY]: receiptExpectation(seeded) }, + }); + }); + + it("takes the namespace from k8s.receipts.namespace", () => { + const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" }); + const output = serializeReceipts(new Map([["seeded", seeded]]), ownership, { + k8s: { receipts: { namespace: "chant-system" } }, + }); + expect(receiptRows(output).seeded.namespace).toBe("chant-system"); + }); + + it("keeps the receipt out of the documents, the only thing an applier applies", () => { + const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" }); + const output = serializeReceipts(new Map([["seeded", seeded]]), ownership); + const documents = loadAll(output).filter((d) => d && typeof d === "object"); + expect(documents).toEqual([]); + expect(output).toContain(EFFECT_RECEIPTS_COMMENT_MARKER); + expect(receiptRows(output).seeded).toBeDefined(); + }); + + it("leaves a real manifest applyable with the comment appended", () => { + const configMap = { + [DECLARABLE_MARKER]: true as const, + lexicon: "k8s", + entityType: "K8s::Core::ConfigMap", + props: { metadata: { name: "app-config" }, data: { a: "1" } }, + }; + const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" }); + const out = k8sSerializer.serialize( + new Map([["appConfig", configMap as unknown as Declarable]]), + [], + { ownership, receipts: new Map([["seeded", seeded]]) }, + ); + const output = typeof out === "string" ? out : out.primary; + const documents = loadAll(output).filter((d): d is Record => !!d && typeof d === "object"); + expect(documents).toHaveLength(1); + expect((documents[0].metadata as { name: string }).name).toBe("app-config"); + expect(receiptRows(output).seeded.name).toBe("chant-receipt.demo.dev.db-seed"); + }); + + it("renders the existence expectation for an existence receipt", () => { + const r = EffectReceipt("booted", { effect: "bootstrap", flavor: "existence" }); + const rows = receiptRows(serializeReceipts(new Map([["booted", r]]), ownership)); + expect(rows.booted.data[RECEIPT_DATA_KEY]).toBe(EXISTENCE_EXPECTATION); + }); + + it("renders the placeholder note, never a placeholder digest, for reference inputs", () => { + const r = EffectReceipt("wired", { effect: "wire-up", flavor: "hash", inputs: { target: someRef } }); + const rows = receiptRows(serializeReceipts(new Map([["wired", r]]), ownership)); + expect(rows.wired.data[RECEIPT_DATA_KEY]).toBe(RECEIPT_UNRESOLVED_VALUE_NOTE); + }); + + it("errors when no ownership marker resolves", () => { + const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" }); + expect(() => serializeReceipts(new Map([["seeded", r]]))).toThrow(/ownership/); + }); + + it("errors when ownership resolves no env, because the segment is explicit and never guessed", () => { + const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" }); + expect(() => serializeReceipts(new Map([["seeded", r]]), { stack: "demo" })).toThrow(/env/); + }); + + it("emits no receipt block when the context carries no receipts", () => { + const out = k8sSerializer.serialize(new Map(), [], { ownership }); + const output = typeof out === "string" ? out : out.primary; + expect(output).not.toContain(EFFECT_RECEIPTS_COMMENT_MARKER); + expect(parseReceiptComment(output)).toEqual({}); + }); + + it("round-trips the block deterministically, and reads an output that has none as none", () => { + const rows = { + b: { kind: "ConfigMap" as const, namespace: "default", name: "chant-receipt.demo.dev.b", data: { expectation: "x" } }, + a: { kind: "ConfigMap" as const, namespace: "default", name: "chant-receipt.demo.dev.a", data: { expectation: "y" } }, + }; + const line = renderReceiptComment(rows); + expect(line.indexOf('"a"')).toBeLessThan(line.indexOf('"b"')); + expect(parseReceiptComment(`${line}\n`)).toEqual(rows); + expect(parseReceiptComment("apiVersion: v1\nkind: Namespace\n")).toEqual({}); + expect(parseReceiptComment(`${EFFECT_RECEIPTS_COMMENT_MARKER}not json\n`)).toEqual({}); + }); +}); + +describe("the receipt label", () => { + it("recognizes a live receipt ConfigMap by its label alone", () => { + expect(isEffectReceiptObject({ [RECEIPT_LABEL_KEY]: "db-seed" })).toBe(true); + expect(isEffectReceiptObject({ [RECEIPT_LABEL_KEY]: "" })).toBe(false); + expect(isEffectReceiptObject({ "app.kubernetes.io/managed-by": "chant" })).toBe(false); + expect(isEffectReceiptObject(undefined)).toBe(false); + }); +}); + +describe("#1833's plain-store guard over the k8s row", () => { + const check = coreReceiptChecks().find((c) => c.id === RECEIPT_PLAIN_STORE_CHECK_ID)!; + + function runCheck(entities: Map) { + const ctx: PostSynthContext = { + outputs: new Map(), + entities, + buildResult: { outputs: new Map(), entities, warnings: [], errors: [], sourceFileCount: 0 }, + }; + return check.check(ctx); + } + + it("passes the factory's row, a plain ConfigMap", () => { + const r = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" }); + expect(runCheck(new Map([["seeded", r]]))).toEqual([]); + }); + + it("fails a Secret-kind fixture, the same way it fails SSM SecureString", () => { + const sneaky = { + [DECLARABLE_MARKER]: true as const, + [EFFECT_RECEIPT_MARKER]: true as const, + lexicon: "k8s", + entityType: "K8s::Core::Secret", + name: "sneaky", + effect: "db-seed", + flavor: "existence" as const, + inputs: {}, + }; + const diagnostics = runCheck(new Map([["sneaky", sneaky as unknown as Declarable]])); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].checkId).toBe(RECEIPT_PLAIN_STORE_CHECK_ID); + expect(diagnostics[0].severity).toBe("error"); + expect(diagnostics[0].message).toMatch(/K8s::Core::Secret/); + }); +}); + +describe("#1833's leaf rule over a ConfigMap receipt", () => { + /** The k8s source fixture: the row's own factory, referenced the way COR022 + * refuses. Recognition is by factory name, so the rule fires here exactly as + * it fires on the aws SSM row. */ + function lintContext(code: string): LintContext { + const sourceFile = ts.createSourceFile("infra.ts", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + return { sourceFile, entities: [], filePath: "infra.ts", lexicon: "k8s" }; + } + + it("fires when a ConfigMap derives a value from the receipt", () => { + const diags = cor022ReceiptLeafRule.check( + lintContext(` + import { EffectReceipt, ConfigMap } from "@intentius/chant-lexicon-k8s"; + export const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" }); + export const app = new ConfigMap({ data: { seededBy: seeded.effect } }); + `), + ); + expect(diags).toHaveLength(1); + expect(diags[0].ruleId).toBe("COR022"); + expect(diags[0].severity).toBe("error"); + expect(diags[0].message).toContain('"seeded" is an effect receipt'); + }); + + it("passes when the receipt is only handed to the effect() step whole", () => { + const diags = cor022ReceiptLeafRule.check( + lintContext(` + import { EffectReceipt } from "@intentius/chant-lexicon-k8s"; + export const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "existence" }); + export const migrate = effect(seeded, [shell({ run: "./seed.sh" })]); + `), + ); + expect(diags).toEqual([]); + }); +}); diff --git a/lexicons/k8s/src/effect-receipt-row.ts b/lexicons/k8s/src/effect-receipt-row.ts new file mode 100644 index 000000000..98ac602f1 --- /dev/null +++ b/lexicons/k8s/src/effect-receipt-row.ts @@ -0,0 +1,268 @@ +/** + * The k8s effect-receipt materialization row (#2074, epic #1703): an effect + * receipt stored as a core `ConfigMap`, named + * `chant-receipt...`, holding the expectation under + * `data.expectation`. + * + * Core's `EffectReceipt` factory (#1831) declares a receipt under the `chant` + * pseudo-lexicon, which no serializer claims. This module is the k8s + * materialization, built to the same shape as the aws SSM row (#1835, + * `lexicons/aws/src/effect-receipt-row.ts`): the {@link EffectReceipt} factory + * here produces core's declaration under `lexicon: "k8s"`, so the build + * partitions it to the k8s serializer, #1832's write-exclusion seam withholds + * it from the apply-bound entity set, and the serializer renders it for + * visibility through `SerializeContext.receipts` (see ./serializer.ts). + * + * Where the aws row parks its rendered rows in the CloudFormation template's + * `Metadata` (deliberately outside `Resources`, the section an applier writes + * from), this row parks them in a YAML COMMENT at the end of the manifest + * stream. Kubernetes YAML has no metadata channel outside the documents + * themselves, and a document is exactly what an applier applies: `loadAll` + * yields no object for a comment, so the receipt block is structurally + * unreachable from `applyManifest` while still riding the one build output the + * observation leg is handed. See {@link EFFECT_RECEIPTS_COMMENT_MARKER}. + * + * ## Name and namespace + * + * Path identity (epic decision 4): the ConfigMap name derives from the SAME + * ownership-block fields that stamp markers, `ownership.stack` and an explicit + * `ownership.env`, plus the receipt's `effect`. The separator is `.`, which no + * segment may contain, so `stack=a-b env=c` and `stack=a env=b-c` cannot + * produce the same name. The result is a valid RFC 1123 DNS subdomain, which + * is what a ConfigMap name has to be. + * + * The namespace is the project's `k8s.receipts.namespace`, and `default` when + * the project sets none, which is where every other namespace-less k8s read + * and write in this lexicon already lands. It is never invented from the stack + * or the environment: a namespace chant guessed would be a namespace chant has + * to create, and the receipt row creates nothing but the receipt. + * + * ## Ownership, and why the prune leaves it alone + * + * Every receipt ConfigMap carries chant's ownership marker labels plus + * {@link RECEIPT_LABEL_KEY}. The marker is what makes `chant kube get` and the + * observation classify it as chant's rather than foreign. The receipt label is + * what keeps `delete: "owned-only"` from pruning it: the receipt is owned and + * is never in any apply set (the effect step is its sole writer), which is the + * exact shape the owned-only sweep deletes. `pruneOrphans` + * (./op/activities/kubectl.ts) reports it `retained` instead, the same + * treatment a generated-once Secret gets (./secret-labels.ts) and for the same + * reason: destroying it silently converts at-least-once into a re-run nobody + * asked for, or worse, into never. + * + * Plain store (#1833's COR023): a ConfigMap is the plain half of the + * ConfigMap/Secret pair, and the entityType alone is what the guard reads. A + * receipt value is a witness (an existence marker or a `sha256:` digest), + * never a secret; materializing one into a `K8s::Core::Secret` fails COR023. + */ + +import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable"; +import { + EffectReceipt as CoreEffectReceipt, + EFFECT_RECEIPT_MARKER, + type EffectReceiptFlavor, + type EffectReceiptOptions, +} from "@intentius/chant/effect-receipt"; + +/** The entityType of the k8s materialization row, the real resource kind the + * receipt is stored as, which is what lint's plain-store guard checks. */ +export const K8S_EFFECT_RECEIPT_ENTITY_TYPE = "K8s::Core::ConfigMap"; + +/** apiVersion/kind of the materialized row, for the client reads and writes. */ +export const RECEIPT_CONFIGMAP_REF = { apiVersion: "v1", kind: "ConfigMap" } as const; + +/** + * The line prefix the serializer renders receipt rows behind, and the + * observation leg reads them back from. A YAML comment: an applier's `loadAll` + * produces no document for it, so a receipt can never enter an apply set, and + * `kubectl apply -f` ignores it exactly as it ignores every other comment. + * The remainder of the line is one JSON object keyed by entity name. + */ +export const EFFECT_RECEIPTS_COMMENT_MARKER = "# chant:effect-receipts "; + +/** First segment of every receipt ConfigMap name. */ +export const RECEIPT_NAME_PREFIX = "chant-receipt"; + +/** The `data` key the expectation is stored under. One key, the same single + * value the aws row puts in the SSM parameter's `Value`. */ +export const RECEIPT_DATA_KEY = "expectation"; + +/** The label a live receipt ConfigMap carries, valued with the effect it + * witnesses. Recognition for the observation leg and, more importantly, the + * exclusion the owned-only prune keys on. */ +export const RECEIPT_LABEL_KEY = "chant.intentius.io/effect-receipt"; + +/** Where receipts live when the project names no namespace. */ +export const RECEIPT_DEFAULT_NAMESPACE = "default"; + +/** + * The rendered value of a hash-flavor receipt that still carries reference + * inputs at synthesis. References resolve at plan and at run, never at + * synthesis (epic decision 5), so the row carries this note instead of a + * digest hashed over placeholders. + */ +export const RECEIPT_UNRESOLVED_VALUE_NOTE = + "unresolved at synthesis, reference inputs; the expectation resolves at plan and at run (chant #1703, decision 5)"; + +/** One segment of the receipt name. RFC 1123 DNS label, which is what each + * dot-separated piece of a ConfigMap name has to be, and the `.` separator is + * excluded from it so the identity stays unambiguous. */ +const NAME_SEGMENT = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/; + +/** Kubernetes' own ceiling on a DNS subdomain, which a ConfigMap name is. */ +const MAX_NAME_LENGTH = 253; + +function checkSegment(field: string, value: string): string { + if (value.length > 63 || !NAME_SEGMENT.test(value)) { + throw new Error( + `receipt name: ${field} "${value}" is not a valid DNS-1123 label, ` + + `use lowercase letters, digits and "-" (the segment becomes one dot-separated piece of ` + + `${RECEIPT_NAME_PREFIX}..., which must be a valid ConfigMap name)`, + ); + } + return value; +} + +/** + * The ConfigMap name of one effect's receipt: + * `chant-receipt...`, from the resolved ownership marker + * fields (epic decision 4). The single source of the name identity: the + * serializer's rendered row, the receipt store's reads and writes, and the + * observation leg all call this. + */ +export function receiptConfigMapName(stack: string, env: string, effect: string): string { + const name = `${RECEIPT_NAME_PREFIX}.${checkSegment("stack", stack)}.${checkSegment("env", env)}.${checkSegment("effect", effect)}`; + if (name.length > MAX_NAME_LENGTH) { + throw new Error( + `receipt name: "${name}" is ${name.length} characters, over Kubernetes' ${MAX_NAME_LENGTH}-character ` + + `limit for a ConfigMap name. Shorten the stack, the environment, or the effect.`, + ); + } + return name; +} + +/** The project's receipt namespace: `k8s.receipts.namespace`, else `default`. + * The serializer reads it off `SerializeContext.config` and the store reads it + * off the project config, so both derive one namespace from one setting. */ +export function receiptNamespaceFrom(config: Record | undefined): string { + const k8s = config?.k8s as { receipts?: { namespace?: unknown } } | undefined; + const declared = k8s?.receipts?.namespace; + return typeof declared === "string" && declared.length > 0 ? declared : RECEIPT_DEFAULT_NAMESPACE; +} + +/** Name and namespace of one effect's receipt ConfigMap. */ +export interface ReceiptConfigMapRef { + name: string; + namespace: string; +} + +/** The full address of one effect's receipt ConfigMap. */ +export function receiptConfigMapRef( + stack: string, + env: string, + effect: string, + namespace: string = RECEIPT_DEFAULT_NAMESPACE, +): ReceiptConfigMapRef { + return { name: receiptConfigMapName(stack, env, effect), namespace }; +} + +/** True when a live object's labels mark it a chant effect receipt. Any + * non-empty value counts: the label's presence is the claim, and a sweep must + * err on the side of keeping. */ +export function isEffectReceiptObject(labels: Record | undefined): boolean { + const value = labels?.[RECEIPT_LABEL_KEY]; + return typeof value === "string" && value.length > 0; +} + +/** One rendered receipt row, as the comment block carries it. */ +export interface RenderedReceiptRow { + kind: typeof RECEIPT_CONFIGMAP_REF.kind; + namespace: string; + name: string; + data: Record; +} + +/** + * Render the receipt block the serializer appends to the manifest stream. + * Deterministic: one line, entity names sorted. + */ +export function renderReceiptComment(rows: Record): string { + const sorted: Record = {}; + for (const name of Object.keys(rows).sort()) sorted[name] = rows[name]; + return `${EFFECT_RECEIPTS_COMMENT_MARKER}${JSON.stringify(sorted)}`; +} + +/** + * Read the receipt rows back out of a build output. Returns an empty record + * for an output that carries no block, which is every project that declares no + * receipt. Never throws: an unparseable block is no block, and the observation + * leg reports the receipts it could not address as absent rather than + * inventing one. + */ +export function parseReceiptComment(buildOutput: string): Record { + const start = buildOutput.lastIndexOf(EFFECT_RECEIPTS_COMMENT_MARKER); + if (start < 0) return {}; + const end = buildOutput.indexOf("\n", start); + const line = buildOutput.slice(start + EFFECT_RECEIPTS_COMMENT_MARKER.length, end < 0 ? undefined : end); + try { + const parsed = JSON.parse(line) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + return parsed as Record; + } catch { + return {}; + } +} + +/** + * A k8s-materialized effect receipt: core's declaration shape (so + * `isEffectReceipt`, `effect(...)`, lint, and the plan engine all recognize it + * through the marker), under the k8s lexicon and the real resource kind. + */ +export interface K8sEffectReceiptDeclaration extends Declarable { + readonly [EFFECT_RECEIPT_MARKER]: true; + readonly lexicon: "k8s"; + readonly entityType: typeof K8S_EFFECT_RECEIPT_ENTITY_TYPE; + /** The receipt's own name (the export-level identity of the witness). */ + readonly name: string; + /** The effect this receipt witnesses, the name's final segment. */ + readonly effect: string; + /** How the receipt is compared: mere presence, or a digest of the inputs. */ + readonly flavor: EffectReceiptFlavor; + /** The effect's inputs as recorded at synthesis (references as placeholders). */ + readonly inputs: Readonly>; +} + +/** + * Declare a k8s-materialized effect receipt. Same signature and semantics as + * core's `EffectReceipt` (#1831), whose factory validates and freezes the + * options, but the declaration lands in the k8s partition, so the k8s + * serializer renders the ConfigMap row and the receipt store + * (./receipt-store.ts) is its writer. Pass the returned const straight to the + * `effect(...)` op step. + */ +export function EffectReceipt(name: string, options: EffectReceiptOptions): K8sEffectReceiptDeclaration { + // Fail at declaration, not at serialize: the effect is the name's final + // segment, and a name that cannot become a segment has no receipt address. + if (typeof options?.effect === "string" && options.effect.length > 0) { + checkSegment("effect", options.effect); + } + const core = CoreEffectReceipt(name, options); + const decl: K8sEffectReceiptDeclaration = { + [DECLARABLE_MARKER]: true, + [EFFECT_RECEIPT_MARKER]: true, + lexicon: "k8s", + entityType: K8S_EFFECT_RECEIPT_ENTITY_TYPE, + name: core.name, + effect: core.effect, + flavor: core.flavor, + // The same frozen structure the core factory built: intrinsic inputs stay + // live so discovery can stamp logical names onto attr-refs. + inputs: core.inputs, + }; + // Declared fields immutable, object extensible for discovery's own + // symbol-keyed metadata, the same lock the core factory applies. + for (const key of Object.keys(decl)) { + Object.defineProperty(decl, key, { writable: false, configurable: false }); + } + return decl; +} diff --git a/lexicons/k8s/src/index.ts b/lexicons/k8s/src/index.ts index 1b6a86784..273e0e029 100644 --- a/lexicons/k8s/src/index.ts +++ b/lexicons/k8s/src/index.ts @@ -27,6 +27,28 @@ export { DEFAULT_LABELS_MARKER, DEFAULT_ANNOTATIONS_MARKER } from "./default-lab // Variables / label constants export { K8sLabels, K8sAnnotations } from "./variables"; +// Effect receipts (#2074, epic #1703): the k8s materialization row, meaning the +// factory a project declares (core's shape under the k8s lexicon, carrying +// the marker) and the name/label constants. The ConfigMap-backed store itself +// lives at the `/receipt-store` subpath, off the build path (#1074). +export { + EffectReceipt, + receiptConfigMapName, + receiptConfigMapRef, + receiptNamespaceFrom, + isEffectReceiptObject, + K8S_EFFECT_RECEIPT_ENTITY_TYPE, + EFFECT_RECEIPTS_COMMENT_MARKER, + RECEIPT_NAME_PREFIX, + RECEIPT_DATA_KEY, + RECEIPT_LABEL_KEY, + RECEIPT_DEFAULT_NAMESPACE, + RECEIPT_UNRESOLVED_VALUE_NOTE, + parseReceiptComment, + renderReceiptComment, +} from "./effect-receipt-row"; +export type { K8sEffectReceiptDeclaration, ReceiptConfigMapRef, RenderedReceiptRow } from "./effect-receipt-row"; + // Generated-once secret marker (#1830) — constants only; the store adapter // itself lives at the `/secret-store` subpath, off the build path (#1074). export { GENERATED_ONCE_LABEL_KEY, GENERATED_ONCE_LABEL_VALUE, isGeneratedOnce } from "./secret-labels"; diff --git a/lexicons/k8s/src/op/activities/index.ts b/lexicons/k8s/src/op/activities/index.ts index a5be7e36e..f38faa7b6 100644 --- a/lexicons/k8s/src/op/activities/index.ts +++ b/lexicons/k8s/src/op/activities/index.ts @@ -63,3 +63,17 @@ export type { // truth — see `lexicons/k8s/src/config.ts` for the config shape. export { resolveClusterTarget, ClusterBindingMismatchError } from "@intentius/chant/kubectl-context"; export type { ResolvedClusterTarget, K8sClusterProfile, K8sConfigShape } from "@intentius/chant/kubectl-context"; + +// Effect-receipt activities (#2074): core's receipt seam (#1834) bound to +// this lexicon's ConfigMap-backed store (../../receipt-store.ts), the same +// way the aws lexicon binds its SSM store (#1835). Re-exported individually: +// `receiptRead`/`receiptWrite` serve the `effect()` step's +// read-compare-run-write, `receiptStaleness` serves WatchOp's read-only +// staleness reporting. +import { receiptActivities } from "@intentius/chant/op/receipt-store"; +import { k8sReceiptStore } from "../../receipt-store"; + +const boundReceiptActivities = receiptActivities(k8sReceiptStore()); +export const receiptRead = boundReceiptActivities.receiptRead; +export const receiptWrite = boundReceiptActivities.receiptWrite; +export const receiptStaleness = boundReceiptActivities.receiptStaleness; diff --git a/lexicons/k8s/src/op/activities/kubectl.test.ts b/lexicons/k8s/src/op/activities/kubectl.test.ts index 80c74a49a..406b50d5a 100644 --- a/lexicons/k8s/src/op/activities/kubectl.test.ts +++ b/lexicons/k8s/src/op/activities/kubectl.test.ts @@ -494,6 +494,44 @@ describe("the ownership-scoped prune (chant #1075)", () => { expect(deletes).toEqual(["/api/v1/namespaces/prod/secrets/stale-config"]); }); + test("an effect receipt ConfigMap is excluded from the prunable set and reported retained (#2074)", async () => { + const cluster = fakeCluster({ + respond: echoApplies, + objects: { + [objectKey("apps/v1", "Deployment", "web", "prod")]: ownedObject("apps/v1", "Deployment", "web", "prod"), + [objectKey("v1", "ConfigMap", "chant-receipt.demo.prod.db-seed", "prod")]: ownedObject( + "v1", + "ConfigMap", + "chant-receipt.demo.prod.db-seed", + "prod", + { + metadata: { + labels: { + "app.kubernetes.io/managed-by": "chant", + "chant.intentius.io/effect-receipt": "db-seed", + }, + }, + }, + ), + [objectKey("v1", "ConfigMap", "stale-config", "prod")]: ownedObject("v1", "ConfigMap", "stale-config", "prod"), + }, + }); + const result = await applyManifest( + { manifest: manifest(), deleteMode: "owned-only" }, + undefined, + cluster.connector, + ); + + expect(result.retained).toEqual([ + { apiVersion: "v1", kind: "ConfigMap", name: "chant-receipt.demo.prod.db-seed", namespace: "prod" }, + ]); + expect(result.pruned).toEqual([ + { apiVersion: "v1", kind: "ConfigMap", name: "stale-config", namespace: "prod" }, + ]); + const deletes = cluster.layer.requests.filter((r) => r.method === "DELETE").map((r) => r.path); + expect(deletes).toEqual(["/api/v1/namespaces/prod/configmaps/stale-config"]); + }); + test("an object without chant's marker is never a candidate", async () => { const cluster = clusterWithOrphans(); await applyManifest({ manifest: manifest(), deleteMode: "owned-only" }, undefined, cluster.connector); diff --git a/lexicons/k8s/src/op/activities/kubectl.ts b/lexicons/k8s/src/op/activities/kubectl.ts index 6c48c60cb..7e19ae238 100644 --- a/lexicons/k8s/src/op/activities/kubectl.ts +++ b/lexicons/k8s/src/op/activities/kubectl.ts @@ -45,6 +45,7 @@ import { defaultK8sConnector, type K8sConnector } from "../../api/connect"; import { operationFor } from "../../api/operation-surface"; import { DEFAULT_IMPORT_TYPES } from "../../api/sweep-types"; import { isGeneratedOnce } from "../../secret-labels"; +import { isEffectReceiptObject } from "../../effect-receipt-row"; /** * How the apply treats chant-owned objects that are no longer declared. The @@ -503,6 +504,20 @@ async function pruneOrphans( ); continue; } + // An effect receipt never enters the prunable set either (#2074, epic + // #1703 decision 3). It is chant-owned and is never in ANY apply set, + // the `effect()` step being its sole writer, which is exactly the shape + // this sweep deletes. Pruning one would tell the next plan that an effect + // never ran and re-fire it, or worse, be re-stamped by nothing and leave + // the estate re-running a migration on every apply. + if (target.kind === "ConfigMap" && isEffectReceiptObject(item.metadata?.labels)) { + retained.push(ref); + console.log( + `${ref.apiVersion} ${ref.kind}/${ref.name} retained (effect receipt: owned and never declared ` + + `in an apply set, but never pruned; the effect() step is its sole writer)`, + ); + continue; + } candidates.push(ref); } diff --git a/lexicons/k8s/src/receipt-store.test.ts b/lexicons/k8s/src/receipt-store.test.ts new file mode 100644 index 000000000..dde5bd2f8 --- /dev/null +++ b/lexicons/k8s/src/receipt-store.test.ts @@ -0,0 +1,380 @@ +/** + * The k8s ConfigMap receipt row, end to end (#2074, epic #1703). + * + * Every leg runs against ./api/fake-cluster.ts, a real + * `@intentius/chant-k8s-client` with only the HTTP send replaced, so no + * ambient kubeconfig is read and no cluster is contacted. The three legs the + * row has to close: + * + * 1. the `effect()` step materializes the receipt (the store's `write`); + * 2. the k8s observation reads it back (`describeResources` and the deep + * read), with the stored value on `attributes.value` where core's + * `readReceiptValue` looks for it; + * 3. WatchOp's staleness phase (`receiptStaleness`, #1834) fires on it. + * + * Plus the two things a receipt must NOT do: show up as property drift or as + * an unclaimed field, and be swept by the owned-only prune. + */ + +import { describe, test, expect } from "vitest"; +import type { K8sObject } from "@intentius/chant-k8s-client"; + +const { k8sReceiptStore, observeReceiptRows, observeReceiptRowsDeep, receiptRowsFor, receiptValueOf } = + await import("./receipt-store"); +const { describeResources } = await import("./describe-resources"); +const { observeResourcesDeepK8s } = await import("./deep-observe"); +const { k8sDeepNormalizationHooks } = await import("./deep-observe-hooks"); +const { fakeCluster, objectKey } = await import("./api/fake-cluster"); +const { + EffectReceipt, + receiptConfigMapRef, + renderReceiptComment, + K8S_EFFECT_RECEIPT_ENTITY_TYPE, + RECEIPT_DATA_KEY, + RECEIPT_LABEL_KEY, +} = await import("./effect-receipt-row"); +const { receiptActivities, receiptCheckInput } = await import("@intentius/chant/op/receipt-store"); +const { receiptExpectation, EXISTENCE_EXPECTATION } = await import("@intentius/chant/effect-receipt"); +const { readReceiptValue, planReceipts, observedValueResolver } = await import( + "@intentius/chant/lifecycle/receipt-plan" +); +const { diffDeepObservation } = await import("@intentius/chant/lifecycle/deep-observe"); +const { normalizeDeepObservation } = await import("@intentius/chant/deep-observation"); +const { statusBody } = await import("@intentius/chant-k8s-client/testing"); + +const IDENTITY = { stack: "demo", environment: "dev", namespace: "default" } as const; +const REF = receiptConfigMapRef("demo", "dev", "db-seed"); + +const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { version: "0042" } }); +const EXPECTED = receiptExpectation(seeded); + +/** The build output the serializer produces for a project declaring `seeded`. */ +function buildOutput(namespace = "default"): string { + return `${renderReceiptComment({ + seeded: { + kind: "ConfigMap", + namespace, + name: REF.name, + data: { [RECEIPT_DATA_KEY]: EXPECTED }, + }, + })}\n`; +} + +/** A live receipt ConfigMap holding `value`. */ +function liveReceipt(value: string, namespace = "default"): K8sObject { + return { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name: REF.name, + namespace, + uid: "uid-receipt", + resourceVersion: "3", + labels: { + "app.kubernetes.io/managed-by": "chant", + "chant.intentius.io/stack": "demo", + "chant.intentius.io/env": "dev", + [RECEIPT_LABEL_KEY]: "db-seed", + }, + }, + data: { [RECEIPT_DATA_KEY]: value }, + } as K8sObject; +} + +/** Echo an applied object back, the way an API server does. */ +const echoApplies = (req: { method: string; body?: unknown }) => + req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined; + +const store = (cluster: { connector: unknown }, namespace = "default") => + k8sReceiptStore({ + ...IDENTITY, + namespace, + connect: cluster.connector as never, + }); + +// ── Leg 1: the effect step materializes the receipt ───────────────────────── + +describe("k8sReceiptStore: the effect() step's sole write", () => { + test("read answers undefined for a receipt that is not there", async () => { + const cluster = fakeCluster(); + expect(await store(cluster).read({ name: "seeded", effect: "db-seed", flavor: "hash", inputs: {} })).toBeUndefined(); + }); + + test("write applies a ConfigMap at the derived address, ownership-marked and receipt-labelled", async () => { + const cluster = fakeCluster({ respond: echoApplies }); + await store(cluster).write({ name: "seeded", effect: "db-seed", flavor: "hash", inputs: {} }, EXPECTED); + + const patch = cluster.layer.requests.find((r) => r.method === "PATCH"); + expect(patch?.path).toBe(`/api/v1/namespaces/default/configmaps/${REF.name}`); + const applied = JSON.parse(String(patch?.body)) as K8sObject; + expect(applied.kind).toBe("ConfigMap"); + expect(applied.metadata?.name).toBe("chant-receipt.demo.dev.db-seed"); + expect(applied.metadata?.namespace).toBe("default"); + expect((applied as { data?: Record }).data).toEqual({ [RECEIPT_DATA_KEY]: EXPECTED }); + expect(applied.metadata?.labels).toMatchObject({ + "app.kubernetes.io/managed-by": "chant", + "chant.intentius.io/stack": "demo", + "chant.intentius.io/env": "dev", + [RECEIPT_LABEL_KEY]: "db-seed", + }); + expect(patch?.path).not.toContain("secrets"); + }); + + test("write lands in the configured receipt namespace", async () => { + const cluster = fakeCluster({ respond: echoApplies }); + await store(cluster, "chant-system").write( + { name: "seeded", effect: "db-seed", flavor: "existence", inputs: {} }, + EXISTENCE_EXPECTATION, + ); + const patch = cluster.layer.requests.find((r) => r.method === "PATCH"); + expect(patch?.path).toBe(`/api/v1/namespaces/chant-system/configmaps/${REF.name}`); + }); + + test("read gets back exactly what write stored, the round trip the effect step compares on", async () => { + const cluster = fakeCluster({ + objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt(EXPECTED) }, + }); + const activities = receiptActivities(store(cluster)); + const result = await activities.receiptRead(receiptCheckInput(seeded)); + expect(result.current).toBe(EXPECTED); + expect(result.expectation).toBe(EXPECTED); + expect(result.applied).toBe(true); + }); + + test("receiptValueOf reads only the expectation key, and nothing from an unrelated ConfigMap", () => { + expect(receiptValueOf(liveReceipt("x"))).toBe("x"); + expect(receiptValueOf({ apiVersion: "v1", kind: "ConfigMap", data: { other: "x" } } as K8sObject)).toBeUndefined(); + expect(receiptValueOf(undefined)).toBeUndefined(); + }); +}); + +// ── Leg 2: the observation reads it back ──────────────────────────────────── + +describe("the k8s observation reads the receipt back", () => { + const entities = new Map([["seeded", { entityType: K8S_EFFECT_RECEIPT_ENTITY_TYPE, props: {} }]]); + + test("describeResources maps the stored value onto attributes.value, where the plan reads it", async () => { + const cluster = fakeCluster({ + objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt(EXPECTED) }, + }); + const result = await describeResources( + { environment: "dev", buildOutput: buildOutput(), entityNames: ["seeded"], entities }, + cluster.connector, + ); + + expect(result.resources.seeded.type).toBe(K8S_EFFECT_RECEIPT_ENTITY_TYPE); + expect(result.resources.seeded.status).toBe("EXTERNAL"); + expect(result.resources.seeded.ownership).toBe("owned"); + expect(readReceiptValue(result.resources.seeded.attributes)).toBe(EXPECTED); + expect(result.unobserved?.seeded).toBeUndefined(); + }); + + test("a receipt that is not there is a real absence, in neither map", async () => { + const cluster = fakeCluster(); + const result = await describeResources( + { environment: "dev", buildOutput: buildOutput(), entityNames: ["seeded"], entities }, + cluster.connector, + ); + expect(result.resources.seeded).toBeUndefined(); + expect(result.unobserved?.seeded).toBeUndefined(); + }); + + test("a failed read is a hole with a reason, never 'the effect never ran'", async () => { + const cluster = fakeCluster({ + respond: (req) => + req.path.includes("/configmaps/") + ? { status: 403, body: statusBody(403, "Forbidden", "configmaps is forbidden") } + : undefined, + }); + const result = await describeResources( + { environment: "dev", buildOutput: buildOutput(), entityNames: ["seeded"], entities }, + cluster.connector, + ); + expect(result.resources.seeded).toBeUndefined(); + expect(result.unobserved?.seeded.reason).toBeDefined(); + expect(result.unobserved?.seeded.detail).toContain(REF.name); + }); + + test("without a receipt block in the build output nothing is read, and the entity is not invented", async () => { + const cluster = fakeCluster({ + objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt(EXPECTED) }, + }); + expect(receiptRowsFor(["seeded"], "")).toEqual(new Map()); + const result = await describeResources( + { environment: "dev", buildOutput: "", entityNames: ["seeded"], entities }, + cluster.connector, + ); + expect(result.resources.seeded).toBeUndefined(); + }); + + test("the plan turns the reading into an effect row when stale and a noop when applied (#1832)", async () => { + const cluster = fakeCluster({ + objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt("sha256:something-else") }, + }); + const observed = await describeResources( + { environment: "dev", buildOutput: buildOutput(), entityNames: ["seeded"], entities }, + cluster.connector, + ); + const reading = { + observed: true, + present: true, + value: readReceiptValue(observed.resources.seeded.attributes), + lexicon: "k8s", + }; + const stale = planReceipts( + new Map([["seeded", seeded]]), + new Map([["seeded", reading]]), + observedValueResolver(observed.resources), + ); + expect(stale).toHaveLength(1); + expect(stale[0].action).toBe("effect"); + + const applied = planReceipts( + new Map([["seeded", seeded]]), + new Map([["seeded", { ...reading, value: EXPECTED }]]), + observedValueResolver(observed.resources), + ); + expect(applied[0].action).toBe("noop"); + }); +}); + +// ── The receipt is neither drift nor an unclaimed field ───────────────────── + +describe("a receipt is never drift and never an unclaimed field (#2160)", () => { + const entities = new Map([["seeded", { entityType: K8S_EFFECT_RECEIPT_ENTITY_TYPE, props: {} }]]); + + test("the deep read reports the receipt observed, with no property paths at all", async () => { + const cluster = fakeCluster({ + objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt(EXPECTED) }, + }); + const deep = await observeResourcesDeepK8s( + { environment: "dev", buildOutput: buildOutput(), entityNames: ["seeded"], entities }, + cluster.connector, + ); + expect(deep.resources.seeded.properties).toEqual({}); + expect(deep.resources.seeded.physicalId).toBe("uid-receipt"); + expect(deep.unobserved?.seeded).toBeUndefined(); + }); + + test("the deep diff proposes nothing for it: no field drift, no unclaimed path", async () => { + const cluster = fakeCluster({ + objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt("sha256:stale") }, + }); + const deep = await observeResourcesDeepK8s( + { environment: "dev", buildOutput: buildOutput(), entityNames: ["seeded"], entities }, + cluster.connector, + ); + const diff = diffDeepObservation(entities, normalizeDeepObservation(deep), k8sDeepNormalizationHooks); + expect(diff.drifted).toEqual([]); + expect(diff.unclaimed).toEqual([]); + expect(diff.held).toEqual([]); + expect(diff.unobserved).toEqual([]); + expect(diff.undeclaredEntities).toEqual([]); + expect(diff.unchanged).toEqual(["seeded"]); + }); + + test("the receipt ConfigMap is not swept into any other entity's observation", async () => { + const cluster = fakeCluster({ + objects: { + [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt(EXPECTED), + [objectKey("v1", "ConfigMap", "app-config", "default")]: { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: "app-config", namespace: "default", uid: "uid-app" }, + data: { a: "1" }, + } as K8sObject, + }, + }); + const result = await describeResources( + { + environment: "dev", + buildOutput: buildOutput(), + entityNames: ["seeded", "appConfig"], + entities: new Map([ + ["seeded", { entityType: K8S_EFFECT_RECEIPT_ENTITY_TYPE, props: {} }], + [ + "appConfig", + { entityType: "K8s::Core::ConfigMap", props: { metadata: { name: "app-config", namespace: "default" } } }, + ], + ]), + }, + cluster.connector, + ); + expect(Object.keys(result.resources).sort()).toEqual(["appConfig", "seeded"]); + expect(result.resources.appConfig.physicalId).toBe("uid-app"); + }); +}); + +// ── Leg 3: WatchOp staleness ──────────────────────────────────────────────── + +describe("WatchOp stale-receipt reporting over the k8s row (#1834)", () => { + const inputs = [receiptCheckInput(seeded)]; + + test("an absent receipt is reported stale, and nothing is written", async () => { + const cluster = fakeCluster(); + const { receiptStaleness } = receiptActivities(store(cluster)); + const result = await receiptStaleness({ receipts: inputs }); + expect(result.stale).toBe(true); + expect(result.findings).toEqual([ + { receipt: "seeded", effect: "db-seed", kind: "absent", expected: EXPECTED }, + ]); + expect(cluster.layer.requests.some((r) => r.method === "PATCH" || r.method === "DELETE")).toBe(false); + }); + + test("a receipt holding a different value is reported stale with both values", async () => { + const cluster = fakeCluster({ + objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt("sha256:old") }, + }); + const { receiptStaleness } = receiptActivities(store(cluster)); + const result = await receiptStaleness({ receipts: inputs }); + expect(result.stale).toBe(true); + expect(result.findings[0]).toEqual({ + receipt: "seeded", + effect: "db-seed", + kind: "differs", + expected: EXPECTED, + current: "sha256:old", + }); + }); + + test("a receipt that matches is not stale", async () => { + const cluster = fakeCluster({ + objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt(EXPECTED) }, + }); + const { receiptStaleness } = receiptActivities(store(cluster)); + expect(await receiptStaleness({ receipts: inputs })).toEqual({ stale: false, findings: [] }); + }); + + test("the activities barrel exports the three receipt activities by the names the registry resolves", async () => { + const barrel = await import("./op/activities/index"); + expect(typeof barrel.receiptRead).toBe("function"); + expect(typeof barrel.receiptWrite).toBe("function"); + expect(typeof barrel.receiptStaleness).toBe("function"); + }); +}); + +// ── The observation leg's own contract ────────────────────────────────────── + +describe("observeReceiptRows", () => { + test("reads only the entities the caller asked about", () => { + const rows = receiptRowsFor(["seeded"], `${buildOutput()}`); + expect([...rows.keys()]).toEqual(["seeded"]); + expect(receiptRowsFor(["other"], buildOutput()).size).toBe(0); + }); + + test("the deep leg reports the same holes the thin leg does", async () => { + const cluster = fakeCluster({ + respond: (req) => + req.path.includes("/configmaps/") + ? { status: 500, body: statusBody(500, "InternalError", "boom") } + : undefined, + }); + const { client } = await cluster.connector({ environment: "dev" }); + const rows = receiptRowsFor(["seeded"], buildOutput()); + const thin = await observeReceiptRows(client, rows); + const deep = await observeReceiptRowsDeep(client, rows); + expect(Object.keys(thin.unobserved)).toEqual(["seeded"]); + expect(Object.keys(deep.unobserved)).toEqual(["seeded"]); + expect(deep.resources.seeded).toBeUndefined(); + }); +}); diff --git a/lexicons/k8s/src/receipt-store.ts b/lexicons/k8s/src/receipt-store.ts new file mode 100644 index 000000000..a0a1d6c07 --- /dev/null +++ b/lexicons/k8s/src/receipt-store.ts @@ -0,0 +1,290 @@ +/** + * The k8s `ReceiptStore` (#2074, epic #1703): core's injectable receipt seam + * (#1834, `@intentius/chant/op/receipt-store`) implemented over a core + * `ConfigMap`, at the address ./effect-receipt-row.ts derives from the + * ownership marker fields, plus the plan-side live read of the same rows. + * + * The transport is the lexicon's own typed client (./api/connect.ts), so a + * receipt read and write take the same environment-to-cluster binding + * (#1100/#1155), the same credential policy and the same field manager as + * every other k8s mutation. No `kubectl` binary is involved. + * + * Write discipline (epic decision 3): `write` exists for the `effect()` step + * alone, because the step's read-compare-run-write is the only path that reaches it, + * on success, last. It is a server-side apply as `chant:`, stamping the + * ownership marker labels and {@link RECEIPT_LABEL_KEY}, so a later write + * updates the value in place and the owned-only prune retains rather than + * deletes it (./op/activities/kubectl.ts). + * + * Identity: the ConfigMap name needs `` and ``, which the activity + * args deliberately do not carry (the `EffectReceiptRef` is + * identity-of-the-effect, not identity-of-the-deployment). The store resolves + * them once, lazily, at first use: an explicit option, else `CHANT_ENV` (what + * `chant run --env` sets) and the project's `ownership` block, the same fields + * that stamp markers (epic decision 4). Nothing resolving is an error, never a + * guessed segment. + * + * Not exported from the package entry point. Like ./secret-store.ts and + * ./teardown.ts, this module names the API client, which must stay off the + * build path (chant #1074, examples/k8s-client-boundary.test.ts). Consumers + * reach it by subpath: `@intentius/chant-lexicon-k8s/receipt-store`. + */ + +import { loadChantConfigUpward, resolveOwnershipStack } from "@intentius/chant/config"; +import { + LABEL_OWNERSHIP_KEYS, + ownershipEntries, + classifyOwnership, + readOwnership, +} from "@intentius/chant/ownership"; +import type { EffectReceiptRef, ReceiptStore } from "@intentius/chant/op/receipt-store"; +import type { ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon"; +import type { DeepResourceObservation } from "@intentius/chant/deep-observation"; +import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client"; +import { defaultK8sConnector, type K8sConnector } from "./api/connect"; +import { classifyApiFailure } from "./api/classify"; +import { + K8S_EFFECT_RECEIPT_ENTITY_TYPE, + RECEIPT_CONFIGMAP_REF, + RECEIPT_DATA_KEY, + RECEIPT_LABEL_KEY, + parseReceiptComment, + receiptConfigMapRef, + receiptNamespaceFrom, + type RenderedReceiptRow, +} from "./effect-receipt-row"; + +/** Options for {@link k8sReceiptStore}. All optional: the default store reads + * its identity and its namespace from the project, and its cluster from the + * environment binding. */ +export interface K8sReceiptStoreOptions { + /** The name's `` segment. Omitted, the project's `ownership.stack` + * (chant.config.ts, found upward from `cwd`) answers. */ + stack?: string; + /** The name's `` segment, explicit by decision 4. Omitted, `CHANT_ENV` + * (set by `chant run --env`) answers, then a literal `ownership.env`. */ + environment?: string; + /** Namespace the receipts live in. Omitted, `k8s.receipts.namespace` + * answers, then `default`. */ + namespace?: string; + /** Where to look for chant.config.ts. Defaults to the working directory. */ + cwd?: string; + /** Explicit kubectl context, for a caller that already resolved one. */ + context?: string; + /** The connector to build a client with. Test seam. */ + connect?: K8sConnector; + /** Environment record the identity fallback reads. Defaults to `process.env`. */ + env?: Record; +} + +/** The resolved name identity. */ +interface ReceiptIdentity { + stack: string; + env: string; + namespace: string; +} + +async function resolveIdentity(options: K8sReceiptStoreOptions): Promise { + const processEnv = options.env ?? process.env; + let stack = options.stack; + let env = options.environment ?? processEnv.CHANT_ENV; + let namespace = options.namespace; + if (!stack || !env || !namespace) { + let config; + try { + config = (await loadChantConfigUpward(options.cwd ?? process.cwd())).config; + } catch { + config = undefined; + } + if (config) { + stack = stack ?? resolveOwnershipStack(config); + // Only a literal env can answer here: a `{ param }` reference resolves + // per build, and an op run has no build parameters, and `--env` does. + const configEnv = config.ownership?.env; + env = env ?? (typeof configEnv === "string" ? configEnv : undefined); + namespace = namespace ?? receiptNamespaceFrom(config as unknown as Record); + } + } + if (!stack) { + throw new Error( + "k8s receipt store: no stack identity. The receipt ConfigMap is named " + + "chant-receipt..., derived from the same ownership fields that stamp " + + "markers (chant #1703, decision 4). Set ownership: { stack } in chant.config.ts.", + ); + } + if (!env) { + throw new Error( + "k8s receipt store: no environment resolved. The receipt name's segment is explicit " + + "(chant #1703, decision 4). Run with --env , set CHANT_ENV, or set a literal " + + "ownership.env in chant.config.ts.", + ); + } + return { stack, env, namespace: namespace ?? receiptNamespaceFrom(undefined) }; +} + +/** The stored expectation on a live receipt ConfigMap, or undefined when the + * object holds none. */ +export function receiptValueOf(object: K8sObject | undefined): string | undefined { + const data = (object as { data?: Record } | undefined)?.data; + const value = data?.[RECEIPT_DATA_KEY]; + return typeof value === "string" ? value : undefined; +} + +/** + * The `ReceiptStore` over ConfigMaps. Bind it once in the op activities barrel + * as `receiptActivities(k8sReceiptStore())`, and the registry resolves + * `receiptRead`/`receiptWrite`/`receiptStaleness` by name, exactly like + * `ensureSecret` (#1830). Identity and cluster resolve lazily at first use, so + * module load never reads the project or connects to anything. + */ +export function k8sReceiptStore(options: K8sReceiptStoreOptions = {}): ReceiptStore { + let identity: Promise | undefined; + const identityOf = () => (identity ??= resolveIdentity(options)); + + let pending: Promise | undefined; + const clientOf = (): Promise => + (pending ??= (options.connect ?? defaultK8sConnector)({ + ...(options.environment !== undefined ? { environment: options.environment } : {}), + ...(options.context !== undefined ? { context: options.context } : {}), + ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), + }).then((connected) => connected.client)); + + return { + async read(receipt: EffectReceiptRef): Promise { + const { stack, env, namespace } = await identityOf(); + const ref = receiptConfigMapRef(stack, env, receipt.effect, namespace); + const client = await clientOf(); + const live = await client.readIfPresent({ ...RECEIPT_CONFIGMAP_REF, ...ref }); + return receiptValueOf(live); + }, + + async write(receipt: EffectReceiptRef, expectation: string): Promise { + const { stack, env, namespace } = await identityOf(); + const ref = receiptConfigMapRef(stack, env, receipt.effect, namespace); + const client = await clientOf(); + const configMap: K8sObject = { + ...RECEIPT_CONFIGMAP_REF, + metadata: { + ...ref, + labels: { + ...ownershipEntries(LABEL_OWNERSHIP_KEYS, { stack, env }), + [RECEIPT_LABEL_KEY]: receipt.effect, + }, + }, + data: { [RECEIPT_DATA_KEY]: expectation }, + } as K8sObject; + await client.apply(configMap, { fieldManager: `chant:${stack}` }); + }, + }; +} + +/** What the observation leg learned about the declared receipt rows. */ +export interface ReceiptRowObservation { + resources: Record; + unobserved: Record; +} + +/** The receipt rows a build output carries, keyed by entity name. Only the + * entities this observation was asked about. */ +export function receiptRowsFor( + entityNames: readonly string[], + buildOutput: string | undefined, +): Map { + const rows = parseReceiptComment(buildOutput ?? ""); + const wanted = new Set(entityNames); + const out = new Map(); + for (const [name, row] of Object.entries(rows)) { + if (!wanted.has(name)) continue; + if (typeof row?.name !== "string" || typeof row?.namespace !== "string") continue; + out.set(name, row); + } + return out; +} + +/** + * The plan-side live read of the receipt rows (#2074's observation leg). + * + * A receipt is not a document the applier ever wrote (#1832), and it carries + * no `props` on the declared side, so the generic declared-entity sweep in + * ./describe-resources.ts has neither a `metadata.name` to query by nor an + * honest verdict to give. The serializer rendered each receipt's derived + * ConfigMap address into the build output's receipt comment, so this leg reads + * the addresses back from there, one derivation, decision 4, and asks the + * cluster for each. + * + * Present maps the stored value onto `attributes.value` (core's + * `RECEIPT_VALUE_ATTRIBUTE`); a genuine 404 is a real absence and stays one; a + * failed read is an `unobserved` hole, never a wrong answer: a receipt nobody + * could read must not arrive downstream as "the effect never ran". + */ +export async function observeReceiptRows( + client: K8sClient, + rows: ReadonlyMap, +): Promise { + const out: ReceiptRowObservation = { resources: {}, unobserved: {} }; + await client.concurrently([...rows], async ([entityName, row]) => { + try { + const live = await client.read({ + ...RECEIPT_CONFIGMAP_REF, + name: row.name, + namespace: row.namespace, + }); + out.resources[entityName] = { + type: K8S_EFFECT_RECEIPT_ENTITY_TYPE, + physicalId: live.metadata?.uid, + // Live outside anything the applier wrote, by design. The same word + // the aws row's observation uses for a receipt parameter (#1835). + status: "EXTERNAL", + ownership: classifyOwnership(live.metadata?.labels, LABEL_OWNERSHIP_KEYS), + marker: readOwnership(live.metadata?.labels, LABEL_OWNERSHIP_KEYS), + attributes: { + namespace: row.namespace, + // Core's RECEIPT_VALUE_ATTRIBUTE, which is what `readReceiptValue` reads. + value: receiptValueOf(live) ?? "", + }, + }; + } catch (err) { + const outcome = classifyApiFailure(err); + if (outcome.kind === "unobserved") { + out.unobserved[entityName] = { + type: K8S_EFFECT_RECEIPT_ENTITY_TYPE, + reason: outcome.reason, + detail: `reading receipt ConfigMap ${row.namespace}/${row.name}: ${outcome.detail}`, + }; + } + // `absent` records nothing: in neither map is how the contract spells + // "asked, and it is not there", which is what the plan reads as "the + // effect has not fired for these inputs". + } + }); + return out; +} + +/** + * The deep read's answer for the receipt rows. + * + * A receipt is read back here for the same reason the thin path reads it: a + * declared entity nobody looked at is a hole, and a hole in the deep read is + * noise on every `lifecycle diff --live --deep` a project with receipts runs. + * What it deliberately contributes is an EMPTY property tree: the declaration + * has no `props`, so every live path would land outside the claimed-field set + * (`@intentius/chant/claimed-fields`) and be reported unclaimed, and the + * receipt's stored value is not drift on any reading, because a stale receipt is an + * `effect` row from `planReceipts` (#1832), never an update. Presence and the + * uid are the whole of what the deep read has to say about a receipt. + */ +export async function observeReceiptRowsDeep( + client: K8sClient, + rows: ReadonlyMap, +): Promise<{ resources: Record; unobserved: Record }> { + const thin = await observeReceiptRows(client, rows); + const resources: Record = {}; + for (const [name, meta] of Object.entries(thin.resources)) { + resources[name] = { + type: K8S_EFFECT_RECEIPT_ENTITY_TYPE, + ...(meta.physicalId ? { physicalId: meta.physicalId } : {}), + properties: {}, + }; + } + return { resources, unobserved: thin.unobserved }; +} diff --git a/lexicons/k8s/src/serializer.ts b/lexicons/k8s/src/serializer.ts index 40f4c5552..3e77b7df7 100644 --- a/lexicons/k8s/src/serializer.ts +++ b/lexicons/k8s/src/serializer.ts @@ -10,6 +10,21 @@ import type { Declarable } from "@intentius/chant/declarable"; import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable"; import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer"; import { ownershipEntries, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership"; +import { + isEffectReceipt, + receiptExpectation, + referenceInputPaths, + type EffectReceiptDeclaration, +} from "@intentius/chant/effect-receipt"; +import { + RECEIPT_CONFIGMAP_REF, + RECEIPT_DATA_KEY, + RECEIPT_UNRESOLVED_VALUE_NOTE, + receiptConfigMapRef, + receiptNamespaceFrom, + renderReceiptComment, + type RenderedReceiptRow, +} from "./effect-receipt-row"; import type { LexiconOutput } from "@intentius/chant/lexicon-output"; import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker"; import { emitYAML } from "@intentius/chant/yaml"; @@ -180,6 +195,65 @@ function resolveK8sAttr(entity: Declarable | undefined, logicalName: string, att ); } +/** The rendered expectation: the synthesis-time value when the receipt is + * fully static, the placeholder note when reference inputs remain (#1703 + * decision 5, since synthesis resolves nothing). */ +function receiptRowValue(receipt: EffectReceiptDeclaration): string { + if (receipt.flavor === "hash" && referenceInputPaths(receipt).length > 0) { + return RECEIPT_UNRESOLVED_VALUE_NOTE; + } + return receiptExpectation(receipt); +} + +/** + * Render the effect receipts (#2074) the build withheld from the apply-bound + * entity set (`SerializeContext.receipts`, #1832) as ConfigMap rows: named + * `chant-receipt...` from the ownership marker fields + * (epic decision 4), in the project's receipt namespace, with the expectation + * under `data.expectation`. + * + * Visibility only. The rows ride a YAML comment at the end of the manifest + * stream (./effect-receipt-row.ts) rather than a document, because a document + * is what an applier applies and the `effect()` step is a receipt's sole + * writer (#1832, epic #1703 decision 3). + * + * The env segment is explicit: a receipt with no resolved `ownership.env` is + * an error, never a guessed segment. + */ +function renderReceiptRows( + receipts: ReadonlyMap, + ownership: { stack: string; env?: string } | undefined, + namespace: string, +): Record { + const rows: Record = {}; + const names = [...receipts.keys()].join(", "); + if (!ownership?.stack) { + throw new Error( + `k8s receipts (${names}): no ownership marker resolved. The receipt ConfigMap is named ` + + `chant-receipt..., derived from the same ownership fields that ` + + `stamp markers (chant #1703, decision 4). Set ownership: { stack } in chant.config.ts.`, + ); + } + if (!ownership.env) { + throw new Error( + `k8s receipts (${names}): ownership resolved no env. The receipt name's segment is ` + + `explicit (chant #1703, decision 4). Set ownership: { env } in chant.config.ts, or ` + + `build with an env-valued parameter that resolves it.`, + ); + } + for (const [name, entity] of receipts) { + if (!isEffectReceipt(entity)) continue; + const ref = receiptConfigMapRef(ownership.stack, ownership.env, entity.effect, namespace); + rows[name] = { + kind: RECEIPT_CONFIGMAP_REF.kind, + namespace: ref.namespace, + name: ref.name, + data: { [RECEIPT_DATA_KEY]: receiptRowValue(entity) }, + }; + } + return rows; +} + /** * K8s visitor for the generic serializer walker. */ @@ -416,7 +490,24 @@ export const k8sSerializer: Serializer = { } } - const primary = [...namespaceDocs, ...otherDocs].join("\n---\n"); + let primary = [...namespaceDocs, ...otherDocs].join("\n---\n"); + + // Effect receipt rows (#2074): visibility only, deliberately NOT a + // document: appliers apply documents, and the `effect()` step is a + // receipt's sole writer (#1832, epic #1703 decision 3). The comment rides + // the same build output the observation leg is handed, which is how it + // learns each receipt's ConfigMap address. + if (context?.receipts && context.receipts.size > 0) { + const rows = renderReceiptRows( + context.receipts, + context.ownership, + receiptNamespaceFrom(context.config), + ); + if (Object.keys(rows).length > 0) { + primary = primary.length > 0 ? `${primary}\n${renderReceiptComment(rows)}\n` : `${renderReceiptComment(rows)}\n`; + } + } + // A bare string when there is nothing extra to write, so the common case // stays byte-identical to what every existing consumer already reads. if (Object.keys(files).length === 0 && warnings.length === 0) return primary; diff --git a/scripts/docs-sentences-baseline.json b/scripts/docs-sentences-baseline.json index 41f5173e0..c3ebbe8a0 100644 --- a/scripts/docs-sentences-baseline.json +++ b/scripts/docs-sentences-baseline.json @@ -363,7 +363,7 @@ "tricolon/comma-series": 1 }, "docs/src/content/docs/concepts/effect-receipts.mdx": { - "formatting/em-dash-density": 30, + "formatting/em-dash-density": 28, "claude/colon-reveal": 5, "dead-metaphor/rare-lemma": 1, "tricolon/comma-series": 4,