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