@intentius/chant-lexicon-helm 0.46.0 → 0.50.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 +19 -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 +25 -13
- 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/op/builders.d.ts +47 -0
- package/dist/op/builders.d.ts.map +1 -0
- 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 +106 -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/op/builders.test.ts +70 -0
- package/src/op/builders.ts +66 -0
- 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 +97 -12
- 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,824 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `helmInstall` release recording (#1243, epic #1228 phase 4).
|
|
3
|
+
*
|
|
4
|
+
* A helm deploy through a chant op used to run `helm upgrade --install` and
|
|
5
|
+
* return void — no trace in the release ledger. It now computes an
|
|
6
|
+
* *input-side* digest (chart, chart version, resolved values, declared
|
|
7
|
+
* capability profile) and appends a `ReleaseRecord` keyed by it after a
|
|
8
|
+
* successful deploy, via the same `maybeRecordAutoRelease` convention the
|
|
9
|
+
* `chant run --components` post-run step uses.
|
|
10
|
+
*
|
|
11
|
+
* The helm and kubectl binaries are scripted doubles (promisify-aware exec
|
|
12
|
+
* mock) and the ledger append is a captured mock — no cluster, no git, no
|
|
13
|
+
* helm. The kubectl double is what the deploy-time capability-profile
|
|
14
|
+
* assertion (#1244) probes as the "live cluster".
|
|
15
|
+
*/
|
|
16
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { load } from "js-yaml";
|
|
22
|
+
|
|
23
|
+
const helm = vi.hoisted(() => ({
|
|
24
|
+
calls: [] as string[],
|
|
25
|
+
fail: false,
|
|
26
|
+
/** Scripted `helm get metadata -o json` body (pinned path, #1242). */
|
|
27
|
+
metadata: undefined as Record<string, unknown> | undefined,
|
|
28
|
+
metadataFail: false,
|
|
29
|
+
/**
|
|
30
|
+
* Wrapper-chart snapshot captured at `helm upgrade` time (#1242) — the
|
|
31
|
+
* activity deletes its temp dir afterwards, so the double reads the files
|
|
32
|
+
* the moment helm would. Keys are chart-relative paths.
|
|
33
|
+
*/
|
|
34
|
+
wrapperFiles: undefined as Record<string, string> | undefined,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
/** Scripted kubectl double for the capability probe (#1244) — what the "live cluster" reports. */
|
|
38
|
+
const cluster = vi.hoisted(() => ({
|
|
39
|
+
serverVersion: { major: "1", minor: "33", gitVersion: "v1.33.6+k3s1" } as Record<string, unknown>,
|
|
40
|
+
apiVersions: ["v1", "apps/v1", "batch/v1"],
|
|
41
|
+
probeFail: false,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const ledger = vi.hoisted(() => ({
|
|
45
|
+
calls: [] as Array<Record<string, unknown>>,
|
|
46
|
+
outcome: undefined as unknown,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
vi.mock("node:child_process", async () => {
|
|
50
|
+
const { readFileSync, readdirSync, statSync } = await import("node:fs");
|
|
51
|
+
const { join } = await import("node:path");
|
|
52
|
+
const snapshotDir = (dir: string, rel = ""): Record<string, string> => {
|
|
53
|
+
const files: Record<string, string> = {};
|
|
54
|
+
for (const entry of readdirSync(join(dir, rel))) {
|
|
55
|
+
const relPath = rel ? `${rel}/${entry}` : entry;
|
|
56
|
+
if (statSync(join(dir, relPath)).isDirectory()) {
|
|
57
|
+
Object.assign(files, snapshotDir(dir, relPath));
|
|
58
|
+
} else {
|
|
59
|
+
files[relPath] = readFileSync(join(dir, relPath), "utf8");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return files;
|
|
63
|
+
};
|
|
64
|
+
const custom = Symbol.for("nodejs.util.promisify.custom");
|
|
65
|
+
const exec = ((_cmd: string, _opts: unknown, cb?: (...a: unknown[]) => void) => {
|
|
66
|
+
cb?.(new Error("unmocked exec path"));
|
|
67
|
+
}) as unknown as Record<symbol, unknown>;
|
|
68
|
+
exec[custom] = async (cmd: string) => {
|
|
69
|
+
helm.calls.push(cmd);
|
|
70
|
+
if (cmd.startsWith("helm get metadata")) {
|
|
71
|
+
if (helm.metadataFail) throw new Error("Error: release: not found");
|
|
72
|
+
return { stdout: JSON.stringify(helm.metadata ?? {}), stderr: "" };
|
|
73
|
+
}
|
|
74
|
+
if (cmd.startsWith("helm upgrade")) {
|
|
75
|
+
// Pinned installs pass a chart directory; snapshot it before the
|
|
76
|
+
// activity's cleanup so tests can assert the exact bytes helm saw.
|
|
77
|
+
const chartArg = cmd.split(" ")[5];
|
|
78
|
+
try {
|
|
79
|
+
helm.wrapperFiles = snapshotDir(chartArg);
|
|
80
|
+
} catch {
|
|
81
|
+
helm.wrapperFiles = undefined; // unpinned: chart ref is not a local dir
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (cmd.startsWith("kubectl")) {
|
|
85
|
+
if (cluster.probeFail) {
|
|
86
|
+
throw new Error("The connection to the server localhost:8080 was refused");
|
|
87
|
+
}
|
|
88
|
+
if (cmd.startsWith("kubectl version")) {
|
|
89
|
+
return {
|
|
90
|
+
stdout: JSON.stringify({ clientVersion: {}, serverVersion: cluster.serverVersion }),
|
|
91
|
+
stderr: "",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (cmd.startsWith("kubectl api-versions")) {
|
|
95
|
+
return { stdout: cluster.apiVersions.join("\n") + "\n", stderr: "" };
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`unscripted kubectl invocation: ${cmd}`);
|
|
98
|
+
}
|
|
99
|
+
if (helm.fail) throw new Error("Error: UPGRADE FAILED: context deadline exceeded");
|
|
100
|
+
return { stdout: "Release deployed\n", stderr: "" };
|
|
101
|
+
};
|
|
102
|
+
return { exec };
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
vi.mock("@intentius/chant/components/auto-release", () => ({
|
|
106
|
+
maybeRecordAutoRelease: async (run: Record<string, unknown>) => {
|
|
107
|
+
ledger.calls.push(run);
|
|
108
|
+
return (
|
|
109
|
+
ledger.outcome ?? {
|
|
110
|
+
recorded: true,
|
|
111
|
+
commit: "abc1234",
|
|
112
|
+
record: {
|
|
113
|
+
version: 1,
|
|
114
|
+
component: run.component,
|
|
115
|
+
env: run.env,
|
|
116
|
+
digest: run.digest,
|
|
117
|
+
gitSha: "deadbeef",
|
|
118
|
+
runId: run.runId,
|
|
119
|
+
timestamp: "2026-08-24T00:00:00.000Z",
|
|
120
|
+
actor: "tester",
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
},
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
import {
|
|
128
|
+
helmInstall,
|
|
129
|
+
helmInstallInputDigest,
|
|
130
|
+
PinnedInstallInputError,
|
|
131
|
+
PinnedProfileMismatchError,
|
|
132
|
+
PinnedRenderIntegrityError,
|
|
133
|
+
PinnedRenderNotFoundError,
|
|
134
|
+
} from "./helm";
|
|
135
|
+
import { loadRenderContent, persistHelmRender, type RenderManifest } from "../../render-store";
|
|
136
|
+
import { routeRender } from "../../render-wrapper";
|
|
137
|
+
|
|
138
|
+
let dir: string;
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
dir = mkdtempSync(join(tmpdir(), "chant-helm-activity-"));
|
|
142
|
+
helm.calls = [];
|
|
143
|
+
helm.fail = false;
|
|
144
|
+
helm.metadata = undefined;
|
|
145
|
+
helm.metadataFail = false;
|
|
146
|
+
helm.wrapperFiles = undefined;
|
|
147
|
+
cluster.serverVersion = { major: "1", minor: "33", gitVersion: "v1.33.6+k3s1" };
|
|
148
|
+
cluster.apiVersions = ["v1", "apps/v1", "batch/v1"];
|
|
149
|
+
cluster.probeFail = false;
|
|
150
|
+
ledger.calls = [];
|
|
151
|
+
ledger.outcome = undefined;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
rmSync(dir, { recursive: true, force: true });
|
|
156
|
+
vi.restoreAllMocks();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
function valuesFile(name: string, content: string): string {
|
|
160
|
+
const file = join(dir, name);
|
|
161
|
+
writeFileSync(file, content);
|
|
162
|
+
return file;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
describe("helmInstallInputDigest", () => {
|
|
166
|
+
test("digests the canonical JSON of { chart, chartVersion, values } — pinned against a hand-computed hash", () => {
|
|
167
|
+
const values = valuesFile("values.yaml", "b: 2\na: 1\n");
|
|
168
|
+
const digest = helmInstallInputDigest({
|
|
169
|
+
name: "web",
|
|
170
|
+
chart: "./chart",
|
|
171
|
+
chartVersion: "1.2.3",
|
|
172
|
+
values,
|
|
173
|
+
});
|
|
174
|
+
// Independently derived: canonical JSON sorts keys, so the exact bytes
|
|
175
|
+
// are known without calling canonicalJson — a non-circular anchor.
|
|
176
|
+
const canonical = '{"chart":"./chart","chartVersion":"1.2.3","values":{"a":1,"b":2}}';
|
|
177
|
+
const expected = `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
|
|
178
|
+
expect(digest).toBe(expected);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("stable across value key order", () => {
|
|
182
|
+
const a = valuesFile("a.yaml", "replicas: 3\nimage:\n tag: v1\n repo: web\n");
|
|
183
|
+
const b = valuesFile("b.yaml", "image:\n repo: web\n tag: v1\nreplicas: 3\n");
|
|
184
|
+
const args = { name: "web", chart: "./chart", chartVersion: "1.2.3" };
|
|
185
|
+
expect(helmInstallInputDigest({ ...args, values: a })).toBe(
|
|
186
|
+
helmInstallInputDigest({ ...args, values: b }),
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("changes when a value changes", () => {
|
|
191
|
+
const v1 = valuesFile("v1.yaml", "replicas: 3\n");
|
|
192
|
+
const v2 = valuesFile("v2.yaml", "replicas: 4\n");
|
|
193
|
+
const args = { name: "web", chart: "./chart" };
|
|
194
|
+
expect(helmInstallInputDigest({ ...args, values: v1 })).not.toBe(
|
|
195
|
+
helmInstallInputDigest({ ...args, values: v2 }),
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("changes when the chart version changes", () => {
|
|
200
|
+
const values = valuesFile("values.yaml", "replicas: 3\n");
|
|
201
|
+
expect(
|
|
202
|
+
helmInstallInputDigest({ name: "web", chart: "./chart", values, chartVersion: "1.2.3" }),
|
|
203
|
+
).not.toBe(
|
|
204
|
+
helmInstallInputDigest({ name: "web", chart: "./chart", values, chartVersion: "1.2.4" }),
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("--set entries resolve into the values at their dotted path, set winning over the file", () => {
|
|
209
|
+
const inFile = valuesFile("in-file.yaml", "image:\n tag: v2\n");
|
|
210
|
+
const overridden = valuesFile("overridden.yaml", "image:\n tag: v1\n");
|
|
211
|
+
expect(
|
|
212
|
+
helmInstallInputDigest({
|
|
213
|
+
name: "web",
|
|
214
|
+
chart: "./chart",
|
|
215
|
+
values: overridden,
|
|
216
|
+
set: { "image.tag": "v2" },
|
|
217
|
+
}),
|
|
218
|
+
).toBe(helmInstallInputDigest({ name: "web", chart: "./chart", values: inFile }));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a declared capability profile joins the digest, order-insensitively for apiVersions", () => {
|
|
222
|
+
const values = valuesFile("values.yaml", "replicas: 3\n");
|
|
223
|
+
const bare = helmInstallInputDigest({ name: "web", chart: "./chart", values });
|
|
224
|
+
const profiled = helmInstallInputDigest({
|
|
225
|
+
name: "web",
|
|
226
|
+
chart: "./chart",
|
|
227
|
+
values,
|
|
228
|
+
capabilityProfile: { kubeVersion: "1.33.6", apiVersions: ["batch/v1", "apps/v1"] },
|
|
229
|
+
});
|
|
230
|
+
expect(profiled).not.toBe(bare);
|
|
231
|
+
expect(
|
|
232
|
+
helmInstallInputDigest({
|
|
233
|
+
name: "web",
|
|
234
|
+
chart: "./chart",
|
|
235
|
+
values,
|
|
236
|
+
capabilityProfile: { kubeVersion: "1.33.6", apiVersions: ["apps/v1", "batch/v1"] },
|
|
237
|
+
}),
|
|
238
|
+
).toBe(profiled);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("helmInstall release recording (#1243)", () => {
|
|
243
|
+
test("a successful deploy appends exactly one record carrying the input digest", async () => {
|
|
244
|
+
const values = valuesFile("values.yaml", "b: 2\na: 1\n");
|
|
245
|
+
const args = { name: "web", chart: "./chart", chartVersion: "1.2.3", values };
|
|
246
|
+
const result = await helmInstall(args);
|
|
247
|
+
|
|
248
|
+
expect(helm.calls).toHaveLength(1);
|
|
249
|
+
expect(helm.calls[0]).toContain("helm upgrade --install --wait web ./chart");
|
|
250
|
+
expect(helm.calls[0]).toContain("--version 1.2.3");
|
|
251
|
+
|
|
252
|
+
const expected = helmInstallInputDigest(args);
|
|
253
|
+
expect(ledger.calls).toHaveLength(1);
|
|
254
|
+
expect(ledger.calls[0]).toMatchObject({
|
|
255
|
+
component: "web",
|
|
256
|
+
env: "local",
|
|
257
|
+
success: true,
|
|
258
|
+
digest: expected,
|
|
259
|
+
});
|
|
260
|
+
expect(result.inputDigest).toBe(expected);
|
|
261
|
+
expect(result.release.recorded).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("component/env/runId args flow into the record when declared", async () => {
|
|
265
|
+
await helmInstall({
|
|
266
|
+
name: "web",
|
|
267
|
+
chart: "./chart",
|
|
268
|
+
component: "search-service",
|
|
269
|
+
env: "staging",
|
|
270
|
+
runId: "gha-42",
|
|
271
|
+
});
|
|
272
|
+
expect(ledger.calls[0]).toMatchObject({
|
|
273
|
+
component: "search-service",
|
|
274
|
+
env: "staging",
|
|
275
|
+
runId: "gha-42",
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("a failed deploy appends nothing", async () => {
|
|
280
|
+
helm.fail = true;
|
|
281
|
+
await expect(helmInstall({ name: "web", chart: "./chart" })).rejects.toThrow("UPGRADE FAILED");
|
|
282
|
+
expect(ledger.calls).toHaveLength(0);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a ledger-append failure warns and the activity still succeeds", async () => {
|
|
286
|
+
ledger.outcome = { recorded: false, reason: "error", error: "push to chant/lifecycle failed" };
|
|
287
|
+
const warn = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
288
|
+
const result = await helmInstall({ name: "web", chart: "./chart" });
|
|
289
|
+
|
|
290
|
+
expect(result.release).toEqual({
|
|
291
|
+
recorded: false,
|
|
292
|
+
reason: "error",
|
|
293
|
+
error: "push to chant/lifecycle failed",
|
|
294
|
+
});
|
|
295
|
+
const warning = warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes("no release record"));
|
|
296
|
+
expect(warning).toContain('helm release "web" deployed, but no release record was appended');
|
|
297
|
+
expect(warning).toContain("push to chant/lifecycle failed");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("recordRelease: false opts out without warning", async () => {
|
|
301
|
+
const warn = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
302
|
+
const result = await helmInstall({ name: "web", chart: "./chart", recordRelease: false });
|
|
303
|
+
expect(ledger.calls).toHaveLength(0);
|
|
304
|
+
expect(result.release).toEqual({ recorded: false, reason: "opted-out" });
|
|
305
|
+
expect(warn.mock.calls.map((c) => String(c[0]))).not.toContainEqual(
|
|
306
|
+
expect.stringContaining("no release record"),
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("capability profile assertion at deploy time (#1244)", () => {
|
|
312
|
+
const prod = { name: "prod", kubeVersion: "1.33.6", apiVersions: ["apps/v1", "batch/v1"] };
|
|
313
|
+
|
|
314
|
+
function helmMutations(): string[] {
|
|
315
|
+
return helm.calls.filter((cmd) => cmd.startsWith("helm "));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function kubectlProbes(): string[] {
|
|
319
|
+
return helm.calls.filter((cmd) => cmd.startsWith("kubectl "));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
test("a matching profile deploys, probing before any helm mutation", async () => {
|
|
323
|
+
const result = await helmInstall({ name: "web", chart: "./chart", capabilityProfile: prod });
|
|
324
|
+
|
|
325
|
+
expect(kubectlProbes().sort()).toEqual(["kubectl api-versions", "kubectl version -o json"]);
|
|
326
|
+
expect(helmMutations()).toHaveLength(1);
|
|
327
|
+
const firstHelm = helm.calls.findIndex((cmd) => cmd.startsWith("helm "));
|
|
328
|
+
for (const probe of kubectlProbes()) {
|
|
329
|
+
expect(helm.calls.indexOf(probe)).toBeLessThan(firstHelm);
|
|
330
|
+
}
|
|
331
|
+
expect(result.profileAssertion).toEqual({ matched: true });
|
|
332
|
+
expect(ledger.calls[0]).not.toHaveProperty("profileOverride", expect.anything());
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("patch skew within the declared major.minor is not a divergence", async () => {
|
|
336
|
+
cluster.serverVersion = { major: "1", minor: "33", gitVersion: "v1.33.1" };
|
|
337
|
+
const result = await helmInstall({
|
|
338
|
+
name: "web",
|
|
339
|
+
chart: "./chart",
|
|
340
|
+
capabilityProfile: { kubeVersion: "1.33.6" },
|
|
341
|
+
});
|
|
342
|
+
expect(result.profileAssertion).toEqual({ matched: true });
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("a kubeVersion mismatch refuses before any helm mutation, naming both versions", async () => {
|
|
346
|
+
await expect(
|
|
347
|
+
helmInstall({
|
|
348
|
+
name: "web",
|
|
349
|
+
chart: "./chart",
|
|
350
|
+
capabilityProfile: { name: "staging", kubeVersion: "1.31.4" },
|
|
351
|
+
}),
|
|
352
|
+
).rejects.toThrow(/profile declares 1\.31\.4 \(1\.31\).*cluster runs v1\.33\.6\+k3s1 \(1\.33\)/s);
|
|
353
|
+
|
|
354
|
+
expect(helmMutations()).toHaveLength(0);
|
|
355
|
+
expect(ledger.calls).toHaveLength(0);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("the mismatch names the profile and offers the recorded override", async () => {
|
|
359
|
+
const args = {
|
|
360
|
+
name: "web",
|
|
361
|
+
chart: "./chart",
|
|
362
|
+
capabilityProfile: { name: "staging", kubeVersion: "1.31.4" },
|
|
363
|
+
};
|
|
364
|
+
await expect(helmInstall(args)).rejects.toThrow('capability profile "staging"');
|
|
365
|
+
await expect(helmInstall(args)).rejects.toThrow("overrideProfileAssertion");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("a profile-declared apiVersion the cluster does not serve refuses, naming it", async () => {
|
|
369
|
+
await expect(
|
|
370
|
+
helmInstall({
|
|
371
|
+
name: "web",
|
|
372
|
+
chart: "./chart",
|
|
373
|
+
capabilityProfile: {
|
|
374
|
+
kubeVersion: "1.33.6",
|
|
375
|
+
apiVersions: ["apps/v1", "monitoring.coreos.com/v1"],
|
|
376
|
+
},
|
|
377
|
+
}),
|
|
378
|
+
).rejects.toThrow(
|
|
379
|
+
"apiVersion monitoring.coreos.com/v1: declared by the profile, not served by the cluster",
|
|
380
|
+
);
|
|
381
|
+
expect(helmMutations()).toHaveLength(0);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("an unreachable cluster refuses — a deploy that cannot verify its profile does not proceed", async () => {
|
|
385
|
+
cluster.probeFail = true;
|
|
386
|
+
await expect(
|
|
387
|
+
helmInstall({ name: "web", chart: "./chart", capabilityProfile: prod }),
|
|
388
|
+
).rejects.toThrow(/capabilities could not be verified.*connection to the server/s);
|
|
389
|
+
expect(helmMutations()).toHaveLength(0);
|
|
390
|
+
expect(ledger.calls).toHaveLength(0);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("no declared profile probes nothing — today's behavior, kubectl never invoked", async () => {
|
|
394
|
+
await helmInstall({ name: "web", chart: "./chart" });
|
|
395
|
+
expect(kubectlProbes()).toHaveLength(0);
|
|
396
|
+
expect(helm.calls).toHaveLength(1);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("a profile declaring no facts probes nothing", async () => {
|
|
400
|
+
const result = await helmInstall({
|
|
401
|
+
name: "web",
|
|
402
|
+
chart: "./chart",
|
|
403
|
+
capabilityProfile: { apiVersions: [] },
|
|
404
|
+
});
|
|
405
|
+
expect(kubectlProbes()).toHaveLength(0);
|
|
406
|
+
expect(result.profileAssertion).toBeUndefined();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("overrideProfileAssertion deploys through a mismatch, warns, and records the override in the release record", async () => {
|
|
410
|
+
const warn = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
411
|
+
const result = await helmInstall({
|
|
412
|
+
name: "web",
|
|
413
|
+
chart: "./chart",
|
|
414
|
+
capabilityProfile: { name: "staging", kubeVersion: "1.31.4", apiVersions: ["missing.io/v1"] },
|
|
415
|
+
overrideProfileAssertion: true,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(helmMutations()).toHaveLength(1);
|
|
419
|
+
expect(result.profileAssertion).toEqual({
|
|
420
|
+
matched: false,
|
|
421
|
+
overridden: true,
|
|
422
|
+
divergences: [
|
|
423
|
+
"kubeVersion: profile declares 1.31.4 (1.31), cluster runs v1.33.6+k3s1 (1.33)",
|
|
424
|
+
"apiVersion missing.io/v1: declared by the profile, not served by the cluster",
|
|
425
|
+
],
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const warning = warn.mock.calls
|
|
429
|
+
.map((c) => String(c[0]))
|
|
430
|
+
.find((m) => m.includes("despite capability profile"));
|
|
431
|
+
expect(warning).toContain('"staging"');
|
|
432
|
+
expect(warning).toContain("kubeVersion: profile declares 1.31.4");
|
|
433
|
+
|
|
434
|
+
expect(ledger.calls).toHaveLength(1);
|
|
435
|
+
expect(ledger.calls[0].profileOverride).toBe(
|
|
436
|
+
"kubeVersion: profile declares 1.31.4 (1.31), cluster runs v1.33.6+k3s1 (1.33); " +
|
|
437
|
+
"apiVersion missing.io/v1: declared by the profile, not served by the cluster",
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("overrideProfileAssertion also covers an unprobeable cluster, recording the probe failure", async () => {
|
|
442
|
+
cluster.probeFail = true;
|
|
443
|
+
const warn = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
444
|
+
const result = await helmInstall({
|
|
445
|
+
name: "web",
|
|
446
|
+
chart: "./chart",
|
|
447
|
+
capabilityProfile: prod,
|
|
448
|
+
overrideProfileAssertion: true,
|
|
449
|
+
});
|
|
450
|
+
expect(helmMutations()).toHaveLength(1);
|
|
451
|
+
expect(result.profileAssertion).toMatchObject({ matched: false, overridden: true });
|
|
452
|
+
expect(String(ledger.calls[0].profileOverride)).toContain("capability probe failed:");
|
|
453
|
+
expect(warn.mock.calls.map((c) => String(c[0])).join("\n")).toContain("capability probe failed:");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("a match with the override flag set records no override", async () => {
|
|
457
|
+
await helmInstall({
|
|
458
|
+
name: "web",
|
|
459
|
+
chart: "./chart",
|
|
460
|
+
capabilityProfile: prod,
|
|
461
|
+
overrideProfileAssertion: true,
|
|
462
|
+
});
|
|
463
|
+
expect(ledger.calls[0].profileOverride).toBeUndefined();
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// ── pinned install path (#1242) ───────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
describe("pinned install path (#1242)", () => {
|
|
470
|
+
const rendered = [
|
|
471
|
+
"---",
|
|
472
|
+
"# Source: umb/charts/kid/crds/kidcrd.yaml",
|
|
473
|
+
"apiVersion: apiextensions.k8s.io/v1",
|
|
474
|
+
"kind: CustomResourceDefinition",
|
|
475
|
+
"metadata:",
|
|
476
|
+
" name: kidthings.example.com",
|
|
477
|
+
"spec:",
|
|
478
|
+
" group: example.com",
|
|
479
|
+
" names:",
|
|
480
|
+
" kind: KidThing",
|
|
481
|
+
"---",
|
|
482
|
+
"# Source: umb/templates/hook-job.yaml",
|
|
483
|
+
"apiVersion: batch/v1",
|
|
484
|
+
"kind: Job",
|
|
485
|
+
"metadata:",
|
|
486
|
+
" name: setup",
|
|
487
|
+
" annotations:",
|
|
488
|
+
' "helm.sh/hook": pre-install',
|
|
489
|
+
"---",
|
|
490
|
+
"# Source: umb/templates/cm.yaml",
|
|
491
|
+
"apiVersion: v1",
|
|
492
|
+
"kind: ConfigMap",
|
|
493
|
+
"metadata:",
|
|
494
|
+
" name: alerts",
|
|
495
|
+
"data:",
|
|
496
|
+
' template: "summary: {{ $labels.instance }} down"',
|
|
497
|
+
].join("\n");
|
|
498
|
+
|
|
499
|
+
/** Persist the fixture render into a per-test store and return its manifest. */
|
|
500
|
+
function storeRender(overrides?: { releaseName?: string }): { manifest: RenderManifest; root: string } {
|
|
501
|
+
const root = join(dir, "render-store");
|
|
502
|
+
const { manifest } = persistHelmRender({
|
|
503
|
+
rendered,
|
|
504
|
+
releaseName: overrides?.releaseName ?? "web",
|
|
505
|
+
chart: "umb",
|
|
506
|
+
chartVersion: "0.1.0",
|
|
507
|
+
values: { replicas: 3 },
|
|
508
|
+
capabilityProfile: { name: "k3d-local", kubeVersion: "1.33.6", apiVersions: ["apps/v1"] },
|
|
509
|
+
root,
|
|
510
|
+
});
|
|
511
|
+
return { manifest, root };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
test("installs the recorded bytes: wrapper argv, CRDs in crds/, docs shipped verbatim through .Files.Get shims", async () => {
|
|
515
|
+
const { manifest, root } = storeRender();
|
|
516
|
+
helm.metadata = { name: "web", revision: 2, version: "0.1.0", namespace: "default" };
|
|
517
|
+
|
|
518
|
+
const result = await helmInstall({ name: "web", contentDigest: manifest.contentDigest, renderStoreRoot: root });
|
|
519
|
+
|
|
520
|
+
// argv: the wrapper directory is the install input — no chart ref, no
|
|
521
|
+
// values, no --set, no --version; nothing helm could re-render from.
|
|
522
|
+
const upgrade = helm.calls.find((c) => c.startsWith("helm upgrade"));
|
|
523
|
+
expect(upgrade).toMatch(/^helm upgrade --install --wait web \S*chant-helm-pinned-[^ ]+$/);
|
|
524
|
+
|
|
525
|
+
// The bytes helm saw are the stored canonical documents, routed: the CRD
|
|
526
|
+
// verbatim in crds/, every other doc verbatim in manifests/ with a
|
|
527
|
+
// .Files.Get shim in templates/ (never re-templated — the ConfigMap
|
|
528
|
+
// carries literal `{{ $labels }}` bytes).
|
|
529
|
+
const files = helm.wrapperFiles!;
|
|
530
|
+
const canonical = loadRenderContent(manifest.contentDigest, { root })!;
|
|
531
|
+
const routed = routeRender(canonical, { chart: manifest.chart, chartVersion: manifest.chartVersion });
|
|
532
|
+
const crdFile = Object.keys(files).find((f) => f.startsWith("crds/"))!;
|
|
533
|
+
expect(files[crdFile]).toBe(routed.crds[0].text + "\n");
|
|
534
|
+
const manifestFiles = Object.keys(files).filter((f) => f.startsWith("manifests/"));
|
|
535
|
+
const shims = Object.keys(files).filter((f) => f.startsWith("templates/"));
|
|
536
|
+
expect(manifestFiles).toHaveLength(2);
|
|
537
|
+
expect(shims).toHaveLength(2);
|
|
538
|
+
for (const doc of [...routed.main, ...routed.hooks]) {
|
|
539
|
+
expect(manifestFiles.map((f) => files[f])).toContain(doc.text + "\n");
|
|
540
|
+
}
|
|
541
|
+
for (const shim of shims) {
|
|
542
|
+
expect(files[shim]).toMatch(/^\{\{ \.Files\.Get "manifests\/[a-z0-9-]+\.yaml" \}\}\n$/);
|
|
543
|
+
}
|
|
544
|
+
expect(files["Chart.yaml"]).toContain("name: umb");
|
|
545
|
+
expect(files["Chart.yaml"]).toContain("version: 0.1.0");
|
|
546
|
+
|
|
547
|
+
// Result fields, complete.
|
|
548
|
+
expect(result.pinned).toBe(true);
|
|
549
|
+
expect(result.contentDigest).toBe(manifest.contentDigest);
|
|
550
|
+
expect(result.inputDigest).toBe(manifest.inputDigest);
|
|
551
|
+
expect(result.releaseName).toBe("web");
|
|
552
|
+
expect(result.namespace).toBeNull();
|
|
553
|
+
expect(result.revision).toBe(2);
|
|
554
|
+
expect(result.chartVersion).toBe("0.1.0");
|
|
555
|
+
expect(result.crdsApplied).toBe(1);
|
|
556
|
+
expect(result.docsApplied).toBe(2);
|
|
557
|
+
expect(result.hooksRun).toBe(1);
|
|
558
|
+
expect(result.profileAssertion).toEqual({ matched: true });
|
|
559
|
+
expect(result.release.recorded).toBe(true);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("the ReleaseRecord is keyed by the content digest and carries the input digest alongside", async () => {
|
|
563
|
+
const { manifest, root } = storeRender();
|
|
564
|
+
helm.metadata = { name: "web", revision: 1, version: "0.1.0" };
|
|
565
|
+
|
|
566
|
+
await helmInstall({ name: "web", contentDigest: manifest.contentDigest, renderStoreRoot: root, env: "prod" });
|
|
567
|
+
|
|
568
|
+
expect(ledger.calls).toHaveLength(1);
|
|
569
|
+
expect(ledger.calls[0].digest).toBe(manifest.contentDigest);
|
|
570
|
+
expect(ledger.calls[0].inputDigest).toBe(manifest.inputDigest);
|
|
571
|
+
expect(ledger.calls[0].env).toBe("prod");
|
|
572
|
+
expect(ledger.calls[0].component).toBe("web");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("the live #1244 assertion runs against the manifest's recorded profile, before the install", async () => {
|
|
576
|
+
const { manifest, root } = storeRender();
|
|
577
|
+
cluster.serverVersion = { major: "1", minor: "30", gitVersion: "v1.30.0" }; // diverges from recorded 1.33.6
|
|
578
|
+
await expect(
|
|
579
|
+
helmInstall({ name: "web", contentDigest: manifest.contentDigest, renderStoreRoot: root }),
|
|
580
|
+
).rejects.toThrow(/does not match the declared capability profile "k3d-local"/);
|
|
581
|
+
expect(helm.calls.filter((c) => c.startsWith("helm"))).toHaveLength(0);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("refuses a digest the store does not hold, before any exec", async () => {
|
|
585
|
+
const { root } = storeRender();
|
|
586
|
+
await expect(
|
|
587
|
+
helmInstall({ name: "web", contentDigest: "sha256:" + "0".repeat(64), renderStoreRoot: root }),
|
|
588
|
+
).rejects.toThrow(PinnedRenderNotFoundError);
|
|
589
|
+
expect(helm.calls).toHaveLength(0);
|
|
590
|
+
expect(ledger.calls).toHaveLength(0);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("verifies the stored bytes against the digest before any mutation — a corrupt entry refuses", async () => {
|
|
594
|
+
const { manifest, root } = storeRender();
|
|
595
|
+
const contentFile = join(root, manifest.contentDigest.replace("sha256:", "sha256-"), "content.yaml");
|
|
596
|
+
appendFileSync(contentFile, "# tampered\n");
|
|
597
|
+
await expect(
|
|
598
|
+
helmInstall({ name: "web", contentDigest: manifest.contentDigest, renderStoreRoot: root }),
|
|
599
|
+
).rejects.toThrow(PinnedRenderIntegrityError);
|
|
600
|
+
expect(helm.calls).toHaveLength(0);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("refuses deploy-time render inputs alongside contentDigest, naming them", async () => {
|
|
604
|
+
const { manifest, root } = storeRender();
|
|
605
|
+
await expect(
|
|
606
|
+
helmInstall({
|
|
607
|
+
name: "web",
|
|
608
|
+
chart: "./chart",
|
|
609
|
+
set: { a: "1" },
|
|
610
|
+
contentDigest: manifest.contentDigest,
|
|
611
|
+
renderStoreRoot: root,
|
|
612
|
+
}),
|
|
613
|
+
).rejects.toThrow(/render input\(s\) `chart`, `set`/);
|
|
614
|
+
expect(helm.calls).toHaveLength(0);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("refuses a release name the bytes were not rendered for", async () => {
|
|
618
|
+
const { manifest, root } = storeRender();
|
|
619
|
+
await expect(
|
|
620
|
+
helmInstall({ name: "other", contentDigest: manifest.contentDigest, renderStoreRoot: root }),
|
|
621
|
+
).rejects.toThrow(PinnedInstallInputError);
|
|
622
|
+
await expect(
|
|
623
|
+
helmInstall({ name: "other", contentDigest: manifest.contentDigest, renderStoreRoot: root }),
|
|
624
|
+
).rejects.toThrow(/rendered for release "web"/);
|
|
625
|
+
expect(helm.calls).toHaveLength(0);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("refuses a declared profile that disagrees with the recorded one, offline, before any probe", async () => {
|
|
629
|
+
const { manifest, root } = storeRender();
|
|
630
|
+
await expect(
|
|
631
|
+
helmInstall({
|
|
632
|
+
name: "web",
|
|
633
|
+
contentDigest: manifest.contentDigest,
|
|
634
|
+
renderStoreRoot: root,
|
|
635
|
+
capabilityProfile: { name: "prod", kubeVersion: "1.30.0" },
|
|
636
|
+
}),
|
|
637
|
+
).rejects.toThrow(PinnedProfileMismatchError);
|
|
638
|
+
expect(helm.calls).toHaveLength(0); // not even the kubectl probe ran
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("a matching declared profile composes with the live assertion and deploys", async () => {
|
|
642
|
+
const { manifest, root } = storeRender();
|
|
643
|
+
helm.metadata = { name: "web", revision: 3, version: "0.1.0" };
|
|
644
|
+
const result = await helmInstall({
|
|
645
|
+
name: "web",
|
|
646
|
+
contentDigest: manifest.contentDigest,
|
|
647
|
+
renderStoreRoot: root,
|
|
648
|
+
capabilityProfile: { name: "k3d-local", kubeVersion: "1.33.6", apiVersions: ["apps/v1"] },
|
|
649
|
+
});
|
|
650
|
+
expect(result.pinned).toBe(true);
|
|
651
|
+
expect(result.revision).toBe(3);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("a metadata read failure after a successful install warns and leaves revision unset", async () => {
|
|
655
|
+
const { manifest, root } = storeRender();
|
|
656
|
+
helm.metadataFail = true;
|
|
657
|
+
const warn = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
658
|
+
const result = await helmInstall({ name: "web", contentDigest: manifest.contentDigest, renderStoreRoot: root });
|
|
659
|
+
expect(result.revision).toBeUndefined();
|
|
660
|
+
expect(result.chartVersion).toBe("0.1.0"); // falls back to the stored render's
|
|
661
|
+
expect(result.release.recorded).toBe(true); // the deploy and its record still happened
|
|
662
|
+
expect(warn.mock.calls.map((c) => String(c[0])).join("\n")).toContain("helm get metadata");
|
|
663
|
+
warn.mockRestore();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("the unpinned path is unchanged: same argv as before #1242, pinned: false, no store access", async () => {
|
|
667
|
+
const result = await helmInstall({ name: "web", chart: "./chart" });
|
|
668
|
+
expect(helm.calls).toEqual(["helm upgrade --install --wait web ./chart"]);
|
|
669
|
+
expect(result.pinned).toBe(false);
|
|
670
|
+
expect(result.contentDigest).toBeUndefined();
|
|
671
|
+
expect(result.releaseName).toBe("web");
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
describe("migration: unpinned release adopts a pinned render in place (#1245)", () => {
|
|
676
|
+
// Shaped after the umbrella-fixture survey corpus (#1232): a parent CRD, a
|
|
677
|
+
// subchart CRD, and the SAME subchart included a second time under an
|
|
678
|
+
// alias — the aliased instance emits a duplicate CRD document for one
|
|
679
|
+
// distinct CRD (epic finding 11), which the wrapper must dedupe. A
|
|
680
|
+
// pre-upgrade hook and two ConfigMaps (parent + subchart) round it out, so
|
|
681
|
+
// the fixture exercises the full "CRD intact, hook fired, configmaps
|
|
682
|
+
// unchanged" claim (epic finding 7) rather than just one document each.
|
|
683
|
+
const rendered = [
|
|
684
|
+
"---",
|
|
685
|
+
"# Source: umbrella-fixture/crds/parentcrd.yaml",
|
|
686
|
+
"apiVersion: apiextensions.k8s.io/v1",
|
|
687
|
+
"kind: CustomResourceDefinition",
|
|
688
|
+
"metadata:",
|
|
689
|
+
" name: parentthings.example.com",
|
|
690
|
+
"spec:",
|
|
691
|
+
" group: example.com",
|
|
692
|
+
" names: { kind: ParentThing }",
|
|
693
|
+
"---",
|
|
694
|
+
"# Source: umbrella-fixture/charts/kid/crds/kidcrd.yaml",
|
|
695
|
+
"apiVersion: apiextensions.k8s.io/v1",
|
|
696
|
+
"kind: CustomResourceDefinition",
|
|
697
|
+
"metadata:",
|
|
698
|
+
" name: kidthings.example.com",
|
|
699
|
+
"spec:",
|
|
700
|
+
" group: example.com",
|
|
701
|
+
" names: { kind: KidThing }",
|
|
702
|
+
"---",
|
|
703
|
+
// Same CRD again via the `kidtwo` alias (#1232's aliased-dependency
|
|
704
|
+
// case) — one distinct CRD, two source documents.
|
|
705
|
+
"# Source: umbrella-fixture/charts/kidtwo/crds/kidcrd.yaml",
|
|
706
|
+
"apiVersion: apiextensions.k8s.io/v1",
|
|
707
|
+
"kind: CustomResourceDefinition",
|
|
708
|
+
"metadata:",
|
|
709
|
+
" name: kidthings.example.com",
|
|
710
|
+
"spec:",
|
|
711
|
+
" group: example.com",
|
|
712
|
+
" names: { kind: KidThing }",
|
|
713
|
+
"---",
|
|
714
|
+
// Annotated pre-upgrade (not just pre-install): the migration deploy is
|
|
715
|
+
// itself an upgrade of the pre-existing unpinned release, so only a hook
|
|
716
|
+
// registered for pre-upgrade actually runs on it.
|
|
717
|
+
"# Source: umbrella-fixture/templates/hook-job.yaml",
|
|
718
|
+
"apiVersion: batch/v1",
|
|
719
|
+
"kind: Job",
|
|
720
|
+
"metadata:",
|
|
721
|
+
" name: setup",
|
|
722
|
+
" annotations:",
|
|
723
|
+
' "helm.sh/hook": pre-upgrade',
|
|
724
|
+
"---",
|
|
725
|
+
"# Source: umbrella-fixture/templates/cm.yaml",
|
|
726
|
+
"apiVersion: v1",
|
|
727
|
+
"kind: ConfigMap",
|
|
728
|
+
"metadata:",
|
|
729
|
+
" name: web-parent",
|
|
730
|
+
"data:",
|
|
731
|
+
" who: parent",
|
|
732
|
+
"---",
|
|
733
|
+
"# Source: umbrella-fixture/charts/kid/templates/cm.yaml",
|
|
734
|
+
"apiVersion: v1",
|
|
735
|
+
"kind: ConfigMap",
|
|
736
|
+
"metadata:",
|
|
737
|
+
" name: web-kid",
|
|
738
|
+
"data:",
|
|
739
|
+
" who: kid",
|
|
740
|
+
].join("\n");
|
|
741
|
+
|
|
742
|
+
function pinForMigration(): { manifest: RenderManifest; root: string } {
|
|
743
|
+
const root = join(dir, "render-store");
|
|
744
|
+
const { manifest } = persistHelmRender({
|
|
745
|
+
rendered,
|
|
746
|
+
releaseName: "web",
|
|
747
|
+
chart: "umbrella-fixture",
|
|
748
|
+
chartVersion: "0.1.0",
|
|
749
|
+
values: { replicas: 1 },
|
|
750
|
+
capabilityProfile: { name: "k3d-local", kubeVersion: "1.33.6", apiVersions: ["apps/v1"] },
|
|
751
|
+
root,
|
|
752
|
+
});
|
|
753
|
+
return { manifest, root };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
test("in-place: same release name, one `helm upgrade --install`, revision advances — never install-under-a-new-name or uninstall+install", async () => {
|
|
757
|
+
// The release already exists from a prior unpinned deploy — revision 1.
|
|
758
|
+
helm.metadata = { name: "web", revision: 1, version: "0.1.0" };
|
|
759
|
+
await helmInstall({ name: "web", chart: "./umbrella-fixture", chartVersion: "0.1.0" });
|
|
760
|
+
expect(helm.calls).toEqual(["helm upgrade --install --wait web ./umbrella-fixture --version 0.1.0"]);
|
|
761
|
+
|
|
762
|
+
// Migrate it onto the pinned render, same name, revision 2 — helm's own
|
|
763
|
+
// upgrade of the same release, not a fresh install under another name.
|
|
764
|
+
const { manifest, root } = pinForMigration();
|
|
765
|
+
helm.calls = [];
|
|
766
|
+
helm.metadata = { name: "web", revision: 2, version: "umbrella-fixture@0.1.0" };
|
|
767
|
+
const result = await helmInstall({ name: "web", contentDigest: manifest.contentDigest, renderStoreRoot: root });
|
|
768
|
+
|
|
769
|
+
const upgrades = helm.calls.filter((c) => c.startsWith("helm upgrade") || c.startsWith("helm install"));
|
|
770
|
+
expect(upgrades).toHaveLength(1); // exactly one mutating helm call — no uninstall, no separate install
|
|
771
|
+
expect(upgrades[0]).toMatch(/^helm upgrade --install --wait web \S*chant-helm-pinned-/);
|
|
772
|
+
expect(result.releaseName).toBe("web");
|
|
773
|
+
expect(result.pinned).toBe(true);
|
|
774
|
+
expect(result.revision).toBe(2); // one more than the pre-existing unpinned release's revision
|
|
775
|
+
|
|
776
|
+
// The wrapper keeps the source chart's identity, so `helm history` reads
|
|
777
|
+
// as one continuous chart, not a swap to a synthetic wrapper chart.
|
|
778
|
+
expect(helm.wrapperFiles!["Chart.yaml"]).toContain("name: umbrella-fixture");
|
|
779
|
+
expect(helm.wrapperFiles!["Chart.yaml"]).toContain("version: 0.1.0");
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("CRDs intact, deduped across the aliased subchart — helm never re-applies crds/ on an upgrade of an existing release", async () => {
|
|
783
|
+
const { manifest, root } = pinForMigration();
|
|
784
|
+
helm.metadata = { name: "web", revision: 2, version: "0.1.0" };
|
|
785
|
+
const result = await helmInstall({ name: "web", contentDigest: manifest.contentDigest, renderStoreRoot: root });
|
|
786
|
+
|
|
787
|
+
// One distinct CRD survives the aliased duplicate (parent CRD + deduped
|
|
788
|
+
// kid CRD), still routed to crds/ — carried for a future fresh install,
|
|
789
|
+
// but this call is an upgrade of an existing release, so helm's own
|
|
790
|
+
// install-only handling of crds/ leaves the already-installed CRDs
|
|
791
|
+
// untouched (documented in the migration guide).
|
|
792
|
+
expect(result.crdsApplied).toBe(2);
|
|
793
|
+
const crdFiles = Object.keys(helm.wrapperFiles!).filter((f) => f.startsWith("crds/"));
|
|
794
|
+
expect(crdFiles).toHaveLength(2);
|
|
795
|
+
const crdNames = crdFiles.map((f) => (load(helm.wrapperFiles![f]) as { metadata: { name: string } }).metadata.name);
|
|
796
|
+
expect(new Set(crdNames)).toEqual(new Set(["parentthings.example.com", "kidthings.example.com"]));
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("hook still fires, configmaps ship byte-identical to the unpinned render's output", async () => {
|
|
800
|
+
const { manifest, root } = pinForMigration();
|
|
801
|
+
helm.metadata = { name: "web", revision: 2, version: "0.1.0" };
|
|
802
|
+
const result = await helmInstall({ name: "web", contentDigest: manifest.contentDigest, renderStoreRoot: root });
|
|
803
|
+
|
|
804
|
+
expect(result.hooksRun).toBe(1);
|
|
805
|
+
const files = helm.wrapperFiles!;
|
|
806
|
+
const manifestFiles = Object.keys(files).filter((f) => f.startsWith("manifests/"));
|
|
807
|
+
const canonical = loadRenderContent(manifest.contentDigest, { root })!;
|
|
808
|
+
const routed = routeRender(canonical, { chart: manifest.chart, chartVersion: manifest.chartVersion });
|
|
809
|
+
// Both ConfigMaps — parent and subchart — reach the cluster as exactly
|
|
810
|
+
// the bytes the render store recorded, never re-templated.
|
|
811
|
+
for (const cm of routed.main.filter((d) => d.kind === "ConfigMap")) {
|
|
812
|
+
expect(manifestFiles.map((f) => files[f])).toContain(cm.text + "\n");
|
|
813
|
+
}
|
|
814
|
+
expect(routed.main.filter((d) => d.kind === "ConfigMap")).toHaveLength(2);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("a flat wrapper under a new release name is refused before any mutation — migrate under the release's own name instead", async () => {
|
|
818
|
+
const { manifest, root } = pinForMigration();
|
|
819
|
+
await expect(
|
|
820
|
+
helmInstall({ name: "web-v2", contentDigest: manifest.contentDigest, renderStoreRoot: root }),
|
|
821
|
+
).rejects.toThrow(/rendered for release "web".*Deploy under "web"/s);
|
|
822
|
+
expect(helm.calls).toHaveLength(0); // refused offline, before the ownership-metadata error helm itself would raise
|
|
823
|
+
});
|
|
824
|
+
});
|