@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,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render canonicalization and the contentDigest/inputDigest split
|
|
3
|
+
* (#1237, epic #1228 Phase 2).
|
|
4
|
+
*
|
|
5
|
+
* A pinned render's output is normalized to a canonical byte form before
|
|
6
|
+
* digesting, and two digests are recorded with different jobs:
|
|
7
|
+
*
|
|
8
|
+
* - `inputDigest` answers "same inputs?" cheaply — a `sha256:` over the
|
|
9
|
+
* canonical JSON of { chart reference, chart version, resolved values,
|
|
10
|
+
* capability profile }. It is the same digest `helmInstall` records in the
|
|
11
|
+
* release ledger (#1243), computed by the same helper, so a render and a
|
|
12
|
+
* deploy of the same inputs share an identity.
|
|
13
|
+
* - `contentDigest` answers "same bytes on the cluster?" — a `sha256:` over
|
|
14
|
+
* the canonical rendered bytes. It is the artifact identity.
|
|
15
|
+
*
|
|
16
|
+
* The two diverge exactly when rendering is unpinned or the chart is
|
|
17
|
+
* unstable: two renders with the same inputDigest but different
|
|
18
|
+
* contentDigests mean the render function is not a function of its declared
|
|
19
|
+
* inputs. That divergence is itself a signal — `renderStability` names it.
|
|
20
|
+
*
|
|
21
|
+
* Canonicalization scope (what the epic measured, not more):
|
|
22
|
+
* - Mapping key order is normalized (sorted) — YAML mappings are unordered
|
|
23
|
+
* in Kubernetes semantics, so key order is render noise. Sequence order is
|
|
24
|
+
* never touched; list order is meaningful to Kubernetes.
|
|
25
|
+
* - Document order is preserved, not sorted. The epic verified document
|
|
26
|
+
* ordering is already stable across renders (three renders, again with
|
|
27
|
+
* subcharts), so reordering would only destroy information.
|
|
28
|
+
* - Duplicate documents are preserved. An aliased dependency emits the same
|
|
29
|
+
* CRD twice (epic finding 11); helm dedupes on install, the artifact keeps
|
|
30
|
+
* both, and the digest stays stable because canonicalization is
|
|
31
|
+
* per-document and order-preserving.
|
|
32
|
+
* - The helm-inserted `# Source:` header is kept (it is the origin-routing
|
|
33
|
+
* key the archive wrapper builds on) and normalized to a single leading
|
|
34
|
+
* line. All other comments, trailing whitespace, CRLF line endings, and
|
|
35
|
+
* document-separator styling are render noise and are dropped or
|
|
36
|
+
* normalized.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { createHash } from "node:crypto";
|
|
40
|
+
|
|
41
|
+
import { canonicalJson } from "@intentius/chant/effect-receipt";
|
|
42
|
+
import yaml from "js-yaml";
|
|
43
|
+
|
|
44
|
+
import { splitDocuments } from "./pinnability/render-stream";
|
|
45
|
+
import type { HelmRenderRecord } from "./render";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The declared inputs of a helm render or deploy, in the exact shape #1243
|
|
49
|
+
* digests for the release ledger. `helmInstallInputDigest`
|
|
50
|
+
* (op/activities/helm.ts) and `HelmRender`'s recorded `inputDigest` both
|
|
51
|
+
* delegate here, so the two cannot drift: the same chart reference, version,
|
|
52
|
+
* resolved values, and capability facts produce the same digest whether they
|
|
53
|
+
* reach helm through a render or a deploy.
|
|
54
|
+
*/
|
|
55
|
+
export interface HelmInputDigestSource {
|
|
56
|
+
/** Chart reference — a local path, or `<repo-url>/<chart>` for repo-fetched charts. */
|
|
57
|
+
chart: string;
|
|
58
|
+
/** Pinned chart version. Digested as `null` when absent. */
|
|
59
|
+
chartVersion?: string;
|
|
60
|
+
/** Resolved values (file merged with overrides). Digested as `{}` when absent. */
|
|
61
|
+
values?: Record<string, unknown>;
|
|
62
|
+
/**
|
|
63
|
+
* Capability facts the render is pinned against. Only the facts join the
|
|
64
|
+
* digest — the profile's *name* is a label, not an input, so two profiles
|
|
65
|
+
* declaring the same cluster digest identically. `apiVersions` is sorted:
|
|
66
|
+
* it declares a set, not an order.
|
|
67
|
+
*/
|
|
68
|
+
capabilityProfile?: { kubeVersion?: string; apiVersions?: string[] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The input-side identity of a helm render or deploy: `sha256:` over the
|
|
73
|
+
* canonical JSON (core's `canonicalJson`, RFC 8785 shape) of the declared
|
|
74
|
+
* inputs. Shared with #1243's release-ledger digest — see
|
|
75
|
+
* `HelmInputDigestSource`. Deliberately excludes the release name and
|
|
76
|
+
* namespace: those are baked into the rendered bytes but the ledger uses
|
|
77
|
+
* this digest as a cross-environment join key, and two environments
|
|
78
|
+
* deploying the same chart, version, and values must share it.
|
|
79
|
+
*/
|
|
80
|
+
export function helmInputDigest(source: HelmInputDigestSource): string {
|
|
81
|
+
const input: Record<string, unknown> = {
|
|
82
|
+
chart: source.chart,
|
|
83
|
+
chartVersion: source.chartVersion ?? null,
|
|
84
|
+
values: source.values ?? {},
|
|
85
|
+
};
|
|
86
|
+
if (source.capabilityProfile) {
|
|
87
|
+
input.capabilityProfile = {
|
|
88
|
+
kubeVersion: source.capabilityProfile.kubeVersion ?? null,
|
|
89
|
+
apiVersions: [...(source.capabilityProfile.apiVersions ?? [])].sort(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return `sha256:${createHash("sha256").update(canonicalJson(input), "utf8").digest("hex")}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The helm-inserted origin header, e.g. `# Source: chart/templates/deploy.yaml`. */
|
|
96
|
+
const SOURCE_HEADER = /^# Source: \S+$/m;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalize a `helm template` stream to its canonical byte form.
|
|
100
|
+
*
|
|
101
|
+
* Per document: the `# Source:` header (if any) becomes the single leading
|
|
102
|
+
* comment line, the YAML body is re-serialized with sorted mapping keys
|
|
103
|
+
* (sequences untouched), no line-width folding, and a trailing newline.
|
|
104
|
+
* Documents are emitted in render order, each introduced by a `---` line.
|
|
105
|
+
* CRLF endings, trailing whitespace, non-Source comments, and empty or
|
|
106
|
+
* comment-only documents are render noise and do not survive.
|
|
107
|
+
*
|
|
108
|
+
* A document that fails to parse as YAML is kept verbatim (trimmed) rather
|
|
109
|
+
* than dropped — the canonical form must never silently lose content, and a
|
|
110
|
+
* verbatim document still digests deterministically.
|
|
111
|
+
*/
|
|
112
|
+
export function canonicalizeRender(rendered: string): string {
|
|
113
|
+
const out: string[] = [];
|
|
114
|
+
for (const doc of splitDocuments(rendered.replace(/\r\n/g, "\n"))) {
|
|
115
|
+
const header = doc.match(SOURCE_HEADER)?.[0];
|
|
116
|
+
let parsed: unknown;
|
|
117
|
+
let parseFailed = false;
|
|
118
|
+
try {
|
|
119
|
+
parsed = yaml.load(doc);
|
|
120
|
+
} catch {
|
|
121
|
+
parseFailed = true;
|
|
122
|
+
}
|
|
123
|
+
if (parseFailed) {
|
|
124
|
+
out.push(`---\n${doc}\n`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (parsed === null || parsed === undefined) continue; // comment-only or empty document
|
|
128
|
+
const body = yaml.dump(parsed, { sortKeys: true, lineWidth: -1, noRefs: true });
|
|
129
|
+
out.push(`---\n${header ? header + "\n" : ""}${body}`);
|
|
130
|
+
}
|
|
131
|
+
return out.join("");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The content-side identity of a render: `sha256:` over the canonical bytes
|
|
136
|
+
* of the rendered stream (see `canonicalizeRender`). Unlike `inputDigest`,
|
|
137
|
+
* this changes whenever the bytes that would reach the cluster change — a
|
|
138
|
+
* chart upgrade with identical values, a helm version that renders
|
|
139
|
+
* differently, an unstable template.
|
|
140
|
+
*/
|
|
141
|
+
export function helmContentDigest(rendered: string): string {
|
|
142
|
+
return `sha256:${createHash("sha256").update(canonicalizeRender(rendered), "utf8").digest("hex")}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One inputDigest's renders and the distinct content identities they produced. */
|
|
146
|
+
export interface RenderStabilityGroup {
|
|
147
|
+
inputDigest: string;
|
|
148
|
+
/** Render names in this group, in record order. */
|
|
149
|
+
names: string[];
|
|
150
|
+
/** Distinct content digests observed, in first-seen order. */
|
|
151
|
+
contentDigests: string[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface RenderStabilityReport {
|
|
155
|
+
/** Groups whose every render produced the same bytes. */
|
|
156
|
+
stable: RenderStabilityGroup[];
|
|
157
|
+
/**
|
|
158
|
+
* Groups where the same inputs produced different bytes: the render is
|
|
159
|
+
* not pinned down by its declared inputs — an unstable chart, or an input
|
|
160
|
+
* that escaped declaration. Every group here is a defect signal.
|
|
161
|
+
*/
|
|
162
|
+
unstable: RenderStabilityGroup[];
|
|
163
|
+
/** Names of records carrying no digests (unpinned renders) — unassessable. */
|
|
164
|
+
unassessed: string[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Group recorded renders by input identity and report whether the same
|
|
169
|
+
* inputs always produced the same bytes.
|
|
170
|
+
*
|
|
171
|
+
* Two renders with equal `inputDigest` and different `contentDigest` mean
|
|
172
|
+
* the render is not a function of its declared inputs — the chart is
|
|
173
|
+
* unstable (generated values, timestamps) or an input escaped declaration.
|
|
174
|
+
* That divergence is the signal this helper exists to name; a single render
|
|
175
|
+
* per input proves nothing and lands in `stable` by default.
|
|
176
|
+
*
|
|
177
|
+
* Grouping keys on `(inputDigest, name)` because the release name is a real
|
|
178
|
+
* render input baked into the bytes but deliberately absent from
|
|
179
|
+
* `inputDigest` (see `helmInputDigest`) — two differently named renders of
|
|
180
|
+
* the same chart legitimately differ in content and must not be read as
|
|
181
|
+
* instability.
|
|
182
|
+
*/
|
|
183
|
+
export function renderStability(records: readonly HelmRenderRecord[]): RenderStabilityReport {
|
|
184
|
+
const groups = new Map<string, RenderStabilityGroup>();
|
|
185
|
+
const unassessed: string[] = [];
|
|
186
|
+
for (const record of records) {
|
|
187
|
+
if (!record.inputDigest || !record.contentDigest) {
|
|
188
|
+
unassessed.push(record.name);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const key = `${record.inputDigest}${record.name}`;
|
|
192
|
+
let group = groups.get(key);
|
|
193
|
+
if (!group) {
|
|
194
|
+
group = { inputDigest: record.inputDigest, names: [], contentDigests: [] };
|
|
195
|
+
groups.set(key, group);
|
|
196
|
+
}
|
|
197
|
+
group.names.push(record.name);
|
|
198
|
+
if (!group.contentDigests.includes(record.contentDigest)) {
|
|
199
|
+
group.contentDigests.push(record.contentDigest);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const report: RenderStabilityReport = { stable: [], unstable: [], unassessed };
|
|
203
|
+
for (const group of groups.values()) {
|
|
204
|
+
(group.contentDigests.length > 1 ? report.unstable : report.stable).push(group);
|
|
205
|
+
}
|
|
206
|
+
return report;
|
|
207
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import yaml from "js-yaml";
|
|
7
|
+
|
|
8
|
+
import { HelmRender, clearHelmRenderRecords, getHelmRenderRecords } from "./render";
|
|
9
|
+
import { canonicalizeRender, helmContentDigest, helmInputDigest } from "./render-digest";
|
|
10
|
+
import {
|
|
11
|
+
findRenderByCacheKey,
|
|
12
|
+
helmValuesDigest,
|
|
13
|
+
indexRenderDocuments,
|
|
14
|
+
listRenderManifests,
|
|
15
|
+
loadRenderContent,
|
|
16
|
+
loadRenderManifest,
|
|
17
|
+
persistHelmRender,
|
|
18
|
+
readRenderDocument,
|
|
19
|
+
renderCacheKey,
|
|
20
|
+
} from "./render-store";
|
|
21
|
+
import type { HelmCapabilityProfile } from "./config";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A raw `helm template`-shaped stream with deliberate render noise: CRLF
|
|
25
|
+
* endings, unsorted mapping keys, a comment-only document, and a duplicate
|
|
26
|
+
* document (epic finding 11 — an aliased dependency emits the same CRD
|
|
27
|
+
* twice).
|
|
28
|
+
*/
|
|
29
|
+
const RENDERED = [
|
|
30
|
+
"---",
|
|
31
|
+
"# Source: tiny/templates/serviceaccount.yaml",
|
|
32
|
+
"kind: ServiceAccount",
|
|
33
|
+
"apiVersion: v1",
|
|
34
|
+
"metadata:",
|
|
35
|
+
" namespace: web",
|
|
36
|
+
" name: tiny-sa",
|
|
37
|
+
"---",
|
|
38
|
+
"# comment-only document",
|
|
39
|
+
"---",
|
|
40
|
+
"# Source: tiny/templates/configmap.yaml\r",
|
|
41
|
+
"apiVersion: v1\r",
|
|
42
|
+
"kind: ConfigMap",
|
|
43
|
+
"metadata:",
|
|
44
|
+
" name: tiny-config",
|
|
45
|
+
" namespace: web",
|
|
46
|
+
"data:",
|
|
47
|
+
" zeta: last",
|
|
48
|
+
" alpha: first",
|
|
49
|
+
"---",
|
|
50
|
+
"# Source: tiny/crds/widgets.yaml",
|
|
51
|
+
"apiVersion: apiextensions.k8s.io/v1",
|
|
52
|
+
"kind: CustomResourceDefinition",
|
|
53
|
+
"metadata:",
|
|
54
|
+
" name: widgets.example.com",
|
|
55
|
+
"spec:",
|
|
56
|
+
" group: example.com",
|
|
57
|
+
"---",
|
|
58
|
+
"# Source: tiny/crds/widgets.yaml",
|
|
59
|
+
"apiVersion: apiextensions.k8s.io/v1",
|
|
60
|
+
"kind: CustomResourceDefinition",
|
|
61
|
+
"metadata:",
|
|
62
|
+
" name: widgets.example.com",
|
|
63
|
+
"spec:",
|
|
64
|
+
" group: example.com",
|
|
65
|
+
"",
|
|
66
|
+
].join("\n");
|
|
67
|
+
|
|
68
|
+
const PROFILE: HelmCapabilityProfile = {
|
|
69
|
+
name: "prod",
|
|
70
|
+
kubeVersion: "1.33.6",
|
|
71
|
+
apiVersions: ["batch/v1"],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const VALUES = { replicaCount: 3 };
|
|
75
|
+
|
|
76
|
+
function freshRoot(): string {
|
|
77
|
+
return mkdtempSync(join(tmpdir(), "chant-helm-render-store-"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function persistFixture(root: string, overrides?: Partial<Parameters<typeof persistHelmRender>[0]>) {
|
|
81
|
+
return persistHelmRender({
|
|
82
|
+
rendered: RENDERED,
|
|
83
|
+
releaseName: "rel",
|
|
84
|
+
chart: "tiny",
|
|
85
|
+
repo: "https://charts.example.com",
|
|
86
|
+
chartVersion: "0.1.0",
|
|
87
|
+
namespace: "web",
|
|
88
|
+
values: VALUES,
|
|
89
|
+
capabilityProfile: PROFILE,
|
|
90
|
+
helmVersion: "v4.1.1",
|
|
91
|
+
sourceRef: "deadbeef",
|
|
92
|
+
root,
|
|
93
|
+
...overrides,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe("persistHelmRender / render store", () => {
|
|
98
|
+
test("round-trip: persisted bytes load back byte-identical, and canonicalization is idempotent over them", () => {
|
|
99
|
+
const root = freshRoot();
|
|
100
|
+
const { manifest } = persistFixture(root);
|
|
101
|
+
|
|
102
|
+
const stored = loadRenderContent(manifest.contentDigest, { root });
|
|
103
|
+
expect(stored).toBe(canonicalizeRender(RENDERED));
|
|
104
|
+
|
|
105
|
+
// The store serves canonical bytes back as the cache — re-canonicalizing
|
|
106
|
+
// and re-digesting them must be a fixed point, or a cache hit would
|
|
107
|
+
// change the render's recorded identity.
|
|
108
|
+
expect(canonicalizeRender(stored!)).toBe(stored);
|
|
109
|
+
expect(helmContentDigest(stored!)).toBe(manifest.contentDigest);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("the manifest records every field: identity, inputs, profile, index, provenance", () => {
|
|
113
|
+
const root = freshRoot();
|
|
114
|
+
const now = () => new Date("2026-08-24T12:00:00.000Z");
|
|
115
|
+
const { manifest, dir } = persistFixture(root, { now });
|
|
116
|
+
|
|
117
|
+
expect(manifest.version).toBe(1);
|
|
118
|
+
expect(manifest.chart).toBe("tiny");
|
|
119
|
+
expect(manifest.chartVersion).toBe("0.1.0");
|
|
120
|
+
expect(manifest.repo).toBe("https://charts.example.com");
|
|
121
|
+
expect(manifest.releaseName).toBe("rel");
|
|
122
|
+
expect(manifest.namespace).toBe("web");
|
|
123
|
+
expect(manifest.valuesDigest).toBe(helmValuesDigest(VALUES));
|
|
124
|
+
expect(manifest.inputDigest).toBe(
|
|
125
|
+
helmInputDigest({
|
|
126
|
+
chart: "https://charts.example.com/tiny",
|
|
127
|
+
chartVersion: "0.1.0",
|
|
128
|
+
values: VALUES,
|
|
129
|
+
capabilityProfile: { kubeVersion: "1.33.6", apiVersions: ["batch/v1"] },
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
expect(manifest.capabilityProfile).toEqual({
|
|
133
|
+
cluster: "prod",
|
|
134
|
+
kubeVersion: "1.33.6",
|
|
135
|
+
apiVersions: ["batch/v1"],
|
|
136
|
+
});
|
|
137
|
+
expect(manifest.contentDigest).toBe(helmContentDigest(RENDERED));
|
|
138
|
+
// 4 canonical documents: the comment-only one is render noise and does
|
|
139
|
+
// not survive canonicalization; the duplicate CRD does (finding 11).
|
|
140
|
+
expect(manifest.docCount).toBe(4);
|
|
141
|
+
expect(manifest.documents.length).toBe(4);
|
|
142
|
+
expect(manifest.renderedAt).toBe("2026-08-24T12:00:00.000Z");
|
|
143
|
+
expect(manifest.helmVersion).toBe("v4.1.1");
|
|
144
|
+
expect(manifest.chantVersion).toMatch(/^\d+\.\d+\.\d+/);
|
|
145
|
+
expect(manifest.sourceRef).toBe("deadbeef");
|
|
146
|
+
|
|
147
|
+
// What landed on disk is the manifest itself, next to the bytes.
|
|
148
|
+
expect(dir).toBe(join(root, manifest.contentDigest.replace(":", "-")));
|
|
149
|
+
const onDisk = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
|
|
150
|
+
expect(onDisk).toEqual(manifest);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("an unpinned render is refused with the specific reason", () => {
|
|
154
|
+
const root = freshRoot();
|
|
155
|
+
expect(() => persistFixture(root, { capabilityProfile: undefined })).toThrow(
|
|
156
|
+
/no capability profile is declared.*no stable content identity/s,
|
|
157
|
+
);
|
|
158
|
+
// Nothing was written.
|
|
159
|
+
expect(readdirSync(root)).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("two renders with the same contentDigest share one store entry", () => {
|
|
163
|
+
const root = freshRoot();
|
|
164
|
+
const first = persistFixture(root, { now: () => new Date("2026-08-24T12:00:00.000Z") });
|
|
165
|
+
expect(first.deduplicated).toBe(false);
|
|
166
|
+
|
|
167
|
+
// Same bytes, later clock, different declared release inputs that do
|
|
168
|
+
// not change the bytes' identity: the content entry is reused untouched.
|
|
169
|
+
const second = persistFixture(root, { now: () => new Date("2026-08-25T09:00:00.000Z") });
|
|
170
|
+
expect(second.deduplicated).toBe(true);
|
|
171
|
+
expect(second.dir).toBe(first.dir);
|
|
172
|
+
// First writer wins — the stored manifest is immutable.
|
|
173
|
+
expect(second.manifest.renderedAt).toBe("2026-08-24T12:00:00.000Z");
|
|
174
|
+
|
|
175
|
+
const entries = readdirSync(root).filter((e) => e.startsWith("sha256-"));
|
|
176
|
+
expect(entries.length).toBe(1);
|
|
177
|
+
expect(listRenderManifests({ root }).length).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("the document index resolves kind/namespace/name to that document's exact bytes", () => {
|
|
181
|
+
const root = freshRoot();
|
|
182
|
+
const { manifest } = persistFixture(root);
|
|
183
|
+
|
|
184
|
+
const hit = readRenderDocument(
|
|
185
|
+
manifest.contentDigest,
|
|
186
|
+
{ kind: "ConfigMap", namespace: "web", name: "tiny-config" },
|
|
187
|
+
{ root },
|
|
188
|
+
);
|
|
189
|
+
expect(hit).toBeDefined();
|
|
190
|
+
expect(hit!.entry.apiVersion).toBe("v1");
|
|
191
|
+
expect(hit!.entry.source).toBe("tiny/templates/configmap.yaml");
|
|
192
|
+
const doc = yaml.load(hit!.text) as { kind: string; data: Record<string, string> };
|
|
193
|
+
expect(doc.kind).toBe("ConfigMap");
|
|
194
|
+
expect(doc.data).toEqual({ zeta: "last", alpha: "first" });
|
|
195
|
+
// The bytes are the manifest-indexed slice of the stored content.
|
|
196
|
+
const content = loadRenderContent(manifest.contentDigest, { root })!;
|
|
197
|
+
expect(hit!.text).toBe(
|
|
198
|
+
Buffer.from(content, "utf8")
|
|
199
|
+
.subarray(hit!.entry.start, hit!.entry.start + hit!.entry.length)
|
|
200
|
+
.toString("utf8"),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Cluster-scoped documents resolve with no namespace.
|
|
204
|
+
const crd = readRenderDocument(
|
|
205
|
+
manifest.contentDigest,
|
|
206
|
+
{ kind: "CustomResourceDefinition", name: "widgets.example.com" },
|
|
207
|
+
{ root },
|
|
208
|
+
);
|
|
209
|
+
expect(crd).toBeDefined();
|
|
210
|
+
expect(crd!.entry.namespace).toBeNull();
|
|
211
|
+
|
|
212
|
+
// A document the render never contained resolves to nothing.
|
|
213
|
+
expect(
|
|
214
|
+
readRenderDocument(manifest.contentDigest, { kind: "Secret", name: "nope" }, { root }),
|
|
215
|
+
).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("the inputs index resolves the full-inputs key to the stored digests", () => {
|
|
219
|
+
const root = freshRoot();
|
|
220
|
+
const { manifest } = persistFixture(root);
|
|
221
|
+
|
|
222
|
+
const key = renderCacheKey({
|
|
223
|
+
chart: "https://charts.example.com/tiny",
|
|
224
|
+
chartVersion: "0.1.0",
|
|
225
|
+
releaseName: "rel",
|
|
226
|
+
namespace: "web",
|
|
227
|
+
values: VALUES,
|
|
228
|
+
capabilityProfile: PROFILE,
|
|
229
|
+
});
|
|
230
|
+
const entry = findRenderByCacheKey(key, { root });
|
|
231
|
+
expect(entry).toEqual({
|
|
232
|
+
version: 1,
|
|
233
|
+
inputDigest: manifest.inputDigest,
|
|
234
|
+
contentDigest: manifest.contentDigest,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// The release name is part of the cache key even though inputDigest
|
|
238
|
+
// excludes it — it is baked into the bytes via .Release.Name.
|
|
239
|
+
const otherName = renderCacheKey({
|
|
240
|
+
chart: "https://charts.example.com/tiny",
|
|
241
|
+
chartVersion: "0.1.0",
|
|
242
|
+
releaseName: "other",
|
|
243
|
+
namespace: "web",
|
|
244
|
+
values: VALUES,
|
|
245
|
+
capabilityProfile: PROFILE,
|
|
246
|
+
});
|
|
247
|
+
expect(otherName).not.toBe(key);
|
|
248
|
+
expect(findRenderByCacheKey(otherName, { root })).toBeUndefined();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("listRenderManifests lists every stored render and skips corrupt entries", () => {
|
|
252
|
+
const root = freshRoot();
|
|
253
|
+
const a = persistFixture(root);
|
|
254
|
+
const b = persistFixture(root, { rendered: RENDERED + "---\nkind: Secret\napiVersion: v1\nmetadata:\n name: extra\n" });
|
|
255
|
+
expect(a.manifest.contentDigest).not.toBe(b.manifest.contentDigest);
|
|
256
|
+
|
|
257
|
+
// A corrupt entry must not fail the listing.
|
|
258
|
+
const corrupt = join(root, `sha256-${"f".repeat(64)}`);
|
|
259
|
+
mkdirSync(corrupt, { recursive: true });
|
|
260
|
+
writeFileSync(join(corrupt, "manifest.json"), "{not json");
|
|
261
|
+
|
|
262
|
+
const listed = listRenderManifests({ root });
|
|
263
|
+
expect(listed.map((m) => m.contentDigest).sort()).toEqual(
|
|
264
|
+
[a.manifest.contentDigest, b.manifest.contentDigest].sort(),
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("indexRenderDocuments counts every canonical document, indexed or not", () => {
|
|
269
|
+
const canonical = canonicalizeRender(RENDERED);
|
|
270
|
+
const { documents, docCount } = indexRenderDocuments(canonical);
|
|
271
|
+
expect(docCount).toBe(4);
|
|
272
|
+
// The duplicate CRD appears twice in the index, at different offsets.
|
|
273
|
+
const crds = documents.filter((d) => d.kind === "CustomResourceDefinition");
|
|
274
|
+
expect(crds.length).toBe(2);
|
|
275
|
+
expect(crds[0].start).not.toBe(crds[1].start);
|
|
276
|
+
expect(crds[0].digest).toBe(crds[1].digest);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* HelmRender integration — asserted against a scripted `helm` double (the
|
|
282
|
+
* same pattern render.test.ts uses) so these tests need no real helm, no
|
|
283
|
+
* network, and no chart. The double answers `helm version` and answers any
|
|
284
|
+
* `helm template` with two small manifests, counting its template
|
|
285
|
+
* invocations so cache hits are observable.
|
|
286
|
+
*/
|
|
287
|
+
describe("HelmRender persistence (#1238)", () => {
|
|
288
|
+
const FAKE_BIN = join(tmpdir(), "chant-helm-render-store-fake-bin");
|
|
289
|
+
let storeRoot: string;
|
|
290
|
+
let countFile: string;
|
|
291
|
+
let origPath: string | undefined;
|
|
292
|
+
let origRoot: string | undefined;
|
|
293
|
+
|
|
294
|
+
beforeAll(() => {
|
|
295
|
+
mkdirSync(FAKE_BIN, { recursive: true });
|
|
296
|
+
writeFileSync(
|
|
297
|
+
join(FAKE_BIN, "helm"),
|
|
298
|
+
`#!/bin/sh
|
|
299
|
+
if [ "$1" = "version" ]; then
|
|
300
|
+
echo "v4.1.1-fake"
|
|
301
|
+
exit 0
|
|
302
|
+
fi
|
|
303
|
+
echo x >> "$CHANT_TEST_HELM_TEMPLATE_COUNT"
|
|
304
|
+
cat <<'EOF'
|
|
305
|
+
---
|
|
306
|
+
# Source: tiny/templates/configmap.yaml
|
|
307
|
+
apiVersion: v1
|
|
308
|
+
kind: ConfigMap
|
|
309
|
+
metadata:
|
|
310
|
+
name: fake-render
|
|
311
|
+
namespace: web
|
|
312
|
+
data:
|
|
313
|
+
b: two
|
|
314
|
+
a: one
|
|
315
|
+
---
|
|
316
|
+
# Source: tiny/templates/serviceaccount.yaml
|
|
317
|
+
apiVersion: v1
|
|
318
|
+
kind: ServiceAccount
|
|
319
|
+
metadata:
|
|
320
|
+
name: fake-sa
|
|
321
|
+
namespace: web
|
|
322
|
+
EOF
|
|
323
|
+
`,
|
|
324
|
+
{ mode: 0o755 },
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
beforeEach(() => {
|
|
329
|
+
storeRoot = freshRoot();
|
|
330
|
+
countFile = join(mkdtempSync(join(tmpdir(), "chant-helm-count-")), "count.txt");
|
|
331
|
+
process.env.CHANT_TEST_HELM_TEMPLATE_COUNT = countFile;
|
|
332
|
+
origRoot = process.env.CHANT_HELM_RENDER_ROOT;
|
|
333
|
+
process.env.CHANT_HELM_RENDER_ROOT = storeRoot;
|
|
334
|
+
origPath = process.env.PATH;
|
|
335
|
+
process.env.PATH = FAKE_BIN + delimiter + (origPath ?? "");
|
|
336
|
+
clearHelmRenderRecords();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
afterEach(() => {
|
|
340
|
+
process.env.PATH = origPath;
|
|
341
|
+
if (origRoot === undefined) delete process.env.CHANT_HELM_RENDER_ROOT;
|
|
342
|
+
else process.env.CHANT_HELM_RENDER_ROOT = origRoot;
|
|
343
|
+
delete process.env.CHANT_TEST_HELM_TEMPLATE_COUNT;
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
function templateInvocations(): number {
|
|
347
|
+
if (!existsSync(countFile)) return 0;
|
|
348
|
+
return readFileSync(countFile, "utf8").split("\n").filter((l) => l.length > 0).length;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const pinnedProps = () =>
|
|
352
|
+
({
|
|
353
|
+
name: "rel",
|
|
354
|
+
chart: "/dev/null/some-chart",
|
|
355
|
+
namespace: "web",
|
|
356
|
+
values: { replicaCount: 2 },
|
|
357
|
+
capabilityProfile: { ...PROFILE },
|
|
358
|
+
}) as unknown as Parameters<typeof HelmRender>[0];
|
|
359
|
+
|
|
360
|
+
test("a pinned render persists by default, and an identical render is a store cache hit", () => {
|
|
361
|
+
HelmRender(pinnedProps());
|
|
362
|
+
expect(templateInvocations()).toBe(1);
|
|
363
|
+
|
|
364
|
+
const [record] = getHelmRenderRecords();
|
|
365
|
+
const manifest = loadRenderManifest(record.contentDigest!);
|
|
366
|
+
expect(manifest).toBeDefined();
|
|
367
|
+
expect(manifest!.inputDigest).toBe(record.inputDigest);
|
|
368
|
+
expect(manifest!.helmVersion).toBe("v4.1.1-fake");
|
|
369
|
+
expect(manifest!.releaseName).toBe("rel");
|
|
370
|
+
expect(loadRenderContent(record.contentDigest!)).toBeDefined();
|
|
371
|
+
|
|
372
|
+
// Second identical render: served from the store through the inputs
|
|
373
|
+
// index — helm template does not run again, and the recorded digests
|
|
374
|
+
// are unchanged.
|
|
375
|
+
clearHelmRenderRecords();
|
|
376
|
+
const result = HelmRender(pinnedProps());
|
|
377
|
+
expect(templateInvocations()).toBe(1);
|
|
378
|
+
const [second] = getHelmRenderRecords();
|
|
379
|
+
expect(second.contentDigest).toBe(record.contentDigest);
|
|
380
|
+
expect(second.inputDigest).toBe(record.inputDigest);
|
|
381
|
+
const keys = Object.keys(result.members as Record<string, unknown>);
|
|
382
|
+
expect(keys).toContain("ConfigMap_fake_render");
|
|
383
|
+
expect(keys).toContain("ServiceAccount_fake_sa");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("persist: true on an unpinned render is a synth error naming the reason", () => {
|
|
387
|
+
expect(() =>
|
|
388
|
+
HelmRender({
|
|
389
|
+
name: "rel",
|
|
390
|
+
chart: "/dev/null/some-chart",
|
|
391
|
+
persist: true,
|
|
392
|
+
} as Parameters<typeof HelmRender>[0]),
|
|
393
|
+
).toThrow(/unpinned.*no capabilityProfile declared/s);
|
|
394
|
+
// Refused before helm ran.
|
|
395
|
+
expect(templateInvocations()).toBe(0);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("noCache renders fresh and skips the store; persist: true forces the write anyway", () => {
|
|
399
|
+
HelmRender({ ...pinnedProps(), noCache: true } as Parameters<typeof HelmRender>[0]);
|
|
400
|
+
expect(templateInvocations()).toBe(1);
|
|
401
|
+
expect(listRenderManifests({ root: storeRoot }).length).toBe(0);
|
|
402
|
+
|
|
403
|
+
HelmRender({ ...pinnedProps(), noCache: true, persist: true } as Parameters<typeof HelmRender>[0]);
|
|
404
|
+
expect(templateInvocations()).toBe(2);
|
|
405
|
+
expect(listRenderManifests({ root: storeRoot }).length).toBe(1);
|
|
406
|
+
|
|
407
|
+
// persist: false turns the store off even without noCache.
|
|
408
|
+
HelmRender({ ...pinnedProps(), name: "rel2", persist: false } as Parameters<typeof HelmRender>[0]);
|
|
409
|
+
expect(templateInvocations()).toBe(3);
|
|
410
|
+
expect(listRenderManifests({ root: storeRoot }).length).toBe(1);
|
|
411
|
+
});
|
|
412
|
+
});
|