@intentius/chant-lexicon-helm 0.124.31 → 0.124.32
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.js +76 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +113 -0
- package/dist/codegen/generate-cli.js +19 -0
- package/dist/codegen/generate.js +210 -0
- package/dist/codegen/naming.js +29 -0
- package/dist/codegen/package.js +44 -0
- package/dist/codegen/values-type.js +87 -0
- package/dist/commands.js +413 -0
- package/dist/components/builders.js +9 -0
- package/dist/components/capability-plugin.js +17 -0
- package/dist/components/helm-upgrade.js +109 -0
- package/dist/components/index.js +11 -0
- package/dist/composites/catalog.js +1119 -0
- package/dist/composites/helm-batch-job.js +148 -0
- package/dist/composites/helm-crd-lifecycle.js +145 -0
- package/dist/composites/helm-cron-job.js +130 -0
- package/dist/composites/helm-daemon-set.js +125 -0
- package/dist/composites/helm-external-secret.js +63 -0
- package/dist/composites/helm-library.js +24 -0
- package/dist/composites/helm-microservice.js +260 -0
- package/dist/composites/helm-monitored-service.js +188 -0
- package/dist/composites/helm-namespace-env.js +116 -0
- package/dist/composites/helm-secure-ingress.js +86 -0
- package/dist/composites/helm-stateful-service.js +160 -0
- package/dist/composites/helm-web-app.js +201 -0
- package/dist/composites/helm-worker.js +164 -0
- package/dist/composites/index.js +13 -0
- package/dist/config.js +161 -0
- package/dist/coverage.js +37 -0
- package/dist/deep-observe-hooks.js +19 -0
- package/dist/deep-observe.js +55 -0
- package/dist/describe-resources.js +119 -0
- package/dist/describe-stack-status.js +66 -0
- package/dist/detect.js +11 -0
- package/dist/generated/index.js +11 -0
- package/dist/helpers.js +84 -0
- package/dist/import/generator.js +292 -0
- package/dist/import/parser.js +141 -0
- package/dist/import/template-stripper.js +86 -0
- package/dist/index.js +55 -0
- package/dist/integrity.json +2 -2
- package/dist/intrinsics.js +482 -0
- package/dist/lint/audit-catalog.js +33 -0
- package/dist/lint/audit-lineage.js +53 -0
- package/dist/lint/post-synth/helm-helpers.js +60 -0
- package/dist/lint/post-synth/index.js +48 -0
- package/dist/lint/post-synth/whm005-no-empty-wrapper.js +47 -0
- package/dist/lint/post-synth/whm101.js +38 -0
- package/dist/lint/post-synth/whm102.js +26 -0
- package/dist/lint/post-synth/whm103.js +51 -0
- package/dist/lint/post-synth/whm104.js +29 -0
- package/dist/lint/post-synth/whm105.js +23 -0
- package/dist/lint/post-synth/whm201.js +29 -0
- package/dist/lint/post-synth/whm202.js +42 -0
- package/dist/lint/post-synth/whm203.js +32 -0
- package/dist/lint/post-synth/whm204.js +51 -0
- package/dist/lint/post-synth/whm301.js +31 -0
- package/dist/lint/post-synth/whm302.js +33 -0
- package/dist/lint/post-synth/whm401.js +52 -0
- package/dist/lint/post-synth/whm402.js +38 -0
- package/dist/lint/post-synth/whm403.js +38 -0
- package/dist/lint/post-synth/whm404.js +30 -0
- package/dist/lint/post-synth/whm405.js +47 -0
- package/dist/lint/post-synth/whm406.js +27 -0
- package/dist/lint/post-synth/whm407.js +72 -0
- package/dist/lint/post-synth/whm501.js +89 -0
- package/dist/lint/post-synth/whm502.js +85 -0
- package/dist/lint/post-synth/whm503.js +82 -0
- package/dist/lint/post-synth/whm504.js +47 -0
- package/dist/lint/rules/chart-metadata.js +53 -0
- package/dist/lint/rules/no-hardcoded-image.js +50 -0
- package/dist/lint/rules/values-no-helm-tpl.js +78 -0
- package/dist/lint/rules/values-no-secrets.js +65 -0
- package/dist/list-artifacts.js +134 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +18 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +40 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/cluster-probe.js +116 -0
- package/dist/op/activities/helm.js +449 -0
- package/dist/op/activities/index.js +10 -0
- package/dist/op/builders.js +45 -0
- package/dist/package-cli.js +21 -0
- package/dist/pinnability/actions.js +207 -0
- package/dist/pinnability/classify.js +416 -0
- package/dist/pinnability/conditions.js +0 -0
- package/dist/pinnability/index.js +16 -0
- package/dist/pinnability/localize.js +518 -0
- package/dist/pinnability/render-stream.js +53 -0
- package/dist/pinnability/values.js +151 -0
- package/dist/plugin.js +458 -0
- package/dist/project-codegen.js +215 -0
- package/dist/release-observe.js +287 -0
- package/dist/render-diff.js +279 -0
- package/dist/render-digest.js +0 -0
- package/dist/render-records.js +29 -0
- package/dist/render-store.js +401 -0
- package/dist/render-wrapper.js +164 -0
- package/dist/render.js +394 -0
- package/dist/resources.js +109 -0
- package/dist/serializer.js +903 -0
- package/dist/validate-cli.js +17 -0
- package/dist/validate.js +30 -0
- package/dist/values-probe.js +504 -0
- package/dist/wrapper-chart.js +95 -0
- package/package.json +11 -11
- package/src/codegen/package.ts +2 -2
- package/src/lsp/completions.ts +2 -1
- package/src/lsp/hover.ts +2 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for Helm lexicon validation.
|
|
4
|
+
*/
|
|
5
|
+
import { validate } from "./validate.js";
|
|
6
|
+
import { printValidationResult } from "@intentius/chant/codegen/validate";
|
|
7
|
+
async function main() {
|
|
8
|
+
const result = await validate();
|
|
9
|
+
printValidationResult(result);
|
|
10
|
+
if (!result.success) {
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
main().catch((err) => {
|
|
15
|
+
console.error("Validation failed:", err);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
});
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate generated lexicon-helm artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around the core validation framework
|
|
5
|
+
* with Helm-specific configuration.
|
|
6
|
+
*/
|
|
7
|
+
import { dirname } from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import { validateLexiconArtifacts } from "@intentius/chant/codegen/validate";
|
|
10
|
+
const REQUIRED_NAMES = [
|
|
11
|
+
"Chart",
|
|
12
|
+
"Values",
|
|
13
|
+
"HelmTest",
|
|
14
|
+
"HelmNotes",
|
|
15
|
+
"HelmHook",
|
|
16
|
+
"HelmDependency",
|
|
17
|
+
"HelmMaintainer",
|
|
18
|
+
"HelmCRD",
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Validate the generated lexicon-helm artifacts.
|
|
22
|
+
*/
|
|
23
|
+
export async function validate(opts) {
|
|
24
|
+
const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
|
|
25
|
+
return validateLexiconArtifacts({
|
|
26
|
+
lexiconJsonFilename: "lexicon-helm.json",
|
|
27
|
+
requiredNames: REQUIRED_NAMES,
|
|
28
|
+
basePath,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,504 @@
|
|
|
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
|
+
import { execFileSync } from "node:child_process";
|
|
41
|
+
import { createHash } from "node:crypto";
|
|
42
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
43
|
+
import { tmpdir } from "node:os";
|
|
44
|
+
import { join } from "node:path";
|
|
45
|
+
import { canonicalJson } from "@intentius/chant/effect-receipt";
|
|
46
|
+
import yaml from "js-yaml";
|
|
47
|
+
import { splitDocuments } from "./pinnability/render-stream.js";
|
|
48
|
+
import { readChartMeta, readYamlFile, valueAtPath } from "./pinnability/values.js";
|
|
49
|
+
/**
|
|
50
|
+
* The probe template. Renders one document per chart instance carrying the
|
|
51
|
+
* exact `.Values` that instance coalesced. For an aliased dependency helm
|
|
52
|
+
* sets `.Chart.Name` (and the `# Source:` path segment) to the alias, so two
|
|
53
|
+
* instances over the same chart directory stay distinguishable.
|
|
54
|
+
*/
|
|
55
|
+
const PROBE_TEMPLATE = "chantValuesProbe: v1\nchart: {{ .Chart.Name }}\nvalues:\n{{ .Values | toYaml | nindent 2 }}\n";
|
|
56
|
+
/** Must not start with `_` — helm skips underscore-prefixed files as partials. */
|
|
57
|
+
const PROBE_FILENAME = "chant-values-probe.yaml";
|
|
58
|
+
function isPlainObject(v) {
|
|
59
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
60
|
+
}
|
|
61
|
+
function deepEqual(a, b) {
|
|
62
|
+
return canonicalJson(a ?? null) === canonicalJson(b ?? null);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Leaf paths of a values tree. Arrays and empty maps are leaves — helm
|
|
66
|
+
* replaces arrays wholesale and an empty map carries no mergeable children.
|
|
67
|
+
*/
|
|
68
|
+
function leafPaths(v, prefix = []) {
|
|
69
|
+
if (isPlainObject(v) && Object.keys(v).length > 0) {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const [k, child] of Object.entries(v))
|
|
72
|
+
out.push(...leafPaths(child, [...prefix, k]));
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
return prefix.length > 0 ? [prefix] : [];
|
|
76
|
+
}
|
|
77
|
+
const scopeKey = (scope) => scope.join(".");
|
|
78
|
+
/**
|
|
79
|
+
* Walk the (copied, tgz-extracted) chart tree the way helm's loader does:
|
|
80
|
+
* declared dependencies under their alias-or-name key, undeclared `charts/`
|
|
81
|
+
* directories as implicit always-enabled dependencies.
|
|
82
|
+
*/
|
|
83
|
+
function collectScopeNodes(rootDir) {
|
|
84
|
+
const nodes = [];
|
|
85
|
+
const visit = (dir, scope) => {
|
|
86
|
+
const meta = readChartMeta(dir);
|
|
87
|
+
nodes.push({
|
|
88
|
+
scope,
|
|
89
|
+
name: meta.name,
|
|
90
|
+
dir,
|
|
91
|
+
parentScope: scope.slice(0, -1),
|
|
92
|
+
// condition is attached where the dependency is declared, below.
|
|
93
|
+
});
|
|
94
|
+
const chartsDir = join(dir, "charts");
|
|
95
|
+
const childDirs = existsSync(chartsDir)
|
|
96
|
+
? readdirSync(chartsDir).filter((e) => {
|
|
97
|
+
const p = join(chartsDir, e);
|
|
98
|
+
return statSync(p).isDirectory() && existsSync(join(p, "Chart.yaml"));
|
|
99
|
+
})
|
|
100
|
+
: [];
|
|
101
|
+
const declared = new Set();
|
|
102
|
+
for (const dep of meta.dependencies) {
|
|
103
|
+
const childDir = join(chartsDir, dep.name);
|
|
104
|
+
if (!existsSync(join(childDir, "Chart.yaml")))
|
|
105
|
+
continue;
|
|
106
|
+
declared.add(dep.name);
|
|
107
|
+
const childScope = [...scope, dep.alias ?? dep.name];
|
|
108
|
+
visit(childDir, childScope);
|
|
109
|
+
const node = nodes.find((n) => scopeKey(n.scope) === scopeKey(childScope));
|
|
110
|
+
if (node)
|
|
111
|
+
node.condition = dep.condition;
|
|
112
|
+
}
|
|
113
|
+
for (const name of childDirs) {
|
|
114
|
+
if (!declared.has(name))
|
|
115
|
+
visit(join(chartsDir, name), [...scope, name]);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
visit(rootDir, []);
|
|
119
|
+
return nodes;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Extract packaged subcharts (`charts/*.tgz`) in place so probes can be
|
|
123
|
+
* injected into them, then recurse. Uses the system `tar`; a failed
|
|
124
|
+
* extraction leaves the tgz alone and records a warning — that subchart's
|
|
125
|
+
* values then appear only through its parents' probe documents.
|
|
126
|
+
*/
|
|
127
|
+
function extractPackagedSubcharts(dir, warnings) {
|
|
128
|
+
const chartsDir = join(dir, "charts");
|
|
129
|
+
if (!existsSync(chartsDir))
|
|
130
|
+
return;
|
|
131
|
+
for (const entry of readdirSync(chartsDir)) {
|
|
132
|
+
if (!entry.endsWith(".tgz"))
|
|
133
|
+
continue;
|
|
134
|
+
const tgz = join(chartsDir, entry);
|
|
135
|
+
if (!statSync(tgz).isFile())
|
|
136
|
+
continue;
|
|
137
|
+
try {
|
|
138
|
+
// A chart archive unpacks to a single top-level directory named after
|
|
139
|
+
// the chart. Skip when that directory already exists.
|
|
140
|
+
execFileSync("tar", ["-xzf", tgz, "-C", chartsDir], { stdio: "ignore" });
|
|
141
|
+
rmSync(tgz);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
warnings.push(`could not extract packaged subchart ${entry} — its own probe document is skipped`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const entry of readdirSync(chartsDir)) {
|
|
148
|
+
const p = join(chartsDir, entry);
|
|
149
|
+
if (statSync(p).isDirectory() && existsSync(join(p, "Chart.yaml"))) {
|
|
150
|
+
extractPackagedSubcharts(p, warnings);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Replace each (sub)chart's `templates/` with the single probe template.
|
|
156
|
+
* Deleting the real templates sidesteps render-time failures (`required`,
|
|
157
|
+
* missing capabilities) — values coalescing does not depend on them.
|
|
158
|
+
* Library charts are left alone: helm never renders their templates.
|
|
159
|
+
*/
|
|
160
|
+
function injectProbes(dir, warnings) {
|
|
161
|
+
const chartYaml = readYamlFile(join(dir, "Chart.yaml"));
|
|
162
|
+
if (chartYaml?.type === "library") {
|
|
163
|
+
warnings.push(`${dir} is a library chart — helm renders no templates for it, so it yields no probe document`);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const templatesDir = join(dir, "templates");
|
|
167
|
+
rmSync(templatesDir, { recursive: true, force: true });
|
|
168
|
+
mkdirSync(templatesDir, { recursive: true });
|
|
169
|
+
writeFileSync(join(templatesDir, PROBE_FILENAME), PROBE_TEMPLATE);
|
|
170
|
+
}
|
|
171
|
+
const chartsDir = join(dir, "charts");
|
|
172
|
+
if (!existsSync(chartsDir))
|
|
173
|
+
return;
|
|
174
|
+
for (const entry of readdirSync(chartsDir)) {
|
|
175
|
+
const p = join(chartsDir, entry);
|
|
176
|
+
if (statSync(p).isDirectory() && existsSync(join(p, "Chart.yaml")))
|
|
177
|
+
injectProbes(p, warnings);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function defaultRunHelm(args) {
|
|
181
|
+
try {
|
|
182
|
+
return execFileSync("helm", args, {
|
|
183
|
+
encoding: "utf8",
|
|
184
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
185
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
const stderr = err && typeof err === "object" && "stderr" in err ? String(err.stderr) : String(err);
|
|
190
|
+
throw new Error(`coalesced-values probe render failed:\n${stderr}\n` +
|
|
191
|
+
`Hint: ensure the 'helm' CLI is on PATH and the chart's dependencies are vendored under charts/.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Scope of a probe document, from its `# Source:` path — every segment following a `charts/`. */
|
|
195
|
+
function scopeFromSource(sourceLine) {
|
|
196
|
+
const m = sourceLine.match(/^# Source: (\S+)$/m);
|
|
197
|
+
if (!m)
|
|
198
|
+
return undefined;
|
|
199
|
+
const segments = m[1].split("/");
|
|
200
|
+
if (segments[segments.length - 1] !== PROBE_FILENAME)
|
|
201
|
+
return undefined;
|
|
202
|
+
const scope = [];
|
|
203
|
+
for (let i = 1; i < segments.length - 2; i += 2) {
|
|
204
|
+
if (segments[i] !== "charts")
|
|
205
|
+
return undefined;
|
|
206
|
+
scope.push(segments[i + 1]);
|
|
207
|
+
}
|
|
208
|
+
return scope;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The coalesced-values identity: `sha256:` over the canonical JSON of the
|
|
212
|
+
* per-instance trees keyed by scope path — the #1237 digest conventions
|
|
213
|
+
* (core `canonicalJson`, `sha256:` prefix). Two renders that coalesce the
|
|
214
|
+
* same values for every instance share it; any value, in any subchart,
|
|
215
|
+
* changing breaks it.
|
|
216
|
+
*/
|
|
217
|
+
export function coalescedValuesDigest(instances) {
|
|
218
|
+
const tree = {};
|
|
219
|
+
for (const i of instances)
|
|
220
|
+
tree[scopeKey(i.scope)] = i.values;
|
|
221
|
+
return `sha256:${createHash("sha256").update(canonicalJson(tree), "utf8").digest("hex")}`;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Attribute each coalesced path to the layer that won it.
|
|
225
|
+
*
|
|
226
|
+
* Keys are dot-joined root-coordinate paths: a subchart instance's paths are
|
|
227
|
+
* prefixed with its scope, so `kidtwo.replicas` is the `replicas` the
|
|
228
|
+
* `kidtwo` instance actually sees. A key of an instance's tree that is
|
|
229
|
+
* itself an enabled child scope is skipped — helm pushes the child's
|
|
230
|
+
* coalesced tree back into the parent's `.Values`, and the child instance
|
|
231
|
+
* owns those paths.
|
|
232
|
+
*
|
|
233
|
+
* Layer precedence mirrors helm: supplied layers (last first), then ancestor
|
|
234
|
+
* chart defaults (root outranks intermediates — "parent override"), then the
|
|
235
|
+
* instance's own defaults ("chart default"). A path no layer explains — an
|
|
236
|
+
* `import-values` product, a helm-computed merge — reports `computed`.
|
|
237
|
+
* Attribution matches on value equality, so a layer that assigned a path but
|
|
238
|
+
* lost it reports as the layer that actually won.
|
|
239
|
+
*/
|
|
240
|
+
export function computeValueSources(input) {
|
|
241
|
+
const out = {};
|
|
242
|
+
const instanceScopes = new Set(input.instances.map((i) => scopeKey(i.scope)));
|
|
243
|
+
for (const instance of input.instances) {
|
|
244
|
+
for (const path of leafPaths(instance.values)) {
|
|
245
|
+
// Skip paths under an enabled child scope — the child instance owns them.
|
|
246
|
+
if (instanceScopes.has(scopeKey([...instance.scope, path[0]])))
|
|
247
|
+
continue;
|
|
248
|
+
const coalesced = valueAtPath(instance.values, path);
|
|
249
|
+
out[scopeKey([...instance.scope, ...path])] = attributePath(instance, path, coalesced, input);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
function attributePath(instance, path, coalesced, input) {
|
|
255
|
+
const rootPath = [...instance.scope, ...path];
|
|
256
|
+
const isGlobal = path[0] === "global" && instance.scope.length > 0;
|
|
257
|
+
// 1. Supplied layers, highest precedence first. A subchart's `global.*`
|
|
258
|
+
// can be fed either scoped (`<scope>.global.*`) or from the root
|
|
259
|
+
// `global.*` — scoped wins, so check it first.
|
|
260
|
+
for (let i = input.supplied.length - 1; i >= 0; i--) {
|
|
261
|
+
const layer = input.supplied[i];
|
|
262
|
+
let candidate = valueAtPath(layer.values, rootPath);
|
|
263
|
+
if (candidate === undefined && isGlobal)
|
|
264
|
+
candidate = valueAtPath(layer.values, path);
|
|
265
|
+
if (candidate !== undefined && deepEqual(candidate, coalesced))
|
|
266
|
+
return layer.origin;
|
|
267
|
+
}
|
|
268
|
+
// 2. Ancestor chart defaults — the top parent outranks intermediates.
|
|
269
|
+
for (let prefixLen = 0; prefixLen < instance.scope.length; prefixLen++) {
|
|
270
|
+
const ancestorScope = instance.scope.slice(0, prefixLen);
|
|
271
|
+
const defaults = input.defaultsFor(ancestorScope);
|
|
272
|
+
if (defaults === undefined)
|
|
273
|
+
continue;
|
|
274
|
+
const rel = [...instance.scope.slice(prefixLen), ...path];
|
|
275
|
+
let candidate = valueAtPath(defaults, rel);
|
|
276
|
+
if (candidate === undefined && isGlobal)
|
|
277
|
+
candidate = valueAtPath(defaults, path);
|
|
278
|
+
if (candidate !== undefined && deepEqual(candidate, coalesced))
|
|
279
|
+
return "parent override";
|
|
280
|
+
}
|
|
281
|
+
// 3. The instance's own values.yaml.
|
|
282
|
+
const own = input.defaultsFor(instance.scope);
|
|
283
|
+
if (own !== undefined) {
|
|
284
|
+
const candidate = valueAtPath(own, path);
|
|
285
|
+
if (candidate !== undefined && deepEqual(candidate, coalesced))
|
|
286
|
+
return "chart default";
|
|
287
|
+
}
|
|
288
|
+
return "computed";
|
|
289
|
+
}
|
|
290
|
+
const layerLabel = (layer) => layer.name !== undefined ? `${layer.origin} (${layer.name})` : layer.origin;
|
|
291
|
+
/**
|
|
292
|
+
* Supplied values that never survive coalescing:
|
|
293
|
+
*
|
|
294
|
+
* - `shadowed` — a later supplied layer assigns the same path (a map merging
|
|
295
|
+
* into a map is not a shadow; anything else replaces).
|
|
296
|
+
* - `disabled-subchart` — the path targets a dependency a `condition:` (or
|
|
297
|
+
* tags) disabled, so no rendered chart ever reads it.
|
|
298
|
+
* - `unknown-subchart` — a map supplied under a top-level key that matches
|
|
299
|
+
* no dependency, no root default, and not `global`, on a chart that HAS
|
|
300
|
+
* dependencies: the classic silently-ignored typo of a subchart name.
|
|
301
|
+
* Reported once per top-level key per layer.
|
|
302
|
+
*/
|
|
303
|
+
export function findDeadAssignments(input, disabled) {
|
|
304
|
+
const out = [];
|
|
305
|
+
const rootDefaults = input.defaultsFor([]) ?? {};
|
|
306
|
+
const knownFirstSegments = new Set(["global", ...Object.keys(rootDefaults)]);
|
|
307
|
+
let hasDependencies = false;
|
|
308
|
+
for (const i of input.instances) {
|
|
309
|
+
if (i.scope.length > 0) {
|
|
310
|
+
knownFirstSegments.add(i.scope[0]);
|
|
311
|
+
hasDependencies = true;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for (const d of disabled) {
|
|
315
|
+
knownFirstSegments.add(d.scope[0]);
|
|
316
|
+
if (d.name !== d.scope[0])
|
|
317
|
+
knownFirstSegments.add(d.name);
|
|
318
|
+
hasDependencies = true;
|
|
319
|
+
}
|
|
320
|
+
for (let i = 0; i < input.supplied.length; i++) {
|
|
321
|
+
const layer = input.supplied[i];
|
|
322
|
+
const flaggedUnknown = new Set();
|
|
323
|
+
for (const path of leafPaths(layer.values)) {
|
|
324
|
+
const value = valueAtPath(layer.values, path);
|
|
325
|
+
// Shadowed by a later supplied layer?
|
|
326
|
+
const shadow = input.supplied.slice(i + 1).find((later) => {
|
|
327
|
+
const laterValue = valueAtPath(later.values, path);
|
|
328
|
+
if (laterValue === undefined)
|
|
329
|
+
return false;
|
|
330
|
+
return !(isPlainObject(value) && isPlainObject(laterValue));
|
|
331
|
+
});
|
|
332
|
+
if (shadow) {
|
|
333
|
+
out.push({
|
|
334
|
+
path: scopeKey(path),
|
|
335
|
+
origin: layerLabel(layer),
|
|
336
|
+
reason: "shadowed",
|
|
337
|
+
shadowedBy: layerLabel(shadow),
|
|
338
|
+
});
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
// Targeting a disabled dependency?
|
|
342
|
+
const disabledHit = disabled.find((d) => d.scope.length <= path.length && scopeKey(path.slice(0, d.scope.length)) === scopeKey(d.scope));
|
|
343
|
+
if (disabledHit) {
|
|
344
|
+
out.push({
|
|
345
|
+
path: scopeKey(path),
|
|
346
|
+
origin: layerLabel(layer),
|
|
347
|
+
reason: "disabled-subchart",
|
|
348
|
+
shadowedBy: disabledHit.condition !== undefined
|
|
349
|
+
? `disabled subchart ${scopeKey(disabledHit.scope)} (condition ${disabledHit.condition} is false)`
|
|
350
|
+
: `disabled subchart ${scopeKey(disabledHit.scope)}`,
|
|
351
|
+
});
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
// A map under an unknown top-level key on a chart with dependencies?
|
|
355
|
+
if (hasDependencies &&
|
|
356
|
+
path.length > 1 &&
|
|
357
|
+
!knownFirstSegments.has(path[0]) &&
|
|
358
|
+
isPlainObject(layer.values[path[0]]) &&
|
|
359
|
+
!flaggedUnknown.has(path[0])) {
|
|
360
|
+
flaggedUnknown.add(path[0]);
|
|
361
|
+
out.push({
|
|
362
|
+
path: path[0],
|
|
363
|
+
origin: layerLabel(layer),
|
|
364
|
+
reason: "unknown-subchart",
|
|
365
|
+
shadowedBy: `no dependency, chart default, or global named "${path[0]}"`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Run the probe: copy the chart, inject probe templates, render, lift the
|
|
374
|
+
* probe documents out, analyze, discard the copy. The source chart is never
|
|
375
|
+
* touched. Every run is recorded for WHM504 (`getValuesProbeRecords`).
|
|
376
|
+
*/
|
|
377
|
+
export function probeCoalescedValues(options) {
|
|
378
|
+
if (!existsSync(join(options.chartDir, "Chart.yaml"))) {
|
|
379
|
+
throw new Error(`coalesced-values probe: ${options.chartDir} has no Chart.yaml`);
|
|
380
|
+
}
|
|
381
|
+
const warnings = [];
|
|
382
|
+
const supplied = options.supplied ?? [];
|
|
383
|
+
const workRoot = mkdtempSync(join(tmpdir(), "chant-values-probe-"));
|
|
384
|
+
try {
|
|
385
|
+
const chartCopy = join(workRoot, "chart");
|
|
386
|
+
cpSync(options.chartDir, chartCopy, { recursive: true });
|
|
387
|
+
extractPackagedSubcharts(chartCopy, warnings);
|
|
388
|
+
injectProbes(chartCopy, warnings);
|
|
389
|
+
const scopeNodes = collectScopeNodes(chartCopy);
|
|
390
|
+
const releaseName = options.releaseName ?? "chant-values-probe";
|
|
391
|
+
const args = ["template", releaseName, chartCopy];
|
|
392
|
+
if (options.namespace !== undefined)
|
|
393
|
+
args.push("--namespace", options.namespace);
|
|
394
|
+
supplied.forEach((layer, i) => {
|
|
395
|
+
const layerPath = join(workRoot, `supplied-${i}.yaml`);
|
|
396
|
+
writeFileSync(layerPath, yaml.dump(layer.values, { lineWidth: -1, noRefs: true }));
|
|
397
|
+
args.push("--values", layerPath);
|
|
398
|
+
});
|
|
399
|
+
const rendered = (options.runHelm ?? defaultRunHelm)(args);
|
|
400
|
+
// Lift the probe documents out.
|
|
401
|
+
const instances = [];
|
|
402
|
+
for (const doc of splitDocuments(rendered)) {
|
|
403
|
+
if (!doc.includes("chantValuesProbe:"))
|
|
404
|
+
continue;
|
|
405
|
+
let parsed;
|
|
406
|
+
try {
|
|
407
|
+
parsed = yaml.load(doc);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
warnings.push("a probe document failed to parse as YAML — skipped");
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (!isPlainObject(parsed) || parsed.chantValuesProbe !== "v1")
|
|
414
|
+
continue;
|
|
415
|
+
const scope = scopeFromSource(doc);
|
|
416
|
+
if (scope === undefined) {
|
|
417
|
+
warnings.push("a probe document carried no usable # Source: header — skipped");
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
instances.push({
|
|
421
|
+
scope,
|
|
422
|
+
chartName: typeof parsed.chart === "string" ? parsed.chart : "",
|
|
423
|
+
values: isPlainObject(parsed.values) ? parsed.values : {},
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
instances.sort((a, b) => a.scope.length - b.scope.length || scopeKey(a.scope).localeCompare(scopeKey(b.scope)));
|
|
427
|
+
if (instances.length === 0) {
|
|
428
|
+
throw new Error(`coalesced-values probe: render of ${options.chartDir} produced no probe document — is the chart renderable?`);
|
|
429
|
+
}
|
|
430
|
+
const root = instances[0];
|
|
431
|
+
// Declared dependencies with no probe document were pruned by helm —
|
|
432
|
+
// disabled by a condition (evaluated here against the EXACT coalesced
|
|
433
|
+
// root values) or by tags. Only nodes whose parent rendered are
|
|
434
|
+
// reported; a disabled subtree's descendants are unreachable anyway.
|
|
435
|
+
const probedScopes = new Set(instances.map((i) => scopeKey(i.scope)));
|
|
436
|
+
const disabled = [];
|
|
437
|
+
for (const node of scopeNodes) {
|
|
438
|
+
if (node.scope.length === 0 || probedScopes.has(scopeKey(node.scope)))
|
|
439
|
+
continue;
|
|
440
|
+
if (!probedScopes.has(scopeKey(node.parentScope)))
|
|
441
|
+
continue;
|
|
442
|
+
let condition;
|
|
443
|
+
for (const rawPath of (node.condition ?? "").split(",")) {
|
|
444
|
+
const path = rawPath.trim();
|
|
445
|
+
if (path === "")
|
|
446
|
+
continue;
|
|
447
|
+
const v = valueAtPath(root.values, path.split("."));
|
|
448
|
+
if (v === undefined)
|
|
449
|
+
continue;
|
|
450
|
+
if (v === false)
|
|
451
|
+
condition = path;
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
disabled.push({ scope: node.scope, name: node.name, condition });
|
|
455
|
+
}
|
|
456
|
+
const defaultsCache = new Map();
|
|
457
|
+
const defaultsFor = (scope) => {
|
|
458
|
+
const key = scopeKey(scope);
|
|
459
|
+
if (!defaultsCache.has(key)) {
|
|
460
|
+
const node = scopeNodes.find((n) => scopeKey(n.scope) === key);
|
|
461
|
+
const loaded = node ? readYamlFile(join(node.dir, "values.yaml")) : undefined;
|
|
462
|
+
defaultsCache.set(key, isPlainObject(loaded) ? loaded : undefined);
|
|
463
|
+
}
|
|
464
|
+
return defaultsCache.get(key);
|
|
465
|
+
};
|
|
466
|
+
const attribution = { instances, supplied, defaultsFor };
|
|
467
|
+
const probe = {
|
|
468
|
+
instances,
|
|
469
|
+
disabled,
|
|
470
|
+
digest: coalescedValuesDigest(instances),
|
|
471
|
+
valueSources: computeValueSources(attribution),
|
|
472
|
+
deadAssignments: findDeadAssignments(attribution, disabled),
|
|
473
|
+
warnings,
|
|
474
|
+
};
|
|
475
|
+
probeRecords.push({ name: options.name ?? releaseName, chartDir: options.chartDir, probe });
|
|
476
|
+
return probe;
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
rmSync(workRoot, { recursive: true, force: true });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const probeRecords = [];
|
|
483
|
+
/** Every probe recorded in this process, in invocation order. */
|
|
484
|
+
export function getValuesProbeRecords() {
|
|
485
|
+
return probeRecords;
|
|
486
|
+
}
|
|
487
|
+
/** Record a probe computed elsewhere (the render path integration point). */
|
|
488
|
+
export function recordValuesProbe(record) {
|
|
489
|
+
probeRecords.push(record);
|
|
490
|
+
}
|
|
491
|
+
/** Reset the record list (test isolation). */
|
|
492
|
+
export function clearValuesProbeRecords() {
|
|
493
|
+
probeRecords.length = 0;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* The values gap between the probe and an installed release: an installed
|
|
497
|
+
* release's `helm get values --all` omits subtrees for disabled
|
|
498
|
+
* dependencies; the probe's root document keeps them (epic findings 14, 15).
|
|
499
|
+
* On shared keys the two agree — this helper names the shared-key view for
|
|
500
|
+
* comparisons against a live release.
|
|
501
|
+
*/
|
|
502
|
+
export function rootCoalescedValues(probe) {
|
|
503
|
+
return probe.instances[0]?.values ?? {};
|
|
504
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper-chart materialization for the pinned install path (#1242, epic
|
|
3
|
+
* #1228 Phase 4).
|
|
4
|
+
*
|
|
5
|
+
* Turns a `RoutedRender` (#1239) into an installable helm chart on disk.
|
|
6
|
+
* The wrapper preserves the recorded bytes exactly:
|
|
7
|
+
*
|
|
8
|
+
* - CRD-group documents are written verbatim into the wrapper's `crds/`,
|
|
9
|
+
* which is what keeps `helm uninstall` from deleting them — the A/B test
|
|
10
|
+
* behind epic finding 4 showed a flat wrap converts uninstall from safe
|
|
11
|
+
* to data-destroying.
|
|
12
|
+
* - Every other document (main and hook groups) is written verbatim into
|
|
13
|
+
* `manifests/`, and each gets a one-line shim in `templates/` that reads
|
|
14
|
+
* it back with `.Files.Get`. The shim exists because rendered output can
|
|
15
|
+
* legitimately contain template-looking text — a ConfigMap carrying
|
|
16
|
+
* alertmanager notification templates renders `{{ $labels }}` as literal
|
|
17
|
+
* bytes — and a document placed directly in `templates/` would be run
|
|
18
|
+
* through the template engine a second time, mangling or failing on
|
|
19
|
+
* exactly those bytes. `.Files.Get` returns file content untemplated, so
|
|
20
|
+
* what helm installs is what the render store recorded.
|
|
21
|
+
* - Hook documents go through the same shim; their `helm.sh/hook`
|
|
22
|
+
* annotations are part of the recorded bytes, so helm registers and runs
|
|
23
|
+
* them from the rendered output unchanged (epic finding 5).
|
|
24
|
+
*
|
|
25
|
+
* The wrapper inherits the source chart's name and version (epic
|
|
26
|
+
* Decisions), keeping `helm history` continuous across the pinned
|
|
27
|
+
* migration. A local chart path becomes its sanitized basename — helm
|
|
28
|
+
* requires a bare chart name — and a render recorded without a version
|
|
29
|
+
* falls back to `0.0.0`.
|
|
30
|
+
*/
|
|
31
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
/**
|
|
34
|
+
* A valid helm chart name from a chart reference that may be a local path
|
|
35
|
+
* (`./charts/web`) or a repo-qualified name. Takes the last path segment,
|
|
36
|
+
* lowercases it, and collapses anything outside `[a-z0-9-]` to `-` — helm
|
|
37
|
+
* chart names are DNS-label-shaped. Falls back to `pinned-chart` when
|
|
38
|
+
* nothing survives.
|
|
39
|
+
*/
|
|
40
|
+
export function wrapperChartName(chart) {
|
|
41
|
+
const base = chart.split("/").filter((s) => s.length > 0).pop() ?? "";
|
|
42
|
+
const sanitized = base
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
45
|
+
.replace(/^-+|-+$/g, "");
|
|
46
|
+
return sanitized.length > 0 ? sanitized : "pinned-chart";
|
|
47
|
+
}
|
|
48
|
+
/** `doc-0007-my-deployment` — a stable, ordered, filesystem-safe stem for one routed document. */
|
|
49
|
+
function fileStem(prefix, index, doc) {
|
|
50
|
+
const label = (doc.name ?? doc.kind ?? "document")
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
53
|
+
.replace(/^-+|-+$/g, "")
|
|
54
|
+
.slice(0, 40);
|
|
55
|
+
return `${prefix}-${String(index).padStart(4, "0")}${label ? `-${label}` : ""}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Write `routed` as an installable wrapper chart under `dir` (which must
|
|
59
|
+
* exist and should be empty). Document text is written byte-for-byte; the
|
|
60
|
+
* only authored files are `Chart.yaml` and the `.Files.Get` shims.
|
|
61
|
+
*/
|
|
62
|
+
export function materializeWrapperChart(routed, dir) {
|
|
63
|
+
const chartName = wrapperChartName(routed.chart);
|
|
64
|
+
const chartVersion = routed.chartVersion ?? "0.0.0";
|
|
65
|
+
writeFileSync(join(dir, "Chart.yaml"), [
|
|
66
|
+
"apiVersion: v2",
|
|
67
|
+
`name: ${chartName}`,
|
|
68
|
+
`version: ${chartVersion}`,
|
|
69
|
+
"description: chant pinned render wrapper (epic #1228) — installs recorded bytes; templates are .Files.Get shims and are never re-rendered",
|
|
70
|
+
"",
|
|
71
|
+
].join("\n"));
|
|
72
|
+
const crdFiles = [];
|
|
73
|
+
if (routed.crds.length > 0) {
|
|
74
|
+
mkdirSync(join(dir, "crds"), { recursive: true });
|
|
75
|
+
routed.crds.forEach((doc, i) => {
|
|
76
|
+
const rel = `crds/${fileStem("crd", i, doc)}.yaml`;
|
|
77
|
+
writeFileSync(join(dir, rel), doc.text.endsWith("\n") ? doc.text : `${doc.text}\n`);
|
|
78
|
+
crdFiles.push(rel);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const manifestFiles = [];
|
|
82
|
+
const docs = [...routed.main, ...routed.hooks];
|
|
83
|
+
if (docs.length > 0) {
|
|
84
|
+
mkdirSync(join(dir, "manifests"), { recursive: true });
|
|
85
|
+
mkdirSync(join(dir, "templates"), { recursive: true });
|
|
86
|
+
docs.forEach((doc, i) => {
|
|
87
|
+
const stem = fileStem("doc", i, doc);
|
|
88
|
+
const rel = `manifests/${stem}.yaml`;
|
|
89
|
+
writeFileSync(join(dir, rel), doc.text.endsWith("\n") ? doc.text : `${doc.text}\n`);
|
|
90
|
+
writeFileSync(join(dir, `templates/${stem}.yaml`), `{{ .Files.Get "${rel}" }}\n`);
|
|
91
|
+
manifestFiles.push(rel);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return { dir, chartName, chartVersion, crdFiles, manifestFiles, hookCount: routed.hooks.length };
|
|
95
|
+
}
|