@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,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-lexicon lifecycle integration — Helm row (#1246/#1247, epic #1228).
|
|
3
|
+
*
|
|
4
|
+
* Drives the REAL helmPlugin through core's live-diff and change-set paths,
|
|
5
|
+
* with the helm CLI faked at the PATH edge: a scripted `helm` shell double
|
|
6
|
+
* answers `list`, `get manifest` and `get hooks` from canned fixtures, so the
|
|
7
|
+
* plugin's own exec conventions (the same `helm …` command strings production
|
|
8
|
+
* runs) are what execute. The deep path is exercised through the same release
|
|
9
|
+
* plumbing with the k8s lexicon's fake cluster at the API edge — the seam the
|
|
10
|
+
* k8s lexicon's own integration test replaces.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { helmPlugin } from "./plugin";
|
|
18
|
+
import { normalizeObservation } from "@intentius/chant/observation";
|
|
19
|
+
import { buildChangeSet } from "@intentius/chant/lifecycle/change-set";
|
|
20
|
+
import { diffLive } from "@intentius/chant/lifecycle/live-diff";
|
|
21
|
+
import { describeObservationConformance } from "@intentius/chant-test-utils";
|
|
22
|
+
import { describeResources } from "./describe-resources";
|
|
23
|
+
import type { HelmRunner } from "./release-observe";
|
|
24
|
+
|
|
25
|
+
const LIST = JSON.stringify([
|
|
26
|
+
{
|
|
27
|
+
name: "web-app",
|
|
28
|
+
namespace: "prod",
|
|
29
|
+
revision: "3",
|
|
30
|
+
updated: "2026-08-01 10:00:00.000000000 +0000 UTC",
|
|
31
|
+
status: "deployed",
|
|
32
|
+
chart: "web-app-0.1.0",
|
|
33
|
+
app_version: "1.0.0",
|
|
34
|
+
},
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const MANIFEST = `---
|
|
38
|
+
# Source: web-app/templates/deployment.yaml
|
|
39
|
+
apiVersion: apps/v1
|
|
40
|
+
kind: Deployment
|
|
41
|
+
metadata:
|
|
42
|
+
name: web-app
|
|
43
|
+
spec:
|
|
44
|
+
replicas: 2
|
|
45
|
+
---
|
|
46
|
+
# Source: web-app/templates/service.yaml
|
|
47
|
+
apiVersion: v1
|
|
48
|
+
kind: Service
|
|
49
|
+
metadata:
|
|
50
|
+
name: web-app
|
|
51
|
+
spec:
|
|
52
|
+
ports:
|
|
53
|
+
- port: 80
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const HOOKS = `---
|
|
57
|
+
# Source: web-app/templates/migrate-job.yaml
|
|
58
|
+
apiVersion: batch/v1
|
|
59
|
+
kind: Job
|
|
60
|
+
metadata:
|
|
61
|
+
name: web-app-migrate
|
|
62
|
+
annotations:
|
|
63
|
+
"helm.sh/hook": pre-install
|
|
64
|
+
"helm.sh/hook-weight": "1"
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
/** Write a scripted `helm` CLI double into its own dir and put it on PATH. */
|
|
68
|
+
function installHelmShim(fixtures: { list: string; manifest: string; hooks: string; fail?: string }): string {
|
|
69
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-helm-shim-"));
|
|
70
|
+
writeFileSync(join(dir, "list.json"), fixtures.list);
|
|
71
|
+
writeFileSync(join(dir, "manifest.yaml"), fixtures.manifest);
|
|
72
|
+
writeFileSync(join(dir, "hooks.yaml"), fixtures.hooks);
|
|
73
|
+
const script = `#!/bin/sh
|
|
74
|
+
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
75
|
+
${fixtures.fail ? `echo "${fixtures.fail}" >&2; exit 1` : ""}
|
|
76
|
+
if [ "$1" = "list" ]; then cat "$DIR/list.json"; exit 0; fi
|
|
77
|
+
if [ "$1" = "get" ] && [ "$2" = "manifest" ]; then cat "$DIR/manifest.yaml"; exit 0; fi
|
|
78
|
+
if [ "$1" = "get" ] && [ "$2" = "hooks" ]; then cat "$DIR/hooks.yaml"; exit 0; fi
|
|
79
|
+
echo "helm shim: unexpected args: $*" >&2
|
|
80
|
+
exit 1
|
|
81
|
+
`;
|
|
82
|
+
writeFileSync(join(dir, "helm"), script);
|
|
83
|
+
chmodSync(join(dir, "helm"), 0o755);
|
|
84
|
+
return dir;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const entities = new Map([
|
|
88
|
+
["chart", { entityType: "Helm::Chart", props: { name: "web-app" } }],
|
|
89
|
+
["valuesSchema", { entityType: "Helm::Values", props: { replicaCount: 2 } }],
|
|
90
|
+
]);
|
|
91
|
+
const declared = new Set(entities.keys());
|
|
92
|
+
const baseOptions = {
|
|
93
|
+
environment: "prod",
|
|
94
|
+
buildOutput: "",
|
|
95
|
+
entityNames: [...entities.keys()],
|
|
96
|
+
entities,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
describe("helm lifecycle integration (#1246/#1247)", () => {
|
|
100
|
+
const originalPath = process.env.PATH;
|
|
101
|
+
let shimDirs: string[] = [];
|
|
102
|
+
|
|
103
|
+
const useShim = (fixtures: Parameters<typeof installHelmShim>[0]): void => {
|
|
104
|
+
const dir = installHelmShim(fixtures);
|
|
105
|
+
shimDirs.push(dir);
|
|
106
|
+
process.env.PATH = `${dir}:${originalPath}`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
shimDirs = [];
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
process.env.PATH = originalPath;
|
|
115
|
+
for (const dir of shimDirs) rmSync(dir, { recursive: true, force: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("lifecycle diff --live gains helm rows: real plugin, scripted helm CLI, both channels", async () => {
|
|
119
|
+
useShim({ list: LIST, manifest: MANIFEST, hooks: HOOKS });
|
|
120
|
+
|
|
121
|
+
const observed = normalizeObservation(await helmPlugin.describeResources!(baseOptions));
|
|
122
|
+
|
|
123
|
+
// The release row and the per-resource rows, hook resource included —
|
|
124
|
+
// the row `helm get manifest` alone can never produce.
|
|
125
|
+
expect(observed.resources.chart).toMatchObject({ type: "Helm::Release", status: "deployed" });
|
|
126
|
+
expect(observed.resources["Deployment/prod/web-app"]).toBeDefined();
|
|
127
|
+
expect(observed.resources["Service/prod/web-app"]).toBeDefined();
|
|
128
|
+
expect(observed.resources["Job/prod/web-app-migrate"]).toMatchObject({
|
|
129
|
+
attributes: expect.objectContaining({ channel: "hooks", hook: "pre-install" }),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const diff = diffLive({ declared, observedNow: observed.resources, observedThen: undefined, unobserved: observed.unobserved });
|
|
133
|
+
// Nothing declared is missing, and the rendered resources are expected
|
|
134
|
+
// runtime — an unpinned release is not reported as drift (#1246).
|
|
135
|
+
expect(diff.missing).toEqual([]);
|
|
136
|
+
expect(diff.orphan).toEqual([]);
|
|
137
|
+
expect(diff.runtimeChildren.map((r) => r.name).sort()).toEqual([
|
|
138
|
+
"Deployment/prod/web-app",
|
|
139
|
+
"Job/prod/web-app-migrate",
|
|
140
|
+
"Service/prod/web-app",
|
|
141
|
+
]);
|
|
142
|
+
expect(diff.runtimeChildren.every((r) => r.owner === "chart")).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("change-set verdicts: present chart is noop, rendered resources are runtime, absent chart is create", async () => {
|
|
146
|
+
useShim({ list: LIST, manifest: MANIFEST, hooks: HOOKS });
|
|
147
|
+
const observed = normalizeObservation(await helmPlugin.describeResources!(baseOptions));
|
|
148
|
+
const cs = buildChangeSet("prod", {
|
|
149
|
+
declared,
|
|
150
|
+
observedNow: observed.resources,
|
|
151
|
+
observedThen: undefined,
|
|
152
|
+
unobserved: observed.unobserved,
|
|
153
|
+
});
|
|
154
|
+
const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e.action]));
|
|
155
|
+
expect(byName.chart).toBe("noop");
|
|
156
|
+
expect(byName.valuesSchema).toBe("noop");
|
|
157
|
+
expect(byName["Deployment/prod/web-app"]).toBe("runtime");
|
|
158
|
+
expect(byName["Job/prod/web-app-migrate"]).toBe("runtime");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a failing helm CLI is unobserved for every declared entity — never N creates", async () => {
|
|
162
|
+
useShim({ list: "[]", manifest: "", hooks: "", fail: "Error: Kubernetes cluster unreachable" });
|
|
163
|
+
const observed = normalizeObservation(await helmPlugin.describeResources!(baseOptions));
|
|
164
|
+
expect(observed.resources).toEqual({});
|
|
165
|
+
expect(observed.unobserved.chart.reason).toBe("no-credentials");
|
|
166
|
+
|
|
167
|
+
const cs = buildChangeSet("prod", {
|
|
168
|
+
declared,
|
|
169
|
+
observedNow: observed.resources,
|
|
170
|
+
observedThen: undefined,
|
|
171
|
+
unobserved: observed.unobserved,
|
|
172
|
+
});
|
|
173
|
+
for (const entry of cs.entries) expect(entry.action).toBe("unobserved");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a missing helm binary is no-credentials, never a clean empty snapshot", async () => {
|
|
177
|
+
const empty = mkdtempSync(join(tmpdir(), "chant-helm-nopath-"));
|
|
178
|
+
shimDirs.push(empty);
|
|
179
|
+
process.env.PATH = empty; // /bin/sh is exec'd absolutely; only `helm` resolution uses PATH
|
|
180
|
+
const observed = normalizeObservation(await helmPlugin.describeResources!(baseOptions));
|
|
181
|
+
expect(observed.resources).toEqual({});
|
|
182
|
+
expect(observed.unobserved.chart.reason).toBe("no-credentials");
|
|
183
|
+
expect(observed.unobserved.valuesSchema.reason).toBe("no-credentials");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("the deep hook is declared and shares the k8s lexicon's normalization hooks (#1247)", async () => {
|
|
187
|
+
expect(typeof helmPlugin.observeResourcesDeep).toBe("function");
|
|
188
|
+
const { k8sDeepNormalizationHooks } = await import("@intentius/chant-lexicon-k8s/deep-observe-hooks");
|
|
189
|
+
expect(helmPlugin.deepNormalizationHooks).toBe(k8sDeepNormalizationHooks);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// The shared conformance suite (#1089) — every observing lexicon runs it.
|
|
194
|
+
// Scenarios drive the same describeResources the plugin dispatches to, with
|
|
195
|
+
// the CLI seam injected per scenario.
|
|
196
|
+
const scripted = (outputs: { list?: string; manifest?: string; hooks?: string; throwWith?: Error }): HelmRunner =>
|
|
197
|
+
async (command) => {
|
|
198
|
+
if (outputs.throwWith) throw outputs.throwWith;
|
|
199
|
+
if (command.startsWith("helm list")) return { stdout: outputs.list ?? "[]" };
|
|
200
|
+
if (command.startsWith("helm get manifest")) return { stdout: outputs.manifest ?? "" };
|
|
201
|
+
if (command.startsWith("helm get hooks")) return { stdout: outputs.hooks ?? "" };
|
|
202
|
+
throw new Error(`unexpected command: ${command}`);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const conformanceEntities = new Map([["chart", { entityType: "Helm::Chart", props: { name: "web-app" } }]]);
|
|
206
|
+
const conformanceOptions = {
|
|
207
|
+
environment: "prod",
|
|
208
|
+
buildOutput: "",
|
|
209
|
+
entityNames: ["chart"],
|
|
210
|
+
entities: conformanceEntities,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const MARKED_MANIFEST = `---
|
|
214
|
+
apiVersion: v1
|
|
215
|
+
kind: ConfigMap
|
|
216
|
+
metadata:
|
|
217
|
+
name: web-app-config
|
|
218
|
+
labels:
|
|
219
|
+
app.kubernetes.io/managed-by: chant
|
|
220
|
+
chant.intentius.io/stack: shop
|
|
221
|
+
chant.intentius.io/env: prod
|
|
222
|
+
data:
|
|
223
|
+
key: value
|
|
224
|
+
---
|
|
225
|
+
apiVersion: v1
|
|
226
|
+
kind: ConfigMap
|
|
227
|
+
metadata:
|
|
228
|
+
name: unmarked-config
|
|
229
|
+
data:
|
|
230
|
+
key: value
|
|
231
|
+
`;
|
|
232
|
+
|
|
233
|
+
describeObservationConformance({
|
|
234
|
+
lexicon: "helm",
|
|
235
|
+
ownershipChannel: helmPlugin.ownershipChannel,
|
|
236
|
+
scenarios: [
|
|
237
|
+
{
|
|
238
|
+
name: "a healthy release read, both channels",
|
|
239
|
+
declared: ["chart"],
|
|
240
|
+
expectPresent: ["chart"],
|
|
241
|
+
run: () => describeResources(conformanceOptions, scripted({ list: LIST, manifest: MANIFEST, hooks: HOOKS })),
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: "helm binary missing alongside nothing else readable",
|
|
245
|
+
declared: ["chart"],
|
|
246
|
+
expectUnobserved: ["chart"],
|
|
247
|
+
run: () =>
|
|
248
|
+
describeResources(
|
|
249
|
+
conformanceOptions,
|
|
250
|
+
scripted({ throwWith: Object.assign(new Error("helm: command not found"), { code: 127 }) }),
|
|
251
|
+
),
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: "an unreachable release (list succeeds, get fails)",
|
|
255
|
+
declared: ["chart"],
|
|
256
|
+
expectUnobserved: ["chart"],
|
|
257
|
+
run: () =>
|
|
258
|
+
describeResources(conformanceOptions, async (command) => {
|
|
259
|
+
if (command.startsWith("helm list")) return { stdout: LIST };
|
|
260
|
+
throw new Error("Error: release: not found");
|
|
261
|
+
}),
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: "a release helm was asked about and did not report",
|
|
265
|
+
declared: ["chart"],
|
|
266
|
+
expectAbsent: ["chart"],
|
|
267
|
+
run: () => describeResources(conformanceOptions, scripted({ list: "[]" })),
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: "an owned read resolves real verdicts — release identity is the marker",
|
|
271
|
+
declared: ["chart"],
|
|
272
|
+
expectPresent: ["chart"],
|
|
273
|
+
owned: true,
|
|
274
|
+
run: () =>
|
|
275
|
+
describeResources(
|
|
276
|
+
{ ...conformanceOptions, owned: true },
|
|
277
|
+
scripted({ list: LIST, manifest: MANIFEST, hooks: HOOKS }),
|
|
278
|
+
),
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: "a marker-stamped rendered document surfaces its stack/env identity; an unmarked one surfaces none (#1222)",
|
|
282
|
+
declared: ["chart"],
|
|
283
|
+
expectPresent: ["chart", "ConfigMap/prod/web-app-config", "ConfigMap/prod/unmarked-config"],
|
|
284
|
+
expectMarker: { "ConfigMap/prod/web-app-config": { stack: "shop", env: "prod" } },
|
|
285
|
+
expectNoMarker: ["ConfigMap/prod/unmarked-config"],
|
|
286
|
+
run: () => describeResources(conformanceOptions, scripted({ list: LIST, manifest: MARKED_MANIFEST, hooks: "" })),
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
});
|
|
@@ -26,4 +26,6 @@ export const helmAuditCatalog: Record<string, RuleMeta> = {
|
|
|
26
26
|
WHM407: auditRule("WHM407", "merge-worthy", "guidance", "Inline Secret data", "Use ExternalSecret/SealedSecret instead of inline Secret data.", { authority: [K8S_SECRETS] }),
|
|
27
27
|
WHM501: auditRule("WHM501", "report-only", "guidance", "Unused values key", "Remove values defined but never referenced.", { category: "best-practice" }),
|
|
28
28
|
WHM502: auditRule("WHM502", "merge-worthy", "guidance", "Deprecated/invalid Kubernetes API version", "Update to a supported apiVersion.", { category: "correctness" }),
|
|
29
|
+
WHM503: auditRule("WHM503", "merge-worthy", "guidance", "Pinned render carries Secret data", "Declare the value with runtimeSlot() or replace the Secret with HelmExternalSecret.", { authority: [K8S_SECRETS] }),
|
|
30
|
+
WHM504: auditRule("WHM504", "merge-worthy", "guidance", "Dead value assignment", "Remove supplied values that never survive coalescing (shadowed, disabled subchart, or unknown subchart).", { category: "correctness" }),
|
|
29
31
|
};
|
|
@@ -21,6 +21,8 @@ import { whm406 } from "./whm406";
|
|
|
21
21
|
import { whm407 } from "./whm407";
|
|
22
22
|
import { whm501 } from "./whm501";
|
|
23
23
|
import { whm502 } from "./whm502";
|
|
24
|
+
import { whm503 } from "./whm503";
|
|
25
|
+
import { whm504 } from "./whm504";
|
|
24
26
|
|
|
25
27
|
export const postSynthChecks: PostSynthCheck[] = [
|
|
26
28
|
whm005,
|
|
@@ -44,4 +46,6 @@ export const postSynthChecks: PostSynthCheck[] = [
|
|
|
44
46
|
whm407,
|
|
45
47
|
whm501,
|
|
46
48
|
whm502,
|
|
49
|
+
whm503,
|
|
50
|
+
whm504,
|
|
47
51
|
];
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { HelmCapabilityProfile } from "../../config";
|
|
7
|
+
import type { HelmRenderRecord } from "../../render";
|
|
8
|
+
import { persistHelmRender } from "../../render-store";
|
|
9
|
+
import { checkRenderRecordsForSecrets, whm503 } from "./whm503";
|
|
10
|
+
|
|
11
|
+
const PROFILE: HelmCapabilityProfile = {
|
|
12
|
+
name: "prod",
|
|
13
|
+
kubeVersion: "1.33.6",
|
|
14
|
+
apiVersions: ["batch/v1"],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function freshRoot(): string {
|
|
18
|
+
return mkdtempSync(join(tmpdir(), "chant-whm503-"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A `helm template`-shaped stream with one Secret document, `data` swapped by caller. */
|
|
22
|
+
function renderedWith(secretBody: string): string {
|
|
23
|
+
return [
|
|
24
|
+
"---",
|
|
25
|
+
"# Source: tiny/templates/serviceaccount.yaml",
|
|
26
|
+
"kind: ServiceAccount",
|
|
27
|
+
"apiVersion: v1",
|
|
28
|
+
"metadata:",
|
|
29
|
+
" name: tiny-sa",
|
|
30
|
+
"---",
|
|
31
|
+
"# Source: tiny/templates/secret.yaml",
|
|
32
|
+
"apiVersion: v1",
|
|
33
|
+
"kind: Secret",
|
|
34
|
+
"metadata:",
|
|
35
|
+
" name: tiny-secret",
|
|
36
|
+
" namespace: web",
|
|
37
|
+
secretBody,
|
|
38
|
+
"",
|
|
39
|
+
].join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function persist(root: string, rendered: string, name = "rel") {
|
|
43
|
+
return persistHelmRender({
|
|
44
|
+
rendered,
|
|
45
|
+
releaseName: name,
|
|
46
|
+
chart: "tiny",
|
|
47
|
+
namespace: "web",
|
|
48
|
+
capabilityProfile: PROFILE,
|
|
49
|
+
root,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("WHM503: pinned render carries Secret data", () => {
|
|
54
|
+
test("flags a Secret with populated data", () => {
|
|
55
|
+
const root = freshRoot();
|
|
56
|
+
const { manifest } = persist(root, renderedWith("data:\n password: c2VjcmV0"));
|
|
57
|
+
const records: HelmRenderRecord[] = [{ name: "rel", chart: "tiny", contentDigest: manifest.contentDigest }];
|
|
58
|
+
|
|
59
|
+
const diags = checkRenderRecordsForSecrets(records, { root });
|
|
60
|
+
expect(diags).toHaveLength(1);
|
|
61
|
+
expect(diags[0].checkId).toBe("WHM503");
|
|
62
|
+
expect(diags[0].severity).toBe("error");
|
|
63
|
+
expect(diags[0].message).toContain("web/tiny-secret");
|
|
64
|
+
expect(diags[0].message).toContain("runtimeSlot()");
|
|
65
|
+
expect(diags[0].message).toContain("HelmExternalSecret");
|
|
66
|
+
expect(diags[0].entity).toBe("web/tiny-secret");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("flags a Secret with populated stringData", () => {
|
|
70
|
+
const root = freshRoot();
|
|
71
|
+
const { manifest } = persist(root, renderedWith("stringData:\n token: hunter2"));
|
|
72
|
+
const records: HelmRenderRecord[] = [{ name: "rel", chart: "tiny", contentDigest: manifest.contentDigest }];
|
|
73
|
+
|
|
74
|
+
const diags = checkRenderRecordsForSecrets(records, { root });
|
|
75
|
+
expect(diags).toHaveLength(1);
|
|
76
|
+
expect(diags[0].message).toContain("web/tiny-secret");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("passes when the Secret's data is empty", () => {
|
|
80
|
+
const root = freshRoot();
|
|
81
|
+
const { manifest } = persist(root, renderedWith("data: {}"));
|
|
82
|
+
const records: HelmRenderRecord[] = [{ name: "rel", chart: "tiny", contentDigest: manifest.contentDigest }];
|
|
83
|
+
|
|
84
|
+
expect(checkRenderRecordsForSecrets(records, { root })).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("passes when the render carries no Secret documents", () => {
|
|
88
|
+
const root = freshRoot();
|
|
89
|
+
const rendered = [
|
|
90
|
+
"---",
|
|
91
|
+
"# Source: tiny/templates/serviceaccount.yaml",
|
|
92
|
+
"kind: ServiceAccount",
|
|
93
|
+
"apiVersion: v1",
|
|
94
|
+
"metadata:",
|
|
95
|
+
" name: tiny-sa",
|
|
96
|
+
"",
|
|
97
|
+
].join("\n");
|
|
98
|
+
const { manifest } = persist(root, rendered);
|
|
99
|
+
const records: HelmRenderRecord[] = [{ name: "rel", chart: "tiny", contentDigest: manifest.contentDigest }];
|
|
100
|
+
|
|
101
|
+
expect(checkRenderRecordsForSecrets(records, { root })).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("skips unpinned records (no contentDigest)", () => {
|
|
105
|
+
const root = freshRoot();
|
|
106
|
+
const records: HelmRenderRecord[] = [{ name: "rel", chart: "tiny" }];
|
|
107
|
+
expect(checkRenderRecordsForSecrets(records, { root })).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("skips a record whose digest is not in the store", () => {
|
|
111
|
+
const root = freshRoot();
|
|
112
|
+
const records: HelmRenderRecord[] = [
|
|
113
|
+
{ name: "rel", chart: "tiny", contentDigest: "sha256:" + "0".repeat(64) },
|
|
114
|
+
];
|
|
115
|
+
expect(checkRenderRecordsForSecrets(records, { root })).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("flags every offending Secret across multiple pinned renders", () => {
|
|
119
|
+
const root = freshRoot();
|
|
120
|
+
const first = persist(root, renderedWith("data:\n password: c2VjcmV0"), "rel-a");
|
|
121
|
+
const second = persist(root, renderedWith("data:\n password: b3RoZXI="), "rel-b");
|
|
122
|
+
const records: HelmRenderRecord[] = [
|
|
123
|
+
{ name: "rel-a", chart: "tiny", contentDigest: first.manifest.contentDigest },
|
|
124
|
+
{ name: "rel-b", chart: "tiny", contentDigest: second.manifest.contentDigest },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
expect(checkRenderRecordsForSecrets(records, { root })).toHaveLength(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("the PostSynthCheck wrapper carries the WHM503 id and description", () => {
|
|
131
|
+
expect(whm503.id).toBe("WHM503");
|
|
132
|
+
expect(whm503.description).toContain("Secret");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHM503: Pinned render artifact carries Secret data.
|
|
3
|
+
*
|
|
4
|
+
* WHM407 covers the pre-render concern — a chart TEMPLATE with an inline,
|
|
5
|
+
* literal `data`/`stringData` value baked into `templates/`. This rule
|
|
6
|
+
* covers a different concern (#1241, epic #1228 Phase 3): the STORED,
|
|
7
|
+
* POST-RENDER artifact (`render-store.ts`, #1238). A pinned render is
|
|
8
|
+
* durable, promoted, and pushed to a registry — unlike a normal `helm
|
|
9
|
+
* upgrade`, whose rendered bytes are computed at deploy time and discarded.
|
|
10
|
+
* By the time a render lands in the store, every `.Values` reference and
|
|
11
|
+
* `{{ }}` expression is resolved, so a `kind: Secret` document with
|
|
12
|
+
* populated `data`/`stringData` there is a live credential baked into
|
|
13
|
+
* something that outlives the deploy that produced it.
|
|
14
|
+
*
|
|
15
|
+
* Scans every pinned render recorded so far in this process
|
|
16
|
+
* (`getHelmRenderRecords()` from `../../render`) that carries a
|
|
17
|
+
* `contentDigest`, loads its manifest + canonical content from the render
|
|
18
|
+
* store, and flags any `Secret` document whose `data` or `stringData` is
|
|
19
|
+
* non-empty.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
23
|
+
import yaml from "js-yaml";
|
|
24
|
+
|
|
25
|
+
import type { HelmRenderRecord } from "../../render";
|
|
26
|
+
import { getHelmRenderRecords } from "../../render";
|
|
27
|
+
import { loadRenderManifest, readRenderDocument } from "../../render-store";
|
|
28
|
+
|
|
29
|
+
interface ParsedSecretDoc {
|
|
30
|
+
data?: unknown;
|
|
31
|
+
stringData?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when a `data`/`stringData` map carries at least one key. */
|
|
35
|
+
function isPopulated(value: unknown): boolean {
|
|
36
|
+
return typeof value === "object" && value !== null && Object.keys(value).length > 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Scan a set of `HelmRenderRecord`s for pinned renders whose stored artifact
|
|
41
|
+
* carries a `Secret` document with populated `data`/`stringData`. Exported
|
|
42
|
+
* separately from `whm503` so tests can drive it directly with hand-built
|
|
43
|
+
* records — no real `helm` binary required, unlike a `HelmRender()` call.
|
|
44
|
+
* `opts.root` overrides the render store root (test isolation).
|
|
45
|
+
*/
|
|
46
|
+
export function checkRenderRecordsForSecrets(
|
|
47
|
+
records: readonly HelmRenderRecord[],
|
|
48
|
+
opts?: { root?: string },
|
|
49
|
+
): PostSynthDiagnostic[] {
|
|
50
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
51
|
+
|
|
52
|
+
for (const record of records) {
|
|
53
|
+
if (!record.contentDigest) continue; // unpinned — no stored artifact to scan
|
|
54
|
+
|
|
55
|
+
const manifest = loadRenderManifest(record.contentDigest, opts);
|
|
56
|
+
if (!manifest) continue; // recorded but not (or no longer) in the store
|
|
57
|
+
|
|
58
|
+
for (const doc of manifest.documents) {
|
|
59
|
+
if (doc.kind !== "Secret") continue;
|
|
60
|
+
|
|
61
|
+
const found = readRenderDocument(record.contentDigest, { kind: doc.kind, name: doc.name, namespace: doc.namespace }, opts);
|
|
62
|
+
if (!found) continue;
|
|
63
|
+
|
|
64
|
+
let parsed: ParsedSecretDoc;
|
|
65
|
+
try {
|
|
66
|
+
parsed = (yaml.load(found.text) ?? {}) as ParsedSecretDoc;
|
|
67
|
+
} catch {
|
|
68
|
+
continue; // unparseable — not this check's problem to diagnose
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!isPopulated(parsed.data) && !isPopulated(parsed.stringData)) continue;
|
|
72
|
+
|
|
73
|
+
const locator = doc.namespace ? `${doc.namespace}/${doc.name}` : doc.name;
|
|
74
|
+
diagnostics.push({
|
|
75
|
+
checkId: "WHM503",
|
|
76
|
+
severity: "error",
|
|
77
|
+
message:
|
|
78
|
+
`pinned render "${record.name}" (${record.chart}, ${record.contentDigest}): Secret "${locator}" ` +
|
|
79
|
+
`carries populated data/stringData — a pinned render is stored, promoted, and pushed to a registry, ` +
|
|
80
|
+
`so this bakes a live credential into a durable artifact. Declare the value with runtimeSlot() so ` +
|
|
81
|
+
`it is supplied per environment, or replace this Secret with HelmExternalSecret.`,
|
|
82
|
+
entity: locator,
|
|
83
|
+
lexicon: "helm",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return diagnostics;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const whm503: PostSynthCheck = {
|
|
92
|
+
id: "WHM503",
|
|
93
|
+
description: "Pinned render artifact must not carry populated Secret data/stringData",
|
|
94
|
+
|
|
95
|
+
// This check's data source is the render store (#1238), not `ctx.outputs`
|
|
96
|
+
// (the un-rendered chart template files WHM005-WHM502 read) — see the file
|
|
97
|
+
// doc comment. `ctx` is accepted to satisfy `PostSynthCheck` and is unused.
|
|
98
|
+
check(_ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
99
|
+
return checkRenderRecordsForSecrets(getHelmRenderRecords());
|
|
100
|
+
},
|
|
101
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { whm504 } from "./whm504";
|
|
4
|
+
import {
|
|
5
|
+
clearValuesProbeRecords,
|
|
6
|
+
recordValuesProbe,
|
|
7
|
+
type CoalescedValuesProbe,
|
|
8
|
+
type DeadAssignment,
|
|
9
|
+
} from "../../values-probe";
|
|
10
|
+
|
|
11
|
+
function makeCtx(): PostSynthContext {
|
|
12
|
+
const outputs = new Map<string, string>();
|
|
13
|
+
return {
|
|
14
|
+
outputs,
|
|
15
|
+
entities: new Map(),
|
|
16
|
+
buildResult: {
|
|
17
|
+
outputs,
|
|
18
|
+
entities: new Map(),
|
|
19
|
+
warnings: [],
|
|
20
|
+
errors: [],
|
|
21
|
+
sourceFileCount: 1,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function probeWith(deadAssignments: DeadAssignment[]): CoalescedValuesProbe {
|
|
27
|
+
return {
|
|
28
|
+
instances: [{ scope: [], chartName: "app", values: {} }],
|
|
29
|
+
disabled: [],
|
|
30
|
+
digest: "sha256:" + "0".repeat(64),
|
|
31
|
+
valueSources: {},
|
|
32
|
+
deadAssignments,
|
|
33
|
+
warnings: [],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("WHM504: dead value assignments", () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
clearValuesProbeRecords();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("no probe records, no diagnostics", () => {
|
|
43
|
+
expect(whm504.check(makeCtx())).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a probe with no dead assignments passes", () => {
|
|
47
|
+
recordValuesProbe({ name: "app", chartDir: "/tmp/app", probe: probeWith([]) });
|
|
48
|
+
expect(whm504.check(makeCtx())).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("one warning per dead assignment, naming path, origin, and what shadowed it", () => {
|
|
52
|
+
recordValuesProbe({
|
|
53
|
+
name: "app",
|
|
54
|
+
chartDir: "/tmp/app",
|
|
55
|
+
probe: probeWith([
|
|
56
|
+
{
|
|
57
|
+
path: "web.tag",
|
|
58
|
+
origin: "supplied file (values-prod.yaml)",
|
|
59
|
+
reason: "shadowed",
|
|
60
|
+
shadowedBy: "--set",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
path: "opt.size",
|
|
64
|
+
origin: "supplied file (values-prod.yaml)",
|
|
65
|
+
reason: "disabled-subchart",
|
|
66
|
+
shadowedBy: "disabled subchart opt (condition opt.enabled is false)",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
path: "wbe",
|
|
70
|
+
origin: "supplied file (values-prod.yaml)",
|
|
71
|
+
reason: "unknown-subchart",
|
|
72
|
+
shadowedBy: 'no dependency, chart default, or global named "wbe"',
|
|
73
|
+
},
|
|
74
|
+
]),
|
|
75
|
+
});
|
|
76
|
+
const diags = whm504.check(makeCtx());
|
|
77
|
+
expect(diags).toHaveLength(3);
|
|
78
|
+
for (const d of diags) {
|
|
79
|
+
expect(d.checkId).toBe("WHM504");
|
|
80
|
+
expect(d.severity).toBe("warning");
|
|
81
|
+
expect(d.entity).toBe("app");
|
|
82
|
+
expect(d.lexicon).toBe("helm");
|
|
83
|
+
expect(d.message).toContain("supplied file (values-prod.yaml)");
|
|
84
|
+
}
|
|
85
|
+
expect(diags[0].message).toContain('"web.tag"');
|
|
86
|
+
expect(diags[0].message).toContain("shadowed by --set");
|
|
87
|
+
expect(diags[1].message).toContain("disabled subchart opt (condition opt.enabled is false)");
|
|
88
|
+
expect(diags[2].message).toContain('no dependency, chart default, or global named "wbe"');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("reports across multiple probe records", () => {
|
|
92
|
+
recordValuesProbe({
|
|
93
|
+
name: "app-a",
|
|
94
|
+
chartDir: "/tmp/a",
|
|
95
|
+
probe: probeWith([{ path: "x", origin: "--set", reason: "shadowed", shadowedBy: "--set" }]),
|
|
96
|
+
});
|
|
97
|
+
recordValuesProbe({
|
|
98
|
+
name: "app-b",
|
|
99
|
+
chartDir: "/tmp/b",
|
|
100
|
+
probe: probeWith([{ path: "y", origin: "supplied file", reason: "shadowed", shadowedBy: "--set" }]),
|
|
101
|
+
});
|
|
102
|
+
const diags = whm504.check(makeCtx());
|
|
103
|
+
expect(diags.map((d) => d.entity)).toEqual(["app-a", "app-b"]);
|
|
104
|
+
});
|
|
105
|
+
});
|