@intentius/chant-lexicon-helm 0.45.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/archive-render.d.ts +94 -0
- package/dist/archive-render.d.ts.map +1 -0
- package/dist/commands.d.ts +54 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/config.d.ts +135 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/deep-observe-hooks.d.ts +2 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +54 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +17 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/index.d.ts +18 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/whm503.d.ts +34 -0
- package/dist/lint/post-synth/whm503.d.ts.map +1 -0
- package/dist/lint/post-synth/whm504.d.ts +20 -0
- package/dist/lint/post-synth/whm504.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +2 -0
- package/dist/okf/rules/WHM503.md +15 -0
- package/dist/okf/rules/WHM504.md +11 -0
- package/dist/okf/types/Values.md +1 -0
- package/dist/op/activities/cluster-probe.d.ts +44 -0
- package/dist/op/activities/cluster-probe.d.ts.map +1 -0
- package/dist/op/activities/helm.d.ts +179 -5
- package/dist/op/activities/helm.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +4 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/pinnability/actions.d.ts +76 -0
- package/dist/pinnability/actions.d.ts.map +1 -0
- package/dist/pinnability/classify.d.ts +129 -0
- package/dist/pinnability/classify.d.ts.map +1 -0
- package/dist/pinnability/conditions.d.ts +109 -0
- package/dist/pinnability/conditions.d.ts.map +1 -0
- package/dist/pinnability/index.d.ts +17 -0
- package/dist/pinnability/index.d.ts.map +1 -0
- package/dist/pinnability/localize.d.ts +124 -0
- package/dist/pinnability/localize.d.ts.map +1 -0
- package/dist/pinnability/render-stream.d.ts +27 -0
- package/dist/pinnability/render-stream.d.ts.map +1 -0
- package/dist/pinnability/values.d.ts +69 -0
- package/dist/pinnability/values.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/release-observe.d.ts +109 -0
- package/dist/release-observe.d.ts.map +1 -0
- package/dist/render-diff.d.ts +192 -0
- package/dist/render-diff.d.ts.map +1 -0
- package/dist/render-digest.d.ts +135 -0
- package/dist/render-digest.d.ts.map +1 -0
- package/dist/render-store.d.ts +310 -0
- package/dist/render-store.d.ts.map +1 -0
- package/dist/render-wrapper.d.ts +140 -0
- package/dist/render-wrapper.d.ts.map +1 -0
- package/dist/render.d.ts +81 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/rules/whm503.ts +101 -0
- package/dist/rules/whm504.ts +52 -0
- package/dist/skills/chant-helm.md +7 -0
- package/dist/values-probe.d.ts +177 -0
- package/dist/values-probe.d.ts.map +1 -0
- package/dist/wrapper-chart.d.ts +61 -0
- package/dist/wrapper-chart.d.ts.map +1 -0
- package/package.json +10 -4
- package/src/archive-render.test.ts +201 -0
- package/src/archive-render.ts +119 -0
- package/src/commands.test.ts +487 -0
- package/src/commands.ts +445 -0
- package/src/config.test.ts +130 -0
- package/src/config.ts +220 -0
- package/src/deep-observe-hooks.ts +20 -0
- package/src/deep-observe.test.ts +165 -0
- package/src/deep-observe.ts +121 -0
- package/src/describe-resources.test.ts +255 -0
- package/src/describe-resources.ts +198 -0
- package/src/index.ts +98 -2
- package/src/lifecycle-integration.test.ts +289 -0
- package/src/lint/audit-catalog.ts +2 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/whm503.test.ts +134 -0
- package/src/lint/post-synth/whm503.ts +101 -0
- package/src/lint/post-synth/whm504.test.ts +105 -0
- package/src/lint/post-synth/whm504.ts +52 -0
- package/src/op/activities/cluster-probe.test.ts +66 -0
- package/src/op/activities/cluster-probe.ts +148 -0
- package/src/op/activities/helm.test.ts +824 -0
- package/src/op/activities/helm.ts +628 -5
- package/src/op/activities/index.ts +17 -2
- package/src/pinnability/actions.ts +263 -0
- package/src/pinnability/classify.ts +579 -0
- package/src/pinnability/conditions.ts +0 -0
- package/src/pinnability/index.ts +77 -0
- package/src/pinnability/localize.test.ts +234 -0
- package/src/pinnability/localize.ts +644 -0
- package/src/pinnability/pinnability.test.ts +560 -0
- package/src/pinnability/render-stream.ts +59 -0
- package/src/pinnability/values.ts +205 -0
- package/src/plugin.test.ts +3 -1
- package/src/plugin.ts +47 -0
- package/src/release-observe.ts +392 -0
- package/src/render-diff.test.ts +366 -0
- package/src/render-diff.ts +407 -0
- package/src/render-digest.test.ts +186 -0
- package/src/render-digest.ts +207 -0
- package/src/render-store.test.ts +412 -0
- package/src/render-store.ts +621 -0
- package/src/render-wrapper.test.ts +259 -0
- package/src/render-wrapper.ts +274 -0
- package/src/render.test.ts +383 -4
- package/src/render.ts +319 -8
- package/src/skills/chant-helm.md +7 -0
- package/src/values-probe.test.ts +400 -0
- package/src/values-probe.ts +627 -0
- package/src/wrapper-chart.test.ts +110 -0
- package/src/wrapper-chart.ts +124 -0
|
@@ -0,0 +1,621 @@
|
|
|
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
|
+
|
|
52
|
+
import { createHash } from "node:crypto";
|
|
53
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
54
|
+
import { createRequire } from "node:module";
|
|
55
|
+
import { homedir } from "node:os";
|
|
56
|
+
import { dirname, join } from "node:path";
|
|
57
|
+
import { fileURLToPath } from "node:url";
|
|
58
|
+
|
|
59
|
+
import { canonicalJson } from "@intentius/chant/effect-receipt";
|
|
60
|
+
import yaml from "js-yaml";
|
|
61
|
+
|
|
62
|
+
import type { HelmCapabilityProfile } from "./config";
|
|
63
|
+
import { canonicalizeRender, helmContentDigest, helmInputDigest } from "./render-digest";
|
|
64
|
+
import type { ValueOrigin } from "./values-probe";
|
|
65
|
+
|
|
66
|
+
// ── manifest shape ────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One document's entry in a render's document index: enough identity to ask
|
|
70
|
+
* "give me the ServiceAccount named X in namespace Y" and enough addressing
|
|
71
|
+
* to answer it without parsing the whole stream.
|
|
72
|
+
*/
|
|
73
|
+
export interface RenderDocumentEntry {
|
|
74
|
+
/** Kubernetes kind, e.g. `Deployment`. */
|
|
75
|
+
kind: string;
|
|
76
|
+
/** apiVersion as rendered, e.g. `apps/v1`. */
|
|
77
|
+
apiVersion: string;
|
|
78
|
+
/** `metadata.name`. */
|
|
79
|
+
name: string;
|
|
80
|
+
/** `metadata.namespace`, or `null` for cluster-scoped / namespace-less documents. */
|
|
81
|
+
namespace: string | null;
|
|
82
|
+
/** The helm-inserted `# Source:` origin path, when the document carries one. */
|
|
83
|
+
source: string | null;
|
|
84
|
+
/**
|
|
85
|
+
* Byte offset of this document's first byte inside `content.yaml`
|
|
86
|
+
* (pointing just past the `---` separator line, at the `# Source:` header
|
|
87
|
+
* when there is one).
|
|
88
|
+
*/
|
|
89
|
+
start: number;
|
|
90
|
+
/** Byte length of the document, up to the next separator (or end of file). */
|
|
91
|
+
length: number;
|
|
92
|
+
/** `sha256:` over exactly those bytes — lets a reader verify a slice without trusting offsets. */
|
|
93
|
+
digest: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The durable record of one pinned render — what `manifest.json` holds.
|
|
98
|
+
* Modeled on `BuildArchiveManifest`: versioned, content-addressed, and
|
|
99
|
+
* carrying both identity sides the epic's Decisions require — the
|
|
100
|
+
* input-side join key (`inputDigest`, with `valuesDigest` as its
|
|
101
|
+
* values-only component) for cross-environment questions, and
|
|
102
|
+
* `contentDigest` proving what this cluster's profile actually received.
|
|
103
|
+
*/
|
|
104
|
+
export interface RenderManifest {
|
|
105
|
+
/** Manifest schema version, so an incompatible future shape is detected before being misread. */
|
|
106
|
+
version: 1;
|
|
107
|
+
/** Chart name or local chart path, as the render declared it. */
|
|
108
|
+
chart: string;
|
|
109
|
+
/** Pinned chart version, or `null` for a local chart rendered without one. */
|
|
110
|
+
chartVersion: string | null;
|
|
111
|
+
/** Chart repo URL, or `null` for a local chart. */
|
|
112
|
+
repo: string | null;
|
|
113
|
+
/** Helm release name baked into the bytes (`.Release.Name`). */
|
|
114
|
+
releaseName: string;
|
|
115
|
+
/** Namespace the render targeted (`--namespace`), or `null`. */
|
|
116
|
+
namespace: string | null;
|
|
117
|
+
/**
|
|
118
|
+
* `sha256:` over the canonical JSON of the resolved values alone — the
|
|
119
|
+
* input-side join key the epic's ledger queries use together with
|
|
120
|
+
* `(chart, chartVersion)`.
|
|
121
|
+
*/
|
|
122
|
+
valuesDigest: string;
|
|
123
|
+
/**
|
|
124
|
+
* `sha256:` over the full declared-input set (chart reference, version,
|
|
125
|
+
* values, capability facts) — `helmInputDigest`, the same digest the
|
|
126
|
+
* release ledger records on deploy (#1243).
|
|
127
|
+
*/
|
|
128
|
+
inputDigest: string;
|
|
129
|
+
/** The capability profile the render was pinned against. `cluster` is the profile's declared name — profiles are per cluster. */
|
|
130
|
+
capabilityProfile: { cluster: string; kubeVersion: string; apiVersions: string[] };
|
|
131
|
+
/** `sha256:` over the canonical rendered bytes — the artifact identity, and this entry's storage key. */
|
|
132
|
+
contentDigest: string;
|
|
133
|
+
/** Number of documents in the canonical stream (including any the index could not identify). */
|
|
134
|
+
docCount: number;
|
|
135
|
+
/** Index of identifiable documents: kind/namespace/name -> byte span + per-document digest. */
|
|
136
|
+
documents: RenderDocumentEntry[];
|
|
137
|
+
/** ISO-8601 timestamp this manifest was written. Not part of any digest. */
|
|
138
|
+
renderedAt: string;
|
|
139
|
+
/**
|
|
140
|
+
* Version of the helm binary that produced the bytes. Load-bearing: the
|
|
141
|
+
* default kube version is a property of the binary (epic finding 1), so
|
|
142
|
+
* this field is what explains a digest mismatch between two machines.
|
|
143
|
+
*/
|
|
144
|
+
helmVersion: string;
|
|
145
|
+
/** chant version that wrote this manifest. */
|
|
146
|
+
chantVersion: string;
|
|
147
|
+
/** Source ref/commit the render was produced from, when the caller supplied one. */
|
|
148
|
+
sourceRef: string | null;
|
|
149
|
+
/**
|
|
150
|
+
* `sha256:` over the canonical JSON of the coalesced values the
|
|
151
|
+
* build-time probe (#1251) observed for every chart instance, keyed by
|
|
152
|
+
* scope path — `coalescedValuesDigest` from `./values-probe.ts`. `null`
|
|
153
|
+
* when the probe did not run for this render (a repo-fetched chart, whose
|
|
154
|
+
* source the probe needs on disk, or a probe failure — never fabricated).
|
|
155
|
+
*/
|
|
156
|
+
coalescedValuesDigest: string | null;
|
|
157
|
+
/**
|
|
158
|
+
* Each coalesced value's winning layer (#1252), dot-joined root-coordinate
|
|
159
|
+
* path to `ValueOrigin` — `computeValueSources` from `./values-probe.ts`.
|
|
160
|
+
* `null` under the same conditions as `coalescedValuesDigest`.
|
|
161
|
+
*/
|
|
162
|
+
valueSources: Record<string, ValueOrigin> | null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** What `inputs/<key>.json` holds: the digests a full-inputs cache key resolves to. */
|
|
166
|
+
export interface RenderInputsIndexEntry {
|
|
167
|
+
version: 1;
|
|
168
|
+
inputDigest: string;
|
|
169
|
+
contentDigest: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── store location ────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Root of the render store. `CHANT_HELM_RENDER_ROOT` overrides the default
|
|
176
|
+
* `~/.chant/helm-renders` — the same directory the legacy input-keyed cache
|
|
177
|
+
* lives in; the two coexist because legacy entries are bare 16-hex names
|
|
178
|
+
* and store entries are `sha256-` prefixed.
|
|
179
|
+
*/
|
|
180
|
+
export function renderStoreRoot(): string {
|
|
181
|
+
return process.env.CHANT_HELM_RENDER_ROOT ?? join(homedir(), ".chant", "helm-renders");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
185
|
+
|
|
186
|
+
/** `sha256:<hex>` -> the store directory name `sha256-<hex>` (colons are not filesystem-safe everywhere). */
|
|
187
|
+
function digestDirName(contentDigest: string): string {
|
|
188
|
+
if (!DIGEST_RE.test(contentDigest)) {
|
|
189
|
+
throw new Error(`not a content digest: ${JSON.stringify(contentDigest)} (expected "sha256:<64 hex>")`);
|
|
190
|
+
}
|
|
191
|
+
return contentDigest.replace(":", "-");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function contentPath(root: string, contentDigest: string): string {
|
|
195
|
+
return join(root, digestDirName(contentDigest), "content.yaml");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function manifestPath(root: string, contentDigest: string): string {
|
|
199
|
+
return join(root, digestDirName(contentDigest), "manifest.json");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── inputs index key ──────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/** The full set of inputs that determine a pinned render's bytes. */
|
|
205
|
+
export interface RenderCacheKeySource {
|
|
206
|
+
/** Chart reference — a local path, or `<repo-url>/<chart>` for repo-fetched charts (same convention `helmInputDigest` uses). */
|
|
207
|
+
chart: string;
|
|
208
|
+
chartVersion?: string;
|
|
209
|
+
/** Release name — baked into the bytes via `.Release.Name`, so a real cache input even though `inputDigest` excludes it. */
|
|
210
|
+
releaseName: string;
|
|
211
|
+
/** Namespace — baked in via `--namespace`, likewise excluded from `inputDigest`. */
|
|
212
|
+
namespace?: string;
|
|
213
|
+
values?: Record<string, unknown>;
|
|
214
|
+
capabilityProfile: HelmCapabilityProfile;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Cache key over *everything* that changes a pinned render's bytes — a
|
|
219
|
+
* strict superset of `helmInputDigest`'s inputs (adds release name,
|
|
220
|
+
* namespace, and the profile's declared name, mirroring the legacy cache
|
|
221
|
+
* key's fields). 64 hex characters, no prefix: this is a lookup key, not an
|
|
222
|
+
* artifact identity.
|
|
223
|
+
*/
|
|
224
|
+
export function renderCacheKey(source: RenderCacheKeySource): string {
|
|
225
|
+
const input = {
|
|
226
|
+
chart: source.chart,
|
|
227
|
+
chartVersion: source.chartVersion ?? null,
|
|
228
|
+
releaseName: source.releaseName,
|
|
229
|
+
namespace: source.namespace ?? null,
|
|
230
|
+
values: source.values ?? {},
|
|
231
|
+
capabilityProfile: {
|
|
232
|
+
name: source.capabilityProfile.name,
|
|
233
|
+
kubeVersion: source.capabilityProfile.kubeVersion,
|
|
234
|
+
apiVersions: [...(source.capabilityProfile.apiVersions ?? [])].sort(),
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
return createHash("sha256").update(canonicalJson(input), "utf8").digest("hex");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const CACHE_KEY_RE = /^[0-9a-f]{64}$/;
|
|
241
|
+
|
|
242
|
+
function inputsIndexPath(root: string, cacheKey: string): string {
|
|
243
|
+
if (!CACHE_KEY_RE.test(cacheKey)) {
|
|
244
|
+
throw new Error(`not a render cache key: ${JSON.stringify(cacheKey)} (expected 64 hex characters)`);
|
|
245
|
+
}
|
|
246
|
+
return join(root, "inputs", `${cacheKey}.json`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── values digest ─────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* `sha256:` over the canonical JSON of the resolved values alone — the
|
|
253
|
+
* `valuesDigest` half of the ledger's input-side join key
|
|
254
|
+
* `(chart, chartVersion, valuesDigest)`. Absent values digest as `{}`,
|
|
255
|
+
* matching `helmInputDigest`'s treatment.
|
|
256
|
+
*/
|
|
257
|
+
export function helmValuesDigest(values?: Record<string, unknown>): string {
|
|
258
|
+
return `sha256:${createHash("sha256").update(canonicalJson(values ?? {}), "utf8").digest("hex")}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── chant version ─────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
function readVersionAbove(startDir: string): string | undefined {
|
|
264
|
+
let dir = startDir;
|
|
265
|
+
for (let i = 0; i < 6; i++) {
|
|
266
|
+
const candidate = join(dir, "package.json");
|
|
267
|
+
if (existsSync(candidate)) {
|
|
268
|
+
try {
|
|
269
|
+
const version = (JSON.parse(readFileSync(candidate, "utf8")) as { version?: string }).version;
|
|
270
|
+
if (version) return version;
|
|
271
|
+
} catch {
|
|
272
|
+
// unreadable package.json — fall through to the caller's fallback
|
|
273
|
+
}
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
const parent = dirname(dir);
|
|
277
|
+
if (parent === dir) break;
|
|
278
|
+
dir = parent;
|
|
279
|
+
}
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The core package's version, read from its `package.json` next to wherever
|
|
285
|
+
* `@intentius/chant` resolves from here. Falls back to this lexicon's own
|
|
286
|
+
* version (the two release in lockstep), then `"unknown"` — a manifest is
|
|
287
|
+
* never blocked on version discovery.
|
|
288
|
+
*/
|
|
289
|
+
function chantVersion(): string {
|
|
290
|
+
try {
|
|
291
|
+
const req = createRequire(import.meta.url);
|
|
292
|
+
const fromCore = readVersionAbove(dirname(req.resolve("@intentius/chant")));
|
|
293
|
+
if (fromCore) return fromCore;
|
|
294
|
+
} catch {
|
|
295
|
+
// resolution failed (unusual layout) — use the lexicon's own version
|
|
296
|
+
}
|
|
297
|
+
return readVersionAbove(dirname(fileURLToPath(import.meta.url))) ?? "unknown";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── document index ────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
interface CanonicalSegment {
|
|
303
|
+
/** Segment text (from just past the `---` line to the next separator). */
|
|
304
|
+
text: string;
|
|
305
|
+
/** Byte offset of the segment inside the canonical stream. */
|
|
306
|
+
byteStart: number;
|
|
307
|
+
/** Byte length of the segment. */
|
|
308
|
+
byteLength: number;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Split a canonical stream (canonicalizeRender output) into its documents
|
|
313
|
+
* with byte-accurate offsets. Boundaries are column-0 `---` lines — the
|
|
314
|
+
* canonical form introduces every document with one, and no canonical
|
|
315
|
+
* document body can contain one (block-scalar content is always indented).
|
|
316
|
+
*/
|
|
317
|
+
function canonicalSegments(canonical: string): CanonicalSegment[] {
|
|
318
|
+
const marks: number[] = [];
|
|
319
|
+
const re = /^---$/gm;
|
|
320
|
+
let m: RegExpExecArray | null;
|
|
321
|
+
while ((m = re.exec(canonical)) !== null) marks.push(m.index);
|
|
322
|
+
|
|
323
|
+
const segments: CanonicalSegment[] = [];
|
|
324
|
+
let lastCharEnd = 0;
|
|
325
|
+
let lastByteEnd = 0;
|
|
326
|
+
for (let k = 0; k < marks.length; k++) {
|
|
327
|
+
const charStart = canonical.indexOf("\n", marks[k]) + 1; // just past "---\n"
|
|
328
|
+
const charEnd = k + 1 < marks.length ? marks[k + 1] : canonical.length;
|
|
329
|
+
const byteStart = lastByteEnd + Buffer.byteLength(canonical.slice(lastCharEnd, charStart), "utf8");
|
|
330
|
+
const text = canonical.slice(charStart, charEnd);
|
|
331
|
+
const byteLength = Buffer.byteLength(text, "utf8");
|
|
332
|
+
segments.push({ text, byteStart, byteLength });
|
|
333
|
+
lastCharEnd = charEnd;
|
|
334
|
+
lastByteEnd = byteStart + byteLength;
|
|
335
|
+
}
|
|
336
|
+
return segments;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const SOURCE_LINE = /^# Source: (\S+)$/m;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Build the document index over a canonical stream: for every identifiable
|
|
343
|
+
* document (it parses and carries `kind` + `metadata.name`), its
|
|
344
|
+
* kind/namespace/name identity, byte span, and per-document digest.
|
|
345
|
+
* Returns the index and the total document count — the count includes
|
|
346
|
+
* documents the index could not identify, so `docCount` is always the
|
|
347
|
+
* number of documents the artifact carries.
|
|
348
|
+
*/
|
|
349
|
+
export function indexRenderDocuments(canonical: string): { documents: RenderDocumentEntry[]; docCount: number } {
|
|
350
|
+
const segments = canonicalSegments(canonical);
|
|
351
|
+
const documents: RenderDocumentEntry[] = [];
|
|
352
|
+
for (const segment of segments) {
|
|
353
|
+
let parsed: unknown;
|
|
354
|
+
try {
|
|
355
|
+
parsed = yaml.load(segment.text);
|
|
356
|
+
} catch {
|
|
357
|
+
continue; // verbatim (unparseable) document — counted, not indexed
|
|
358
|
+
}
|
|
359
|
+
if (parsed === null || typeof parsed !== "object") continue;
|
|
360
|
+
const doc = parsed as { kind?: unknown; apiVersion?: unknown; metadata?: { name?: unknown; namespace?: unknown } };
|
|
361
|
+
if (typeof doc.kind !== "string" || typeof doc.metadata?.name !== "string") continue;
|
|
362
|
+
documents.push({
|
|
363
|
+
kind: doc.kind,
|
|
364
|
+
apiVersion: typeof doc.apiVersion === "string" ? doc.apiVersion : "",
|
|
365
|
+
name: doc.metadata.name,
|
|
366
|
+
namespace: typeof doc.metadata.namespace === "string" ? doc.metadata.namespace : null,
|
|
367
|
+
source: segment.text.match(SOURCE_LINE)?.[1] ?? null,
|
|
368
|
+
start: segment.byteStart,
|
|
369
|
+
length: segment.byteLength,
|
|
370
|
+
digest: `sha256:${createHash("sha256").update(segment.text, "utf8").digest("hex")}`,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
return { documents, docCount: segments.length };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── write path ────────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
export interface PersistHelmRenderInput {
|
|
379
|
+
/** The `helm template` output — raw or already canonical; it is canonicalized before storage either way. */
|
|
380
|
+
rendered: string;
|
|
381
|
+
/** Release name the render was produced under (`HelmRenderProps.name`). */
|
|
382
|
+
releaseName: string;
|
|
383
|
+
/** Chart name or local chart path, as declared. */
|
|
384
|
+
chart: string;
|
|
385
|
+
/** Chart repo URL for repo-fetched charts. */
|
|
386
|
+
repo?: string;
|
|
387
|
+
chartVersion?: string;
|
|
388
|
+
namespace?: string;
|
|
389
|
+
values?: Record<string, unknown>;
|
|
390
|
+
/**
|
|
391
|
+
* The capability profile the render was pinned against. Absent means the
|
|
392
|
+
* render was unpinned — persistence is refused, because an unpinned
|
|
393
|
+
* render's bytes are a function of the local helm binary's defaults and
|
|
394
|
+
* have no identity worth storing.
|
|
395
|
+
*/
|
|
396
|
+
capabilityProfile?: HelmCapabilityProfile;
|
|
397
|
+
/** Version of the helm binary that produced the bytes. Recorded as `"unknown"` when not supplied. */
|
|
398
|
+
helmVersion?: string;
|
|
399
|
+
/** Source ref/commit, when the caller has one. Never fabricated. */
|
|
400
|
+
sourceRef?: string;
|
|
401
|
+
/**
|
|
402
|
+
* The build-time coalesced-values probe's digest (#1251), when the caller
|
|
403
|
+
* ran one for this render. Absent (not merely `undefined`-passed) records
|
|
404
|
+
* `null` in the manifest — the probe needs the chart source on disk, so a
|
|
405
|
+
* repo-fetched render legitimately has none.
|
|
406
|
+
*/
|
|
407
|
+
coalescedValuesDigest?: string;
|
|
408
|
+
/** The probe's value-source attribution (#1252), alongside `coalescedValuesDigest`. */
|
|
409
|
+
valueSources?: Record<string, ValueOrigin>;
|
|
410
|
+
/** Clock override for tests. */
|
|
411
|
+
now?: () => Date;
|
|
412
|
+
/** Store root override; defaults to `renderStoreRoot()`. */
|
|
413
|
+
root?: string;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export interface PersistedHelmRender {
|
|
417
|
+
/** The manifest now on disk — the existing one when the content was already stored. */
|
|
418
|
+
manifest: RenderManifest;
|
|
419
|
+
/** Directory the render lives in: `<root>/sha256-<hex>`. */
|
|
420
|
+
dir: string;
|
|
421
|
+
/**
|
|
422
|
+
* True when this contentDigest was already in the store — the bytes and
|
|
423
|
+
* manifest were left untouched (only the inputs index was updated).
|
|
424
|
+
*/
|
|
425
|
+
deduplicated: boolean;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Persist one pinned render: canonical bytes under their `contentDigest`,
|
|
430
|
+
* the `RenderManifest` beside them, and an inputs-index entry so an
|
|
431
|
+
* identical future render is a cache hit.
|
|
432
|
+
*
|
|
433
|
+
* Content-addressed entries are immutable: when the digest directory
|
|
434
|
+
* already holds a manifest, both files are left as they are and the
|
|
435
|
+
* existing manifest is returned (`deduplicated: true`). The inputs index is
|
|
436
|
+
* still written — a new input combination can legitimately map to bytes the
|
|
437
|
+
* store already holds.
|
|
438
|
+
*
|
|
439
|
+
* Refuses an unpinned render (no capability profile) with the specific
|
|
440
|
+
* reason: no profile means the bytes depend on the local helm binary's
|
|
441
|
+
* defaulted capabilities, so they have no stable content identity to store
|
|
442
|
+
* under.
|
|
443
|
+
*/
|
|
444
|
+
export function persistHelmRender(input: PersistHelmRenderInput): PersistedHelmRender {
|
|
445
|
+
if (!input.capabilityProfile) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`refusing to persist unpinned render "${input.releaseName}" (${input.chart}): ` +
|
|
448
|
+
`no capability profile is declared, so the rendered bytes are a function of the local ` +
|
|
449
|
+
`helm binary's defaulted capabilities and have no stable content identity to store under. ` +
|
|
450
|
+
`Declare capabilityProfile on the HelmRender to pin it (#1235).`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const profile = input.capabilityProfile;
|
|
454
|
+
const root = input.root ?? renderStoreRoot();
|
|
455
|
+
|
|
456
|
+
const canonical = canonicalizeRender(input.rendered);
|
|
457
|
+
const contentDigest = helmContentDigest(input.rendered);
|
|
458
|
+
const chartRef = input.repo ? `${input.repo}/${input.chart}` : input.chart;
|
|
459
|
+
const inputDigest = helmInputDigest({
|
|
460
|
+
chart: chartRef,
|
|
461
|
+
chartVersion: input.chartVersion,
|
|
462
|
+
values: input.values ?? {},
|
|
463
|
+
capabilityProfile: { kubeVersion: profile.kubeVersion, apiVersions: profile.apiVersions },
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const dir = join(root, digestDirName(contentDigest));
|
|
467
|
+
const existing = loadRenderManifest(contentDigest, { root });
|
|
468
|
+
let manifest: RenderManifest;
|
|
469
|
+
let deduplicated = false;
|
|
470
|
+
if (existing) {
|
|
471
|
+
// First writer wins — the entry is immutable, like a build archive's.
|
|
472
|
+
manifest = existing;
|
|
473
|
+
deduplicated = true;
|
|
474
|
+
} else {
|
|
475
|
+
const { documents, docCount } = indexRenderDocuments(canonical);
|
|
476
|
+
manifest = {
|
|
477
|
+
version: 1,
|
|
478
|
+
chart: input.chart,
|
|
479
|
+
chartVersion: input.chartVersion ?? null,
|
|
480
|
+
repo: input.repo ?? null,
|
|
481
|
+
releaseName: input.releaseName,
|
|
482
|
+
namespace: input.namespace ?? null,
|
|
483
|
+
valuesDigest: helmValuesDigest(input.values),
|
|
484
|
+
inputDigest,
|
|
485
|
+
capabilityProfile: {
|
|
486
|
+
cluster: profile.name,
|
|
487
|
+
kubeVersion: profile.kubeVersion,
|
|
488
|
+
apiVersions: profile.apiVersions ?? [],
|
|
489
|
+
},
|
|
490
|
+
contentDigest,
|
|
491
|
+
docCount,
|
|
492
|
+
documents,
|
|
493
|
+
renderedAt: (input.now ?? (() => new Date()))().toISOString(),
|
|
494
|
+
helmVersion: input.helmVersion ?? "unknown",
|
|
495
|
+
chantVersion: chantVersion(),
|
|
496
|
+
sourceRef: input.sourceRef ?? null,
|
|
497
|
+
coalescedValuesDigest: input.coalescedValuesDigest ?? null,
|
|
498
|
+
valueSources: input.valueSources ?? null,
|
|
499
|
+
};
|
|
500
|
+
mkdirSync(dir, { recursive: true });
|
|
501
|
+
writeFileSync(contentPath(root, contentDigest), canonical);
|
|
502
|
+
writeFileSync(manifestPath(root, contentDigest), JSON.stringify(manifest, null, 2) + "\n");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const key = renderCacheKey({
|
|
506
|
+
chart: chartRef,
|
|
507
|
+
chartVersion: input.chartVersion,
|
|
508
|
+
releaseName: input.releaseName,
|
|
509
|
+
namespace: input.namespace,
|
|
510
|
+
values: input.values,
|
|
511
|
+
capabilityProfile: profile,
|
|
512
|
+
});
|
|
513
|
+
const indexEntry: RenderInputsIndexEntry = { version: 1, inputDigest, contentDigest };
|
|
514
|
+
mkdirSync(join(root, "inputs"), { recursive: true });
|
|
515
|
+
writeFileSync(inputsIndexPath(root, key), JSON.stringify(indexEntry, null, 2) + "\n");
|
|
516
|
+
|
|
517
|
+
return { manifest, dir, deduplicated };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── read path ─────────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
/** Load the `RenderManifest` stored under a content digest, or `undefined` when the store has no such entry. */
|
|
523
|
+
export function loadRenderManifest(contentDigest: string, opts?: { root?: string }): RenderManifest | undefined {
|
|
524
|
+
const root = opts?.root ?? renderStoreRoot();
|
|
525
|
+
const path = manifestPath(root, contentDigest);
|
|
526
|
+
if (!existsSync(path)) return undefined;
|
|
527
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as RenderManifest;
|
|
528
|
+
if (parsed.version !== 1) {
|
|
529
|
+
throw new Error(`render manifest ${path} has unsupported version ${String(parsed.version)}`);
|
|
530
|
+
}
|
|
531
|
+
return parsed;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Load the canonical rendered bytes stored under a content digest, or `undefined` when absent. */
|
|
535
|
+
export function loadRenderContent(contentDigest: string, opts?: { root?: string }): string | undefined {
|
|
536
|
+
const root = opts?.root ?? renderStoreRoot();
|
|
537
|
+
const path = contentPath(root, contentDigest);
|
|
538
|
+
if (!existsSync(path)) return undefined;
|
|
539
|
+
return readFileSync(path, "utf8");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Resolve a full-inputs cache key through the inputs index, or `undefined` on a miss. */
|
|
543
|
+
export function findRenderByCacheKey(
|
|
544
|
+
cacheKey: string,
|
|
545
|
+
opts?: { root?: string },
|
|
546
|
+
): RenderInputsIndexEntry | undefined {
|
|
547
|
+
const root = opts?.root ?? renderStoreRoot();
|
|
548
|
+
const path = inputsIndexPath(root, cacheKey);
|
|
549
|
+
if (!existsSync(path)) return undefined;
|
|
550
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as RenderInputsIndexEntry;
|
|
551
|
+
if (parsed.version !== 1) return undefined;
|
|
552
|
+
return parsed;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Every manifest in the store, sorted by `renderedAt` then `contentDigest`
|
|
557
|
+
* for a stable listing. Entries whose manifest is missing or unreadable are
|
|
558
|
+
* skipped — a listing never fails on one corrupt entry. This is the query
|
|
559
|
+
* surface the diff issues (#1249/#1250) resolve digests against.
|
|
560
|
+
*/
|
|
561
|
+
export function listRenderManifests(opts?: { root?: string }): RenderManifest[] {
|
|
562
|
+
const root = opts?.root ?? renderStoreRoot();
|
|
563
|
+
if (!existsSync(root)) return [];
|
|
564
|
+
const manifests: RenderManifest[] = [];
|
|
565
|
+
for (const entry of readdirSync(root)) {
|
|
566
|
+
if (!/^sha256-[0-9a-f]{64}$/.test(entry)) continue;
|
|
567
|
+
try {
|
|
568
|
+
const manifest = loadRenderManifest(entry.replace("-", ":"), { root });
|
|
569
|
+
if (manifest) manifests.push(manifest);
|
|
570
|
+
} catch {
|
|
571
|
+
// corrupt entry — skip it, never fail the listing
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
manifests.sort((a, b) =>
|
|
575
|
+
a.renderedAt === b.renderedAt
|
|
576
|
+
? a.contentDigest.localeCompare(b.contentDigest)
|
|
577
|
+
: a.renderedAt.localeCompare(b.renderedAt),
|
|
578
|
+
);
|
|
579
|
+
return manifests;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** How a caller names one document inside a stored render. */
|
|
583
|
+
export interface RenderDocumentRef {
|
|
584
|
+
kind: string;
|
|
585
|
+
name: string;
|
|
586
|
+
/** Omit (or pass `null`) to match a cluster-scoped / namespace-less document. */
|
|
587
|
+
namespace?: string | null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Resolve one document by kind/namespace/name to its exact bytes inside a
|
|
592
|
+
* stored render, via the manifest's document index. The slice is verified
|
|
593
|
+
* against the entry's per-document digest before being returned — corrupt
|
|
594
|
+
* content is an error, never silently wrong bytes. `undefined` means the
|
|
595
|
+
* store has no such render or the index has no such document.
|
|
596
|
+
*/
|
|
597
|
+
export function readRenderDocument(
|
|
598
|
+
contentDigest: string,
|
|
599
|
+
ref: RenderDocumentRef,
|
|
600
|
+
opts?: { root?: string },
|
|
601
|
+
): { entry: RenderDocumentEntry; text: string } | undefined {
|
|
602
|
+
const root = opts?.root ?? renderStoreRoot();
|
|
603
|
+
const manifest = loadRenderManifest(contentDigest, { root });
|
|
604
|
+
if (!manifest) return undefined;
|
|
605
|
+
const wantNs = ref.namespace ?? null;
|
|
606
|
+
const entry = manifest.documents.find(
|
|
607
|
+
(d) => d.kind === ref.kind && d.name === ref.name && d.namespace === wantNs,
|
|
608
|
+
);
|
|
609
|
+
if (!entry) return undefined;
|
|
610
|
+
const content = loadRenderContent(contentDigest, { root });
|
|
611
|
+
if (content === undefined) return undefined;
|
|
612
|
+
const bytes = Buffer.from(content, "utf8").subarray(entry.start, entry.start + entry.length);
|
|
613
|
+
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
614
|
+
if (digest !== entry.digest) {
|
|
615
|
+
throw new Error(
|
|
616
|
+
`stored render ${contentDigest} is corrupt: document ${entry.kind}/${entry.name} ` +
|
|
617
|
+
`digests to ${digest}, manifest says ${entry.digest}`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
return { entry, text: bytes.toString("utf8") };
|
|
621
|
+
}
|