@intentius/chant-lexicon-helm 0.46.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/archive-render.d.ts +94 -0
- package/dist/archive-render.d.ts.map +1 -0
- package/dist/commands.d.ts +54 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/config.d.ts +135 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/deep-observe-hooks.d.ts +2 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +54 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +17 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/index.d.ts +18 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/whm503.d.ts +34 -0
- package/dist/lint/post-synth/whm503.d.ts.map +1 -0
- package/dist/lint/post-synth/whm504.d.ts +20 -0
- package/dist/lint/post-synth/whm504.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +2 -0
- package/dist/okf/rules/WHM503.md +15 -0
- package/dist/okf/rules/WHM504.md +11 -0
- package/dist/okf/types/Values.md +1 -0
- package/dist/op/activities/cluster-probe.d.ts +44 -0
- package/dist/op/activities/cluster-probe.d.ts.map +1 -0
- package/dist/op/activities/helm.d.ts +179 -5
- package/dist/op/activities/helm.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +4 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/pinnability/actions.d.ts +76 -0
- package/dist/pinnability/actions.d.ts.map +1 -0
- package/dist/pinnability/classify.d.ts +129 -0
- package/dist/pinnability/classify.d.ts.map +1 -0
- package/dist/pinnability/conditions.d.ts +109 -0
- package/dist/pinnability/conditions.d.ts.map +1 -0
- package/dist/pinnability/index.d.ts +17 -0
- package/dist/pinnability/index.d.ts.map +1 -0
- package/dist/pinnability/localize.d.ts +124 -0
- package/dist/pinnability/localize.d.ts.map +1 -0
- package/dist/pinnability/render-stream.d.ts +27 -0
- package/dist/pinnability/render-stream.d.ts.map +1 -0
- package/dist/pinnability/values.d.ts +69 -0
- package/dist/pinnability/values.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/release-observe.d.ts +109 -0
- package/dist/release-observe.d.ts.map +1 -0
- package/dist/render-diff.d.ts +192 -0
- package/dist/render-diff.d.ts.map +1 -0
- package/dist/render-digest.d.ts +135 -0
- package/dist/render-digest.d.ts.map +1 -0
- package/dist/render-store.d.ts +310 -0
- package/dist/render-store.d.ts.map +1 -0
- package/dist/render-wrapper.d.ts +140 -0
- package/dist/render-wrapper.d.ts.map +1 -0
- package/dist/render.d.ts +81 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/rules/whm503.ts +101 -0
- package/dist/rules/whm504.ts +52 -0
- package/dist/skills/chant-helm.md +7 -0
- package/dist/values-probe.d.ts +177 -0
- package/dist/values-probe.d.ts.map +1 -0
- package/dist/wrapper-chart.d.ts +61 -0
- package/dist/wrapper-chart.d.ts.map +1 -0
- package/package.json +10 -4
- package/src/archive-render.test.ts +201 -0
- package/src/archive-render.ts +119 -0
- package/src/commands.test.ts +487 -0
- package/src/commands.ts +445 -0
- package/src/config.test.ts +130 -0
- package/src/config.ts +220 -0
- package/src/deep-observe-hooks.ts +20 -0
- package/src/deep-observe.test.ts +165 -0
- package/src/deep-observe.ts +121 -0
- package/src/describe-resources.test.ts +255 -0
- package/src/describe-resources.ts +198 -0
- package/src/index.ts +98 -2
- package/src/lifecycle-integration.test.ts +289 -0
- package/src/lint/audit-catalog.ts +2 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/whm503.test.ts +134 -0
- package/src/lint/post-synth/whm503.ts +101 -0
- package/src/lint/post-synth/whm504.test.ts +105 -0
- package/src/lint/post-synth/whm504.ts +52 -0
- package/src/op/activities/cluster-probe.test.ts +66 -0
- package/src/op/activities/cluster-probe.ts +148 -0
- package/src/op/activities/helm.test.ts +824 -0
- package/src/op/activities/helm.ts +628 -5
- package/src/op/activities/index.ts +17 -2
- package/src/pinnability/actions.ts +263 -0
- package/src/pinnability/classify.ts +579 -0
- package/src/pinnability/conditions.ts +0 -0
- package/src/pinnability/index.ts +77 -0
- package/src/pinnability/localize.test.ts +234 -0
- package/src/pinnability/localize.ts +644 -0
- package/src/pinnability/pinnability.test.ts +560 -0
- package/src/pinnability/render-stream.ts +59 -0
- package/src/pinnability/values.ts +205 -0
- package/src/plugin.test.ts +3 -1
- package/src/plugin.ts +47 -0
- package/src/release-observe.ts +392 -0
- package/src/render-diff.test.ts +366 -0
- package/src/render-diff.ts +407 -0
- package/src/render-digest.test.ts +186 -0
- package/src/render-digest.ts +207 -0
- package/src/render-store.test.ts +412 -0
- package/src/render-store.ts +621 -0
- package/src/render-wrapper.test.ts +259 -0
- package/src/render-wrapper.ts +274 -0
- package/src/render.test.ts +383 -4
- package/src/render.ts +319 -8
- package/src/skills/chant-helm.md +7 -0
- package/src/values-probe.test.ts +400 -0
- package/src/values-probe.ts +627 -0
- package/src/wrapper-chart.test.ts +110 -0
- package/src/wrapper-chart.ts +124 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant helm <verb>` — the pinned-renders surface (#1248, epic #1228
|
|
3
|
+
* Phase 6), mounted through the command-group seam (#1078).
|
|
4
|
+
*
|
|
5
|
+
* Four verbs over machinery that already exists but had no CLI reach:
|
|
6
|
+
*
|
|
7
|
+
* - `classify` runs the pinnability gate (#1234) over a chart directory and
|
|
8
|
+
* prints the verdict with every finding located — capability references,
|
|
9
|
+
* open generated inputs, conditional hazards, lookup sites.
|
|
10
|
+
* - `localize` runs the double-render localizer (#1236): it renders the
|
|
11
|
+
* chart twice, maps every unstable output line to the open input that
|
|
12
|
+
* produced it, and prints the validated pins that would close each one.
|
|
13
|
+
* - `renders` discovers the current project and lists every `HelmRender`
|
|
14
|
+
* record with its digests (#1237) and capability profile, plus the
|
|
15
|
+
* stability report grouping renders by input identity.
|
|
16
|
+
* - `diff` (#1238) reports what changed between two renders, dispatching on
|
|
17
|
+
* its arguments and `--live`: `diff <from-digest> <to-digest>` resolves
|
|
18
|
+
* two content digests from the render store and reports added/removed/
|
|
19
|
+
* changed documents with field-level changes inside each changed one —
|
|
20
|
+
* no cluster, no credentials (#1249, render-to-render, offline).
|
|
21
|
+
* `diff <content-digest> <environment> --live` resolves a single stored
|
|
22
|
+
* render and diffs every one of its documents against the live cluster,
|
|
23
|
+
* property by property (#1250, render-to-live). Both modes share
|
|
24
|
+
* ./render-diff.ts and this file's formatting.
|
|
25
|
+
*
|
|
26
|
+
* This module is plain data reachable from plugin.ts — every implementation
|
|
27
|
+
* (classifier, localizer, discovery, differ) sits behind a dynamic import,
|
|
28
|
+
* following `lexicons/k8s/src/kube/group.ts`'s discipline, so loading the
|
|
29
|
+
* plugin never pays for machinery no verb was asked to run.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { CommandGroup, CommandGroupContext } from "@intentius/chant/cli/command-group";
|
|
33
|
+
import { splitJoinedFlags, unknownFlagError } from "@intentius/chant/cli/command-group";
|
|
34
|
+
|
|
35
|
+
import type { PinnabilityReport } from "./pinnability/classify";
|
|
36
|
+
import type { LocalizationReport } from "./pinnability/localize";
|
|
37
|
+
import type { HelmRenderRecord } from "./render";
|
|
38
|
+
import type { RenderStabilityReport } from "./render-digest";
|
|
39
|
+
import type { DeepDiffResult } from "@intentius/chant/lifecycle/deep-diff";
|
|
40
|
+
import type { RenderDiffResult } from "./render-diff";
|
|
41
|
+
|
|
42
|
+
const BOOLEAN_FLAGS = new Set(["--json", "--live"]);
|
|
43
|
+
|
|
44
|
+
interface ParsedArgs {
|
|
45
|
+
positionals: string[];
|
|
46
|
+
valuesFiles: string[];
|
|
47
|
+
json: boolean;
|
|
48
|
+
live: boolean;
|
|
49
|
+
kubeVersion?: string;
|
|
50
|
+
maxProbes?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseVerbArgs(verb: string, raw: string[], accept: ReadonlySet<string>): ParsedArgs {
|
|
54
|
+
const args = splitJoinedFlags(raw, BOOLEAN_FLAGS);
|
|
55
|
+
const parsed: ParsedArgs = { positionals: [], valuesFiles: [], json: false, live: false };
|
|
56
|
+
for (let i = 0; i < args.length; i++) {
|
|
57
|
+
const a = args[i];
|
|
58
|
+
if (a === "--json" && accept.has("--json")) {
|
|
59
|
+
parsed.json = true;
|
|
60
|
+
} else if (a === "--live" && accept.has("--live")) {
|
|
61
|
+
parsed.live = true;
|
|
62
|
+
} else if (a === "--values" && accept.has("--values")) {
|
|
63
|
+
const value = args[++i];
|
|
64
|
+
if (!value) throw new Error(`--values needs a file path.`);
|
|
65
|
+
parsed.valuesFiles.push(value);
|
|
66
|
+
} else if (a === "--kube-version" && accept.has("--kube-version")) {
|
|
67
|
+
const value = args[++i];
|
|
68
|
+
if (!value) throw new Error(`--kube-version needs a version like "1.33.6".`);
|
|
69
|
+
parsed.kubeVersion = value;
|
|
70
|
+
} else if (a === "--max-probes" && accept.has("--max-probes")) {
|
|
71
|
+
const value = Number(args[++i]);
|
|
72
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`--max-probes needs a positive integer.`);
|
|
73
|
+
parsed.maxProbes = value;
|
|
74
|
+
} else if (a.startsWith("-")) {
|
|
75
|
+
throw unknownFlagError(a, `"chant helm ${verb}" accepts ${[...accept].join(", ")}.`);
|
|
76
|
+
} else {
|
|
77
|
+
parsed.positionals.push(a);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return parsed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function requireChartDir(verb: string, parsed: ParsedArgs): string {
|
|
84
|
+
if (parsed.positionals.length === 0) {
|
|
85
|
+
throw new Error(`Usage: chant helm ${verb} <chart-dir> [flags]\nThe chart directory (the one holding Chart.yaml) is required.`);
|
|
86
|
+
}
|
|
87
|
+
if (parsed.positionals.length > 1) {
|
|
88
|
+
throw new Error(`chant helm ${verb} takes one chart directory, got: ${parsed.positionals.join(", ")}`);
|
|
89
|
+
}
|
|
90
|
+
return parsed.positionals[0];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requireTwoDigests(verb: string, parsed: ParsedArgs): [string, string] {
|
|
94
|
+
if (parsed.positionals.length !== 2) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Usage: chant helm ${verb} <from-digest> <to-digest> [--json]\n` +
|
|
97
|
+
` chant helm ${verb} <content-digest> <environment> --live [--json]\n` +
|
|
98
|
+
`Two content digests (sha256:<64 hex>, from "chant helm renders") are required for the offline ` +
|
|
99
|
+
`render-to-render diff, got ${parsed.positionals.length}. Pass --live to diff one stored render ` +
|
|
100
|
+
`against a live cluster instead.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return [parsed.positionals[0], parsed.positionals[1]];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function shortDigest(digest: string | undefined): string {
|
|
107
|
+
if (!digest) return "-";
|
|
108
|
+
const hex = digest.startsWith("sha256:") ? digest.slice("sha256:".length) : digest;
|
|
109
|
+
return `sha256:${hex.slice(0, 12)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The printed classify report — one line per finding, locations included. */
|
|
113
|
+
export function formatPinnabilityReport(report: PinnabilityReport): string {
|
|
114
|
+
const lines: string[] = [`verdict: ${report.verdict}`];
|
|
115
|
+
if (report.requiresProfile.length > 0) {
|
|
116
|
+
lines.push(`requires capability profile (${report.requiresProfile.length} reference(s)):`);
|
|
117
|
+
for (const req of report.requiresProfile) {
|
|
118
|
+
lines.push(` .Capabilities.${req.capability} at ${req.file}:${req.line}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (report.closedInputs.length > 0) {
|
|
122
|
+
lines.push(`open inputs (${report.closedInputs.length}):`);
|
|
123
|
+
for (const input of report.closedInputs) {
|
|
124
|
+
const where = input.file !== undefined ? ` at ${input.file}:${input.line}` : "";
|
|
125
|
+
lines.push(` ${input.kind}${input.fn !== undefined ? ` ${input.fn}` : ""}${where} — ${input.detail}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (report.hazards.length > 0) {
|
|
129
|
+
lines.push(`conditional hazards (${report.hazards.length}):`);
|
|
130
|
+
for (const hazard of report.hazards) {
|
|
131
|
+
lines.push(` ${hazard.file}:${hazard.line} — ${hazard.detail}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
lines.push(
|
|
135
|
+
`lookups: control-flow=${report.lookups.controlFlow.length} value-position=${report.lookups.valuePosition.length}`,
|
|
136
|
+
);
|
|
137
|
+
if (report.reasons.length > 0) {
|
|
138
|
+
lines.push("reasons:");
|
|
139
|
+
for (const reason of report.reasons) lines.push(` - ${reason}`);
|
|
140
|
+
}
|
|
141
|
+
for (const warning of report.warnings) lines.push(`warning: ${warning}`);
|
|
142
|
+
return lines.join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The printed localization table: one block per open input, then the lines
|
|
147
|
+
* nothing explains. Same shape the survey harness prints, kept here so the
|
|
148
|
+
* CLI does not reach into test code.
|
|
149
|
+
*/
|
|
150
|
+
export function formatLocalizationReport(loc: LocalizationReport): string {
|
|
151
|
+
if (loc.deterministic) return "deterministic (double render byte-stable)";
|
|
152
|
+
const lines: string[] = [
|
|
153
|
+
`unstable: ${loc.differingLines} differing line(s), ${loc.renders} renders used` +
|
|
154
|
+
(loc.stableWithAllPins === undefined ? "" : `, stable with all pins: ${loc.stableWithAllPins ? "yes" : "no"}`),
|
|
155
|
+
];
|
|
156
|
+
for (const input of loc.inputs) {
|
|
157
|
+
lines.push(
|
|
158
|
+
` ${input.fn} at ${input.file}:${input.line}` +
|
|
159
|
+
` suppliable=${input.suppliable ? "yes" : "no"}` +
|
|
160
|
+
(input.suggestedPin !== undefined ? ` pin: ${input.suggestedPin}` : "") +
|
|
161
|
+
(input.existingSlots.length > 0 ? ` existing-slots: ${input.existingSlots.join(", ")}` : ""),
|
|
162
|
+
);
|
|
163
|
+
for (const occ of input.occurrences) {
|
|
164
|
+
lines.push(` ${occ.derived ? "derived " : ""}${occ.docId} ${occ.doc}:${occ.line} ${occ.key}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const occ of loc.unlocalized) {
|
|
168
|
+
lines.push(` UNLOCALIZED ${occ.docId} ${occ.doc}:${occ.line} ${occ.key}`);
|
|
169
|
+
}
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The printed render-record table plus the stability summary. */
|
|
174
|
+
export function formatRenderRecords(
|
|
175
|
+
records: readonly HelmRenderRecord[],
|
|
176
|
+
stability: RenderStabilityReport,
|
|
177
|
+
): string {
|
|
178
|
+
const rows = records.map((r) => [
|
|
179
|
+
r.name,
|
|
180
|
+
r.chart,
|
|
181
|
+
r.version ?? "-",
|
|
182
|
+
r.capabilityProfile?.name ?? "(unpinned)",
|
|
183
|
+
shortDigest(r.inputDigest),
|
|
184
|
+
shortDigest(r.contentDigest),
|
|
185
|
+
]);
|
|
186
|
+
const header = ["NAME", "CHART", "VERSION", "PROFILE", "INPUT DIGEST", "CONTENT DIGEST"];
|
|
187
|
+
const widths = header.map((h, col) => Math.max(h.length, ...rows.map((row) => row[col].length)));
|
|
188
|
+
const lines = [header, ...rows].map((row) => row.map((cell, col) => cell.padEnd(widths[col])).join(" ").trimEnd());
|
|
189
|
+
lines.push(
|
|
190
|
+
`stability: ${stability.stable.length} stable group(s), ${stability.unstable.length} unstable, ${stability.unassessed.length} unassessed`,
|
|
191
|
+
);
|
|
192
|
+
for (const group of stability.unstable) {
|
|
193
|
+
lines.push(
|
|
194
|
+
` UNSTABLE ${group.names[0]}: ${group.contentDigests.length} distinct content digests for input ${shortDigest(group.inputDigest)}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The printed render-diff report: one line per added/removed document, a block per changed one. */
|
|
201
|
+
export function formatRenderDiff(diff: RenderDiffResult): string {
|
|
202
|
+
const lines: string[] = [
|
|
203
|
+
`${shortDigest(diff.from.contentDigest)} (${diff.from.chart}/${diff.from.releaseName}) -> ` +
|
|
204
|
+
`${shortDigest(diff.to.contentDigest)} (${diff.to.chart}/${diff.to.releaseName})`,
|
|
205
|
+
];
|
|
206
|
+
if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) {
|
|
207
|
+
lines.push("no differences — identical documents on both sides");
|
|
208
|
+
}
|
|
209
|
+
for (const doc of diff.removed) {
|
|
210
|
+
lines.push(` REMOVED ${doc.kind} ${doc.namespace ?? "-"}/${doc.name}`);
|
|
211
|
+
}
|
|
212
|
+
for (const doc of diff.added) {
|
|
213
|
+
lines.push(` ADDED ${doc.kind} ${doc.namespace ?? "-"}/${doc.name}`);
|
|
214
|
+
}
|
|
215
|
+
for (const doc of diff.changed) {
|
|
216
|
+
lines.push(` CHANGED ${doc.kind} ${doc.namespace ?? "-"}/${doc.name} (${doc.changes.length} field change(s))`);
|
|
217
|
+
for (const change of doc.changes) {
|
|
218
|
+
const before = "before" in change ? JSON.stringify(change.before) : "-";
|
|
219
|
+
const after = "after" in change ? JSON.stringify(change.after) : "-";
|
|
220
|
+
lines.push(` ${change.kind} ${change.path}: ${before} -> ${after}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
lines.push(
|
|
224
|
+
`${diff.unchanged.length} unchanged document(s)` +
|
|
225
|
+
(diff.unindexed.from > 0 || diff.unindexed.to > 0
|
|
226
|
+
? `; unindexed: ${diff.unindexed.from} (from), ${diff.unindexed.to} (to) — not diffable by identity`
|
|
227
|
+
: ""),
|
|
228
|
+
);
|
|
229
|
+
return lines.join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function classifyHandler(ctx: CommandGroupContext): Promise<number> {
|
|
233
|
+
const parsed = parseVerbArgs("classify", ctx.rawArgs, new Set(["--values", "--json"]));
|
|
234
|
+
const chartDir = requireChartDir("classify", parsed);
|
|
235
|
+
const { classifyChart } = await import("./pinnability/classify");
|
|
236
|
+
const report = classifyChart(chartDir, { valuesFiles: parsed.valuesFiles });
|
|
237
|
+
console.log(parsed.json ? JSON.stringify(report, null, 2) : formatPinnabilityReport(report));
|
|
238
|
+
// The gate's answer IS the exit code, so CI can refuse to pin on it:
|
|
239
|
+
// deterministic and pinnable exit 0, unpinnable exits 1.
|
|
240
|
+
return report.verdict === "unpinnable" ? 1 : 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function localizeHandler(ctx: CommandGroupContext): Promise<number> {
|
|
244
|
+
const parsed = parseVerbArgs(
|
|
245
|
+
"localize",
|
|
246
|
+
ctx.rawArgs,
|
|
247
|
+
new Set(["--values", "--kube-version", "--max-probes", "--json"]),
|
|
248
|
+
);
|
|
249
|
+
const chartDir = requireChartDir("localize", parsed);
|
|
250
|
+
const { classifyChart } = await import("./pinnability/classify");
|
|
251
|
+
const { localizeOpenInputs } = await import("./pinnability/localize");
|
|
252
|
+
const { execFileSync } = await import("node:child_process");
|
|
253
|
+
|
|
254
|
+
const render = (pins: Record<string, string>): string => {
|
|
255
|
+
const args = ["template", "rel", chartDir, "--include-crds"];
|
|
256
|
+
if (parsed.kubeVersion !== undefined) args.push("--kube-version", parsed.kubeVersion);
|
|
257
|
+
for (const file of parsed.valuesFiles) args.push("--values", file);
|
|
258
|
+
// Values keys with literal dots would need helm's backslash escaping; a
|
|
259
|
+
// probe that misses merely fails to validate the pin, never misreports.
|
|
260
|
+
for (const [path, value] of Object.entries(pins)) args.push("--set-string", `${path}=${value}`);
|
|
261
|
+
try {
|
|
262
|
+
return execFileSync("helm", args, {
|
|
263
|
+
encoding: "utf8",
|
|
264
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
265
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
266
|
+
});
|
|
267
|
+
} catch (err) {
|
|
268
|
+
const stderr = err && typeof err === "object" && "stderr" in err ? String((err as { stderr: unknown }).stderr) : String(err);
|
|
269
|
+
throw new Error(
|
|
270
|
+
`helm template failed for ${chartDir}:\n${stderr}\nHint: ensure the 'helm' CLI is on PATH (helm version).`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const report = classifyChart(chartDir, { valuesFiles: parsed.valuesFiles });
|
|
276
|
+
const localization = localizeOpenInputs(chartDir, report, {
|
|
277
|
+
render,
|
|
278
|
+
valuesFiles: parsed.valuesFiles,
|
|
279
|
+
maxProbesPerInput: parsed.maxProbes,
|
|
280
|
+
});
|
|
281
|
+
console.log(parsed.json ? JSON.stringify(localization, null, 2) : formatLocalizationReport(localization));
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function rendersHandler(ctx: CommandGroupContext): Promise<number> {
|
|
286
|
+
const parsed = parseVerbArgs("renders", ctx.rawArgs, new Set(["--json"]));
|
|
287
|
+
if (parsed.positionals.length > 0) {
|
|
288
|
+
throw new Error(`chant helm renders takes no positional arguments, got: ${parsed.positionals.join(", ")}`);
|
|
289
|
+
}
|
|
290
|
+
const { loadChantConfig } = await import("@intentius/chant/config");
|
|
291
|
+
const { discover } = await import("@intentius/chant");
|
|
292
|
+
const { getHelmRenderRecords, clearHelmRenderRecords } = await import("./render");
|
|
293
|
+
const { renderStability } = await import("./render-digest");
|
|
294
|
+
const { resolve } = await import("node:path");
|
|
295
|
+
|
|
296
|
+
const cwd = process.cwd();
|
|
297
|
+
const { config, configPath } = await loadChantConfig(cwd);
|
|
298
|
+
if (!configPath) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`Not a chant project: no chant.config.ts or chant.config.json at ${cwd}.\n` +
|
|
301
|
+
`chant helm renders discovers the project's HelmRender declarations, so it needs one.`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
// Records accumulate per process; discovery below is the only source this
|
|
305
|
+
// command reports, so start from an empty list (a no-op in the one-shot
|
|
306
|
+
// CLI, but honest if a long-lived caller invokes the handler directly).
|
|
307
|
+
clearHelmRenderRecords();
|
|
308
|
+
const root = resolve(cwd, config.sourceDir ?? ".");
|
|
309
|
+
const result = await discover(root);
|
|
310
|
+
if (result.errors.length > 0) {
|
|
311
|
+
for (const error of result.errors) console.error(String(error));
|
|
312
|
+
return 1;
|
|
313
|
+
}
|
|
314
|
+
const records = getHelmRenderRecords();
|
|
315
|
+
const stability = renderStability(records);
|
|
316
|
+
if (parsed.json) {
|
|
317
|
+
console.log(JSON.stringify({ records, stability }, null, 2));
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
if (records.length === 0) {
|
|
321
|
+
console.log("no HelmRender declarations recorded — the project discovered clean but declares none");
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
console.log(formatRenderRecords(records, stability));
|
|
325
|
+
return 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function formatDeepValue(v: unknown): string {
|
|
329
|
+
if (v === undefined) return "<unset>";
|
|
330
|
+
const json = JSON.stringify(v);
|
|
331
|
+
return json.length > 60 ? json.slice(0, 57) + "..." : json;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The printed render-to-live diff report: property drift per document, then holes. */
|
|
335
|
+
export function formatRenderLiveDiff(contentDigest: string, environment: string, diff: DeepDiffResult): string {
|
|
336
|
+
const driftCount = diff.drifted.reduce((n, e) => n + e.changes.length, 0);
|
|
337
|
+
const lines: string[] = [
|
|
338
|
+
`render ${contentDigest} vs live (${environment})`,
|
|
339
|
+
`${driftCount} property drift across ${diff.drifted.length} document(s), ` +
|
|
340
|
+
`${diff.accepted.length} accepted, ${diff.unchanged.length} unchanged` +
|
|
341
|
+
(diff.unobserved.length > 0 ? `, ${diff.unobserved.length} unobserved` : "") +
|
|
342
|
+
(diff.undeclaredEntities.length > 0 ? `, ${diff.undeclaredEntities.length} undeclared` : ""),
|
|
343
|
+
];
|
|
344
|
+
if (diff.unobserved.length > 0) {
|
|
345
|
+
lines.push("", "UNOBSERVED (in the render; the live read could not look):");
|
|
346
|
+
for (const u of diff.unobserved) lines.push(` ? ${u.name}${u.detail ? ` — ${u.detail}` : ` (${u.reason})`}`);
|
|
347
|
+
}
|
|
348
|
+
if (diff.drifted.length > 0) {
|
|
349
|
+
lines.push("", "DRIFT (render vs live):");
|
|
350
|
+
for (const entity of diff.drifted) {
|
|
351
|
+
lines.push(` - ${entity.name} (${entity.type})`);
|
|
352
|
+
for (const change of entity.changes) {
|
|
353
|
+
const rendered = "declared" in change ? formatDeepValue(change.declared) : "<absent from render>";
|
|
354
|
+
const live = "live" in change ? formatDeepValue(change.live) : "<absent live>";
|
|
355
|
+
lines.push(` ${change.path}: ${rendered} → ${live}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (diff.undeclaredEntities.length > 0) {
|
|
360
|
+
lines.push("", "UNDECLARED (live, not in this render):");
|
|
361
|
+
for (const name of diff.undeclaredEntities) lines.push(` - ${name}`);
|
|
362
|
+
}
|
|
363
|
+
return lines.join("\n");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* `chant helm diff` dispatches on `--live`: with it, exactly two
|
|
368
|
+
* positionals are `<content-digest> <environment>` and the render-to-live
|
|
369
|
+
* path runs (#1250); without it, they are `<from-digest> <to-digest>` and
|
|
370
|
+
* the offline render-to-render path runs (#1249). Both refuse a missing
|
|
371
|
+
* digest rather than report a false clean/empty diff — the offline path by
|
|
372
|
+
* throwing (`diffRenders`'s `requireManifest`), the live path by returning
|
|
373
|
+
* `found: false`, printed here as an error exit.
|
|
374
|
+
*/
|
|
375
|
+
async function diffHandler(ctx: CommandGroupContext): Promise<number> {
|
|
376
|
+
const parsed = parseVerbArgs("diff", ctx.rawArgs, new Set(["--live", "--json"]));
|
|
377
|
+
|
|
378
|
+
if (parsed.live) {
|
|
379
|
+
if (parsed.positionals.length !== 2) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`Usage: chant helm diff <content-digest> <environment> --live [--json]\n` +
|
|
382
|
+
`Diffs a stored render (see "chant helm renders") against the live cluster it targets. ` +
|
|
383
|
+
`The digest is a render's contentDigest; the environment resolves the cluster binding ` +
|
|
384
|
+
`(k8s.profiles.<environment>.context), same as any other live read.`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
const [contentDigest, environment] = parsed.positionals;
|
|
388
|
+
const { diffRenderLive } = await import("./render-diff");
|
|
389
|
+
const outcome = await diffRenderLive({ contentDigest, environment });
|
|
390
|
+
if (!outcome.found) {
|
|
391
|
+
console.error(
|
|
392
|
+
`no stored render for ${contentDigest} — only a PINNED render persists to the store ` +
|
|
393
|
+
`(see "chant helm renders"); an unpinned render has no stable bytes to diff against`,
|
|
394
|
+
);
|
|
395
|
+
return 1;
|
|
396
|
+
}
|
|
397
|
+
if (parsed.json) {
|
|
398
|
+
console.log(JSON.stringify({ contentDigest, environment, manifest: outcome.manifest, diff: outcome.diff }, null, 2));
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
console.log(formatRenderLiveDiff(contentDigest, environment, outcome.diff));
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const [from, to] = requireTwoDigests("diff", parsed);
|
|
406
|
+
const { diffRenders } = await import("./render-diff");
|
|
407
|
+
const diff = diffRenders(from, to);
|
|
408
|
+
if (parsed.json) {
|
|
409
|
+
console.log(JSON.stringify(diff, null, 2));
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
console.log(formatRenderDiff(diff));
|
|
413
|
+
return 0;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The `chant helm` verb group (#1248). */
|
|
417
|
+
export function helmCommandGroup(): CommandGroup {
|
|
418
|
+
return {
|
|
419
|
+
name: "helm",
|
|
420
|
+
description: "Pinned-render surface for helm charts: classify pinnability, localize unstable inputs, list recorded renders, diff two stored renders",
|
|
421
|
+
commands: [
|
|
422
|
+
{
|
|
423
|
+
name: "classify",
|
|
424
|
+
description: "Run the pinnability gate over a chart directory — verdict, capability references, open inputs, hazards (exit 1 = unpinnable)",
|
|
425
|
+
handler: classifyHandler,
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
name: "localize",
|
|
429
|
+
description: "Double-render a chart and map every unstable output line to the open input that produced it, with validated pins",
|
|
430
|
+
handler: localizeHandler,
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
name: "renders",
|
|
434
|
+
description: "Discover the project and list every HelmRender record with digests, capability profile, and render stability",
|
|
435
|
+
handler: rendersHandler,
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
name: "diff",
|
|
439
|
+
description:
|
|
440
|
+
"Diff two stored renders by content digest, offline; or diff one against the live cluster with --live",
|
|
441
|
+
handler: diffHandler,
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
};
|
|
445
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
helmConfigSchema,
|
|
8
|
+
helmCapabilityProfileSchema,
|
|
9
|
+
resolveCapabilityProfile,
|
|
10
|
+
validateCapabilityProfile,
|
|
11
|
+
KUBE_VERSION_PATTERN,
|
|
12
|
+
} from "./config";
|
|
13
|
+
|
|
14
|
+
describe("helmConfigSchema", () => {
|
|
15
|
+
test("accepts a well-formed capabilityProfiles record", () => {
|
|
16
|
+
const result = helmConfigSchema.safeParse({
|
|
17
|
+
capabilityProfiles: {
|
|
18
|
+
prod: { kubeVersion: "1.33.6", apiVersions: ["monitoring.coreos.com/v1"] },
|
|
19
|
+
staging: { kubeVersion: "v1.31" },
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
expect(result.success).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("rejects a kubeVersion that is not version-shaped", () => {
|
|
26
|
+
const result = helmCapabilityProfileSchema.safeParse({ kubeVersion: "latest" });
|
|
27
|
+
expect(result.success).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("rejects an empty apiVersions entry", () => {
|
|
31
|
+
const result = helmCapabilityProfileSchema.safeParse({ kubeVersion: "1.33.6", apiVersions: [""] });
|
|
32
|
+
expect(result.success).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("rejects an unknown profile field (typo protection)", () => {
|
|
36
|
+
const result = helmCapabilityProfileSchema.safeParse({ kubeVersion: "1.33.6", apiVersion: ["v1"] });
|
|
37
|
+
expect(result.success).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("KUBE_VERSION_PATTERN accepts the shapes --kube-version does", () => {
|
|
41
|
+
for (const ok of ["1.33", "1.33.6", "v1.33.6", "v1.31"]) {
|
|
42
|
+
expect(KUBE_VERSION_PATTERN.test(ok)).toBe(true);
|
|
43
|
+
}
|
|
44
|
+
for (const bad of ["latest", "1", "one.two", "1.33.6-rc.1", ""]) {
|
|
45
|
+
expect(KUBE_VERSION_PATTERN.test(bad)).toBe(false);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("validateCapabilityProfile", () => {
|
|
51
|
+
test("a sound profile has no errors", () => {
|
|
52
|
+
expect(
|
|
53
|
+
validateCapabilityProfile({ name: "prod", kubeVersion: "1.33.6", apiVersions: ["batch/v1"] }),
|
|
54
|
+
).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("errors name the profile and the offending field", () => {
|
|
58
|
+
const errors = validateCapabilityProfile({ name: "prod", kubeVersion: "latest", apiVersions: [""] });
|
|
59
|
+
expect(errors.length).toBe(2);
|
|
60
|
+
expect(errors[0]).toContain('"prod"');
|
|
61
|
+
expect(errors[0]).toContain("kubeVersion");
|
|
62
|
+
expect(errors[1]).toContain("apiVersions");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a missing name is an error", () => {
|
|
66
|
+
const errors = validateCapabilityProfile({ name: "", kubeVersion: "1.33.6" });
|
|
67
|
+
expect(errors.some((e) => e.includes("name"))).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("resolveCapabilityProfile", () => {
|
|
72
|
+
test("an inline profile is validated and returned as-is", () => {
|
|
73
|
+
const profile = { name: "prod", kubeVersion: "1.33.6", apiVersions: ["monitoring.coreos.com/v1"] };
|
|
74
|
+
expect(resolveCapabilityProfile(profile)).toBe(profile);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("an inline profile with a bad kubeVersion throws", () => {
|
|
78
|
+
expect(() => resolveCapabilityProfile({ name: "prod", kubeVersion: "latest" })).toThrow(/kubeVersion/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a name resolves against helm.capabilityProfiles in chant.config.json", () => {
|
|
82
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-helm-profile-test-"));
|
|
83
|
+
try {
|
|
84
|
+
writeFileSync(
|
|
85
|
+
join(dir, "chant.config.json"),
|
|
86
|
+
JSON.stringify({
|
|
87
|
+
helm: {
|
|
88
|
+
capabilityProfiles: {
|
|
89
|
+
prod: { kubeVersion: "1.33.6", apiVersions: ["monitoring.coreos.com/v1"] },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
const profile = resolveCapabilityProfile("prod", { startDir: dir });
|
|
95
|
+
expect(profile).toEqual({
|
|
96
|
+
name: "prod",
|
|
97
|
+
kubeVersion: "1.33.6",
|
|
98
|
+
apiVersions: ["monitoring.coreos.com/v1"],
|
|
99
|
+
});
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(dir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("an undeclared name throws, naming the profile and the declared ones", () => {
|
|
106
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-helm-profile-test-"));
|
|
107
|
+
try {
|
|
108
|
+
writeFileSync(
|
|
109
|
+
join(dir, "chant.config.json"),
|
|
110
|
+
JSON.stringify({ helm: { capabilityProfiles: { staging: { kubeVersion: "1.31.4" } } } }),
|
|
111
|
+
);
|
|
112
|
+
expect(() => resolveCapabilityProfile("prod", { startDir: dir })).toThrow(
|
|
113
|
+
/capability profile "prod" is not declared.*staging/s,
|
|
114
|
+
);
|
|
115
|
+
} finally {
|
|
116
|
+
rmSync(dir, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a name with no config at all throws, naming the profile", () => {
|
|
121
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-helm-profile-test-"));
|
|
122
|
+
try {
|
|
123
|
+
expect(() => resolveCapabilityProfile("prod", { startDir: dir })).toThrow(
|
|
124
|
+
/capability profile "prod" is not declared/,
|
|
125
|
+
);
|
|
126
|
+
} finally {
|
|
127
|
+
rmSync(dir, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|