1import * as eks from "@distilled.cloud/aws/eks";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import type { Input } from "../../Input.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
13import type { Providers } from "../Providers.ts";
14
15export interface NodegroupProps {
16 /**
17 * Name of the EKS cluster that owns this managed node group.
18 */
19 clusterName: Input<string>;
20 /**
21 * Name of the managed node group. If omitted, a unique name is generated.
22 */
23 nodegroupName?: string;
24 /**
25 * ARN of the IAM role that provides permissions for the node group's worker
26 * nodes (the `NodeInstanceRole`). Changing this replaces the node group.
27 */
28 nodeRole: Input<string>;
29 /**
30 * Subnet IDs to launch worker nodes into. Changing this replaces the node
31 * group.
32 */
33 subnets: Input<string>[];
34 /**
35 * EC2 instance types for the node group. Changing this replaces the node
36 * group.
37 * @default ["t3.medium"]
38 */
39 instanceTypes?: string[];
40 /**
41 * Scaling configuration (min/max/desired node counts). Mutable — updated in
42 * place via `updateNodegroupConfig`.
43 */
44 scalingConfig?: eks.NodegroupScalingConfig;
45 /**
46 * AMI type for the node group's nodes (e.g. `AL2023_x86_64_STANDARD`,
47 * `BOTTLEROCKET_x86_64`). Changing this replaces the node group.
48 */
49 amiType?: eks.AMITypes;
50 /**
51 * Capacity type for the node group. Changing this replaces the node group.
52 * @default "ON_DEMAND"
53 */
54 capacityType?: eks.CapacityTypes;
55 /**
56 * Root device disk size in GiB. Changing this replaces the node group.
57 */
58 diskSize?: number;
59 /**
60 * Kubernetes labels applied to the nodes. Mutable — reconciled in place.
61 */
62 labels?: Record<string, string>;
63 /**
64 * Kubernetes taints applied to the nodes. Mutable — reconciled in place.
65 */
66 taints?: eks.Taint[];
67 /**
68 * Node group update configuration (`maxUnavailable` /
69 * `maxUnavailablePercentage`). Mutable — updated in place.
70 */
71 updateConfig?: eks.NodegroupUpdateConfig;
72 /**
73 * Remote SSH access configuration. Changing this replaces the node group.
74 */
75 remoteAccess?: eks.RemoteAccessConfig;
76 /**
77 * Kubernetes version for the node group. Mutable — upgraded in place via
78 * `updateNodegroupVersion`.
79 */
80 version?: string;
81 /**
82 * AMI release version for the node group. Mutable — upgraded in place.
83 */
84 releaseVersion?: string;
85 /**
86 * User-defined tags to apply to the node group.
87 */
88 tags?: Record<string, string>;
89}
90
91export interface Nodegroup extends Resource<
92 "AWS.EKS.Nodegroup",
93 NodegroupProps,
94 {
95 /** The name of the node group. */
96 nodegroupName: string;
97 /** The ARN of the node group. */
98 nodegroupArn: string;
99 /** The name of the EKS cluster the node group belongs to. */
100 clusterName: string;
101 /** The node group status (e.g. `CREATING`, `ACTIVE`, `UPDATING`). */
102 status: eks.NodegroupStatus;
103 /** The capacity type (`ON_DEMAND`, `SPOT`, or `CAPACITY_BLOCK`). */
104 capacityType: eks.CapacityTypes | undefined;
105 /** The scaling configuration (min/max/desired size). */
106 scalingConfig: eks.NodegroupScalingConfig | undefined;
107 /** The EC2 instance types the node group launches. */
108 instanceTypes: string[];
109 /** The IDs of the subnets nodes are launched into. */
110 subnets: string[];
111 /** The AMI type of the nodes (e.g. `AL2023_x86_64_STANDARD`). */
112 amiType: eks.AMITypes | undefined;
113 /** The ARN of the IAM role attached to the nodes. */
114 nodeRole: string;
115 /** The Kubernetes labels applied to nodes in the group. */
116 labels: Record<string, string>;
117 /** The Kubernetes taints applied to nodes in the group. */
118 taints: eks.Taint[];
119 /** The root device disk size in GiB, when not using a launch template. */
120 diskSize: number | undefined;
121 /** The Kubernetes version of the node group. */
122 version: string | undefined;
123 /** The AMI release version of the node group. */
124 releaseVersion: string | undefined;
125 /** The rolling-update configuration (max unavailable). */
126 updateConfig: eks.NodegroupUpdateConfig | undefined;
127 /** The tags applied to the node group. */
128 tags: Record<string, string>;
129 },
130 never,
131 Providers
132> {}
133
134/**
135 * An Amazon EKS managed node group — an EC2 Auto Scaling group of worker nodes
136 * managed by EKS for a cluster.
137 *
138 * Managed node groups provide the compute capacity a non-Auto-Mode EKS cluster
139 * needs to run pods. Creation is asynchronous (`CREATING` → `ACTIVE`, ~2–5 min)
140 * and the provider waits for the group to become `ACTIVE` before returning.
141 * Scaling, labels, taints, update config, and version are mutable in place;
142 * subnets, instance types, AMI type, disk size, node role, capacity type, and
143 * remote access are immutable and force a replacement.
144 * ### Creating Node Groups
145 * **Example:** Managed Node Group
146 * ```typescript
147 * const nodes = yield* Nodegroup("AppNodes", {
148 * clusterName: cluster.clusterName,
149 * nodeRole: nodeRole.roleArn,
150 * subnets: network.privateSubnetIds,
151 * instanceTypes: ["t3.medium"],
152 * scalingConfig: { minSize: 1, maxSize: 3, desiredSize: 2 },
153 * });
154 * ```
155 *
156 * **Example:** Spot Node Group with Labels and Taints
157 * ```typescript
158 * const spot = yield* Nodegroup("SpotNodes", {
159 * clusterName: cluster.clusterName,
160 * nodeRole: nodeRole.roleArn,
161 * subnets: network.privateSubnetIds,
162 * capacityType: "SPOT",
163 * instanceTypes: ["t3.large", "t3a.large"],
164 * scalingConfig: { minSize: 0, maxSize: 5, desiredSize: 1 },
165 * labels: { workload: "batch" },
166 * taints: [{ key: "spot", value: "true", effect: "NO_SCHEDULE" }],
167 * });
168 * ```
169 *
170 * @resource
171 */
172export const Nodegroup = Resource<Nodegroup>("AWS.EKS.Nodegroup");
173
174class NodegroupNotReady extends Data.TaggedError("EKS.NodegroupNotReady")<{
175 status: eks.NodegroupStatus | undefined;
176}> {}
177
178class NodegroupStillExists extends Data.TaggedError(
179 "EKS.NodegroupStillExists",
180)<{}> {}
181
182class NodegroupUpdateNotComplete extends Data.TaggedError(
183 "EKS.NodegroupUpdateNotComplete",
184)<{
185 status: eks.UpdateStatus | undefined;
186}> {}
187
188const normalizeTags = (tags: Record<string, string | undefined> | undefined) =>
189 Object.fromEntries(
190 Object.entries(tags ?? {}).filter(
191 (entry): entry is [string, string] => entry[1] !== undefined,
192 ),
193 );
194
195const normalizeLabels = (
196 labels: Record<string, string | undefined> | undefined,
197) =>
198 Object.fromEntries(
199 Object.entries(labels ?? {}).filter(
200 (entry): entry is [string, string] => entry[1] !== undefined,
201 ),
202 );
203
204// Wait budget: ~10 min at 5s spacing — node groups reach ACTIVE in 2–5 min.
205const waitSchedule = Schedule.max([
206 Schedule.spaced("5 seconds"),
207 Schedule.recurs(120),
208]);
209
210const taintKey = (taint: eks.Taint) =>
211 `${taint.key ?? ""}::${taint.effect ?? ""}`;
212
213/**
214 * Compute the `UpdateLabelsPayload` delta between observed and desired labels.
215 * Returns `undefined` when there is nothing to change.
216 */
217const diffLabels = (
218 observed: Record<string, string>,
219 desired: Record<string, string>,
220): eks.UpdateLabelsPayload | undefined => {
221 const addOrUpdateLabels: Record<string, string> = {};
222 for (const [key, value] of Object.entries(desired)) {
223 if (observed[key] !== value) {
224 addOrUpdateLabels[key] = value;
225 }
226 }
227 const removeLabels = Object.keys(observed).filter((key) => !(key in desired));
228 if (
229 Object.keys(addOrUpdateLabels).length === 0 &&
230 removeLabels.length === 0
231 ) {
232 return undefined;
233 }
234 return {
235 addOrUpdateLabels:
236 Object.keys(addOrUpdateLabels).length > 0 ? addOrUpdateLabels : undefined,
237 removeLabels: removeLabels.length > 0 ? removeLabels : undefined,
238 };
239};
240
241/**
242 * Compute the `UpdateTaintsPayload` delta between observed and desired taints,
243 * keyed by (key, effect). Returns `undefined` when there is nothing to change.
244 */
245const diffTaints = (
246 observed: eks.Taint[],
247 desired: eks.Taint[],
248): eks.UpdateTaintsPayload | undefined => {
249 const observedByKey = new Map(observed.map((t) => [taintKey(t), t]));
250 const desiredByKey = new Map(desired.map((t) => [taintKey(t), t]));
251
252 const addOrUpdateTaints = desired.filter((t) => {
253 const existing = observedByKey.get(taintKey(t));
254 return !existing || existing.value !== t.value;
255 });
256 const removeTaints = observed.filter((t) => !desiredByKey.has(taintKey(t)));
257
258 if (addOrUpdateTaints.length === 0 && removeTaints.length === 0) {
259 return undefined;
260 }
261 return {
262 addOrUpdateTaints:
263 addOrUpdateTaints.length > 0 ? addOrUpdateTaints : undefined,
264 removeTaints: removeTaints.length > 0 ? removeTaints : undefined,
265 };
266};
267
268const scalingConfigChanged = (
269 observed: eks.NodegroupScalingConfig | undefined,
270 desired: eks.NodegroupScalingConfig | undefined,
271) => {
272 if (!desired) return false;
273 return (
274 observed?.minSize !== desired.minSize ||
275 observed?.maxSize !== desired.maxSize ||
276 observed?.desiredSize !== desired.desiredSize
277 );
278};
279
280const updateConfigChanged = (
281 observed: eks.NodegroupUpdateConfig | undefined,
282 desired: eks.NodegroupUpdateConfig | undefined,
283) => {
284 if (!desired) return false;
285 return (
286 observed?.maxUnavailable !== desired.maxUnavailable ||
287 observed?.maxUnavailablePercentage !== desired.maxUnavailablePercentage ||
288 observed?.updateStrategy !== desired.updateStrategy
289 );
290};
291
292const mapNodegroup = (
293 nodegroup: eks.Nodegroup,
294 tags: Record<string, string>,
295): Nodegroup["Attributes"] => ({
296 nodegroupName: nodegroup.nodegroupName!,
297 nodegroupArn: nodegroup.nodegroupArn!,
298 clusterName: nodegroup.clusterName!,
299 status: nodegroup.status ?? "CREATING",
300 capacityType: nodegroup.capacityType,
301 scalingConfig: nodegroup.scalingConfig,
302 instanceTypes: nodegroup.instanceTypes ?? [],
303 subnets: nodegroup.subnets ?? [],
304 amiType: nodegroup.amiType,
305 nodeRole: nodegroup.nodeRole!,
306 labels: normalizeLabels(nodegroup.labels),
307 taints: nodegroup.taints ?? [],
308 diskSize: nodegroup.diskSize,
309 version: nodegroup.version,
310 releaseVersion: nodegroup.releaseVersion,
311 updateConfig: nodegroup.updateConfig,
312 tags,
313});
314
315export const NodegroupProvider = () =>
316 Provider.effect(
317 Nodegroup,
318 Effect.gen(function* () {
319 const toNodegroupName = (
320 id: string,
321 props: { nodegroupName?: string } = {},
322 ) =>
323 props.nodegroupName
324 ? Effect.succeed(props.nodegroupName)
325 : createPhysicalName({ id, maxLength: 63 });
326
327 const toClientRequestToken = (id: string, action: string) =>
328 createPhysicalName({
329 id: `${id}-${action}`,
330 maxLength: 64,
331 delimiter: "-",
332 });
333
334 const readNodegroup = Effect.fn(function* ({
335 clusterName,
336 nodegroupName,
337 }: {
338 clusterName: string;
339 nodegroupName: string;
340 }) {
341 const described = yield* eks
342 .describeNodegroup({ clusterName, nodegroupName })
343 .pipe(
344 Effect.catchTag("ResourceNotFoundException", () =>
345 Effect.succeed(undefined),
346 ),
347 );
348 const nodegroup = described?.nodegroup;
349 if (
350 !nodegroup?.nodegroupArn ||
351 !nodegroup.nodegroupName ||
352 !nodegroup.clusterName ||
353 !nodegroup.nodeRole
354 ) {
355 return undefined;
356 }
357 return mapNodegroup(nodegroup, normalizeTags(nodegroup.tags));
358 });
359
360 const waitForNodegroupActive = (
361 clusterName: string,
362 nodegroupName: string,
363 ) =>
364 readNodegroup({ clusterName, nodegroupName }).pipe(
365 Effect.flatMap((state) => {
366 if (!state) {
367 return Effect.fail(new NodegroupNotReady({ status: undefined }));
368 }
369 if (state.status === "ACTIVE") {
370 return Effect.succeed(state);
371 }
372 if (
373 state.status === "CREATE_FAILED" ||
374 state.status === "DELETE_FAILED"
375 ) {
376 return Effect.fail(
377 new Error(
378 `EKS node group '${nodegroupName}' entered ${state.status}`,
379 ),
380 );
381 }
382 return Effect.fail(new NodegroupNotReady({ status: state.status }));
383 }),
384 Effect.retry({
385 while: (error) => error instanceof NodegroupNotReady,
386 schedule: waitSchedule,
387 }),
388 );
389
390 const waitForNodegroupDeleted = (
391 clusterName: string,
392 nodegroupName: string,
393 ) =>
394 readNodegroup({ clusterName, nodegroupName }).pipe(
395 Effect.flatMap((state) =>
396 state
397 ? Effect.fail(new NodegroupStillExists())
398 : Effect.succeed(undefined),
399 ),
400 Effect.retry({
401 while: (error) => error instanceof NodegroupStillExists,
402 schedule: waitSchedule,
403 }),
404 );
405
406 const waitForUpdate = (
407 clusterName: string,
408 nodegroupName: string,
409 updateId: string,
410 ) =>
411 eks.describeUpdate({ name: clusterName, nodegroupName, updateId }).pipe(
412 Effect.flatMap(({ update }) => {
413 if (update?.status === "Successful") {
414 return Effect.succeed(update);
415 }
416 if (update?.status === "Failed" || update?.status === "Cancelled") {
417 return Effect.fail(
418 new Error(
419 `EKS node group update '${updateId}' failed with status '${update?.status}'`,
420 ),
421 );
422 }
423 return Effect.fail(
424 new NodegroupUpdateNotComplete({ status: update?.status }),
425 );
426 }),
427 Effect.retry({
428 while: (error) => error instanceof NodegroupUpdateNotComplete,
429 schedule: waitSchedule,
430 }),
431 );
432
433 return {
434 stables: ["nodegroupArn", "nodegroupName", "clusterName"],
435 // Enumerate every node group across the account/region. `listNodegroups`
436 // is cluster-scoped, so first enumerate all clusters, list each
437 // cluster's node groups, then hydrate each via `describeNodegroup`.
438 list: () =>
439 Effect.gen(function* () {
440 const clusterNames = yield* eks.listClusters.pages({}).pipe(
441 Stream.runCollect,
442 Effect.map((chunk) =>
443 Array.from(chunk).flatMap((page) => page.clusters ?? []),
444 ),
445 );
446
447 const perCluster = yield* Effect.forEach(
448 clusterNames,
449 (clusterName) =>
450 eks.listNodegroups.pages({ clusterName }).pipe(
451 Stream.runCollect,
452 Effect.map((chunk) =>
453 Array.from(chunk).flatMap((page) => page.nodegroups ?? []),
454 ),
455 Effect.flatMap((names) =>
456 Effect.forEach(
457 names,
458 (nodegroupName) =>
459 readNodegroup({ clusterName, nodegroupName }),
460 { concurrency: 5 },
461 ),
462 ),
463 ),
464 { concurrency: 5 },
465 );
466
467 return perCluster
468 .flat()
469 .filter(
470 (state): state is Nodegroup["Attributes"] =>
471 state !== undefined,
472 );
473 }),
474 diff: Effect.fn(function* ({ id, olds = {} as NodegroupProps, news }) {
475 if (!isResolved(news)) return;
476 if (
477 (yield* toNodegroupName(id, olds)) !==
478 (yield* toNodegroupName(id, news ?? {}))
479 ) {
480 return { action: "replace" } as const;
481 }
482 if (olds.clusterName !== news.clusterName) {
483 return { action: "replace" } as const;
484 }
485 if (olds.nodeRole !== news.nodeRole) {
486 return { action: "replace" } as const;
487 }
488 if (
489 JSON.stringify(olds.subnets ?? []) !==
490 JSON.stringify(news.subnets ?? [])
491 ) {
492 return { action: "replace" } as const;
493 }
494 if (
495 JSON.stringify(olds.instanceTypes ?? []) !==
496 JSON.stringify(news.instanceTypes ?? [])
497 ) {
498 return { action: "replace" } as const;
499 }
500 if ((olds.amiType ?? undefined) !== (news.amiType ?? undefined)) {
501 return { action: "replace" } as const;
502 }
503 if ((olds.diskSize ?? undefined) !== (news.diskSize ?? undefined)) {
504 return { action: "replace" } as const;
505 }
506 if (
507 (olds.capacityType ?? "ON_DEMAND") !==
508 (news.capacityType ?? "ON_DEMAND")
509 ) {
510 return { action: "replace" } as const;
511 }
512 if (
513 JSON.stringify(olds.remoteAccess ?? undefined) !==
514 JSON.stringify(news.remoteAccess ?? undefined)
515 ) {
516 return { action: "replace" } as const;
517 }
518 }),
519 read: Effect.fn(function* ({ id, olds, output }) {
520 const clusterName = (output?.clusterName ??
521 (olds?.clusterName as string | undefined)) as string | undefined;
522 if (!clusterName) return undefined;
523 const nodegroupName =
524 output?.nodegroupName ?? (yield* toNodegroupName(id, olds ?? {}));
525 const state = yield* readNodegroup({ clusterName, nodegroupName });
526 if (!state) return undefined;
527 return (yield* hasAlchemyTags(id, state.tags))
528 ? state
529 : Unowned(state);
530 }),
531 reconcile: Effect.fn(function* ({ id, news, output, session }) {
532 const clusterName = news.clusterName as string;
533 const nodegroupName = yield* toNodegroupName(id, news);
534 const desiredTags = {
535 ...(yield* createInternalTags(id)),
536 ...news.tags,
537 };
538
539 // Observe — cloud state is authoritative.
540 let state = yield* readNodegroup({ clusterName, nodegroupName });
541
542 // Ensure — create if missing; tolerate a create race, then wait for
543 // the node group to reach ACTIVE before syncing.
544 if (!state) {
545 yield* eks
546 .createNodegroup({
547 clusterName,
548 nodegroupName,
549 nodeRole: news.nodeRole as string,
550 subnets: news.subnets as string[],
551 instanceTypes: news.instanceTypes,
552 scalingConfig: news.scalingConfig,
553 amiType: news.amiType,
554 capacityType: news.capacityType,
555 diskSize: news.diskSize,
556 labels: news.labels,
557 taints: news.taints,
558 updateConfig: news.updateConfig,
559 remoteAccess: news.remoteAccess,
560 version: news.version,
561 releaseVersion: news.releaseVersion,
562 tags: desiredTags,
563 clientRequestToken: yield* toClientRequestToken(id, "create"),
564 })
565 .pipe(
566 Effect.catchTag("ResourceInUseException", () => Effect.void),
567 );
568
569 yield* session.note(`Creating EKS node group ${nodegroupName}...`);
570 state = yield* waitForNodegroupActive(clusterName, nodegroupName);
571 }
572
573 // Sync mutable config (scaling, updateConfig, labels, taints) in a
574 // single updateNodegroupConfig call when anything changed.
575 const desiredLabels = normalizeLabels(news.labels);
576 const labelsDelta = diffLabels(state.labels, desiredLabels);
577 const taintsDelta = diffTaints(state.taints, news.taints ?? []);
578 const scalingChanged = scalingConfigChanged(
579 state.scalingConfig,
580 news.scalingConfig,
581 );
582 const updateCfgChanged = updateConfigChanged(
583 state.updateConfig,
584 news.updateConfig,
585 );
586 if (
587 labelsDelta ||
588 taintsDelta ||
589 scalingChanged ||
590 updateCfgChanged
591 ) {
592 const configUpdate = yield* eks.updateNodegroupConfig({
593 clusterName,
594 nodegroupName,
595 scalingConfig: scalingChanged ? news.scalingConfig : undefined,
596 updateConfig: updateCfgChanged ? news.updateConfig : undefined,
597 labels: labelsDelta,
598 taints: taintsDelta,
599 clientRequestToken: yield* toClientRequestToken(id, "config"),
600 });
601 if (configUpdate.update?.id) {
602 yield* session.note(
603 `Updating EKS node group config ${nodegroupName}...`,
604 );
605 yield* waitForUpdate(
606 clusterName,
607 nodegroupName,
608 configUpdate.update.id,
609 );
610 state =
611 (yield* waitForNodegroupActive(clusterName, nodegroupName)) ??
612 state;
613 }
614 }
615
616 // Sync version / release version via updateNodegroupVersion.
617 if (
618 (news.version && state.version !== news.version) ||
619 (news.releaseVersion &&
620 state.releaseVersion !== news.releaseVersion)
621 ) {
622 const versionUpdate = yield* eks.updateNodegroupVersion({
623 clusterName,
624 nodegroupName,
625 version: news.version,
626 releaseVersion: news.releaseVersion,
627 clientRequestToken: yield* toClientRequestToken(id, "version"),
628 });
629 if (versionUpdate.update?.id) {
630 yield* session.note(
631 `Updating EKS node group version ${nodegroupName}...`,
632 );
633 yield* waitForUpdate(
634 clusterName,
635 nodegroupName,
636 versionUpdate.update.id,
637 );
638 state =
639 (yield* waitForNodegroupActive(clusterName, nodegroupName)) ??
640 state;
641 }
642 }
643
644 // Sync tags — diff observed cloud tags against desired.
645 const { removed, upsert } = diffTags(state.tags, desiredTags);
646 if (upsert.length > 0) {
647 yield* eks.tagResource({
648 resourceArn: state.nodegroupArn,
649 tags: Object.fromEntries(
650 upsert.map((tag) => [tag.Key, tag.Value] as const),
651 ),
652 });
653 }
654 if (removed.length > 0) {
655 yield* eks.untagResource({
656 resourceArn: state.nodegroupArn,
657 tagKeys: removed,
658 });
659 }
660
661 yield* session.note(state.nodegroupArn);
662
663 const final = yield* readNodegroup({ clusterName, nodegroupName });
664 if (!final) {
665 return yield* Effect.fail(
666 new Error(
667 `EKS node group '${nodegroupName}' could not be read after reconcile`,
668 ),
669 );
670 }
671 return final;
672 }),
673 delete: Effect.fn(function* ({ output }) {
674 yield* eks
675 .deleteNodegroup({
676 clusterName: output.clusterName,
677 nodegroupName: output.nodegroupName,
678 })
679 .pipe(
680 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
681 );
682 yield* waitForNodegroupDeleted(
683 output.clusterName,
684 output.nodegroupName,
685 );
686 }),
687 };
688 }),
689 );