| 1 | import * as container from "@distilled.cloud/gcp/container_v1"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import { |
| 8 | deleteObjects, |
| 9 | reconcileObjects, |
| 10 | } from "../../Kubernetes/internal/client.ts"; |
| 11 | import { |
| 12 | type KubernetesObjectBinding, |
| 13 | type KubernetesObjectDefinition, |
| 14 | type KubernetesObjectRef, |
| 15 | } from "../../Kubernetes/internal/objects.ts"; |
| 16 | import type { Connection } from "../../Kubernetes/Connection.ts"; |
| 17 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 18 | import * as Provider from "../../Provider.ts"; |
| 19 | import { Resource, type ResourceBinding } from "../../Resource.ts"; |
| 20 | import { tagRecord } from "../../Tags.ts"; |
| 21 | import { GcpEnvironment } from "../Environment.ts"; |
| 22 | import { waitForOperation as waitForGcpOperation } from "../Operation.ts"; |
| 23 | import { |
| 24 | createInternalLabels, |
| 25 | diffLabels, |
| 26 | hasAlchemyLabels, |
| 27 | stripInternalLabels, |
| 28 | toLabels, |
| 29 | } from "../Labels.ts"; |
| 30 | import type { Providers } from "../Providers.ts"; |
| 31 | import { |
| 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. |
| 39 | const DEFAULT_LOCATION = "us-central1-a"; |
| 40 | const DEFAULT_MACHINE_TYPE = "e2-medium"; |
| 41 | const DEFAULT_DISK_TYPE = "pd-standard"; |
| 42 | const DEFAULT_DISK_SIZE_GB = 20; |
| 43 | const DEFAULT_NODE_COUNT = 1; |
| 44 | const DEFAULT_POOL_NAME = "default-pool"; |
| 45 | const MAX_NAME_LENGTH = 40; |
| 46 | |
| 47 | export 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 | |
| 157 | export 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 | */ |
| 290 | export const Cluster = Resource<Cluster>("GCP.Container.Cluster"); |
| 291 | |
| 292 | export class ClusterNotResolved extends Data.TaggedError( |
| 293 | "GCP.Container.ClusterNotResolved", |
| 294 | )<{ |
| 295 | name: string; |
| 296 | }> {} |
| 297 | |
| 298 | export class ClusterNotReady extends Data.TaggedError( |
| 299 | "GCP.Container.ClusterNotReady", |
| 300 | )<{ |
| 301 | name: string; |
| 302 | status: string; |
| 303 | }> {} |
| 304 | |
| 305 | export class ClusterOperationFailed extends Data.TaggedError( |
| 306 | "GCP.Container.ClusterOperationFailed", |
| 307 | )<{ |
| 308 | operation: string; |
| 309 | message: string; |
| 310 | }> {} |
| 311 | |
| 312 | export class ClusterStillExists extends Data.TaggedError( |
| 313 | "GCP.Container.ClusterStillExists", |
| 314 | )<{ |
| 315 | name: string; |
| 316 | }> {} |
| 317 | |
| 318 | const lastSegment = (value: string) => { |
| 319 | const trimmed = value.replace(/\/+$/, ""); |
| 320 | const parts = trimmed.split("/"); |
| 321 | return parts[parts.length - 1] || trimmed; |
| 322 | }; |
| 323 | |
| 324 | const normalizeLocation = (location: string | undefined) => |
| 325 | lastSegment(location ?? DEFAULT_LOCATION).toLowerCase(); |
| 326 | |
| 327 | const normalizeChannel = (channel: string | undefined) => { |
| 328 | const value = (channel ?? "").toUpperCase(); |
| 329 | return value === "UNSPECIFIED" || value.length === 0 ? "" : value; |
| 330 | }; |
| 331 | |
| 332 | const 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 | |
| 339 | const 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 | |
| 352 | const resourceName = (project: string, location: string, clusterId: string) => |
| 353 | `projects/${project}/locations/${location}/clusters/${clusterId}`; |
| 354 | |
| 355 | const 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 | |
| 374 | const userLabels = ( |
| 375 | labels: Record<string, string | undefined> | null | undefined, |
| 376 | ): Record<string, string> => stripInternalLabels(tagRecord(labels)); |
| 377 | |
| 378 | const 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 | |
| 393 | const 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 | |
| 442 | const 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 | |
| 452 | const 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 | |
| 470 | const getByName = (name: string) => |
| 471 | container |
| 472 | .getProjectsLocationsClusters({ name }) |
| 473 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 474 | |
| 475 | const 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. |
| 496 | const 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 | */ |
| 507 | const 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 | |
| 531 | const 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 | |
| 544 | const 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 | |
| 577 | const 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 | |
| 590 | const 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 | |
| 603 | const 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 | |
| 633 | export 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 | }); |