@intentius/chant-lexicon-helm 0.124.30 → 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.
Files changed (111) hide show
  1. package/dist/archive-render.js +76 -0
  2. package/dist/codegen/docs-cli.js +3 -0
  3. package/dist/codegen/docs.js +113 -0
  4. package/dist/codegen/generate-cli.js +19 -0
  5. package/dist/codegen/generate.js +210 -0
  6. package/dist/codegen/naming.js +29 -0
  7. package/dist/codegen/package.js +44 -0
  8. package/dist/codegen/values-type.js +87 -0
  9. package/dist/commands.js +413 -0
  10. package/dist/components/builders.js +9 -0
  11. package/dist/components/capability-plugin.js +17 -0
  12. package/dist/components/helm-upgrade.js +109 -0
  13. package/dist/components/index.js +11 -0
  14. package/dist/composites/catalog.js +1119 -0
  15. package/dist/composites/helm-batch-job.js +148 -0
  16. package/dist/composites/helm-crd-lifecycle.js +145 -0
  17. package/dist/composites/helm-cron-job.js +130 -0
  18. package/dist/composites/helm-daemon-set.js +125 -0
  19. package/dist/composites/helm-external-secret.js +63 -0
  20. package/dist/composites/helm-library.js +24 -0
  21. package/dist/composites/helm-microservice.js +260 -0
  22. package/dist/composites/helm-monitored-service.js +188 -0
  23. package/dist/composites/helm-namespace-env.js +116 -0
  24. package/dist/composites/helm-secure-ingress.js +86 -0
  25. package/dist/composites/helm-stateful-service.js +160 -0
  26. package/dist/composites/helm-web-app.js +201 -0
  27. package/dist/composites/helm-worker.js +164 -0
  28. package/dist/composites/index.js +13 -0
  29. package/dist/config.js +161 -0
  30. package/dist/coverage.js +37 -0
  31. package/dist/deep-observe-hooks.js +19 -0
  32. package/dist/deep-observe.js +55 -0
  33. package/dist/describe-resources.js +119 -0
  34. package/dist/describe-stack-status.js +66 -0
  35. package/dist/detect.js +11 -0
  36. package/dist/generated/index.js +11 -0
  37. package/dist/helpers.js +84 -0
  38. package/dist/import/generator.js +292 -0
  39. package/dist/import/parser.js +141 -0
  40. package/dist/import/template-stripper.js +86 -0
  41. package/dist/index.js +55 -0
  42. package/dist/integrity.json +2 -2
  43. package/dist/intrinsics.js +482 -0
  44. package/dist/lint/audit-catalog.js +33 -0
  45. package/dist/lint/audit-lineage.js +53 -0
  46. package/dist/lint/post-synth/helm-helpers.js +60 -0
  47. package/dist/lint/post-synth/index.js +48 -0
  48. package/dist/lint/post-synth/whm005-no-empty-wrapper.js +47 -0
  49. package/dist/lint/post-synth/whm101.js +38 -0
  50. package/dist/lint/post-synth/whm102.js +26 -0
  51. package/dist/lint/post-synth/whm103.js +51 -0
  52. package/dist/lint/post-synth/whm104.js +29 -0
  53. package/dist/lint/post-synth/whm105.js +23 -0
  54. package/dist/lint/post-synth/whm201.js +29 -0
  55. package/dist/lint/post-synth/whm202.js +42 -0
  56. package/dist/lint/post-synth/whm203.js +32 -0
  57. package/dist/lint/post-synth/whm204.js +51 -0
  58. package/dist/lint/post-synth/whm301.js +31 -0
  59. package/dist/lint/post-synth/whm302.js +33 -0
  60. package/dist/lint/post-synth/whm401.js +52 -0
  61. package/dist/lint/post-synth/whm402.js +38 -0
  62. package/dist/lint/post-synth/whm403.js +38 -0
  63. package/dist/lint/post-synth/whm404.js +30 -0
  64. package/dist/lint/post-synth/whm405.js +47 -0
  65. package/dist/lint/post-synth/whm406.js +27 -0
  66. package/dist/lint/post-synth/whm407.js +72 -0
  67. package/dist/lint/post-synth/whm501.js +89 -0
  68. package/dist/lint/post-synth/whm502.js +85 -0
  69. package/dist/lint/post-synth/whm503.js +82 -0
  70. package/dist/lint/post-synth/whm504.js +47 -0
  71. package/dist/lint/rules/chart-metadata.js +53 -0
  72. package/dist/lint/rules/no-hardcoded-image.js +50 -0
  73. package/dist/lint/rules/values-no-helm-tpl.js +78 -0
  74. package/dist/lint/rules/values-no-secrets.js +65 -0
  75. package/dist/list-artifacts.js +134 -0
  76. package/dist/lsp/completions.d.ts.map +1 -1
  77. package/dist/lsp/completions.js +18 -0
  78. package/dist/lsp/hover.d.ts.map +1 -1
  79. package/dist/lsp/hover.js +40 -0
  80. package/dist/manifest.json +1 -1
  81. package/dist/op/activities/cluster-probe.js +116 -0
  82. package/dist/op/activities/helm.js +449 -0
  83. package/dist/op/activities/index.js +10 -0
  84. package/dist/op/builders.js +45 -0
  85. package/dist/package-cli.js +21 -0
  86. package/dist/pinnability/actions.js +207 -0
  87. package/dist/pinnability/classify.js +416 -0
  88. package/dist/pinnability/conditions.js +0 -0
  89. package/dist/pinnability/index.js +16 -0
  90. package/dist/pinnability/localize.js +518 -0
  91. package/dist/pinnability/render-stream.js +53 -0
  92. package/dist/pinnability/values.js +151 -0
  93. package/dist/plugin.js +458 -0
  94. package/dist/project-codegen.js +215 -0
  95. package/dist/release-observe.js +287 -0
  96. package/dist/render-diff.js +279 -0
  97. package/dist/render-digest.js +0 -0
  98. package/dist/render-records.js +29 -0
  99. package/dist/render-store.js +401 -0
  100. package/dist/render-wrapper.js +164 -0
  101. package/dist/render.js +394 -0
  102. package/dist/resources.js +109 -0
  103. package/dist/serializer.js +903 -0
  104. package/dist/validate-cli.js +17 -0
  105. package/dist/validate.js +30 -0
  106. package/dist/values-probe.js +504 -0
  107. package/dist/wrapper-chart.js +95 -0
  108. package/package.json +11 -11
  109. package/src/codegen/package.ts +2 -2
  110. package/src/lsp/completions.ts +2 -1
  111. 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
+ }