1/**
2 * Internal Helm rendering for `Kubernetes.HelmChart`.
3 *
4 * Renders a chart to plain Kubernetes objects with the local `helm` CLI
5 * (`helm template` — pure local templating, no cluster connection, no
6 * in-cluster release records), so the rendered objects flow through the same
7 * server-side-apply machinery as `Kubernetes.Manifest` and the workload platforms.
8 * Mirrors the `Docker` service's local-CLI dependency: the `helm` binary
9 * must be installed on the deploying machine (`HELM_BIN` overrides the
10 * binary path).
11 */
12import * as Config from "effect/Config";
13import * as Data from "effect/Data";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Path from "effect/Path";
17import * as Stream from "effect/Stream";
18import * as ChildProcess from "effect/process/ChildProcess";
19import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
20import * as YAML from "yaml";
21import type { KubernetesObjectDefinition } from "./objects.ts";
22
23/** A Helm invocation or render failure (bad chart ref, template error, …). */
24export class HelmError extends Data.TaggedError("HelmError")<{
25 readonly message: string;
26 readonly cause?: unknown;
27}> {}
28
29const HelmBin = Config.String("HELM_BIN").pipe(
30 Effect.orElseSucceed(() => "helm"),
31);
32
33export interface RenderHelmChartOptions {
34 /**
35 * Chart reference: a repository chart name (with `repo`), an
36 * `oci://` reference, or a local chart directory path.
37 */
38 chart: string;
39 /** Classic chart repository URL (`--repo`). */
40 repo?: string | undefined;
41 /** Chart version (`--version`). */
42 version?: string | undefined;
43 /** Release name the chart's templates render with (`.Release.Name`). */
44 releaseName: string;
45 /** Namespace the chart renders into (`.Release.Namespace`). */
46 namespace: string;
47 /** Values passed to the chart (written to a temp values file). */
48 values?: Record<string, unknown> | undefined;
49 /**
50 * Render objects from the chart's `crds/` directory too
51 * (`--include-crds`).
52 * @default true
53 */
54 includeCrds?: boolean | undefined;
55}
56
57/**
58 * Render a chart with `helm template --no-hooks` and parse the
59 * multi-document YAML output into object definitions. Every rendered object
60 * must carry `apiVersion`, `kind`, and `metadata.name` (server-side apply
61 * needs a name; `generateName`-only objects are rejected with a clear
62 * error). Helm lifecycle hooks are excluded from the result (see
63 * {@link isHelmHook}).
64 */
65export const renderHelmChart = Effect.fn(function* (
66 options: RenderHelmChartOptions,
67) {
68 const fs = yield* FileSystem.FileSystem;
69 const path = yield* Path.Path;
70 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
71 const bin = yield* HelmBin;
72
73 const args = [
74 "template",
75 options.releaseName,
76 options.chart,
77 "--namespace",
78 options.namespace,
79 // Helm lifecycle hooks (`helm.sh/hook`) only make sense under Helm's own
80 // release events; HelmChart server-side applies the render and does not
81 // implement hook timing/weights/delete policies, so a hook (e.g. a
82 // pre-delete uninstall Job) must never enter the managed-object graph.
83 "--no-hooks",
84 ];
85 if (options.repo !== undefined) {
86 args.push("--repo", options.repo);
87 }
88 if (options.version !== undefined) {
89 args.push("--version", options.version);
90 }
91 if (options.includeCrds ?? true) {
92 args.push("--include-crds");
93 }
94 if (options.values !== undefined && Object.keys(options.values).length > 0) {
95 // JSON is valid YAML, so the literal values object round-trips through
96 // a temp values file without a YAML serializer.
97 const dir = yield* fs.makeTempDirectory({ prefix: "alchemy-helm-" });
98 const valuesFile = path.join(dir, "values.json");
99 yield* fs.writeFileString(valuesFile, JSON.stringify(options.values));
100 args.push("--values", valuesFile);
101 }
102
103 const result = yield* ChildProcess.make(bin, args, {
104 stdin: "ignore",
105 stdout: "pipe",
106 stderr: "pipe",
107 detached: false,
108 extendEnv: true,
109 }).pipe(
110 spawner.spawn,
111 Effect.flatMap((child) =>
112 Effect.all(
113 {
114 exitCode: child.exitCode,
115 stdout: child.stdout.pipe(Stream.decodeText, Stream.mkString),
116 stderr: child.stderr.pipe(Stream.decodeText, Stream.mkString),
117 },
118 { concurrency: "unbounded" },
119 ),
120 ),
121 // Scope the child to this render so it isn't tied to (and killed by)
122 // an enclosing scope that closes before helm exits.
123 Effect.scoped,
124 // A spawn failure (almost always ENOENT) means the helm CLI itself is
125 // missing — a machine-setup problem, not a resource error.
126 Effect.catchCause((cause) =>
127 Effect.die(
128 new Error(
129 `Failed to run '${bin}': ${String(cause)}. Kubernetes.HelmChart renders charts with the local helm CLI; if it isn't installed, install it (https://helm.sh/docs/intro/install/) or point HELM_BIN at the binary.`,
130 ),
131 ),
132 ),
133 );
134
135 if (result.exitCode !== 0) {
136 return yield* Effect.fail(
137 new HelmError({
138 message:
139 `helm ${args.join(" ")} exited with code ${String(result.exitCode)}: ` +
140 result.stderr.trim(),
141 }),
142 );
143 }
144
145 return yield* parseRenderedManifests(options.chart, result.stdout);
146});
147
148/**
149 * Whether a rendered object is a Helm lifecycle hook (`helm.sh/hook`
150 * annotation). Hooks are executed by Helm at release events (pre-install,
151 * pre-delete, test, …); HelmChart has no release and no hook lifecycle, so
152 * they are filtered out rather than applied as ordinary objects.
153 */
154export const isHelmHook = (object: KubernetesObjectDefinition): boolean =>
155 typeof object.metadata.annotations?.["helm.sh/hook"] === "string";
156
157/**
158 * Parse `helm template` output (multi-document YAML) into definitions.
159 * Hook-annotated objects are dropped — `renderHelmChart` already passes
160 * `--no-hooks`, but the parser enforces the invariant independently of the
161 * helm CLI's behavior.
162 */
163export const parseRenderedManifests = (
164 chart: string,
165 rendered: string,
166): Effect.Effect<Array<KubernetesObjectDefinition>, HelmError> =>
167 Effect.gen(function* () {
168 // Helm 4 writes OCI pull metadata to stdout before the rendered YAML.
169 // Strip only that exact leading preamble; chart output remains subject to
170 // the same strict Kubernetes object validation below.
171 const manifests = chart.startsWith("oci://")
172 ? rendered.replace(
173 /^Pulled: [^\r\n]+\r?\nDigest: [^\r\n]+\r?\n(?=---(?:\r?\n|$))/,
174 "",
175 )
176 : rendered;
177 const documents = yield* Effect.try({
178 try: () => YAML.parseAllDocuments(manifests),
179 catch: (cause) =>
180 new HelmError({
181 message: `Failed to parse rendered manifests from chart '${chart}'`,
182 cause,
183 }),
184 });
185
186 const objects: Array<KubernetesObjectDefinition> = [];
187 for (const document of documents) {
188 const value = document.toJS() as unknown;
189 // helm renders empty documents for templates that produce no output
190 // (conditionals, whitespace) — skip them.
191 if (value === null || value === undefined) continue;
192 if (typeof value !== "object" || Array.isArray(value)) {
193 return yield* Effect.fail(
194 new HelmError({
195 message: `Chart '${chart}' rendered a non-object YAML document: ${JSON.stringify(value)}`,
196 }),
197 );
198 }
199 const object = value as Partial<KubernetesObjectDefinition>;
200 if (
201 typeof object.apiVersion !== "string" ||
202 typeof object.kind !== "string"
203 ) {
204 return yield* Effect.fail(
205 new HelmError({
206 message: `Chart '${chart}' rendered an object without apiVersion/kind: ${JSON.stringify(value).slice(0, 200)}`,
207 }),
208 );
209 }
210 if (typeof object.metadata?.name !== "string") {
211 return yield* Effect.fail(
212 new HelmError({
213 message: `Chart '${chart}' rendered a ${object.apiVersion}/${object.kind} without metadata.name — server-side apply requires a concrete name (generateName is not supported)`,
214 }),
215 );
216 }
217 const definition = object as KubernetesObjectDefinition;
218 if (isHelmHook(definition)) continue;
219 objects.push(definition);
220 }
221 return objects;
222 });