@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.
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,449 @@
1
+ import { exec } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { load } from "js-yaml";
8
+ import { helmInputDigest } from "../../render-digest.js";
9
+ import { loadRenderContent, loadRenderManifest, renderStoreRoot } from "../../render-store.js";
10
+ import { routeRender } from "../../render-wrapper.js";
11
+ import { materializeWrapperChart } from "../../wrapper-chart.js";
12
+ import { ClusterProbeError, compareCapabilityProfile, probeClusterCapabilities, } from "./cluster-probe.js";
13
+ import { maybeRecordAutoRelease, } from "@intentius/chant/components/auto-release";
14
+ const execAsync = promisify(exec);
15
+ /** Thrown by the pinned path (#1242) when `contentDigest` resolves to nothing in the render store. */
16
+ export class PinnedRenderNotFoundError extends Error {
17
+ constructor(contentDigest, root) {
18
+ super(`pinned install refused: render ${contentDigest} is not in the render store at ${root}. ` +
19
+ `Nothing was deployed. A pinned deploy installs recorded bytes; render and persist the chart ` +
20
+ `first (HelmRender with a capabilityProfile), or check the digest against \`listRenderManifests\`.`);
21
+ this.name = "PinnedRenderNotFoundError";
22
+ }
23
+ }
24
+ /** Thrown by the pinned path (#1242) when the stored bytes no longer hash to their digest — store corruption, caught before any mutation. */
25
+ export class PinnedRenderIntegrityError extends Error {
26
+ constructor(contentDigest, actual) {
27
+ super(`pinned install refused: stored bytes for ${contentDigest} hash to ${actual} — the render store ` +
28
+ `entry is corrupt. Nothing was deployed. Re-render to repopulate the store; a digest that does ` +
29
+ `not match its bytes must never reach a cluster.`);
30
+ this.name = "PinnedRenderIntegrityError";
31
+ }
32
+ }
33
+ /** Thrown by the pinned path (#1242) when the call mixes pinned and unpinned inputs, or targets a release name the bytes were not rendered for. */
34
+ export class PinnedInstallInputError extends Error {
35
+ constructor(message) {
36
+ super(message);
37
+ this.name = "PinnedInstallInputError";
38
+ }
39
+ }
40
+ /**
41
+ * Thrown by the pinned path (#1242) when the deploy's declared capability
42
+ * profile disagrees with the profile recorded in the stored render's
43
+ * manifest. Distinct from `CapabilityProfileMismatchError` (#1244, declared
44
+ * vs *live cluster*): this is declared vs *recorded*, checked first and
45
+ * entirely offline.
46
+ */
47
+ export class PinnedProfileMismatchError extends Error {
48
+ constructor(release, contentDigest, divergences) {
49
+ super(`pinned install refused: release "${release}" declares a capability profile that does not match ` +
50
+ `the one render ${contentDigest} was pinned against:\n` +
51
+ divergences.map((d) => ` - ${d}`).join("\n") +
52
+ `\nThe stored bytes are only accurate for the profile they were rendered for. Deploy them ` +
53
+ `against that profile, or re-render for this one. Nothing was deployed.`);
54
+ this.name = "PinnedProfileMismatchError";
55
+ }
56
+ }
57
+ /**
58
+ * Thrown before any helm mutation when the target cluster does not match the
59
+ * deploy's declared capability profile (#1244). The message names every
60
+ * divergence — declared vs live — so the refusal is specific, never a shrug.
61
+ */
62
+ export class CapabilityProfileMismatchError extends Error {
63
+ constructor(release, profileName, divergences) {
64
+ const profile = profileName ? ` "${profileName}"` : "";
65
+ super(`helm release "${release}": target cluster does not match the declared capability profile${profile}:\n` +
66
+ divergences.map((d) => ` - ${d}`).join("\n") +
67
+ `\nThe deploy's identity was pinned against the declared profile; deploying to a diverging cluster ` +
68
+ `breaks the per-cluster guarantee that the same rendered bytes reach every cluster sharing a profile. ` +
69
+ `Fix the profile or retarget the deploy — or pass overrideProfileAssertion: true for a deliberate ` +
70
+ `override, which is recorded in the release record. Nothing was deployed.`);
71
+ this.divergences = divergences;
72
+ this.name = "CapabilityProfileMismatchError";
73
+ }
74
+ }
75
+ function isRecord(value) {
76
+ return !!value && typeof value === "object" && !Array.isArray(value);
77
+ }
78
+ /**
79
+ * Merge one `--set key=value` entry into `values` at its dotted path,
80
+ * helm's precedence (set wins over the file). Helm's backslash escaping for
81
+ * literal dots in keys is not interpreted — the digest needs a deterministic
82
+ * shape, not a re-implementation of helm's parser, and an escaped key still
83
+ * digests deterministically, just under its split form.
84
+ */
85
+ function applySetEntry(values, path, value) {
86
+ const segments = path.split(".");
87
+ let node = values;
88
+ for (const segment of segments.slice(0, -1)) {
89
+ const next = node[segment];
90
+ if (isRecord(next)) {
91
+ node = next;
92
+ }
93
+ else {
94
+ const created = {};
95
+ node[segment] = created;
96
+ node = created;
97
+ }
98
+ }
99
+ node[segments[segments.length - 1]] = value;
100
+ }
101
+ /**
102
+ * The resolved values a deploy will hand to helm: the parsed values file
103
+ * (when given) with `--set` entries applied on top. A values file that does
104
+ * not parse to a mapping contributes nothing — helm itself rejects such a
105
+ * file at deploy time, so the digest never sees it succeed.
106
+ */
107
+ function resolveValues(args) {
108
+ const parsed = args.values ? load(readFileSync(args.values, "utf8")) : undefined;
109
+ const values = isRecord(parsed) ? parsed : {};
110
+ for (const [path, value] of Object.entries(args.set ?? {}))
111
+ applySetEntry(values, path, value);
112
+ return values;
113
+ }
114
+ /**
115
+ * The input-side identity of a helm deploy (#1243): a `sha256:` digest over
116
+ * the canonical JSON of `{ chart, chartVersion, values, capabilityProfile? }`.
117
+ * Canonical JSON (core's `canonicalJson`, RFC 8785 shape) sorts object keys,
118
+ * so the digest is stable across value key order; `apiVersions` is sorted
119
+ * for the same reason. The digest changes when any actual input changes —
120
+ * the chart, its version, a value, the declared profile.
121
+ *
122
+ * Delegates to `helmInputDigest` (#1237) — the same helper `HelmRender`
123
+ * records as a pinned render's `inputDigest` — so a deploy and a render of
124
+ * the same inputs share their identity by construction.
125
+ */
126
+ export function helmInstallInputDigest(args) {
127
+ if (!args.chart) {
128
+ throw new Error(`cannot compute an input digest for release "${args.name}": no chart reference. ` +
129
+ `A pinned deploy carries its digests in the stored RenderManifest instead.`);
130
+ }
131
+ return helmInputDigest({
132
+ chart: args.chart,
133
+ chartVersion: args.chartVersion,
134
+ values: resolveValues(args),
135
+ capabilityProfile: args.capabilityProfile,
136
+ });
137
+ }
138
+ /**
139
+ * Assert the target cluster matches the deploy's declared capability profile
140
+ * (#1244), before any helm mutation. Returns the assertion outcome, or
141
+ * `undefined` when there was nothing to assert (no profile, or a profile
142
+ * declaring no facts) — that path never touches kubectl, keeping the
143
+ * no-profile deploy exactly what it was.
144
+ *
145
+ * A mismatch throws `CapabilityProfileMismatchError` naming every divergence.
146
+ * An unprobeable cluster throws too — a deploy that cannot verify its
147
+ * guarantee must not silently proceed. `overrideProfileAssertion: true`
148
+ * turns both refusals into a warned, recorded override.
149
+ */
150
+ async function assertCapabilityProfile(args, signal) {
151
+ const profile = args.capabilityProfile;
152
+ if (!profile || (!profile.kubeVersion && !(profile.apiVersions?.length)))
153
+ return undefined;
154
+ let divergences;
155
+ try {
156
+ const live = await probeClusterCapabilities(signal);
157
+ divergences = compareCapabilityProfile(profile, live);
158
+ }
159
+ catch (err) {
160
+ const why = err instanceof Error ? err.message : String(err);
161
+ if (!args.overrideProfileAssertion) {
162
+ throw new ClusterProbeError(`helm release "${args.name}": the deploy declares capability profile` +
163
+ `${profile.name ? ` "${profile.name}"` : ""} but the target cluster's capabilities could not ` +
164
+ `be verified — ${why}. A deploy that cannot verify its declared profile does not proceed. ` +
165
+ `Nothing was deployed. Pass overrideProfileAssertion: true only for a deliberate override.`);
166
+ }
167
+ divergences = [`capability probe failed: ${why}`];
168
+ }
169
+ if (divergences.length === 0)
170
+ return { matched: true };
171
+ if (!args.overrideProfileAssertion) {
172
+ throw new CapabilityProfileMismatchError(args.name, profile.name, divergences);
173
+ }
174
+ console.error(`warning: helm release "${args.name}" deploying despite capability profile` +
175
+ `${profile.name ? ` "${profile.name}"` : ""} divergence (overrideProfileAssertion):\n` +
176
+ divergences.map((d) => ` - ${d}`).join("\n") +
177
+ `\nThe override is recorded in the release record.`);
178
+ return { matched: false, divergences, overridden: true };
179
+ }
180
+ /**
181
+ * Append the deploy's release record, best-effort (#1243). Called only after
182
+ * `helm upgrade --install` succeeded — a failed deploy writes nothing, by
183
+ * construction, because this function is never reached.
184
+ *
185
+ * Reuses `maybeRecordAutoRelease` — the same field resolution the
186
+ * `chant run --components` post-run step applies (actor from
187
+ * `GITHUB_ACTOR`/`GITLAB_USER_LOGIN`/`USER`, git sha from `HEAD`, timestamp
188
+ * taken at record time, append + push to the `chant/lifecycle` orphan
189
+ * branch). A field that cannot be resolved here (e.g. no git repo in a
190
+ * worker container) is never faked: the append is skipped and the skip is
191
+ * warned about, exactly as the convention's error path behaves.
192
+ */
193
+ async function recordHelmRelease(args, digest, digestError, profileOverride, inputDigest) {
194
+ if (args.recordRelease === false)
195
+ return { recorded: false, reason: "opted-out" };
196
+ if (!digest) {
197
+ return { recorded: false, reason: "error", error: digestError ?? "input digest unavailable" };
198
+ }
199
+ const runId = args.runId ?? process.env.GITHUB_RUN_ID ?? process.env.CI_PIPELINE_ID ?? `local-${Date.now()}`;
200
+ return maybeRecordAutoRelease({
201
+ component: args.component ?? args.name,
202
+ env: args.env ?? "local",
203
+ success: true,
204
+ digest,
205
+ runId,
206
+ profileOverride,
207
+ inputDigest,
208
+ });
209
+ }
210
+ /**
211
+ * Deploy a helm release and append a `ReleaseRecord` (#1243).
212
+ *
213
+ * Two paths, selected by `contentDigest`:
214
+ *
215
+ * - **Unpinned** (no `contentDigest`): `helm upgrade --install <name>
216
+ * <chart>`, exactly as before #1242 — helm renders at deploy time. The
217
+ * ledger record is keyed by the input digest.
218
+ * - **Pinned** (`contentDigest` set, #1242): the recorded render is loaded
219
+ * from the render store, its digest verified, and those exact bytes
220
+ * installed as a structure-preserving wrapper chart — no deploy-time
221
+ * render. The ledger record is keyed by the content digest and carries
222
+ * the stored render's input digest alongside it.
223
+ *
224
+ * A ledger-append failure after a successful deploy warns and never throws:
225
+ * the deploy already happened, so a ledger-write hiccup must not turn a
226
+ * finished deploy into a failed activity — the record is observability, not
227
+ * part of the deploy itself.
228
+ *
229
+ * Uses the longInfra profile: 20m timeout, three attempts backing off from 30s.
230
+ */
231
+ export async function helmInstall(args, signal) {
232
+ if (args.contentDigest)
233
+ return pinnedHelmInstall(args, args.contentDigest, signal);
234
+ if (!args.chart) {
235
+ throw new PinnedInstallInputError(`helm release "${args.name}": no chart reference and no contentDigest — pass \`chart\` for an ` +
236
+ `unpinned deploy, or \`contentDigest\` to install a recorded pinned render. Nothing was deployed.`);
237
+ }
238
+ // The digest is computed over the same bytes the deploy reads, before the
239
+ // deploy runs. A compute failure (unreadable values file, unrepresentable
240
+ // value) must not block the deploy — helm gives the authoritative error —
241
+ // so it is carried into the record outcome instead of thrown here.
242
+ let inputDigest;
243
+ let digestError;
244
+ try {
245
+ inputDigest = helmInstallInputDigest(args);
246
+ }
247
+ catch (err) {
248
+ digestError = `could not compute input digest: ${err instanceof Error ? err.message : String(err)}`;
249
+ }
250
+ // Deploy-time profile assertion (#1244): when a profile is declared, the
251
+ // live cluster must match it before helm mutates anything. A refusal (or
252
+ // an unprobeable cluster) throws here, so no helm command has run yet.
253
+ const profileAssertion = await assertCapabilityProfile(args, signal);
254
+ const parts = ["helm", "upgrade", "--install", "--wait", args.name, args.chart];
255
+ if (args.chartVersion)
256
+ parts.push("--version", args.chartVersion);
257
+ if (args.namespace)
258
+ parts.push("--namespace", args.namespace, "--create-namespace");
259
+ if (args.values)
260
+ parts.push("-f", args.values);
261
+ for (const [k, v] of Object.entries(args.set ?? {}))
262
+ parts.push("--set", `${k}=${v}`);
263
+ const { stdout, stderr } = await execAsync(parts.join(" "), { signal });
264
+ if (stdout)
265
+ console.log(stdout);
266
+ if (stderr)
267
+ console.error(stderr);
268
+ const profileOverride = profileAssertion && !profileAssertion.matched ? profileAssertion.divergences.join("; ") : undefined;
269
+ const release = await recordHelmRelease(args, inputDigest, digestError, profileOverride);
270
+ if (!release.recorded && release.reason !== "opted-out") {
271
+ const why = release.reason === "error"
272
+ ? release.error
273
+ : `${release.reason}${release.detail ? ` — ${release.detail}` : ""}`;
274
+ console.error(`warning: helm release "${args.name}" deployed, but no release record was appended (${why}). ` +
275
+ `The deploy succeeded; the ledger write is best-effort observability and never fails a finished deploy.`);
276
+ }
277
+ return {
278
+ inputDigest,
279
+ release,
280
+ ...(profileAssertion ? { profileAssertion } : {}),
281
+ releaseName: args.name,
282
+ namespace: args.namespace ?? null,
283
+ pinned: false,
284
+ };
285
+ }
286
+ /**
287
+ * The pinned install path (#1242): deploy a recorded render's exact bytes.
288
+ *
289
+ * Refusals, all before any helm mutation, each with its own error type:
290
+ * mixed pinned/unpinned inputs (`PinnedInstallInputError`), digest not in
291
+ * the store (`PinnedRenderNotFoundError`), stored bytes not hashing to
292
+ * their digest (`PinnedRenderIntegrityError`), a declared profile
293
+ * disagreeing with the recorded one (`PinnedProfileMismatchError`), a
294
+ * release name the bytes were not rendered for
295
+ * (`PinnedInstallInputError` — `.Release.Name` is baked into the bytes),
296
+ * and the #1244 live-cluster assertion, which on this path always runs
297
+ * against the *manifest's* profile — a stored render is pinned by
298
+ * construction, so the deploy always has a profile to verify.
299
+ *
300
+ * The install input is a structure-preserving wrapper chart materialized
301
+ * from the routed render (`routeRender` over the stored bytes — the #1239
302
+ * seam): CRD-origin documents in `crds/` (uninstall-safe, epic finding 4),
303
+ * everything else shipped verbatim through `.Files.Get` shims so no byte is
304
+ * re-templated. `helm history` / `helm rollback` keep working — the wrapper
305
+ * inherits the source chart's name and version, and the release is a normal
306
+ * helm release.
307
+ */
308
+ async function pinnedHelmInstall(args, contentDigest, signal) {
309
+ const renderInputs = ["chart", "chartVersion", "values"].filter((k) => args[k] !== undefined);
310
+ if (args.set && Object.keys(args.set).length > 0)
311
+ renderInputs.push("set");
312
+ if (renderInputs.length > 0) {
313
+ throw new PinnedInstallInputError(`pinned install refused: release "${args.name}" passes contentDigest together with ` +
314
+ `deploy-time render input(s) ${renderInputs.map((k) => `\`${k}\``).join(", ")}. A pinned deploy ` +
315
+ `installs the recorded bytes of ${contentDigest} — chart, values, set and chartVersion were ` +
316
+ `closed at render time and cannot be re-opened here. Drop them, or drop contentDigest for an ` +
317
+ `unpinned deploy. Nothing was deployed.`);
318
+ }
319
+ const storeOpts = args.renderStoreRoot ? { root: args.renderStoreRoot } : undefined;
320
+ const manifest = loadRenderManifest(contentDigest, storeOpts);
321
+ const content = manifest ? loadRenderContent(contentDigest, storeOpts) : undefined;
322
+ if (!manifest || content === undefined) {
323
+ throw new PinnedRenderNotFoundError(contentDigest, args.renderStoreRoot ?? renderStoreRoot());
324
+ }
325
+ // Verify before any mutation: the stored bytes are the digest's preimage
326
+ // (the store writes canonical bytes under their own hash), so a mismatch
327
+ // means the entry was corrupted or tampered with after it was written.
328
+ const actual = `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`;
329
+ if (actual !== contentDigest)
330
+ throw new PinnedRenderIntegrityError(contentDigest, actual);
331
+ if (args.name !== manifest.releaseName) {
332
+ throw new PinnedInstallInputError(`pinned install refused: render ${contentDigest} was rendered for release ` +
333
+ `"${manifest.releaseName}" but the deploy targets release "${args.name}". \`.Release.Name\` is ` +
334
+ `baked into the recorded bytes (labels, resource names), so installing them under another name ` +
335
+ `deploys bytes that claim a different release. Deploy under "${manifest.releaseName}", or ` +
336
+ `render for "${args.name}". Nothing was deployed.`);
337
+ }
338
+ assertDeclaredProfileMatchesManifest(args, contentDigest, manifest);
339
+ // The #1244 live-cluster assertion, against the profile the bytes were
340
+ // actually rendered for — the RenderManifest's, not the caller's (the two
341
+ // were just proven to agree where the caller declared one).
342
+ const profileAssertion = await assertCapabilityProfile({
343
+ ...args,
344
+ capabilityProfile: {
345
+ name: manifest.capabilityProfile.cluster,
346
+ kubeVersion: manifest.capabilityProfile.kubeVersion,
347
+ apiVersions: manifest.capabilityProfile.apiVersions,
348
+ },
349
+ }, signal);
350
+ const namespace = args.namespace ?? manifest.namespace ?? undefined;
351
+ if (args.namespace && manifest.namespace && args.namespace !== manifest.namespace) {
352
+ console.error(`warning: pinned release "${args.name}" deploys to namespace "${args.namespace}" but was ` +
353
+ `rendered for "${manifest.namespace}" — documents that bake in \`.Release.Namespace\` will ` +
354
+ `carry the rendered value.`);
355
+ }
356
+ const routed = routeRender(content, { chart: manifest.chart, chartVersion: manifest.chartVersion });
357
+ for (const warning of routed.warnings) {
358
+ console.error(`warning: pinned release "${args.name}": ${warning.message}`);
359
+ }
360
+ const wrapperDir = mkdtempSync(join(tmpdir(), "chant-helm-pinned-"));
361
+ try {
362
+ materializeWrapperChart(routed, wrapperDir);
363
+ const parts = ["helm", "upgrade", "--install", "--wait", args.name, wrapperDir];
364
+ if (namespace)
365
+ parts.push("--namespace", namespace, "--create-namespace");
366
+ const { stdout, stderr } = await execAsync(parts.join(" "), { signal });
367
+ if (stdout)
368
+ console.log(stdout);
369
+ if (stderr)
370
+ console.error(stderr);
371
+ }
372
+ finally {
373
+ rmSync(wrapperDir, { recursive: true, force: true });
374
+ }
375
+ // Revision from helm's own bookkeeping (`helm get metadata -o json`) —
376
+ // best-effort after a successful install, like the ledger append: a
377
+ // metadata read hiccup must not fail a finished deploy.
378
+ let revision;
379
+ let chartVersion = manifest.chartVersion;
380
+ try {
381
+ const metaParts = ["helm", "get", "metadata", args.name, "-o", "json"];
382
+ if (namespace)
383
+ metaParts.push("--namespace", namespace);
384
+ const { stdout } = await execAsync(metaParts.join(" "), { signal });
385
+ const meta = JSON.parse(stdout);
386
+ const parsed = Number(meta.revision);
387
+ if (Number.isFinite(parsed))
388
+ revision = parsed;
389
+ if (typeof meta.version === "string" && meta.version.length > 0)
390
+ chartVersion = meta.version;
391
+ }
392
+ catch (err) {
393
+ console.error(`warning: pinned release "${args.name}" deployed, but \`helm get metadata\` failed ` +
394
+ `(${err instanceof Error ? err.message : String(err)}); the result carries no revision.`);
395
+ }
396
+ const profileOverride = profileAssertion && !profileAssertion.matched ? profileAssertion.divergences.join("; ") : undefined;
397
+ // Pinned deploys record the content digest — what this cluster actually
398
+ // received — with the stored render's input digest alongside as the
399
+ // cross-environment join key (epic Decisions: profiles are per cluster,
400
+ // so bytes legitimately differ across environments; inputs do not).
401
+ const release = await recordHelmRelease(args, contentDigest, undefined, profileOverride, manifest.inputDigest);
402
+ if (!release.recorded && release.reason !== "opted-out") {
403
+ const why = release.reason === "error"
404
+ ? release.error
405
+ : `${release.reason}${release.detail ? ` — ${release.detail}` : ""}`;
406
+ console.error(`warning: helm release "${args.name}" deployed, but no release record was appended (${why}). ` +
407
+ `The deploy succeeded; the ledger write is best-effort observability and never fails a finished deploy.`);
408
+ }
409
+ return {
410
+ inputDigest: manifest.inputDigest,
411
+ release,
412
+ ...(profileAssertion ? { profileAssertion } : {}),
413
+ releaseName: args.name,
414
+ namespace: namespace ?? null,
415
+ pinned: true,
416
+ contentDigest,
417
+ revision,
418
+ chartVersion,
419
+ crdsApplied: routed.crds.length,
420
+ docsApplied: routed.main.length + routed.hooks.length,
421
+ hooksRun: routed.hooks.length,
422
+ };
423
+ }
424
+ /**
425
+ * Offline half of the pinned profile check: when the deploy itself declares
426
+ * a capability profile, its facts must agree with the profile recorded in
427
+ * the stored render's manifest — same kubeVersion, declared apiVersions all
428
+ * among the recorded set. A caller that declares nothing defers entirely to
429
+ * the manifest.
430
+ */
431
+ function assertDeclaredProfileMatchesManifest(args, contentDigest, manifest) {
432
+ const declared = args.capabilityProfile;
433
+ if (!declared)
434
+ return;
435
+ const recorded = manifest.capabilityProfile;
436
+ const divergences = [];
437
+ if (declared.kubeVersion && declared.kubeVersion !== recorded.kubeVersion) {
438
+ divergences.push(`kubeVersion: deploy declares ${declared.kubeVersion}, render was pinned against ${recorded.kubeVersion}`);
439
+ }
440
+ const recordedApis = new Set(recorded.apiVersions);
441
+ for (const apiVersion of declared.apiVersions ?? []) {
442
+ if (!recordedApis.has(apiVersion)) {
443
+ divergences.push(`apiVersion ${apiVersion}: declared by the deploy, absent from the render's recorded profile`);
444
+ }
445
+ }
446
+ if (divergences.length > 0) {
447
+ throw new PinnedProfileMismatchError(args.name, contentDigest, divergences);
448
+ }
449
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * helm Op activities — resolved by the core activity registry when a project's
3
+ * `chant.config.ts` lists the `helm` lexicon. `helmInstall` shells out to the
4
+ * helm CLI, retried under the step's profile; relocated from the hosting
5
+ * lexicon (#809) so helm's imperative activity lives with its product. The
6
+ * `helmInstall` step builder stays in core and reaches authors through
7
+ * `@intentius/chant/op` like the other core builders.
8
+ */
9
+ export { helmInstall, helmInstallInputDigest, CapabilityProfileMismatchError, PinnedRenderNotFoundError, PinnedRenderIntegrityError, PinnedInstallInputError, PinnedProfileMismatchError, } from "./helm.js";
10
+ export { probeClusterCapabilities, compareCapabilityProfile, ClusterProbeError } from "./cluster-probe.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Typed step-builder wrappers for this lexicon's `helmInstall` activity
3
+ * (chant #1288 Stage 2 — "regenerate the step builders as fully typed
4
+ * wrappers"). Core cannot import a lexicon's `*Args` types (lexicons depend
5
+ * on core, not the other way around), so a fully typed `helmInstall` /
6
+ * `helmInstallPinned` has to live beside {@link HelmInstallArgs} — here,
7
+ * next to `./activities/helm.ts`. `opts`'s type in each wrapper below IS
8
+ * `HelmInstallArgs` itself (via `Omit`/`WithStepRefs`) — never a
9
+ * hand-restated mirror — so the option surface Stage 1's zod contracts
10
+ * deliberately skipped (`capabilityProfile`, `set`, `deleteMode`-adjacent
11
+ * cross-field options) is authoring-time typed for free.
12
+ *
13
+ * Every field also accepts a {@link StepOutputRef} in its place
14
+ * ({@link WithStepRefs}, chant #1950).
15
+ *
16
+ * `core`'s own `helmInstall`/`helmInstallPinned` (in `@intentius/chant/op`)
17
+ * are UNCHANGED and produce byte-identical `ActivityStep` output for the same
18
+ * inputs — these are purely additive. Deliberately not swapped into core's
19
+ * barrel (see `lexicons/k8s/src/op/builders.ts`'s module doc for why: it
20
+ * would make core depend on this package at runtime, undoing the
21
+ * product-agnostic split #809 did). An author who wants the typed surface
22
+ * imports it from
23
+ * here — `@intentius/chant-lexicon-helm` — which a project using
24
+ * `helmInstall` already depends on.
25
+ */
26
+ import { activity, takeProfileAndId, } from "@intentius/chant/op";
27
+ /**
28
+ * Deploy a helm release — the fully typed twin of core's `helmInstall`.
29
+ * `opts` is {@link HelmInstallArgs} itself, minus the positional `name`/
30
+ * `chart`. Defaults to the `longInfra` profile.
31
+ */
32
+ export const helmInstall = (name, chart, opts) => {
33
+ const { args, profile, id } = takeProfileAndId(opts);
34
+ return activity("helmInstall", { name, chart, ...args }, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
35
+ };
36
+ /**
37
+ * Deploy a recorded pinned render by its `sha256:` content digest (#1242) —
38
+ * the fully typed twin of core's `helmInstallPinned`. `opts` is
39
+ * {@link HelmInstallArgs} itself, minus the positional `name`/
40
+ * `contentDigest`. Defaults to the `longInfra` profile.
41
+ */
42
+ export const helmInstallPinned = (name, contentDigest, opts) => {
43
+ const { args, profile, id } = takeProfileAndId(opts);
44
+ return activity("helmInstall", { name, contentDigest, ...args }, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
45
+ };
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * CLI entry point for Helm lexicon packaging.
4
+ */
5
+ import { packageLexicon } from "./codegen/package.js";
6
+ import { writeBundleSpec } from "@intentius/chant/codegen/package";
7
+ import { join, dirname } from "path";
8
+ import { fileURLToPath } from "url";
9
+ const pkgDir = dirname(fileURLToPath(import.meta.url));
10
+ async function main() {
11
+ const verbose = process.argv.includes("--verbose") || process.argv.includes("-v");
12
+ const force = process.argv.includes("--force") || process.argv.includes("-f");
13
+ const { spec, stats } = await packageLexicon({ verbose, force });
14
+ const distDir = join(pkgDir, "..", "dist");
15
+ writeBundleSpec(spec, distDir);
16
+ console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
17
+ }
18
+ main().catch((err) => {
19
+ console.error("Packaging failed:", err);
20
+ process.exit(1);
21
+ });