1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import { isResolved } from "../Diff.ts";
4import { hashDirectory } from "../Command/Memo.ts";
5import { createPhysicalName } from "../PhysicalName.ts";
6import * as Provider from "../Provider.ts";
7import { Resource } from "../Resource.ts";
8import { sha256Object } from "../Util/sha256.ts";
9import {
10 toConnection,
11 type ClusterLike,
12 type Connection,
13} from "./Connection.ts";
14import type { ClusterTransport } from "./ClusterAdapter.ts";
15import {
16 connectCluster,
17 deleteObjects,
18 reconcileObjects,
19 resolveKindSpec,
20} from "./internal/client.ts";
21import { renderHelmChart } from "./internal/helm.ts";
22import type {
23 KubernetesObjectDefinition,
24 KubernetesObjectRef,
25} from "./internal/objects.ts";
26import {
27 connectionIdentity,
28 connectionOfOutput,
29 tryConnectionOf,
30} from "./internal/workload.ts";
31import type { Providers } from "./Providers.ts";
32
33export interface HelmChartProps {
34 /**
35 * Target cluster the chart's objects are applied onto. Pass a managed
36 * cluster resource (e.g. `AWS.EKS.Cluster`), a
37 * `Kubernetes.KubeConfig(...)`, or a raw `Kubernetes.Connection`.
38 */
39 cluster: ClusterLike;
40 /**
41 * Chart reference: a repository chart name (used with {@link repo}, e.g.
42 * `"ingress-nginx"`), an `oci://` registry reference, or a local chart
43 * directory path.
44 */
45 chart: string;
46 /**
47 * Classic chart repository URL, e.g.
48 * `https://kubernetes.github.io/ingress-nginx`. Not needed for `oci://`
49 * references or local chart directories.
50 */
51 repo?: string;
52 /**
53 * Chart version to render. Pin this for repository/OCI charts — without
54 * it, helm resolves the latest version at each deploy and the rendered
55 * objects can drift between runs. Ignored for local chart directories.
56 */
57 version?: string;
58 /**
59 * Helm release name the chart's templates render with
60 * (`.Release.Name`). If omitted, a deterministic name is derived from
61 * the stack, stage, and logical ID.
62 */
63 releaseName?: string;
64 /**
65 * Kubernetes namespace the objects are rendered into
66 * (`.Release.Namespace`; also injected as `metadata.namespace` on
67 * namespaced objects the chart leaves namespace-less). The namespace
68 * must already exist unless {@link createNamespace} is set.
69 * @default "default"
70 */
71 namespace?: string;
72 /**
73 * Values passed to the chart — a literal object, the same shape as a
74 * `values.yaml` file.
75 */
76 values?: Record<string, unknown>;
77 /**
78 * Render objects from the chart's `crds/` directory too.
79 * @default true
80 */
81 includeCrds?: boolean;
82 /**
83 * Create (and own) the target Namespace object alongside the chart's
84 * objects.
85 * @default false
86 */
87 createNamespace?: boolean;
88}
89
90export interface HelmChart extends Resource<
91 "Kubernetes.HelmChart",
92 HelmChartProps,
93 {
94 /** The connection of the cluster the chart is applied to. */
95 connection: Connection;
96 /** The Helm release name the chart rendered with. */
97 releaseName: string;
98 /** The namespace the chart rendered into. */
99 namespace: string;
100 /** The chart reference that was rendered. */
101 chart: string;
102 /** The pinned chart version, when one was declared. */
103 version: string | undefined;
104 /** References to the applied Kubernetes objects. */
105 objects: KubernetesObjectRef[];
106 /** Content hash of the chart inputs (and local chart files). */
107 code: {
108 hash: string;
109 };
110 },
111 {},
112 Providers
113> {}
114
115/**
116 * Renders a Helm chart and converges its objects onto any Kubernetes
117 * cluster via server-side apply.
118 *
119 * The chart is rendered locally with the `helm` CLI (`helm template` —
120 * install helm on the deploying machine, like Docker for image builds);
121 * the rendered objects then flow through the same apply machinery as
122 * `Kubernetes.Manifest`: Alchemy owns the object lifecycle, re-applies the
123 * full render whenever the chart's inputs change, prunes objects that drop
124 * out of the render, and deletes everything on destroy. There is no
125 * in-cluster Helm release record; the target `cluster` can be a managed
126 * cluster resource (e.g. `AWS.EKS.Cluster`) or any cluster your kubeconfig
127 * can reach.
128 *
129 * Helm lifecycle hooks (`helm.sh/hook`-annotated objects: install/upgrade/
130 * delete hooks, tests) are neither executed nor applied — the chart is
131 * rendered with `--no-hooks`, so they never enter the managed-object graph.
132 * Charts that depend on hooks for correctness should be installed with Helm
133 * directly.
134 * ### Installing a Chart
135 * **Example:** Chart from a repository
136 * ```typescript
137 * const ingress = yield* Kubernetes.HelmChart("IngressNginx", {
138 * cluster,
139 * chart: "ingress-nginx",
140 * repo: "https://kubernetes.github.io/ingress-nginx",
141 * version: "4.11.2",
142 * namespace: "ingress-nginx",
143 * createNamespace: true,
144 * values: {
145 * controller: { replicaCount: 2 },
146 * },
147 * });
148 * ```
149 *
150 * **Example:** OCI chart
151 * ```typescript
152 * const karpenter = yield* Kubernetes.HelmChart("Karpenter", {
153 * cluster,
154 * chart: "oci://public.ecr.aws/karpenter/karpenter",
155 * version: "1.0.6",
156 * namespace: "kube-system",
157 * });
158 * ```
159 *
160 * **Example:** Local chart directory
161 * ```typescript
162 * const app = yield* Kubernetes.HelmChart("App", {
163 * cluster,
164 * chart: "./charts/app",
165 * values: { image: { tag: "v1.2.3" } },
166 * });
167 * ```
168 *
169 * @resource
170 * @product Helm
171 */
172export const HelmChart = Resource<HelmChart>("Kubernetes.HelmChart", {
173 aliases: ["AWS.EKS.HelmChart"],
174});
175
176/**
177 * Hash the chart identity: every render input, plus the chart directory's
178 * content hash when `chart` is a local path (so editing a local chart is
179 * visible to `diff` even though no prop changed).
180 */
181const computeChartHash = Effect.fn(function* (
182 news: HelmChartProps,
183 releaseName: string,
184) {
185 const fs = yield* FileSystem.FileSystem;
186 const isLocalDir =
187 !news.chart.startsWith("oci://") && (yield* fs.exists(news.chart));
188 return yield* sha256Object({
189 chart: news.chart,
190 repo: news.repo,
191 version: news.version,
192 releaseName,
193 namespace: news.namespace ?? "default",
194 values: news.values,
195 includeCrds: news.includeCrds ?? true,
196 createNamespace: news.createNamespace ?? false,
197 localChart: isLocalDir
198 ? yield* hashDirectory({ cwd: news.chart })
199 : undefined,
200 });
201});
202
203const resolveReleaseName = (
204 id: string,
205 news: HelmChartProps,
206 output: HelmChart["Attributes"] | undefined,
207) =>
208 Effect.suspend(() => {
209 if (news.releaseName) return Effect.succeed(news.releaseName);
210 if (output?.releaseName) return Effect.succeed(output.releaseName);
211 // Helm rejects release names longer than 53 characters.
212 return createPhysicalName({ id, maxLength: 53, lowercase: true });
213 });
214
215/**
216 * Charts commonly omit `metadata.namespace` and rely on the install
217 * namespace. Server-side apply addresses objects by explicit path, so
218 * inject the target namespace into namespaced objects that omit it
219 * (resolving each kind's scope; cluster-scoped objects pass through).
220 */
221const injectNamespace = Effect.fn(function* (
222 transport: ClusterTransport,
223 objects: ReadonlyArray<KubernetesObjectDefinition>,
224 namespace: string,
225) {
226 return yield* Effect.forEach(objects, (object) =>
227 Effect.gen(function* () {
228 if (object.metadata.namespace !== undefined) return object;
229 const spec = yield* resolveKindSpec({ transport, input: object });
230 if (spec.scope === "Cluster") return object;
231 return {
232 ...object,
233 metadata: { ...object.metadata, namespace },
234 } satisfies KubernetesObjectDefinition;
235 }),
236 );
237});
238
239export const HelmChartProvider = () =>
240 Provider.effect(
241 HelmChart,
242 Effect.gen(function* () {
243 return {
244 stables: ["connection", "releaseName", "namespace"],
245 // In-cluster objects have no cloud-side enumeration that attributes
246 // them to alchemy; refresh happens per-instance through `read`.
247 list: () => Effect.succeed([] as HelmChart["Attributes"][]),
248 diff: Effect.fn(function* ({ id, olds, news, output }) {
249 if (!isResolved(news)) return;
250 const releaseName = yield* resolveReleaseName(id, news, output);
251 const oldCluster = connectionIdentity(tryConnectionOf(olds?.cluster));
252 const newCluster = connectionIdentity(tryConnectionOf(news.cluster));
253 // Object identity is the (cluster, release, namespace) triple —
254 // moving any of it means a different set of objects.
255 if (
256 output &&
257 ((oldCluster !== undefined &&
258 newCluster !== undefined &&
259 oldCluster !== newCluster) ||
260 output.releaseName !== releaseName ||
261 output.namespace !== (news.namespace ?? "default"))
262 ) {
263 return { action: "replace" } as const;
264 }
265 if (output) {
266 const hash = yield* computeChartHash(news, releaseName);
267 if (hash !== output.code.hash) {
268 return { action: "update" } as const;
269 }
270 }
271 }),
272 reconcile: Effect.fn(function* ({ id, news, output, session }) {
273 const releaseName = yield* resolveReleaseName(id, news, output);
274 const namespace = news.namespace ?? "default";
275 const connection = toConnection(news.cluster);
276 const transport = yield* connectCluster(connection);
277 const hash = yield* computeChartHash(news, releaseName);
278
279 yield* session.note(
280 `Rendering Helm chart ${news.chart}${news.version ? `@${news.version}` : ""}...`,
281 );
282 const rendered = yield* renderHelmChart({
283 chart: news.chart,
284 repo: news.repo,
285 version: news.version,
286 releaseName,
287 namespace,
288 values: news.values,
289 includeCrds: news.includeCrds,
290 });
291 const placed = yield* injectNamespace(transport, rendered, namespace);
292 const desiredObjects: Array<KubernetesObjectDefinition> =
293 news.createNamespace && namespace !== "default"
294 ? [
295 {
296 apiVersion: "v1",
297 kind: "Namespace",
298 metadata: { name: namespace },
299 },
300 ...placed,
301 ]
302 : [...placed];
303
304 yield* session.note(
305 `Applying ${String(desiredObjects.length)} objects from ${news.chart}...`,
306 );
307 const objects = yield* reconcileObjects({
308 transport,
309 previousObjects: output?.objects ?? [],
310 desiredObjects,
311 });
312
313 return {
314 connection,
315 releaseName,
316 namespace,
317 chart: news.chart,
318 version: news.version,
319 objects: [...objects],
320 code: { hash },
321 };
322 }),
323 read: Effect.fn(function* ({ output }) {
324 if (!output) return undefined;
325 const connection = connectionOfOutput(output);
326 if (!connection) return undefined;
327 // The objects live in-cluster; if the cluster itself is gone, so
328 // are they.
329 const transport = yield* connectCluster(connection).pipe(
330 Effect.catchTag("Kubernetes.ClusterNotFoundError", () =>
331 Effect.succeed(undefined),
332 ),
333 );
334 if (!transport) return undefined;
335 return output;
336 }),
337 delete: Effect.fn(function* ({ output }) {
338 const connection = connectionOfOutput(output);
339 if (!connection) return;
340 const transport = yield* connectCluster(connection).pipe(
341 // Cluster already destroyed — its objects went with it.
342 Effect.catchTag("Kubernetes.ClusterNotFoundError", () =>
343 Effect.succeed(undefined),
344 ),
345 );
346 if (!transport) return;
347 yield* deleteObjects({ transport, objects: output.objects });
348 }),
349 };
350 }),
351 );