@intentius/chant-lexicon-helm 0.46.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/archive-render.d.ts +94 -0
- package/dist/archive-render.d.ts.map +1 -0
- package/dist/commands.d.ts +54 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/config.d.ts +135 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/deep-observe-hooks.d.ts +2 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +54 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +17 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/index.d.ts +18 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/whm503.d.ts +34 -0
- package/dist/lint/post-synth/whm503.d.ts.map +1 -0
- package/dist/lint/post-synth/whm504.d.ts +20 -0
- package/dist/lint/post-synth/whm504.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +2 -0
- package/dist/okf/rules/WHM503.md +15 -0
- package/dist/okf/rules/WHM504.md +11 -0
- package/dist/okf/types/Values.md +1 -0
- package/dist/op/activities/cluster-probe.d.ts +44 -0
- package/dist/op/activities/cluster-probe.d.ts.map +1 -0
- package/dist/op/activities/helm.d.ts +179 -5
- package/dist/op/activities/helm.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +4 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/pinnability/actions.d.ts +76 -0
- package/dist/pinnability/actions.d.ts.map +1 -0
- package/dist/pinnability/classify.d.ts +129 -0
- package/dist/pinnability/classify.d.ts.map +1 -0
- package/dist/pinnability/conditions.d.ts +109 -0
- package/dist/pinnability/conditions.d.ts.map +1 -0
- package/dist/pinnability/index.d.ts +17 -0
- package/dist/pinnability/index.d.ts.map +1 -0
- package/dist/pinnability/localize.d.ts +124 -0
- package/dist/pinnability/localize.d.ts.map +1 -0
- package/dist/pinnability/render-stream.d.ts +27 -0
- package/dist/pinnability/render-stream.d.ts.map +1 -0
- package/dist/pinnability/values.d.ts +69 -0
- package/dist/pinnability/values.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/release-observe.d.ts +109 -0
- package/dist/release-observe.d.ts.map +1 -0
- package/dist/render-diff.d.ts +192 -0
- package/dist/render-diff.d.ts.map +1 -0
- package/dist/render-digest.d.ts +135 -0
- package/dist/render-digest.d.ts.map +1 -0
- package/dist/render-store.d.ts +310 -0
- package/dist/render-store.d.ts.map +1 -0
- package/dist/render-wrapper.d.ts +140 -0
- package/dist/render-wrapper.d.ts.map +1 -0
- package/dist/render.d.ts +81 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/rules/whm503.ts +101 -0
- package/dist/rules/whm504.ts +52 -0
- package/dist/skills/chant-helm.md +7 -0
- package/dist/values-probe.d.ts +177 -0
- package/dist/values-probe.d.ts.map +1 -0
- package/dist/wrapper-chart.d.ts +61 -0
- package/dist/wrapper-chart.d.ts.map +1 -0
- package/package.json +10 -4
- package/src/archive-render.test.ts +201 -0
- package/src/archive-render.ts +119 -0
- package/src/commands.test.ts +487 -0
- package/src/commands.ts +445 -0
- package/src/config.test.ts +130 -0
- package/src/config.ts +220 -0
- package/src/deep-observe-hooks.ts +20 -0
- package/src/deep-observe.test.ts +165 -0
- package/src/deep-observe.ts +121 -0
- package/src/describe-resources.test.ts +255 -0
- package/src/describe-resources.ts +198 -0
- package/src/index.ts +98 -2
- package/src/lifecycle-integration.test.ts +289 -0
- package/src/lint/audit-catalog.ts +2 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/whm503.test.ts +134 -0
- package/src/lint/post-synth/whm503.ts +101 -0
- package/src/lint/post-synth/whm504.test.ts +105 -0
- package/src/lint/post-synth/whm504.ts +52 -0
- package/src/op/activities/cluster-probe.test.ts +66 -0
- package/src/op/activities/cluster-probe.ts +148 -0
- package/src/op/activities/helm.test.ts +824 -0
- package/src/op/activities/helm.ts +628 -5
- package/src/op/activities/index.ts +17 -2
- package/src/pinnability/actions.ts +263 -0
- package/src/pinnability/classify.ts +579 -0
- package/src/pinnability/conditions.ts +0 -0
- package/src/pinnability/index.ts +77 -0
- package/src/pinnability/localize.test.ts +234 -0
- package/src/pinnability/localize.ts +644 -0
- package/src/pinnability/pinnability.test.ts +560 -0
- package/src/pinnability/render-stream.ts +59 -0
- package/src/pinnability/values.ts +205 -0
- package/src/plugin.test.ts +3 -1
- package/src/plugin.ts +47 -0
- package/src/release-observe.ts +392 -0
- package/src/render-diff.test.ts +366 -0
- package/src/render-diff.ts +407 -0
- package/src/render-digest.test.ts +186 -0
- package/src/render-digest.ts +207 -0
- package/src/render-store.test.ts +412 -0
- package/src/render-store.ts +621 -0
- package/src/render-wrapper.test.ts +259 -0
- package/src/render-wrapper.ts +274 -0
- package/src/render.test.ts +383 -4
- package/src/render.ts +319 -8
- package/src/skills/chant-helm.md +7 -0
- package/src/values-probe.test.ts +400 -0
- package/src/values-probe.ts +627 -0
- package/src/wrapper-chart.test.ts +110 -0
- package/src/wrapper-chart.ts +124 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time coalesced-values probe (#1251, epic #1228 Phase 7) and the
|
|
3
|
+
* provenance derived from it (#1252).
|
|
4
|
+
*
|
|
5
|
+
* `helm template --debug` does not print coalesced values on either major
|
|
6
|
+
* version, and `helm get values --all` needs an installed release — the
|
|
7
|
+
* wrong side of the build/deploy line. So the probe extracts them from helm
|
|
8
|
+
* itself: copy the chart, replace every (sub)chart's `templates/` with a
|
|
9
|
+
* single `chant-values-probe.yaml` template containing `{{ toYaml .Values }}`,
|
|
10
|
+
* render, lift the probe documents out, discard the copy. Coalescing —
|
|
11
|
+
* parent-overrides-child, `global` propagation, alias scoping,
|
|
12
|
+
* `import-values`, tags — is delegated to helm rather than reimplemented
|
|
13
|
+
* (the approximation in `pinnability/values.ts` remains only as the
|
|
14
|
+
* classifier's offline fallback).
|
|
15
|
+
*
|
|
16
|
+
* The probe filename must NOT begin with `_`: helm treats underscore-prefixed
|
|
17
|
+
* template files as partials and skips them without error.
|
|
18
|
+
*
|
|
19
|
+
* The probe runs against a private copy in a temp directory, so the probe
|
|
20
|
+
* document can never appear in a real render of the chart — the pinned
|
|
21
|
+
* artifact path (#1237/#1242) never sees the injected template.
|
|
22
|
+
*
|
|
23
|
+
* A dependency disabled by a `condition:` (or tags) is pruned by helm before
|
|
24
|
+
* rendering, so it yields no probe document. Those instances are reported in
|
|
25
|
+
* `disabled` instead, with the condition path where determinable. Note the
|
|
26
|
+
* ROOT probe document still carries the values subtree a disabled dependency
|
|
27
|
+
* would have received — an installed release omits it (epic findings 14, 15).
|
|
28
|
+
*
|
|
29
|
+
* On the probe output, three provenance products (#1252):
|
|
30
|
+
*
|
|
31
|
+
* - `digest` — `sha256:` over the canonical JSON (core's `canonicalJson`,
|
|
32
|
+
* the #1237 convention) of the per-instance coalesced trees, keyed by
|
|
33
|
+
* scope path. Same inputs, same coalesced values, same digest.
|
|
34
|
+
* - `valueSources` — each coalesced path attributed to its winning layer:
|
|
35
|
+
* chart default / parent override / supplied file / --set.
|
|
36
|
+
* - `deadAssignments` — supplied values that never survive coalescing:
|
|
37
|
+
* shadowed by a later supplied layer, or targeting a disabled or unknown
|
|
38
|
+
* subchart path. WHM504 turns these into lint diagnostics.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { execFileSync } from "node:child_process";
|
|
42
|
+
import { createHash } from "node:crypto";
|
|
43
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
44
|
+
import { tmpdir } from "node:os";
|
|
45
|
+
import { join } from "node:path";
|
|
46
|
+
|
|
47
|
+
import { canonicalJson } from "@intentius/chant/effect-receipt";
|
|
48
|
+
import yaml from "js-yaml";
|
|
49
|
+
|
|
50
|
+
import { splitDocuments } from "./pinnability/render-stream";
|
|
51
|
+
import { readChartMeta, readYamlFile, valueAtPath } from "./pinnability/values";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The probe template. Renders one document per chart instance carrying the
|
|
55
|
+
* exact `.Values` that instance coalesced. For an aliased dependency helm
|
|
56
|
+
* sets `.Chart.Name` (and the `# Source:` path segment) to the alias, so two
|
|
57
|
+
* instances over the same chart directory stay distinguishable.
|
|
58
|
+
*/
|
|
59
|
+
const PROBE_TEMPLATE =
|
|
60
|
+
"chantValuesProbe: v1\nchart: {{ .Chart.Name }}\nvalues:\n{{ .Values | toYaml | nindent 2 }}\n";
|
|
61
|
+
|
|
62
|
+
/** Must not start with `_` — helm skips underscore-prefixed files as partials. */
|
|
63
|
+
const PROBE_FILENAME = "chant-values-probe.yaml";
|
|
64
|
+
|
|
65
|
+
/** Where a coalesced value came from. `computed` = no supplied or authored layer matches (e.g. `import-values`). */
|
|
66
|
+
export type ValueOrigin = "chart default" | "parent override" | "supplied file" | "--set" | "computed";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One layer of values supplied to the render, in ascending precedence order
|
|
70
|
+
* (a later layer overrides an earlier one, like repeated `--values` flags;
|
|
71
|
+
* `--set` layers conventionally come last). `origin` is the label reported
|
|
72
|
+
* by `valueSources` and dead-assignment findings.
|
|
73
|
+
*/
|
|
74
|
+
export interface SuppliedValuesLayer {
|
|
75
|
+
origin: "supplied file" | "--set";
|
|
76
|
+
/** Optional identifier (a file path, a flag) used in messages. */
|
|
77
|
+
name?: string;
|
|
78
|
+
values: Record<string, unknown>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One chart instance and the values helm coalesced for it. */
|
|
82
|
+
export interface CoalescedChartValues {
|
|
83
|
+
/** Value scope from the root: `[]` for the root chart, `["kidtwo"]` for an aliased dependency, nested for grandchildren. */
|
|
84
|
+
scope: string[];
|
|
85
|
+
/** `.Chart.Name` as rendered — the alias for an aliased dependency. */
|
|
86
|
+
chartName: string;
|
|
87
|
+
/** The fully coalesced values this instance saw, exactly as helm computed them. */
|
|
88
|
+
values: Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A declared dependency helm pruned before rendering. */
|
|
92
|
+
export interface DisabledDependency {
|
|
93
|
+
scope: string[];
|
|
94
|
+
/** The dependency's real chart name (not the alias). */
|
|
95
|
+
name: string;
|
|
96
|
+
/** The `condition:` path that resolved false, when determinable (tags-disabled dependencies carry none). */
|
|
97
|
+
condition?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A supplied value that never survives coalescing. */
|
|
101
|
+
export interface DeadAssignment {
|
|
102
|
+
/** Dot-joined path in root coordinates. */
|
|
103
|
+
path: string;
|
|
104
|
+
/** The layer that supplied it, e.g. `supplied file (values-prod.yaml)`. */
|
|
105
|
+
origin: string;
|
|
106
|
+
reason: "shadowed" | "disabled-subchart" | "unknown-subchart";
|
|
107
|
+
/** What killed it: the shadowing layer, or the disabling condition. */
|
|
108
|
+
shadowedBy?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface CoalescedValuesProbe {
|
|
112
|
+
/** Enabled chart instances, root first, then by scope path. */
|
|
113
|
+
instances: CoalescedChartValues[];
|
|
114
|
+
disabled: DisabledDependency[];
|
|
115
|
+
/** `sha256:` over the canonical JSON of the per-instance coalesced trees. */
|
|
116
|
+
digest: string;
|
|
117
|
+
/** Dot-joined path (root coordinates, subchart paths scope-prefixed) to winning layer. */
|
|
118
|
+
valueSources: Record<string, ValueOrigin>;
|
|
119
|
+
deadAssignments: DeadAssignment[];
|
|
120
|
+
warnings: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ValuesProbeOptions {
|
|
124
|
+
/** Directory of the chart to probe (must contain Chart.yaml). Never modified — the probe works on a copy. */
|
|
125
|
+
chartDir: string;
|
|
126
|
+
/** Logical name recorded with the probe. Defaults to the release name. */
|
|
127
|
+
name?: string;
|
|
128
|
+
/** Release name passed to `helm template`. Default `chant-values-probe`. */
|
|
129
|
+
releaseName?: string;
|
|
130
|
+
namespace?: string;
|
|
131
|
+
/** Values layers in ascending precedence order. */
|
|
132
|
+
supplied?: SuppliedValuesLayer[];
|
|
133
|
+
/** Injectable helm runner (tests). Receives the full argv after `helm`. */
|
|
134
|
+
runHelm?: (args: string[]) => string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
138
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
142
|
+
return canonicalJson(a ?? null) === canonicalJson(b ?? null);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Leaf paths of a values tree. Arrays and empty maps are leaves — helm
|
|
147
|
+
* replaces arrays wholesale and an empty map carries no mergeable children.
|
|
148
|
+
*/
|
|
149
|
+
function leafPaths(v: unknown, prefix: string[] = []): string[][] {
|
|
150
|
+
if (isPlainObject(v) && Object.keys(v).length > 0) {
|
|
151
|
+
const out: string[][] = [];
|
|
152
|
+
for (const [k, child] of Object.entries(v)) out.push(...leafPaths(child, [...prefix, k]));
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
return prefix.length > 0 ? [prefix] : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A node of the declared dependency tree (walked over the probe's chart copy). */
|
|
159
|
+
interface ScopeNode {
|
|
160
|
+
scope: string[];
|
|
161
|
+
/** Real chart name. */
|
|
162
|
+
name: string;
|
|
163
|
+
/** Chart directory (aliased instances share one). */
|
|
164
|
+
dir: string;
|
|
165
|
+
condition?: string;
|
|
166
|
+
/** Scope of the parent node ([] for children of the root). */
|
|
167
|
+
parentScope: string[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const scopeKey = (scope: readonly string[]): string => scope.join(".");
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Walk the (copied, tgz-extracted) chart tree the way helm's loader does:
|
|
174
|
+
* declared dependencies under their alias-or-name key, undeclared `charts/`
|
|
175
|
+
* directories as implicit always-enabled dependencies.
|
|
176
|
+
*/
|
|
177
|
+
function collectScopeNodes(rootDir: string): ScopeNode[] {
|
|
178
|
+
const nodes: ScopeNode[] = [];
|
|
179
|
+
const visit = (dir: string, scope: string[]): void => {
|
|
180
|
+
const meta = readChartMeta(dir);
|
|
181
|
+
nodes.push({
|
|
182
|
+
scope,
|
|
183
|
+
name: meta.name,
|
|
184
|
+
dir,
|
|
185
|
+
parentScope: scope.slice(0, -1),
|
|
186
|
+
// condition is attached where the dependency is declared, below.
|
|
187
|
+
});
|
|
188
|
+
const chartsDir = join(dir, "charts");
|
|
189
|
+
const childDirs = existsSync(chartsDir)
|
|
190
|
+
? readdirSync(chartsDir).filter((e) => {
|
|
191
|
+
const p = join(chartsDir, e);
|
|
192
|
+
return statSync(p).isDirectory() && existsSync(join(p, "Chart.yaml"));
|
|
193
|
+
})
|
|
194
|
+
: [];
|
|
195
|
+
const declared = new Set<string>();
|
|
196
|
+
for (const dep of meta.dependencies) {
|
|
197
|
+
const childDir = join(chartsDir, dep.name);
|
|
198
|
+
if (!existsSync(join(childDir, "Chart.yaml"))) continue;
|
|
199
|
+
declared.add(dep.name);
|
|
200
|
+
const childScope = [...scope, dep.alias ?? dep.name];
|
|
201
|
+
visit(childDir, childScope);
|
|
202
|
+
const node = nodes.find((n) => scopeKey(n.scope) === scopeKey(childScope));
|
|
203
|
+
if (node) node.condition = dep.condition;
|
|
204
|
+
}
|
|
205
|
+
for (const name of childDirs) {
|
|
206
|
+
if (!declared.has(name)) visit(join(chartsDir, name), [...scope, name]);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
visit(rootDir, []);
|
|
210
|
+
return nodes;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Extract packaged subcharts (`charts/*.tgz`) in place so probes can be
|
|
215
|
+
* injected into them, then recurse. Uses the system `tar`; a failed
|
|
216
|
+
* extraction leaves the tgz alone and records a warning — that subchart's
|
|
217
|
+
* values then appear only through its parents' probe documents.
|
|
218
|
+
*/
|
|
219
|
+
function extractPackagedSubcharts(dir: string, warnings: string[]): void {
|
|
220
|
+
const chartsDir = join(dir, "charts");
|
|
221
|
+
if (!existsSync(chartsDir)) return;
|
|
222
|
+
for (const entry of readdirSync(chartsDir)) {
|
|
223
|
+
if (!entry.endsWith(".tgz")) continue;
|
|
224
|
+
const tgz = join(chartsDir, entry);
|
|
225
|
+
if (!statSync(tgz).isFile()) continue;
|
|
226
|
+
try {
|
|
227
|
+
// A chart archive unpacks to a single top-level directory named after
|
|
228
|
+
// the chart. Skip when that directory already exists.
|
|
229
|
+
execFileSync("tar", ["-xzf", tgz, "-C", chartsDir], { stdio: "ignore" });
|
|
230
|
+
rmSync(tgz);
|
|
231
|
+
} catch {
|
|
232
|
+
warnings.push(`could not extract packaged subchart ${entry} — its own probe document is skipped`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const entry of readdirSync(chartsDir)) {
|
|
236
|
+
const p = join(chartsDir, entry);
|
|
237
|
+
if (statSync(p).isDirectory() && existsSync(join(p, "Chart.yaml"))) {
|
|
238
|
+
extractPackagedSubcharts(p, warnings);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Replace each (sub)chart's `templates/` with the single probe template.
|
|
245
|
+
* Deleting the real templates sidesteps render-time failures (`required`,
|
|
246
|
+
* missing capabilities) — values coalescing does not depend on them.
|
|
247
|
+
* Library charts are left alone: helm never renders their templates.
|
|
248
|
+
*/
|
|
249
|
+
function injectProbes(dir: string, warnings: string[]): void {
|
|
250
|
+
const chartYaml = readYamlFile(join(dir, "Chart.yaml")) as { type?: unknown } | undefined;
|
|
251
|
+
if (chartYaml?.type === "library") {
|
|
252
|
+
warnings.push(`${dir} is a library chart — helm renders no templates for it, so it yields no probe document`);
|
|
253
|
+
} else {
|
|
254
|
+
const templatesDir = join(dir, "templates");
|
|
255
|
+
rmSync(templatesDir, { recursive: true, force: true });
|
|
256
|
+
mkdirSync(templatesDir, { recursive: true });
|
|
257
|
+
writeFileSync(join(templatesDir, PROBE_FILENAME), PROBE_TEMPLATE);
|
|
258
|
+
}
|
|
259
|
+
const chartsDir = join(dir, "charts");
|
|
260
|
+
if (!existsSync(chartsDir)) return;
|
|
261
|
+
for (const entry of readdirSync(chartsDir)) {
|
|
262
|
+
const p = join(chartsDir, entry);
|
|
263
|
+
if (statSync(p).isDirectory() && existsSync(join(p, "Chart.yaml"))) injectProbes(p, warnings);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function defaultRunHelm(args: string[]): string {
|
|
268
|
+
try {
|
|
269
|
+
return execFileSync("helm", args, {
|
|
270
|
+
encoding: "utf8",
|
|
271
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
272
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
273
|
+
});
|
|
274
|
+
} catch (err) {
|
|
275
|
+
const stderr =
|
|
276
|
+
err && typeof err === "object" && "stderr" in err ? String((err as { stderr: unknown }).stderr) : String(err);
|
|
277
|
+
throw new Error(
|
|
278
|
+
`coalesced-values probe render failed:\n${stderr}\n` +
|
|
279
|
+
`Hint: ensure the 'helm' CLI is on PATH and the chart's dependencies are vendored under charts/.`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Scope of a probe document, from its `# Source:` path — every segment following a `charts/`. */
|
|
285
|
+
function scopeFromSource(sourceLine: string): string[] | undefined {
|
|
286
|
+
const m = sourceLine.match(/^# Source: (\S+)$/m);
|
|
287
|
+
if (!m) return undefined;
|
|
288
|
+
const segments = m[1].split("/");
|
|
289
|
+
if (segments[segments.length - 1] !== PROBE_FILENAME) return undefined;
|
|
290
|
+
const scope: string[] = [];
|
|
291
|
+
for (let i = 1; i < segments.length - 2; i += 2) {
|
|
292
|
+
if (segments[i] !== "charts") return undefined;
|
|
293
|
+
scope.push(segments[i + 1]);
|
|
294
|
+
}
|
|
295
|
+
return scope;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The coalesced-values identity: `sha256:` over the canonical JSON of the
|
|
300
|
+
* per-instance trees keyed by scope path — the #1237 digest conventions
|
|
301
|
+
* (core `canonicalJson`, `sha256:` prefix). Two renders that coalesce the
|
|
302
|
+
* same values for every instance share it; any value, in any subchart,
|
|
303
|
+
* changing breaks it.
|
|
304
|
+
*/
|
|
305
|
+
export function coalescedValuesDigest(instances: readonly CoalescedChartValues[]): string {
|
|
306
|
+
const tree: Record<string, unknown> = {};
|
|
307
|
+
for (const i of instances) tree[scopeKey(i.scope)] = i.values;
|
|
308
|
+
return `sha256:${createHash("sha256").update(canonicalJson(tree), "utf8").digest("hex")}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Inputs the pure provenance analysis needs — separable from the render for tests. */
|
|
312
|
+
export interface ValuesAttributionInput {
|
|
313
|
+
instances: readonly CoalescedChartValues[];
|
|
314
|
+
supplied: readonly SuppliedValuesLayer[];
|
|
315
|
+
/** The chart-default `values.yaml` for an instance scope; undefined when unknown. */
|
|
316
|
+
defaultsFor: (scope: readonly string[]) => Record<string, unknown> | undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Attribute each coalesced path to the layer that won it.
|
|
321
|
+
*
|
|
322
|
+
* Keys are dot-joined root-coordinate paths: a subchart instance's paths are
|
|
323
|
+
* prefixed with its scope, so `kidtwo.replicas` is the `replicas` the
|
|
324
|
+
* `kidtwo` instance actually sees. A key of an instance's tree that is
|
|
325
|
+
* itself an enabled child scope is skipped — helm pushes the child's
|
|
326
|
+
* coalesced tree back into the parent's `.Values`, and the child instance
|
|
327
|
+
* owns those paths.
|
|
328
|
+
*
|
|
329
|
+
* Layer precedence mirrors helm: supplied layers (last first), then ancestor
|
|
330
|
+
* chart defaults (root outranks intermediates — "parent override"), then the
|
|
331
|
+
* instance's own defaults ("chart default"). A path no layer explains — an
|
|
332
|
+
* `import-values` product, a helm-computed merge — reports `computed`.
|
|
333
|
+
* Attribution matches on value equality, so a layer that assigned a path but
|
|
334
|
+
* lost it reports as the layer that actually won.
|
|
335
|
+
*/
|
|
336
|
+
export function computeValueSources(input: ValuesAttributionInput): Record<string, ValueOrigin> {
|
|
337
|
+
const out: Record<string, ValueOrigin> = {};
|
|
338
|
+
const instanceScopes = new Set(input.instances.map((i) => scopeKey(i.scope)));
|
|
339
|
+
|
|
340
|
+
for (const instance of input.instances) {
|
|
341
|
+
for (const path of leafPaths(instance.values)) {
|
|
342
|
+
// Skip paths under an enabled child scope — the child instance owns them.
|
|
343
|
+
if (instanceScopes.has(scopeKey([...instance.scope, path[0]]))) continue;
|
|
344
|
+
const coalesced = valueAtPath(instance.values, path);
|
|
345
|
+
out[scopeKey([...instance.scope, ...path])] = attributePath(instance, path, coalesced, input);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function attributePath(
|
|
352
|
+
instance: CoalescedChartValues,
|
|
353
|
+
path: string[],
|
|
354
|
+
coalesced: unknown,
|
|
355
|
+
input: ValuesAttributionInput,
|
|
356
|
+
): ValueOrigin {
|
|
357
|
+
const rootPath = [...instance.scope, ...path];
|
|
358
|
+
const isGlobal = path[0] === "global" && instance.scope.length > 0;
|
|
359
|
+
|
|
360
|
+
// 1. Supplied layers, highest precedence first. A subchart's `global.*`
|
|
361
|
+
// can be fed either scoped (`<scope>.global.*`) or from the root
|
|
362
|
+
// `global.*` — scoped wins, so check it first.
|
|
363
|
+
for (let i = input.supplied.length - 1; i >= 0; i--) {
|
|
364
|
+
const layer = input.supplied[i];
|
|
365
|
+
let candidate = valueAtPath(layer.values, rootPath);
|
|
366
|
+
if (candidate === undefined && isGlobal) candidate = valueAtPath(layer.values, path);
|
|
367
|
+
if (candidate !== undefined && deepEqual(candidate, coalesced)) return layer.origin;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 2. Ancestor chart defaults — the top parent outranks intermediates.
|
|
371
|
+
for (let prefixLen = 0; prefixLen < instance.scope.length; prefixLen++) {
|
|
372
|
+
const ancestorScope = instance.scope.slice(0, prefixLen);
|
|
373
|
+
const defaults = input.defaultsFor(ancestorScope);
|
|
374
|
+
if (defaults === undefined) continue;
|
|
375
|
+
const rel = [...instance.scope.slice(prefixLen), ...path];
|
|
376
|
+
let candidate = valueAtPath(defaults, rel);
|
|
377
|
+
if (candidate === undefined && isGlobal) candidate = valueAtPath(defaults, path);
|
|
378
|
+
if (candidate !== undefined && deepEqual(candidate, coalesced)) return "parent override";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 3. The instance's own values.yaml.
|
|
382
|
+
const own = input.defaultsFor(instance.scope);
|
|
383
|
+
if (own !== undefined) {
|
|
384
|
+
const candidate = valueAtPath(own, path);
|
|
385
|
+
if (candidate !== undefined && deepEqual(candidate, coalesced)) return "chart default";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return "computed";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const layerLabel = (layer: SuppliedValuesLayer): string =>
|
|
392
|
+
layer.name !== undefined ? `${layer.origin} (${layer.name})` : layer.origin;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Supplied values that never survive coalescing:
|
|
396
|
+
*
|
|
397
|
+
* - `shadowed` — a later supplied layer assigns the same path (a map merging
|
|
398
|
+
* into a map is not a shadow; anything else replaces).
|
|
399
|
+
* - `disabled-subchart` — the path targets a dependency a `condition:` (or
|
|
400
|
+
* tags) disabled, so no rendered chart ever reads it.
|
|
401
|
+
* - `unknown-subchart` — a map supplied under a top-level key that matches
|
|
402
|
+
* no dependency, no root default, and not `global`, on a chart that HAS
|
|
403
|
+
* dependencies: the classic silently-ignored typo of a subchart name.
|
|
404
|
+
* Reported once per top-level key per layer.
|
|
405
|
+
*/
|
|
406
|
+
export function findDeadAssignments(
|
|
407
|
+
input: ValuesAttributionInput,
|
|
408
|
+
disabled: readonly DisabledDependency[],
|
|
409
|
+
): DeadAssignment[] {
|
|
410
|
+
const out: DeadAssignment[] = [];
|
|
411
|
+
const rootDefaults = input.defaultsFor([]) ?? {};
|
|
412
|
+
const knownFirstSegments = new Set<string>(["global", ...Object.keys(rootDefaults)]);
|
|
413
|
+
let hasDependencies = false;
|
|
414
|
+
for (const i of input.instances) {
|
|
415
|
+
if (i.scope.length > 0) {
|
|
416
|
+
knownFirstSegments.add(i.scope[0]);
|
|
417
|
+
hasDependencies = true;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const d of disabled) {
|
|
421
|
+
knownFirstSegments.add(d.scope[0]);
|
|
422
|
+
if (d.name !== d.scope[0]) knownFirstSegments.add(d.name);
|
|
423
|
+
hasDependencies = true;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
for (let i = 0; i < input.supplied.length; i++) {
|
|
427
|
+
const layer = input.supplied[i];
|
|
428
|
+
const flaggedUnknown = new Set<string>();
|
|
429
|
+
for (const path of leafPaths(layer.values)) {
|
|
430
|
+
const value = valueAtPath(layer.values, path);
|
|
431
|
+
|
|
432
|
+
// Shadowed by a later supplied layer?
|
|
433
|
+
const shadow = input.supplied.slice(i + 1).find((later) => {
|
|
434
|
+
const laterValue = valueAtPath(later.values, path);
|
|
435
|
+
if (laterValue === undefined) return false;
|
|
436
|
+
return !(isPlainObject(value) && isPlainObject(laterValue));
|
|
437
|
+
});
|
|
438
|
+
if (shadow) {
|
|
439
|
+
out.push({
|
|
440
|
+
path: scopeKey(path),
|
|
441
|
+
origin: layerLabel(layer),
|
|
442
|
+
reason: "shadowed",
|
|
443
|
+
shadowedBy: layerLabel(shadow),
|
|
444
|
+
});
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Targeting a disabled dependency?
|
|
449
|
+
const disabledHit = disabled.find(
|
|
450
|
+
(d) => d.scope.length <= path.length && scopeKey(path.slice(0, d.scope.length)) === scopeKey(d.scope),
|
|
451
|
+
);
|
|
452
|
+
if (disabledHit) {
|
|
453
|
+
out.push({
|
|
454
|
+
path: scopeKey(path),
|
|
455
|
+
origin: layerLabel(layer),
|
|
456
|
+
reason: "disabled-subchart",
|
|
457
|
+
shadowedBy:
|
|
458
|
+
disabledHit.condition !== undefined
|
|
459
|
+
? `disabled subchart ${scopeKey(disabledHit.scope)} (condition ${disabledHit.condition} is false)`
|
|
460
|
+
: `disabled subchart ${scopeKey(disabledHit.scope)}`,
|
|
461
|
+
});
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// A map under an unknown top-level key on a chart with dependencies?
|
|
466
|
+
if (
|
|
467
|
+
hasDependencies &&
|
|
468
|
+
path.length > 1 &&
|
|
469
|
+
!knownFirstSegments.has(path[0]) &&
|
|
470
|
+
isPlainObject(layer.values[path[0]]) &&
|
|
471
|
+
!flaggedUnknown.has(path[0])
|
|
472
|
+
) {
|
|
473
|
+
flaggedUnknown.add(path[0]);
|
|
474
|
+
out.push({
|
|
475
|
+
path: path[0],
|
|
476
|
+
origin: layerLabel(layer),
|
|
477
|
+
reason: "unknown-subchart",
|
|
478
|
+
shadowedBy: `no dependency, chart default, or global named "${path[0]}"`,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Run the probe: copy the chart, inject probe templates, render, lift the
|
|
488
|
+
* probe documents out, analyze, discard the copy. The source chart is never
|
|
489
|
+
* touched. Every run is recorded for WHM504 (`getValuesProbeRecords`).
|
|
490
|
+
*/
|
|
491
|
+
export function probeCoalescedValues(options: ValuesProbeOptions): CoalescedValuesProbe {
|
|
492
|
+
if (!existsSync(join(options.chartDir, "Chart.yaml"))) {
|
|
493
|
+
throw new Error(`coalesced-values probe: ${options.chartDir} has no Chart.yaml`);
|
|
494
|
+
}
|
|
495
|
+
const warnings: string[] = [];
|
|
496
|
+
const supplied = options.supplied ?? [];
|
|
497
|
+
const workRoot = mkdtempSync(join(tmpdir(), "chant-values-probe-"));
|
|
498
|
+
try {
|
|
499
|
+
const chartCopy = join(workRoot, "chart");
|
|
500
|
+
cpSync(options.chartDir, chartCopy, { recursive: true });
|
|
501
|
+
extractPackagedSubcharts(chartCopy, warnings);
|
|
502
|
+
injectProbes(chartCopy, warnings);
|
|
503
|
+
const scopeNodes = collectScopeNodes(chartCopy);
|
|
504
|
+
|
|
505
|
+
const releaseName = options.releaseName ?? "chant-values-probe";
|
|
506
|
+
const args = ["template", releaseName, chartCopy];
|
|
507
|
+
if (options.namespace !== undefined) args.push("--namespace", options.namespace);
|
|
508
|
+
supplied.forEach((layer, i) => {
|
|
509
|
+
const layerPath = join(workRoot, `supplied-${i}.yaml`);
|
|
510
|
+
writeFileSync(layerPath, yaml.dump(layer.values, { lineWidth: -1, noRefs: true }));
|
|
511
|
+
args.push("--values", layerPath);
|
|
512
|
+
});
|
|
513
|
+
const rendered = (options.runHelm ?? defaultRunHelm)(args);
|
|
514
|
+
|
|
515
|
+
// Lift the probe documents out.
|
|
516
|
+
const instances: CoalescedChartValues[] = [];
|
|
517
|
+
for (const doc of splitDocuments(rendered)) {
|
|
518
|
+
if (!doc.includes("chantValuesProbe:")) continue;
|
|
519
|
+
let parsed: unknown;
|
|
520
|
+
try {
|
|
521
|
+
parsed = yaml.load(doc);
|
|
522
|
+
} catch {
|
|
523
|
+
warnings.push("a probe document failed to parse as YAML — skipped");
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (!isPlainObject(parsed) || parsed.chantValuesProbe !== "v1") continue;
|
|
527
|
+
const scope = scopeFromSource(doc);
|
|
528
|
+
if (scope === undefined) {
|
|
529
|
+
warnings.push("a probe document carried no usable # Source: header — skipped");
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
instances.push({
|
|
533
|
+
scope,
|
|
534
|
+
chartName: typeof parsed.chart === "string" ? parsed.chart : "",
|
|
535
|
+
values: isPlainObject(parsed.values) ? parsed.values : {},
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
instances.sort((a, b) => a.scope.length - b.scope.length || scopeKey(a.scope).localeCompare(scopeKey(b.scope)));
|
|
539
|
+
if (instances.length === 0) {
|
|
540
|
+
throw new Error(
|
|
541
|
+
`coalesced-values probe: render of ${options.chartDir} produced no probe document — is the chart renderable?`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
const root = instances[0];
|
|
545
|
+
|
|
546
|
+
// Declared dependencies with no probe document were pruned by helm —
|
|
547
|
+
// disabled by a condition (evaluated here against the EXACT coalesced
|
|
548
|
+
// root values) or by tags. Only nodes whose parent rendered are
|
|
549
|
+
// reported; a disabled subtree's descendants are unreachable anyway.
|
|
550
|
+
const probedScopes = new Set(instances.map((i) => scopeKey(i.scope)));
|
|
551
|
+
const disabled: DisabledDependency[] = [];
|
|
552
|
+
for (const node of scopeNodes) {
|
|
553
|
+
if (node.scope.length === 0 || probedScopes.has(scopeKey(node.scope))) continue;
|
|
554
|
+
if (!probedScopes.has(scopeKey(node.parentScope))) continue;
|
|
555
|
+
let condition: string | undefined;
|
|
556
|
+
for (const rawPath of (node.condition ?? "").split(",")) {
|
|
557
|
+
const path = rawPath.trim();
|
|
558
|
+
if (path === "") continue;
|
|
559
|
+
const v = valueAtPath(root.values, path.split("."));
|
|
560
|
+
if (v === undefined) continue;
|
|
561
|
+
if (v === false) condition = path;
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
disabled.push({ scope: node.scope, name: node.name, condition });
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const defaultsCache = new Map<string, Record<string, unknown> | undefined>();
|
|
568
|
+
const defaultsFor = (scope: readonly string[]): Record<string, unknown> | undefined => {
|
|
569
|
+
const key = scopeKey(scope);
|
|
570
|
+
if (!defaultsCache.has(key)) {
|
|
571
|
+
const node = scopeNodes.find((n) => scopeKey(n.scope) === key);
|
|
572
|
+
const loaded = node ? readYamlFile(join(node.dir, "values.yaml")) : undefined;
|
|
573
|
+
defaultsCache.set(key, isPlainObject(loaded) ? loaded : undefined);
|
|
574
|
+
}
|
|
575
|
+
return defaultsCache.get(key);
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const attribution: ValuesAttributionInput = { instances, supplied, defaultsFor };
|
|
579
|
+
const probe: CoalescedValuesProbe = {
|
|
580
|
+
instances,
|
|
581
|
+
disabled,
|
|
582
|
+
digest: coalescedValuesDigest(instances),
|
|
583
|
+
valueSources: computeValueSources(attribution),
|
|
584
|
+
deadAssignments: findDeadAssignments(attribution, disabled),
|
|
585
|
+
warnings,
|
|
586
|
+
};
|
|
587
|
+
probeRecords.push({ name: options.name ?? releaseName, chartDir: options.chartDir, probe });
|
|
588
|
+
return probe;
|
|
589
|
+
} finally {
|
|
590
|
+
rmSync(workRoot, { recursive: true, force: true });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** What one probe run recorded about itself — the WHM504 lint surface. */
|
|
595
|
+
export interface HelmValuesProbeRecord {
|
|
596
|
+
name: string;
|
|
597
|
+
chartDir: string;
|
|
598
|
+
probe: CoalescedValuesProbe;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const probeRecords: HelmValuesProbeRecord[] = [];
|
|
602
|
+
|
|
603
|
+
/** Every probe recorded in this process, in invocation order. */
|
|
604
|
+
export function getValuesProbeRecords(): readonly HelmValuesProbeRecord[] {
|
|
605
|
+
return probeRecords;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Record a probe computed elsewhere (the render path integration point). */
|
|
609
|
+
export function recordValuesProbe(record: HelmValuesProbeRecord): void {
|
|
610
|
+
probeRecords.push(record);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Reset the record list (test isolation). */
|
|
614
|
+
export function clearValuesProbeRecords(): void {
|
|
615
|
+
probeRecords.length = 0;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* The values gap between the probe and an installed release: an installed
|
|
620
|
+
* release's `helm get values --all` omits subtrees for disabled
|
|
621
|
+
* dependencies; the probe's root document keeps them (epic findings 14, 15).
|
|
622
|
+
* On shared keys the two agree — this helper names the shared-key view for
|
|
623
|
+
* comparisons against a live release.
|
|
624
|
+
*/
|
|
625
|
+
export function rootCoalescedValues(probe: CoalescedValuesProbe): Record<string, unknown> {
|
|
626
|
+
return probe.instances[0]?.values ?? {};
|
|
627
|
+
}
|