@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.
- package/dist/archive-render.d.ts +94 -0
- package/dist/archive-render.d.ts.map +1 -0
- package/dist/commands.d.ts +54 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/config.d.ts +135 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/deep-observe-hooks.d.ts +2 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +54 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +17 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/index.d.ts +18 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/whm503.d.ts +34 -0
- package/dist/lint/post-synth/whm503.d.ts.map +1 -0
- package/dist/lint/post-synth/whm504.d.ts +20 -0
- package/dist/lint/post-synth/whm504.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +2 -0
- package/dist/okf/rules/WHM503.md +15 -0
- package/dist/okf/rules/WHM504.md +11 -0
- package/dist/okf/types/Values.md +1 -0
- package/dist/op/activities/cluster-probe.d.ts +44 -0
- package/dist/op/activities/cluster-probe.d.ts.map +1 -0
- package/dist/op/activities/helm.d.ts +179 -5
- package/dist/op/activities/helm.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +4 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/pinnability/actions.d.ts +76 -0
- package/dist/pinnability/actions.d.ts.map +1 -0
- package/dist/pinnability/classify.d.ts +129 -0
- package/dist/pinnability/classify.d.ts.map +1 -0
- package/dist/pinnability/conditions.d.ts +109 -0
- package/dist/pinnability/conditions.d.ts.map +1 -0
- package/dist/pinnability/index.d.ts +17 -0
- package/dist/pinnability/index.d.ts.map +1 -0
- package/dist/pinnability/localize.d.ts +124 -0
- package/dist/pinnability/localize.d.ts.map +1 -0
- package/dist/pinnability/render-stream.d.ts +27 -0
- package/dist/pinnability/render-stream.d.ts.map +1 -0
- package/dist/pinnability/values.d.ts +69 -0
- package/dist/pinnability/values.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/release-observe.d.ts +109 -0
- package/dist/release-observe.d.ts.map +1 -0
- package/dist/render-diff.d.ts +192 -0
- package/dist/render-diff.d.ts.map +1 -0
- package/dist/render-digest.d.ts +135 -0
- package/dist/render-digest.d.ts.map +1 -0
- package/dist/render-store.d.ts +310 -0
- package/dist/render-store.d.ts.map +1 -0
- package/dist/render-wrapper.d.ts +140 -0
- package/dist/render-wrapper.d.ts.map +1 -0
- package/dist/render.d.ts +81 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/rules/whm503.ts +101 -0
- package/dist/rules/whm504.ts +52 -0
- package/dist/skills/chant-helm.md +7 -0
- package/dist/values-probe.d.ts +177 -0
- package/dist/values-probe.d.ts.map +1 -0
- package/dist/wrapper-chart.d.ts +61 -0
- package/dist/wrapper-chart.d.ts.map +1 -0
- package/package.json +10 -4
- package/src/archive-render.test.ts +201 -0
- package/src/archive-render.ts +119 -0
- package/src/commands.test.ts +487 -0
- package/src/commands.ts +445 -0
- package/src/config.test.ts +130 -0
- package/src/config.ts +220 -0
- package/src/deep-observe-hooks.ts +20 -0
- package/src/deep-observe.test.ts +165 -0
- package/src/deep-observe.ts +121 -0
- package/src/describe-resources.test.ts +255 -0
- package/src/describe-resources.ts +198 -0
- package/src/index.ts +98 -2
- package/src/lifecycle-integration.test.ts +289 -0
- package/src/lint/audit-catalog.ts +2 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/whm503.test.ts +134 -0
- package/src/lint/post-synth/whm503.ts +101 -0
- package/src/lint/post-synth/whm504.test.ts +105 -0
- package/src/lint/post-synth/whm504.ts +52 -0
- package/src/op/activities/cluster-probe.test.ts +66 -0
- package/src/op/activities/cluster-probe.ts +148 -0
- package/src/op/activities/helm.test.ts +824 -0
- package/src/op/activities/helm.ts +628 -5
- package/src/op/activities/index.ts +17 -2
- package/src/pinnability/actions.ts +263 -0
- package/src/pinnability/classify.ts +579 -0
- package/src/pinnability/conditions.ts +0 -0
- package/src/pinnability/index.ts +77 -0
- package/src/pinnability/localize.test.ts +234 -0
- package/src/pinnability/localize.ts +644 -0
- package/src/pinnability/pinnability.test.ts +560 -0
- package/src/pinnability/render-stream.ts +59 -0
- package/src/pinnability/values.ts +205 -0
- package/src/plugin.test.ts +3 -1
- package/src/plugin.ts +47 -0
- package/src/release-observe.ts +392 -0
- package/src/render-diff.test.ts +366 -0
- package/src/render-diff.ts +407 -0
- package/src/render-digest.test.ts +186 -0
- package/src/render-digest.ts +207 -0
- package/src/render-store.test.ts +412 -0
- package/src/render-store.ts +621 -0
- package/src/render-wrapper.test.ts +259 -0
- package/src/render-wrapper.ts +274 -0
- package/src/render.test.ts +383 -4
- package/src/render.ts +319 -8
- package/src/skills/chant-helm.md +7 -0
- package/src/values-probe.test.ts +400 -0
- package/src/values-probe.ts +627 -0
- package/src/wrapper-chart.test.ts +110 -0
- 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
|
+
});
|