@intentius/chant-lexicon-helm 0.46.0 → 0.49.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 (118) hide show
  1. package/dist/archive-render.d.ts +94 -0
  2. package/dist/archive-render.d.ts.map +1 -0
  3. package/dist/commands.d.ts +54 -0
  4. package/dist/commands.d.ts.map +1 -0
  5. package/dist/config.d.ts +135 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/deep-observe-hooks.d.ts +2 -0
  8. package/dist/deep-observe-hooks.d.ts.map +1 -0
  9. package/dist/deep-observe.d.ts +54 -0
  10. package/dist/deep-observe.d.ts.map +1 -0
  11. package/dist/describe-resources.d.ts +17 -0
  12. package/dist/describe-resources.d.ts.map +1 -0
  13. package/dist/index.d.ts +18 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/integrity.json +5 -3
  16. package/dist/lint/audit-catalog.d.ts.map +1 -1
  17. package/dist/lint/post-synth/index.d.ts.map +1 -1
  18. package/dist/lint/post-synth/whm503.d.ts +34 -0
  19. package/dist/lint/post-synth/whm503.d.ts.map +1 -0
  20. package/dist/lint/post-synth/whm504.d.ts +20 -0
  21. package/dist/lint/post-synth/whm504.d.ts.map +1 -0
  22. package/dist/manifest.json +1 -1
  23. package/dist/okf/index.md +2 -0
  24. package/dist/okf/rules/WHM503.md +15 -0
  25. package/dist/okf/rules/WHM504.md +11 -0
  26. package/dist/okf/types/Values.md +1 -0
  27. package/dist/op/activities/cluster-probe.d.ts +44 -0
  28. package/dist/op/activities/cluster-probe.d.ts.map +1 -0
  29. package/dist/op/activities/helm.d.ts +179 -5
  30. package/dist/op/activities/helm.d.ts.map +1 -1
  31. package/dist/op/activities/index.d.ts +4 -2
  32. package/dist/op/activities/index.d.ts.map +1 -1
  33. package/dist/pinnability/actions.d.ts +76 -0
  34. package/dist/pinnability/actions.d.ts.map +1 -0
  35. package/dist/pinnability/classify.d.ts +129 -0
  36. package/dist/pinnability/classify.d.ts.map +1 -0
  37. package/dist/pinnability/conditions.d.ts +109 -0
  38. package/dist/pinnability/conditions.d.ts.map +1 -0
  39. package/dist/pinnability/index.d.ts +17 -0
  40. package/dist/pinnability/index.d.ts.map +1 -0
  41. package/dist/pinnability/localize.d.ts +124 -0
  42. package/dist/pinnability/localize.d.ts.map +1 -0
  43. package/dist/pinnability/render-stream.d.ts +27 -0
  44. package/dist/pinnability/render-stream.d.ts.map +1 -0
  45. package/dist/pinnability/values.d.ts +69 -0
  46. package/dist/pinnability/values.d.ts.map +1 -0
  47. package/dist/plugin.d.ts.map +1 -1
  48. package/dist/release-observe.d.ts +109 -0
  49. package/dist/release-observe.d.ts.map +1 -0
  50. package/dist/render-diff.d.ts +192 -0
  51. package/dist/render-diff.d.ts.map +1 -0
  52. package/dist/render-digest.d.ts +135 -0
  53. package/dist/render-digest.d.ts.map +1 -0
  54. package/dist/render-store.d.ts +310 -0
  55. package/dist/render-store.d.ts.map +1 -0
  56. package/dist/render-wrapper.d.ts +140 -0
  57. package/dist/render-wrapper.d.ts.map +1 -0
  58. package/dist/render.d.ts +81 -0
  59. package/dist/render.d.ts.map +1 -1
  60. package/dist/rules/whm503.ts +101 -0
  61. package/dist/rules/whm504.ts +52 -0
  62. package/dist/skills/chant-helm.md +7 -0
  63. package/dist/values-probe.d.ts +177 -0
  64. package/dist/values-probe.d.ts.map +1 -0
  65. package/dist/wrapper-chart.d.ts +61 -0
  66. package/dist/wrapper-chart.d.ts.map +1 -0
  67. package/package.json +10 -4
  68. package/src/archive-render.test.ts +201 -0
  69. package/src/archive-render.ts +119 -0
  70. package/src/commands.test.ts +487 -0
  71. package/src/commands.ts +445 -0
  72. package/src/config.test.ts +130 -0
  73. package/src/config.ts +220 -0
  74. package/src/deep-observe-hooks.ts +20 -0
  75. package/src/deep-observe.test.ts +165 -0
  76. package/src/deep-observe.ts +121 -0
  77. package/src/describe-resources.test.ts +255 -0
  78. package/src/describe-resources.ts +198 -0
  79. package/src/index.ts +98 -2
  80. package/src/lifecycle-integration.test.ts +289 -0
  81. package/src/lint/audit-catalog.ts +2 -0
  82. package/src/lint/post-synth/index.ts +4 -0
  83. package/src/lint/post-synth/whm503.test.ts +134 -0
  84. package/src/lint/post-synth/whm503.ts +101 -0
  85. package/src/lint/post-synth/whm504.test.ts +105 -0
  86. package/src/lint/post-synth/whm504.ts +52 -0
  87. package/src/op/activities/cluster-probe.test.ts +66 -0
  88. package/src/op/activities/cluster-probe.ts +148 -0
  89. package/src/op/activities/helm.test.ts +824 -0
  90. package/src/op/activities/helm.ts +628 -5
  91. package/src/op/activities/index.ts +17 -2
  92. package/src/pinnability/actions.ts +263 -0
  93. package/src/pinnability/classify.ts +579 -0
  94. package/src/pinnability/conditions.ts +0 -0
  95. package/src/pinnability/index.ts +77 -0
  96. package/src/pinnability/localize.test.ts +234 -0
  97. package/src/pinnability/localize.ts +644 -0
  98. package/src/pinnability/pinnability.test.ts +560 -0
  99. package/src/pinnability/render-stream.ts +59 -0
  100. package/src/pinnability/values.ts +205 -0
  101. package/src/plugin.test.ts +3 -1
  102. package/src/plugin.ts +47 -0
  103. package/src/release-observe.ts +392 -0
  104. package/src/render-diff.test.ts +366 -0
  105. package/src/render-diff.ts +407 -0
  106. package/src/render-digest.test.ts +186 -0
  107. package/src/render-digest.ts +207 -0
  108. package/src/render-store.test.ts +412 -0
  109. package/src/render-store.ts +621 -0
  110. package/src/render-wrapper.test.ts +259 -0
  111. package/src/render-wrapper.ts +274 -0
  112. package/src/render.test.ts +383 -4
  113. package/src/render.ts +319 -8
  114. package/src/skills/chant-helm.md +7 -0
  115. package/src/values-probe.test.ts +400 -0
  116. package/src/values-probe.ts +627 -0
  117. package/src/wrapper-chart.test.ts +110 -0
  118. package/src/wrapper-chart.ts +124 -0
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Render diffing (#1249 offline, #1250 live — epic #1228 Phase 6), the two
3
+ * halves of `chant helm diff` (./commands.ts).
4
+ *
5
+ * `diffRenders` (#1249) is the offline half: given two content digests
6
+ * already in the render store (render-store.ts), report what changed
7
+ * between them — no cluster, no credentials, no live read. This is the
8
+ * pre-flight question a pin, a chart bump, or a values change all raise:
9
+ * "what will this touch?"
10
+ *
11
+ * Pairing is document-level, keyed on `(kind, namespace, name)` — the same
12
+ * identity `RenderManifest.documents` already indexes (render-store.ts).
13
+ * That index exists precisely so two renders can be matched without
14
+ * reparsing a whole stream: a document present on both sides with the same
15
+ * per-document digest is unchanged and costs nothing further; one whose
16
+ * digest differs is parsed on both sides and diffed field by field.
17
+ *
18
+ * Field-level diffing reuses core's `flattenDeepProperties` /
19
+ * `deepValueEqual` (packages/core/src/deep-observation.ts) — the same
20
+ * path-flattening core's own live-drift diff (lifecycle/deep-diff.ts) is
21
+ * built on. This module does not reuse `diffDeep` itself for the offline
22
+ * path: that function's shape is declared/live/baseline/fieldOwners,
23
+ * tailored to a source tree vs a cluster read. A stored render has neither
24
+ * a baseline nor a field owner, and both sides here are equally "declared"
25
+ * — reusing the flatten/compare primitives without the three-way apparatus
26
+ * is the right cut for render-to-render.
27
+ *
28
+ * Duplicate documents (an aliased dependency emitting the same CRD twice,
29
+ * render-digest.ts's canonicalization note) are matched positionally within
30
+ * their `(kind, namespace, name)` group: first occurrence to first, second
31
+ * to second. A group with more entries on one side reports the surplus as
32
+ * added or removed rather than guessing which duplicate corresponds to
33
+ * which.
34
+ *
35
+ * A document neither side's manifest could index (unparseable, or missing
36
+ * `kind`/`metadata.name` — render-store.ts's `indexRenderDocuments`) cannot
37
+ * be paired by identity. It is never silently dropped: `unindexed` counts
38
+ * it on whichever side(s) it appears, so a diff over two renders that both
39
+ * carry unindexable documents does not quietly under-report.
40
+ *
41
+ * `diffRenderLive` (#1250) is the online complement: given a single
42
+ * `contentDigest` resolved from the render store, read the live property
43
+ * tree for every document the render recorded — by identity
44
+ * (kind/namespace/name), not by helm release — and diff each one against
45
+ * the render's own stored bytes with `lifecycle/deep-diff.ts`. This *does*
46
+ * reuse `diffDeep`'s three-way apparatus (via `diffDeepObservation`): the
47
+ * declared side is the render's stored documents, the live side is a
48
+ * cluster read, which is exactly that function's shape.
49
+ *
50
+ * The declared side for the live path is the render's stored documents,
51
+ * never a fresh `helm template` run and never a chant TS build. A digest
52
+ * not in the store — an unpinned render was never persisted
53
+ * (`render-store.ts`'s `persistHelmRender` refuses that) — has nothing to
54
+ * diff against and comes back `found: false`, never a false "no drift" over
55
+ * documents nobody has (epic AC: unpinned releases handled without false
56
+ * drift).
57
+ *
58
+ * The live side reuses the exact seam `observeResourcesDeepHelm` (#1247)
59
+ * uses — the k8s lexicon's typed client and normalization hooks — so a
60
+ * document this reads and one a release's `describeResources` reads compare
61
+ * under the same rules (secret masking, managed-fields ownership, pruning).
62
+ * Hook resources need no special handling here the way the release-scoped
63
+ * read needs a second `helm get hooks` channel: `helm template` (what
64
+ * produced the stored render) already includes hook manifests as ordinary
65
+ * documents, so they are simply rows in `manifest.documents` like any other.
66
+ */
67
+
68
+ import yaml from "js-yaml";
69
+
70
+ import { deepValueEqual, flattenDeepProperties, normalizeDeepObservation } from "@intentius/chant/deep-observation";
71
+ import type { DeepDiffResult } from "@intentius/chant/lifecycle/deep-diff";
72
+ import { diffDeepObservation } from "@intentius/chant/lifecycle/deep-observe";
73
+ import type { K8sConnector } from "@intentius/chant-lexicon-k8s/api/connect";
74
+ import { observeResourcesDeepK8s } from "@intentius/chant-lexicon-k8s/deep-observe";
75
+ import { gvkToTypeName } from "@intentius/chant-lexicon-k8s/spec/parse";
76
+
77
+ import { helmDeepNormalizationHooks } from "./deep-observe-hooks";
78
+ import { CLUSTER_SCOPED_KINDS } from "./release-observe";
79
+ import { loadRenderManifest, readRenderDocument, type RenderDocumentEntry, type RenderManifest } from "./render-store";
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Offline: render-to-render (#1249)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** How one field within a changed document differs between the two renders. */
86
+ export interface RenderFieldChange {
87
+ /** Path within the document's property tree (`spec.replicas`, `data.zeta`). */
88
+ path: string;
89
+ /**
90
+ * `changed` — both renders have the path, with different values.
91
+ * `added` — only the right-hand (`to`) render has it.
92
+ * `removed` — only the left-hand (`from`) render has it.
93
+ */
94
+ kind: "changed" | "added" | "removed";
95
+ /** Value on the `from` side. Absent for `added`. */
96
+ before?: unknown;
97
+ /** Value on the `to` side. Absent for `removed`. */
98
+ after?: unknown;
99
+ }
100
+
101
+ /** Identity of one document, for reporting — the same triple documents are paired on. */
102
+ export interface RenderDiffDocumentRef {
103
+ kind: string;
104
+ namespace: string | null;
105
+ name: string;
106
+ /** The `# Source:` origin path, when the document carried one. */
107
+ source: string | null;
108
+ }
109
+
110
+ /** A document present on only one side. */
111
+ export interface AddedOrRemovedDocument extends RenderDiffDocumentRef {
112
+ digest: string;
113
+ }
114
+
115
+ /** A document present on both sides with different content. */
116
+ export interface ChangedDocument extends RenderDiffDocumentRef {
117
+ /** Per-document digest on the `from` side. */
118
+ beforeDigest: string;
119
+ /** Per-document digest on the `to` side. */
120
+ afterDigest: string;
121
+ /** Field-level differences, sorted by path. Empty only if a document parses but yields no leaves. */
122
+ changes: RenderFieldChange[];
123
+ }
124
+
125
+ export interface RenderDiffResult {
126
+ from: { contentDigest: string; chart: string; releaseName: string };
127
+ to: { contentDigest: string; chart: string; releaseName: string };
128
+ /** Present only on `to`. Sorted by kind/namespace/name. */
129
+ added: AddedOrRemovedDocument[];
130
+ /** Present only on `from`. Sorted by kind/namespace/name. */
131
+ removed: AddedOrRemovedDocument[];
132
+ /** Present on both, different bytes. Sorted by kind/namespace/name. */
133
+ changed: ChangedDocument[];
134
+ /** Present on both with equal per-document digest — reported as identities only, no field pass run. */
135
+ unchanged: RenderDiffDocumentRef[];
136
+ /**
137
+ * Documents each manifest's index could not identify — present in the
138
+ * stream (`docCount`) but absent from `documents` (unparseable, or no
139
+ * `kind` + `metadata.name`). Never matched, never silently dropped.
140
+ */
141
+ unindexed: { from: number; to: number };
142
+ }
143
+
144
+ function docKey(doc: Pick<RenderDocumentEntry, "kind" | "namespace" | "name">): string {
145
+ return `${doc.kind}\0${doc.namespace ?? ""}\0${doc.name}`;
146
+ }
147
+
148
+ function docRef(doc: RenderDocumentEntry): RenderDiffDocumentRef {
149
+ return { kind: doc.kind, namespace: doc.namespace, name: doc.name, source: doc.source };
150
+ }
151
+
152
+ /** Group a manifest's document entries by identity, preserving occurrence order for duplicate matching. */
153
+ function groupByIdentity(documents: readonly RenderDocumentEntry[]): Map<string, RenderDocumentEntry[]> {
154
+ const groups = new Map<string, RenderDocumentEntry[]>();
155
+ for (const doc of documents) {
156
+ const key = docKey(doc);
157
+ const group = groups.get(key);
158
+ if (group) group.push(doc);
159
+ else groups.set(key, [doc]);
160
+ }
161
+ return groups;
162
+ }
163
+
164
+ function compareRefs(a: RenderDiffDocumentRef, b: RenderDiffDocumentRef): number {
165
+ return (
166
+ a.kind.localeCompare(b.kind) ||
167
+ (a.namespace ?? "").localeCompare(b.namespace ?? "") ||
168
+ a.name.localeCompare(b.name)
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Field-level diff between a document's two parsed trees. Both sides are
174
+ * flattened with `flattenDeepProperties` (no ordering hooks — a stored
175
+ * render carries no lexicon to supply one, so arrays are compared
176
+ * positionally, exactly as the canonicalized bytes ordered them) and walked
177
+ * path by path.
178
+ */
179
+ function diffDocumentFields(beforeText: string, afterText: string, entityType: string): RenderFieldChange[] {
180
+ const beforeTree = (yaml.load(beforeText) ?? {}) as Record<string, unknown>;
181
+ const afterTree = (yaml.load(afterText) ?? {}) as Record<string, unknown>;
182
+ const beforeFlat = flattenDeepProperties(beforeTree, { entityType, side: "declared" });
183
+ const afterFlat = flattenDeepProperties(afterTree, { entityType, side: "live" });
184
+
185
+ const paths = [...new Set([...beforeFlat.keys(), ...afterFlat.keys()])].sort();
186
+ const changes: RenderFieldChange[] = [];
187
+ for (const path of paths) {
188
+ const hasBefore = beforeFlat.has(path);
189
+ const hasAfter = afterFlat.has(path);
190
+ const beforeValue = beforeFlat.get(path);
191
+ const afterValue = afterFlat.get(path);
192
+ if (hasBefore && hasAfter && deepValueEqual(beforeValue, afterValue)) continue;
193
+ const kind: RenderFieldChange["kind"] = !hasBefore ? "added" : !hasAfter ? "removed" : "changed";
194
+ changes.push({
195
+ path,
196
+ kind,
197
+ ...(hasBefore ? { before: beforeValue } : {}),
198
+ ...(hasAfter ? { after: afterValue } : {}),
199
+ });
200
+ }
201
+ return changes;
202
+ }
203
+
204
+ export interface DiffRendersOptions {
205
+ /** Render store root override; defaults to `renderStoreRoot()`. */
206
+ root?: string;
207
+ }
208
+
209
+ /**
210
+ * Diff two renders already in the store, by content digest. Offline: every
211
+ * byte compared comes from the archive (render-store.ts), never a cluster.
212
+ *
213
+ * Throws when either digest has no manifest — a diff over a render nobody
214
+ * persisted is a usage error, not an empty report.
215
+ */
216
+ export function diffRenders(from: string, to: string, options?: DiffRendersOptions): RenderDiffResult {
217
+ const opts = { root: options?.root };
218
+ const fromManifest = requireManifest(from, opts.root);
219
+ const toManifest = requireManifest(to, opts.root);
220
+
221
+ const fromGroups = groupByIdentity(fromManifest.documents);
222
+ const toGroups = groupByIdentity(toManifest.documents);
223
+
224
+ const added: AddedOrRemovedDocument[] = [];
225
+ const removed: AddedOrRemovedDocument[] = [];
226
+ const changed: ChangedDocument[] = [];
227
+ const unchanged: RenderDiffDocumentRef[] = [];
228
+
229
+ const keys = new Set([...fromGroups.keys(), ...toGroups.keys()]);
230
+ for (const key of keys) {
231
+ const fromEntries = fromGroups.get(key) ?? [];
232
+ const toEntries = toGroups.get(key) ?? [];
233
+ const pairCount = Math.min(fromEntries.length, toEntries.length);
234
+
235
+ for (let i = 0; i < pairCount; i++) {
236
+ const fromDoc = fromEntries[i];
237
+ const toDoc = toEntries[i];
238
+ if (fromDoc.digest === toDoc.digest) {
239
+ unchanged.push(docRef(fromDoc));
240
+ continue;
241
+ }
242
+ const fromRead = readRenderDocument(from, fromDoc, opts);
243
+ const toRead = readRenderDocument(to, toDoc, opts);
244
+ /* c8 ignore next 5 -- indexed entries always resolve; defensive only */
245
+ if (!fromRead || !toRead) {
246
+ throw new Error(
247
+ `render diff: indexed document ${fromDoc.kind}/${fromDoc.name} failed to resolve from its own store entry`,
248
+ );
249
+ }
250
+ changed.push({
251
+ ...docRef(fromDoc),
252
+ beforeDigest: fromDoc.digest,
253
+ afterDigest: toDoc.digest,
254
+ changes: diffDocumentFields(fromRead.text, toRead.text, fromDoc.kind),
255
+ });
256
+ }
257
+ for (let i = pairCount; i < fromEntries.length; i++) {
258
+ removed.push({ ...docRef(fromEntries[i]), digest: fromEntries[i].digest });
259
+ }
260
+ for (let i = pairCount; i < toEntries.length; i++) {
261
+ added.push({ ...docRef(toEntries[i]), digest: toEntries[i].digest });
262
+ }
263
+ }
264
+
265
+ return {
266
+ from: { contentDigest: fromManifest.contentDigest, chart: fromManifest.chart, releaseName: fromManifest.releaseName },
267
+ to: { contentDigest: toManifest.contentDigest, chart: toManifest.chart, releaseName: toManifest.releaseName },
268
+ added: added.sort(compareRefs),
269
+ removed: removed.sort(compareRefs),
270
+ changed: changed.sort(compareRefs),
271
+ unchanged: unchanged.sort(compareRefs),
272
+ unindexed: {
273
+ from: fromManifest.docCount - fromManifest.documents.length,
274
+ to: toManifest.docCount - toManifest.documents.length,
275
+ },
276
+ };
277
+ }
278
+
279
+ function requireManifest(contentDigest: string, root?: string): RenderManifest {
280
+ const manifest = loadRenderManifest(contentDigest, { root });
281
+ if (!manifest) {
282
+ throw new Error(
283
+ `no stored render for ${contentDigest} — resolve it first (chant helm renders), or check ` +
284
+ `CHANT_HELM_RENDER_ROOT if the store root was overridden.`,
285
+ );
286
+ }
287
+ return manifest;
288
+ }
289
+
290
+ /** True when the diff found no added, removed, or changed documents. */
291
+ export function renderDiffIsEmpty(result: RenderDiffResult): boolean {
292
+ return result.added.length === 0 && result.removed.length === 0 && result.changed.length === 0;
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Live: render-to-live (#1250)
297
+ // ---------------------------------------------------------------------------
298
+
299
+ export interface RenderLiveDiffOptions {
300
+ /** The stored render to diff against the cluster — a `contentDigest` from `chant helm renders`. */
301
+ contentDigest: string;
302
+ /** The chant environment the live read targets — resolves the k8s cluster binding (`k8s.profiles.<environment>.context`), same as any other live read. */
303
+ environment: string;
304
+ /** Render store root override (tests); defaults to `renderStoreRoot()`. */
305
+ root?: string;
306
+ owned?: boolean;
307
+ }
308
+
309
+ export interface RenderLiveDiffDeps {
310
+ /** Forwarded to the k8s reader — a test passes a fake cluster's connector. */
311
+ connect?: K8sConnector;
312
+ }
313
+
314
+ export type RenderLiveDiffOutcome =
315
+ | { found: true; manifest: RenderManifest; diff: DeepDiffResult }
316
+ /** No such render in the store — refuse the diff rather than report a false clean result. */
317
+ | { found: false; contentDigest: string };
318
+
319
+ function splitApiVersion(apiVersion: string): { group: string; version: string } {
320
+ const slash = apiVersion.indexOf("/");
321
+ return slash > 0
322
+ ? { group: apiVersion.slice(0, slash), version: apiVersion.slice(slash + 1) }
323
+ : { group: "", version: apiVersion };
324
+ }
325
+
326
+ /** Row key convention shared with the release-scoped reads (#1246/#1247): `<Kind>/<namespace>/<name>`, `cluster:<Kind>/<name>`. */
327
+ export function documentKey(kind: string, namespace: string | undefined, name: string): string {
328
+ return namespace ? `${kind}/${namespace}/${name}` : `cluster:${kind}/${name}`;
329
+ }
330
+
331
+ /**
332
+ * A namespace-silent document defaults to the render's target namespace —
333
+ * the same rule a deployed release applies to its manifest (`helm apply
334
+ * -n <ns>` targets whatever a namespace-silent object's `.Release.Namespace`
335
+ * would have filled in) — unless the kind is cluster-scoped, in which case a
336
+ * missing namespace is never defaulted.
337
+ */
338
+ export function resolveDocumentNamespace(
339
+ kind: string,
340
+ declared: string | null,
341
+ renderNamespace: string | null,
342
+ ): string | undefined {
343
+ if (declared) return declared;
344
+ if (CLUSTER_SCOPED_KINDS.has(kind)) return undefined;
345
+ return renderNamespace ?? undefined;
346
+ }
347
+
348
+ /** The document minus the envelope the row's `type` already carries — mirrors `deep-observe.ts`'s `declaredTreeOf`. */
349
+ function declaredTreeOf(doc: Record<string, unknown>, namespace: string | undefined): Record<string, unknown> {
350
+ const { apiVersion: _apiVersion, kind: _kind, ...rest } = doc;
351
+ const metadata = (rest.metadata ?? {}) as Record<string, unknown>;
352
+ return {
353
+ ...rest,
354
+ ...(namespace ? { metadata: { ...metadata, ...(metadata.namespace === undefined ? { namespace } : {}) } } : {}),
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Diff one stored render's documents against the live cluster.
360
+ *
361
+ * `found: false` when `contentDigest` is not in the store — never fabricates
362
+ * a document, and never reports "no drift" over a render this has no bytes
363
+ * for. On a found render, every document indexed in its manifest becomes a
364
+ * synthetic declared entity (keyed like a release's rows), read live through
365
+ * the k8s lexicon's deep observer, and diffed property-by-property.
366
+ */
367
+ export async function diffRenderLive(
368
+ options: RenderLiveDiffOptions,
369
+ deps: RenderLiveDiffDeps = {},
370
+ ): Promise<RenderLiveDiffOutcome> {
371
+ const manifest = loadRenderManifest(options.contentDigest, { root: options.root });
372
+ if (!manifest) return { found: false, contentDigest: options.contentDigest };
373
+
374
+ const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
375
+ for (const docEntry of manifest.documents) {
376
+ const resolved = readRenderDocument(
377
+ options.contentDigest,
378
+ { kind: docEntry.kind, name: docEntry.name, namespace: docEntry.namespace },
379
+ { root: options.root },
380
+ );
381
+ if (!resolved) continue; // integrity gap between the index and the content — skip, never fabricate
382
+ const parsed: unknown = yaml.load(resolved.text);
383
+ if (parsed === null || typeof parsed !== "object") continue;
384
+ const namespace = resolveDocumentNamespace(docEntry.kind, docEntry.namespace, manifest.namespace);
385
+ const key = documentKey(docEntry.kind, namespace, docEntry.name);
386
+ const { group, version } = splitApiVersion(docEntry.apiVersion);
387
+ entities.set(key, {
388
+ entityType: gvkToTypeName({ group, version, kind: docEntry.kind }),
389
+ props: declaredTreeOf(parsed as Record<string, unknown>, namespace),
390
+ });
391
+ }
392
+
393
+ const k8sOptions = {
394
+ environment: options.environment,
395
+ buildOutput: "",
396
+ entityNames: [...entities.keys()],
397
+ entities,
398
+ ...(options.owned !== undefined ? { owned: options.owned } : {}),
399
+ };
400
+ const raw = deps.connect
401
+ ? await observeResourcesDeepK8s(k8sOptions, deps.connect)
402
+ : await observeResourcesDeepK8s(k8sOptions);
403
+ const live = normalizeDeepObservation(raw);
404
+ const diff = diffDeepObservation(entities, live, helmDeepNormalizationHooks);
405
+
406
+ return { found: true, manifest, diff };
407
+ }
@@ -0,0 +1,186 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtempSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ import {
8
+ canonicalizeRender,
9
+ helmContentDigest,
10
+ helmInputDigest,
11
+ renderStability,
12
+ } from "./render-digest";
13
+ import { helmInstallInputDigest } from "./op/activities/helm";
14
+ import type { HelmRenderRecord } from "./render";
15
+
16
+ const RENDERED = `---
17
+ # Source: tiny-chart/templates/service.yaml
18
+ apiVersion: v1
19
+ kind: Service
20
+ metadata:
21
+ name: rel-tiny
22
+ spec:
23
+ selector:
24
+ app: rel
25
+ ports:
26
+ - port: 80
27
+ ---
28
+ # Source: tiny-chart/templates/deployment.yaml
29
+ apiVersion: apps/v1
30
+ kind: Deployment
31
+ metadata:
32
+ name: rel-tiny
33
+ spec:
34
+ replicas: 1
35
+ `;
36
+
37
+ describe("canonicalizeRender", () => {
38
+ test("mapping key order is normalized; two key orders canonicalize identically", () => {
39
+ const a = "---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm\ndata:\n x: \"1\"\n";
40
+ const b = "---\nkind: ConfigMap\ndata:\n x: \"1\"\nmetadata:\n name: cm\napiVersion: v1\n";
41
+ expect(canonicalizeRender(a)).toBe(canonicalizeRender(b));
42
+ });
43
+
44
+ test("sequence order is preserved — list order is meaningful to Kubernetes", () => {
45
+ const a = "---\nkind: ConfigMap\napiVersion: v1\nitems:\n- one\n- two\n";
46
+ const b = "---\nkind: ConfigMap\napiVersion: v1\nitems:\n- two\n- one\n";
47
+ expect(canonicalizeRender(a)).not.toBe(canonicalizeRender(b));
48
+ });
49
+
50
+ test("document order is preserved, not sorted", () => {
51
+ const svc = "# Source: c/templates/s.yaml\napiVersion: v1\nkind: Service\nmetadata:\n name: s\n";
52
+ const dep = "# Source: c/templates/d.yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: d\n";
53
+ const ab = canonicalizeRender(`---\n${svc}---\n${dep}`);
54
+ const ba = canonicalizeRender(`---\n${dep}---\n${svc}`);
55
+ expect(ab).not.toBe(ba);
56
+ // Same documents either way — only the order differs.
57
+ expect(ab.split("---\n").sort()).toEqual(ba.split("---\n").sort());
58
+ });
59
+
60
+ test("duplicate documents are preserved, and the form is stable across calls", () => {
61
+ const crd =
62
+ "# Source: c/charts/kid/crds/kid.yaml\napiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n name: kidthings.example.com\n";
63
+ const doubled = `---\n${crd}---\n${crd}`;
64
+ const canonical = canonicalizeRender(doubled);
65
+ expect(canonical.match(/kidthings\.example\.com/g)?.length).toBe(2);
66
+ expect(canonicalizeRender(doubled)).toBe(canonical);
67
+ });
68
+
69
+ test("render noise is normalized: CRLF, trailing whitespace, non-Source comments, empty documents", () => {
70
+ const noisy =
71
+ "---\r\n# Source: c/templates/cm.yaml\r\nkind: ConfigMap \r\napiVersion: v1\r\n# helm inserted this remark\r\nmetadata:\r\n name: cm \r\n---\r\n# Source: c/templates/empty.yaml\r\n---\r\n";
72
+ const clean =
73
+ "---\n# Source: c/templates/cm.yaml\nkind: ConfigMap\napiVersion: v1\nmetadata:\n name: cm\n";
74
+ expect(canonicalizeRender(noisy)).toBe(canonicalizeRender(clean));
75
+ });
76
+
77
+ test("the # Source: header survives as the document's leading line", () => {
78
+ const canonical = canonicalizeRender(RENDERED);
79
+ expect(canonical).toContain("---\n# Source: tiny-chart/templates/service.yaml\napiVersion: v1\n");
80
+ expect(canonical).toContain("---\n# Source: tiny-chart/templates/deployment.yaml\napiVersion: apps/v1\n");
81
+ });
82
+ });
83
+
84
+ describe("helmContentDigest", () => {
85
+ test("pinned against a hand-written canonical form — the cross-machine anchor", () => {
86
+ const rendered = "---\n# Source: c/templates/cm.yaml\nkind: ConfigMap\ndata:\n b: '2'\n a: '1'\napiVersion: v1\nmetadata:\n name: cm\n";
87
+ // Independently derived: sorted keys, 2-space indent, one trailing
88
+ // newline. Knowing the exact bytes without calling canonicalizeRender
89
+ // makes this a non-circular reproducibility anchor: any machine, any
90
+ // platform, must produce this digest for this input.
91
+ const canonical =
92
+ "---\n# Source: c/templates/cm.yaml\napiVersion: v1\ndata:\n a: '1'\n b: '2'\nkind: ConfigMap\nmetadata:\n name: cm\n";
93
+ expect(canonicalizeRender(rendered)).toBe(canonical);
94
+ const expected = `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
95
+ expect(helmContentDigest(rendered)).toBe(expected);
96
+ });
97
+
98
+ test("changes when the rendered content changes", () => {
99
+ const one = RENDERED;
100
+ const three = RENDERED.replace("replicas: 1", "replicas: 3");
101
+ expect(helmContentDigest(one)).not.toBe(helmContentDigest(three));
102
+ });
103
+
104
+ test("full sha256: prefix, 64 hex characters", () => {
105
+ expect(helmContentDigest(RENDERED)).toMatch(/^sha256:[0-9a-f]{64}$/);
106
+ });
107
+ });
108
+
109
+ describe("helmInputDigest", () => {
110
+ test("agrees with helmInstallInputDigest (#1243) for the same inputs", () => {
111
+ const values = { replicaCount: 3, image: { tag: "v1" } };
112
+ const dir = mkdtempSync(join(tmpdir(), "chant-helm-digest-"));
113
+ const valuesFile = join(dir, "values.yaml");
114
+ writeFileSync(valuesFile, "replicaCount: 3\nimage:\n tag: v1\n");
115
+
116
+ const fromRenderSide = helmInputDigest({
117
+ chart: "./chart",
118
+ chartVersion: "1.2.3",
119
+ values,
120
+ capabilityProfile: { kubeVersion: "1.33.6", apiVersions: ["batch/v1", "apps/v1"] },
121
+ });
122
+ const fromDeploySide = helmInstallInputDigest({
123
+ name: "web",
124
+ chart: "./chart",
125
+ chartVersion: "1.2.3",
126
+ values: valuesFile,
127
+ capabilityProfile: { kubeVersion: "1.33.6", apiVersions: ["apps/v1", "batch/v1"] },
128
+ });
129
+ expect(fromRenderSide).toBe(fromDeploySide);
130
+ });
131
+
132
+ test("the profile's facts join the digest; its name would not (the shape has no name field)", () => {
133
+ const base = { chart: "./chart", chartVersion: "1.0.0", values: {} };
134
+ const bare = helmInputDigest(base);
135
+ const pinned = helmInputDigest({
136
+ ...base,
137
+ capabilityProfile: { kubeVersion: "1.33.6" },
138
+ });
139
+ expect(pinned).not.toBe(bare);
140
+ expect(pinned).toMatch(/^sha256:[0-9a-f]{64}$/);
141
+ });
142
+ });
143
+
144
+ function record(overrides: Partial<HelmRenderRecord> & { name: string }): HelmRenderRecord {
145
+ return { chart: "c", version: "1.0.0", ...overrides };
146
+ }
147
+
148
+ describe("renderStability", () => {
149
+ test("same inputDigest, different contentDigest — flagged as unstable", () => {
150
+ const report = renderStability([
151
+ record({ name: "eso", inputDigest: "sha256:aa", contentDigest: "sha256:11" }),
152
+ record({ name: "eso", inputDigest: "sha256:aa", contentDigest: "sha256:22" }),
153
+ ]);
154
+ expect(report.unstable).toEqual([
155
+ { inputDigest: "sha256:aa", names: ["eso", "eso"], contentDigests: ["sha256:11", "sha256:22"] },
156
+ ]);
157
+ expect(report.stable).toEqual([]);
158
+ });
159
+
160
+ test("same inputDigest, same contentDigest — stable", () => {
161
+ const report = renderStability([
162
+ record({ name: "eso", inputDigest: "sha256:aa", contentDigest: "sha256:11" }),
163
+ record({ name: "eso", inputDigest: "sha256:aa", contentDigest: "sha256:11" }),
164
+ ]);
165
+ expect(report.stable.length).toBe(1);
166
+ expect(report.unstable).toEqual([]);
167
+ });
168
+
169
+ test("different names never group together — the release name is a real render input", () => {
170
+ const report = renderStability([
171
+ record({ name: "a", inputDigest: "sha256:aa", contentDigest: "sha256:11" }),
172
+ record({ name: "b", inputDigest: "sha256:aa", contentDigest: "sha256:22" }),
173
+ ]);
174
+ expect(report.unstable).toEqual([]);
175
+ expect(report.stable.length).toBe(2);
176
+ });
177
+
178
+ test("unpinned records (no digests) are unassessed, never stable by omission", () => {
179
+ const report = renderStability([
180
+ record({ name: "unpinned" }),
181
+ record({ name: "eso", inputDigest: "sha256:aa", contentDigest: "sha256:11" }),
182
+ ]);
183
+ expect(report.unassessed).toEqual(["unpinned"]);
184
+ expect(report.stable.length).toBe(1);
185
+ });
186
+ });