@alchemy.run/kubernetes 0.0.0 → 2.0.0-beta.85

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