| 1 | import * as workstations from "@distilled.cloud/gcp/workstations_v1"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import { tagRecord } from "../../Tags.ts"; |
| 9 | import { GcpEnvironment } from "../Environment.ts"; |
| 10 | import { |
| 11 | createInternalLabels, |
| 12 | diffLabels, |
| 13 | hasAlchemyLabels, |
| 14 | toLabels, |
| 15 | } from "../Labels.ts"; |
| 16 | import type { Providers } from "../Providers.ts"; |
| 17 | import { |
| 18 | expandParent, |
| 19 | fieldMask, |
| 20 | fingerprint, |
| 21 | listAtNested, |
| 22 | listLabeledPages, |
| 23 | normalizeLocation, |
| 24 | parseName, |
| 25 | replaceOnIdentity, |
| 26 | ResourceNotResolved, |
| 27 | sameBool, |
| 28 | sameNumber, |
| 29 | sameText, |
| 30 | stringMap, |
| 31 | toPhysicalId, |
| 32 | userLabels, |
| 33 | waitForOperation, |
| 34 | waitUntilExists, |
| 35 | waitUntilGone, |
| 36 | } from "./internal.ts"; |
| 37 | |
| 38 | export type Container = { |
| 39 | /** |
| 40 | * Docker image that defines the workstation environment. Public images |
| 41 | * or Artifact Registry images the host service account can pull. |
| 42 | */ |
| 43 | image?: string; |
| 44 | /** |
| 45 | * Environment variables passed to the container entrypoint. |
| 46 | */ |
| 47 | env?: Record<string, string>; |
| 48 | /** |
| 49 | * Override the image ENTRYPOINT. |
| 50 | */ |
| 51 | command?: string[]; |
| 52 | /** |
| 53 | * Arguments passed to the entrypoint. |
| 54 | */ |
| 55 | args?: string[]; |
| 56 | /** |
| 57 | * Override the image working directory. |
| 58 | */ |
| 59 | workingDir?: string; |
| 60 | /** |
| 61 | * Run the container as this uid. |
| 62 | */ |
| 63 | runAsUser?: number; |
| 64 | }; |
| 65 | |
| 66 | export type ReadinessCheck = { |
| 67 | /** Path the readiness probe requests. */ |
| 68 | path?: string; |
| 69 | /** Port the readiness probe requests. */ |
| 70 | port?: number; |
| 71 | }; |
| 72 | |
| 73 | export type Accelerator = { |
| 74 | /** Accelerator type (`nvidia-tesla-p100`, …). */ |
| 75 | type?: string; |
| 76 | /** Number of accelerator cards. */ |
| 77 | count?: number; |
| 78 | }; |
| 79 | |
| 80 | export type GceShieldedInstanceConfig = { |
| 81 | /** Enable Secure Boot. */ |
| 82 | enableSecureBoot?: boolean; |
| 83 | /** Enable vTPM. */ |
| 84 | enableVtpm?: boolean; |
| 85 | /** Enable integrity monitoring. */ |
| 86 | enableIntegrityMonitoring?: boolean; |
| 87 | }; |
| 88 | |
| 89 | export type GceConfidentialInstanceConfig = { |
| 90 | /** Enable Confidential Compute. */ |
| 91 | enableConfidentialCompute?: boolean; |
| 92 | }; |
| 93 | |
| 94 | export type BoostConfig = { |
| 95 | /** Boost configuration id. Required when listed. */ |
| 96 | id?: string; |
| 97 | /** Machine type for boosted VMs. */ |
| 98 | machineType?: string; |
| 99 | /** Idle boost VMs to keep ready. */ |
| 100 | poolSize?: number; |
| 101 | /** Boot disk size in GB (minimum 30). */ |
| 102 | bootDiskSizeGb?: number; |
| 103 | /** Enable nested virtualization on boosted VMs. */ |
| 104 | enableNestedVirtualization?: boolean; |
| 105 | /** Accelerators attached to boosted VMs. */ |
| 106 | accelerators?: Accelerator[]; |
| 107 | }; |
| 108 | |
| 109 | export type GceInstance = { |
| 110 | /** |
| 111 | * Machine type (`e2-standard-4`, …). |
| 112 | */ |
| 113 | machineType?: string; |
| 114 | /** |
| 115 | * Boot disk size in GB. Minimum 30, default 50. |
| 116 | */ |
| 117 | bootDiskSizeGb?: number; |
| 118 | /** |
| 119 | * Idle VMs kept ready for fast start. Default 0. |
| 120 | */ |
| 121 | poolSize?: number; |
| 122 | /** |
| 123 | * Disable public IPs. Requires Private Google Access or Cloud NAT. |
| 124 | * @default false |
| 125 | */ |
| 126 | disablePublicIpAddresses?: boolean; |
| 127 | /** |
| 128 | * Disable SSH access to the VM. |
| 129 | */ |
| 130 | disableSsh?: boolean; |
| 131 | /** |
| 132 | * Enable nested virtualization (N1/N2 machine series only). |
| 133 | */ |
| 134 | enableNestedVirtualization?: boolean; |
| 135 | /** |
| 136 | * Service account email for workstation VMs. |
| 137 | */ |
| 138 | serviceAccount?: string; |
| 139 | /** |
| 140 | * Scopes granted to `serviceAccount`. |
| 141 | */ |
| 142 | serviceAccountScopes?: string[]; |
| 143 | /** |
| 144 | * Network tags applied to workstation VMs. |
| 145 | */ |
| 146 | tags?: string[]; |
| 147 | /** |
| 148 | * Resource Manager tags bound to the VM |
| 149 | * (`tagKeys/{id}` → `tagValues/{id}`). |
| 150 | */ |
| 151 | vmTags?: Record<string, string>; |
| 152 | /** |
| 153 | * Custom instance metadata. |
| 154 | */ |
| 155 | instanceMetadata?: Record<string, string>; |
| 156 | /** |
| 157 | * GCS URI of a startup script (`gs://{bucket}/{object}`). |
| 158 | */ |
| 159 | startupScriptUri?: string; |
| 160 | /** |
| 161 | * Shielded VM options. |
| 162 | */ |
| 163 | shieldedInstanceConfig?: GceShieldedInstanceConfig; |
| 164 | /** |
| 165 | * Confidential VM options. |
| 166 | */ |
| 167 | confidentialInstanceConfig?: GceConfidentialInstanceConfig; |
| 168 | /** |
| 169 | * Accelerators attached to the VM. |
| 170 | */ |
| 171 | accelerators?: Accelerator[]; |
| 172 | /** |
| 173 | * Boost configurations users may choose at start. |
| 174 | */ |
| 175 | boostConfigs?: BoostConfig[]; |
| 176 | }; |
| 177 | |
| 178 | export type Host = { |
| 179 | /** Compute Engine VM that backs workstations. */ |
| 180 | gceInstance?: GceInstance; |
| 181 | }; |
| 182 | |
| 183 | export type CustomerEncryptionKey = { |
| 184 | /** |
| 185 | * Cloud KMS key |
| 186 | * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`). |
| 187 | * Immutable. |
| 188 | */ |
| 189 | kmsKey?: string; |
| 190 | /** |
| 191 | * Service account used with the KMS key. Immutable. |
| 192 | */ |
| 193 | kmsKeyServiceAccount?: string; |
| 194 | }; |
| 195 | |
| 196 | export type GcePersistentDisk = { |
| 197 | /** Source disk image. Mutually exclusive with `sourceSnapshot`. */ |
| 198 | sourceImage?: string; |
| 199 | /** Source snapshot. Mutually exclusive with `sourceImage`. */ |
| 200 | sourceSnapshot?: string; |
| 201 | /** When true the disk is read-only and may be shared. */ |
| 202 | readOnly?: boolean; |
| 203 | /** |
| 204 | * Disk type. |
| 205 | * @default "pd-standard" |
| 206 | */ |
| 207 | diskType?: string; |
| 208 | }; |
| 209 | |
| 210 | export type EphemeralDirectory = { |
| 211 | /** Mount path inside the workstation. */ |
| 212 | mountPath?: string; |
| 213 | /** Persistent-disk backing for the ephemeral directory. */ |
| 214 | gcePd?: GcePersistentDisk; |
| 215 | }; |
| 216 | |
| 217 | export type GceRegionalPersistentDisk = { |
| 218 | /** |
| 219 | * Capacity in GB (`10`, `50`, `100`, `200`, `500`, or `1000`). |
| 220 | * Default 200. |
| 221 | */ |
| 222 | sizeGb?: number; |
| 223 | /** |
| 224 | * Disk type (`pd-standard`, `pd-balanced`, `pd-ssd`). |
| 225 | * @default "pd-standard" |
| 226 | */ |
| 227 | diskType?: string; |
| 228 | /** |
| 229 | * File system type. |
| 230 | * @default "ext4" |
| 231 | */ |
| 232 | fsType?: string; |
| 233 | /** |
| 234 | * Whether to delete the disk when the workstation is deleted. |
| 235 | * @default "DELETE" |
| 236 | */ |
| 237 | reclaimPolicy?: |
| 238 | | workstations.GceRegionalPersistentDiskReclaimPolicyEnum |
| 239 | | (string & {}); |
| 240 | /** Snapshot used as the disk source. */ |
| 241 | sourceSnapshot?: string; |
| 242 | /** |
| 243 | * Seconds to wait before archiving the disk as a snapshot (`"0s"` |
| 244 | * disables archival). |
| 245 | */ |
| 246 | archiveTimeout?: string; |
| 247 | /** Maximum resize size in GB. */ |
| 248 | maxSizeGb?: number; |
| 249 | }; |
| 250 | |
| 251 | export type GceHyperdiskBalancedHighAvailability = { |
| 252 | /** |
| 253 | * Capacity in GB (`10`, `50`, `100`, `200`, `500`, or `1000`). |
| 254 | * Default 200. |
| 255 | */ |
| 256 | sizeGb?: number; |
| 257 | /** |
| 258 | * Whether to delete the disk when the workstation is deleted. |
| 259 | * @default "DELETE" |
| 260 | */ |
| 261 | reclaimPolicy?: |
| 262 | | workstations.GceHyperdiskBalancedHighAvailabilityReclaimPolicyEnum |
| 263 | | (string & {}); |
| 264 | /** Snapshot used as the disk source. */ |
| 265 | sourceSnapshot?: string; |
| 266 | /** |
| 267 | * Seconds to wait before archiving the disk as a snapshot. |
| 268 | */ |
| 269 | archiveTimeout?: string; |
| 270 | /** Maximum resize size in GB. */ |
| 271 | maxSizeGb?: number; |
| 272 | }; |
| 273 | |
| 274 | export type PersistentDirectory = { |
| 275 | /** Mount path inside the workstation. */ |
| 276 | mountPath?: string; |
| 277 | /** Regional persistent disk backing. */ |
| 278 | gcePd?: GceRegionalPersistentDisk; |
| 279 | /** Hyperdisk Balanced HA backing. */ |
| 280 | gceHd?: GceHyperdiskBalancedHighAvailability; |
| 281 | }; |
| 282 | |
| 283 | export type PortRange = { |
| 284 | /** Inclusive start port (22, 80, or 1024-65535). */ |
| 285 | first?: number; |
| 286 | /** Inclusive end port (22, 80, or 1024-65535). */ |
| 287 | last?: number; |
| 288 | }; |
| 289 | |
| 290 | export type WorkstationClustersWorkstationConfigProps = { |
| 291 | /** |
| 292 | * Parent workstation cluster. Full name |
| 293 | * `projects/{project}/locations/{location}/workstationClusters/{workstationCluster}` |
| 294 | * or a cluster id (combined with `location`). Immutable — changing it |
| 295 | * replaces the configuration. |
| 296 | */ |
| 297 | workstationCluster: string; |
| 298 | /** |
| 299 | * Configuration id (the `{workstationConfig}` segment). If omitted, a |
| 300 | * unique RFC1035 name is generated from the stack, stage, and logical |
| 301 | * id. Immutable — changing it replaces the configuration. |
| 302 | */ |
| 303 | workstationConfigId?: string; |
| 304 | /** |
| 305 | * Region used when `workstationCluster` is a bare id. |
| 306 | * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`) |
| 307 | */ |
| 308 | location?: string; |
| 309 | /** |
| 310 | * Human-readable display name. |
| 311 | */ |
| 312 | displayName?: string; |
| 313 | /** |
| 314 | * User labels. Alchemy ownership labels are merged in automatically. |
| 315 | */ |
| 316 | labels?: Record<string, string>; |
| 317 | /** |
| 318 | * Client-specified annotations. |
| 319 | */ |
| 320 | annotations?: Record<string, string>; |
| 321 | /** |
| 322 | * Container that runs on each workstation using this configuration. |
| 323 | */ |
| 324 | container?: Container; |
| 325 | /** |
| 326 | * Runtime host for workstations created from this configuration. |
| 327 | */ |
| 328 | host?: Host; |
| 329 | /** |
| 330 | * Readiness checks that must return 200 before the workstation is |
| 331 | * marked running. |
| 332 | */ |
| 333 | readinessChecks?: ReadinessCheck[]; |
| 334 | /** |
| 335 | * Idle timeout before automatic stop (`"1200s"` default, `"0s"` never). |
| 336 | */ |
| 337 | idleTimeout?: string; |
| 338 | /** |
| 339 | * Maximum running time (`"43200s"` default, `"0s"` never). |
| 340 | */ |
| 341 | runningTimeout?: string; |
| 342 | /** |
| 343 | * Directories persisted across workstation sessions. |
| 344 | */ |
| 345 | persistentDirectories?: PersistentDirectory[]; |
| 346 | /** |
| 347 | * Directories that are recreated on every start. |
| 348 | */ |
| 349 | ephemeralDirectories?: EphemeralDirectory[]; |
| 350 | /** |
| 351 | * Customer-managed encryption key. Immutable. |
| 352 | */ |
| 353 | encryptionKey?: CustomerEncryptionKey; |
| 354 | /** |
| 355 | * Zones used to replicate VM and disk resources. Exactly two zones in |
| 356 | * the cluster region. Immutable. |
| 357 | */ |
| 358 | replicaZones?: string[]; |
| 359 | /** |
| 360 | * Maximum workstations a user may have `workstations.workstation.use` |
| 361 | * on. `0` is unlimited. |
| 362 | */ |
| 363 | maxUsableWorkstations?: number; |
| 364 | /** |
| 365 | * Enable Linux `auditd` logging. Requires a host service account with |
| 366 | * log/metric writer roles. |
| 367 | */ |
| 368 | enableAuditAgent?: boolean; |
| 369 | /** |
| 370 | * Disable the websocket TCP relay (SSH and other plain TCP). |
| 371 | */ |
| 372 | disableTcpConnections?: boolean; |
| 373 | /** |
| 374 | * Ports exposed on the workstation. Defaults to 22, 80, and 1024-65535. |
| 375 | */ |
| 376 | allowedPorts?: PortRange[]; |
| 377 | /** |
| 378 | * Grant the creator `roles/workstations.policyAdmin` on workstations |
| 379 | * they create. |
| 380 | * @default false |
| 381 | */ |
| 382 | grantWorkstationAdminRoleOnCreate?: boolean; |
| 383 | }; |
| 384 | |
| 385 | export type WorkstationClustersWorkstationConfig = Resource< |
| 386 | "GCP.Workstations.WorkstationClustersWorkstationConfig", |
| 387 | WorkstationClustersWorkstationConfigProps, |
| 388 | { |
| 389 | /** Full resource name. */ |
| 390 | name: string; |
| 391 | /** Configuration id (last path segment). */ |
| 392 | workstationConfigId: string; |
| 393 | /** Parent cluster name. */ |
| 394 | workstationCluster: string; |
| 395 | /** Project id. */ |
| 396 | project: string; |
| 397 | /** Location id. */ |
| 398 | location: string; |
| 399 | /** Human-readable display name. */ |
| 400 | displayName: string | undefined; |
| 401 | /** User labels (Alchemy ownership labels stripped). */ |
| 402 | labels: Record<string, string>; |
| 403 | /** Client-specified annotations. */ |
| 404 | annotations: Record<string, string>; |
| 405 | /** Container configuration. */ |
| 406 | container: Container | undefined; |
| 407 | /** Runtime host configuration. */ |
| 408 | host: Host | undefined; |
| 409 | /** Readiness checks. */ |
| 410 | readinessChecks: ReadinessCheck[] | undefined; |
| 411 | /** Idle timeout. */ |
| 412 | idleTimeout: string | undefined; |
| 413 | /** Running timeout. */ |
| 414 | runningTimeout: string | undefined; |
| 415 | /** Persistent directories. */ |
| 416 | persistentDirectories: PersistentDirectory[] | undefined; |
| 417 | /** Ephemeral directories. */ |
| 418 | ephemeralDirectories: EphemeralDirectory[] | undefined; |
| 419 | /** Customer-managed encryption key. */ |
| 420 | encryptionKey: CustomerEncryptionKey | undefined; |
| 421 | /** Replica zones. */ |
| 422 | replicaZones: string[] | undefined; |
| 423 | /** Max usable workstations per user. */ |
| 424 | maxUsableWorkstations: number | undefined; |
| 425 | /** Whether auditd logging is enabled. */ |
| 426 | enableAuditAgent: boolean; |
| 427 | /** Whether the TCP relay is disabled. */ |
| 428 | disableTcpConnections: boolean; |
| 429 | /** Exposed port ranges. */ |
| 430 | allowedPorts: PortRange[] | undefined; |
| 431 | /** Whether creators get policyAdmin. */ |
| 432 | grantWorkstationAdminRoleOnCreate: boolean; |
| 433 | /** Whether the configuration is reconciling. */ |
| 434 | reconciling: boolean; |
| 435 | /** Whether the configuration is degraded. */ |
| 436 | degraded: boolean; |
| 437 | /** Server-generated resource uid. */ |
| 438 | uid: string | undefined; |
| 439 | /** RFC3339 creation timestamp. */ |
| 440 | createTime: string | undefined; |
| 441 | /** RFC3339 last-update timestamp. */ |
| 442 | updateTime: string | undefined; |
| 443 | }, |
| 444 | never, |
| 445 | Providers |
| 446 | >; |
| 447 | |
| 448 | /** |
| 449 | * A Cloud Workstations configuration — the template for workstation VMs |
| 450 | * (machine type, image, disks, timeouts). |
| 451 | * |
| 452 | * Changing `workstationConfigId`, `location`, `workstationCluster`, |
| 453 | * `replicaZones`, or `encryptionKey` replaces the configuration. Display |
| 454 | * name, labels, container, host, timeouts, directories, and ports update |
| 455 | * in place. |
| 456 | * |
| 457 | * ### Creating a Configuration |
| 458 | * **Example:** Generated name |
| 459 | * ```typescript |
| 460 | * const config = yield* GCP.Workstations.WorkstationClustersWorkstationConfig( |
| 461 | * "Code", |
| 462 | * { workstationCluster: cluster.name }, |
| 463 | * ); |
| 464 | * ``` |
| 465 | * |
| 466 | * **Example:** Custom machine type and image |
| 467 | * ```typescript |
| 468 | * const config = yield* GCP.Workstations.WorkstationClustersWorkstationConfig( |
| 469 | * "Code", |
| 470 | * { |
| 471 | * workstationCluster: cluster.name, |
| 472 | * idleTimeout: "1800s", |
| 473 | * host: { gceInstance: { machineType: "e2-standard-4", poolSize: 0 } }, |
| 474 | * labels: { env: "prod" }, |
| 475 | * }, |
| 476 | * ); |
| 477 | * ``` |
| 478 | * |
| 479 | * ### Updating a Configuration |
| 480 | * **Example:** Display name and idle timeout |
| 481 | * ```typescript |
| 482 | * // Same logical id, changed props: the engine updates it in place. |
| 483 | * const config = yield* GCP.Workstations.WorkstationClustersWorkstationConfig( |
| 484 | * "Code", |
| 485 | * { |
| 486 | * workstationCluster: cluster.name, |
| 487 | * displayName: "code v2", |
| 488 | * idleTimeout: "3600s", |
| 489 | * }, |
| 490 | * ); |
| 491 | * ``` |
| 492 | * |
| 493 | * @resource |
| 494 | * @category Workstations |
| 495 | */ |
| 496 | export const WorkstationClustersWorkstationConfig = |
| 497 | Resource<WorkstationClustersWorkstationConfig>( |
| 498 | "GCP.Workstations.WorkstationClustersWorkstationConfig", |
| 499 | ); |
| 500 | |
| 501 | const resourceName = (cluster: string, workstationConfigId: string) => |
| 502 | `${cluster}/workstationConfigs/${workstationConfigId}`; |
| 503 | |
| 504 | const toContainer = ( |
| 505 | value: workstations.Container | Container | undefined, |
| 506 | ): Container | undefined => |
| 507 | value === undefined |
| 508 | ? undefined |
| 509 | : { |
| 510 | image: value.image, |
| 511 | env: stringMap(value.env), |
| 512 | command: value.command, |
| 513 | args: value.args, |
| 514 | workingDir: value.workingDir, |
| 515 | runAsUser: value.runAsUser, |
| 516 | }; |
| 517 | |
| 518 | const toGceInstance = ( |
| 519 | value: workstations.GceInstance | GceInstance | undefined, |
| 520 | ): GceInstance | undefined => |
| 521 | value === undefined |
| 522 | ? undefined |
| 523 | : { |
| 524 | machineType: value.machineType, |
| 525 | bootDiskSizeGb: value.bootDiskSizeGb, |
| 526 | poolSize: value.poolSize, |
| 527 | disablePublicIpAddresses: value.disablePublicIpAddresses, |
| 528 | disableSsh: value.disableSsh, |
| 529 | enableNestedVirtualization: value.enableNestedVirtualization, |
| 530 | serviceAccount: value.serviceAccount, |
| 531 | serviceAccountScopes: value.serviceAccountScopes, |
| 532 | tags: value.tags, |
| 533 | vmTags: stringMap(value.vmTags), |
| 534 | instanceMetadata: stringMap(value.instanceMetadata), |
| 535 | startupScriptUri: value.startupScriptUri, |
| 536 | shieldedInstanceConfig: value.shieldedInstanceConfig, |
| 537 | confidentialInstanceConfig: value.confidentialInstanceConfig, |
| 538 | accelerators: value.accelerators, |
| 539 | boostConfigs: value.boostConfigs, |
| 540 | }; |
| 541 | |
| 542 | const toHost = ( |
| 543 | value: workstations.Host | Host | undefined, |
| 544 | ): Host | undefined => |
| 545 | value === undefined |
| 546 | ? undefined |
| 547 | : { gceInstance: toGceInstance(value.gceInstance) }; |
| 548 | |
| 549 | // Reads omit proto3 defaults (`poolSize: 0`, `false`) and add server defaults |
| 550 | // the user never set (scopes, disk size), so compare only the fields the user |
| 551 | // declared, treating an omitted default as equal to its zero value. |
| 552 | const isZero = (value: unknown) => |
| 553 | value === undefined || |
| 554 | value === null || |
| 555 | value === 0 || |
| 556 | value === false || |
| 557 | value === "" || |
| 558 | (Array.isArray(value) && value.length === 0) || |
| 559 | (typeof value === "object" && Object.keys(value as object).length === 0); |
| 560 | |
| 561 | const hostMatches = ( |
| 562 | observed: workstations.Host | undefined, |
| 563 | desired: Host, |
| 564 | ): boolean => { |
| 565 | const want = toGceInstance(desired.gceInstance); |
| 566 | if (want === undefined) return true; |
| 567 | const have = toGceInstance(observed?.gceInstance) ?? {}; |
| 568 | return Object.entries(want).every(([key, value]) => { |
| 569 | if (value === undefined) return true; |
| 570 | const current = have[key as keyof GceInstance]; |
| 571 | return isZero(value) |
| 572 | ? isZero(current) |
| 573 | : fingerprint(current) === fingerprint(value); |
| 574 | }); |
| 575 | }; |
| 576 | |
| 577 | const toPersistent = ( |
| 578 | value: workstations.PersistentDirectory | PersistentDirectory, |
| 579 | ): PersistentDirectory => ({ |
| 580 | mountPath: value.mountPath, |
| 581 | gcePd: value.gcePd, |
| 582 | gceHd: value.gceHd, |
| 583 | }); |
| 584 | |
| 585 | const toEphemeral = ( |
| 586 | value: workstations.EphemeralDirectory | EphemeralDirectory, |
| 587 | ): EphemeralDirectory => ({ |
| 588 | mountPath: value.mountPath, |
| 589 | gcePd: value.gcePd, |
| 590 | }); |
| 591 | |
| 592 | const toAttrs = ( |
| 593 | item: workstations.WorkstationConfig, |
| 594 | project: string, |
| 595 | region: string, |
| 596 | ) => { |
| 597 | const name = item.name ?? ""; |
| 598 | const parsed = parseName(name, "workstationConfigs", region); |
| 599 | return { |
| 600 | name, |
| 601 | workstationConfigId: parsed.id, |
| 602 | workstationCluster: parsed.parent, |
| 603 | project: parsed.project || project, |
| 604 | location: parsed.location, |
| 605 | displayName: item.displayName, |
| 606 | labels: userLabels(item.labels), |
| 607 | annotations: stringMap(item.annotations) ?? {}, |
| 608 | container: toContainer(item.container), |
| 609 | host: toHost(item.host), |
| 610 | readinessChecks: item.readinessChecks, |
| 611 | idleTimeout: item.idleTimeout, |
| 612 | runningTimeout: item.runningTimeout, |
| 613 | persistentDirectories: item.persistentDirectories?.map(toPersistent), |
| 614 | ephemeralDirectories: item.ephemeralDirectories?.map(toEphemeral), |
| 615 | encryptionKey: item.encryptionKey, |
| 616 | replicaZones: item.replicaZones, |
| 617 | maxUsableWorkstations: item.maxUsableWorkstations, |
| 618 | enableAuditAgent: item.enableAuditAgent === true, |
| 619 | disableTcpConnections: item.disableTcpConnections === true, |
| 620 | allowedPorts: item.allowedPorts, |
| 621 | grantWorkstationAdminRoleOnCreate: |
| 622 | item.grantWorkstationAdminRoleOnCreate === true, |
| 623 | reconciling: item.reconciling === true, |
| 624 | degraded: item.degraded === true, |
| 625 | uid: item.uid, |
| 626 | createTime: item.createTime, |
| 627 | updateTime: item.updateTime, |
| 628 | }; |
| 629 | }; |
| 630 | |
| 631 | const getByName = (name: string) => |
| 632 | workstations |
| 633 | .getProjectsLocationsWorkstationClustersWorkstationConfigs({ name }) |
| 634 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 635 | |
| 636 | const listOwned = (project: string, region: string) => |
| 637 | listAtNested(project, region, "workstationClusters/-", (parent) => |
| 638 | listLabeledPages( |
| 639 | workstations.listProjectsLocationsWorkstationClustersWorkstationConfigs.pages( |
| 640 | { |
| 641 | parent, |
| 642 | pageSize: 1000, |
| 643 | }, |
| 644 | ), |
| 645 | (page) => page.workstationConfigs, |
| 646 | (item) => item.labels, |
| 647 | ), |
| 648 | ); |
| 649 | |
| 650 | const replicaKey = (zones: string[] | undefined) => |
| 651 | fingerprint([...(zones ?? [])].map((zone) => zone.toLowerCase()).sort()); |
| 652 | |
| 653 | export const WorkstationClustersWorkstationConfigProvider = () => |
| 654 | Provider.succeed(WorkstationClustersWorkstationConfig, { |
| 655 | stables: [ |
| 656 | "name", |
| 657 | "workstationConfigId", |
| 658 | "workstationCluster", |
| 659 | "project", |
| 660 | "location", |
| 661 | "uid", |
| 662 | "createTime", |
| 663 | ], |
| 664 | |
| 665 | diff: Effect.fn(function* ({ news, olds, output }) { |
| 666 | if (!isResolved(news)) return undefined; |
| 667 | const env = yield* GcpEnvironment.current; |
| 668 | const previousKey = olds?.encryptionKey ?? output?.encryptionKey; |
| 669 | const previousZones = olds?.replicaZones ?? output?.replicaZones; |
| 670 | return replaceOnIdentity({ |
| 671 | previousId: olds?.workstationConfigId ?? output?.workstationConfigId, |
| 672 | nextId: |
| 673 | news.workstationConfigId ?? |
| 674 | olds?.workstationConfigId ?? |
| 675 | output?.workstationConfigId, |
| 676 | previousLocation: normalizeLocation( |
| 677 | olds?.location ?? output?.location, |
| 678 | env.region, |
| 679 | ), |
| 680 | nextLocation: normalizeLocation( |
| 681 | news.location ?? olds?.location ?? output?.location, |
| 682 | env.region, |
| 683 | ), |
| 684 | previousParent: olds?.workstationCluster ?? output?.workstationCluster, |
| 685 | nextParent: news.workstationCluster, |
| 686 | extra: |
| 687 | (previousKey?.kmsKey !== undefined && |
| 688 | news.encryptionKey?.kmsKey !== undefined && |
| 689 | previousKey.kmsKey !== news.encryptionKey.kmsKey) || |
| 690 | (previousZones !== undefined && |
| 691 | news.replicaZones !== undefined && |
| 692 | replicaKey(previousZones) !== replicaKey(news.replicaZones)), |
| 693 | }); |
| 694 | }), |
| 695 | |
| 696 | read: Effect.fn(function* ({ id, olds, output }) { |
| 697 | const env = yield* GcpEnvironment.current; |
| 698 | const parent = olds?.workstationCluster ?? output?.workstationCluster; |
| 699 | // A config cannot exist before its cluster's name resolved. |
| 700 | if (output?.name === undefined && !parent) return undefined; |
| 701 | const workstationConfigId = yield* toPhysicalId( |
| 702 | id, |
| 703 | olds?.workstationConfigId, |
| 704 | output?.workstationConfigId, |
| 705 | "config", |
| 706 | ); |
| 707 | const location = normalizeLocation( |
| 708 | olds?.location ?? output?.location, |
| 709 | env.region, |
| 710 | ); |
| 711 | const cluster = expandParent( |
| 712 | parent ?? "", |
| 713 | env.project, |
| 714 | location, |
| 715 | "workstationClusters", |
| 716 | ); |
| 717 | const name = output?.name ?? resourceName(cluster, workstationConfigId); |
| 718 | const existing = yield* getByName(name); |
| 719 | if (existing === undefined) return undefined; |
| 720 | const attrs = toAttrs(existing, env.project, env.region); |
| 721 | return (yield* hasAlchemyLabels(id, tagRecord(existing.labels))) |
| 722 | ? attrs |
| 723 | : Unowned(attrs); |
| 724 | }), |
| 725 | |
| 726 | list: () => |
| 727 | Effect.gen(function* () { |
| 728 | const env = yield* GcpEnvironment.current; |
| 729 | const items = yield* listOwned(env.project, env.region); |
| 730 | return items.map((item) => toAttrs(item, env.project, env.region)); |
| 731 | }), |
| 732 | |
| 733 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 734 | const env = yield* GcpEnvironment.current; |
| 735 | const workstationConfigId = yield* toPhysicalId( |
| 736 | id, |
| 737 | news.workstationConfigId, |
| 738 | output?.workstationConfigId, |
| 739 | "config", |
| 740 | ); |
| 741 | const location = normalizeLocation( |
| 742 | news.location ?? output?.location, |
| 743 | env.region, |
| 744 | ); |
| 745 | const cluster = expandParent( |
| 746 | news.workstationCluster, |
| 747 | env.project, |
| 748 | location, |
| 749 | "workstationClusters", |
| 750 | ); |
| 751 | const name = resourceName(cluster, workstationConfigId); |
| 752 | const desiredLabels = { |
| 753 | ...toLabels(news.labels), |
| 754 | ...(yield* createInternalLabels(id)), |
| 755 | }; |
| 756 | const desiredAnnotations = stringMap(news.annotations); |
| 757 | |
| 758 | let current = yield* getByName(output?.name ?? name); |
| 759 | |
| 760 | if (current === undefined) { |
| 761 | const created = yield* workstations |
| 762 | .createProjectsLocationsWorkstationClustersWorkstationConfigs({ |
| 763 | parent: cluster, |
| 764 | workstationConfigId, |
| 765 | body: { |
| 766 | displayName: news.displayName, |
| 767 | labels: desiredLabels, |
| 768 | annotations: desiredAnnotations, |
| 769 | container: news.container, |
| 770 | host: news.host, |
| 771 | readinessChecks: news.readinessChecks, |
| 772 | idleTimeout: news.idleTimeout, |
| 773 | runningTimeout: news.runningTimeout, |
| 774 | persistentDirectories: news.persistentDirectories, |
| 775 | ephemeralDirectories: news.ephemeralDirectories, |
| 776 | encryptionKey: news.encryptionKey, |
| 777 | replicaZones: news.replicaZones, |
| 778 | maxUsableWorkstations: news.maxUsableWorkstations, |
| 779 | enableAuditAgent: news.enableAuditAgent, |
| 780 | disableTcpConnections: news.disableTcpConnections, |
| 781 | allowedPorts: news.allowedPorts, |
| 782 | grantWorkstationAdminRoleOnCreate: |
| 783 | news.grantWorkstationAdminRoleOnCreate, |
| 784 | }, |
| 785 | }) |
| 786 | .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined))); |
| 787 | if (created !== undefined) { |
| 788 | // ALREADY_EXISTS (6): a concurrent create won the race. |
| 789 | yield* waitForOperation(created).pipe( |
| 790 | Effect.catchTag("GCP.OperationFailed", (error) => |
| 791 | error.code === 6 ? Effect.void : Effect.fail(error), |
| 792 | ), |
| 793 | ); |
| 794 | } |
| 795 | current = yield* waitUntilExists(getByName(name), name); |
| 796 | } |
| 797 | |
| 798 | if (current === undefined) { |
| 799 | return yield* new ResourceNotResolved({ name }); |
| 800 | } |
| 801 | |
| 802 | const observedLabels = tagRecord(current.labels); |
| 803 | const { upsert, removed } = diffLabels(observedLabels, desiredLabels); |
| 804 | const mask = fieldMask([ |
| 805 | (upsert.length > 0 || removed.length > 0) && "labels", |
| 806 | !sameText(current.displayName, news.displayName) && "displayName", |
| 807 | fingerprint(stringMap(current.annotations)) !== |
| 808 | fingerprint(desiredAnnotations) && "annotations", |
| 809 | news.container !== undefined && |
| 810 | fingerprint(toContainer(current.container)) !== |
| 811 | fingerprint(toContainer(news.container)) && |
| 812 | "container", |
| 813 | news.host !== undefined && |
| 814 | !hostMatches(current.host, news.host) && |
| 815 | "host", |
| 816 | news.readinessChecks !== undefined && |
| 817 | fingerprint(current.readinessChecks) !== |
| 818 | fingerprint(news.readinessChecks) && |
| 819 | "readinessChecks", |
| 820 | news.idleTimeout !== undefined && |
| 821 | !sameText(current.idleTimeout, news.idleTimeout) && |
| 822 | "idleTimeout", |
| 823 | news.runningTimeout !== undefined && |
| 824 | !sameText(current.runningTimeout, news.runningTimeout) && |
| 825 | "runningTimeout", |
| 826 | news.persistentDirectories !== undefined && |
| 827 | fingerprint(current.persistentDirectories?.map(toPersistent)) !== |
| 828 | fingerprint(news.persistentDirectories.map(toPersistent)) && |
| 829 | "persistentDirectories", |
| 830 | news.ephemeralDirectories !== undefined && |
| 831 | fingerprint(current.ephemeralDirectories?.map(toEphemeral)) !== |
| 832 | fingerprint(news.ephemeralDirectories.map(toEphemeral)) && |
| 833 | "ephemeralDirectories", |
| 834 | news.maxUsableWorkstations !== undefined && |
| 835 | !sameNumber( |
| 836 | current.maxUsableWorkstations, |
| 837 | news.maxUsableWorkstations, |
| 838 | ) && |
| 839 | "maxUsableWorkstations", |
| 840 | news.enableAuditAgent !== undefined && |
| 841 | !sameBool(current.enableAuditAgent, news.enableAuditAgent) && |
| 842 | "enableAuditAgent", |
| 843 | news.disableTcpConnections !== undefined && |
| 844 | !sameBool( |
| 845 | current.disableTcpConnections, |
| 846 | news.disableTcpConnections, |
| 847 | ) && |
| 848 | "disableTcpConnections", |
| 849 | news.allowedPorts !== undefined && |
| 850 | fingerprint(current.allowedPorts) !== |
| 851 | fingerprint(news.allowedPorts) && |
| 852 | "allowedPorts", |
| 853 | news.grantWorkstationAdminRoleOnCreate !== undefined && |
| 854 | !sameBool( |
| 855 | current.grantWorkstationAdminRoleOnCreate, |
| 856 | news.grantWorkstationAdminRoleOnCreate, |
| 857 | ) && |
| 858 | "grantWorkstationAdminRoleOnCreate", |
| 859 | ]); |
| 860 | |
| 861 | if (mask.length > 0) { |
| 862 | const operation = yield* workstations |
| 863 | .patchProjectsLocationsWorkstationClustersWorkstationConfigs({ |
| 864 | name: current.name ?? name, |
| 865 | updateMask: mask, |
| 866 | body: { |
| 867 | etag: current.etag, |
| 868 | labels: desiredLabels, |
| 869 | displayName: news.displayName, |
| 870 | annotations: desiredAnnotations, |
| 871 | container: news.container, |
| 872 | host: news.host, |
| 873 | readinessChecks: news.readinessChecks, |
| 874 | idleTimeout: news.idleTimeout, |
| 875 | runningTimeout: news.runningTimeout, |
| 876 | persistentDirectories: news.persistentDirectories, |
| 877 | ephemeralDirectories: news.ephemeralDirectories, |
| 878 | maxUsableWorkstations: news.maxUsableWorkstations, |
| 879 | enableAuditAgent: news.enableAuditAgent, |
| 880 | disableTcpConnections: news.disableTcpConnections, |
| 881 | allowedPorts: news.allowedPorts, |
| 882 | grantWorkstationAdminRoleOnCreate: |
| 883 | news.grantWorkstationAdminRoleOnCreate, |
| 884 | }, |
| 885 | }) |
| 886 | .pipe( |
| 887 | Effect.retry({ |
| 888 | while: (error) => error._tag === "Conflict", |
| 889 | times: 8, |
| 890 | schedule: Schedule.spaced("2 seconds"), |
| 891 | }), |
| 892 | ); |
| 893 | yield* waitForOperation(operation); |
| 894 | current = yield* waitUntilExists( |
| 895 | getByName(current.name ?? name), |
| 896 | current.name ?? name, |
| 897 | ); |
| 898 | } |
| 899 | |
| 900 | return toAttrs(current, env.project, env.region); |
| 901 | }), |
| 902 | |
| 903 | delete: Effect.fn(function* ({ output }) { |
| 904 | const operation = yield* workstations |
| 905 | .deleteProjectsLocationsWorkstationClustersWorkstationConfigs({ |
| 906 | name: output.name, |
| 907 | force: true, |
| 908 | }) |
| 909 | .pipe( |
| 910 | Effect.retry({ |
| 911 | while: (error) => error._tag === "Conflict", |
| 912 | times: 8, |
| 913 | schedule: Schedule.spaced("2 seconds"), |
| 914 | }), |
| 915 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 916 | ); |
| 917 | if (operation !== undefined) { |
| 918 | // NOT_FOUND (5): already gone. |
| 919 | yield* waitForOperation(operation).pipe( |
| 920 | Effect.catchTag("GCP.OperationFailed", (error) => |
| 921 | error.code === 5 ? Effect.void : Effect.fail(error), |
| 922 | ), |
| 923 | ); |
| 924 | } |
| 925 | yield* waitUntilGone(getByName(output.name), output.name); |
| 926 | }), |
| 927 | }); |