@intentius/chant-lexicon-helm 0.45.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,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant helm classify` / `chant helm localize` / `chant helm renders` /
|
|
3
|
+
* `chant helm diff` (#1248, epic #1228 Phase 6; `diff` itself is #1249).
|
|
4
|
+
*
|
|
5
|
+
* Handler-level, the way core's dispatcher drives them: cwd pointed at a
|
|
6
|
+
* fixture, rawArgs handed over unparsed, exit code and printed output
|
|
7
|
+
* asserted. `classify` is static analysis and needs no helm binary; the
|
|
8
|
+
* verbs that render (`localize`) or discover a project whose `HelmRender`
|
|
9
|
+
* shells out (`renders`) sit behind the same skipIf discipline as
|
|
10
|
+
* render.test.ts. `diff` reads only the render store (render-store.ts) so it
|
|
11
|
+
* needs neither helm nor a chart — its own fixtures persist renders directly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
import { RESERVED_COMMAND_NAMES } from "@intentius/chant/cli/command-group";
|
|
22
|
+
import { formatRenderDiff, formatRenderRecords, helmCommandGroup } from "./commands";
|
|
23
|
+
import { helmPlugin } from "./plugin";
|
|
24
|
+
import { renderStability } from "./render-digest";
|
|
25
|
+
import { persistHelmRender } from "./render-store";
|
|
26
|
+
import type { HelmCapabilityProfile } from "./config";
|
|
27
|
+
|
|
28
|
+
function helmOnPath(): boolean {
|
|
29
|
+
try {
|
|
30
|
+
execFileSync("helm", ["version"], { stdio: "ignore" });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const helmAvailable = helmOnPath();
|
|
37
|
+
|
|
38
|
+
const MINIMAL_CHART = "apiVersion: v2\nname: unit\ntype: application\nversion: 0.1.0\n";
|
|
39
|
+
|
|
40
|
+
const cleanupDirs: string[] = [];
|
|
41
|
+
|
|
42
|
+
/** Materialize a chart (or any fixture tree) from a { relPath: content } map. */
|
|
43
|
+
function mkTree(files: Record<string, string>): string {
|
|
44
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-helm-commands-")));
|
|
45
|
+
cleanupDirs.push(dir);
|
|
46
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
47
|
+
const p = join(dir, rel);
|
|
48
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
49
|
+
writeFileSync(p, content);
|
|
50
|
+
}
|
|
51
|
+
return dir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function verb(name: string) {
|
|
55
|
+
const command = helmCommandGroup().commands.find((c) => c.name === name);
|
|
56
|
+
if (!command) throw new Error(`no verb ${name}`);
|
|
57
|
+
return command;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let logged: string[];
|
|
61
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
62
|
+
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
logged = [];
|
|
66
|
+
logSpy = vi.spyOn(console, "log").mockImplementation((line: unknown) => {
|
|
67
|
+
logged.push(String(line));
|
|
68
|
+
});
|
|
69
|
+
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
logSpy.mockRestore();
|
|
74
|
+
errSpy.mockRestore();
|
|
75
|
+
while (cleanupDirs.length > 0) {
|
|
76
|
+
rmSync(cleanupDirs.pop() as string, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("the helm command group", () => {
|
|
81
|
+
it("mounts under a name core does not reserve, with classify, localize, renders, and diff", () => {
|
|
82
|
+
const group = helmCommandGroup();
|
|
83
|
+
expect(group.name).toBe("helm");
|
|
84
|
+
expect(RESERVED_COMMAND_NAMES.has(group.name)).toBe(false);
|
|
85
|
+
expect(group.commands.map((c) => c.name)).toEqual(["classify", "localize", "renders", "diff"]);
|
|
86
|
+
expect(helmPlugin.commands?.().name).toBe("helm");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects flags it does not know, naming the ones it does", async () => {
|
|
90
|
+
await expect(verb("classify").handler({ verb: "classify", rawArgs: ["--bogus"] })).rejects.toThrow(
|
|
91
|
+
/Unknown flag: --bogus[\s\S]*--values/,
|
|
92
|
+
);
|
|
93
|
+
await expect(verb("renders").handler({ verb: "renders", rawArgs: ["--values", "x.yaml"] })).rejects.toThrow(
|
|
94
|
+
/Unknown flag: --values[\s\S]*--json/,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("requires the chart directory positional where one is declared", async () => {
|
|
99
|
+
await expect(verb("classify").handler({ verb: "classify", rawArgs: [] })).rejects.toThrow(
|
|
100
|
+
/Usage: chant helm classify <chart-dir>/,
|
|
101
|
+
);
|
|
102
|
+
await expect(
|
|
103
|
+
verb("renders").handler({ verb: "renders", rawArgs: ["some-chart"] }),
|
|
104
|
+
).rejects.toThrow(/takes no positional arguments/);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("chant helm classify", () => {
|
|
109
|
+
it("prints a deterministic verdict for a closed chart and exits 0", async () => {
|
|
110
|
+
const chart = mkTree({
|
|
111
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
112
|
+
"templates/cm.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: {{ .Release.Name }}-cm\n",
|
|
113
|
+
});
|
|
114
|
+
expect(await verb("classify").handler({ verb: "classify", rawArgs: [chart] })).toBe(0);
|
|
115
|
+
expect(logged.join("\n")).toContain("verdict: deterministic");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("locates capability references and open generated inputs on a pinnable chart", async () => {
|
|
119
|
+
const chart = mkTree({
|
|
120
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
121
|
+
"values.yaml": 'adminPassword: ""\n',
|
|
122
|
+
"templates/secret.yaml":
|
|
123
|
+
"kube: {{ .Capabilities.KubeVersion.Version }}\npassword: {{ .Values.adminPassword | default (randAlphaNum 16) | quote }}",
|
|
124
|
+
});
|
|
125
|
+
expect(await verb("classify").handler({ verb: "classify", rawArgs: [chart] })).toBe(0);
|
|
126
|
+
const out = logged.join("\n");
|
|
127
|
+
expect(out).toContain("verdict: pinnable");
|
|
128
|
+
expect(out).toContain("requires capability profile");
|
|
129
|
+
expect(out).toContain("randAlphaNum");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("exits 1 on an unpinnable chart — the verdict is the gate", async () => {
|
|
133
|
+
const chart = mkTree({
|
|
134
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
135
|
+
"templates/cm.yaml": '{{- if not (lookup "v1" "ConfigMap" "ns" "seen") }}\nx: 1\n{{- end }}',
|
|
136
|
+
});
|
|
137
|
+
expect(await verb("classify").handler({ verb: "classify", rawArgs: [chart] })).toBe(1);
|
|
138
|
+
expect(logged.join("\n")).toContain("verdict: unpinnable");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("merges --values files into the classification", async () => {
|
|
142
|
+
const chart = mkTree({
|
|
143
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
144
|
+
"values.yaml": 'adminPassword: ""\n',
|
|
145
|
+
"templates/secret.yaml": "password: {{ .Values.adminPassword | default (randAlphaNum 16) | quote }}",
|
|
146
|
+
"supplied.yaml": "adminPassword: pinned\n",
|
|
147
|
+
});
|
|
148
|
+
expect(
|
|
149
|
+
await verb("classify").handler({ verb: "classify", rawArgs: [chart, "--values", join(chart, "supplied.yaml")] }),
|
|
150
|
+
).toBe(0);
|
|
151
|
+
const out = logged.join("\n");
|
|
152
|
+
expect(out).toContain("verdict: pinnable");
|
|
153
|
+
expect(out).toContain("supplied-values");
|
|
154
|
+
expect(out).not.toContain("randAlphaNum");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("emits the full report as JSON with --json", async () => {
|
|
158
|
+
const chart = mkTree({
|
|
159
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
160
|
+
"templates/ing.yaml": "kube: {{ .Capabilities.KubeVersion.Version }}",
|
|
161
|
+
});
|
|
162
|
+
expect(await verb("classify").handler({ verb: "classify", rawArgs: [chart, "--json"] })).toBe(0);
|
|
163
|
+
const report = JSON.parse(logged.join("\n")) as {
|
|
164
|
+
verdict: string;
|
|
165
|
+
requiresProfile: { capability: string; file: string; line: number }[];
|
|
166
|
+
closedInputs: unknown[];
|
|
167
|
+
hazards: unknown[];
|
|
168
|
+
reasons: string[];
|
|
169
|
+
lookups: { controlFlow: unknown[]; valuePosition: unknown[] };
|
|
170
|
+
warnings: string[];
|
|
171
|
+
};
|
|
172
|
+
expect(report.verdict).toBe("pinnable");
|
|
173
|
+
expect(report.requiresProfile).toHaveLength(1);
|
|
174
|
+
expect(report.requiresProfile[0].capability).toBe("KubeVersion");
|
|
175
|
+
expect(report.lookups.controlFlow).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe.skipIf(!helmAvailable)("chant helm localize", () => {
|
|
180
|
+
it("confirms a closed chart deterministic via the double render", async () => {
|
|
181
|
+
const chart = mkTree({
|
|
182
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
183
|
+
"templates/cm.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: {{ .Release.Name }}-cm\n",
|
|
184
|
+
});
|
|
185
|
+
expect(await verb("localize").handler({ verb: "localize", rawArgs: [chart] })).toBe(0);
|
|
186
|
+
expect(logged.join("\n")).toContain("deterministic (double render byte-stable)");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("maps unstable lines to the generator and suggests the validated pin", async () => {
|
|
190
|
+
const chart = mkTree({
|
|
191
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
192
|
+
"values.yaml": 'adminPassword: ""\n',
|
|
193
|
+
"templates/secret.yaml": [
|
|
194
|
+
"apiVersion: v1",
|
|
195
|
+
"kind: Secret",
|
|
196
|
+
"metadata:",
|
|
197
|
+
" name: {{ .Release.Name }}-admin",
|
|
198
|
+
"stringData:",
|
|
199
|
+
" password: {{ .Values.adminPassword | default (randAlphaNum 16) | quote }}",
|
|
200
|
+
].join("\n"),
|
|
201
|
+
});
|
|
202
|
+
expect(await verb("localize").handler({ verb: "localize", rawArgs: [chart, "--json"] })).toBe(0);
|
|
203
|
+
const report = JSON.parse(logged.join("\n")) as {
|
|
204
|
+
deterministic: boolean;
|
|
205
|
+
inputs: { fn: string; suppliable: boolean; valuesPath?: string }[];
|
|
206
|
+
unlocalized: unknown[];
|
|
207
|
+
renders: number;
|
|
208
|
+
};
|
|
209
|
+
expect(report.deterministic).toBe(false);
|
|
210
|
+
expect(report.inputs).toHaveLength(1);
|
|
211
|
+
expect(report.inputs[0].fn).toBe("randAlphaNum");
|
|
212
|
+
expect(report.inputs[0].suppliable).toBe(true);
|
|
213
|
+
expect(report.inputs[0].valuesPath).toBe("adminPassword");
|
|
214
|
+
expect(report.unlocalized).toEqual([]);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe.skipIf(!helmAvailable)("chant helm renders", () => {
|
|
219
|
+
const cwd = process.cwd();
|
|
220
|
+
const renderModule = join(dirname(fileURLToPath(import.meta.url)), "render.ts");
|
|
221
|
+
|
|
222
|
+
afterEach(() => {
|
|
223
|
+
process.chdir(cwd);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
/** A chant project whose one source file records a pinned HelmRender. */
|
|
227
|
+
function mkProject(): string {
|
|
228
|
+
const chart = mkTree({
|
|
229
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
230
|
+
"templates/cm.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: {{ .Release.Name }}-cm\n",
|
|
231
|
+
});
|
|
232
|
+
return mkTree({
|
|
233
|
+
"chant.config.json": JSON.stringify({ lexicons: ["helm"] }),
|
|
234
|
+
"infra.ts": [
|
|
235
|
+
`import { HelmRender } from ${JSON.stringify(renderModule)};`,
|
|
236
|
+
"export const web = HelmRender({",
|
|
237
|
+
' name: "web",',
|
|
238
|
+
` chart: ${JSON.stringify(chart)},`,
|
|
239
|
+
" noCache: true,",
|
|
240
|
+
' capabilityProfile: { name: "test-profile", kubeVersion: "1.33.0" },',
|
|
241
|
+
"} as never);",
|
|
242
|
+
].join("\n"),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
it("discovers the project and lists the record with both digests", async () => {
|
|
247
|
+
process.chdir(mkProject());
|
|
248
|
+
expect(await verb("renders").handler({ verb: "renders", rawArgs: [] })).toBe(0);
|
|
249
|
+
const out = logged.join("\n");
|
|
250
|
+
expect(out).toContain("web");
|
|
251
|
+
expect(out).toContain("test-profile");
|
|
252
|
+
expect(out).toMatch(/sha256:[0-9a-f]{12}/);
|
|
253
|
+
expect(out).toContain("1 stable group(s), 0 unstable, 0 unassessed");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("emits records and stability as JSON with --json", async () => {
|
|
257
|
+
process.chdir(mkProject());
|
|
258
|
+
expect(await verb("renders").handler({ verb: "renders", rawArgs: ["--json"] })).toBe(0);
|
|
259
|
+
const payload = JSON.parse(logged.join("\n")) as {
|
|
260
|
+
records: { name: string; inputDigest?: string; contentDigest?: string; capabilityProfile?: { name: string } }[];
|
|
261
|
+
stability: { stable: unknown[]; unstable: unknown[]; unassessed: string[] };
|
|
262
|
+
};
|
|
263
|
+
expect(payload.records).toHaveLength(1);
|
|
264
|
+
expect(payload.records[0].name).toBe("web");
|
|
265
|
+
expect(payload.records[0].inputDigest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
266
|
+
expect(payload.records[0].contentDigest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
267
|
+
expect(payload.records[0].capabilityProfile?.name).toBe("test-profile");
|
|
268
|
+
expect(payload.stability.stable).toHaveLength(1);
|
|
269
|
+
expect(payload.stability.unassessed).toEqual([]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("refuses outside a chant project instead of discovering an arbitrary directory", async () => {
|
|
273
|
+
process.chdir(mkTree({ "readme.txt": "not a chant project" }));
|
|
274
|
+
await expect(verb("renders").handler({ verb: "renders", rawArgs: [] })).rejects.toThrow(/Not a chant project/);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("chant helm diff", () => {
|
|
279
|
+
const PROFILE: HelmCapabilityProfile = { name: "prod", kubeVersion: "1.33.6" };
|
|
280
|
+
const RENDERED_A = [
|
|
281
|
+
"---",
|
|
282
|
+
"# Source: tiny/templates/configmap.yaml",
|
|
283
|
+
"apiVersion: v1",
|
|
284
|
+
"kind: ConfigMap",
|
|
285
|
+
"metadata:",
|
|
286
|
+
" name: tiny-config",
|
|
287
|
+
" namespace: web",
|
|
288
|
+
"data:",
|
|
289
|
+
" color: blue",
|
|
290
|
+
"",
|
|
291
|
+
].join("\n");
|
|
292
|
+
const RENDERED_B = RENDERED_A.replace("color: blue", "color: green");
|
|
293
|
+
|
|
294
|
+
function freshRoot(): string {
|
|
295
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-helm-diff-cli-"));
|
|
296
|
+
cleanupDirs.push(dir);
|
|
297
|
+
return dir;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
it("rejects anything other than exactly two digest positionals", async () => {
|
|
301
|
+
await expect(verb("diff").handler({ verb: "diff", rawArgs: [] })).rejects.toThrow(
|
|
302
|
+
/Usage: chant helm diff <from-digest> <to-digest>/,
|
|
303
|
+
);
|
|
304
|
+
await expect(verb("diff").handler({ verb: "diff", rawArgs: ["only-one"] })).rejects.toThrow(
|
|
305
|
+
/Usage: chant helm diff <from-digest> <to-digest>/,
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("rejects flags it does not know", async () => {
|
|
310
|
+
await expect(
|
|
311
|
+
verb("diff").handler({ verb: "diff", rawArgs: ["a", "b", "--bogus"] }),
|
|
312
|
+
).rejects.toThrow(/Unknown flag: --bogus[\s\S]*--json/);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("reports the field change between two stored renders, text and --json", async () => {
|
|
316
|
+
const root = freshRoot();
|
|
317
|
+
const { manifest: from } = persistHelmRender({
|
|
318
|
+
rendered: RENDERED_A,
|
|
319
|
+
releaseName: "rel",
|
|
320
|
+
chart: "tiny",
|
|
321
|
+
namespace: "web",
|
|
322
|
+
capabilityProfile: PROFILE,
|
|
323
|
+
root,
|
|
324
|
+
});
|
|
325
|
+
const { manifest: to } = persistHelmRender({
|
|
326
|
+
rendered: RENDERED_B,
|
|
327
|
+
releaseName: "rel",
|
|
328
|
+
chart: "tiny",
|
|
329
|
+
namespace: "web",
|
|
330
|
+
capabilityProfile: PROFILE,
|
|
331
|
+
root,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const origRoot = process.env.CHANT_HELM_RENDER_ROOT;
|
|
335
|
+
process.env.CHANT_HELM_RENDER_ROOT = root;
|
|
336
|
+
try {
|
|
337
|
+
expect(
|
|
338
|
+
await verb("diff").handler({ verb: "diff", rawArgs: [from.contentDigest, to.contentDigest] }),
|
|
339
|
+
).toBe(0);
|
|
340
|
+
const out = logged.join("\n");
|
|
341
|
+
expect(out).toContain("CHANGED ConfigMap web/tiny-config");
|
|
342
|
+
expect(out).toContain("changed data.color: \"blue\" -> \"green\"");
|
|
343
|
+
|
|
344
|
+
logged = [];
|
|
345
|
+
expect(
|
|
346
|
+
await verb("diff").handler({ verb: "diff", rawArgs: [from.contentDigest, to.contentDigest, "--json"] }),
|
|
347
|
+
).toBe(0);
|
|
348
|
+
const payload = JSON.parse(logged.join("\n"));
|
|
349
|
+
expect(payload.changed).toHaveLength(1);
|
|
350
|
+
expect(payload.changed[0].changes).toEqual([
|
|
351
|
+
{ path: "data.color", kind: "changed", before: "blue", after: "green" },
|
|
352
|
+
]);
|
|
353
|
+
expect(payload.unindexed).toEqual({ from: 0, to: 0 });
|
|
354
|
+
} finally {
|
|
355
|
+
if (origRoot === undefined) delete process.env.CHANT_HELM_RENDER_ROOT;
|
|
356
|
+
else process.env.CHANT_HELM_RENDER_ROOT = origRoot;
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("surfaces the store's own error when a digest was never persisted", async () => {
|
|
361
|
+
const root = freshRoot();
|
|
362
|
+
const origRoot = process.env.CHANT_HELM_RENDER_ROOT;
|
|
363
|
+
process.env.CHANT_HELM_RENDER_ROOT = root;
|
|
364
|
+
try {
|
|
365
|
+
await expect(
|
|
366
|
+
verb("diff").handler({
|
|
367
|
+
verb: "diff",
|
|
368
|
+
rawArgs: [`sha256:${"0".repeat(64)}`, `sha256:${"1".repeat(64)}`],
|
|
369
|
+
}),
|
|
370
|
+
).rejects.toThrow(/no stored render for sha256:0{64}/);
|
|
371
|
+
} finally {
|
|
372
|
+
if (origRoot === undefined) delete process.env.CHANT_HELM_RENDER_ROOT;
|
|
373
|
+
else process.env.CHANT_HELM_RENDER_ROOT = origRoot;
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("formatRenderDiff", () => {
|
|
379
|
+
it("prints added, removed, and changed documents with field-level lines", () => {
|
|
380
|
+
const out = formatRenderDiff({
|
|
381
|
+
from: { contentDigest: `sha256:${"a".repeat(64)}`, chart: "tiny", releaseName: "rel-a" },
|
|
382
|
+
to: { contentDigest: `sha256:${"b".repeat(64)}`, chart: "tiny", releaseName: "rel-b" },
|
|
383
|
+
added: [{ kind: "Secret", namespace: "web", name: "new-secret", source: null, digest: `sha256:${"c".repeat(64)}` }],
|
|
384
|
+
removed: [{ kind: "ConfigMap", namespace: "web", name: "old-cm", source: null, digest: `sha256:${"d".repeat(64)}` }],
|
|
385
|
+
changed: [
|
|
386
|
+
{
|
|
387
|
+
kind: "Deployment",
|
|
388
|
+
namespace: "web",
|
|
389
|
+
name: "app",
|
|
390
|
+
source: null,
|
|
391
|
+
beforeDigest: `sha256:${"e".repeat(64)}`,
|
|
392
|
+
afterDigest: `sha256:${"f".repeat(64)}`,
|
|
393
|
+
changes: [{ path: "spec.replicas", kind: "changed", before: 2, after: 3 }],
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
unchanged: [{ kind: "ServiceAccount", namespace: "web", name: "sa", source: null }],
|
|
397
|
+
unindexed: { from: 0, to: 0 },
|
|
398
|
+
});
|
|
399
|
+
expect(out).toContain("REMOVED ConfigMap web/old-cm");
|
|
400
|
+
expect(out).toContain("ADDED Secret web/new-secret");
|
|
401
|
+
expect(out).toContain("CHANGED Deployment web/app (1 field change(s))");
|
|
402
|
+
expect(out).toContain("changed spec.replicas: 2 -> 3");
|
|
403
|
+
expect(out).toContain("1 unchanged document(s)");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("says so plainly when there are no differences", () => {
|
|
407
|
+
const out = formatRenderDiff({
|
|
408
|
+
from: { contentDigest: `sha256:${"a".repeat(64)}`, chart: "tiny", releaseName: "rel" },
|
|
409
|
+
to: { contentDigest: `sha256:${"a".repeat(64)}`, chart: "tiny", releaseName: "rel" },
|
|
410
|
+
added: [],
|
|
411
|
+
removed: [],
|
|
412
|
+
changed: [],
|
|
413
|
+
unchanged: [{ kind: "ServiceAccount", namespace: "web", name: "sa", source: null }],
|
|
414
|
+
unindexed: { from: 0, to: 0 },
|
|
415
|
+
});
|
|
416
|
+
expect(out).toContain("no differences — identical documents on both sides");
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("chant helm diff --live (#1250)", () => {
|
|
421
|
+
it("requires a content digest and an environment", async () => {
|
|
422
|
+
await expect(verb("diff").handler({ verb: "diff", rawArgs: ["--live"] })).rejects.toThrow(
|
|
423
|
+
/Usage: chant helm diff <content-digest> <environment> --live/,
|
|
424
|
+
);
|
|
425
|
+
await expect(verb("diff").handler({ verb: "diff", rawArgs: ["sha256:abc", "--live"] })).rejects.toThrow(
|
|
426
|
+
/Usage: chant helm diff <content-digest> <environment> --live/,
|
|
427
|
+
);
|
|
428
|
+
await expect(
|
|
429
|
+
verb("diff").handler({ verb: "diff", rawArgs: ["sha256:abc", "prod", "extra", "--live"] }),
|
|
430
|
+
).rejects.toThrow(/Usage: chant helm diff <content-digest> <environment> --live/);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("without --live, two positionals dispatch to the offline render-to-render diff instead of refusing", async () => {
|
|
434
|
+
// Neither digest is in the store, so the offline path's own refusal fires —
|
|
435
|
+
// proof this reaches diffRenders, not a "needs --live" rejection.
|
|
436
|
+
const missing = `sha256:${"0".repeat(64)}`;
|
|
437
|
+
await expect(
|
|
438
|
+
verb("diff").handler({ verb: "diff", rawArgs: [missing, "prod"] }),
|
|
439
|
+
).rejects.toThrow(new RegExp(`no stored render for ${missing}`));
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("refuses a digest the render store has never seen, rather than reporting a false clean diff", async () => {
|
|
443
|
+
const missing = `sha256:${"0".repeat(64)}`;
|
|
444
|
+
const code = await verb("diff").handler({ verb: "diff", rawArgs: [missing, "prod", "--live"] });
|
|
445
|
+
expect(code).toBe(1);
|
|
446
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("no stored render"));
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("rejects flags it does not know", async () => {
|
|
450
|
+
await expect(verb("diff").handler({ verb: "diff", rawArgs: ["--bogus"] })).rejects.toThrow(
|
|
451
|
+
/Unknown flag: --bogus[\s\S]*--live/,
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// The full drift-reporting path (a real stored render vs a fake live
|
|
457
|
+
// cluster) is covered at the render-diff.ts unit level — render-diff.test.ts
|
|
458
|
+
// — the same split describe-resources.test.ts / lifecycle-integration.test.ts
|
|
459
|
+
// keep for the release-scoped read: CLI dispatch here, cluster behavior there.
|
|
460
|
+
|
|
461
|
+
describe("formatRenderRecords", () => {
|
|
462
|
+
it("prints unpinned renders honestly and flags unstable groups", () => {
|
|
463
|
+
const records = [
|
|
464
|
+
{ name: "legacy", chart: "old-chart", version: "1.0.0" },
|
|
465
|
+
{
|
|
466
|
+
name: "web",
|
|
467
|
+
chart: "tiny",
|
|
468
|
+
version: "0.1.0",
|
|
469
|
+
capabilityProfile: { name: "gke", kubeVersion: "1.33.0" },
|
|
470
|
+
inputDigest: `sha256:${"a".repeat(64)}`,
|
|
471
|
+
contentDigest: `sha256:${"b".repeat(64)}`,
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
name: "web",
|
|
475
|
+
chart: "tiny",
|
|
476
|
+
version: "0.1.0",
|
|
477
|
+
capabilityProfile: { name: "gke", kubeVersion: "1.33.0" },
|
|
478
|
+
inputDigest: `sha256:${"a".repeat(64)}`,
|
|
479
|
+
contentDigest: `sha256:${"c".repeat(64)}`,
|
|
480
|
+
},
|
|
481
|
+
];
|
|
482
|
+
const out = formatRenderRecords(records, renderStability(records));
|
|
483
|
+
expect(out).toContain("(unpinned)");
|
|
484
|
+
expect(out).toContain("0 stable group(s), 1 unstable, 1 unassessed");
|
|
485
|
+
expect(out).toContain("UNSTABLE web: 2 distinct content digests");
|
|
486
|
+
});
|
|
487
|
+
});
|