1import * as container from "@distilled.cloud/gcp/container_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21
22// A zone, not a region: `GCP.Region` has no zone and a regional default would triple node count.
23const DEFAULT_LOCATION = "us-central1-a";
24const DEFAULT_MACHINE_TYPE = "e2-medium";
25const DEFAULT_DISK_TYPE = "pd-standard";
26const DEFAULT_DISK_SIZE_GB = 20;
27const DEFAULT_NODE_COUNT = 1;
28const MAX_NAME_LENGTH = 40;
29
30export type NodePoolAutoscaling = {
31 /** Enable the cluster autoscaler for this pool. */
32 enabled?: boolean;
33 /** Minimum nodes per zone. Mutually exclusive with `totalMinNodeCount`. */
34 minNodeCount?: number;
35 /** Maximum nodes per zone. Mutually exclusive with `totalMaxNodeCount`. */
36 maxNodeCount?: number;
37 /** Minimum nodes across all zones. */
38 totalMinNodeCount?: number;
39 /** Maximum nodes across all zones. */
40 totalMaxNodeCount?: number;
41 /** Scale-up location policy (`BALANCED`, `ANY`). */
42 locationPolicy?:
43 | container.NodePoolAutoscalingLocationPolicyEnum
44 | (string & {});
45 /** Allow NAP to delete this pool. */
46 autoprovisioned?: boolean;
47};
48
49export type NodePoolManagement = {
50 /** Automatically repair unhealthy nodes. */
51 autoRepair?: boolean;
52 /** Automatically upgrade nodes. */
53 autoUpgrade?: boolean;
54};
55
56export type NodePoolUpgradeSettings = {
57 /** Extra nodes created during an upgrade. */
58 maxSurge?: number;
59 /** Nodes allowed to be unavailable during an upgrade. */
60 maxUnavailable?: number;
61 /** Upgrade strategy (`SURGE`, `BLUE_GREEN`). */
62 strategy?: container.UpgradeSettingsStrategyEnum | (string & {});
63};
64
65export type NodePoolTaint = {
66 /** Taint key. */
67 key?: string;
68 /** Taint value. */
69 value?: string;
70 /** Taint effect (`NO_SCHEDULE`, `PREFER_NO_SCHEDULE`, `NO_EXECUTE`). */
71 effect?: container.NodeTaintEffectEnum | (string & {});
72};
73
74export type NodePoolProps = {
75 /**
76 * Parent cluster id or full resource name
77 * (`projects/{project}/locations/{location}/clusters/{cluster}`).
78 * Immutable — changing it replaces the node pool.
79 */
80 cluster: string;
81 /**
82 * Zone or region of the parent cluster (`us-central1-a`, `us-central1`,
83 * …). Ignored when `cluster` is a full resource name. Immutable —
84 * changing it replaces the node pool.
85 * @default "us-central1-a"
86 */
87 location?: string;
88 /**
89 * Node pool id (the `{nodePool}` segment of
90 * `.../clusters/{cluster}/nodePools/{nodePool}`). If omitted, a unique
91 * RFC1035 name is generated. Must be 1-40 characters and match
92 * `[a-z]([a-z0-9-]*[a-z0-9])?`. Immutable — changing it replaces the
93 * pool.
94 */
95 nodePoolId?: string;
96 /**
97 * Desired node count (per zone for zonal/multi-zonal pools). Create
98 * uses `initialNodeCount`; later changes call `setSize`. Ignored while
99 * autoscaling is enabled.
100 * @default 1
101 */
102 nodeCount?: number;
103 /**
104 * User labels stored as GKE `config.resourceLabels` on the pool's GCE
105 * VMs. Alchemy ownership labels are merged in automatically.
106 */
107 labels?: Record<string, string>;
108 /**
109 * Machine type (`e2-medium`, `e2-small`, …).
110 * @default "e2-medium"
111 */
112 machineType?: string;
113 /**
114 * Boot disk size in GB. Smallest allowed is 10.
115 * @default 20
116 */
117 diskSizeGb?: number;
118 /**
119 * Boot disk type (`pd-standard`, `pd-balanced`, `pd-ssd`).
120 * @default "pd-standard"
121 */
122 diskType?: string;
123 /**
124 * Node image type (`COS_CONTAINERD`, `UBUNTU_CONTAINERD`, …).
125 */
126 imageType?: string;
127 /**
128 * Kubernetes version for nodes (or alias such as `"-"` for the
129 * control-plane version).
130 */
131 version?: string;
132 /**
133 * Use Spot VMs. Immutable — changing it replaces the pool.
134 * @default false
135 */
136 spot?: boolean;
137 /**
138 * Use preemptible VMs. Immutable — changing it replaces the pool.
139 * @default false
140 */
141 preemptible?: boolean;
142 /**
143 * Zones in which this pool's nodes should run.
144 */
145 nodeLocations?: string[];
146 /**
147 * Kubernetes labels applied to each node.
148 */
149 nodeLabels?: Record<string, string>;
150 /**
151 * Network tags applied to each node.
152 */
153 tags?: string[];
154 /**
155 * Kubernetes taints applied to each node.
156 */
157 taints?: NodePoolTaint[];
158 /**
159 * OAuth scopes for the node service account. Immutable — changing them
160 * replaces the pool.
161 */
162 oauthScopes?: string[];
163 /**
164 * Service account email for node VMs. Immutable — changing it replaces
165 * the pool.
166 */
167 serviceAccount?: string;
168 /**
169 * Local SSD count. Immutable — changing it replaces the pool.
170 */
171 localSsdCount?: number;
172 /**
173 * Customer-managed KMS key for boot disks. Immutable — changing it
174 * replaces the pool.
175 */
176 bootDiskKmsKey?: string;
177 /**
178 * Max pods per node. Immutable — changing it replaces the pool.
179 */
180 maxPodsPerNode?: number;
181 /**
182 * Cluster autoscaler settings for this pool.
183 */
184 autoscaling?: NodePoolAutoscaling;
185 /**
186 * Auto-repair / auto-upgrade settings.
187 */
188 management?: NodePoolManagement;
189 /**
190 * Upgrade surge settings.
191 */
192 upgradeSettings?: NodePoolUpgradeSettings;
193};
194
195export type NodePool = Resource<
196 "GCP.Container.NodePool",
197 NodePoolProps,
198 {
199 /** Full resource name `projects/{project}/locations/{location}/clusters/{cluster}/nodePools/{nodePool}`. */
200 name: string;
201 /** Node pool id (last path segment). */
202 nodePoolId: string;
203 /** Parent cluster id. */
204 clusterId: string;
205 /** Parent cluster resource name. */
206 clusterName: string;
207 /** Project id. */
208 project: string;
209 /** Zone or region id. */
210 location: string;
211 /** Server-reported status (`RUNNING`, `PROVISIONING`, …). */
212 status: string | undefined;
213 /** Kubernetes version currently on the nodes. */
214 version: string | undefined;
215 /** Desired / initial node count. */
216 nodeCount: number;
217 /** Machine type. */
218 machineType: string | undefined;
219 /** Boot disk size in GB. */
220 diskSizeGb: number | undefined;
221 /** Boot disk type. */
222 diskType: string | undefined;
223 /** Node image type. */
224 imageType: string | undefined;
225 /** Whether nodes are Spot VMs. */
226 spot: boolean;
227 /** Whether nodes are preemptible VMs. */
228 preemptible: boolean;
229 /** User GCP labels (Alchemy ownership labels stripped). */
230 labels: Record<string, string>;
231 /** Kubernetes node labels. */
232 nodeLabels: Record<string, string>;
233 /** Network tags. */
234 tags: string[];
235 /** Node locations. */
236 nodeLocations: string[];
237 /** Autoscaler settings currently applied. */
238 autoscaling: NodePoolAutoscaling | undefined;
239 /** Management settings currently applied. */
240 management: NodePoolManagement | undefined;
241 /** Managed instance group URLs. */
242 instanceGroupUrls: string[];
243 /** Server-defined URL. */
244 selfLink: string | undefined;
245 },
246 never,
247 Providers
248>;
249
250/**
251 * A Google Kubernetes Engine node pool.
252 *
253 * Node pools belong to a {@link Cluster}. Changing `cluster`, `location`,
254 * `nodePoolId`, `spot`, `preemptible`, `serviceAccount`, `oauthScopes`,
255 * `localSsdCount`, `bootDiskKmsKey`, or `maxPodsPerNode` replaces the
256 * pool. Size, labels, machine type, disk, image, version, management,
257 * autoscaling, and upgrade settings update in place.
258 *
259 * Provisioning typically takes several minutes.
260 *
261 * ### Creating a Node Pool
262 * **Example:** Generated name on an existing cluster
263 * ```typescript
264 * const cluster = yield* GCP.Container.Cluster("App", {});
265 * const pool = yield* GCP.Container.NodePool("Workers", {
266 * cluster: cluster.clusterId,
267 * location: cluster.location,
268 * });
269 * ```
270 *
271 * **Example:** Explicit id, Spot nodes, and labels
272 * ```typescript
273 * const pool = yield* GCP.Container.NodePool("Workers", {
274 * cluster: cluster.name,
275 * nodePoolId: "app-workers",
276 * nodeCount: 1,
277 * machineType: "e2-small",
278 * diskSizeGb: 20,
279 * spot: true,
280 * labels: { env: "prod" },
281 * });
282 * ```
283 *
284 * ### Autoscaling
285 * **Example:** Per-zone autoscaling
286 * ```typescript
287 * const pool = yield* GCP.Container.NodePool("Workers", {
288 * cluster: cluster.clusterId,
289 * location: cluster.location,
290 * autoscaling: { enabled: true, minNodeCount: 1, maxNodeCount: 3 },
291 * });
292 * ```
293 *
294 * ### Reading a Node Pool
295 * **Example:** Get the bound node pool
296 * ```typescript
297 * const getNodePool = yield* GCP.Container.GetNodePool(pool);
298 * const live = yield* getNodePool();
299 * ```
300 *
301 * @resource
302 * @category Container
303 */
304export const NodePool = Resource<NodePool>("GCP.Container.NodePool");
305
306export class NodePoolNotResolved extends Data.TaggedError(
307 "GCP.Container.NodePoolNotResolved",
308)<{
309 name: string;
310}> {}
311
312export class NodePoolClusterMissing extends Data.TaggedError(
313 "GCP.Container.NodePoolClusterMissing",
314)<{
315 message: string;
316}> {}
317
318export class NodePoolNotReady extends Data.TaggedError(
319 "GCP.Container.NodePoolNotReady",
320)<{
321 name: string;
322 status: string;
323}> {}
324
325export class NodePoolOperationFailed extends Data.TaggedError(
326 "GCP.Container.NodePoolOperationFailed",
327)<{
328 operation: string;
329 message: string;
330}> {}
331
332export class NodePoolStillExists extends Data.TaggedError(
333 "GCP.Container.NodePoolStillExists",
334)<{
335 name: string;
336}> {}
337
338const lastSegment = (value: string) => {
339 const trimmed = value.replace(/\/+$/, "");
340 const parts = trimmed.split("/");
341 return parts[parts.length - 1] || trimmed;
342};
343
344const normalizeLocation = (location: string | undefined) =>
345 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
346
347const rfc1035 = (name: string): string => {
348 let next = name
349 .toLowerCase()
350 .replace(/[^a-z0-9-]/g, "-")
351 .replace(/-+/g, "-")
352 .replace(/^-+|-+$/g, "");
353 if (!/^[a-z]/.test(next)) next = `n${next}`;
354 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
355 if (next.length === 0) return "nodepool";
356 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
357 return next.slice(0, MAX_NAME_LENGTH);
358};
359
360const clusterNameOf = (project: string, location: string, clusterId: string) =>
361 `projects/${project}/locations/${location}/clusters/${clusterId}`;
362
363const resourceName = (
364 project: string,
365 location: string,
366 clusterId: string,
367 nodePoolId: string,
368) => `${clusterNameOf(project, location, clusterId)}/nodePools/${nodePoolId}`;
369
370const parseName = (name: string) => {
371 const idx = name.indexOf("projects/");
372 const path = idx >= 0 ? name.slice(idx) : name;
373 const parts = path.split("/").filter((part) => part.length > 0);
374 const poolsAt = parts.lastIndexOf("nodePools");
375 const clustersAt = parts.lastIndexOf("clusters");
376 // `selfLink` spells the location as `zones/{zone}` (zonal clusters) or
377 // `regions/{region}`; resource names use `locations/{location}`.
378 const locationsAt = Math.max(
379 parts.lastIndexOf("locations"),
380 parts.lastIndexOf("zones"),
381 parts.lastIndexOf("regions"),
382 );
383 const projectsAt = parts.lastIndexOf("projects");
384 return {
385 project:
386 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
387 location:
388 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
389 clusterId:
390 clustersAt >= 0 && parts[clustersAt + 1] ? parts[clustersAt + 1]! : "",
391 nodePoolId:
392 poolsAt >= 0 && parts[poolsAt + 1]
393 ? parts[poolsAt + 1]!
394 : lastSegment(name),
395 };
396};
397
398const parseClusterRef = (
399 cluster: string,
400 fallbackProject: string,
401 fallbackLocation: string | undefined,
402) => {
403 const trimmed = cluster.trim();
404 if (trimmed.includes("/clusters/") || trimmed.includes("projects/")) {
405 const parsed = parseName(
406 trimmed.includes("/nodePools/") ? trimmed : `${trimmed}/nodePools/_`,
407 );
408 return {
409 project: parsed.project || fallbackProject,
410 location: normalizeLocation(parsed.location || fallbackLocation),
411 clusterId: parsed.clusterId,
412 };
413 }
414 return {
415 project: fallbackProject,
416 location: normalizeLocation(fallbackLocation),
417 clusterId: lastSegment(trimmed),
418 };
419};
420
421const userLabels = (
422 labels: Record<string, string | undefined> | null | undefined,
423): Record<string, string> => stripInternalLabels(tagRecord(labels));
424
425const stringMap = (
426 labels: Record<string, string | undefined> | null | undefined,
427): Record<string, string> => tagRecord(labels);
428
429const sameStrings = (left: readonly string[], right: readonly string[]) => {
430 if (left.length !== right.length) return false;
431 const a = [...left].map((item) => item.toLowerCase()).sort();
432 const b = [...right].map((item) => item.toLowerCase()).sort();
433 return a.every((value, index) => value === b[index]);
434};
435
436const sameStringMap = (
437 left: Record<string, string> | undefined,
438 right: Record<string, string> | undefined,
439) => {
440 const a = stringMap(left);
441 const b = stringMap(right);
442 const keys = Object.keys(a);
443 return (
444 keys.length === Object.keys(b).length &&
445 keys.every((key) => a[key] === b[key])
446 );
447};
448
449const taintKey = (taint: NodePoolTaint) =>
450 `${taint.key ?? ""}\0${taint.value ?? ""}\0${(taint.effect ?? "").toUpperCase()}`;
451
452const sameTaints = (
453 left: readonly NodePoolTaint[] | undefined,
454 right: readonly NodePoolTaint[] | undefined,
455) => {
456 const a = [...(left ?? [])].map(taintKey).sort();
457 const b = [...(right ?? [])].map(taintKey).sort();
458 return a.length === b.length && a.every((value, index) => value === b[index]);
459};
460
461const autoscalingKey = (value: NodePoolAutoscaling | undefined) =>
462 JSON.stringify({
463 enabled: value?.enabled === true,
464 minNodeCount: value?.minNodeCount ?? 0,
465 maxNodeCount: value?.maxNodeCount ?? 0,
466 totalMinNodeCount: value?.totalMinNodeCount ?? 0,
467 totalMaxNodeCount: value?.totalMaxNodeCount ?? 0,
468 locationPolicy: (value?.locationPolicy ?? "").toUpperCase(),
469 autoprovisioned: value?.autoprovisioned === true,
470 });
471
472const managementKey = (value: NodePoolManagement | undefined) =>
473 JSON.stringify({
474 autoRepair: value?.autoRepair === true,
475 autoUpgrade: value?.autoUpgrade === true,
476 });
477
478const upgradeKey = (value: NodePoolUpgradeSettings | undefined) =>
479 JSON.stringify({
480 maxSurge: value?.maxSurge ?? 0,
481 maxUnavailable: value?.maxUnavailable ?? 0,
482 strategy: (value?.strategy ?? "").toUpperCase(),
483 });
484
485const toId = (id: string, nodePoolId: string | undefined, existing?: string) =>
486 Effect.gen(function* () {
487 return (
488 nodePoolId ??
489 existing ??
490 rfc1035(
491 yield* createPhysicalName({
492 id,
493 maxLength: MAX_NAME_LENGTH,
494 lowercase: true,
495 }),
496 )
497 );
498 });
499
500const toAttrs = (
501 pool: container.NodePool,
502 project: string,
503 location: string,
504 clusterId: string,
505) => {
506 const selfLink = pool.selfLink ?? "";
507 const parsed = parseName(
508 selfLink.length > 0
509 ? selfLink
510 : resourceName(project, location, clusterId, pool.name ?? ""),
511 );
512 const nodePoolId = pool.name || parsed.nodePoolId;
513 const resolvedProject = parsed.project || project;
514 const resolvedLocation = normalizeLocation(parsed.location || location);
515 const resolvedCluster = parsed.clusterId || clusterId;
516 const config = pool.config;
517 return {
518 name: resourceName(
519 resolvedProject,
520 resolvedLocation,
521 resolvedCluster,
522 nodePoolId,
523 ),
524 nodePoolId,
525 clusterId: resolvedCluster,
526 clusterName: clusterNameOf(
527 resolvedProject,
528 resolvedLocation,
529 resolvedCluster,
530 ),
531 project: resolvedProject,
532 location: resolvedLocation,
533 status: pool.status,
534 version: pool.version,
535 // The API omits a zero count.
536 nodeCount: pool.initialNodeCount ?? 0,
537 machineType: config?.machineType,
538 diskSizeGb: config?.diskSizeGb,
539 diskType: config?.diskType,
540 imageType: config?.imageType,
541 spot: config?.spot === true,
542 preemptible: config?.preemptible === true,
543 labels: userLabels(config?.resourceLabels),
544 nodeLabels: stringMap(config?.labels),
545 tags: [...(config?.tags ?? [])],
546 nodeLocations: [...(pool.locations ?? [])],
547 autoscaling: pool.autoscaling,
548 management: pool.management
549 ? {
550 autoRepair: pool.management.autoRepair,
551 autoUpgrade: pool.management.autoUpgrade,
552 }
553 : undefined,
554 instanceGroupUrls: [...(pool.instanceGroupUrls ?? [])],
555 selfLink: pool.selfLink,
556 };
557};
558
559const getByName = (name: string) =>
560 container
561 .getProjectsLocationsClustersNodePools({ name })
562 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
563
564const operationResourceName = (
565 project: string,
566 location: string,
567 operation: container.Operation,
568) => {
569 const raw = operation.name ?? "";
570 if (raw.includes("/operations/")) {
571 const idx = raw.indexOf("projects/");
572 return idx >= 0 ? raw.slice(idx) : raw;
573 }
574 const fromLink = operation.selfLink ?? "";
575 if (fromLink.includes("/operations/")) {
576 const idx = fromLink.indexOf("projects/");
577 return idx >= 0 ? fromLink.slice(idx) : fromLink;
578 }
579 const loc = lastSegment(operation.location ?? operation.zone ?? location);
580 return `projects/${project}/locations/${loc}/operations/${lastSegment(raw)}`;
581};
582
583/**
584 * Wait for a GKE node-pool operation (create/delete takes several
585 * minutes). ALREADY_EXISTS (code 6) counts as success (create race); with
586 * `notFoundOk`, so does NOT_FOUND (code 5, delete race) and an operation
587 * that is already gone.
588 */
589const waitForOperation = (
590 project: string,
591 location: string,
592 operation: container.Operation,
593 options?: { notFoundOk?: boolean },
594) =>
595 waitForGcpOperation(
596 {
597 ...operation,
598 name: operationResourceName(project, location, operation),
599 },
600 (name) => container.getProjectsLocationsOperations({ name }),
601 { budget: "20 minutes", interval: "10 seconds" },
602 ).pipe(
603 Effect.catchIf(
604 (error) =>
605 (error._tag === "GCP.OperationFailed" &&
606 (error.code === 6 ||
607 (options?.notFoundOk === true && error.code === 5))) ||
608 (options?.notFoundOk === true && error._tag === "NotFound"),
609 () => Effect.succeed(operation),
610 ),
611 );
612
613const waitUntilExists = (name: string) =>
614 getByName(name).pipe(
615 Effect.filterOrFail(
616 (pool): pool is container.NodePool => pool !== undefined,
617 () => new NodePoolNotResolved({ name }),
618 ),
619 Effect.retry({
620 while: (error) => error._tag === "GCP.Container.NodePoolNotResolved",
621 times: 8,
622 schedule: Schedule.spaced("1 second"),
623 }),
624 );
625
626const waitUntilReady = (name: string) =>
627 getByName(name).pipe(
628 Effect.filterOrFail(
629 (pool): pool is container.NodePool => pool !== undefined,
630 () => new NodePoolNotResolved({ name }),
631 ),
632 Effect.filterOrFail(
633 (pool) => {
634 const status = pool.status ?? "STATUS_UNSPECIFIED";
635 return status !== "ERROR" && status !== "STOPPING";
636 },
637 (pool) =>
638 new NodePoolOperationFailed({
639 operation: name,
640 message:
641 pool.statusMessage ??
642 `node pool is in ${pool.status ?? "STATUS_UNSPECIFIED"}`,
643 }),
644 ),
645 Effect.filterOrFail(
646 (pool) => {
647 const status = pool.status ?? "STATUS_UNSPECIFIED";
648 return status === "RUNNING" || status === "RUNNING_WITH_ERROR";
649 },
650 (pool) =>
651 new NodePoolNotReady({
652 name,
653 status: pool.status ?? "STATUS_UNSPECIFIED",
654 }),
655 ),
656 Effect.retry({
657 while: (error) =>
658 error._tag === "GCP.Container.NodePoolNotReady" ||
659 error._tag === "GCP.Container.NodePoolNotResolved",
660 // Node pool create/delete takes several minutes.
661 times: 60,
662 schedule: Schedule.spaced("10 seconds"),
663 }),
664 );
665
666const waitUntilGone = (name: string) =>
667 getByName(name).pipe(
668 Effect.flatMap((pool) =>
669 pool === undefined
670 ? Effect.void
671 : Effect.fail(new NodePoolStillExists({ name })),
672 ),
673 Effect.retry({
674 while: (error) => error._tag === "GCP.Container.NodePoolStillExists",
675 // Node pool create/delete takes several minutes.
676 times: 60,
677 schedule: Schedule.spaced("10 seconds"),
678 }),
679 );
680
681const retryConflict = <A, E extends { readonly _tag: string }, R>(
682 effect: Effect.Effect<A, E, R>,
683) =>
684 effect.pipe(
685 Effect.retry({
686 while: (error) => error._tag === "Conflict",
687 times: 8,
688 schedule: Schedule.spaced("5 seconds"),
689 }),
690 );
691
692const toCreatePool = (
693 news: NodePoolProps,
694 nodePoolId: string,
695 desiredLabels: Record<string, string>,
696 workloadIdentity: boolean,
697): container.NodePool => ({
698 name: nodePoolId,
699 initialNodeCount: news.nodeCount ?? DEFAULT_NODE_COUNT,
700 config: {
701 machineType: news.machineType ?? DEFAULT_MACHINE_TYPE,
702 diskSizeGb: news.diskSizeGb ?? DEFAULT_DISK_SIZE_GB,
703 diskType: news.diskType ?? DEFAULT_DISK_TYPE,
704 imageType: news.imageType,
705 spot: news.spot === true,
706 preemptible: news.preemptible === true,
707 resourceLabels: desiredLabels,
708 labels: news.nodeLabels,
709 tags: news.tags,
710 taints: news.taints,
711 oauthScopes: news.oauthScopes,
712 serviceAccount: news.serviceAccount,
713 localSsdCount: news.localSsdCount,
714 bootDiskKmsKey: news.bootDiskKmsKey,
715 // GKE rejects GKE_METADATA on clusters without Workload Identity.
716 workloadMetadataConfig: workloadIdentity
717 ? { mode: "GKE_METADATA" }
718 : undefined,
719 },
720 autoscaling: news.autoscaling,
721 management: news.management,
722 version: news.version,
723 locations: news.nodeLocations,
724 maxPodsConstraint:
725 news.maxPodsPerNode !== undefined
726 ? { maxPodsPerNode: String(news.maxPodsPerNode) }
727 : undefined,
728 upgradeSettings: news.upgradeSettings,
729});
730
731const poolLabels = (pool: container.NodePool) =>
732 tagRecord(pool.config?.resourceLabels);
733
734const systemLabels = (labels: Record<string, string>) =>
735 Object.fromEntries(
736 Object.entries(labels).filter(([key]) => key.startsWith("goog-")),
737 );
738
739const shortName = (value: string | undefined, fallback: string) =>
740 lastSegment(value ?? fallback);
741
742const desiredManagement = (
743 news: NodePoolManagement,
744 observed: NodePoolManagement | undefined,
745): NodePoolManagement => ({
746 autoRepair: news.autoRepair ?? observed?.autoRepair,
747 autoUpgrade: news.autoUpgrade ?? observed?.autoUpgrade,
748});
749
750export const NodePoolProvider = () =>
751 Provider.succeed(NodePool, {
752 stables: [
753 "name",
754 "nodePoolId",
755 "clusterId",
756 "clusterName",
757 "project",
758 "location",
759 "selfLink",
760 ],
761
762 diff: Effect.fn(function* ({ news, olds, output }) {
763 if (!isResolved(news)) return undefined;
764 if (news.cluster === undefined || news.cluster.length === 0) {
765 return undefined;
766 }
767
768 const previousId = olds?.nodePoolId ?? output?.nodePoolId;
769 const nextId = news.nodePoolId ?? previousId;
770 const previousCluster = lastSegment(
771 olds?.cluster ?? output?.clusterId ?? "",
772 );
773 const nextCluster = lastSegment(
774 parseClusterRef(news.cluster, "", news.location ?? output?.location)
775 .clusterId,
776 );
777 const previousLocation = normalizeLocation(
778 olds?.location ?? output?.location,
779 );
780 const nextLocation = normalizeLocation(
781 parseClusterRef(news.cluster, "", news.location ?? output?.location)
782 .location,
783 );
784 const previousSpot = olds?.spot === true || output?.spot === true;
785 const nextSpot = news.spot === true;
786 const previousPreemptible =
787 olds?.preemptible === true || output?.preemptible === true;
788 const nextPreemptible = news.preemptible === true;
789 const previousSa = olds?.serviceAccount ?? "";
790 const nextSa = news.serviceAccount ?? previousSa;
791 const previousKms = olds?.bootDiskKmsKey ?? "";
792 const nextKms = news.bootDiskKmsKey ?? previousKms;
793 const previousSsds = olds?.localSsdCount ?? 0;
794 const nextSsds = news.localSsdCount ?? previousSsds;
795 const previousPods = olds?.maxPodsPerNode;
796 const nextPods = news.maxPodsPerNode ?? previousPods;
797 const previousScopes = olds?.oauthScopes ?? [];
798 const nextScopes = news.oauthScopes ?? previousScopes;
799
800 const replace =
801 (previousId !== undefined &&
802 nextId !== undefined &&
803 nextId !== previousId) ||
804 (previousCluster.length > 0 &&
805 nextCluster.length > 0 &&
806 previousCluster !== nextCluster) ||
807 previousLocation !== nextLocation ||
808 previousSpot !== nextSpot ||
809 previousPreemptible !== nextPreemptible ||
810 previousSa !== nextSa ||
811 previousKms !== nextKms ||
812 previousSsds !== nextSsds ||
813 (previousPods !== undefined &&
814 nextPods !== undefined &&
815 previousPods !== nextPods) ||
816 (news.oauthScopes !== undefined &&
817 !sameStrings(previousScopes, nextScopes));
818
819 if (!replace) return undefined;
820 return {
821 action: "replace" as const,
822 deleteFirst:
823 previousLocation === nextLocation &&
824 previousCluster === nextCluster &&
825 previousId !== undefined &&
826 nextId === previousId,
827 };
828 }),
829
830 read: Effect.fn(function* ({ id, olds, output }) {
831 const env = yield* GcpEnvironment.current;
832 const nodePoolId = yield* toId(id, olds?.nodePoolId, output?.nodePoolId);
833 const clusterRef =
834 (typeof olds?.cluster === "string" ? olds.cluster : undefined) ??
835 output?.clusterName ??
836 output?.clusterId ??
837 "";
838 // A pool whose cluster never resolved (e.g. the cluster create failed)
839 // was never created and cannot be looked up.
840 if (output?.name === undefined && clusterRef.trim().length === 0) {
841 return undefined;
842 }
843 const ref = parseClusterRef(
844 clusterRef,
845 env.project,
846 olds?.location ?? output?.location,
847 );
848 const name =
849 output?.name ??
850 resourceName(ref.project, ref.location, ref.clusterId, nodePoolId);
851 const existing = yield* getByName(name);
852 if (existing === undefined) return undefined;
853 const attrs = toAttrs(existing, ref.project, ref.location, ref.clusterId);
854 return (yield* hasAlchemyLabels(id, poolLabels(existing)))
855 ? attrs
856 : Unowned(attrs);
857 }),
858
859 list: () =>
860 Effect.gen(function* () {
861 const env = yield* GcpEnvironment.current;
862 const page = yield* container
863 .listProjectsLocationsClusters({
864 parent: `projects/${env.project}/locations/-`,
865 })
866 .pipe(
867 Effect.catchTag("NotFound", () =>
868 Effect.succeed({
869 clusters: [],
870 } satisfies container.ListClustersResponse),
871 ),
872 );
873
874 const found: ReturnType<typeof toAttrs>[] = [];
875 for (const cluster of page.clusters ?? []) {
876 const parsed = parseName(
877 cluster.selfLink ??
878 resourceName(
879 env.project,
880 cluster.location ?? DEFAULT_LOCATION,
881 cluster.name ?? "",
882 "_",
883 ),
884 );
885 const project = parsed.project || env.project;
886 const location = normalizeLocation(
887 cluster.location ?? parsed.location,
888 );
889 const clusterId = cluster.name || parsed.clusterId;
890 const nested = cluster.nodePools;
891 const pools =
892 nested !== undefined
893 ? nested
894 : yield* container
895 .listProjectsLocationsClustersNodePools({
896 parent: clusterNameOf(project, location, clusterId),
897 })
898 .pipe(
899 Effect.map((response) => response.nodePools ?? []),
900 Effect.catchTag("NotFound", () =>
901 Effect.succeed([] as container.NodePool[]),
902 ),
903 );
904 for (const pool of pools) {
905 if (
906 Object.keys(pool.config?.resourceLabels ?? {}).some((key) =>
907 key.startsWith("alchemy-"),
908 )
909 ) {
910 found.push(toAttrs(pool, project, location, clusterId));
911 }
912 }
913 }
914 return found;
915 }),
916
917 reconcile: Effect.fn(function* ({ id, news, output }) {
918 const env = yield* GcpEnvironment.current;
919 if (news.cluster === undefined || news.cluster.length === 0) {
920 return yield* new NodePoolClusterMissing({
921 message:
922 "GCP.Container.NodePool requires `cluster` (cluster id or full resource name).",
923 });
924 }
925 const ref = parseClusterRef(
926 news.cluster,
927 env.project,
928 news.location ?? output?.location,
929 );
930 const nodePoolId = yield* toId(id, news.nodePoolId, output?.nodePoolId);
931 const name = resourceName(
932 ref.project,
933 ref.location,
934 ref.clusterId,
935 nodePoolId,
936 );
937 const parent = clusterNameOf(ref.project, ref.location, ref.clusterId);
938 const desiredLabels = {
939 ...toLabels(news.labels),
940 ...(yield* createInternalLabels(id)),
941 };
942 const nodeCount = news.nodeCount ?? DEFAULT_NODE_COUNT;
943 const machineType = news.machineType ?? DEFAULT_MACHINE_TYPE;
944 const diskSizeGb = news.diskSizeGb ?? DEFAULT_DISK_SIZE_GB;
945 const diskType = news.diskType ?? DEFAULT_DISK_TYPE;
946
947 let current = yield* getByName(output?.name ?? name);
948
949 if (current === undefined) {
950 const cluster = yield* container
951 .getProjectsLocationsClusters({ name: parent })
952 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
953 const workloadIdentity =
954 (cluster?.workloadIdentityConfig?.workloadPool ?? "").length > 0;
955 const created = yield* retryConflict(
956 container
957 .createProjectsLocationsClustersNodePools({
958 parent,
959 body: {
960 nodePool: toCreatePool(
961 news,
962 nodePoolId,
963 desiredLabels,
964 workloadIdentity,
965 ),
966 },
967 })
968 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined))),
969 );
970 if (created !== undefined) {
971 yield* waitForOperation(ref.project, ref.location, created);
972 }
973 current = yield* waitUntilExists(name);
974 }
975
976 if (current === undefined) {
977 return yield* new NodePoolNotResolved({ name });
978 }
979
980 let live = current;
981 const status = live.status ?? "STATUS_UNSPECIFIED";
982 if (status !== "RUNNING" && status !== "RUNNING_WITH_ERROR") {
983 live = yield* waitUntilReady(name);
984 }
985
986 const apply = (operation: container.Operation) =>
987 Effect.gen(function* () {
988 yield* waitForOperation(ref.project, ref.location, operation);
989 return yield* waitUntilReady(name);
990 });
991
992 const observedLabels = poolLabels(live);
993 const nextLabels = { ...systemLabels(observedLabels), ...desiredLabels };
994 const { upsert, removed } = diffLabels(observedLabels, nextLabels);
995 const labelsChanged = upsert.length > 0 || removed.length > 0;
996 const observedMachine = shortName(
997 live.config?.machineType,
998 DEFAULT_MACHINE_TYPE,
999 );
1000 const machineChanged =
1001 observedMachine.toLowerCase() !== machineType.toLowerCase();
1002 const observedDiskSize = live.config?.diskSizeGb ?? DEFAULT_DISK_SIZE_GB;
1003 const diskSizeChanged = observedDiskSize !== diskSizeGb;
1004 const observedDiskType = shortName(
1005 live.config?.diskType,
1006 DEFAULT_DISK_TYPE,
1007 ).toLowerCase();
1008 const diskTypeChanged = observedDiskType !== diskType.toLowerCase();
1009 const imageChanged =
1010 news.imageType !== undefined &&
1011 (live.config?.imageType ?? "") !== news.imageType;
1012 const versionChanged =
1013 news.version !== undefined && (live.version ?? "") !== news.version;
1014 const nodeLabelsChanged =
1015 news.nodeLabels !== undefined &&
1016 !sameStringMap(stringMap(live.config?.labels), news.nodeLabels);
1017 const tagsChanged =
1018 news.tags !== undefined &&
1019 !sameStrings(live.config?.tags ?? [], news.tags);
1020 const taintsChanged =
1021 news.taints !== undefined &&
1022 !sameTaints(live.config?.taints, news.taints);
1023 const upgradeChanged =
1024 news.upgradeSettings !== undefined &&
1025 upgradeKey(live.upgradeSettings) !== upgradeKey(news.upgradeSettings);
1026 const locationsChanged =
1027 news.nodeLocations !== undefined &&
1028 !sameStrings(live.locations ?? [], news.nodeLocations);
1029
1030 const configChanged =
1031 labelsChanged ||
1032 machineChanged ||
1033 diskSizeChanged ||
1034 diskTypeChanged ||
1035 imageChanged ||
1036 versionChanged ||
1037 nodeLabelsChanged ||
1038 tagsChanged ||
1039 taintsChanged ||
1040 upgradeChanged;
1041
1042 if (configChanged) {
1043 const imageType = news.imageType ?? live.config?.imageType;
1044 const nodeVersion = news.version ?? live.version;
1045 const body: container.UpdateNodePoolRequest = {};
1046 if (imageType !== undefined) body.imageType = imageType;
1047 if (nodeVersion !== undefined) body.nodeVersion = nodeVersion;
1048 if (labelsChanged) {
1049 body.resourceLabels = { labels: nextLabels };
1050 }
1051 if (machineChanged) body.machineType = machineType;
1052 if (diskSizeChanged) body.diskSizeGb = String(diskSizeGb);
1053 if (diskTypeChanged) body.diskType = diskType;
1054 if (nodeLabelsChanged) {
1055 body.labels = { labels: news.nodeLabels };
1056 }
1057 if (tagsChanged) body.tags = { tags: news.tags };
1058 if (taintsChanged) body.taints = { taints: news.taints };
1059 if (upgradeChanged) body.upgradeSettings = news.upgradeSettings;
1060 const updated = yield* retryConflict(
1061 container.updateProjectsLocationsClustersNodePools({
1062 name,
1063 body,
1064 }),
1065 );
1066 live = yield* apply(updated);
1067 }
1068
1069 if (locationsChanged) {
1070 const imageType = news.imageType ?? live.config?.imageType;
1071 const nodeVersion = news.version ?? live.version;
1072 const relocated = yield* retryConflict(
1073 container.updateProjectsLocationsClustersNodePools({
1074 name,
1075 body: {
1076 locations: news.nodeLocations,
1077 ...(imageType !== undefined ? { imageType } : {}),
1078 ...(nodeVersion !== undefined ? { nodeVersion } : {}),
1079 },
1080 }),
1081 );
1082 live = yield* apply(relocated);
1083 }
1084
1085 if (
1086 news.autoscaling !== undefined &&
1087 autoscalingKey(live.autoscaling) !== autoscalingKey(news.autoscaling)
1088 ) {
1089 const scaled = yield* retryConflict(
1090 container.setAutoscalingProjectsLocationsClustersNodePools({
1091 name,
1092 body: { autoscaling: news.autoscaling },
1093 }),
1094 );
1095 live = yield* apply(scaled);
1096 }
1097
1098 if (news.management !== undefined) {
1099 const nextManagement = desiredManagement(
1100 news.management,
1101 live.management,
1102 );
1103 if (managementKey(live.management) !== managementKey(nextManagement)) {
1104 const managed = yield* retryConflict(
1105 container.setManagementProjectsLocationsClustersNodePools({
1106 name,
1107 body: { management: nextManagement },
1108 }),
1109 );
1110 live = yield* apply(managed);
1111 }
1112 }
1113
1114 const autoscalingOn =
1115 news.autoscaling?.enabled === true ||
1116 live.autoscaling?.enabled === true;
1117 if (!autoscalingOn && (live.initialNodeCount ?? 0) !== nodeCount) {
1118 const resized = yield* retryConflict(
1119 container.setSizeProjectsLocationsClustersNodePools({
1120 name,
1121 body: { nodeCount },
1122 }),
1123 );
1124 live = yield* apply(resized);
1125 }
1126
1127 return toAttrs(live, ref.project, ref.location, ref.clusterId);
1128 }),
1129
1130 delete: Effect.fn(function* ({ output }) {
1131 const env = yield* GcpEnvironment.current;
1132 const location = normalizeLocation(output.location);
1133 const operation = yield* container
1134 .deleteProjectsLocationsClustersNodePools({ name: output.name })
1135 .pipe(
1136 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
1137 Effect.retry({
1138 while: (error) => error._tag === "Conflict",
1139 times: 8,
1140 schedule: Schedule.spaced("5 seconds"),
1141 }),
1142 );
1143 if (operation !== undefined) {
1144 yield* waitForOperation(env.project, location, operation, {
1145 notFoundOk: true,
1146 });
1147 }
1148 yield* waitUntilGone(output.name);
1149 }),
1150 });