| 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 | */ |
| 12 | import * as Config from "effect/Config"; |
| 13 | import * as Data from "effect/Data"; |
| 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as FileSystem from "effect/FileSystem"; |
| 16 | import * as Path from "effect/Path"; |
| 17 | import * as Stream from "effect/Stream"; |
| 18 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 19 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 20 | import * as YAML from "yaml"; |
| 21 | import type { KubernetesObjectDefinition } from "./objects.ts"; |
| 22 | |
| 23 | /** A Helm invocation or render failure (bad chart ref, template error, …). */ |
| 24 | export class HelmError extends Data.TaggedError("HelmError")<{ |
| 25 | readonly message: string; |
| 26 | readonly cause?: unknown; |
| 27 | }> {} |
| 28 | |
| 29 | const HelmBin = Config.String("HELM_BIN").pipe( |
| 30 | Effect.orElseSucceed(() => "helm"), |
| 31 | ); |
| 32 | |
| 33 | export 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 | */ |
| 65 | export 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 | */ |
| 154 | export 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 | */ |
| 163 | export 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 | }); |