@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,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Go template action extraction and control-flow scoping (#1234, epic #1228).
|
|
3
|
+
*
|
|
4
|
+
* The pinnability gate inspects template ACTIONS (`{{ ... }}`), never raw
|
|
5
|
+
* text. That distinction is finding 9: cert-manager carries 52 textual
|
|
6
|
+
* "lookup" occurrences, all comment prose, and a real template-action count
|
|
7
|
+
* of 0 — a text-matching gate refuses a chart that is perfectly pinnable.
|
|
8
|
+
*
|
|
9
|
+
* Beyond extraction, this module reconstructs the control-flow structure of a
|
|
10
|
+
* template file — which `if` / `with` / `range` blocks enclose each action —
|
|
11
|
+
* so the classifier can ask whether a construct is REACHABLE under the
|
|
12
|
+
* supplied values, not merely present. Reachability is what separates
|
|
13
|
+
* "unpinnable" from "pinnable with a recorded conditional hazard": bundled
|
|
14
|
+
* grafana's control-flow `lookup` sits behind `persistence.enabled`, off by
|
|
15
|
+
* default, so a default-values render never consults the cluster.
|
|
16
|
+
*/
|
|
17
|
+
const KIND_RE = /^(if\b|else\s+if\b|else\b|with\b|range\b|define\b|block\b|end\b)/;
|
|
18
|
+
function kindOf(body) {
|
|
19
|
+
const m = body.match(KIND_RE);
|
|
20
|
+
if (!m)
|
|
21
|
+
return "other";
|
|
22
|
+
const kw = m[1].replace(/\s+/g, " ").trim();
|
|
23
|
+
if (kw === "else if")
|
|
24
|
+
return "elseif";
|
|
25
|
+
return kw.replace(/\b\s.*$/, "");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Extract template actions from a source file, skipping comment actions
|
|
29
|
+
* (`{{/* ... *\/}}`). Text outside actions — including YAML `#` comments,
|
|
30
|
+
* where finding 9's 52 false positives lived — is never inspected by any
|
|
31
|
+
* scan built on this.
|
|
32
|
+
*/
|
|
33
|
+
export function extractActions(source, file) {
|
|
34
|
+
const actions = [];
|
|
35
|
+
let i = 0;
|
|
36
|
+
let line = 1;
|
|
37
|
+
let lineScanPos = 0;
|
|
38
|
+
const lineAt = (pos) => {
|
|
39
|
+
// Incremental line counting: `pos` is monotonically increasing.
|
|
40
|
+
for (; lineScanPos < pos; lineScanPos++) {
|
|
41
|
+
if (source[lineScanPos] === "\n")
|
|
42
|
+
line += 1;
|
|
43
|
+
}
|
|
44
|
+
return line;
|
|
45
|
+
};
|
|
46
|
+
while (i < source.length) {
|
|
47
|
+
const open = source.indexOf("{{", i);
|
|
48
|
+
if (open === -1)
|
|
49
|
+
break;
|
|
50
|
+
const actionLine = lineAt(open);
|
|
51
|
+
// Body starts after `{{` and an optional trim marker.
|
|
52
|
+
let bodyStart = open + 2;
|
|
53
|
+
if (source[bodyStart] === "-")
|
|
54
|
+
bodyStart += 1;
|
|
55
|
+
const rest = source.slice(bodyStart);
|
|
56
|
+
// Comment action: {{/* ... */}} — skip to its closing */}}.
|
|
57
|
+
if (/^\s*\/\*/.test(rest)) {
|
|
58
|
+
const closeComment = source.indexOf("*/", bodyStart);
|
|
59
|
+
if (closeComment === -1)
|
|
60
|
+
break;
|
|
61
|
+
const close = source.indexOf("}}", closeComment);
|
|
62
|
+
i = close === -1 ? source.length : close + 2;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const close = source.indexOf("}}", bodyStart);
|
|
66
|
+
if (close === -1)
|
|
67
|
+
break;
|
|
68
|
+
let body = source.slice(bodyStart, close);
|
|
69
|
+
if (body.endsWith("-"))
|
|
70
|
+
body = body.slice(0, -1);
|
|
71
|
+
body = body.trim();
|
|
72
|
+
actions.push({ file, line: actionLine, body, kind: kindOf(body) });
|
|
73
|
+
i = close + 2;
|
|
74
|
+
}
|
|
75
|
+
return actions;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The pipeline of an action, structural keyword and variable assignment
|
|
79
|
+
* stripped: `if X` → `X`, `range $i, $v := X` → `X`, `$pw := X` → `X`.
|
|
80
|
+
* This is what condition evaluation and call-reachability parse.
|
|
81
|
+
*/
|
|
82
|
+
export function actionPipeline(action) {
|
|
83
|
+
let expr = action.body.replace(KIND_RE, "").trim();
|
|
84
|
+
// `range $i, $v := .Values.x` / `$pw := .Values.x` — the pipeline is what
|
|
85
|
+
// follows the assignment.
|
|
86
|
+
const assign = expr.match(/^\$[\w]*\s*(?:,\s*\$[\w]*\s*)?:?=\s*(.*)$/);
|
|
87
|
+
if (assign)
|
|
88
|
+
expr = assign[1].trim();
|
|
89
|
+
return expr;
|
|
90
|
+
}
|
|
91
|
+
/** `.Values.a.b` (or `$.Values.a.b`) as a path array, else undefined. */
|
|
92
|
+
function valuesPathOf(expr) {
|
|
93
|
+
const m = expr.match(/^\$?\.Values((?:\.[A-Za-z0-9_-]+)+)$/);
|
|
94
|
+
return m ? m[1].slice(1).split(".") : undefined;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Walk a file's actions and attach the enclosing control-flow frames to each.
|
|
98
|
+
* `else` / `else if` branches carry the prior branch conditions as
|
|
99
|
+
* `negatedConditions`. Structure that cannot be tracked (an unbalanced
|
|
100
|
+
* `end`, template text split across files) degrades to fewer frames — which
|
|
101
|
+
* only ever makes the classifier MORE conservative, never less: a missing
|
|
102
|
+
* frame means a gate we cannot prove closed.
|
|
103
|
+
*/
|
|
104
|
+
export function scopeActions(actions) {
|
|
105
|
+
const scoped = [];
|
|
106
|
+
const stack = [];
|
|
107
|
+
for (const action of actions) {
|
|
108
|
+
switch (action.kind) {
|
|
109
|
+
case "if": {
|
|
110
|
+
const cond = actionPipeline(action);
|
|
111
|
+
// The `if` action itself is evaluated in the ENCLOSING scope.
|
|
112
|
+
scoped.push({ action, frames: stack.map((s) => s.frame) });
|
|
113
|
+
stack.push({
|
|
114
|
+
frame: { kind: "if", conditions: [cond], negatedConditions: [] },
|
|
115
|
+
chain: [cond],
|
|
116
|
+
});
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case "elseif": {
|
|
120
|
+
scoped.push({
|
|
121
|
+
action,
|
|
122
|
+
frames: stack.slice(0, -1).map((s) => s.frame),
|
|
123
|
+
});
|
|
124
|
+
const top = stack[stack.length - 1];
|
|
125
|
+
const cond = action.body.replace(/^else\s+if\b/, "").trim();
|
|
126
|
+
if (top) {
|
|
127
|
+
top.frame = {
|
|
128
|
+
kind: "if",
|
|
129
|
+
conditions: [cond],
|
|
130
|
+
negatedConditions: [...top.chain],
|
|
131
|
+
};
|
|
132
|
+
top.chain.push(cond);
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "else": {
|
|
137
|
+
scoped.push({
|
|
138
|
+
action,
|
|
139
|
+
frames: stack.slice(0, -1).map((s) => s.frame),
|
|
140
|
+
});
|
|
141
|
+
const top = stack[stack.length - 1];
|
|
142
|
+
if (top) {
|
|
143
|
+
top.frame = {
|
|
144
|
+
kind: top.frame.kind === "if" ? "if" : top.frame.kind,
|
|
145
|
+
conditions: [],
|
|
146
|
+
negatedConditions: [...top.chain],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case "with": {
|
|
152
|
+
const expr = actionPipeline(action);
|
|
153
|
+
scoped.push({ action, frames: stack.map((s) => s.frame) });
|
|
154
|
+
const path = valuesPathOf(expr);
|
|
155
|
+
stack.push({
|
|
156
|
+
frame: {
|
|
157
|
+
kind: "with",
|
|
158
|
+
conditions: [expr],
|
|
159
|
+
negatedConditions: [],
|
|
160
|
+
dotValuesPath: path,
|
|
161
|
+
dotUnresolvable: path === undefined,
|
|
162
|
+
},
|
|
163
|
+
chain: [expr],
|
|
164
|
+
});
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "range": {
|
|
168
|
+
const expr = actionPipeline(action);
|
|
169
|
+
scoped.push({ action, frames: stack.map((s) => s.frame) });
|
|
170
|
+
stack.push({
|
|
171
|
+
frame: {
|
|
172
|
+
kind: "range",
|
|
173
|
+
conditions: [expr],
|
|
174
|
+
negatedConditions: [],
|
|
175
|
+
dotUnresolvable: true,
|
|
176
|
+
},
|
|
177
|
+
chain: [expr],
|
|
178
|
+
});
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case "define":
|
|
182
|
+
case "block": {
|
|
183
|
+
scoped.push({ action, frames: stack.map((s) => s.frame) });
|
|
184
|
+
// A define body runs with whatever context `include`/`template`
|
|
185
|
+
// passes. Most charts pass the root context; the frame records
|
|
186
|
+
// nothing to gate on, so constructs inside a define are treated as
|
|
187
|
+
// reachable unless gated within the define itself.
|
|
188
|
+
stack.push({
|
|
189
|
+
frame: { kind: action.kind, conditions: [], negatedConditions: [] },
|
|
190
|
+
chain: [],
|
|
191
|
+
});
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case "end": {
|
|
195
|
+
scoped.push({
|
|
196
|
+
action,
|
|
197
|
+
frames: stack.slice(0, -1).map((s) => s.frame),
|
|
198
|
+
});
|
|
199
|
+
stack.pop();
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
default:
|
|
203
|
+
scoped.push({ action, frames: stack.map((s) => s.frame) });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return scoped;
|
|
207
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinnability gate (#1234, epic #1228 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* A render is pinnable exactly when every input to it is closed. `helm
|
|
5
|
+
* template` has three inputs beyond (chart, values) that are silently
|
|
6
|
+
* defaulted or silently empty:
|
|
7
|
+
*
|
|
8
|
+
* - cluster capabilities — closable via --kube-version / --api-versions
|
|
9
|
+
* - generated values — closable by supplying the value as an input
|
|
10
|
+
* - cluster state (lookup) — not closable offline
|
|
11
|
+
*
|
|
12
|
+
* The gate is therefore not "is this chart deterministic" but "are all of
|
|
13
|
+
* this render's inputs closed" — a property of `(chart, values)`, not of the
|
|
14
|
+
* chart alone. Concretely:
|
|
15
|
+
*
|
|
16
|
+
* - A control-flow `lookup` that is REACHABLE under the supplied values is
|
|
17
|
+
* a hard refusal: it changes which documents exist, and no value
|
|
18
|
+
* substitution recovers that.
|
|
19
|
+
* - A control-flow `lookup` the supplied values leave gated OFF is a
|
|
20
|
+
* recorded conditional hazard, not a refusal — bundled grafana's
|
|
21
|
+
* `lookup` behind `persistence.enabled: false` never runs, so the
|
|
22
|
+
* render is pinnable; flipping that value re-classifies the pair.
|
|
23
|
+
* - Value-position `lookup` is a declared-input requirement (epic
|
|
24
|
+
* Decisions): it changes a field, not the document set.
|
|
25
|
+
* - `.Capabilities.*` references require an explicit capability profile —
|
|
26
|
+
* the default is a property of the helm BINARY (3.16.2 says v1.31.0,
|
|
27
|
+
* 4.1.1 says v1.35.0), so an unpinned render's digest depends on who
|
|
28
|
+
* rendered it. 11 of the 13 survey charts hit this; it is the normal
|
|
29
|
+
* case, not an error.
|
|
30
|
+
* - Generated values (`randAlphaNum` and friends) are detected statically
|
|
31
|
+
* and named; the double-render localizer (#1236) confirms them
|
|
32
|
+
* dynamically. They classify as closable when a `.Values` path in the
|
|
33
|
+
* same action can supply them.
|
|
34
|
+
*
|
|
35
|
+
* A chart that fails is refused with the specific construct and location,
|
|
36
|
+
* and keeps working unpinned. Adoption is incremental.
|
|
37
|
+
*/
|
|
38
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
import { actionPipeline, extractActions, scopeActions, } from "./actions.js";
|
|
41
|
+
import { UNKNOWN, callReachability, evaluateCondition, truthy, } from "./conditions.js";
|
|
42
|
+
import { buildChartInstances, mergeValues, readYamlFile, valueAtPath, } from "./values.js";
|
|
43
|
+
/**
|
|
44
|
+
* Matches a call to the `lookup` template function: the word `lookup`
|
|
45
|
+
* followed by whitespace (it always takes arguments), not preceded by a word
|
|
46
|
+
* character, `.` or `$` — so `.Values.persistence.lookupVolumeName` (a value
|
|
47
|
+
* named after the feature, present in grafana) is not a hit. Finding 9's
|
|
48
|
+
* word-boundary lesson: `\blookup\b` false-positives on the very chart the
|
|
49
|
+
* refusal is about.
|
|
50
|
+
*/
|
|
51
|
+
const LOOKUP_CALL = /(?<![.\w$])lookup\s/;
|
|
52
|
+
const LOOKUP_FN = /^lookup$/;
|
|
53
|
+
/**
|
|
54
|
+
* Template functions whose output differs per render: sprig's random and
|
|
55
|
+
* crypto generators, plus `now`. Detected statically so the classifier can
|
|
56
|
+
* NAME the open input; the double-render localizer (#1236) is what proves
|
|
57
|
+
* one fired.
|
|
58
|
+
*/
|
|
59
|
+
const GENERATED_VALUE_FNS = [
|
|
60
|
+
"randAlphaNum",
|
|
61
|
+
"randAlpha",
|
|
62
|
+
"randNumeric",
|
|
63
|
+
"randAscii",
|
|
64
|
+
"randBytes",
|
|
65
|
+
"randInt",
|
|
66
|
+
"uuidv4",
|
|
67
|
+
"derivePassword",
|
|
68
|
+
"genPrivateKey",
|
|
69
|
+
"genCA",
|
|
70
|
+
"genCAWithKey",
|
|
71
|
+
"genSelfSignedCert",
|
|
72
|
+
"genSelfSignedCertWithKey",
|
|
73
|
+
"genSignedCert",
|
|
74
|
+
"genSignedCertWithKey",
|
|
75
|
+
"htpasswd",
|
|
76
|
+
"bcrypt",
|
|
77
|
+
"now",
|
|
78
|
+
];
|
|
79
|
+
const GENERATED_CALL = new RegExp(`(?<![.\\w$])(${GENERATED_VALUE_FNS.join("|")})\\b`);
|
|
80
|
+
const CAPABILITY_REF = /\.Capabilities\.(KubeVersion|APIVersions)/g;
|
|
81
|
+
/** Structural kinds whose pipeline decides which documents exist. */
|
|
82
|
+
const CONTROL_FLOW_KINDS = new Set(["if", "elseif", "with", "range"]);
|
|
83
|
+
const VALUES_PATH_IN_ACTION = /\$?\.Values((?:\.[A-Za-z0-9_-]+)+)/;
|
|
84
|
+
/**
|
|
85
|
+
* Template files of ONE chart directory — `templates/` at any depth below
|
|
86
|
+
* it, excluding `charts/` (subcharts are their own instances) and NOTES.txt
|
|
87
|
+
* (rendered for the console, not the cluster).
|
|
88
|
+
*/
|
|
89
|
+
export function collectOwnTemplateFiles(chartDir) {
|
|
90
|
+
const out = [];
|
|
91
|
+
const walk = (dir, underTemplates) => {
|
|
92
|
+
for (const entry of readdirSync(dir)) {
|
|
93
|
+
const p = join(dir, entry);
|
|
94
|
+
if (statSync(p).isDirectory()) {
|
|
95
|
+
if (entry === "charts" && !underTemplates)
|
|
96
|
+
continue;
|
|
97
|
+
walk(p, underTemplates || entry === "templates");
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!underTemplates)
|
|
101
|
+
continue;
|
|
102
|
+
if (entry === "NOTES.txt")
|
|
103
|
+
continue;
|
|
104
|
+
if (/\.(ya?ml|tpl)$/.test(entry))
|
|
105
|
+
out.push(p);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
walk(chartDir, false);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
function scanDir(chartDir) {
|
|
112
|
+
return collectOwnTemplateFiles(chartDir)
|
|
113
|
+
.sort()
|
|
114
|
+
.map((file) => {
|
|
115
|
+
const rel = file.slice(chartDir.length + 1);
|
|
116
|
+
return { rel, scoped: scopeActions(extractActions(readFileSync(file, "utf8"), rel)) };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// --- gating ----------------------------------------------------------------
|
|
120
|
+
/**
|
|
121
|
+
* Walk the enclosing frames of an action and decide whether the supplied
|
|
122
|
+
* values leave it unreachable. Returns the gates that close it, or null when
|
|
123
|
+
* every frame is (or may be) open — in which case the construct counts as
|
|
124
|
+
* reachable.
|
|
125
|
+
*/
|
|
126
|
+
function framesGate(frames, instance) {
|
|
127
|
+
// Dot binding threads through the frames: `with .Values.persistence`
|
|
128
|
+
// rebinds bare `.enabled` to `persistence.enabled` for everything inside.
|
|
129
|
+
let dot = null;
|
|
130
|
+
let allKnown = true;
|
|
131
|
+
for (const frame of frames) {
|
|
132
|
+
const ctx = {
|
|
133
|
+
values: instance.values,
|
|
134
|
+
scope: instance.scope,
|
|
135
|
+
dotValuesPath: dot,
|
|
136
|
+
};
|
|
137
|
+
for (const cond of frame.conditions) {
|
|
138
|
+
const r = evaluateCondition(cond, ctx);
|
|
139
|
+
if (r.value === false)
|
|
140
|
+
return { gates: r.gates, allKnown };
|
|
141
|
+
if (r.value === UNKNOWN)
|
|
142
|
+
allKnown = false;
|
|
143
|
+
}
|
|
144
|
+
for (const negated of frame.negatedConditions) {
|
|
145
|
+
// An else/else-if branch runs only when every earlier condition was
|
|
146
|
+
// false; a definitively TRUE earlier condition closes the branch.
|
|
147
|
+
const r = evaluateCondition(negated, ctx);
|
|
148
|
+
if (r.value === true) {
|
|
149
|
+
const m = negated.match(VALUES_PATH_IN_ACTION);
|
|
150
|
+
return {
|
|
151
|
+
gates: m
|
|
152
|
+
? [{ path: [...instance.scope, ...m[1].slice(1).split(".")].join("."), value: true }]
|
|
153
|
+
: [],
|
|
154
|
+
allKnown,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (r.value === UNKNOWN)
|
|
158
|
+
allKnown = false;
|
|
159
|
+
}
|
|
160
|
+
if (frame.kind === "with")
|
|
161
|
+
dot = frame.dotUnresolvable ? undefined : (frame.dotValuesPath ?? undefined);
|
|
162
|
+
else if (frame.kind === "range")
|
|
163
|
+
dot = undefined;
|
|
164
|
+
else if (frame.kind === "define" || frame.kind === "block")
|
|
165
|
+
dot = null;
|
|
166
|
+
}
|
|
167
|
+
return { gates: null, allKnown };
|
|
168
|
+
}
|
|
169
|
+
/** Reachability of a function call inside an action, frames + same-action gates. */
|
|
170
|
+
function assessCall(scopedAction, fnRe, instance) {
|
|
171
|
+
if (instance.disabledBy !== undefined) {
|
|
172
|
+
return {
|
|
173
|
+
reachable: false,
|
|
174
|
+
gates: [{ path: instance.disabledBy, value: false }],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const enclosing = framesGate(scopedAction.frames, instance);
|
|
178
|
+
if (enclosing.gates !== null)
|
|
179
|
+
return { reachable: false, gates: enclosing.gates };
|
|
180
|
+
// Same-action short-circuit gating: `if and .Values.gate (lookup ...)`
|
|
181
|
+
// never calls lookup when the gate is false.
|
|
182
|
+
let dot = null;
|
|
183
|
+
for (const frame of scopedAction.frames) {
|
|
184
|
+
if (frame.kind === "with")
|
|
185
|
+
dot = frame.dotUnresolvable ? undefined : (frame.dotValuesPath ?? undefined);
|
|
186
|
+
else if (frame.kind === "range")
|
|
187
|
+
dot = undefined;
|
|
188
|
+
else if (frame.kind === "define" || frame.kind === "block")
|
|
189
|
+
dot = null;
|
|
190
|
+
}
|
|
191
|
+
const ctx = { values: instance.values, scope: instance.scope, dotValuesPath: dot };
|
|
192
|
+
const inAction = callReachability(actionPipeline(scopedAction.action), fnRe, ctx);
|
|
193
|
+
// An enclosing gate we could not resolve leaves reachability unproven even
|
|
194
|
+
// when nothing in the action itself gates the call.
|
|
195
|
+
if (inAction.reachable === true && !enclosing.allKnown) {
|
|
196
|
+
return { reachable: UNKNOWN, gates: [] };
|
|
197
|
+
}
|
|
198
|
+
return inAction;
|
|
199
|
+
}
|
|
200
|
+
// --- classification --------------------------------------------------------
|
|
201
|
+
/**
|
|
202
|
+
* Classify a chart directory against the values a render would be given.
|
|
203
|
+
* Subchart-recursive: dependencies vendored under `charts/` are scanned with
|
|
204
|
+
* their own effective values (aliases get their own scope, `condition:`-
|
|
205
|
+
* disabled dependencies gate their contents — findings 11 and 12).
|
|
206
|
+
*/
|
|
207
|
+
export function classifyChart(chartDir, options = {}) {
|
|
208
|
+
let supplied;
|
|
209
|
+
for (const file of options.valuesFiles ?? []) {
|
|
210
|
+
supplied = mergeValues(supplied ?? {}, readYamlFile(file));
|
|
211
|
+
}
|
|
212
|
+
if (options.values !== undefined)
|
|
213
|
+
supplied = mergeValues(supplied ?? {}, options.values);
|
|
214
|
+
const valuesSupplied = supplied !== undefined;
|
|
215
|
+
const { instances, warnings } = buildChartInstances(chartDir, supplied);
|
|
216
|
+
const requiresProfile = [];
|
|
217
|
+
const valuePosition = [];
|
|
218
|
+
const controlFlowSites = new Map();
|
|
219
|
+
const generatedSites = new Map();
|
|
220
|
+
const dirScans = new Map();
|
|
221
|
+
const staticallyRecordedDirs = new Set();
|
|
222
|
+
for (const instance of instances) {
|
|
223
|
+
let files = dirScans.get(instance.dir);
|
|
224
|
+
if (files === undefined) {
|
|
225
|
+
files = scanDir(instance.dir);
|
|
226
|
+
dirScans.set(instance.dir, files);
|
|
227
|
+
}
|
|
228
|
+
// Static findings (capability refs, value-position lookups) are
|
|
229
|
+
// per-FILE: an aliased dependency scans the same directory for two
|
|
230
|
+
// instances but its files exist once.
|
|
231
|
+
const recordStatic = !staticallyRecordedDirs.has(instance.dir);
|
|
232
|
+
staticallyRecordedDirs.add(instance.dir);
|
|
233
|
+
for (const { rel, scoped } of files) {
|
|
234
|
+
const file = instance.relDir === "" ? rel : `${instance.relDir}/${rel}`;
|
|
235
|
+
for (const s of scoped) {
|
|
236
|
+
const { action } = s;
|
|
237
|
+
if (recordStatic) {
|
|
238
|
+
for (const m of action.body.matchAll(CAPABILITY_REF)) {
|
|
239
|
+
requiresProfile.push({
|
|
240
|
+
file,
|
|
241
|
+
line: action.line,
|
|
242
|
+
capability: m[1],
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (LOOKUP_CALL.test(action.body)) {
|
|
247
|
+
if (CONTROL_FLOW_KINDS.has(action.kind)) {
|
|
248
|
+
const key = `${file}:${action.line}`;
|
|
249
|
+
let site = controlFlowSites.get(key);
|
|
250
|
+
if (site === undefined) {
|
|
251
|
+
site = { file, line: action.line, action: action.body, assessments: [] };
|
|
252
|
+
controlFlowSites.set(key, site);
|
|
253
|
+
}
|
|
254
|
+
const a = assessCall(s, LOOKUP_FN, instance);
|
|
255
|
+
site.assessments.push({
|
|
256
|
+
chart: instance.scope.join("."),
|
|
257
|
+
reachable: a.reachable,
|
|
258
|
+
gates: a.gates,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
else if (recordStatic) {
|
|
262
|
+
valuePosition.push({ file, line: action.line, action: action.body });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const gen = action.body.match(GENERATED_CALL);
|
|
266
|
+
if (gen !== null) {
|
|
267
|
+
const a = assessCall(s, new RegExp(`^${gen[1]}$`), instance);
|
|
268
|
+
// A generator whose enclosing gates are definitively closed cannot
|
|
269
|
+
// fire for this render; re-classification on a values flip
|
|
270
|
+
// rediscovers it.
|
|
271
|
+
if (a.reachable !== false) {
|
|
272
|
+
const valuesRef = action.body.match(VALUES_PATH_IN_ACTION);
|
|
273
|
+
// A value closes a generator only through the `x | default
|
|
274
|
+
// (rand)` shape, where a truthy x discards the generated result.
|
|
275
|
+
// A generator that CONSUMES values (`htpasswd .Values.user
|
|
276
|
+
// .Values.password` — random salt per render) stays open no
|
|
277
|
+
// matter what the values hold, so the path it reads is not a
|
|
278
|
+
// suppliable slot; the localizer (#1236) finds the real pin
|
|
279
|
+
// (harbor's `registry.credentials.htpasswdString` else-branch).
|
|
280
|
+
const closable = valuesRef !== null && /\bdefault\b/.test(action.body);
|
|
281
|
+
const relPath = closable && valuesRef ? valuesRef[1].slice(1).split(".") : undefined;
|
|
282
|
+
// Open unless the suppliable value is already set (a truthy
|
|
283
|
+
// value makes `x | default (randAlphaNum ...)` deterministic).
|
|
284
|
+
const open = relPath === undefined || !truthy(valueAtPath(instance.values, relPath));
|
|
285
|
+
const key = `${file}:${action.line}:${gen[1]}`;
|
|
286
|
+
const existing = generatedSites.get(key);
|
|
287
|
+
if (existing === undefined) {
|
|
288
|
+
generatedSites.set(key, {
|
|
289
|
+
file,
|
|
290
|
+
line: action.line,
|
|
291
|
+
fn: gen[1],
|
|
292
|
+
open,
|
|
293
|
+
valuesPath: relPath ? [...instance.scope, ...relPath].join(".") : undefined,
|
|
294
|
+
suppliable: relPath !== undefined,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
else if (open) {
|
|
298
|
+
existing.open = true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// --- fold findings into the report ---------------------------------------
|
|
306
|
+
const reasons = [];
|
|
307
|
+
const hazards = [];
|
|
308
|
+
const controlFlow = [];
|
|
309
|
+
let refusals = 0;
|
|
310
|
+
for (const site of controlFlowSites.values()) {
|
|
311
|
+
const reachableIn = site.assessments.filter((a) => a.reachable !== false);
|
|
312
|
+
if (reachableIn.length > 0) {
|
|
313
|
+
refusals += 1;
|
|
314
|
+
controlFlow.push({
|
|
315
|
+
file: site.file,
|
|
316
|
+
line: site.line,
|
|
317
|
+
action: site.action,
|
|
318
|
+
status: "refused",
|
|
319
|
+
charts: site.assessments.map((a) => a.chart),
|
|
320
|
+
});
|
|
321
|
+
for (const a of reachableIn) {
|
|
322
|
+
const where = a.chart === "" ? "" : ` (subchart ${a.chart})`;
|
|
323
|
+
const proof = a.reachable === UNKNOWN
|
|
324
|
+
? "not provably gated off by the supplied values"
|
|
325
|
+
: "reachable with the supplied values";
|
|
326
|
+
reasons.push(`lookup in control flow at ${site.file}:${site.line}${where} — ${proof}; which documents exist depends on cluster state`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
controlFlow.push({
|
|
331
|
+
file: site.file,
|
|
332
|
+
line: site.line,
|
|
333
|
+
action: site.action,
|
|
334
|
+
status: "hazard",
|
|
335
|
+
charts: site.assessments.map((a) => a.chart),
|
|
336
|
+
});
|
|
337
|
+
for (const a of site.assessments) {
|
|
338
|
+
const gateText = a.gates.map((g) => `${g.path}=${JSON.stringify(g.value ?? null)}`).join(", ");
|
|
339
|
+
hazards.push({
|
|
340
|
+
chart: a.chart,
|
|
341
|
+
file: site.file,
|
|
342
|
+
line: site.line,
|
|
343
|
+
gates: a.gates,
|
|
344
|
+
detail: `control-flow lookup at ${site.file}:${site.line} is gated off by ${gateText} — flipping it makes this render unpinnable`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// The report stays fully populated even for a refused chart: the caller
|
|
350
|
+
// renders unpinned and still benefits from knowing the other open inputs.
|
|
351
|
+
const closedInputs = [];
|
|
352
|
+
if (requiresProfile.length > 0) {
|
|
353
|
+
reasons.push(`declare capability profile (${requiresProfile.length} .Capabilities refs)`);
|
|
354
|
+
}
|
|
355
|
+
for (const v of valuePosition) {
|
|
356
|
+
closedInputs.push({
|
|
357
|
+
kind: "value-position-lookup",
|
|
358
|
+
file: v.file,
|
|
359
|
+
line: v.line,
|
|
360
|
+
suppliable: true,
|
|
361
|
+
detail: `declare input for value-position lookup at ${v.file}:${v.line}`,
|
|
362
|
+
});
|
|
363
|
+
reasons.push(`declare input for value-position lookup at ${v.file}:${v.line}`);
|
|
364
|
+
}
|
|
365
|
+
for (const g of generatedSites.values()) {
|
|
366
|
+
if (!g.open)
|
|
367
|
+
continue;
|
|
368
|
+
const supply = g.suppliable
|
|
369
|
+
? `suppliable via ${g.valuesPath}`
|
|
370
|
+
: "no suppliable value found in the action — the double-render localizer (#1236) must confirm and localize it";
|
|
371
|
+
closedInputs.push({
|
|
372
|
+
kind: "generated-value",
|
|
373
|
+
file: g.file,
|
|
374
|
+
line: g.line,
|
|
375
|
+
fn: g.fn,
|
|
376
|
+
valuesPath: g.valuesPath,
|
|
377
|
+
suppliable: g.suppliable,
|
|
378
|
+
detail: `open generated input ${g.fn} at ${g.file}:${g.line} — ${supply}`,
|
|
379
|
+
});
|
|
380
|
+
reasons.push(`open generated input ${g.fn} at ${g.file}:${g.line} — ${supply}`);
|
|
381
|
+
}
|
|
382
|
+
if (options.renderEvidence !== undefined && !options.renderEvidence.stable) {
|
|
383
|
+
reasons.push(`open generated input: double render differs on ${options.renderEvidence.unstableLines} lines — supply the value(s) as declared inputs`);
|
|
384
|
+
}
|
|
385
|
+
for (const h of hazards) {
|
|
386
|
+
reasons.push(`conditional hazard: ${h.detail}`);
|
|
387
|
+
}
|
|
388
|
+
if (valuesSupplied) {
|
|
389
|
+
closedInputs.push({ kind: "supplied-values", detail: "values supplied as closed input" });
|
|
390
|
+
reasons.push("values supplied as closed input");
|
|
391
|
+
}
|
|
392
|
+
for (const w of warnings)
|
|
393
|
+
reasons.push(`warning: ${w}`);
|
|
394
|
+
let verdict;
|
|
395
|
+
if (refusals > 0) {
|
|
396
|
+
verdict = "unpinnable";
|
|
397
|
+
}
|
|
398
|
+
else if (requiresProfile.length > 0 ||
|
|
399
|
+
closedInputs.length > 0 ||
|
|
400
|
+
hazards.length > 0 ||
|
|
401
|
+
(options.renderEvidence !== undefined && !options.renderEvidence.stable)) {
|
|
402
|
+
verdict = "pinnable";
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
verdict = "deterministic";
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
verdict,
|
|
409
|
+
requiresProfile,
|
|
410
|
+
closedInputs,
|
|
411
|
+
hazards,
|
|
412
|
+
reasons,
|
|
413
|
+
lookups: { controlFlow, valuePosition },
|
|
414
|
+
warnings,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinnability gate for helm renders (#1234, epic #1228 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* `classifyChart` is the entry point the pinned-render pipeline
|
|
5
|
+
* (#1235–#1240) calls before anything is pinned; the survey harness
|
|
6
|
+
* (lexicons/helm/test/survey) asserts its verdicts over a corpus of real
|
|
7
|
+
* upstream charts. This module deliberately imports nothing from the
|
|
8
|
+
* lexicon's generated artifacts, so it loads on a fresh clone and in the
|
|
9
|
+
* survey CI job without a generation step.
|
|
10
|
+
*/
|
|
11
|
+
export { classifyChart, collectOwnTemplateFiles, } from "./classify.js";
|
|
12
|
+
export { actionPipeline, extractActions, scopeActions, } from "./actions.js";
|
|
13
|
+
export { UNKNOWN, truthy, evaluateCondition, callReachability, parseExpr, } from "./conditions.js";
|
|
14
|
+
export { buildChartInstances, mergeValues, valueAtPath, readChartMeta, } from "./values.js";
|
|
15
|
+
export { splitDocuments, sourcePath, isCrdSource, routeBySource, countDifferingLines, } from "./render-stream.js";
|
|
16
|
+
export { localizeOpenInputs, diffRenderPair, } from "./localize.js";
|