@intentius/chant-lexicon-helm 0.124.31 → 0.124.33
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,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RenderManifest + content-addressed render storage (#1238, epic #1228
|
|
3
|
+
* Phase 2).
|
|
4
|
+
*
|
|
5
|
+
* #1237 gave a pinned render its identities — `inputDigest` over the
|
|
6
|
+
* declared inputs, `contentDigest` over the canonical rendered bytes. This
|
|
7
|
+
* module makes the render durable: the canonical bytes land in a
|
|
8
|
+
* content-addressed store keyed by `contentDigest`, and a `RenderManifest`
|
|
9
|
+
* — modeled on core's `BuildArchiveManifest`
|
|
10
|
+
* (packages/core/src/components/verbs/build-archive.ts) — records what the
|
|
11
|
+
* bytes are, what produced them, and how to find each document inside them.
|
|
12
|
+
*
|
|
13
|
+
* Storage layout, under `~/.chant/helm-renders/` (override with
|
|
14
|
+
* `CHANT_HELM_RENDER_ROOT`):
|
|
15
|
+
*
|
|
16
|
+
* ```
|
|
17
|
+
* sha256-<64 hex>/ one per distinct contentDigest
|
|
18
|
+
* content.yaml the canonical rendered bytes (canonicalizeRender)
|
|
19
|
+
* manifest.json the RenderManifest
|
|
20
|
+
* inputs/<64 hex>.json inputs index: full-render-inputs key -> digests
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* The `sha256-<hex>` entries are the new root the issue asks for: the
|
|
24
|
+
* pre-#1238 cache keyed entries by a *truncated, input-derived* hash
|
|
25
|
+
* (16 hex characters, no algorithm prefix), which is a cache key, not an
|
|
26
|
+
* artifact identity. Those legacy entries are left exactly where they are —
|
|
27
|
+
* unpinned renders keep using them — and pinned renders write here instead.
|
|
28
|
+
* No migration: a truncated key cannot be turned back into a digest.
|
|
29
|
+
*
|
|
30
|
+
* Two renders that produce the same bytes share one `sha256-<hex>` entry —
|
|
31
|
+
* the content file and manifest are written once and never rewritten
|
|
32
|
+
* (first writer wins; the store is immutable, like a build archive entry).
|
|
33
|
+
* Dedup falls out of content addressing rather than being a feature.
|
|
34
|
+
*
|
|
35
|
+
* The inputs index preserves cache-hit behavior: before shelling out to
|
|
36
|
+
* helm, `HelmRender` computes the full-inputs key and reads
|
|
37
|
+
* `inputs/<key>.json` to find the bytes a previous identical render stored.
|
|
38
|
+
* The index key deliberately differs from `inputDigest`: the release name
|
|
39
|
+
* and namespace are baked into the rendered bytes (`.Release.Name`,
|
|
40
|
+
* `--namespace`) but are excluded from `inputDigest` because the release
|
|
41
|
+
* ledger uses that digest as a cross-environment join key (see
|
|
42
|
+
* `helmInputDigest` in ./render-digest.ts). A cache must key on everything
|
|
43
|
+
* that changes the bytes, so the index key includes both.
|
|
44
|
+
*
|
|
45
|
+
* Retention: none, deliberately. The build archive this is modeled on has
|
|
46
|
+
* no garbage collection — an archive entry lives until someone deletes it —
|
|
47
|
+
* and the pre-#1238 render cache behaved the same way. This store follows
|
|
48
|
+
* suit: entries accumulate until removed by hand. If the archive ever grows
|
|
49
|
+
* a retention story, this store should adopt the same one.
|
|
50
|
+
*/
|
|
51
|
+
import { createHash } from "node:crypto";
|
|
52
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
53
|
+
import { createRequire } from "node:module";
|
|
54
|
+
import { homedir } from "node:os";
|
|
55
|
+
import { dirname, join } from "node:path";
|
|
56
|
+
import { fileURLToPath } from "node:url";
|
|
57
|
+
import { canonicalJson } from "@intentius/chant/effect-receipt";
|
|
58
|
+
import yaml from "js-yaml";
|
|
59
|
+
import { canonicalizeRender, helmContentDigest, helmInputDigest } from "./render-digest.js";
|
|
60
|
+
// ── store location ────────────────────────────────────────────────────────
|
|
61
|
+
/**
|
|
62
|
+
* Root of the render store. `CHANT_HELM_RENDER_ROOT` overrides the default
|
|
63
|
+
* `~/.chant/helm-renders` — the same directory the legacy input-keyed cache
|
|
64
|
+
* lives in; the two coexist because legacy entries are bare 16-hex names
|
|
65
|
+
* and store entries are `sha256-` prefixed.
|
|
66
|
+
*/
|
|
67
|
+
export function renderStoreRoot() {
|
|
68
|
+
return process.env.CHANT_HELM_RENDER_ROOT ?? join(homedir(), ".chant", "helm-renders");
|
|
69
|
+
}
|
|
70
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
71
|
+
/** `sha256:<hex>` -> the store directory name `sha256-<hex>` (colons are not filesystem-safe everywhere). */
|
|
72
|
+
function digestDirName(contentDigest) {
|
|
73
|
+
if (!DIGEST_RE.test(contentDigest)) {
|
|
74
|
+
throw new Error(`not a content digest: ${JSON.stringify(contentDigest)} (expected "sha256:<64 hex>")`);
|
|
75
|
+
}
|
|
76
|
+
return contentDigest.replace(":", "-");
|
|
77
|
+
}
|
|
78
|
+
function contentPath(root, contentDigest) {
|
|
79
|
+
return join(root, digestDirName(contentDigest), "content.yaml");
|
|
80
|
+
}
|
|
81
|
+
function manifestPath(root, contentDigest) {
|
|
82
|
+
return join(root, digestDirName(contentDigest), "manifest.json");
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Cache key over *everything* that changes a pinned render's bytes — a
|
|
86
|
+
* strict superset of `helmInputDigest`'s inputs (adds release name,
|
|
87
|
+
* namespace, and the profile's declared name, mirroring the legacy cache
|
|
88
|
+
* key's fields). 64 hex characters, no prefix: this is a lookup key, not an
|
|
89
|
+
* artifact identity.
|
|
90
|
+
*/
|
|
91
|
+
export function renderCacheKey(source) {
|
|
92
|
+
const input = {
|
|
93
|
+
chart: source.chart,
|
|
94
|
+
chartVersion: source.chartVersion ?? null,
|
|
95
|
+
releaseName: source.releaseName,
|
|
96
|
+
namespace: source.namespace ?? null,
|
|
97
|
+
values: source.values ?? {},
|
|
98
|
+
capabilityProfile: {
|
|
99
|
+
name: source.capabilityProfile.name,
|
|
100
|
+
kubeVersion: source.capabilityProfile.kubeVersion,
|
|
101
|
+
apiVersions: [...(source.capabilityProfile.apiVersions ?? [])].sort(),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
return createHash("sha256").update(canonicalJson(input), "utf8").digest("hex");
|
|
105
|
+
}
|
|
106
|
+
const CACHE_KEY_RE = /^[0-9a-f]{64}$/;
|
|
107
|
+
function inputsIndexPath(root, cacheKey) {
|
|
108
|
+
if (!CACHE_KEY_RE.test(cacheKey)) {
|
|
109
|
+
throw new Error(`not a render cache key: ${JSON.stringify(cacheKey)} (expected 64 hex characters)`);
|
|
110
|
+
}
|
|
111
|
+
return join(root, "inputs", `${cacheKey}.json`);
|
|
112
|
+
}
|
|
113
|
+
// ── values digest ─────────────────────────────────────────────────────────
|
|
114
|
+
/**
|
|
115
|
+
* `sha256:` over the canonical JSON of the resolved values alone — the
|
|
116
|
+
* `valuesDigest` half of the ledger's input-side join key
|
|
117
|
+
* `(chart, chartVersion, valuesDigest)`. Absent values digest as `{}`,
|
|
118
|
+
* matching `helmInputDigest`'s treatment.
|
|
119
|
+
*/
|
|
120
|
+
export function helmValuesDigest(values) {
|
|
121
|
+
return `sha256:${createHash("sha256").update(canonicalJson(values ?? {}), "utf8").digest("hex")}`;
|
|
122
|
+
}
|
|
123
|
+
// ── chant version ─────────────────────────────────────────────────────────
|
|
124
|
+
function readVersionAbove(startDir) {
|
|
125
|
+
let dir = startDir;
|
|
126
|
+
for (let i = 0; i < 6; i++) {
|
|
127
|
+
const candidate = join(dir, "package.json");
|
|
128
|
+
if (existsSync(candidate)) {
|
|
129
|
+
try {
|
|
130
|
+
const version = JSON.parse(readFileSync(candidate, "utf8")).version;
|
|
131
|
+
if (version)
|
|
132
|
+
return version;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// unreadable package.json — fall through to the caller's fallback
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const parent = dirname(dir);
|
|
140
|
+
if (parent === dir)
|
|
141
|
+
break;
|
|
142
|
+
dir = parent;
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The core package's version, read from its `package.json` next to wherever
|
|
148
|
+
* `@intentius/chant` resolves from here. Falls back to this lexicon's own
|
|
149
|
+
* version (the two release in lockstep), then `"unknown"` — a manifest is
|
|
150
|
+
* never blocked on version discovery.
|
|
151
|
+
*/
|
|
152
|
+
function chantVersion() {
|
|
153
|
+
try {
|
|
154
|
+
const req = createRequire(import.meta.url);
|
|
155
|
+
const fromCore = readVersionAbove(dirname(req.resolve("@intentius/chant")));
|
|
156
|
+
if (fromCore)
|
|
157
|
+
return fromCore;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// resolution failed (unusual layout) — use the lexicon's own version
|
|
161
|
+
}
|
|
162
|
+
return readVersionAbove(dirname(fileURLToPath(import.meta.url))) ?? "unknown";
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Split a canonical stream (canonicalizeRender output) into its documents
|
|
166
|
+
* with byte-accurate offsets. Boundaries are column-0 `---` lines — the
|
|
167
|
+
* canonical form introduces every document with one, and no canonical
|
|
168
|
+
* document body can contain one (block-scalar content is always indented).
|
|
169
|
+
*/
|
|
170
|
+
function canonicalSegments(canonical) {
|
|
171
|
+
const marks = [];
|
|
172
|
+
const re = /^---$/gm;
|
|
173
|
+
let m;
|
|
174
|
+
while ((m = re.exec(canonical)) !== null)
|
|
175
|
+
marks.push(m.index);
|
|
176
|
+
const segments = [];
|
|
177
|
+
let lastCharEnd = 0;
|
|
178
|
+
let lastByteEnd = 0;
|
|
179
|
+
for (let k = 0; k < marks.length; k++) {
|
|
180
|
+
const charStart = canonical.indexOf("\n", marks[k]) + 1; // just past "---\n"
|
|
181
|
+
const charEnd = k + 1 < marks.length ? marks[k + 1] : canonical.length;
|
|
182
|
+
const byteStart = lastByteEnd + Buffer.byteLength(canonical.slice(lastCharEnd, charStart), "utf8");
|
|
183
|
+
const text = canonical.slice(charStart, charEnd);
|
|
184
|
+
const byteLength = Buffer.byteLength(text, "utf8");
|
|
185
|
+
segments.push({ text, byteStart, byteLength });
|
|
186
|
+
lastCharEnd = charEnd;
|
|
187
|
+
lastByteEnd = byteStart + byteLength;
|
|
188
|
+
}
|
|
189
|
+
return segments;
|
|
190
|
+
}
|
|
191
|
+
const SOURCE_LINE = /^# Source: (\S+)$/m;
|
|
192
|
+
/**
|
|
193
|
+
* Build the document index over a canonical stream: for every identifiable
|
|
194
|
+
* document (it parses and carries `kind` + `metadata.name`), its
|
|
195
|
+
* kind/namespace/name identity, byte span, and per-document digest.
|
|
196
|
+
* Returns the index and the total document count — the count includes
|
|
197
|
+
* documents the index could not identify, so `docCount` is always the
|
|
198
|
+
* number of documents the artifact carries.
|
|
199
|
+
*/
|
|
200
|
+
export function indexRenderDocuments(canonical) {
|
|
201
|
+
const segments = canonicalSegments(canonical);
|
|
202
|
+
const documents = [];
|
|
203
|
+
for (const segment of segments) {
|
|
204
|
+
let parsed;
|
|
205
|
+
try {
|
|
206
|
+
parsed = yaml.load(segment.text);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
continue; // verbatim (unparseable) document — counted, not indexed
|
|
210
|
+
}
|
|
211
|
+
if (parsed === null || typeof parsed !== "object")
|
|
212
|
+
continue;
|
|
213
|
+
const doc = parsed;
|
|
214
|
+
if (typeof doc.kind !== "string" || typeof doc.metadata?.name !== "string")
|
|
215
|
+
continue;
|
|
216
|
+
documents.push({
|
|
217
|
+
kind: doc.kind,
|
|
218
|
+
apiVersion: typeof doc.apiVersion === "string" ? doc.apiVersion : "",
|
|
219
|
+
name: doc.metadata.name,
|
|
220
|
+
namespace: typeof doc.metadata.namespace === "string" ? doc.metadata.namespace : null,
|
|
221
|
+
source: segment.text.match(SOURCE_LINE)?.[1] ?? null,
|
|
222
|
+
start: segment.byteStart,
|
|
223
|
+
length: segment.byteLength,
|
|
224
|
+
digest: `sha256:${createHash("sha256").update(segment.text, "utf8").digest("hex")}`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return { documents, docCount: segments.length };
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Persist one pinned render: canonical bytes under their `contentDigest`,
|
|
231
|
+
* the `RenderManifest` beside them, and an inputs-index entry so an
|
|
232
|
+
* identical future render is a cache hit.
|
|
233
|
+
*
|
|
234
|
+
* Content-addressed entries are immutable: when the digest directory
|
|
235
|
+
* already holds a manifest, both files are left as they are and the
|
|
236
|
+
* existing manifest is returned (`deduplicated: true`). The inputs index is
|
|
237
|
+
* still written — a new input combination can legitimately map to bytes the
|
|
238
|
+
* store already holds.
|
|
239
|
+
*
|
|
240
|
+
* Refuses an unpinned render (no capability profile) with the specific
|
|
241
|
+
* reason: no profile means the bytes depend on the local helm binary's
|
|
242
|
+
* defaulted capabilities, so they have no stable content identity to store
|
|
243
|
+
* under.
|
|
244
|
+
*/
|
|
245
|
+
export function persistHelmRender(input) {
|
|
246
|
+
if (!input.capabilityProfile) {
|
|
247
|
+
throw new Error(`refusing to persist unpinned render "${input.releaseName}" (${input.chart}): ` +
|
|
248
|
+
`no capability profile is declared, so the rendered bytes are a function of the local ` +
|
|
249
|
+
`helm binary's defaulted capabilities and have no stable content identity to store under. ` +
|
|
250
|
+
`Declare capabilityProfile on the HelmRender to pin it (#1235).`);
|
|
251
|
+
}
|
|
252
|
+
const profile = input.capabilityProfile;
|
|
253
|
+
const root = input.root ?? renderStoreRoot();
|
|
254
|
+
const canonical = canonicalizeRender(input.rendered);
|
|
255
|
+
const contentDigest = helmContentDigest(input.rendered);
|
|
256
|
+
const chartRef = input.repo ? `${input.repo}/${input.chart}` : input.chart;
|
|
257
|
+
const inputDigest = helmInputDigest({
|
|
258
|
+
chart: chartRef,
|
|
259
|
+
chartVersion: input.chartVersion,
|
|
260
|
+
values: input.values ?? {},
|
|
261
|
+
capabilityProfile: { kubeVersion: profile.kubeVersion, apiVersions: profile.apiVersions },
|
|
262
|
+
});
|
|
263
|
+
const dir = join(root, digestDirName(contentDigest));
|
|
264
|
+
const existing = loadRenderManifest(contentDigest, { root });
|
|
265
|
+
let manifest;
|
|
266
|
+
let deduplicated = false;
|
|
267
|
+
if (existing) {
|
|
268
|
+
// First writer wins — the entry is immutable, like a build archive's.
|
|
269
|
+
manifest = existing;
|
|
270
|
+
deduplicated = true;
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
const { documents, docCount } = indexRenderDocuments(canonical);
|
|
274
|
+
manifest = {
|
|
275
|
+
version: 1,
|
|
276
|
+
chart: input.chart,
|
|
277
|
+
chartVersion: input.chartVersion ?? null,
|
|
278
|
+
repo: input.repo ?? null,
|
|
279
|
+
releaseName: input.releaseName,
|
|
280
|
+
namespace: input.namespace ?? null,
|
|
281
|
+
valuesDigest: helmValuesDigest(input.values),
|
|
282
|
+
inputDigest,
|
|
283
|
+
capabilityProfile: {
|
|
284
|
+
cluster: profile.name,
|
|
285
|
+
kubeVersion: profile.kubeVersion,
|
|
286
|
+
apiVersions: profile.apiVersions ?? [],
|
|
287
|
+
},
|
|
288
|
+
contentDigest,
|
|
289
|
+
docCount,
|
|
290
|
+
documents,
|
|
291
|
+
renderedAt: (input.now ?? (() => new Date()))().toISOString(),
|
|
292
|
+
helmVersion: input.helmVersion ?? "unknown",
|
|
293
|
+
chantVersion: chantVersion(),
|
|
294
|
+
sourceRef: input.sourceRef ?? null,
|
|
295
|
+
coalescedValuesDigest: input.coalescedValuesDigest ?? null,
|
|
296
|
+
valueSources: input.valueSources ?? null,
|
|
297
|
+
};
|
|
298
|
+
mkdirSync(dir, { recursive: true });
|
|
299
|
+
writeFileSync(contentPath(root, contentDigest), canonical);
|
|
300
|
+
writeFileSync(manifestPath(root, contentDigest), JSON.stringify(manifest, null, 2) + "\n");
|
|
301
|
+
}
|
|
302
|
+
const key = renderCacheKey({
|
|
303
|
+
chart: chartRef,
|
|
304
|
+
chartVersion: input.chartVersion,
|
|
305
|
+
releaseName: input.releaseName,
|
|
306
|
+
namespace: input.namespace,
|
|
307
|
+
values: input.values,
|
|
308
|
+
capabilityProfile: profile,
|
|
309
|
+
});
|
|
310
|
+
const indexEntry = { version: 1, inputDigest, contentDigest };
|
|
311
|
+
mkdirSync(join(root, "inputs"), { recursive: true });
|
|
312
|
+
writeFileSync(inputsIndexPath(root, key), JSON.stringify(indexEntry, null, 2) + "\n");
|
|
313
|
+
return { manifest, dir, deduplicated };
|
|
314
|
+
}
|
|
315
|
+
// ── read path ─────────────────────────────────────────────────────────────
|
|
316
|
+
/** Load the `RenderManifest` stored under a content digest, or `undefined` when the store has no such entry. */
|
|
317
|
+
export function loadRenderManifest(contentDigest, opts) {
|
|
318
|
+
const root = opts?.root ?? renderStoreRoot();
|
|
319
|
+
const path = manifestPath(root, contentDigest);
|
|
320
|
+
if (!existsSync(path))
|
|
321
|
+
return undefined;
|
|
322
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
323
|
+
if (parsed.version !== 1) {
|
|
324
|
+
throw new Error(`render manifest ${path} has unsupported version ${String(parsed.version)}`);
|
|
325
|
+
}
|
|
326
|
+
return parsed;
|
|
327
|
+
}
|
|
328
|
+
/** Load the canonical rendered bytes stored under a content digest, or `undefined` when absent. */
|
|
329
|
+
export function loadRenderContent(contentDigest, opts) {
|
|
330
|
+
const root = opts?.root ?? renderStoreRoot();
|
|
331
|
+
const path = contentPath(root, contentDigest);
|
|
332
|
+
if (!existsSync(path))
|
|
333
|
+
return undefined;
|
|
334
|
+
return readFileSync(path, "utf8");
|
|
335
|
+
}
|
|
336
|
+
/** Resolve a full-inputs cache key through the inputs index, or `undefined` on a miss. */
|
|
337
|
+
export function findRenderByCacheKey(cacheKey, opts) {
|
|
338
|
+
const root = opts?.root ?? renderStoreRoot();
|
|
339
|
+
const path = inputsIndexPath(root, cacheKey);
|
|
340
|
+
if (!existsSync(path))
|
|
341
|
+
return undefined;
|
|
342
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
343
|
+
if (parsed.version !== 1)
|
|
344
|
+
return undefined;
|
|
345
|
+
return parsed;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Every manifest in the store, sorted by `renderedAt` then `contentDigest`
|
|
349
|
+
* for a stable listing. Entries whose manifest is missing or unreadable are
|
|
350
|
+
* skipped — a listing never fails on one corrupt entry. This is the query
|
|
351
|
+
* surface the diff issues (#1249/#1250) resolve digests against.
|
|
352
|
+
*/
|
|
353
|
+
export function listRenderManifests(opts) {
|
|
354
|
+
const root = opts?.root ?? renderStoreRoot();
|
|
355
|
+
if (!existsSync(root))
|
|
356
|
+
return [];
|
|
357
|
+
const manifests = [];
|
|
358
|
+
for (const entry of readdirSync(root)) {
|
|
359
|
+
if (!/^sha256-[0-9a-f]{64}$/.test(entry))
|
|
360
|
+
continue;
|
|
361
|
+
try {
|
|
362
|
+
const manifest = loadRenderManifest(entry.replace("-", ":"), { root });
|
|
363
|
+
if (manifest)
|
|
364
|
+
manifests.push(manifest);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// corrupt entry — skip it, never fail the listing
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
manifests.sort((a, b) => a.renderedAt === b.renderedAt
|
|
371
|
+
? a.contentDigest.localeCompare(b.contentDigest)
|
|
372
|
+
: a.renderedAt.localeCompare(b.renderedAt));
|
|
373
|
+
return manifests;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Resolve one document by kind/namespace/name to its exact bytes inside a
|
|
377
|
+
* stored render, via the manifest's document index. The slice is verified
|
|
378
|
+
* against the entry's per-document digest before being returned — corrupt
|
|
379
|
+
* content is an error, never silently wrong bytes. `undefined` means the
|
|
380
|
+
* store has no such render or the index has no such document.
|
|
381
|
+
*/
|
|
382
|
+
export function readRenderDocument(contentDigest, ref, opts) {
|
|
383
|
+
const root = opts?.root ?? renderStoreRoot();
|
|
384
|
+
const manifest = loadRenderManifest(contentDigest, { root });
|
|
385
|
+
if (!manifest)
|
|
386
|
+
return undefined;
|
|
387
|
+
const wantNs = ref.namespace ?? null;
|
|
388
|
+
const entry = manifest.documents.find((d) => d.kind === ref.kind && d.name === ref.name && d.namespace === wantNs);
|
|
389
|
+
if (!entry)
|
|
390
|
+
return undefined;
|
|
391
|
+
const content = loadRenderContent(contentDigest, { root });
|
|
392
|
+
if (content === undefined)
|
|
393
|
+
return undefined;
|
|
394
|
+
const bytes = Buffer.from(content, "utf8").subarray(entry.start, entry.start + entry.length);
|
|
395
|
+
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
396
|
+
if (digest !== entry.digest) {
|
|
397
|
+
throw new Error(`stored render ${contentDigest} is corrupt: document ${entry.kind}/${entry.name} ` +
|
|
398
|
+
`digests to ${digest}, manifest says ${entry.digest}`);
|
|
399
|
+
}
|
|
400
|
+
return { entry, text: bytes.toString("utf8") };
|
|
401
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structure-preserving render wrapper (#1239, epic #1228 Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* Turns a pinned render's document stream into chant-shaped routing groups
|
|
5
|
+
* WITHOUT restructuring the documents. Structure-preserving means the bytes
|
|
6
|
+
* that deploy are the bytes the render store recorded — this module only
|
|
7
|
+
* routes and labels; it never re-serializes, reorders lines, or edits a
|
|
8
|
+
* document. Each `RoutedDocument.text` is the exact document text from the
|
|
9
|
+
* input stream.
|
|
10
|
+
*
|
|
11
|
+
* Routing is by origin, not by kind. `helm template` stamps every document
|
|
12
|
+
* with `# Source: <chart>/<path>`, and the discriminator is whether that
|
|
13
|
+
* path contains a `crds/` **segment** (`(^|/)crds/` — `isCrdSource` from
|
|
14
|
+
* the #1234 classifier seeds):
|
|
15
|
+
*
|
|
16
|
+
* - A `crds/`-segment origin routes to the CRD group — the wrapper's
|
|
17
|
+
* `crds/`. Segment matching is load-bearing: a subchart's CRDs arrive as
|
|
18
|
+
* `charts/<child>/crds/<file>`, so a prefix rule catches none of them and
|
|
19
|
+
* would route every subchart CRD into `templates/`, making it deletable
|
|
20
|
+
* on uninstall (epic findings 4, 10 — the A/B test showed a flat wrap
|
|
21
|
+
* converts `helm uninstall` from safe to data-destroying).
|
|
22
|
+
* - A hook-annotated template document (`helm.sh/hook` in
|
|
23
|
+
* `metadata.annotations`) routes to its own group, annotations intact.
|
|
24
|
+
* Hooks survive pinning (epic finding 5); they are surfaced separately so
|
|
25
|
+
* the install path can account for them, never silently dropped.
|
|
26
|
+
* - Everything else routes to the main group — the wrapper's `templates/`.
|
|
27
|
+
* A template-rendered `CustomResourceDefinition` stays here, because the
|
|
28
|
+
* `crds/`-segment is the discriminator, not the kind: a chart that
|
|
29
|
+
* templates its CRDs opted into template lifecycle semantics (upgrades
|
|
30
|
+
* and deletes apply to them), and moving them to `crds/` would change
|
|
31
|
+
* that. The routing notes it with a warning so the choice is visible.
|
|
32
|
+
*
|
|
33
|
+
* Aliased dependencies emit duplicate CRDs (epic finding 11): the same
|
|
34
|
+
* dependency included twice under an alias renders its `crds/` once per
|
|
35
|
+
* instance — two documents, one distinct CRD. The stored artifact keeps
|
|
36
|
+
* both (canonicalization is order- and duplicate-preserving), but a wrapper
|
|
37
|
+
* chart's `crds/` holds each CRD once, so the routed CRD group deduplicates
|
|
38
|
+
* by `(group, kind)`: the first occurrence wins and a warning names both
|
|
39
|
+
* sources. A CRD document that cannot be identified (unparseable, or
|
|
40
|
+
* missing `spec.group` / `spec.names.kind`) is never deduplicated — the
|
|
41
|
+
* routing must not silently drop what it cannot identify.
|
|
42
|
+
*
|
|
43
|
+
* The wrapper inherits the source chart's name and version (epic
|
|
44
|
+
* Decisions), keeping `helm history` continuous across the pinned
|
|
45
|
+
* migration; `RoutedRender.chart` / `chartVersion` carry them.
|
|
46
|
+
*
|
|
47
|
+
* This is the seam the pinned-install path (#1242) consumes:
|
|
48
|
+
* `routeStoredRender(contentDigest)` loads a stored render's canonical
|
|
49
|
+
* bytes and manifest and routes them.
|
|
50
|
+
*/
|
|
51
|
+
import yaml from "js-yaml";
|
|
52
|
+
import { isCrdSource, sourcePath, splitDocuments } from "./pinnability/render-stream.js";
|
|
53
|
+
import { loadRenderContent, loadRenderManifest } from "./render-store.js";
|
|
54
|
+
const CRD_KIND = "CustomResourceDefinition";
|
|
55
|
+
/**
|
|
56
|
+
* Parse a document just far enough to label it. Parsing is for labels only —
|
|
57
|
+
* the routed text is always the original bytes. An unparseable document
|
|
58
|
+
* labels as `null`s and routes to the main group.
|
|
59
|
+
*/
|
|
60
|
+
function parseForLabels(doc) {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = yaml.load(doc);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { kind: null, apiVersion: null, name: null, hook: false, crdKey: null };
|
|
67
|
+
}
|
|
68
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
69
|
+
return { kind: null, apiVersion: null, name: null, hook: false, crdKey: null };
|
|
70
|
+
}
|
|
71
|
+
const obj = parsed;
|
|
72
|
+
const kind = typeof obj.kind === "string" ? obj.kind : null;
|
|
73
|
+
const annotations = obj.metadata?.annotations;
|
|
74
|
+
const hook = annotations !== null &&
|
|
75
|
+
typeof annotations === "object" &&
|
|
76
|
+
Object.prototype.hasOwnProperty.call(annotations, "helm.sh/hook");
|
|
77
|
+
let crdKey = null;
|
|
78
|
+
if (kind === CRD_KIND) {
|
|
79
|
+
const group = obj.spec?.group;
|
|
80
|
+
const namesKind = obj.spec?.names?.kind;
|
|
81
|
+
if (typeof group === "string" && typeof namesKind === "string") {
|
|
82
|
+
crdKey = `${group}/${namesKind}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
kind,
|
|
87
|
+
apiVersion: typeof obj.apiVersion === "string" ? obj.apiVersion : null,
|
|
88
|
+
name: typeof obj.metadata?.name === "string" ? obj.metadata.name : null,
|
|
89
|
+
hook,
|
|
90
|
+
crdKey,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Route a rendered stream (canonical or raw `helm template` output) into
|
|
95
|
+
* `RoutedRender` groups. Pure over the stream: no store access, no helm.
|
|
96
|
+
*/
|
|
97
|
+
export function routeRender(rendered, opts) {
|
|
98
|
+
const routed = {
|
|
99
|
+
chart: opts.chart,
|
|
100
|
+
chartVersion: opts.chartVersion ?? null,
|
|
101
|
+
crds: [],
|
|
102
|
+
hooks: [],
|
|
103
|
+
main: [],
|
|
104
|
+
warnings: [],
|
|
105
|
+
};
|
|
106
|
+
const firstCrdByKey = new Map();
|
|
107
|
+
for (const text of splitDocuments(rendered)) {
|
|
108
|
+
const source = sourcePath(text) ?? null;
|
|
109
|
+
const labels = parseForLabels(text);
|
|
110
|
+
const doc = {
|
|
111
|
+
text,
|
|
112
|
+
source,
|
|
113
|
+
kind: labels.kind,
|
|
114
|
+
apiVersion: labels.apiVersion,
|
|
115
|
+
name: labels.name,
|
|
116
|
+
};
|
|
117
|
+
if (source !== null && isCrdSource(source)) {
|
|
118
|
+
if (labels.crdKey !== null) {
|
|
119
|
+
const first = firstCrdByKey.get(labels.crdKey);
|
|
120
|
+
if (first) {
|
|
121
|
+
routed.warnings.push({
|
|
122
|
+
code: "duplicate-crd",
|
|
123
|
+
message: `duplicate CRD ${labels.crdKey}: keeping ${first.source ?? "<no source>"}, ` +
|
|
124
|
+
`dropping ${source} (an aliased dependency emits the same CRD once per instance; ` +
|
|
125
|
+
`a wrapper chart's crds/ holds it once, matching what helm installs)`,
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
firstCrdByKey.set(labels.crdKey, doc);
|
|
130
|
+
}
|
|
131
|
+
routed.crds.push(doc);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (labels.kind === CRD_KIND) {
|
|
135
|
+
// Template-rendered CRD: the crds/-segment is the discriminator, not
|
|
136
|
+
// the kind. It stays in its rendered group with template lifecycle
|
|
137
|
+
// semantics — noted, never moved.
|
|
138
|
+
routed.warnings.push({
|
|
139
|
+
code: "template-crd",
|
|
140
|
+
message: `CustomResourceDefinition ${labels.name ?? "<unnamed>"} is template-rendered ` +
|
|
141
|
+
`(source: ${source ?? "<none>"}), not shipped in crds/ — it stays in the main set ` +
|
|
142
|
+
`with template lifecycle semantics: helm upgrades and deletes will apply to it`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
(labels.hook ? routed.hooks : routed.main).push(doc);
|
|
146
|
+
}
|
|
147
|
+
return routed;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Route a stored pinned render by its `contentDigest`: loads the canonical
|
|
151
|
+
* bytes and the `RenderManifest` from the render store (#1238) and routes
|
|
152
|
+
* them, inheriting the manifest's chart name and version. This is the seam
|
|
153
|
+
* the pinned-install path (#1242) consumes. `undefined` when the store has
|
|
154
|
+
* no such entry.
|
|
155
|
+
*/
|
|
156
|
+
export function routeStoredRender(contentDigest, opts) {
|
|
157
|
+
const manifest = loadRenderManifest(contentDigest, opts);
|
|
158
|
+
if (!manifest)
|
|
159
|
+
return undefined;
|
|
160
|
+
const content = loadRenderContent(contentDigest, opts);
|
|
161
|
+
if (content === undefined)
|
|
162
|
+
return undefined;
|
|
163
|
+
return routeRender(content, { chart: manifest.chart, chartVersion: manifest.chartVersion });
|
|
164
|
+
}
|