@intentius/chant-lexicon-helm 0.46.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/archive-render.d.ts +94 -0
- package/dist/archive-render.d.ts.map +1 -0
- package/dist/commands.d.ts +54 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/config.d.ts +135 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/deep-observe-hooks.d.ts +2 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +54 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +17 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/index.d.ts +19 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/whm503.d.ts +34 -0
- package/dist/lint/post-synth/whm503.d.ts.map +1 -0
- package/dist/lint/post-synth/whm504.d.ts +20 -0
- package/dist/lint/post-synth/whm504.d.ts.map +1 -0
- package/dist/manifest.json +25 -13
- package/dist/okf/index.md +2 -0
- package/dist/okf/rules/WHM503.md +15 -0
- package/dist/okf/rules/WHM504.md +11 -0
- package/dist/okf/types/Values.md +1 -0
- package/dist/op/activities/cluster-probe.d.ts +44 -0
- package/dist/op/activities/cluster-probe.d.ts.map +1 -0
- package/dist/op/activities/helm.d.ts +179 -5
- package/dist/op/activities/helm.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +4 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/builders.d.ts +47 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/pinnability/actions.d.ts +76 -0
- package/dist/pinnability/actions.d.ts.map +1 -0
- package/dist/pinnability/classify.d.ts +129 -0
- package/dist/pinnability/classify.d.ts.map +1 -0
- package/dist/pinnability/conditions.d.ts +109 -0
- package/dist/pinnability/conditions.d.ts.map +1 -0
- package/dist/pinnability/index.d.ts +17 -0
- package/dist/pinnability/index.d.ts.map +1 -0
- package/dist/pinnability/localize.d.ts +124 -0
- package/dist/pinnability/localize.d.ts.map +1 -0
- package/dist/pinnability/render-stream.d.ts +27 -0
- package/dist/pinnability/render-stream.d.ts.map +1 -0
- package/dist/pinnability/values.d.ts +69 -0
- package/dist/pinnability/values.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/release-observe.d.ts +109 -0
- package/dist/release-observe.d.ts.map +1 -0
- package/dist/render-diff.d.ts +192 -0
- package/dist/render-diff.d.ts.map +1 -0
- package/dist/render-digest.d.ts +135 -0
- package/dist/render-digest.d.ts.map +1 -0
- package/dist/render-store.d.ts +310 -0
- package/dist/render-store.d.ts.map +1 -0
- package/dist/render-wrapper.d.ts +140 -0
- package/dist/render-wrapper.d.ts.map +1 -0
- package/dist/render.d.ts +81 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/rules/whm503.ts +101 -0
- package/dist/rules/whm504.ts +52 -0
- package/dist/skills/chant-helm.md +7 -0
- package/dist/values-probe.d.ts +177 -0
- package/dist/values-probe.d.ts.map +1 -0
- package/dist/wrapper-chart.d.ts +61 -0
- package/dist/wrapper-chart.d.ts.map +1 -0
- package/package.json +10 -4
- package/src/archive-render.test.ts +201 -0
- package/src/archive-render.ts +119 -0
- package/src/commands.test.ts +487 -0
- package/src/commands.ts +445 -0
- package/src/config.test.ts +130 -0
- package/src/config.ts +220 -0
- package/src/deep-observe-hooks.ts +20 -0
- package/src/deep-observe.test.ts +165 -0
- package/src/deep-observe.ts +121 -0
- package/src/describe-resources.test.ts +255 -0
- package/src/describe-resources.ts +198 -0
- package/src/index.ts +106 -2
- package/src/lifecycle-integration.test.ts +289 -0
- package/src/lint/audit-catalog.ts +2 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/whm503.test.ts +134 -0
- package/src/lint/post-synth/whm503.ts +101 -0
- package/src/lint/post-synth/whm504.test.ts +105 -0
- package/src/lint/post-synth/whm504.ts +52 -0
- package/src/op/activities/cluster-probe.test.ts +66 -0
- package/src/op/activities/cluster-probe.ts +148 -0
- package/src/op/activities/helm.test.ts +824 -0
- package/src/op/activities/helm.ts +628 -5
- package/src/op/activities/index.ts +17 -2
- package/src/op/builders.test.ts +70 -0
- package/src/op/builders.ts +66 -0
- package/src/pinnability/actions.ts +263 -0
- package/src/pinnability/classify.ts +579 -0
- package/src/pinnability/conditions.ts +0 -0
- package/src/pinnability/index.ts +77 -0
- package/src/pinnability/localize.test.ts +234 -0
- package/src/pinnability/localize.ts +644 -0
- package/src/pinnability/pinnability.test.ts +560 -0
- package/src/pinnability/render-stream.ts +59 -0
- package/src/pinnability/values.ts +205 -0
- package/src/plugin.test.ts +3 -1
- package/src/plugin.ts +97 -12
- package/src/release-observe.ts +392 -0
- package/src/render-diff.test.ts +366 -0
- package/src/render-diff.ts +407 -0
- package/src/render-digest.test.ts +186 -0
- package/src/render-digest.ts +207 -0
- package/src/render-store.test.ts +412 -0
- package/src/render-store.ts +621 -0
- package/src/render-wrapper.test.ts +259 -0
- package/src/render-wrapper.ts +274 -0
- package/src/render.test.ts +383 -4
- package/src/render.ts +319 -8
- package/src/skills/chant-helm.md +7 -0
- package/src/values-probe.test.ts +400 -0
- package/src/values-probe.ts +627 -0
- package/src/wrapper-chart.test.ts +110 -0
- package/src/wrapper-chart.ts +124 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the pinnability classifier (#1234, epic #1228 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Pure — no helm binary, no network. The two prototype-era regressions stay
|
|
5
|
+
* pinned here (finding 9: actions not text; finding 10: crds/ segment not
|
|
6
|
+
* prefix), plus the capability the promotion added: values-aware
|
|
7
|
+
* reachability — a control-flow `lookup` behind a gate the supplied values
|
|
8
|
+
* close is a recorded hazard, not a refusal, and flipping the value
|
|
9
|
+
* re-classifies the (chart, values) pair.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, test, expect } from "vitest";
|
|
13
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { extractActions, scopeActions } from "./actions";
|
|
18
|
+
import { UNKNOWN, callReachability, evaluateCondition, truthy } from "./conditions";
|
|
19
|
+
import { buildChartInstances, mergeValues } from "./values";
|
|
20
|
+
import { classifyChart } from "./classify";
|
|
21
|
+
import { countDifferingLines, isCrdSource, routeBySource } from "./render-stream";
|
|
22
|
+
|
|
23
|
+
/** Materialize a chart directory from a { relPath: content } map. */
|
|
24
|
+
function mkChart(files: Record<string, string>): string {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pinnability-"));
|
|
26
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
27
|
+
const p = join(dir, rel);
|
|
28
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
29
|
+
writeFileSync(p, content);
|
|
30
|
+
}
|
|
31
|
+
return dir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MINIMAL_CHART = 'apiVersion: v2\nname: unit\ntype: application\nversion: 0.1.0\n';
|
|
35
|
+
|
|
36
|
+
describe("lookup detection parses template actions, not text (finding 9)", () => {
|
|
37
|
+
test("prose in YAML comments is not a lookup hit", () => {
|
|
38
|
+
// cert-manager has 52 textual "lookup" occurrences, all comment prose,
|
|
39
|
+
// real template-action count 0. A text-matching gate refuses a chart
|
|
40
|
+
// that is perfectly pinnable.
|
|
41
|
+
const chart = mkChart({
|
|
42
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
43
|
+
"templates/cm.yaml": [
|
|
44
|
+
"# the keys are used to lookup values from secrets",
|
|
45
|
+
"apiVersion: v1",
|
|
46
|
+
"kind: ConfigMap",
|
|
47
|
+
"metadata:",
|
|
48
|
+
" name: {{ .Release.Name }}-cm",
|
|
49
|
+
].join("\n"),
|
|
50
|
+
});
|
|
51
|
+
const report = classifyChart(chart);
|
|
52
|
+
expect(report.lookups.controlFlow).toEqual([]);
|
|
53
|
+
expect(report.lookups.valuePosition).toEqual([]);
|
|
54
|
+
expect(report.verdict).toBe("deterministic");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("prose in template comment actions is not a lookup hit", () => {
|
|
58
|
+
const chart = mkChart({
|
|
59
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
60
|
+
"templates/x.yaml": '{{/* lookup is documented here, not called */}}\nname: {{ .Values.name }}',
|
|
61
|
+
});
|
|
62
|
+
const report = classifyChart(chart);
|
|
63
|
+
expect(report.lookups.controlFlow).toEqual([]);
|
|
64
|
+
expect(report.lookups.valuePosition).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a value named lookup* is not a lookup call", () => {
|
|
68
|
+
// grafana's `persistence.lookupVolumeName` — the value is named after
|
|
69
|
+
// the feature; only the function call counts (survey finding 2:
|
|
70
|
+
// \blookup\b would false-positive on the very chart the refusal is about).
|
|
71
|
+
const chart = mkChart({
|
|
72
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
73
|
+
"templates/pvc.yaml": "{{- if .Values.persistence.lookupVolumeName }}\nx: 1\n{{- end }}",
|
|
74
|
+
});
|
|
75
|
+
const report = classifyChart(chart);
|
|
76
|
+
expect(report.lookups.controlFlow).toEqual([]);
|
|
77
|
+
expect(report.lookups.valuePosition).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("lookup in an if action is control flow; in an output action it is value position", () => {
|
|
81
|
+
const chart = mkChart({
|
|
82
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
83
|
+
"templates/cm.yaml": [
|
|
84
|
+
'{{- if not (lookup "v1" "ConfigMap" "ns" "seen") }}',
|
|
85
|
+
"kind: ConfigMap",
|
|
86
|
+
"{{- end }}",
|
|
87
|
+
'prior: {{ (lookup "v1" "Namespace" "" "default").metadata.name | default "none" }}',
|
|
88
|
+
].join("\n"),
|
|
89
|
+
});
|
|
90
|
+
const report = classifyChart(chart);
|
|
91
|
+
expect(report.verdict).toBe("unpinnable");
|
|
92
|
+
expect(report.lookups.controlFlow).toHaveLength(1);
|
|
93
|
+
expect(report.lookups.controlFlow[0].line).toBe(1);
|
|
94
|
+
expect(report.lookups.controlFlow[0].status).toBe("refused");
|
|
95
|
+
expect(report.lookups.valuePosition).toHaveLength(1);
|
|
96
|
+
expect(report.lookups.valuePosition[0].line).toBe(4);
|
|
97
|
+
// The refusal is specific: construct and location.
|
|
98
|
+
expect(report.reasons[0]).toContain("templates/cm.yaml:1");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("CRD routing matches a crds/ segment, not prefix (finding 10)", () => {
|
|
103
|
+
test("subchart CRD paths are CRD sources", () => {
|
|
104
|
+
expect(isCrdSource("crds/topcrd.yaml")).toBe(true);
|
|
105
|
+
expect(isCrdSource("charts/kid/crds/kidcrd.yaml")).toBe(true);
|
|
106
|
+
expect(isCrdSource("charts/kidtwo/crds/kidcrd.yaml")).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("templates paths are not, even when a file is named after crds", () => {
|
|
110
|
+
expect(isCrdSource("templates/cm.yaml")).toBe(false);
|
|
111
|
+
expect(isCrdSource("charts/kid/templates/cm.yaml")).toBe(false);
|
|
112
|
+
expect(isCrdSource("templates/crds-readme.yaml")).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("routeBySource splits a stream by origin", () => {
|
|
116
|
+
const rendered = [
|
|
117
|
+
"---",
|
|
118
|
+
"# Source: umb/crds/parent.yaml",
|
|
119
|
+
"kind: CustomResourceDefinition",
|
|
120
|
+
"---",
|
|
121
|
+
"# Source: umb/charts/kid/crds/kid.yaml",
|
|
122
|
+
"kind: CustomResourceDefinition",
|
|
123
|
+
"---",
|
|
124
|
+
"# Source: umb/templates/cm.yaml",
|
|
125
|
+
"kind: ConfigMap",
|
|
126
|
+
].join("\n");
|
|
127
|
+
const routed = routeBySource(rendered);
|
|
128
|
+
expect(routed.crds).toHaveLength(2);
|
|
129
|
+
expect(routed.templates).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("countDifferingLines is 0 for identical renders and symmetric otherwise", () => {
|
|
133
|
+
expect(countDifferingLines("a\nb", "a\nb")).toBe(0);
|
|
134
|
+
expect(countDifferingLines("a\nx", "a\ny")).toBe(2);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("condition evaluation", () => {
|
|
139
|
+
const ctx = {
|
|
140
|
+
values: { persistence: { enabled: false, type: "pvc", lookupVolumeName: true }, replicas: 0 },
|
|
141
|
+
scope: [],
|
|
142
|
+
dotValuesPath: null,
|
|
143
|
+
} as const;
|
|
144
|
+
|
|
145
|
+
test("Go truthiness: false, zero, empty string/collection are false", () => {
|
|
146
|
+
expect(truthy(false)).toBe(false);
|
|
147
|
+
expect(truthy(0)).toBe(false);
|
|
148
|
+
expect(truthy("")).toBe(false);
|
|
149
|
+
expect(truthy([])).toBe(false);
|
|
150
|
+
expect(truthy({})).toBe(false);
|
|
151
|
+
expect(truthy(undefined)).toBe(false);
|
|
152
|
+
expect(truthy("no")).toBe(true); // a non-empty string, even "no", is truthy
|
|
153
|
+
expect(truthy([0])).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a false values path names itself as the gate", () => {
|
|
157
|
+
const r = evaluateCondition(".Values.persistence.enabled", ctx);
|
|
158
|
+
expect(r.value).toBe(false);
|
|
159
|
+
expect(r.gates).toEqual([{ path: "persistence.enabled", value: false }]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("grafana's outer pvc gate resolves false through and/not/eq", () => {
|
|
163
|
+
const r = evaluateCondition(
|
|
164
|
+
'and (not .Values.useStatefulSet) .Values.persistence.enabled (not .Values.persistence.existingClaim) (eq .Values.persistence.type "pvc")',
|
|
165
|
+
ctx,
|
|
166
|
+
);
|
|
167
|
+
expect(r.value).toBe(false);
|
|
168
|
+
expect(r.gates.map((g) => g.path)).toEqual(["persistence.enabled"]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("missing keys are nil and nil is false — helm's own semantics", () => {
|
|
172
|
+
expect(evaluateCondition(".Values.not.there", ctx).value).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("what the grammar cannot resolve is UNKNOWN, and unknown refuses rather than pins", () => {
|
|
176
|
+
expect(evaluateCondition('include "chart.enabled" .', ctx).value).toBe(UNKNOWN);
|
|
177
|
+
expect(evaluateCondition(".Values.replicas | mul 2", ctx).value).toBe(UNKNOWN);
|
|
178
|
+
// ...but a known-false conjunct still decides an `and` containing unknowns.
|
|
179
|
+
const r = evaluateCondition('and (include "x" .) .Values.persistence.enabled', ctx);
|
|
180
|
+
expect(r.value).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("pipeline default: a truthy value closes over the fallback", () => {
|
|
184
|
+
expect(evaluateCondition(".Values.persistence.type | default \"none\"", ctx).value).toBe(true);
|
|
185
|
+
expect(evaluateCondition(".Values.missing | default true", ctx).value).toBe(true);
|
|
186
|
+
expect(evaluateCondition(".Values.missing | default false", ctx).value).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("scope prefixes gate paths into root coordinates", () => {
|
|
190
|
+
const r = evaluateCondition(".Values.persistence.enabled", { ...ctx, scope: ["grafana"] });
|
|
191
|
+
expect(r.gates[0].path).toBe("grafana.persistence.enabled");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("short-circuit reachability of a call inside and/or", () => {
|
|
195
|
+
const lookupRe = /^lookup$/;
|
|
196
|
+
// `and gate (lookup ...)` — a false gate before the call skips it.
|
|
197
|
+
const gated = callReachability('and .Values.persistence.enabled (lookup "v1" "X" "" "y")', lookupRe, ctx);
|
|
198
|
+
expect(gated.reachable).toBe(false);
|
|
199
|
+
expect(gated.gates[0].path).toBe("persistence.enabled");
|
|
200
|
+
// A true gate leaves the call live.
|
|
201
|
+
const live = callReachability('and .Values.persistence.lookupVolumeName (lookup "v1" "X" "" "y")', lookupRe, ctx);
|
|
202
|
+
expect(live.reachable).toBe(true);
|
|
203
|
+
// `or` skips the call when an earlier operand is true.
|
|
204
|
+
const orGated = callReachability('or .Values.persistence.lookupVolumeName (lookup "v1" "X" "" "y")', lookupRe, ctx);
|
|
205
|
+
expect(orGated.reachable).toBe(false);
|
|
206
|
+
// An unresolvable earlier operand never proves the call unreachable.
|
|
207
|
+
const unknown = callReachability('and (include "x" .) (lookup "v1" "X" "" "y")', lookupRe, ctx);
|
|
208
|
+
expect(unknown.reachable).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("value coalescing and the chart instance tree", () => {
|
|
213
|
+
test("mergeValues: parent overrides child, null deletes, arrays replace", () => {
|
|
214
|
+
expect(mergeValues({ a: { b: 1, c: 2 } }, { a: { b: 9 } })).toEqual({ a: { b: 9, c: 2 } });
|
|
215
|
+
expect(mergeValues({ a: 1, b: 2 }, { b: null })).toEqual({ a: 1 });
|
|
216
|
+
expect(mergeValues({ a: [1, 2] }, { a: [3] })).toEqual({ a: [3] });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("aliased dependencies get their own scope over one directory; conditions disable", () => {
|
|
220
|
+
const chart = mkChart({
|
|
221
|
+
"Chart.yaml": [
|
|
222
|
+
"apiVersion: v2",
|
|
223
|
+
"name: umb",
|
|
224
|
+
"version: 0.1.0",
|
|
225
|
+
"dependencies:",
|
|
226
|
+
" - name: kid",
|
|
227
|
+
" version: 0.1.0",
|
|
228
|
+
" - name: kid",
|
|
229
|
+
" version: 0.1.0",
|
|
230
|
+
" alias: kidtwo",
|
|
231
|
+
" - name: opt",
|
|
232
|
+
" version: 0.1.0",
|
|
233
|
+
" condition: opt.enabled",
|
|
234
|
+
].join("\n"),
|
|
235
|
+
"values.yaml": "opt:\n enabled: false\nkidtwo:\n mode: aliased\n",
|
|
236
|
+
"charts/kid/Chart.yaml": "apiVersion: v2\nname: kid\nversion: 0.1.0\n",
|
|
237
|
+
"charts/kid/values.yaml": "mode: plain\n",
|
|
238
|
+
"charts/kid/templates/cm.yaml": "x: {{ .Values.mode }}",
|
|
239
|
+
"charts/opt/Chart.yaml": "apiVersion: v2\nname: opt\nversion: 0.1.0\n",
|
|
240
|
+
"charts/opt/templates/cm.yaml": "x: 1",
|
|
241
|
+
});
|
|
242
|
+
const { instances, warnings } = buildChartInstances(chart);
|
|
243
|
+
expect(warnings).toEqual([]);
|
|
244
|
+
const byScope = new Map(instances.map((i) => [i.scope.join("."), i]));
|
|
245
|
+
expect([...byScope.keys()].sort()).toEqual(["", "kid", "kidtwo", "opt"]);
|
|
246
|
+
// Alias scoping: each instance sees its own value tree over the same dir.
|
|
247
|
+
expect((byScope.get("kid")!.values as { mode: string }).mode).toBe("plain");
|
|
248
|
+
expect((byScope.get("kidtwo")!.values as { mode: string }).mode).toBe("aliased");
|
|
249
|
+
expect(byScope.get("kidtwo")!.dir).toBe(byScope.get("kid")!.dir);
|
|
250
|
+
// The conditional dependency is disabled by the root values (finding 12).
|
|
251
|
+
expect(byScope.get("opt")!.disabledBy).toBe("opt.enabled");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("globals propagate into subchart trees", () => {
|
|
255
|
+
const chart = mkChart({
|
|
256
|
+
"Chart.yaml": "apiVersion: v2\nname: umb\nversion: 0.1.0\n",
|
|
257
|
+
"values.yaml": "global:\n region: eu\n",
|
|
258
|
+
"charts/kid/Chart.yaml": "apiVersion: v2\nname: kid\nversion: 0.1.0\n",
|
|
259
|
+
"charts/kid/templates/cm.yaml": "x: 1",
|
|
260
|
+
});
|
|
261
|
+
const { instances } = buildChartInstances(chart);
|
|
262
|
+
const kid = instances.find((i) => i.scope.join(".") === "kid")!;
|
|
263
|
+
expect((kid.values as { global: { region: string } }).global.region).toBe("eu");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("an unvendored dependency is a warning, never a silent pass", () => {
|
|
267
|
+
const chart = mkChart({
|
|
268
|
+
"Chart.yaml": [
|
|
269
|
+
"apiVersion: v2",
|
|
270
|
+
"name: umb",
|
|
271
|
+
"version: 0.1.0",
|
|
272
|
+
"dependencies:",
|
|
273
|
+
" - name: ghost",
|
|
274
|
+
" version: 1.0.0",
|
|
275
|
+
].join("\n"),
|
|
276
|
+
"templates/cm.yaml": "x: 1",
|
|
277
|
+
});
|
|
278
|
+
const report = classifyChart(chart);
|
|
279
|
+
expect(report.warnings.join(" ")).toContain("ghost");
|
|
280
|
+
expect(report.reasons.join(" ")).toContain("not scanned");
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("values-aware reachability (the epic's core insight: pinnability is a property of (chart, values))", () => {
|
|
285
|
+
const gatedPvc = [
|
|
286
|
+
"{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}",
|
|
287
|
+
"kind: PersistentVolumeClaim",
|
|
288
|
+
'{{- if and (.Values.persistence.lookupVolumeName) (lookup "v1" "PersistentVolumeClaim" "ns" "n") }}',
|
|
289
|
+
"volumeName: pinned",
|
|
290
|
+
"{{- end }}",
|
|
291
|
+
"{{- end }}",
|
|
292
|
+
].join("\n");
|
|
293
|
+
|
|
294
|
+
function gatedChart(): string {
|
|
295
|
+
return mkChart({
|
|
296
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
297
|
+
"values.yaml": "persistence:\n enabled: false\n lookupVolumeName: true\n",
|
|
298
|
+
"templates/pvc.yaml": gatedPvc,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
test("a control-flow lookup the supplied values gate off is a hazard, not a refusal", () => {
|
|
303
|
+
const report = classifyChart(gatedChart());
|
|
304
|
+
expect(report.verdict).toBe("pinnable");
|
|
305
|
+
expect(report.lookups.controlFlow).toHaveLength(1);
|
|
306
|
+
expect(report.lookups.controlFlow[0].status).toBe("hazard");
|
|
307
|
+
expect(report.hazards).toHaveLength(1);
|
|
308
|
+
expect(report.hazards[0].file).toBe("templates/pvc.yaml");
|
|
309
|
+
expect(report.hazards[0].line).toBe(3);
|
|
310
|
+
expect(report.hazards[0].gates.map((g) => g.path)).toEqual(["persistence.enabled"]);
|
|
311
|
+
expect(report.reasons.join("\n")).toContain("flipping it makes this render unpinnable");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("flipping the gating value re-classifies the same chart to unpinnable", () => {
|
|
315
|
+
const report = classifyChart(gatedChart(), {
|
|
316
|
+
values: { persistence: { enabled: true } },
|
|
317
|
+
});
|
|
318
|
+
expect(report.verdict).toBe("unpinnable");
|
|
319
|
+
expect(report.reasons.join("\n")).toContain("templates/pvc.yaml:3");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("same-action short-circuit gating: `if and .Values.gate (lookup ...)`", () => {
|
|
323
|
+
const chart = mkChart({
|
|
324
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
325
|
+
"values.yaml": "gate: false\n",
|
|
326
|
+
"templates/cm.yaml": '{{- if and .Values.gate (lookup "v1" "ConfigMap" "ns" "n") }}\nx: 1\n{{- end }}',
|
|
327
|
+
});
|
|
328
|
+
const off = classifyChart(chart);
|
|
329
|
+
expect(off.verdict).toBe("pinnable");
|
|
330
|
+
expect(off.hazards[0].gates.map((g) => g.path)).toEqual(["gate"]);
|
|
331
|
+
const on = classifyChart(chart, { values: { gate: true } });
|
|
332
|
+
expect(on.verdict).toBe("unpinnable");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("an unresolvable gate refuses: a gate we cannot prove closed is open", () => {
|
|
336
|
+
const chart = mkChart({
|
|
337
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
338
|
+
"templates/cm.yaml": [
|
|
339
|
+
'{{- if include "chart.someHelper" . }}',
|
|
340
|
+
'{{- if lookup "v1" "ConfigMap" "ns" "n" }}',
|
|
341
|
+
"x: 1",
|
|
342
|
+
"{{- end }}",
|
|
343
|
+
"{{- end }}",
|
|
344
|
+
].join("\n"),
|
|
345
|
+
});
|
|
346
|
+
const report = classifyChart(chart);
|
|
347
|
+
expect(report.verdict).toBe("unpinnable");
|
|
348
|
+
expect(report.reasons[0]).toContain("not provably gated off");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("`with` rebinds the dot into the values tree for gate evaluation", () => {
|
|
352
|
+
const chart = mkChart({
|
|
353
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
354
|
+
"values.yaml": "persistence:\n enabled: false\n size: 1Gi\n",
|
|
355
|
+
"templates/pvc.yaml": [
|
|
356
|
+
"{{- with .Values.persistence }}",
|
|
357
|
+
'{{- if and .enabled (lookup "v1" "PersistentVolumeClaim" "ns" "n") }}',
|
|
358
|
+
"x: 1",
|
|
359
|
+
"{{- end }}",
|
|
360
|
+
"{{- end }}",
|
|
361
|
+
].join("\n"),
|
|
362
|
+
});
|
|
363
|
+
const report = classifyChart(chart);
|
|
364
|
+
expect(report.verdict).toBe("pinnable");
|
|
365
|
+
expect(report.hazards[0].gates.map((g) => g.path)).toEqual(["persistence.enabled"]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("a subchart's gate is reported in root coordinates and flips from the root", () => {
|
|
369
|
+
const files = {
|
|
370
|
+
"Chart.yaml": [
|
|
371
|
+
"apiVersion: v2",
|
|
372
|
+
"name: umb",
|
|
373
|
+
"version: 0.1.0",
|
|
374
|
+
"dependencies:",
|
|
375
|
+
" - name: kid",
|
|
376
|
+
" version: 0.1.0",
|
|
377
|
+
].join("\n"),
|
|
378
|
+
"charts/kid/Chart.yaml": "apiVersion: v2\nname: kid\nversion: 0.1.0\n",
|
|
379
|
+
"charts/kid/values.yaml": "persistence:\n enabled: false\n",
|
|
380
|
+
"charts/kid/templates/pvc.yaml":
|
|
381
|
+
'{{- if .Values.persistence.enabled }}\n{{- if lookup "v1" "P" "ns" "n" }}\nx: 1\n{{- end }}\n{{- end }}',
|
|
382
|
+
};
|
|
383
|
+
const off = classifyChart(mkChart(files));
|
|
384
|
+
expect(off.verdict).toBe("pinnable");
|
|
385
|
+
expect(off.hazards[0].chart).toBe("kid");
|
|
386
|
+
expect(off.hazards[0].file).toBe("charts/kid/templates/pvc.yaml");
|
|
387
|
+
expect(off.hazards[0].gates.map((g) => g.path)).toEqual(["kid.persistence.enabled"]);
|
|
388
|
+
// Parent-overrides-child: the root values flip the subchart's gate.
|
|
389
|
+
const on = classifyChart(mkChart(files), { values: { kid: { persistence: { enabled: true } } } });
|
|
390
|
+
expect(on.verdict).toBe("unpinnable");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("a condition-disabled dependency gates everything inside it (finding 12)", () => {
|
|
394
|
+
const chart = mkChart({
|
|
395
|
+
"Chart.yaml": [
|
|
396
|
+
"apiVersion: v2",
|
|
397
|
+
"name: umb",
|
|
398
|
+
"version: 0.1.0",
|
|
399
|
+
"dependencies:",
|
|
400
|
+
" - name: opt",
|
|
401
|
+
" version: 0.1.0",
|
|
402
|
+
" condition: opt.enabled",
|
|
403
|
+
].join("\n"),
|
|
404
|
+
"values.yaml": "opt:\n enabled: false\n",
|
|
405
|
+
"charts/opt/Chart.yaml": "apiVersion: v2\nname: opt\nversion: 0.1.0\n",
|
|
406
|
+
"charts/opt/templates/cm.yaml": '{{- if lookup "v1" "ConfigMap" "ns" "n" }}\nx: 1\n{{- end }}',
|
|
407
|
+
});
|
|
408
|
+
const report = classifyChart(chart);
|
|
409
|
+
expect(report.verdict).toBe("pinnable");
|
|
410
|
+
expect(report.hazards[0].chart).toBe("opt");
|
|
411
|
+
expect(report.hazards[0].gates.map((g) => g.path)).toEqual(["opt.enabled"]);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("an aliased instance left open refuses even when the plain instance is gated", () => {
|
|
415
|
+
const chart = mkChart({
|
|
416
|
+
"Chart.yaml": [
|
|
417
|
+
"apiVersion: v2",
|
|
418
|
+
"name: umb",
|
|
419
|
+
"version: 0.1.0",
|
|
420
|
+
"dependencies:",
|
|
421
|
+
" - name: kid",
|
|
422
|
+
" version: 0.1.0",
|
|
423
|
+
" - name: kid",
|
|
424
|
+
" version: 0.1.0",
|
|
425
|
+
" alias: kidtwo",
|
|
426
|
+
].join("\n"),
|
|
427
|
+
"values.yaml": "kidtwo:\n gate: true\n",
|
|
428
|
+
"charts/kid/Chart.yaml": "apiVersion: v2\nname: kid\nversion: 0.1.0\n",
|
|
429
|
+
"charts/kid/values.yaml": "gate: false\n",
|
|
430
|
+
"charts/kid/templates/cm.yaml":
|
|
431
|
+
'{{- if and .Values.gate (lookup "v1" "ConfigMap" "ns" "n") }}\nx: 1\n{{- end }}',
|
|
432
|
+
});
|
|
433
|
+
const report = classifyChart(chart);
|
|
434
|
+
// One directory, two instances: kid is gated, kidtwo is live — the pair
|
|
435
|
+
// (chart, values) is unpinnable because SOME instance reaches the lookup.
|
|
436
|
+
expect(report.verdict).toBe("unpinnable");
|
|
437
|
+
expect(report.lookups.controlFlow).toHaveLength(1);
|
|
438
|
+
expect(report.reasons.join("\n")).toContain("kidtwo");
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("capability references and generated inputs", () => {
|
|
443
|
+
test(".Capabilities refs are listed per path and require a profile — the normal case, not an error", () => {
|
|
444
|
+
const chart = mkChart({
|
|
445
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
446
|
+
"templates/ing.yaml": [
|
|
447
|
+
'{{- if .Values.ingress.enabled }}',
|
|
448
|
+
'{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}',
|
|
449
|
+
"apiVersion: networking.k8s.io/v1",
|
|
450
|
+
"{{- end }}",
|
|
451
|
+
"{{- end }}",
|
|
452
|
+
"kube: {{ .Capabilities.KubeVersion.Version }}",
|
|
453
|
+
].join("\n"),
|
|
454
|
+
});
|
|
455
|
+
const report = classifyChart(chart);
|
|
456
|
+
expect(report.verdict).toBe("pinnable");
|
|
457
|
+
expect(report.requiresProfile).toHaveLength(2);
|
|
458
|
+
expect(report.requiresProfile.map((r) => r.capability).sort()).toEqual([
|
|
459
|
+
"APIVersions",
|
|
460
|
+
"KubeVersion",
|
|
461
|
+
]);
|
|
462
|
+
expect(report.requiresProfile.every((r) => r.file === "templates/ing.yaml")).toBe(true);
|
|
463
|
+
expect(report.reasons.join("\n")).toContain("declare capability profile");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("an open generated input is detected statically and its suppliable path named", () => {
|
|
467
|
+
const chart = mkChart({
|
|
468
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
469
|
+
"values.yaml": 'adminPassword: ""\n',
|
|
470
|
+
"templates/secret.yaml":
|
|
471
|
+
"{{- $pw := .Values.adminPassword | default (randAlphaNum 16) }}\npassword: {{ $pw | b64enc }}",
|
|
472
|
+
});
|
|
473
|
+
const report = classifyChart(chart);
|
|
474
|
+
expect(report.verdict).toBe("pinnable");
|
|
475
|
+
const generated = report.closedInputs.filter((c) => c.kind === "generated-value");
|
|
476
|
+
expect(generated).toHaveLength(1);
|
|
477
|
+
expect(generated[0].fn).toBe("randAlphaNum");
|
|
478
|
+
expect(generated[0].valuesPath).toBe("adminPassword");
|
|
479
|
+
expect(generated[0].suppliable).toBe(true);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("supplying the value closes the generated input", () => {
|
|
483
|
+
const chart = mkChart({
|
|
484
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
485
|
+
"values.yaml": 'adminPassword: ""\n',
|
|
486
|
+
"templates/secret.yaml":
|
|
487
|
+
"{{- $pw := .Values.adminPassword | default (randAlphaNum 16) }}\npassword: {{ $pw | b64enc }}",
|
|
488
|
+
});
|
|
489
|
+
const report = classifyChart(chart, { values: { adminPassword: "pinned" } });
|
|
490
|
+
expect(report.closedInputs.filter((c) => c.kind === "generated-value")).toHaveLength(0);
|
|
491
|
+
// Still pinnable (not deterministic): the supplied values ARE a closed input.
|
|
492
|
+
expect(report.verdict).toBe("pinnable");
|
|
493
|
+
expect(report.closedInputs.map((c) => c.kind)).toContain("supplied-values");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("a generator with no suppliable value is named for the double-render localizer (#1236)", () => {
|
|
497
|
+
const chart = mkChart({
|
|
498
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
499
|
+
"templates/secret.yaml": "token: {{ randAlphaNum 32 | quote }}",
|
|
500
|
+
});
|
|
501
|
+
const report = classifyChart(chart);
|
|
502
|
+
expect(report.verdict).toBe("pinnable");
|
|
503
|
+
const generated = report.closedInputs.filter((c) => c.kind === "generated-value");
|
|
504
|
+
expect(generated).toHaveLength(1);
|
|
505
|
+
expect(generated[0].suppliable).toBe(false);
|
|
506
|
+
expect(generated[0].detail).toContain("#1236");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("a generator behind a closed gate cannot fire and is not an open input", () => {
|
|
510
|
+
const chart = mkChart({
|
|
511
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
512
|
+
"values.yaml": "autoGenerate: false\n",
|
|
513
|
+
"templates/secret.yaml":
|
|
514
|
+
"{{- if .Values.autoGenerate }}\ntoken: {{ randAlphaNum 32 | quote }}\n{{- end }}",
|
|
515
|
+
});
|
|
516
|
+
const report = classifyChart(chart);
|
|
517
|
+
expect(report.closedInputs.filter((c) => c.kind === "generated-value")).toHaveLength(0);
|
|
518
|
+
expect(report.verdict).toBe("deterministic");
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("double-render evidence alone still classifies pinnable with the diff surfaced", () => {
|
|
522
|
+
const chart = mkChart({
|
|
523
|
+
"Chart.yaml": MINIMAL_CHART,
|
|
524
|
+
"templates/cm.yaml": "x: 1",
|
|
525
|
+
});
|
|
526
|
+
const report = classifyChart(chart, { renderEvidence: { stable: false, unstableLines: 4 } });
|
|
527
|
+
expect(report.verdict).toBe("pinnable");
|
|
528
|
+
expect(report.reasons.join("\n")).toContain("double render differs on 4 lines");
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe("action extraction and scoping", () => {
|
|
533
|
+
test("line numbers are 1-based and survive trim markers", () => {
|
|
534
|
+
const actions = extractActions("a\nb: {{ .Values.x }}\n{{- if .Values.y }}\nc\n{{- end }}", "f.yaml");
|
|
535
|
+
expect(actions.map((a) => [a.line, a.kind])).toEqual([
|
|
536
|
+
[2, "other"],
|
|
537
|
+
[3, "if"],
|
|
538
|
+
[5, "end"],
|
|
539
|
+
]);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("scoping attaches enclosing frames, else branches negate prior conditions", () => {
|
|
543
|
+
const actions = extractActions(
|
|
544
|
+
["{{- if .Values.a }}", "{{ .Values.inner }}", "{{- else }}", "{{ .Values.other }}", "{{- end }}"].join("\n"),
|
|
545
|
+
"f.yaml",
|
|
546
|
+
);
|
|
547
|
+
const scoped = scopeActions(actions);
|
|
548
|
+
const inner = scoped.find((s) => s.action.body === ".Values.inner")!;
|
|
549
|
+
expect(inner.frames).toHaveLength(1);
|
|
550
|
+
expect(inner.frames[0].conditions).toEqual([".Values.a"]);
|
|
551
|
+
const other = scoped.find((s) => s.action.body === ".Values.other")!;
|
|
552
|
+
expect(other.frames[0].conditions).toEqual([]);
|
|
553
|
+
expect(other.frames[0].negatedConditions).toEqual([".Values.a"]);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("unbalanced structure degrades toward fewer frames, never a crash", () => {
|
|
557
|
+
const scoped = scopeActions(extractActions("{{- end }}\n{{ .Values.x }}", "f.yaml"));
|
|
558
|
+
expect(scoped.find((s) => s.action.body === ".Values.x")!.frames).toEqual([]);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers over a `helm template` output stream (#1234, epic #1228).
|
|
3
|
+
*
|
|
4
|
+
* The CRD routing rule here is what the structure-preserving wrapper
|
|
5
|
+
* (#1239) builds on, and the line diff seeds the double-render localizer
|
|
6
|
+
* (#1236). Both were proven in the survey harness before promotion.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Split a `helm template` stream into non-empty documents. */
|
|
10
|
+
export function splitDocuments(rendered: string): string[] {
|
|
11
|
+
return rendered
|
|
12
|
+
.split(/^---$/m)
|
|
13
|
+
.map((d) => d.trim())
|
|
14
|
+
.filter((d) => d.length > 0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The `# Source: <chart>/<path>` origin of a rendered document, if any. */
|
|
18
|
+
export function sourcePath(doc: string): string | undefined {
|
|
19
|
+
const m = doc.match(/^# Source: \S+?\/(\S+)$/m);
|
|
20
|
+
return m ? m[1] : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Finding 10: segment matching, not prefix matching. A subchart's CRDs
|
|
25
|
+
* arrive as `charts/<child>/crds/<file>`; `startsWith("crds/")` catches
|
|
26
|
+
* 0 of them and would route every one into templates/, making it deletable
|
|
27
|
+
* on uninstall.
|
|
28
|
+
*/
|
|
29
|
+
export function isCrdSource(path: string): boolean {
|
|
30
|
+
return /(^|\/)crds\//.test(path);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RoutedDocuments {
|
|
34
|
+
crds: string[];
|
|
35
|
+
templates: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Route rendered documents by origin: `crds/`-segment sources vs the rest. */
|
|
39
|
+
export function routeBySource(rendered: string): RoutedDocuments {
|
|
40
|
+
const routed: RoutedDocuments = { crds: [], templates: [] };
|
|
41
|
+
for (const doc of splitDocuments(rendered)) {
|
|
42
|
+
const p = sourcePath(doc);
|
|
43
|
+
(p !== undefined && isCrdSource(p) ? routed.crds : routed.templates).push(doc);
|
|
44
|
+
}
|
|
45
|
+
return routed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Count lines that differ between two renders (symmetric, order-blind). */
|
|
49
|
+
export function countDifferingLines(a: string, b: string): number {
|
|
50
|
+
if (a === b) return 0;
|
|
51
|
+
const linesA = a.split("\n");
|
|
52
|
+
const linesB = b.split("\n");
|
|
53
|
+
const count = new Map<string, number>();
|
|
54
|
+
for (const l of linesA) count.set(l, (count.get(l) ?? 0) + 1);
|
|
55
|
+
for (const l of linesB) count.set(l, (count.get(l) ?? 0) - 1);
|
|
56
|
+
let diff = 0;
|
|
57
|
+
for (const n of count.values()) diff += Math.abs(n);
|
|
58
|
+
return diff;
|
|
59
|
+
}
|