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 {
8 deleteObjects,
9 reconcileObjects,
10} from "../../Kubernetes/internal/client.ts";
11import {
12 type KubernetesObjectBinding,
13 type KubernetesObjectDefinition,
14 type KubernetesObjectRef,
15} from "../../Kubernetes/internal/objects.ts";
16import type { Connection } from "../../Kubernetes/Connection.ts";
17import { createPhysicalName } from "../../PhysicalName.ts";
18import * as Provider from "../../Provider.ts";
19import { Resource, type ResourceBinding } from "../../Resource.ts";
20import { tagRecord } from "../../Tags.ts";
21import { GcpEnvironment } from "../Environment.ts";
22import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
23import {
24 createInternalLabels,
25 diffLabels,
26 hasAlchemyLabels,
27 stripInternalLabels,
28 toLabels,
29} from "../Labels.ts";
30import type { Providers } from "../Providers.ts";
31import {
32 apiServerEndpoint,
33 gkeConnectionOf,
34 makeGkeTransport,
35 workloadPoolOf,
36} from "./KubernetesAdapter.ts";
37
38// A zone, not a region: `GCP.Region` has no zone and a regional default would triple node count.
39const DEFAULT_LOCATION = "us-central1-a";
40const DEFAULT_MACHINE_TYPE = "e2-medium";
41const DEFAULT_DISK_TYPE = "pd-standard";
42const DEFAULT_DISK_SIZE_GB = 20;
43const DEFAULT_NODE_COUNT = 1;
44const DEFAULT_POOL_NAME = "default-pool";
45const MAX_NAME_LENGTH = 40;
46
47export type ClusterProps = {
48 /**
49 * Cluster id (the `{cluster}` segment of
50 * `projects/{project}/locations/{location}/clusters/{cluster}`). If
51 * omitted, a unique RFC1035 name is generated from the stack, stage,
52 * and logical id. Must be 1-40 characters, start with a letter, and
53 * match `[a-z]([a-z0-9-]*[a-z0-9])?`. Immutable — changing it replaces
54 * the cluster.
55 */
56 clusterId?: string;
57 /**
58 * Zone or region (`us-central1-a`, `us-central1`, …). Immutable —
59 * changing it replaces the cluster. Autopilot clusters must be
60 * regional. `US-CENTRAL1-A` is accepted and normalized to
61 * `us-central1-a`.
62 * @default "us-central1-a"
63 */
64 location?: string;
65 /**
66 * Optional description. Immutable — changing it replaces the cluster.
67 */
68 description?: string;
69 /**
70 * User labels stored as GKE `resourceLabels`. Alchemy ownership labels
71 * are merged in automatically.
72 */
73 labels?: Record<string, string>;
74 /**
75 * VPC network name or URL. Immutable — changing it replaces the cluster.
76 * Defaults to the project `default` network.
77 */
78 network?: string;
79 /**
80 * Subnetwork name or URL. Immutable — changing it replaces the cluster.
81 */
82 subnetwork?: string;
83 /**
84 * Create an Autopilot cluster. Immutable — changing it replaces the
85 * cluster. Node pool props (`initialNodeCount`, `machineType`, …) are
86 * ignored when Autopilot is enabled.
87 * @default false
88 */
89 autopilot?: boolean;
90 /**
91 * Initial Kubernetes version (or alias such as `"1.31"` / `"latest"`).
92 * Applied at create time only.
93 */
94 initialClusterVersion?: string;
95 /**
96 * Logging service (`logging.googleapis.com/kubernetes` or `none`).
97 */
98 loggingService?: string;
99 /**
100 * Monitoring service (`monitoring.googleapis.com/kubernetes` or `none`).
101 */
102 monitoringService?: string;
103 /**
104 * Enable Kubernetes alpha APIs. The cluster is deleted after 30 days
105 * and has no SLA. Immutable — changing it replaces the cluster.
106 * @default false
107 */
108 enableKubernetesAlpha?: boolean;
109 /**
110 * Release channel (`RAPID`, `REGULAR`, `STABLE`, `EXTENDED`).
111 */
112 releaseChannel?: container.ReleaseChannelChannelEnum | (string & {});
113 /**
114 * Zones in which nodes should run. Must include the cluster's primary
115 * zone for zonal clusters.
116 */
117 nodeLocations?: string[];
118 /**
119 * Initial node count for the default pool. Ignored on Autopilot.
120 * Immutable — changing it replaces the cluster.
121 * @default 1
122 */
123 initialNodeCount?: number;
124 /**
125 * Machine type for the default pool. Ignored on Autopilot. Immutable —
126 * changing it replaces the cluster.
127 * @default "e2-medium"
128 */
129 machineType?: string;
130 /**
131 * Boot disk size in GB for the default pool. Ignored on Autopilot.
132 * Immutable — changing it replaces the cluster.
133 * @default 20
134 */
135 diskSizeGb?: number;
136 /**
137 * Boot disk type for the default pool (`pd-standard`, `pd-balanced`,
138 * `pd-ssd`). Ignored on Autopilot. Immutable — changing it replaces
139 * the cluster.
140 * @default "pd-standard"
141 */
142 diskType?: string;
143 /**
144 * Use Spot VMs for the default pool. Ignored on Autopilot. Immutable —
145 * changing it replaces the cluster.
146 * @default false
147 */
148 spot?: boolean;
149 /**
150 * Use preemptible VMs for the default pool. Ignored on Autopilot.
151 * Immutable — changing it replaces the cluster.
152 * @default false
153 */
154 preemptible?: boolean;
155};
156
157export type Cluster = Resource<
158 "GCP.Container.Cluster",
159 ClusterProps,
160 {
161 /** Full resource name `projects/{project}/locations/{location}/clusters/{cluster}`. */
162 name: string;
163 /** Cluster id (last path segment). */
164 clusterId: string;
165 /** Project id. */
166 project: string;
167 /** Zone or region id. */
168 location: string;
169 /** Server-assigned unique cluster id. */
170 clusterUid: string | undefined;
171 /** Current status (`RUNNING`, `PROVISIONING`, …). */
172 status: string | undefined;
173 /** Kubernetes API server endpoint URL. */
174 endpoint: string | undefined;
175 /**
176 * Base64-encoded cluster CA from
177 * `masterAuth.clusterCaCertificate`.
178 */
179 certificateAuthorityData: string | undefined;
180 /**
181 * The cluster-agnostic `Kubernetes.Connection` for this cluster.
182 * Passing the whole cluster resource as a `Kubernetes.*` workload's
183 * `cluster` prop resolves through this — auth uses Google OAuth
184 * bearer tokens minted from the ambient GCP credentials.
185 */
186 connection: Connection;
187 /** References to Kubernetes objects applied via the binding contract. */
188 kubernetesObjects: KubernetesObjectRef[];
189 /**
190 * Workload Identity pool (`{project}.svc.id.goog`) when enabled.
191 * Distilled has no IAM v1, so GSA CRUD is out of band; Alchemy still
192 * enables WI on the cluster and annotates KSAs when
193 * `identity.gcpServiceAccount` is set.
194 */
195 workloadPool: string | undefined;
196 /** Current control-plane version. */
197 currentMasterVersion: string | undefined;
198 /** User-provided description. */
199 description: string | undefined;
200 /** User labels (Alchemy ownership labels stripped). */
201 labels: Record<string, string>;
202 /** VPC network. */
203 network: string | undefined;
204 /** Subnetwork. */
205 subnetwork: string | undefined;
206 /** Logging service. */
207 loggingService: string | undefined;
208 /** Monitoring service. */
209 monitoringService: string | undefined;
210 /** Whether Autopilot is enabled. */
211 autopilot: boolean;
212 /** Whether Kubernetes alpha APIs are enabled. */
213 enableKubernetesAlpha: boolean;
214 /** Release channel (`RAPID`, `REGULAR`, …). */
215 releaseChannel: string | undefined;
216 /** Node locations currently configured. */
217 nodeLocations: string[];
218 /** Services IPv4 CIDR. */
219 servicesIpv4Cidr: string | undefined;
220 /** Server-defined URL. */
221 selfLink: string | undefined;
222 /** RFC3339 creation timestamp. */
223 createTime: string | undefined;
224 },
225 KubernetesObjectBinding,
226 Providers
227>;
228
229/**
230 * A Google Kubernetes Engine cluster.
231 *
232 * Changing `clusterId`, `location`, `description`, `network`,
233 * `subnetwork`, `autopilot`, `enableKubernetesAlpha`, or default-pool
234 * node shape (`initialNodeCount`, `machineType`, `diskSizeGb`,
235 * `diskType`, `spot`, `preemptible`) replaces the cluster. Labels,
236 * logging/monitoring services, release channel, and node locations are
237 * updated in place.
238 *
239 * Provisioning typically takes several minutes.
240 *
241 * ### Creating a Cluster
242 * **Example:** Generated name, zonal default pool
243 * ```typescript
244 * const cluster = yield* GCP.Container.Cluster("App", {});
245 * ```
246 *
247 * **Example:** Explicit id, labels, and Spot nodes
248 * ```typescript
249 * const cluster = yield* GCP.Container.Cluster("App", {
250 * clusterId: "app-cluster",
251 * location: "us-central1-a",
252 * machineType: "e2-medium",
253 * initialNodeCount: 1,
254 * spot: true,
255 * labels: { env: "prod" },
256 * });
257 * ```
258 *
259 * ### Autopilot
260 * **Example:** Regional Autopilot cluster
261 * ```typescript
262 * const cluster = yield* GCP.Container.Cluster("App", {
263 * location: "us-central1",
264 * autopilot: true,
265 * releaseChannel: "REGULAR",
266 * });
267 * ```
268 *
269 * ### Reading a Cluster
270 * **Example:** Get the bound cluster
271 * ```typescript
272 * const getCluster = yield* GCP.Container.GetCluster(cluster);
273 * const live = yield* getCluster();
274 * ```
275 *
276 * ### Kubernetes Workloads
277 * **Example:** Deploy an Effect-native workload onto the cluster
278 * ```typescript
279 * const cluster = yield* GCP.Container.Cluster("App", {});
280 * const api = yield* Kubernetes.Deployment("Api", {
281 * cluster,
282 * main: import.meta.url,
283 * port: 8080,
284 * });
285 * ```
286 *
287 * @resource
288 * @category Container
289 */
290export const Cluster = Resource<Cluster>("GCP.Container.Cluster");
291
292export class ClusterNotResolved extends Data.TaggedError(
293 "GCP.Container.ClusterNotResolved",
294)<{
295 name: string;
296}> {}
297
298export class ClusterNotReady extends Data.TaggedError(
299 "GCP.Container.ClusterNotReady",
300)<{
301 name: string;
302 status: string;
303}> {}
304
305export class ClusterOperationFailed extends Data.TaggedError(
306 "GCP.Container.ClusterOperationFailed",
307)<{
308 operation: string;
309 message: string;
310}> {}
311
312export class ClusterStillExists extends Data.TaggedError(
313 "GCP.Container.ClusterStillExists",
314)<{
315 name: string;
316}> {}
317
318const lastSegment = (value: string) => {
319 const trimmed = value.replace(/\/+$/, "");
320 const parts = trimmed.split("/");
321 return parts[parts.length - 1] || trimmed;
322};
323
324const normalizeLocation = (location: string | undefined) =>
325 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
326
327const normalizeChannel = (channel: string | undefined) => {
328 const value = (channel ?? "").toUpperCase();
329 return value === "UNSPECIFIED" || value.length === 0 ? "" : value;
330};
331
332const sameLocations = (left: string[], right: string[]) => {
333 if (left.length !== right.length) return false;
334 const a = [...left].map((item) => item.toLowerCase()).sort();
335 const b = [...right].map((item) => item.toLowerCase()).sort();
336 return a.every((value, index) => value === b[index]);
337};
338
339const rfc1035 = (name: string): string => {
340 let next = name
341 .toLowerCase()
342 .replace(/[^a-z0-9-]/g, "-")
343 .replace(/-+/g, "-")
344 .replace(/^-+|-+$/g, "");
345 if (!/^[a-z]/.test(next)) next = `c${next}`;
346 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
347 if (next.length === 0) return "cluster";
348 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
349 return next.slice(0, MAX_NAME_LENGTH);
350};
351
352const resourceName = (project: string, location: string, clusterId: string) =>
353 `projects/${project}/locations/${location}/clusters/${clusterId}`;
354
355const parseName = (name: string) => {
356 const parts = name.split("/").filter((part) => part.length > 0);
357 const clustersAt = parts.lastIndexOf("clusters");
358 const locationsAt = parts.lastIndexOf("locations");
359 const projectsAt = parts.lastIndexOf("projects");
360 return {
361 project:
362 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
363 location:
364 locationsAt >= 0 && parts[locationsAt + 1]
365 ? parts[locationsAt + 1]!
366 : DEFAULT_LOCATION,
367 clusterId:
368 clustersAt >= 0 && parts[clustersAt + 1]
369 ? parts[clustersAt + 1]!
370 : lastSegment(name),
371 };
372};
373
374const userLabels = (
375 labels: Record<string, string | undefined> | null | undefined,
376): Record<string, string> => stripInternalLabels(tagRecord(labels));
377
378const toId = (id: string, clusterId: string | undefined, existing?: string) =>
379 Effect.gen(function* () {
380 return (
381 clusterId ??
382 existing ??
383 rfc1035(
384 yield* createPhysicalName({
385 id,
386 maxLength: MAX_NAME_LENGTH,
387 lowercase: true,
388 }),
389 )
390 );
391 });
392
393const toAttrs = (
394 cluster: container.Cluster,
395 project: string,
396 kubernetesObjects: KubernetesObjectRef[] = [],
397) => {
398 const selfLink = cluster.selfLink ?? "";
399 const parsed = parseName(
400 selfLink.length > 0 ? selfLink : (cluster.name ?? ""),
401 );
402 const clusterId = cluster.name || parsed.clusterId;
403 const location = lastSegment(cluster.location ?? parsed.location);
404 const resolvedProject = parsed.project || project;
405 const endpoint = apiServerEndpoint(cluster.endpoint) ?? cluster.endpoint;
406 const certificateAuthorityData = cluster.masterAuth?.clusterCaCertificate;
407 return {
408 name: resourceName(resolvedProject, location, clusterId),
409 clusterId,
410 project: resolvedProject,
411 location,
412 clusterUid: cluster.id,
413 status: cluster.status,
414 endpoint,
415 certificateAuthorityData,
416 connection: gkeConnectionOf({
417 clusterId,
418 location,
419 project: resolvedProject,
420 endpoint,
421 certificateAuthorityData,
422 }),
423 kubernetesObjects,
424 workloadPool: cluster.workloadIdentityConfig?.workloadPool,
425 currentMasterVersion: cluster.currentMasterVersion,
426 description: cluster.description,
427 labels: userLabels(cluster.resourceLabels),
428 network: cluster.network,
429 subnetwork: cluster.subnetwork,
430 loggingService: cluster.loggingService,
431 monitoringService: cluster.monitoringService,
432 autopilot: cluster.autopilot?.enabled === true,
433 enableKubernetesAlpha: cluster.enableKubernetesAlpha === true,
434 releaseChannel: cluster.releaseChannel?.channel,
435 nodeLocations: [...(cluster.locations ?? [])],
436 servicesIpv4Cidr: cluster.servicesIpv4Cidr,
437 selfLink: cluster.selfLink,
438 createTime: cluster.createTime,
439 };
440};
441
442const getDesiredKubernetesObjects = (
443 bindings: ReadonlyArray<ResourceBinding<KubernetesObjectBinding>>,
444): KubernetesObjectDefinition[] =>
445 bindings
446 .filter(
447 (binding): binding is ResourceBinding<KubernetesObjectBinding> =>
448 binding.data.type === "kubernetes-object",
449 )
450 .map((binding) => binding.data.object);
451
452const getKubernetesTransport = (
453 state: Pick<
454 Cluster["Attributes"],
455 "clusterId" | "endpoint" | "certificateAuthorityData"
456 >,
457) =>
458 Effect.suspend(() => {
459 if (!state.endpoint || !state.certificateAuthorityData) {
460 throw new Error(
461 `GKE cluster '${state.clusterId}' is missing endpoint or certificate authority data`,
462 );
463 }
464 return makeGkeTransport({
465 endpoint: state.endpoint,
466 certificateAuthorityData: state.certificateAuthorityData,
467 });
468 });
469
470const getByName = (name: string) =>
471 container
472 .getProjectsLocationsClusters({ name })
473 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
474
475const operationResourceName = (
476 project: string,
477 location: string,
478 operation: container.Operation,
479) => {
480 const raw = operation.name ?? "";
481 if (raw.includes("/operations/")) {
482 const idx = raw.indexOf("projects/");
483 return idx >= 0 ? raw.slice(idx) : raw;
484 }
485 const fromLink = operation.selfLink ?? "";
486 if (fromLink.includes("/operations/")) {
487 const idx = fromLink.indexOf("projects/");
488 return idx >= 0 ? fromLink.slice(idx) : fromLink;
489 }
490 const loc = lastSegment(operation.location ?? operation.zone ?? location);
491 return `projects/${project}/locations/${loc}/operations/${lastSegment(raw)}`;
492};
493
494// Cluster create / delete runs 5–10 min (Autopilot included) and most
495// updates several minutes; bounded at 15 min.
496const clusterOperationSchedule = Schedule.max([
497 Schedule.spaced("10 seconds"),
498 Schedule.recurs(90),
499]);
500
501/**
502 * Wait for a GKE operation (create/delete runs 5–10 min, Autopilot and
503 * large updates longer). ALREADY_EXISTS (code 6) counts as success
504 * (create race); with `notFoundOk`, so does NOT_FOUND (code 5, delete
505 * race) and an operation that is already gone.
506 */
507const waitForOperation = (
508 project: string,
509 location: string,
510 operation: container.Operation,
511 options?: { notFoundOk?: boolean },
512) =>
513 waitForGcpOperation(
514 {
515 ...operation,
516 name: operationResourceName(project, location, operation),
517 },
518 (name) => container.getProjectsLocationsOperations({ name }),
519 { budget: "30 minutes", interval: "10 seconds" },
520 ).pipe(
521 Effect.catchIf(
522 (error) =>
523 (error._tag === "GCP.OperationFailed" &&
524 (error.code === 6 ||
525 (options?.notFoundOk === true && error.code === 5))) ||
526 (options?.notFoundOk === true && error._tag === "NotFound"),
527 () => Effect.succeed(operation),
528 ),
529 );
530
531const waitUntilExists = (name: string) =>
532 getByName(name).pipe(
533 Effect.filterOrFail(
534 (cluster): cluster is container.Cluster => cluster !== undefined,
535 () => new ClusterNotResolved({ name }),
536 ),
537 Effect.retry({
538 while: (error) => error._tag === "GCP.Container.ClusterNotResolved",
539 times: 8,
540 schedule: Schedule.spaced("1 second"),
541 }),
542 );
543
544const waitUntilReady = (name: string) =>
545 getByName(name).pipe(
546 Effect.filterOrFail(
547 (cluster): cluster is container.Cluster => cluster !== undefined,
548 () => new ClusterNotResolved({ name }),
549 ),
550 Effect.filterOrFail(
551 (cluster) => (cluster.status ?? "STATUS_UNSPECIFIED") !== "ERROR",
552 (cluster) =>
553 new ClusterOperationFailed({
554 operation: name,
555 message: cluster.statusMessage ?? "cluster is in ERROR",
556 }),
557 ),
558 Effect.filterOrFail(
559 (cluster) => {
560 const status = cluster.status ?? "STATUS_UNSPECIFIED";
561 return status === "RUNNING" || status === "DEGRADED";
562 },
563 (cluster) =>
564 new ClusterNotReady({
565 name,
566 status: cluster.status ?? "STATUS_UNSPECIFIED",
567 }),
568 ),
569 Effect.retry({
570 while: (error) =>
571 error._tag === "GCP.Container.ClusterNotReady" ||
572 error._tag === "GCP.Container.ClusterNotResolved",
573 schedule: clusterOperationSchedule,
574 }),
575 );
576
577const waitUntilGone = (name: string) =>
578 getByName(name).pipe(
579 Effect.flatMap((cluster) =>
580 cluster === undefined
581 ? Effect.void
582 : Effect.fail(new ClusterStillExists({ name })),
583 ),
584 Effect.retry({
585 while: (error) => error._tag === "GCP.Container.ClusterStillExists",
586 schedule: clusterOperationSchedule,
587 }),
588 );
589
590const defaultPool = (news: ClusterProps): container.NodePool => ({
591 name: DEFAULT_POOL_NAME,
592 initialNodeCount: news.initialNodeCount ?? DEFAULT_NODE_COUNT,
593 config: {
594 machineType: news.machineType ?? DEFAULT_MACHINE_TYPE,
595 diskSizeGb: news.diskSizeGb ?? DEFAULT_DISK_SIZE_GB,
596 diskType: news.diskType ?? DEFAULT_DISK_TYPE,
597 spot: news.spot === true,
598 preemptible: news.preemptible === true,
599 workloadMetadataConfig: { mode: "GKE_METADATA" },
600 },
601});
602
603const toCreateBody = (
604 news: ClusterProps,
605 clusterId: string,
606 desiredLabels: Record<string, string>,
607 autopilot: boolean,
608 project: string,
609): container.Cluster => {
610 const channel = normalizeChannel(news.releaseChannel);
611 return {
612 name: clusterId,
613 description: news.description,
614 resourceLabels: desiredLabels,
615 network: news.network,
616 subnetwork: news.subnetwork,
617 initialClusterVersion: news.initialClusterVersion,
618 loggingService: news.loggingService,
619 monitoringService: news.monitoringService,
620 enableKubernetesAlpha: news.enableKubernetesAlpha === true,
621 locations: news.nodeLocations,
622 autopilot: autopilot ? { enabled: true } : undefined,
623 workloadIdentityConfig: { workloadPool: workloadPoolOf(project) },
624 releaseChannel:
625 channel.length > 0
626 ? { channel: channel as container.ReleaseChannelChannelEnum }
627 : undefined,
628 ipAllocationPolicy: { useIpAliases: true },
629 nodePools: autopilot ? undefined : [defaultPool(news)],
630 };
631};
632
633export const ClusterProvider = () =>
634 Provider.succeed(Cluster, {
635 stables: [
636 "name",
637 "clusterId",
638 "project",
639 "location",
640 "clusterUid",
641 "selfLink",
642 "createTime",
643 "connection",
644 ],
645
646 diff: Effect.fn(function* ({ news, olds, output }) {
647 if (!isResolved(news)) return undefined;
648
649 const previousId = olds?.clusterId ?? output?.clusterId;
650 const nextId = news.clusterId ?? previousId;
651 const previousLocation = normalizeLocation(
652 olds?.location ?? output?.location,
653 );
654 const nextLocation = normalizeLocation(news.location ?? output?.location);
655 const previousAutopilot =
656 olds?.autopilot === true || output?.autopilot === true;
657 const nextAutopilot = news.autopilot === true;
658 const previousNetwork = olds?.network ?? output?.network ?? "";
659 const nextNetwork = news.network ?? previousNetwork;
660 const previousSubnetwork = olds?.subnetwork ?? output?.subnetwork ?? "";
661 const nextSubnetwork = news.subnetwork ?? previousSubnetwork;
662 const previousAlpha =
663 olds?.enableKubernetesAlpha === true ||
664 output?.enableKubernetesAlpha === true;
665 const nextAlpha = news.enableKubernetesAlpha === true;
666 const previousDescription =
667 olds?.description ?? output?.description ?? "";
668 const nextDescription = news.description ?? previousDescription;
669 const nodeShapeChanged =
670 !nextAutopilot &&
671 olds !== undefined &&
672 ((olds.initialNodeCount ?? DEFAULT_NODE_COUNT) !==
673 (news.initialNodeCount ?? DEFAULT_NODE_COUNT) ||
674 (olds.machineType ?? DEFAULT_MACHINE_TYPE) !==
675 (news.machineType ?? DEFAULT_MACHINE_TYPE) ||
676 (olds.diskSizeGb ?? DEFAULT_DISK_SIZE_GB) !==
677 (news.diskSizeGb ?? DEFAULT_DISK_SIZE_GB) ||
678 (olds.diskType ?? DEFAULT_DISK_TYPE).toLowerCase() !==
679 (news.diskType ?? DEFAULT_DISK_TYPE).toLowerCase() ||
680 (olds.spot === true) !== (news.spot === true) ||
681 (olds.preemptible === true) !== (news.preemptible === true));
682
683 const replace =
684 (previousId !== undefined &&
685 nextId !== undefined &&
686 nextId !== previousId) ||
687 previousLocation !== nextLocation ||
688 previousAutopilot !== nextAutopilot ||
689 previousNetwork !== nextNetwork ||
690 previousSubnetwork !== nextSubnetwork ||
691 previousAlpha !== nextAlpha ||
692 previousDescription !== nextDescription ||
693 nodeShapeChanged;
694
695 if (!replace) return undefined;
696 return {
697 action: "replace" as const,
698 deleteFirst:
699 previousLocation === nextLocation &&
700 previousId !== undefined &&
701 nextId === previousId,
702 };
703 }),
704
705 read: Effect.fn(function* ({ id, olds, output }) {
706 const env = yield* GcpEnvironment.current;
707 const clusterId = yield* toId(id, olds?.clusterId, output?.clusterId);
708 const location = normalizeLocation(olds?.location ?? output?.location);
709 const name =
710 output?.name ?? resourceName(env.project, location, clusterId);
711 const existing = yield* getByName(name);
712 if (existing === undefined) return undefined;
713 const attrs = toAttrs(
714 existing,
715 env.project,
716 output?.kubernetesObjects ?? [],
717 );
718 return (yield* hasAlchemyLabels(id, tagRecord(existing.resourceLabels)))
719 ? attrs
720 : Unowned(attrs);
721 }),
722
723 list: () =>
724 Effect.gen(function* () {
725 const env = yield* GcpEnvironment.current;
726 const page = yield* container
727 .listProjectsLocationsClusters({
728 parent: `projects/${env.project}/locations/-`,
729 })
730 .pipe(
731 Effect.catchTag("NotFound", () =>
732 Effect.succeed({
733 clusters: [],
734 } satisfies container.ListClustersResponse),
735 ),
736 );
737 return (page.clusters ?? [])
738 .filter((cluster) =>
739 Object.keys(cluster.resourceLabels ?? {}).some((key) =>
740 key.startsWith("alchemy-"),
741 ),
742 )
743 .map((cluster) => toAttrs(cluster, env.project));
744 }),
745
746 reconcile: Effect.fn(function* ({ id, news, output, bindings }) {
747 const env = yield* GcpEnvironment.current;
748 const clusterId = yield* toId(id, news.clusterId, output?.clusterId);
749 const location = normalizeLocation(news.location ?? output?.location);
750 const name = resourceName(env.project, location, clusterId);
751 const autopilot = news.autopilot === true;
752 const desiredLabels = {
753 ...toLabels(news.labels),
754 ...(yield* createInternalLabels(id)),
755 };
756 const desiredObjects = getDesiredKubernetesObjects(bindings);
757 const desiredPool = workloadPoolOf(env.project);
758
759 let current = yield* getByName(output?.name ?? name);
760
761 if (current === undefined) {
762 const create = Effect.gen(function* () {
763 const created = yield* container
764 .createProjectsLocationsClusters({
765 parent: `projects/${env.project}/locations/${location}`,
766 body: {
767 cluster: toCreateBody(
768 news,
769 clusterId,
770 desiredLabels,
771 autopilot,
772 env.project,
773 ),
774 },
775 })
776 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
777 if (created !== undefined) {
778 yield* waitForOperation(env.project, location, created);
779 }
780 });
781 // GKE intermittently fails a create with INTERNAL ("Failed to create
782 // cluster") and leaves the cluster in ERROR; delete it and try again.
783 const removeFailed = Effect.gen(function* () {
784 const operation = yield* container
785 .deleteProjectsLocationsClusters({ name })
786 .pipe(
787 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
788 Effect.retry({
789 while: (error) => error._tag === "Conflict",
790 times: 8,
791 schedule: Schedule.spaced("5 seconds"),
792 }),
793 );
794 if (operation !== undefined) {
795 yield* waitForOperation(env.project, location, operation, {
796 notFoundOk: true,
797 });
798 }
799 yield* waitUntilGone(name);
800 });
801 const isInternal = (error: { _tag: string; code?: number }) =>
802 error._tag === "GCP.OperationFailed" && error.code === 13;
803 yield* create.pipe(
804 Effect.tapError((error) =>
805 isInternal(error) ? removeFailed : Effect.void,
806 ),
807 Effect.retry({
808 while: isInternal,
809 times: 2,
810 schedule: Schedule.spaced("30 seconds"),
811 }),
812 );
813 current = yield* waitUntilExists(name);
814 }
815
816 if (current === undefined) {
817 return yield* new ClusterNotResolved({ name });
818 }
819
820 let live = current;
821 const status = live.status ?? "STATUS_UNSPECIFIED";
822 if (status !== "RUNNING" && status !== "DEGRADED") {
823 live = yield* waitUntilReady(name);
824 }
825
826 const observedLabels = tagRecord(live.resourceLabels);
827 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
828 const labelsChanged = upsert.length > 0 || removed.length > 0;
829 if (labelsChanged) {
830 const labeled =
831 yield* container.setResourceLabelsProjectsLocationsClusters({
832 name,
833 body: {
834 resourceLabels: desiredLabels,
835 labelFingerprint: live.labelFingerprint,
836 },
837 });
838 yield* waitForOperation(env.project, location, labeled);
839 live = yield* waitUntilReady(name);
840 }
841
842 // GKE rejects a logging-only or monitoring-only change that would
843 // implicitly flip the other service, so both are always sent together.
844 const desiredLogging = news.loggingService ?? live.loggingService;
845 const desiredMonitoring =
846 news.monitoringService ?? live.monitoringService;
847 if (
848 (live.loggingService ?? "") !== (desiredLogging ?? "") ||
849 (live.monitoringService ?? "") !== (desiredMonitoring ?? "")
850 ) {
851 const serviced = yield* container.updateProjectsLocationsClusters({
852 name,
853 body: {
854 update: {
855 desiredLoggingService: desiredLogging,
856 desiredMonitoringService: desiredMonitoring,
857 },
858 },
859 });
860 yield* waitForOperation(env.project, location, serviced);
861 live = yield* waitUntilReady(name);
862 }
863
864 const desiredChannel = normalizeChannel(news.releaseChannel);
865 const observedChannel = normalizeChannel(live.releaseChannel?.channel);
866 const desiredLocations = news.nodeLocations;
867 const locationsChanged =
868 desiredLocations !== undefined &&
869 !sameLocations(live.locations ?? [], desiredLocations);
870 const channelChanged =
871 desiredChannel.length > 0 && desiredChannel !== observedChannel;
872
873 if (channelChanged || locationsChanged) {
874 const update: container.ClusterUpdate = {};
875 if (channelChanged) {
876 update.desiredReleaseChannel = {
877 channel: desiredChannel as container.ReleaseChannelChannelEnum,
878 };
879 }
880 if (locationsChanged) {
881 update.desiredLocations = desiredLocations;
882 }
883 const updated = yield* container.updateProjectsLocationsClusters({
884 name,
885 body: { update },
886 });
887 yield* waitForOperation(env.project, location, updated);
888 live = yield* waitUntilReady(name);
889 }
890
891 const observedPool = live.workloadIdentityConfig?.workloadPool ?? "";
892 if (observedPool !== desiredPool) {
893 const identified = yield* container.updateProjectsLocationsClusters({
894 name,
895 body: {
896 update: {
897 desiredWorkloadIdentityConfig: { workloadPool: desiredPool },
898 },
899 },
900 });
901 yield* waitForOperation(env.project, location, identified);
902 live = yield* waitUntilReady(name);
903 }
904
905 const attrs = toAttrs(live, env.project, output?.kubernetesObjects ?? []);
906 const kubernetesObjects = yield* reconcileObjects({
907 transport: yield* getKubernetesTransport(attrs),
908 previousObjects: output?.kubernetesObjects ?? [],
909 desiredObjects,
910 });
911 return { ...attrs, kubernetesObjects };
912 }),
913
914 delete: Effect.fn(function* ({ output }) {
915 if ((output.kubernetesObjects ?? []).length > 0) {
916 yield* deleteObjects({
917 transport: yield* getKubernetesTransport(output),
918 objects: output.kubernetesObjects ?? [],
919 });
920 }
921 const env = yield* GcpEnvironment.current;
922 const location = normalizeLocation(output.location);
923 const operation = yield* container
924 .deleteProjectsLocationsClusters({ name: output.name })
925 .pipe(
926 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
927 Effect.retry({
928 while: (error) => error._tag === "Conflict",
929 times: 8,
930 schedule: Schedule.spaced("5 seconds"),
931 }),
932 );
933 if (operation !== undefined) {
934 yield* waitForOperation(env.project, location, operation, {
935 notFoundOk: true,
936 });
937 }
938 yield* waitUntilGone(output.name);
939 }),
940 });