1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../Diff.ts";
5import { createPhysicalName } from "../PhysicalName.ts";
6import { Platform, type Main, type PlatformProps } from "../Platform.ts";
7import * as Provider from "../Provider.ts";
8import { Resource } from "../Resource.ts";
9import {
10 createHostRuntimeContext,
11 type HostRuntimeContext,
12 type ServerHost as ServerHostService,
13} from "../Server/Process.ts";
14import { Stack } from "../Stack.ts";
15import { createInternalTags } from "../Tags.ts";
16import {
17 findClusterAdapter,
18 type ClusterTransport,
19 type IdentityState,
20 type RegistryState,
21 type WorkloadBindingContract,
22 type WorkloadIdentityOptions,
23 type WorkloadImageSource,
24 type WorkloadServices,
25} from "./ClusterAdapter.ts";
26import {
27 toConnection,
28 type ClusterLike,
29 type Connection,
30} from "./Connection.ts";
31import {
32 connectCluster,
33 deleteObjects,
34 readObject,
35 reconcileObjects,
36 KubernetesApiError,
37} from "./internal/client.ts";
38import {
39 toKubernetesObjectRef,
40 type KubernetesObjectDefinition,
41 type KubernetesObjectRef,
42} from "./internal/objects.ts";
43import {
44 collectBindingEnv,
45 connectionIdentity,
46 connectionOfOutput,
47 deepMerge,
48 imagePlatformOf,
49 makeServerBootstrap,
50 resolveWorkloadImage,
51 tryConnectionOf,
52 workloadImageHash,
53} from "./internal/workload.ts";
54import { makeConnectionRegistry } from "./internal/registry.ts";
55import type { Providers } from "./Providers.ts";
56
57export const isDeployment = (value: any): value is Deployment => {
58 return (
59 typeof value === "object" &&
60 value !== null &&
61 "Type" in value &&
62 value.Type === "Kubernetes.Deployment"
63 );
64};
65
66/**
67 * The image-source props shared by the workload platforms. Exactly one of
68 * `main` (bundle an inline Effect program), `context`/`dockerfile` (build
69 * the user's own Dockerfile), or `image` (a pre-built registry reference).
70 */
71export interface DeploymentPropsBase extends PlatformProps {
72 /**
73 * Target cluster the workload is deployed onto: a cluster resource
74 * (`Kubernetes.LocalCluster`, `AWS.EKS.Cluster`), a
75 * `Kubernetes.KubeConfig(...)`, or a raw `Kubernetes.Connection`. The
76 * connection supplies authentication and the registry `main` / `context`
77 * images are pushed to.
78 */
79 cluster: ClusterLike;
80 /**
81 * Base name for the generated Deployment / Service / ServiceAccount. If
82 * omitted, a deterministic name is derived from the stack, stage, and
83 * logical id.
84 */
85 name?: string;
86 /**
87 * Kubernetes namespace to deploy into. The namespace must already exist.
88 * @default "default"
89 */
90 namespace?: string;
91 /**
92 * HTTP port exposed by the container and the Service.
93 * @default 3000
94 */
95 port?: number;
96 /**
97 * Replica count for the Deployment.
98 * @default 1
99 */
100 replicas?: number;
101 /**
102 * Kubernetes Service type. `LoadBalancer` provisions the platform's
103 * cloud load balancer and exposes its hostname as the Deployment `url`.
104 * @default "LoadBalancer"
105 */
106 serviceType?: "ClusterIP" | "NodePort" | "LoadBalancer";
107 /**
108 * Annotations applied to the Service (e.g. load-balancer scheme /
109 * target-type hints).
110 */
111 serviceAnnotations?: Record<string, string>;
112 /**
113 * Container entrypoint override (Kubernetes `command`). Mostly useful with
114 * `image` / `context` sources.
115 */
116 command?: string[];
117 /**
118 * Container arguments (Kubernetes `args`).
119 */
120 args?: string[];
121 /**
122 * Container CPU/memory requests + limits (Kubernetes resource quantities).
123 */
124 resources?: {
125 requests?: { cpu?: string; memory?: string };
126 limits?: { cpu?: string; memory?: string };
127 };
128 /**
129 * Additional environment variables for the container.
130 */
131 env?: Record<string, any>;
132 /**
133 * Container image build architecture.
134 * @default the connection's `architecture`, else "amd64"
135 */
136 architecture?: "amd64" | "arm64";
137 /**
138 * Deep-partial Kubernetes Pod template merged into the synthesized
139 * template (objects merge recursively; arrays and primitives replace) —
140 * a literal object in the shape of `PodTemplateSpec`, e.g.
141 * `{ spec: { tolerations: [...], nodeSelector: {...} } }`.
142 */
143 podTemplate?: Record<string, unknown>;
144 /**
145 * Cloud-specific workload-identity options, consumed by the cluster
146 * platform's identity adapter (on EKS: `{ managedPolicyArns: [...] }`
147 * attaches extra managed policies to the generated pod-identity role; on
148 * GKE: `{ gcpServiceAccount }` runs the pods as an existing Google
149 * service account instead of the ServiceAccount's own principal).
150 */
151 identity?: WorkloadIdentityOptions;
152 /**
153 * Deployment / pod labels, merged over the generated
154 * `app.kubernetes.io/name` label. Also used as the Service selector.
155 */
156 labels?: Record<string, string>;
157 /**
158 * User-defined tags applied to workload-owned cloud resources (identity
159 * roles, image repositories).
160 */
161 tags?: Record<string, string>;
162}
163
164/** Bundle an inline Effect program (`main`) into a generated image. */
165export interface BundledDeploymentProps extends DeploymentPropsBase {
166 /**
167 * Module entrypoint for the bundled program. This should typically be
168 * `import.meta.url` from an inline Effect program.
169 */
170 main: string;
171 /**
172 * Environment image used as the generated Dockerfile's `FROM`; must be
173 * able to run the bun runtime.
174 * @default "oven/bun:1"
175 */
176 image?: string;
177 /** Named export to load from `main`. @default "default" */
178 handler?: string;
179 /** Bundler configuration for the entrypoint. */
180 build?: WorkloadImageSource["build"];
181 /** Environment Dockerfile (path or inline content). */
182 dockerfile?: WorkloadImageSource["dockerfile"];
183 /** Build context for a path {@link dockerfile} environment. */
184 context?: string;
185}
186
187/** Build the user's own Dockerfile (`context` + optional `dockerfile` path). */
188export interface DockerfileDeploymentProps extends DeploymentPropsBase {
189 /** Docker build context directory. */
190 context?: string;
191 /**
192 * Path to the Dockerfile (relative to the cwd), or inline content.
193 * @default `${context}/Dockerfile`
194 */
195 dockerfile?: WorkloadImageSource["dockerfile"];
196}
197
198/** Run a pre-built registry image. */
199export interface ImageDeploymentProps extends DeploymentPropsBase {
200 /**
201 * A pre-built image reference, e.g. `nginx:1.27`, pulled by the nodes as
202 * written. EKS clusters mirror it into ECR first.
203 */
204 image: string;
205}
206
207export type DeploymentProps =
208 | BundledDeploymentProps
209 | DockerfileDeploymentProps
210 | ImageDeploymentProps;
211
212export interface Deployment extends Resource<
213 "Kubernetes.Deployment",
214 DeploymentProps,
215 {
216 /** The connection of the cluster the deployment runs on. */
217 connection: Connection;
218 /** The Kubernetes namespace the deployment's objects live in. */
219 namespace: string;
220 /** The name of the Kubernetes Deployment. */
221 deploymentName: string;
222 /** The name of the Kubernetes Service exposing the deployment. */
223 serviceName: string;
224 /** The name of the service account the pods run as. */
225 serviceAccountName: string;
226 /** The container port the server listens on. */
227 port: number;
228 /** The URI of the container image the deployment runs. */
229 imageUri: string;
230 /**
231 * Workload-identity state provisioned by the cluster platform's
232 * adapter (on EKS: the pod-identity role + association; on GKE: the
233 * Workload Identity principal and the IAM grants applied to it).
234 */
235 identity: IdentityState | undefined;
236 /**
237 * Image-registry state provisioned by the cluster platform's adapter
238 * (on EKS: the ECR repository).
239 */
240 registry: RegistryState | undefined;
241 /**
242 * The LoadBalancer URL (`http://<hostname>[:port]` — the cloud load
243 * balancer listens on the Service `port`, so a non-80 port is part of
244 * the URL) when `serviceType` is `LoadBalancer`, otherwise
245 * `undefined`. May be `undefined` immediately after a create while
246 * the cloud load balancer is still provisioning.
247 */
248 url: string | undefined;
249 /** References to the Kubernetes objects created for the deployment. */
250 kubernetesObjects: KubernetesObjectRef[];
251 /** The content hash of the container image source. */
252 code: {
253 hash: string;
254 };
255 },
256 WorkloadBindingContract,
257 Providers
258> {}
259
260export type DeploymentServices = ServerHostService | WorkloadServices;
261
262export type DeploymentShape = Main<DeploymentServices>;
263
264export interface DeploymentRuntimeContext extends HostRuntimeContext {
265 readonly Type: "Kubernetes.Deployment";
266}
267
268/**
269 * A replicated Kubernetes server on any cluster, running a container image
270 * or an HTTP server written in Effect.
271 *
272 * `Deployment` provisions a Kubernetes `Deployment` + `Service` (+
273 * `ServiceAccount`) via server-side apply, with the container image from
274 * exactly one of three sources flat on props: `main` (bundle an inline
275 * Effect program), `context` (build your own Dockerfile), or `image` (a
276 * pre-built registry reference). `main` and `context` images are built on
277 * the deploying machine and pushed to the connection's registry
278 * (`Kubernetes.LocalCluster` includes one; EKS uses ECR; GKE uses
279 * Artifact Registry). Bindings attach environment variables on any
280 * cluster; cloud credential grants need a platform with workload identity.
281 * On `AWS.EKS.Cluster` targets it accepts the `{ env, policyStatements }`
282 * contract of `AWS.Lambda.Function`: IAM policy statements go to a
283 * generated pod-identity role. On `GCP.Container.Cluster` targets it
284 * accepts the `{ env, iam }` contract of `GCP.Run.Service`: IAM roles are
285 * granted to the Kubernetes ServiceAccount's Workload Identity Federation
286 * principal.
287 * ### Creating a Deployment
288 * **Example:** Run an image on a local cluster
289 * ```typescript
290 * const cluster = yield* Kubernetes.LocalCluster("Cluster", {
291 * name: "alchemy",
292 * });
293 *
294 * const web = yield* Kubernetes.Deployment("Web", {
295 * cluster,
296 * image: "ghcr.io/stefanprodan/podinfo:6.15.0",
297 * replicas: 2,
298 * port: 9898,
299 * serviceType: "ClusterIP",
300 * });
301 * web.serviceName; // K8s-native attrs
302 * ```
303 *
304 * **Example:** Any cluster via kubeconfig
305 * ```typescript
306 * const cluster = Kubernetes.KubeConfig({ context: "prod-east" });
307 *
308 * const nginx = yield* Kubernetes.Deployment("Nginx", {
309 * cluster,
310 * image: "nginx:1.27",
311 * replicas: 3,
312 * port: 80,
313 * serviceType: "LoadBalancer",
314 * });
315 * nginx.url; // the load balancer's address
316 * ```
317 *
318 * **Example:** Build your own Dockerfile
319 * ```typescript
320 * const legacy = yield* Kubernetes.Deployment("LegacyApp", {
321 * cluster,
322 * context: "./legacy",
323 * replicas: 2,
324 * port: 8080,
325 * });
326 * ```
327 *
328 * ### Effect Servers
329 * **Example:** Inline Effect server
330 * ```typescript
331 * const api = yield* Kubernetes.Deployment(
332 * "Api",
333 * { cluster, main: import.meta.url, port: 3000, replicas: 2 },
334 * Effect.gen(function* () {
335 * return {
336 * fetch: Effect.succeed(HttpServerResponse.text("ok")),
337 * };
338 * }),
339 * );
340 * ```
341 *
342 * **Example:** Push to your own registry
343 * ```typescript
344 * const cluster = Kubernetes.KubeConfig({
345 * context: "prod-east",
346 * registry: { server: "ghcr.io/acme" },
347 * });
348 *
349 * const api = yield* Kubernetes.Deployment(
350 * "Api",
351 * { cluster, main: import.meta.url, port: 3000 },
352 * Effect.gen(function* () {
353 * return {
354 * fetch: Effect.succeed(HttpServerResponse.text("ok")),
355 * };
356 * }),
357 * );
358 * ```
359 *
360 * **Example:** DynamoDB binding on EKS
361 * ```typescript
362 * const cluster = yield* AWS.EKS.Cluster("Cluster", { compute: "auto" });
363 *
364 * const api = yield* Kubernetes.Deployment(
365 * "Api",
366 * { cluster, main: import.meta.url, port: 3000 },
367 * Effect.gen(function* () {
368 * const putItem = yield* AWS.DynamoDB.PutItem(table);
369 * return {
370 * fetch: Effect.gen(function* () {
371 * yield* putItem({ Item: { id: { S: "1" } } });
372 * return HttpServerResponse.text("ok");
373 * }),
374 * };
375 * }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)),
376 * );
377 * ```
378 *
379 * **Example:** Tagged Effect server
380 * ```typescript
381 * export class Api extends Kubernetes.Deployment<Api, {
382 * health: () => Effect.Effect<string>;
383 * }>()("Api") {}
384 *
385 * export default Api.make(
386 * { cluster, main: import.meta.url, port: 3000 },
387 * Effect.gen(function* () {
388 * return {
389 * fetch: Effect.gen(function* () {
390 * return HttpServerResponse.text("ok");
391 * }),
392 * health: () => Effect.succeed("ok"),
393 * };
394 * }),
395 * );
396 * ```
397 *
398 * ### Bundling & Tree-shaking
399 * `main` is bundled with rolldown at deploy time. Unused code is
400 * tree-shaken. `effect`, alchemy, and `@distilled.cloud` are marked
401 * pure so unused parts prune more aggressively. Your app is not
402 * marked pure.
403 *
404 * **Example:** Mark additional packages as pure
405 * Only list packages with no top-level side effects.
406 * ```typescript
407 * {
408 * main: import.meta.url,
409 * build: {
410 * pure: { packages: ["my-lib", "@my-scope/*"] },
411 * },
412 * }
413 * ```
414 *
415 * **Example:** Turn it off
416 * ```typescript
417 * {
418 * main: import.meta.url,
419 * build: { pure: false },
420 * }
421 * ```
422 *
423 * ### Kubernetes Escape Hatch
424 * **Example:** Tune the synthesized pod template
425 * ```typescript
426 * const tuned = yield* Kubernetes.Deployment("Api", {
427 * cluster,
428 * main: import.meta.url,
429 * port: 3000,
430 * podTemplate: {
431 * spec: {
432 * tolerations: [{ key: "gpu", operator: "Exists" }],
433 * nodeSelector: { pool: "arm" },
434 * },
435 * },
436 * });
437 * ```
438 *
439 * @resource
440 * @product Workloads
441 */
442export const Deployment: Platform<
443 Deployment,
444 DeploymentServices,
445 DeploymentShape,
446 DeploymentRuntimeContext
447> = Platform("Kubernetes.Deployment", {
448 aliases: ["AWS.EKS.Deployment"],
449 createRuntimeContext: createHostRuntimeContext("Kubernetes.Deployment") as (
450 id: string,
451 ) => DeploymentRuntimeContext,
452});
453
454class ServiceNotReady extends Data.TaggedError(
455 "Kubernetes.ServiceNotReady",
456)<{}> {}
457
458// Bounded ~3 min wait for the cloud load balancer to publish its hostname
459// (an EKS Auto Mode NLB typically appears within 2–3 min of the Service
460// apply).
461const loadBalancerRetrySchedule = Schedule.max([
462 Schedule.spaced("5 seconds"),
463 Schedule.recurs(36),
464]);
465
466/**
467 * Explicitly-typed pipeable retry for the LB-hostname wait. An inline
468 * `Effect.retry` in the provider leaks `Retry.Return`'s conditional into
469 * declaration emit and widens the provider layer to `unknown` R.
470 */
471const retryUntilServiceReady = <A, E, R>(
472 self: Effect.Effect<A, E, R>,
473): Effect.Effect<A, E, R> =>
474 Effect.retry(self, {
475 while: (error) => error instanceof ServiceNotReady,
476 schedule: loadBalancerRetrySchedule,
477 });
478
479const isNotFound = (error: unknown): error is KubernetesApiError =>
480 error instanceof KubernetesApiError && error.statusCode === 404;
481
482class ServiceStillExists extends Data.TaggedError(
483 "Kubernetes.ServiceStillExists",
484)<{}> {}
485
486/**
487 * Bounded (~3 min) wait for a deleted Service to disappear, i.e. for the
488 * cloud controller to tear down its load balancer and drop the finalizer.
489 */
490const waitForServiceGone = (
491 transport: ClusterTransport,
492 service: KubernetesObjectRef,
493): Effect.Effect<void, unknown> =>
494 Effect.retry(
495 readObject({ transport, object: service }).pipe(
496 Effect.flatMap(() => Effect.fail(new ServiceStillExists())),
497 Effect.catchIf(isNotFound, () => Effect.void),
498 ),
499 {
500 while: (error) => error instanceof ServiceStillExists,
501 schedule: loadBalancerRetrySchedule,
502 },
503 );
504
505export const DeploymentProvider = () =>
506 Provider.effect(
507 Deployment,
508 Effect.gen(function* () {
509 const stack = yield* Stack;
510 const connectionRegistry = yield* makeConnectionRegistry;
511
512 const alchemyEnv = {
513 ALCHEMY_STACK_NAME: stack.name,
514 ALCHEMY_STAGE: stack.stage,
515 ALCHEMY_PHASE: "runtime",
516 };
517
518 // The base name doubles as the `app.kubernetes.io/name` label value
519 // (and the Service name), both capped at 63 characters.
520 const toBaseName = (id: string, props: { name?: string } = {}) =>
521 props.name
522 ? Effect.succeed(props.name)
523 : createPhysicalName({ id, maxLength: 63, lowercase: true }).pipe(
524 Effect.map((name) => name.replaceAll(/[^a-z0-9-]/g, "-")),
525 );
526
527 // Read a LoadBalancer Service's assigned hostname (bounded wait).
528 const waitForLoadBalancer = (
529 transport: ClusterTransport,
530 service: KubernetesObjectRef,
531 ) =>
532 readObject({ transport, object: service }).pipe(
533 Effect.map((response) => {
534 const ingress = (
535 response as {
536 status?: {
537 loadBalancer?: {
538 ingress?: { hostname?: string; ip?: string }[];
539 };
540 };
541 }
542 )?.status?.loadBalancer?.ingress?.[0];
543 return ingress?.hostname ?? ingress?.ip;
544 }),
545 Effect.flatMap((hostname) =>
546 hostname
547 ? Effect.succeed(hostname)
548 : Effect.fail(new ServiceNotReady()),
549 ),
550 retryUntilServiceReady,
551 Effect.catchTag("Kubernetes.ServiceNotReady", () =>
552 Effect.succeed(undefined),
553 ),
554 );
555
556 return {
557 stables: [
558 "connection",
559 "namespace",
560 "serviceAccountName",
561 "deploymentName",
562 "serviceName",
563 "identity",
564 "registry",
565 ],
566 // A Deployment's identity spans in-cluster Kubernetes objects plus
567 // adapter-owned cloud resources (identity role, image repository).
568 // There is no single enumeration that faithfully reconstructs that
569 // composite, so enumeration is intentionally empty — `read` (below)
570 // refreshes a known instance from its persisted output.
571 list: () => Effect.succeed([] as Deployment["Attributes"][]),
572 diff: Effect.fn(function* ({
573 olds = {} as DeploymentProps,
574 news: input,
575 output,
576 }) {
577 // `exports` carries the program's runtime Effects (never plain
578 // data); everything else must be resolved to diff.
579 const { exports: _exports, ...declared } = input as typeof input & {
580 exports?: unknown;
581 };
582 if (!isResolved(declared)) return;
583 const news = input as unknown as DeploymentProps;
584 const oldCluster = connectionIdentity(tryConnectionOf(olds.cluster));
585 const newCluster = connectionIdentity(tryConnectionOf(news.cluster));
586 // Workload identity keys on (cluster, namespace, serviceAccount);
587 // a change to either forces a replacement. Only compare when the
588 // old value is present so a first create (empty `olds`) doesn't
589 // spuriously replace.
590 if (
591 oldCluster !== undefined &&
592 newCluster !== undefined &&
593 oldCluster !== newCluster
594 ) {
595 return { action: "replace" } as const;
596 }
597 if (
598 oldCluster !== undefined &&
599 (olds.namespace ?? "default") !== (news.namespace ?? "default")
600 ) {
601 return { action: "replace" } as const;
602 }
603 // Content drift: the props don't change when files under a build
604 // context (or the bundled program) do, so surface hash drift as
605 // an update.
606 const connection = tryConnectionOf(news.cluster);
607 if (output && connection) {
608 const adapter = yield* findClusterAdapter(connection.auth.kind);
609 const source = news as WorkloadImageSource;
610 const hash = yield* workloadImageHash({
611 adapter,
612 connection,
613 connectionRegistry,
614 source,
615 platform: imagePlatformOf(news.architecture, connection),
616 port: news.port ?? 3000,
617 isExternal: news.isExternal,
618 bootstrap: (adapter.bootstrap?.server ?? makeServerBootstrap)(
619 source.handler ?? "default",
620 ),
621 });
622 if (hash !== undefined && hash !== output.code.hash) {
623 return { action: "update" } as const;
624 }
625 }
626 }),
627 read: Effect.fn(function* ({ output }) {
628 if (!output) return undefined;
629 const connection = connectionOfOutput(output);
630 if (!connection) return undefined;
631 const transport = yield* connectCluster(connection).pipe(
632 Effect.catchTag("Kubernetes.ClusterNotFoundError", () =>
633 Effect.succeed(undefined),
634 ),
635 // Transient unreachability must not read as "gone" — keep the
636 // persisted state and let reconcile converge.
637 Effect.catch(() => Effect.succeed("unreachable" as const)),
638 );
639 if (transport === undefined) return undefined;
640 if (transport === "unreachable") return output;
641 // The ServiceAccount is the stable first object of the workload
642 // (one-shot Job objects are content-addressed and may have been
643 // TTL-collected, so they are not an existence signal).
644 const anchor = (output.kubernetesObjects ?? [])[0];
645 if (!anchor) return output;
646 const observed = yield* readObject({
647 transport,
648 object: anchor,
649 }).pipe(
650 Effect.catchIf(isNotFound, () => Effect.succeed(undefined)),
651 Effect.catch(() => Effect.succeed(output)),
652 );
653 if (observed === undefined) return undefined;
654 return output;
655 }),
656 reconcile: Effect.fn(function* ({
657 id,
658 news,
659 bindings,
660 output,
661 session,
662 }) {
663 const connection = toConnection(news.cluster);
664 const adapter = yield* findClusterAdapter(connection.auth.kind);
665 const transport = yield* adapter.connect(connection);
666 const namespace = news.namespace ?? "default";
667 const port = news.port ?? 3000;
668 const serviceType = news.serviceType ?? "LoadBalancer";
669
670 const baseName =
671 output?.deploymentName ?? (yield* toBaseName(id, news));
672 const serviceAccountName = output?.serviceAccountName ?? baseName;
673 const tags = {
674 ...(yield* createInternalTags(id)),
675 ...news.tags,
676 };
677
678 // Environment from bindings is collected generically; cloud
679 // credential grants (any non-env binding channel) require the
680 // cluster platform's identity adapter.
681 const { env: bindingEnv, grantKeys } = collectBindingEnv(bindings);
682 if (grantKeys.length > 0 && adapter.identity === undefined) {
683 return yield* Effect.die(
684 new Error(
685 `'${id}': bindings carry cloud credential grants ` +
686 `(${grantKeys.join(", ")}) but this cluster's platform ` +
687 "has no workload-identity adapter. Target a managed " +
688 "cluster (e.g. AWS.EKS.Cluster) or bind through " +
689 "environment variables instead.",
690 ),
691 );
692 }
693 const identity = adapter.identity
694 ? yield* adapter.identity.reconcile({
695 id,
696 connection,
697 namespace,
698 serviceAccount: serviceAccountName,
699 bindings,
700 options: news.identity,
701 state:
702 (output?.identity as Record<string, unknown> | undefined) ??
703 (output as Record<string, unknown> | undefined),
704 tags,
705 })
706 : undefined;
707
708 // Resolve the container image from whichever source the props
709 // declare (`main` | `context` | `image`) through the platform's
710 // registry adapter (pre-built `image` refs pass through verbatim
711 // on registry-less clusters).
712 const source = news as WorkloadImageSource;
713 const resolved = yield* resolveWorkloadImage({
714 adapter,
715 connection,
716 connectionRegistry,
717 id,
718 source,
719 platform: imagePlatformOf(news.architecture, connection),
720 port,
721 isExternal: news.isExternal,
722 bootstrap: (adapter.bootstrap?.server ?? makeServerBootstrap)(
723 source.handler ?? "default",
724 ),
725 tags,
726 state:
727 (output?.registry as Record<string, unknown> | undefined) ??
728 (output as Record<string, unknown> | undefined),
729 session,
730 });
731
732 // Synthesize the Kubernetes objects. Container env merges binding
733 // env (Output-referenced resource attributes flow via the
734 // RuntimeContext into `news.env`), adapter env (e.g. AWS_REGION on
735 // EKS), alchemy env, and user env. The generated name label is
736 // always present so the Service selector stays workload-unique
737 // even when user labels are set.
738 const labels = {
739 "app.kubernetes.io/name": baseName,
740 ...news.labels,
741 };
742 const containerEnv = {
743 ...bindingEnv,
744 ...identity?.env,
745 ...alchemyEnv,
746 PORT: String(port),
747 ...news.env,
748 };
749
750 const serviceAccountObject: KubernetesObjectDefinition = {
751 apiVersion: "v1",
752 kind: "ServiceAccount",
753 metadata: {
754 name: serviceAccountName,
755 namespace,
756 labels,
757 ...(identity?.serviceAccountAnnotations
758 ? { annotations: identity.serviceAccountAnnotations }
759 : {}),
760 },
761 };
762 const podTemplate = deepMerge(
763 {
764 metadata: { labels },
765 spec: {
766 serviceAccountName,
767 containers: [
768 {
769 name: baseName,
770 image: resolved.imageUri,
771 command: news.command,
772 args: news.args,
773 ports: [{ containerPort: port }],
774 env: Object.entries(containerEnv).map(([name, value]) => ({
775 name,
776 value:
777 typeof value === "string"
778 ? value
779 : JSON.stringify(value),
780 })),
781 resources: news.resources,
782 },
783 ],
784 },
785 },
786 news.podTemplate,
787 );
788 const deploymentObject: KubernetesObjectDefinition = {
789 apiVersion: "apps/v1",
790 kind: "Deployment",
791 metadata: { name: baseName, namespace, labels },
792 spec: {
793 replicas: news.replicas ?? 1,
794 selector: { matchLabels: labels },
795 template: podTemplate,
796 },
797 };
798 // `LoadBalancer` Services need platform defaults on some managed
799 // clouds (EKS Auto Mode only reconciles Services carrying its
800 // `loadBalancerClass`, and defaults new NLBs to the internal
801 // scheme) — ask the adapter. User `serviceAnnotations` always win.
802 let loadBalancerClass: string | undefined;
803 let serviceAnnotations = news.serviceAnnotations;
804 if (serviceType === "LoadBalancer") {
805 const defaults = adapter.loadBalancerDefaults
806 ? yield* adapter.loadBalancerDefaults({ connection })
807 : undefined;
808 loadBalancerClass = defaults?.loadBalancerClass;
809 serviceAnnotations = {
810 ...defaults?.annotations,
811 ...news.serviceAnnotations,
812 };
813 }
814 const serviceObject: KubernetesObjectDefinition = {
815 apiVersion: "v1",
816 kind: "Service",
817 metadata: {
818 name: baseName,
819 namespace,
820 labels,
821 annotations: serviceAnnotations,
822 },
823 spec: {
824 type: serviceType,
825 ...(loadBalancerClass !== undefined ? { loadBalancerClass } : {}),
826 selector: labels,
827 ports: [{ port, targetPort: port, protocol: "TCP" }],
828 },
829 };
830
831 const desiredObjects = [
832 serviceAccountObject,
833 deploymentObject,
834 serviceObject,
835 ];
836
837 const kubernetesObjects = yield* reconcileObjects({
838 transport,
839 previousObjects: output?.kubernetesObjects ?? [],
840 desiredObjects,
841 });
842
843 yield* session.note(
844 `Applied Kubernetes Deployment ${namespace}/${baseName}`,
845 );
846
847 // Resolve the LoadBalancer URL if applicable. The cloud listener
848 // is the Service `port` (Kubernetes maps `spec.ports[].port` 1:1
849 // to it), so the URL carries the port unless it's 80 — mirroring
850 // `AWS.ECS.Service`'s url semantics.
851 const hostname =
852 serviceType === "LoadBalancer"
853 ? yield* waitForLoadBalancer(
854 transport,
855 toKubernetesObjectRef(serviceObject),
856 )
857 : undefined;
858 const url =
859 hostname === undefined
860 ? undefined
861 : port === 80
862 ? `http://${hostname}`
863 : `http://${hostname}:${port}`;
864
865 return {
866 connection,
867 namespace,
868 deploymentName: baseName,
869 serviceName: baseName,
870 serviceAccountName,
871 port,
872 imageUri: resolved.imageUri,
873 identity: identity?.state,
874 registry: resolved.state,
875 url,
876 kubernetesObjects,
877 code: { hash: resolved.codeHash },
878 };
879 }),
880 delete: Effect.fn(function* ({ output }) {
881 const connection = connectionOfOutput(output);
882 if (!connection) return;
883 const adapter = yield* findClusterAdapter(connection.auth.kind);
884
885 // Delete the in-cluster objects. If the cluster is gone (or
886 // transiently unreachable) skip them — cluster-scoped state dies
887 // with the cluster — and still clean up the adapter-owned cloud
888 // resources that outlive it (image repository, identity role).
889 const transport = yield* adapter
890 .connect(connection)
891 .pipe(Effect.catch(() => Effect.succeed(undefined)));
892 if (transport && (output.kubernetesObjects ?? []).length > 0) {
893 yield* deleteObjects({
894 transport,
895 objects: output.kubernetesObjects ?? [],
896 }).pipe(Effect.catch(() => Effect.void));
897 // A LoadBalancer Service carries the cloud controller's cleanup
898 // finalizer; wait for it so the cloud load balancer is gone
899 // before the cluster (and its controller) can be deleted —
900 // deleting the cluster first leaks the load balancer.
901 yield* Effect.forEach(
902 (output.kubernetesObjects ?? []).filter(
903 (object) => object.kind === "Service",
904 ),
905 (service) =>
906 waitForServiceGone(transport, service).pipe(
907 Effect.catch(() => Effect.void),
908 ),
909 { discard: true },
910 );
911 }
912
913 if (adapter.identity) {
914 yield* adapter.identity.delete({
915 connection,
916 state:
917 (output.identity as Record<string, unknown> | undefined) ??
918 (output as Record<string, unknown>),
919 });
920 }
921 if (adapter.registry) {
922 yield* adapter.registry.delete({
923 state:
924 (output.registry as Record<string, unknown> | undefined) ??
925 (output as Record<string, unknown>),
926 });
927 }
928 }),
929 };
930 }),
931 );