1import * as eks from "@distilled.cloud/aws/eks";
2import * as iam from "@distilled.cloud/aws/iam";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import {
10 deleteObjects,
11 reconcileObjects,
12} from "../../Kubernetes/internal/client.ts";
13import {
14 type KubernetesObjectBinding,
15 type KubernetesObjectDefinition,
16 type KubernetesObjectRef,
17} from "../../Kubernetes/internal/objects.ts";
18import type { Connection } from "../../Kubernetes/Connection.ts";
19import { eksConnectionOf, makeEksTransport } from "./KubernetesAdapter.ts";
20import { createPhysicalName } from "../../PhysicalName.ts";
21import * as Provider from "../../Provider.ts";
22import { Resource, type ResourceBinding } from "../../Resource.ts";
23import type { Providers } from "../Providers.ts";
24import {
25 createInternalTags,
26 diffTags,
27 hasAlchemyTags,
28 hasTags,
29} from "../../Tags.ts";
30import type { AccountID } from "../Environment.ts";
31import type { RegionID } from "../Region.ts";
32
33export type ClusterName = string;
34export type ClusterArn =
35 `arn:aws:eks:${RegionID}:${AccountID}:cluster/${ClusterName}`;
36
37export interface ClusterProps {
38 /**
39 * Cluster name. If omitted, a unique name is generated.
40 */
41 clusterName?: string;
42 /**
43 * `"auto"` turns on EKS Auto Mode with sensible defaults: managed compute
44 * (`system` + `general-purpose` node pools), block storage, elastic load
45 * balancing, and `API` authentication with bootstrap admin permissions.
46 * When `roleArn` (or `computeConfig.nodeRoleArn`) is omitted, the cluster
47 * provisions and owns the required IAM roles with the standard Auto Mode
48 * managed policies. Explicit `accessConfig` / `computeConfig` /
49 * `storageConfig` / `kubernetesNetworkConfig` props override the defaults.
50 */
51 compute?: "auto";
52 /**
53 * IAM role ARN assumed by the EKS control plane. Required unless
54 * `compute: "auto"`, in which case an Auto Mode cluster role is created
55 * and managed automatically when omitted.
56 */
57 roleArn?: string;
58 /**
59 * VPC configuration for the cluster control plane.
60 */
61 resourcesVpcConfig: eks.VpcConfigRequest;
62 /**
63 * Desired Kubernetes version.
64 */
65 version?: string;
66 /**
67 * Cluster access configuration.
68 */
69 accessConfig?: eks.CreateAccessConfigRequest;
70 /**
71 * Auto Mode compute configuration.
72 */
73 computeConfig?: eks.ComputeConfigRequest;
74 /**
75 * Auto Mode storage configuration.
76 */
77 storageConfig?: eks.StorageConfigRequest;
78 /**
79 * Kubernetes network configuration.
80 */
81 kubernetesNetworkConfig?: eks.KubernetesNetworkConfigRequest;
82 /**
83 * Control plane logging configuration.
84 */
85 logging?: eks.Logging;
86 /**
87 * Upgrade support policy for the cluster.
88 */
89 upgradePolicy?: eks.UpgradePolicyRequest;
90 /**
91 * Whether deletion protection is enabled.
92 * @default false
93 */
94 deletionProtection?: boolean;
95 /**
96 * User-defined tags to apply to the cluster.
97 */
98 tags?: Record<string, string>;
99}
100
101export interface Cluster extends Resource<
102 "AWS.EKS.Cluster",
103 ClusterProps,
104 {
105 /** The ARN of the cluster. */
106 clusterArn: ClusterArn;
107 /** The name of the cluster. */
108 clusterName: ClusterName;
109 /** The cluster status (e.g. `CREATING`, `ACTIVE`, `UPDATING`). */
110 status: string;
111 /** The Kubernetes API server endpoint URL. */
112 endpoint: string | undefined;
113 /** The base64-encoded certificate authority data for the cluster. */
114 certificateAuthorityData: string | undefined;
115 /** The Kubernetes version the cluster is running (e.g. `1.31`). */
116 version: string | undefined;
117 /** The EKS platform version of the cluster. */
118 platformVersion: string | undefined;
119 /** The ARN of the IAM role the EKS control plane assumes. */
120 roleArn: string;
121 /** The VPC configuration of the cluster (subnets, security groups, endpoint access). */
122 resourcesVpcConfig: eks.VpcConfigResponse;
123 /** The access configuration (authentication mode, bootstrap admin). */
124 accessConfig: eks.AccessConfigResponse | undefined;
125 /** The EKS Auto Mode compute configuration, if enabled. */
126 computeConfig: eks.ComputeConfigResponse | undefined;
127 /** The EKS Auto Mode block storage configuration, if enabled. */
128 storageConfig: eks.StorageConfigResponse | undefined;
129 /** The Kubernetes network configuration (service CIDR, IP family, elastic load balancing). */
130 kubernetesNetworkConfig: eks.KubernetesNetworkConfigResponse | undefined;
131 /** The control-plane log types shipped to CloudWatch. */
132 logging: eks.Logging | undefined;
133 /** The cluster's upgrade policy (support type). */
134 upgradePolicy: eks.UpgradePolicyResponse | undefined;
135 /** Whether deletion protection is enabled on the cluster. */
136 deletionProtection: boolean;
137 /** The OIDC identity provider issuer URL for the cluster. */
138 oidcIssuer: string | undefined;
139 /** The tags applied to the cluster. */
140 tags: Record<string, string>;
141 /** References to Kubernetes objects applied via `kubernetes` props. */
142 kubernetesObjects: KubernetesObjectRef[];
143 /**
144 * The cluster-agnostic `Kubernetes.Connection` for this cluster.
145 * Passing the whole cluster resource as a `Kubernetes.*` workload's
146 * `cluster` prop resolves through this — auth uses SigV4 tokens
147 * minted from the ambient AWS credentials.
148 */
149 connection: Connection;
150 /** The name of the cluster IAM role created for `compute: "auto"`, if managed by alchemy. */
151 managedClusterRoleName: string | undefined;
152 /** The name of the node IAM role created for `compute: "auto"`, if managed by alchemy. */
153 managedNodeRoleName: string | undefined;
154 },
155 KubernetesObjectBinding,
156 Providers
157> {}
158
159/**
160 * An Amazon EKS cluster with support for EKS Auto Mode settings.
161 * ### Creating Clusters
162 * **Example:** Auto Mode Cluster (managed roles)
163 * ```typescript
164 * const cluster = yield* Cluster("AppCluster", {
165 * compute: "auto",
166 * resourcesVpcConfig: {
167 * subnetIds: network.privateSubnetIds,
168 * },
169 * });
170 * ```
171 *
172 * **Example:** Auto Mode Cluster from Existing Roles and Subnets
173 * ```typescript
174 * const cluster = yield* Cluster("AppCluster", {
175 * roleArn: clusterRole.roleArn,
176 * resourcesVpcConfig: {
177 * subnetIds: network.privateSubnetIds,
178 * endpointPublicAccess: true,
179 * endpointPrivateAccess: true,
180 * },
181 * accessConfig: {
182 * authenticationMode: "API",
183 * },
184 * computeConfig: {
185 * enabled: true,
186 * nodeRoleArn: nodeRole.roleArn,
187 * nodePools: ["system", "general-purpose"],
188 * },
189 * kubernetesNetworkConfig: {
190 * elasticLoadBalancing: { enabled: true },
191 * },
192 * storageConfig: {
193 * blockStorage: { enabled: true },
194 * },
195 * });
196 * ```
197 *
198 * ### Running Workloads
199 * **Example:** Cluster with a Managed Node Group and an Add-on
200 * ```typescript
201 * const cluster = yield* AWS.EKS.Cluster("AppCluster", {
202 * roleArn: clusterRole.roleArn,
203 * resourcesVpcConfig: { subnetIds: network.privateSubnetIds },
204 * accessConfig: { authenticationMode: "API" },
205 * });
206 *
207 * const nodes = yield* AWS.EKS.Nodegroup("AppNodes", {
208 * clusterName: cluster.clusterName,
209 * nodeRole: nodeRole.roleArn,
210 * subnets: network.privateSubnetIds,
211 * instanceTypes: ["t3.medium"],
212 * scalingConfig: { minSize: 1, maxSize: 2, desiredSize: 1 },
213 * });
214 *
215 * const metricsServer = yield* AWS.EKS.Addon("MetricsServer", {
216 * clusterName: cluster.clusterName,
217 * addonName: "metrics-server",
218 * });
219 * ```
220 *
221 * @resource
222 */
223export const Cluster = Resource<Cluster>("AWS.EKS.Cluster");
224
225class ClusterNotReady extends Data.TaggedError("EKS.ClusterNotReady")<{
226 status: string | undefined;
227}> {}
228
229class ClusterStillExists extends Data.TaggedError(
230 "EKS.ClusterStillExists",
231)<{}> {}
232
233class ClusterUpdateNotComplete extends Data.TaggedError(
234 "EKS.ClusterUpdateNotComplete",
235)<{
236 status: eks.UpdateStatus | undefined;
237}> {}
238
239const normalizeTags = (tags: Record<string, string | undefined> | undefined) =>
240 Object.fromEntries(
241 Object.entries(tags ?? {}).filter(
242 (entry): entry is [string, string] => entry[1] !== undefined,
243 ),
244 );
245
246// Wait budget: ~30 min at 10s spacing. Cluster create/delete takes ~10–15 min
247// and async config/version updates ~5–15 min. The interval MUST be flat, not
248// exponential: `Schedule.exponential` with no delay cap sleeps 8.5/17/34 min
249// between late attempts — a silent multi-minute park that presents as a
250// deadlocked (0% CPU, no output) deploy and blows any hook budget.
251const updateRetrySchedule = Schedule.max([
252 Schedule.spaced("10 seconds"),
253 Schedule.recurs(180),
254]);
255
256const getKubernetesTransport = (
257 state: Pick<
258 Cluster["Attributes"],
259 "clusterArn" | "clusterName" | "endpoint" | "certificateAuthorityData"
260 >,
261) =>
262 Effect.suspend(() => {
263 if (!state.endpoint || !state.certificateAuthorityData) {
264 throw new Error(
265 `EKS cluster '${state.clusterName}' is missing endpoint or certificate authority data`,
266 );
267 }
268 return makeEksTransport({
269 clusterName: state.clusterName,
270 region: regionOfClusterArn(state.clusterArn),
271 endpoint: state.endpoint,
272 certificateAuthorityData: state.certificateAuthorityData,
273 });
274 });
275
276/** The region segment of a cluster ARN (`arn:aws:eks:REGION:...`). */
277const regionOfClusterArn = (arn: string | undefined): string | undefined =>
278 arn?.split(":")[3] || undefined;
279
280const getDesiredKubernetesObjects = (
281 bindings: ReadonlyArray<ResourceBinding<KubernetesObjectBinding>>,
282): KubernetesObjectDefinition[] =>
283 bindings
284 .filter(
285 (binding): binding is ResourceBinding<KubernetesObjectBinding> =>
286 binding.data.type === "kubernetes-object",
287 )
288 .map((binding) => binding.data.object);
289
290const autoClusterManagedPolicyArns = [
291 "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy",
292 "arn:aws:iam::aws:policy/AmazonEKSComputePolicy",
293 "arn:aws:iam::aws:policy/AmazonEKSBlockStoragePolicy",
294 "arn:aws:iam::aws:policy/AmazonEKSLoadBalancingPolicy",
295 "arn:aws:iam::aws:policy/AmazonEKSNetworkingPolicy",
296];
297
298const autoNodeManagedPolicyArns = [
299 "arn:aws:iam::aws:policy/AmazonEKSWorkerNodeMinimalPolicy",
300 "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPullOnly",
301];
302
303/**
304 * Expand `compute: "auto"` into the concrete EKS Auto Mode configuration
305 * (explicit user props always win over the defaults).
306 */
307const applyAutoModeDefaults = (
308 news: ClusterProps,
309 roles: { roleArn: string; nodeRoleArn: string | undefined },
310): ClusterProps & { roleArn: string } =>
311 news.compute === "auto"
312 ? {
313 ...news,
314 roleArn: roles.roleArn,
315 accessConfig: {
316 bootstrapClusterCreatorAdminPermissions: true,
317 authenticationMode: "API",
318 ...news.accessConfig,
319 },
320 computeConfig: {
321 enabled: true,
322 nodePools: ["system", "general-purpose"],
323 ...news.computeConfig,
324 nodeRoleArn: roles.nodeRoleArn,
325 },
326 kubernetesNetworkConfig: {
327 ...news.kubernetesNetworkConfig,
328 elasticLoadBalancing: {
329 enabled: true,
330 ...news.kubernetesNetworkConfig?.elasticLoadBalancing,
331 },
332 },
333 storageConfig: {
334 blockStorage: {
335 enabled: true,
336 ...news.storageConfig?.blockStorage,
337 },
338 },
339 }
340 : { ...news, roleArn: roles.roleArn };
341
342const stringSetEqual = (
343 a: readonly string[] | undefined,
344 b: readonly string[] | undefined,
345) =>
346 JSON.stringify([...(a ?? [])].sort()) ===
347 JSON.stringify([...(b ?? [])].sort());
348
349/** Flatten an EKS `Logging` shape into a `logType -> enabled` map. */
350const loggingByType = (logging: eks.Logging | undefined) => {
351 const byType: Record<string, boolean> = {};
352 for (const setup of logging?.clusterLogging ?? []) {
353 for (const type of setup.types ?? []) {
354 byType[type] = setup.enabled ?? false;
355 }
356 }
357 return byType;
358};
359
360interface ClusterConfigUpdate {
361 /** Stable category slug — also feeds the per-category idempotency token. */
362 category: string;
363 request: Omit<eks.UpdateClusterConfigRequest, "name" | "clientRequestToken">;
364}
365
366/**
367 * Plan the `updateClusterConfig` calls needed to converge observed cloud
368 * state to the desired props.
369 *
370 * EKS's UpdateClusterConfig API accepts exactly ONE update category per call
371 * (`InvalidParameterException: Only one type of update can be allowed.`), so
372 * drift is computed per category and each drifted category becomes its own
373 * request — the caller issues them serially, waiting for each async update
374 * to complete before the next.
375 *
376 * Comparison is desired-subset only: a field the user did not specify is
377 * "don't care" and is never diffed against server-populated response fields
378 * (vpcId, clusterSecurityGroupId, default upgradePolicy, ...). A freshly
379 * created cluster therefore plans zero updates.
380 */
381const planClusterConfigUpdates = (
382 observed: Cluster["Attributes"],
383 desired: ClusterProps,
384): ClusterConfigUpdate[] => {
385 const updates: ClusterConfigUpdate[] = [];
386
387 // Access config — authenticationMode is the only mutable field.
388 const desiredAuthMode = desired.accessConfig?.authenticationMode;
389 if (
390 desiredAuthMode !== undefined &&
391 desiredAuthMode !== observed.accessConfig?.authenticationMode
392 ) {
393 updates.push({
394 category: "access",
395 request: { accessConfig: { authenticationMode: desiredAuthMode } },
396 });
397 }
398
399 // Auto Mode trio — computeConfig, storageConfig and kubernetesNetworkConfig
400 // count as ONE update category and must ship together in a single call.
401 const desiredCompute = desired.computeConfig;
402 const observedCompute = observed.computeConfig;
403 const computeDrift =
404 desiredCompute !== undefined &&
405 ((desiredCompute.enabled !== undefined &&
406 desiredCompute.enabled !== (observedCompute?.enabled ?? false)) ||
407 (desiredCompute.nodePools !== undefined &&
408 !stringSetEqual(
409 desiredCompute.nodePools,
410 observedCompute?.nodePools,
411 )) ||
412 (desiredCompute.nodeRoleArn !== undefined &&
413 desiredCompute.nodeRoleArn !== observedCompute?.nodeRoleArn));
414 const desiredBlockStorage = desired.storageConfig?.blockStorage?.enabled;
415 const storageDrift =
416 desiredBlockStorage !== undefined &&
417 desiredBlockStorage !==
418 (observed.storageConfig?.blockStorage?.enabled ?? false);
419 const desiredElb =
420 desired.kubernetesNetworkConfig?.elasticLoadBalancing?.enabled;
421 const elbDrift =
422 desiredElb !== undefined &&
423 desiredElb !==
424 (observed.kubernetesNetworkConfig?.elasticLoadBalancing?.enabled ??
425 false);
426 if (computeDrift || storageDrift || elbDrift) {
427 updates.push({
428 category: "auto-mode",
429 request: {
430 computeConfig: desiredCompute ?? {
431 enabled: observedCompute?.enabled ?? false,
432 nodePools: observedCompute?.nodePools,
433 nodeRoleArn: observedCompute?.nodeRoleArn,
434 },
435 storageConfig: {
436 blockStorage: {
437 enabled:
438 desiredBlockStorage ??
439 observed.storageConfig?.blockStorage?.enabled ??
440 false,
441 },
442 },
443 // serviceIpv4Cidr / ipFamily are create-only (diff replaces on
444 // change) — never send them on update.
445 kubernetesNetworkConfig: {
446 elasticLoadBalancing: {
447 enabled:
448 desiredElb ??
449 observed.kubernetesNetworkConfig?.elasticLoadBalancing?.enabled ??
450 false,
451 },
452 },
453 },
454 });
455 }
456
457 // VPC endpoint access (public/private endpoint + public access CIDRs).
458 const desiredVpc = desired.resourcesVpcConfig;
459 const observedVpc = observed.resourcesVpcConfig;
460 const endpointPublicDrift =
461 desiredVpc.endpointPublicAccess !== undefined &&
462 desiredVpc.endpointPublicAccess !== observedVpc.endpointPublicAccess;
463 const endpointPrivateDrift =
464 desiredVpc.endpointPrivateAccess !== undefined &&
465 desiredVpc.endpointPrivateAccess !== observedVpc.endpointPrivateAccess;
466 const publicCidrsDrift =
467 desiredVpc.publicAccessCidrs !== undefined &&
468 !stringSetEqual(
469 desiredVpc.publicAccessCidrs,
470 observedVpc.publicAccessCidrs,
471 );
472 if (endpointPublicDrift || endpointPrivateDrift || publicCidrsDrift) {
473 updates.push({
474 category: "vpc-endpoint",
475 request: {
476 resourcesVpcConfig: {
477 endpointPublicAccess: desiredVpc.endpointPublicAccess,
478 endpointPrivateAccess: desiredVpc.endpointPrivateAccess,
479 publicAccessCidrs: desiredVpc.publicAccessCidrs,
480 },
481 },
482 });
483 }
484
485 // VPC subnet / security-group membership — separate from endpoint access.
486 const subnetsDrift =
487 desiredVpc.subnetIds !== undefined &&
488 !stringSetEqual(desiredVpc.subnetIds, observedVpc.subnetIds);
489 const securityGroupsDrift =
490 desiredVpc.securityGroupIds !== undefined &&
491 !stringSetEqual(desiredVpc.securityGroupIds, observedVpc.securityGroupIds);
492 if (subnetsDrift || securityGroupsDrift) {
493 updates.push({
494 category: "vpc-network",
495 request: {
496 resourcesVpcConfig: {
497 subnetIds: desiredVpc.subnetIds,
498 securityGroupIds: desiredVpc.securityGroupIds,
499 },
500 },
501 });
502 }
503
504 // Control-plane logging — compare per log type; types the user didn't
505 // mention are "don't care".
506 if (desired.logging !== undefined) {
507 const observedLogging = loggingByType(observed.logging);
508 const desiredLogging = loggingByType(desired.logging);
509 if (
510 Object.entries(desiredLogging).some(
511 ([type, enabled]) => (observedLogging[type] ?? false) !== enabled,
512 )
513 ) {
514 updates.push({
515 category: "logging",
516 request: { logging: desired.logging },
517 });
518 }
519 }
520
521 // Upgrade policy.
522 if (
523 desired.upgradePolicy?.supportType !== undefined &&
524 desired.upgradePolicy.supportType !== observed.upgradePolicy?.supportType
525 ) {
526 updates.push({
527 category: "upgrade-policy",
528 request: { upgradePolicy: desired.upgradePolicy },
529 });
530 }
531
532 // Deletion protection.
533 if (
534 desired.deletionProtection !== undefined &&
535 desired.deletionProtection !== observed.deletionProtection
536 ) {
537 updates.push({
538 category: "deletion-protection",
539 request: { deletionProtection: desired.deletionProtection },
540 });
541 }
542
543 return updates;
544};
545
546const mapClusterState = (
547 cluster: eks.Cluster,
548 tags: Record<string, string>,
549 kubernetesObjects: KubernetesObjectRef[],
550 managedRoles: {
551 managedClusterRoleName?: string;
552 managedNodeRoleName?: string;
553 } = {},
554): Cluster["Attributes"] => ({
555 managedClusterRoleName: managedRoles.managedClusterRoleName,
556 managedNodeRoleName: managedRoles.managedNodeRoleName,
557 clusterArn: cluster.arn as ClusterArn,
558 clusterName: cluster.name!,
559 status: cluster.status ?? "CREATING",
560 endpoint: cluster.endpoint,
561 certificateAuthorityData: cluster.certificateAuthority?.data,
562 connection: eksConnectionOf({
563 clusterName: cluster.name!,
564 region: regionOfClusterArn(cluster.arn) ?? "",
565 endpoint: cluster.endpoint,
566 certificateAuthorityData: cluster.certificateAuthority?.data,
567 }),
568 version: cluster.version,
569 platformVersion: cluster.platformVersion,
570 roleArn: cluster.roleArn!,
571 resourcesVpcConfig: {
572 subnetIds: cluster.resourcesVpcConfig?.subnetIds ?? [],
573 securityGroupIds: cluster.resourcesVpcConfig?.securityGroupIds ?? [],
574 clusterSecurityGroupId: cluster.resourcesVpcConfig?.clusterSecurityGroupId,
575 vpcId: cluster.resourcesVpcConfig?.vpcId,
576 endpointPublicAccess: cluster.resourcesVpcConfig?.endpointPublicAccess,
577 endpointPrivateAccess: cluster.resourcesVpcConfig?.endpointPrivateAccess,
578 publicAccessCidrs: cluster.resourcesVpcConfig?.publicAccessCidrs ?? [],
579 },
580 accessConfig: cluster.accessConfig,
581 computeConfig: cluster.computeConfig,
582 storageConfig: cluster.storageConfig,
583 kubernetesNetworkConfig: cluster.kubernetesNetworkConfig,
584 logging: cluster.logging,
585 upgradePolicy: cluster.upgradePolicy,
586 deletionProtection: cluster.deletionProtection ?? false,
587 oidcIssuer: cluster.identity?.oidc?.issuer,
588 tags,
589 kubernetesObjects,
590});
591
592export const ClusterProvider = () =>
593 Provider.effect(
594 Cluster,
595 Effect.gen(function* () {
596 const toClusterName = (
597 id: string,
598 props: { clusterName?: string } = {},
599 ) =>
600 props.clusterName
601 ? Effect.succeed(props.clusterName)
602 : createPhysicalName({ id, maxLength: 100 });
603
604 const toClientRequestToken = (id: string, action: string) =>
605 createPhysicalName({
606 id: `${id}-${action}`,
607 maxLength: 64,
608 delimiter: "-",
609 });
610
611 const validateProps = Effect.fn(function* (props: ClusterProps) {
612 const subnetIds = props.resourcesVpcConfig.subnetIds ?? [];
613 if (subnetIds.length < 2) {
614 return yield* Effect.fail(
615 new Error("AWS.EKS.Cluster requires at least two subnet IDs"),
616 );
617 }
618 if (!props.roleArn && props.compute !== "auto") {
619 return yield* Effect.fail(
620 new Error(
621 "AWS.EKS.Cluster requires roleArn unless compute is 'auto'",
622 ),
623 );
624 }
625 if (
626 props.computeConfig?.enabled &&
627 props.accessConfig?.authenticationMode === "CONFIG_MAP"
628 ) {
629 return yield* Effect.fail(
630 new Error(
631 "AWS.EKS.Cluster Auto Mode requires accessConfig.authenticationMode to include API access",
632 ),
633 );
634 }
635 });
636
637 // Ensure an alchemy-managed IAM role for `compute: "auto"` (cluster or
638 // node role). Idempotent: creates on miss, adopts our own role on race,
639 // and converges managed policy attachments.
640 const ensureManagedRole = Effect.fn(function* ({
641 id,
642 roleName,
643 service,
644 actions,
645 managedPolicyArns,
646 userTags,
647 }: {
648 id: string;
649 roleName: string;
650 service: string;
651 actions: string[];
652 managedPolicyArns: string[];
653 userTags: Record<string, string> | undefined;
654 }) {
655 const tags = yield* createInternalTags(id);
656 const role = yield* iam
657 .createRole({
658 RoleName: roleName,
659 AssumeRolePolicyDocument: JSON.stringify({
660 Version: "2012-10-17",
661 Statement: [
662 {
663 Effect: "Allow",
664 Principal: { Service: service },
665 Action: actions,
666 },
667 ],
668 }),
669 Tags: Object.entries({ ...tags, ...userTags }).map(
670 ([Key, Value]) => ({ Key, Value }),
671 ),
672 })
673 .pipe(
674 Effect.catchTag("EntityAlreadyExistsException", () =>
675 iam.getRole({ RoleName: roleName }).pipe(
676 Effect.filterOrFail(
677 (existing) => hasTags(tags, existing.Role?.Tags),
678 () =>
679 new Error(
680 `Role '${roleName}' already exists and is not managed by alchemy`,
681 ),
682 ),
683 ),
684 ),
685 );
686 for (const policyArn of managedPolicyArns) {
687 yield* iam
688 .attachRolePolicy({ RoleName: roleName, PolicyArn: policyArn })
689 .pipe(Effect.catchTag("LimitExceededException", () => Effect.void));
690 }
691 return role.Role!.Arn!;
692 });
693
694 const deleteManagedRole = Effect.fn(function* (roleName: string) {
695 yield* iam.listAttachedRolePolicies.items({ RoleName: roleName }).pipe(
696 Stream.mapEffect((policy) =>
697 iam
698 .detachRolePolicy({
699 RoleName: roleName,
700 PolicyArn: policy.PolicyArn!,
701 })
702 .pipe(
703 Effect.catchTag("NoSuchEntityException", () => Effect.void),
704 ),
705 ),
706 Stream.runDrain,
707 Effect.catchTag("NoSuchEntityException", () => Effect.void),
708 );
709 yield* iam.listRolePolicies.items({ RoleName: roleName }).pipe(
710 Stream.mapEffect((policyName) =>
711 iam
712 .deleteRolePolicy({
713 RoleName: roleName,
714 PolicyName: policyName,
715 })
716 .pipe(
717 Effect.catchTag("NoSuchEntityException", () => Effect.void),
718 ),
719 ),
720 Stream.runDrain,
721 Effect.catchTag("NoSuchEntityException", () => Effect.void),
722 );
723 yield* iam
724 .deleteRole({ RoleName: roleName })
725 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
726 });
727
728 const readCluster = Effect.fn(function* ({
729 clusterName,
730 kubernetesObjects,
731 managedRoles,
732 }: {
733 clusterName: string;
734 kubernetesObjects?: KubernetesObjectRef[];
735 managedRoles?: {
736 managedClusterRoleName?: string;
737 managedNodeRoleName?: string;
738 };
739 }) {
740 const described = yield* eks
741 .describeCluster({
742 name: clusterName,
743 })
744 .pipe(
745 Effect.catchTag("ResourceNotFoundException", () =>
746 Effect.succeed(undefined),
747 ),
748 );
749 const cluster = described?.cluster;
750 if (!cluster?.arn || !cluster.name || !cluster.roleArn) {
751 return undefined;
752 }
753 const listedTags = yield* eks
754 .listTagsForResource({
755 resourceArn: cluster.arn,
756 })
757 .pipe(
758 Effect.catchTag("NotFoundException", () =>
759 Effect.succeed(undefined),
760 ),
761 );
762 const tags = normalizeTags(listedTags?.tags ?? cluster.tags);
763 return mapClusterState(
764 cluster,
765 tags,
766 kubernetesObjects ?? [],
767 managedRoles,
768 );
769 });
770
771 const waitForClusterActive = (
772 clusterName: string,
773 kubernetesObjects: KubernetesObjectRef[] = [],
774 managedRoles?: {
775 managedClusterRoleName?: string;
776 managedNodeRoleName?: string;
777 },
778 ) =>
779 readCluster({
780 clusterName,
781 kubernetesObjects,
782 managedRoles,
783 }).pipe(
784 Effect.flatMap((state) => {
785 if (!state) {
786 return Effect.fail(
787 new ClusterNotReady({
788 status: undefined,
789 }),
790 );
791 }
792 if (state.status === "ACTIVE") {
793 return Effect.succeed(state);
794 }
795 if (state.status === "FAILED") {
796 return Effect.fail(
797 new Error(`EKS cluster '${clusterName}' entered FAILED state`),
798 );
799 }
800 return Effect.fail(
801 new ClusterNotReady({
802 status: state.status,
803 }),
804 );
805 }),
806 Effect.retry({
807 while: (error) => error instanceof ClusterNotReady,
808 schedule: updateRetrySchedule,
809 }),
810 );
811
812 const waitForClusterDeleted = (clusterName: string) =>
813 readCluster({
814 clusterName,
815 }).pipe(
816 Effect.flatMap((state) =>
817 state
818 ? Effect.fail(new ClusterStillExists())
819 : Effect.succeed(undefined),
820 ),
821 Effect.retry({
822 while: (error) => error instanceof ClusterStillExists,
823 schedule: updateRetrySchedule,
824 }),
825 );
826
827 const waitForUpdate = (clusterName: string, updateId: string) =>
828 eks
829 .describeUpdate({
830 name: clusterName,
831 updateId,
832 })
833 .pipe(
834 Effect.flatMap(({ update }) => {
835 if (update?.status === "Successful") {
836 return Effect.succeed(update);
837 }
838 if (
839 update?.status === "Failed" ||
840 update?.status === "Cancelled"
841 ) {
842 return Effect.fail(
843 new Error(
844 `EKS cluster update '${updateId}' failed with status '${update?.status}'`,
845 ),
846 );
847 }
848 return Effect.fail(
849 new ClusterUpdateNotComplete({
850 status: update?.status,
851 }),
852 );
853 }),
854 Effect.retry({
855 while: (error) => error instanceof ClusterUpdateNotComplete,
856 schedule: updateRetrySchedule,
857 }),
858 );
859
860 return {
861 stables: ["clusterArn", "clusterName", "connection"],
862 // Enumerate every cluster in the ambient account/region. `listClusters`
863 // returns only names, so we paginate it exhaustively then hydrate each
864 // name through `readCluster` (describe + tags) to produce the full
865 // `Attributes` shape `read` returns. Concurrency is bounded so we don't
866 // stampede `describeCluster`.
867 list: () =>
868 Effect.gen(function* () {
869 const names = yield* eks.listClusters.pages({}).pipe(
870 Stream.runCollect,
871 Effect.map((chunk) =>
872 Array.from(chunk).flatMap((page) => page.clusters ?? []),
873 ),
874 );
875 const states = yield* Effect.forEach(
876 names,
877 (clusterName) => readCluster({ clusterName }),
878 { concurrency: 8 },
879 );
880 return states.filter(
881 (state): state is Cluster["Attributes"] => state !== undefined,
882 );
883 }),
884 diff: Effect.fn(function* ({ id, olds = {} as ClusterProps, news }) {
885 if (!isResolved(news)) return;
886 if (
887 (yield* toClusterName(id, olds)) !==
888 (yield* toClusterName(id, news ?? {}))
889 ) {
890 return { action: "replace" } as const;
891 }
892 if (olds.roleArn !== news.roleArn) {
893 return { action: "replace" } as const;
894 }
895 if (
896 olds.accessConfig?.bootstrapClusterCreatorAdminPermissions !==
897 news.accessConfig?.bootstrapClusterCreatorAdminPermissions
898 ) {
899 return { action: "replace" } as const;
900 }
901 if (
902 olds.kubernetesNetworkConfig?.serviceIpv4Cidr !==
903 news.kubernetesNetworkConfig?.serviceIpv4Cidr ||
904 olds.kubernetesNetworkConfig?.ipFamily !==
905 news.kubernetesNetworkConfig?.ipFamily
906 ) {
907 return { action: "replace" } as const;
908 }
909 if (
910 olds.computeConfig?.nodeRoleArn !==
911 news.computeConfig?.nodeRoleArn &&
912 (olds.computeConfig?.enabled || news.computeConfig?.enabled)
913 ) {
914 return { action: "replace" } as const;
915 }
916 }),
917 read: Effect.fn(function* ({ id, olds, output }) {
918 const clusterName =
919 output?.clusterName ?? (yield* toClusterName(id, olds ?? {}));
920 const state = yield* readCluster({
921 clusterName,
922 kubernetesObjects: output?.kubernetesObjects,
923 managedRoles: output,
924 });
925 if (!state) return undefined;
926 return (yield* hasAlchemyTags(id, state.tags))
927 ? state
928 : Unowned(state);
929 }),
930 reconcile: Effect.fn(function* ({
931 id,
932 news,
933 output,
934 bindings,
935 session,
936 }) {
937 yield* validateProps(news);
938
939 const clusterName = yield* toClusterName(id, news);
940 const desiredObjects = getDesiredKubernetesObjects(bindings);
941 const desiredTags = {
942 ...(yield* createInternalTags(id)),
943 ...news.tags,
944 };
945
946 // Ensure `compute: "auto"` managed IAM roles before touching the
947 // cluster. Physical names are cached in the output so re-runs and
948 // updates converge on the same roles.
949 const auto = news.compute === "auto";
950 let managedClusterRoleName = output?.managedClusterRoleName;
951 let managedNodeRoleName = output?.managedNodeRoleName;
952 let roleArn = news.roleArn;
953 if (!roleArn && auto) {
954 managedClusterRoleName ??= yield* createPhysicalName({
955 id: `${id}-cluster-role`,
956 maxLength: 64,
957 });
958 roleArn = yield* ensureManagedRole({
959 id,
960 roleName: managedClusterRoleName,
961 service: "eks.amazonaws.com",
962 actions: ["sts:AssumeRole", "sts:TagSession"],
963 managedPolicyArns: autoClusterManagedPolicyArns,
964 userTags: news.tags,
965 });
966 } else if (roleArn) {
967 managedClusterRoleName = undefined;
968 }
969 let nodeRoleArn = news.computeConfig?.nodeRoleArn;
970 if (!nodeRoleArn && auto) {
971 managedNodeRoleName ??= yield* createPhysicalName({
972 id: `${id}-node-role`,
973 maxLength: 64,
974 });
975 nodeRoleArn = yield* ensureManagedRole({
976 id,
977 roleName: managedNodeRoleName,
978 service: "ec2.amazonaws.com",
979 actions: ["sts:AssumeRole"],
980 managedPolicyArns: autoNodeManagedPolicyArns,
981 userTags: news.tags,
982 });
983 } else if (nodeRoleArn) {
984 managedNodeRoleName = undefined;
985 }
986 if (!roleArn) {
987 return yield* Effect.fail(
988 new Error(
989 "AWS.EKS.Cluster requires roleArn unless compute is 'auto'",
990 ),
991 );
992 }
993 const managedRoles = { managedClusterRoleName, managedNodeRoleName };
994 const effective = applyAutoModeDefaults(news, {
995 roleArn,
996 nodeRoleArn,
997 });
998
999 // Observe — fetch live cloud state. We always fetch fresh so
1000 // adoption, drift, and partial-prior-runs all converge.
1001 let state = yield* readCluster({
1002 clusterName,
1003 kubernetesObjects: output?.kubernetesObjects,
1004 managedRoles,
1005 });
1006
1007 // Ensure — create cluster if missing. Tolerate
1008 // `ResourceInUseException` as a race with a peer reconciler:
1009 // re-read and continue with the sync path. The control plane
1010 // takes 10+ minutes; we wait for ACTIVE before any sync work.
1011 if (!state) {
1012 yield* eks
1013 .createCluster({
1014 name: clusterName,
1015 version: effective.version,
1016 roleArn: effective.roleArn,
1017 resourcesVpcConfig: effective.resourcesVpcConfig,
1018 kubernetesNetworkConfig: effective.kubernetesNetworkConfig,
1019 logging: effective.logging,
1020 accessConfig: effective.accessConfig,
1021 computeConfig: effective.computeConfig,
1022 storageConfig: effective.storageConfig,
1023 deletionProtection: effective.deletionProtection,
1024 upgradePolicy: effective.upgradePolicy,
1025 tags: desiredTags,
1026 clientRequestToken: yield* toClientRequestToken(id, "create"),
1027 })
1028 .pipe(
1029 Effect.catchTag("ResourceInUseException", () => Effect.void),
1030 );
1031
1032 yield* session.note(`Creating EKS cluster ${clusterName}...`);
1033 state = yield* waitForClusterActive(clusterName, [], managedRoles);
1034 }
1035
1036 const clusterArn = state.clusterArn;
1037
1038 // Sync cluster config — EKS's UpdateClusterConfig accepts exactly
1039 // ONE update category per call ("Only one type of update can be
1040 // allowed"), so we plan the observed↔desired drift per category and
1041 // issue one serialized updateClusterConfig per drifted category,
1042 // waiting for each async update to land (Successful + cluster back
1043 // to ACTIVE) before the next. Everything settable at create time is
1044 // already passed to createCluster, so a greenfield create plans
1045 // zero updates.
1046 for (const { category, request } of planClusterConfigUpdates(
1047 state,
1048 effective,
1049 )) {
1050 const configUpdate = yield* eks.updateClusterConfig({
1051 name: clusterName,
1052 ...request,
1053 clientRequestToken: yield* toClientRequestToken(
1054 id,
1055 `config-${category}`,
1056 ),
1057 });
1058 if (configUpdate.update?.id) {
1059 yield* session.note(
1060 `Updating EKS cluster ${category} config (${clusterName})...`,
1061 );
1062 yield* waitForUpdate(clusterName, configUpdate.update.id);
1063 state =
1064 (yield* waitForClusterActive(
1065 clusterName,
1066 output?.kubernetesObjects ?? [],
1067 managedRoles,
1068 )) ?? state;
1069 }
1070 }
1071
1072 // Sync version — observed ↔ desired.
1073 if (effective.version && state.version !== effective.version) {
1074 const versionUpdate = yield* eks.updateClusterVersion({
1075 name: clusterName,
1076 version: effective.version,
1077 clientRequestToken: yield* toClientRequestToken(id, "version"),
1078 });
1079 if (versionUpdate.update?.id) {
1080 yield* session.note(
1081 `Updating EKS cluster version ${clusterName}...`,
1082 );
1083 yield* waitForUpdate(clusterName, versionUpdate.update.id);
1084 state =
1085 (yield* waitForClusterActive(
1086 clusterName,
1087 output?.kubernetesObjects ?? [],
1088 managedRoles,
1089 )) ?? state;
1090 }
1091 }
1092
1093 // Sync tags — diff observed cloud tags against desired.
1094 const { removed, upsert } = diffTags(state.tags, desiredTags);
1095 if (upsert.length > 0) {
1096 yield* eks.tagResource({
1097 resourceArn: clusterArn,
1098 tags: Object.fromEntries(
1099 upsert.map((tag) => [tag.Key, tag.Value] as const),
1100 ),
1101 });
1102 }
1103 if (removed.length > 0) {
1104 yield* eks.untagResource({
1105 resourceArn: clusterArn,
1106 tagKeys: removed,
1107 });
1108 }
1109
1110 yield* session.note(clusterArn);
1111
1112 // Re-read final state so returned attributes reflect the post-
1113 // sync cloud state.
1114 const final = yield* readCluster({
1115 clusterName,
1116 kubernetesObjects: output?.kubernetesObjects ?? [],
1117 managedRoles,
1118 });
1119 if (!final) {
1120 return yield* Effect.fail(
1121 new Error(
1122 `EKS cluster '${clusterName}' could not be read after reconcile`,
1123 ),
1124 );
1125 }
1126
1127 const kubernetesObjects = yield* reconcileObjects({
1128 transport: yield* getKubernetesTransport(final),
1129 previousObjects: output?.kubernetesObjects ?? [],
1130 desiredObjects,
1131 });
1132
1133 return {
1134 ...final,
1135 kubernetesObjects,
1136 };
1137 }),
1138 delete: Effect.fn(function* ({ id, output }) {
1139 if ((output.kubernetesObjects ?? []).length > 0) {
1140 yield* deleteObjects({
1141 transport: yield* getKubernetesTransport(output),
1142 objects: output.kubernetesObjects ?? [],
1143 });
1144 }
1145
1146 if (output.deletionProtection) {
1147 const disableDeletionProtection = yield* eks.updateClusterConfig({
1148 name: output.clusterName,
1149 deletionProtection: false,
1150 clientRequestToken: yield* toClientRequestToken(
1151 id,
1152 "disable-deletion-protection",
1153 ),
1154 });
1155 if (disableDeletionProtection.update?.id) {
1156 yield* waitForUpdate(
1157 output.clusterName,
1158 disableDeletionProtection.update.id,
1159 );
1160 yield* waitForClusterActive(
1161 output.clusterName,
1162 output.kubernetesObjects ?? [],
1163 );
1164 }
1165 }
1166
1167 yield* eks
1168 .deleteCluster({
1169 name: output.clusterName,
1170 })
1171 .pipe(
1172 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
1173 );
1174
1175 yield* waitForClusterDeleted(output.clusterName);
1176
1177 // Delete the `compute: "auto"` managed IAM roles this cluster owns.
1178 if (output.managedClusterRoleName) {
1179 yield* deleteManagedRole(output.managedClusterRoleName);
1180 }
1181 if (output.managedNodeRoleName) {
1182 yield* deleteManagedRole(output.managedNodeRoleName);
1183 }
1184 }),
1185 };
1186 }),
1187 );