@intentius/chant-lexicon-k8s 0.58.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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
@@ -0,0 +1,290 @@
1
+ /**
2
+ * The k8s `ReceiptStore` (#2074, epic #1703): core's injectable receipt seam
3
+ * (#1834, `@intentius/chant/op/receipt-store`) implemented over a core
4
+ * `ConfigMap`, at the address ./effect-receipt-row.ts derives from the
5
+ * ownership marker fields, plus the plan-side live read of the same rows.
6
+ *
7
+ * The transport is the lexicon's own typed client (./api/connect.ts), so a
8
+ * receipt read and write take the same environment-to-cluster binding
9
+ * (#1100/#1155), the same credential policy and the same field manager as
10
+ * every other k8s mutation. No `kubectl` binary is involved.
11
+ *
12
+ * Write discipline (epic decision 3): `write` exists for the `effect()` step
13
+ * alone, because the step's read-compare-run-write is the only path that reaches it,
14
+ * on success, last. It is a server-side apply as `chant:<stack>`, stamping the
15
+ * ownership marker labels and {@link RECEIPT_LABEL_KEY}, so a later write
16
+ * updates the value in place and the owned-only prune retains rather than
17
+ * deletes it (./op/activities/kubectl.ts).
18
+ *
19
+ * Identity: the ConfigMap name needs `<stack>` and `<env>`, which the activity
20
+ * args deliberately do not carry (the `EffectReceiptRef` is
21
+ * identity-of-the-effect, not identity-of-the-deployment). The store resolves
22
+ * them once, lazily, at first use: an explicit option, else `CHANT_ENV` (what
23
+ * `chant run --env` sets) and the project's `ownership` block, the same fields
24
+ * that stamp markers (epic decision 4). Nothing resolving is an error, never a
25
+ * guessed segment.
26
+ *
27
+ * Not exported from the package entry point. Like ./secret-store.ts and
28
+ * ./teardown.ts, this module names the API client, which must stay off the
29
+ * build path (chant #1074, examples/k8s-client-boundary.test.ts). Consumers
30
+ * reach it by subpath: `@intentius/chant-lexicon-k8s/receipt-store`.
31
+ */
32
+
33
+ import { loadChantConfigUpward, resolveOwnershipStack } from "@intentius/chant/config";
34
+ import {
35
+ LABEL_OWNERSHIP_KEYS,
36
+ ownershipEntries,
37
+ classifyOwnership,
38
+ readOwnership,
39
+ } from "@intentius/chant/ownership";
40
+ import type { EffectReceiptRef, ReceiptStore } from "@intentius/chant/op/receipt-store";
41
+ import type { ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
42
+ import type { DeepResourceObservation } from "@intentius/chant/deep-observation";
43
+ import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
44
+ import { defaultK8sConnector, type K8sConnector } from "./api/connect";
45
+ import { classifyApiFailure } from "./api/classify";
46
+ import {
47
+ K8S_EFFECT_RECEIPT_ENTITY_TYPE,
48
+ RECEIPT_CONFIGMAP_REF,
49
+ RECEIPT_DATA_KEY,
50
+ RECEIPT_LABEL_KEY,
51
+ parseReceiptComment,
52
+ receiptConfigMapRef,
53
+ receiptNamespaceFrom,
54
+ type RenderedReceiptRow,
55
+ } from "./effect-receipt-row";
56
+
57
+ /** Options for {@link k8sReceiptStore}. All optional: the default store reads
58
+ * its identity and its namespace from the project, and its cluster from the
59
+ * environment binding. */
60
+ export interface K8sReceiptStoreOptions {
61
+ /** The name's `<stack>` segment. Omitted, the project's `ownership.stack`
62
+ * (chant.config.ts, found upward from `cwd`) answers. */
63
+ stack?: string;
64
+ /** The name's `<env>` segment, explicit by decision 4. Omitted, `CHANT_ENV`
65
+ * (set by `chant run --env`) answers, then a literal `ownership.env`. */
66
+ environment?: string;
67
+ /** Namespace the receipts live in. Omitted, `k8s.receipts.namespace`
68
+ * answers, then `default`. */
69
+ namespace?: string;
70
+ /** Where to look for chant.config.ts. Defaults to the working directory. */
71
+ cwd?: string;
72
+ /** Explicit kubectl context, for a caller that already resolved one. */
73
+ context?: string;
74
+ /** The connector to build a client with. Test seam. */
75
+ connect?: K8sConnector;
76
+ /** Environment record the identity fallback reads. Defaults to `process.env`. */
77
+ env?: Record<string, string | undefined>;
78
+ }
79
+
80
+ /** The resolved name identity. */
81
+ interface ReceiptIdentity {
82
+ stack: string;
83
+ env: string;
84
+ namespace: string;
85
+ }
86
+
87
+ async function resolveIdentity(options: K8sReceiptStoreOptions): Promise<ReceiptIdentity> {
88
+ const processEnv = options.env ?? process.env;
89
+ let stack = options.stack;
90
+ let env = options.environment ?? processEnv.CHANT_ENV;
91
+ let namespace = options.namespace;
92
+ if (!stack || !env || !namespace) {
93
+ let config;
94
+ try {
95
+ config = (await loadChantConfigUpward(options.cwd ?? process.cwd())).config;
96
+ } catch {
97
+ config = undefined;
98
+ }
99
+ if (config) {
100
+ stack = stack ?? resolveOwnershipStack(config);
101
+ // Only a literal env can answer here: a `{ param }` reference resolves
102
+ // per build, and an op run has no build parameters, and `--env` does.
103
+ const configEnv = config.ownership?.env;
104
+ env = env ?? (typeof configEnv === "string" ? configEnv : undefined);
105
+ namespace = namespace ?? receiptNamespaceFrom(config as unknown as Record<string, unknown>);
106
+ }
107
+ }
108
+ if (!stack) {
109
+ throw new Error(
110
+ "k8s receipt store: no stack identity. The receipt ConfigMap is named " +
111
+ "chant-receipt.<stack>.<env>.<effect>, derived from the same ownership fields that stamp " +
112
+ "markers (chant #1703, decision 4). Set ownership: { stack } in chant.config.ts.",
113
+ );
114
+ }
115
+ if (!env) {
116
+ throw new Error(
117
+ "k8s receipt store: no environment resolved. The receipt name's <env> segment is explicit " +
118
+ "(chant #1703, decision 4). Run with --env <name>, set CHANT_ENV, or set a literal " +
119
+ "ownership.env in chant.config.ts.",
120
+ );
121
+ }
122
+ return { stack, env, namespace: namespace ?? receiptNamespaceFrom(undefined) };
123
+ }
124
+
125
+ /** The stored expectation on a live receipt ConfigMap, or undefined when the
126
+ * object holds none. */
127
+ export function receiptValueOf(object: K8sObject | undefined): string | undefined {
128
+ const data = (object as { data?: Record<string, unknown> } | undefined)?.data;
129
+ const value = data?.[RECEIPT_DATA_KEY];
130
+ return typeof value === "string" ? value : undefined;
131
+ }
132
+
133
+ /**
134
+ * The `ReceiptStore` over ConfigMaps. Bind it once in the op activities barrel
135
+ * as `receiptActivities(k8sReceiptStore())`, and the registry resolves
136
+ * `receiptRead`/`receiptWrite`/`receiptStaleness` by name, exactly like
137
+ * `ensureSecret` (#1830). Identity and cluster resolve lazily at first use, so
138
+ * module load never reads the project or connects to anything.
139
+ */
140
+ export function k8sReceiptStore(options: K8sReceiptStoreOptions = {}): ReceiptStore {
141
+ let identity: Promise<ReceiptIdentity> | undefined;
142
+ const identityOf = () => (identity ??= resolveIdentity(options));
143
+
144
+ let pending: Promise<K8sClient> | undefined;
145
+ const clientOf = (): Promise<K8sClient> =>
146
+ (pending ??= (options.connect ?? defaultK8sConnector)({
147
+ ...(options.environment !== undefined ? { environment: options.environment } : {}),
148
+ ...(options.context !== undefined ? { context: options.context } : {}),
149
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
150
+ }).then((connected) => connected.client));
151
+
152
+ return {
153
+ async read(receipt: EffectReceiptRef): Promise<string | undefined> {
154
+ const { stack, env, namespace } = await identityOf();
155
+ const ref = receiptConfigMapRef(stack, env, receipt.effect, namespace);
156
+ const client = await clientOf();
157
+ const live = await client.readIfPresent({ ...RECEIPT_CONFIGMAP_REF, ...ref });
158
+ return receiptValueOf(live);
159
+ },
160
+
161
+ async write(receipt: EffectReceiptRef, expectation: string): Promise<void> {
162
+ const { stack, env, namespace } = await identityOf();
163
+ const ref = receiptConfigMapRef(stack, env, receipt.effect, namespace);
164
+ const client = await clientOf();
165
+ const configMap: K8sObject = {
166
+ ...RECEIPT_CONFIGMAP_REF,
167
+ metadata: {
168
+ ...ref,
169
+ labels: {
170
+ ...ownershipEntries(LABEL_OWNERSHIP_KEYS, { stack, env }),
171
+ [RECEIPT_LABEL_KEY]: receipt.effect,
172
+ },
173
+ },
174
+ data: { [RECEIPT_DATA_KEY]: expectation },
175
+ } as K8sObject;
176
+ await client.apply(configMap, { fieldManager: `chant:${stack}` });
177
+ },
178
+ };
179
+ }
180
+
181
+ /** What the observation leg learned about the declared receipt rows. */
182
+ export interface ReceiptRowObservation {
183
+ resources: Record<string, ResourceMetadata>;
184
+ unobserved: Record<string, UnobservedEntity>;
185
+ }
186
+
187
+ /** The receipt rows a build output carries, keyed by entity name. Only the
188
+ * entities this observation was asked about. */
189
+ export function receiptRowsFor(
190
+ entityNames: readonly string[],
191
+ buildOutput: string | undefined,
192
+ ): Map<string, RenderedReceiptRow> {
193
+ const rows = parseReceiptComment(buildOutput ?? "");
194
+ const wanted = new Set(entityNames);
195
+ const out = new Map<string, RenderedReceiptRow>();
196
+ for (const [name, row] of Object.entries(rows)) {
197
+ if (!wanted.has(name)) continue;
198
+ if (typeof row?.name !== "string" || typeof row?.namespace !== "string") continue;
199
+ out.set(name, row);
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * The plan-side live read of the receipt rows (#2074's observation leg).
206
+ *
207
+ * A receipt is not a document the applier ever wrote (#1832), and it carries
208
+ * no `props` on the declared side, so the generic declared-entity sweep in
209
+ * ./describe-resources.ts has neither a `metadata.name` to query by nor an
210
+ * honest verdict to give. The serializer rendered each receipt's derived
211
+ * ConfigMap address into the build output's receipt comment, so this leg reads
212
+ * the addresses back from there, one derivation, decision 4, and asks the
213
+ * cluster for each.
214
+ *
215
+ * Present maps the stored value onto `attributes.value` (core's
216
+ * `RECEIPT_VALUE_ATTRIBUTE`); a genuine 404 is a real absence and stays one; a
217
+ * failed read is an `unobserved` hole, never a wrong answer: a receipt nobody
218
+ * could read must not arrive downstream as "the effect never ran".
219
+ */
220
+ export async function observeReceiptRows(
221
+ client: K8sClient,
222
+ rows: ReadonlyMap<string, RenderedReceiptRow>,
223
+ ): Promise<ReceiptRowObservation> {
224
+ const out: ReceiptRowObservation = { resources: {}, unobserved: {} };
225
+ await client.concurrently([...rows], async ([entityName, row]) => {
226
+ try {
227
+ const live = await client.read({
228
+ ...RECEIPT_CONFIGMAP_REF,
229
+ name: row.name,
230
+ namespace: row.namespace,
231
+ });
232
+ out.resources[entityName] = {
233
+ type: K8S_EFFECT_RECEIPT_ENTITY_TYPE,
234
+ physicalId: live.metadata?.uid,
235
+ // Live outside anything the applier wrote, by design. The same word
236
+ // the aws row's observation uses for a receipt parameter (#1835).
237
+ status: "EXTERNAL",
238
+ ownership: classifyOwnership(live.metadata?.labels, LABEL_OWNERSHIP_KEYS),
239
+ marker: readOwnership(live.metadata?.labels, LABEL_OWNERSHIP_KEYS),
240
+ attributes: {
241
+ namespace: row.namespace,
242
+ // Core's RECEIPT_VALUE_ATTRIBUTE, which is what `readReceiptValue` reads.
243
+ value: receiptValueOf(live) ?? "",
244
+ },
245
+ };
246
+ } catch (err) {
247
+ const outcome = classifyApiFailure(err);
248
+ if (outcome.kind === "unobserved") {
249
+ out.unobserved[entityName] = {
250
+ type: K8S_EFFECT_RECEIPT_ENTITY_TYPE,
251
+ reason: outcome.reason,
252
+ detail: `reading receipt ConfigMap ${row.namespace}/${row.name}: ${outcome.detail}`,
253
+ };
254
+ }
255
+ // `absent` records nothing: in neither map is how the contract spells
256
+ // "asked, and it is not there", which is what the plan reads as "the
257
+ // effect has not fired for these inputs".
258
+ }
259
+ });
260
+ return out;
261
+ }
262
+
263
+ /**
264
+ * The deep read's answer for the receipt rows.
265
+ *
266
+ * A receipt is read back here for the same reason the thin path reads it: a
267
+ * declared entity nobody looked at is a hole, and a hole in the deep read is
268
+ * noise on every `lifecycle diff --live --deep` a project with receipts runs.
269
+ * What it deliberately contributes is an EMPTY property tree: the declaration
270
+ * has no `props`, so every live path would land outside the claimed-field set
271
+ * (`@intentius/chant/claimed-fields`) and be reported unclaimed, and the
272
+ * receipt's stored value is not drift on any reading, because a stale receipt is an
273
+ * `effect` row from `planReceipts` (#1832), never an update. Presence and the
274
+ * uid are the whole of what the deep read has to say about a receipt.
275
+ */
276
+ export async function observeReceiptRowsDeep(
277
+ client: K8sClient,
278
+ rows: ReadonlyMap<string, RenderedReceiptRow>,
279
+ ): Promise<{ resources: Record<string, DeepResourceObservation>; unobserved: Record<string, UnobservedEntity> }> {
280
+ const thin = await observeReceiptRows(client, rows);
281
+ const resources: Record<string, DeepResourceObservation> = {};
282
+ for (const [name, meta] of Object.entries(thin.resources)) {
283
+ resources[name] = {
284
+ type: K8S_EFFECT_RECEIPT_ENTITY_TYPE,
285
+ ...(meta.physicalId ? { physicalId: meta.physicalId } : {}),
286
+ properties: {},
287
+ };
288
+ }
289
+ return { resources, unobserved: thin.unobserved };
290
+ }
package/src/serializer.ts CHANGED
@@ -10,6 +10,21 @@ import type { Declarable } from "@intentius/chant/declarable";
10
10
  import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
11
11
  import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
12
12
  import { ownershipEntries, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
13
+ import {
14
+ isEffectReceipt,
15
+ receiptExpectation,
16
+ referenceInputPaths,
17
+ type EffectReceiptDeclaration,
18
+ } from "@intentius/chant/effect-receipt";
19
+ import {
20
+ RECEIPT_CONFIGMAP_REF,
21
+ RECEIPT_DATA_KEY,
22
+ RECEIPT_UNRESOLVED_VALUE_NOTE,
23
+ receiptConfigMapRef,
24
+ receiptNamespaceFrom,
25
+ renderReceiptComment,
26
+ type RenderedReceiptRow,
27
+ } from "./effect-receipt-row";
13
28
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
14
29
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
15
30
  import { emitYAML } from "@intentius/chant/yaml";
@@ -180,6 +195,65 @@ function resolveK8sAttr(entity: Declarable | undefined, logicalName: string, att
180
195
  );
181
196
  }
182
197
 
198
+ /** The rendered expectation: the synthesis-time value when the receipt is
199
+ * fully static, the placeholder note when reference inputs remain (#1703
200
+ * decision 5, since synthesis resolves nothing). */
201
+ function receiptRowValue(receipt: EffectReceiptDeclaration): string {
202
+ if (receipt.flavor === "hash" && referenceInputPaths(receipt).length > 0) {
203
+ return RECEIPT_UNRESOLVED_VALUE_NOTE;
204
+ }
205
+ return receiptExpectation(receipt);
206
+ }
207
+
208
+ /**
209
+ * Render the effect receipts (#2074) the build withheld from the apply-bound
210
+ * entity set (`SerializeContext.receipts`, #1832) as ConfigMap rows: named
211
+ * `chant-receipt.<stack>.<env>.<effect>` from the ownership marker fields
212
+ * (epic decision 4), in the project's receipt namespace, with the expectation
213
+ * under `data.expectation`.
214
+ *
215
+ * Visibility only. The rows ride a YAML comment at the end of the manifest
216
+ * stream (./effect-receipt-row.ts) rather than a document, because a document
217
+ * is what an applier applies and the `effect()` step is a receipt's sole
218
+ * writer (#1832, epic #1703 decision 3).
219
+ *
220
+ * The env segment is explicit: a receipt with no resolved `ownership.env` is
221
+ * an error, never a guessed segment.
222
+ */
223
+ function renderReceiptRows(
224
+ receipts: ReadonlyMap<string, Declarable>,
225
+ ownership: { stack: string; env?: string } | undefined,
226
+ namespace: string,
227
+ ): Record<string, RenderedReceiptRow> {
228
+ const rows: Record<string, RenderedReceiptRow> = {};
229
+ const names = [...receipts.keys()].join(", ");
230
+ if (!ownership?.stack) {
231
+ throw new Error(
232
+ `k8s receipts (${names}): no ownership marker resolved. The receipt ConfigMap is named ` +
233
+ `chant-receipt.<stack>.<env>.<effect>, derived from the same ownership fields that ` +
234
+ `stamp markers (chant #1703, decision 4). Set ownership: { stack } in chant.config.ts.`,
235
+ );
236
+ }
237
+ if (!ownership.env) {
238
+ throw new Error(
239
+ `k8s receipts (${names}): ownership resolved no env. The receipt name's <env> segment is ` +
240
+ `explicit (chant #1703, decision 4). Set ownership: { env } in chant.config.ts, or ` +
241
+ `build with an env-valued parameter that resolves it.`,
242
+ );
243
+ }
244
+ for (const [name, entity] of receipts) {
245
+ if (!isEffectReceipt(entity)) continue;
246
+ const ref = receiptConfigMapRef(ownership.stack, ownership.env, entity.effect, namespace);
247
+ rows[name] = {
248
+ kind: RECEIPT_CONFIGMAP_REF.kind,
249
+ namespace: ref.namespace,
250
+ name: ref.name,
251
+ data: { [RECEIPT_DATA_KEY]: receiptRowValue(entity) },
252
+ };
253
+ }
254
+ return rows;
255
+ }
256
+
183
257
  /**
184
258
  * K8s visitor for the generic serializer walker.
185
259
  */
@@ -416,7 +490,24 @@ export const k8sSerializer: Serializer = {
416
490
  }
417
491
  }
418
492
 
419
- const primary = [...namespaceDocs, ...otherDocs].join("\n---\n");
493
+ let primary = [...namespaceDocs, ...otherDocs].join("\n---\n");
494
+
495
+ // Effect receipt rows (#2074): visibility only, deliberately NOT a
496
+ // document: appliers apply documents, and the `effect()` step is a
497
+ // receipt's sole writer (#1832, epic #1703 decision 3). The comment rides
498
+ // the same build output the observation leg is handed, which is how it
499
+ // learns each receipt's ConfigMap address.
500
+ if (context?.receipts && context.receipts.size > 0) {
501
+ const rows = renderReceiptRows(
502
+ context.receipts,
503
+ context.ownership,
504
+ receiptNamespaceFrom(context.config),
505
+ );
506
+ if (Object.keys(rows).length > 0) {
507
+ primary = primary.length > 0 ? `${primary}\n${renderReceiptComment(rows)}\n` : `${renderReceiptComment(rows)}\n`;
508
+ }
509
+ }
510
+
420
511
  // A bare string when there is nothing extra to write, so the common case
421
512
  // stays byte-identical to what every existing consumer already reads.
422
513
  if (Object.keys(files).length === 0 && warnings.length === 0) return primary;