1import * as workstations from "@distilled.cloud/gcp/workstations_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
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
38export 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
66export type ReadinessCheck = {
67 /** Path the readiness probe requests. */
68 path?: string;
69 /** Port the readiness probe requests. */
70 port?: number;
71};
72
73export type Accelerator = {
74 /** Accelerator type (`nvidia-tesla-p100`, …). */
75 type?: string;
76 /** Number of accelerator cards. */
77 count?: number;
78};
79
80export type GceShieldedInstanceConfig = {
81 /** Enable Secure Boot. */
82 enableSecureBoot?: boolean;
83 /** Enable vTPM. */
84 enableVtpm?: boolean;
85 /** Enable integrity monitoring. */
86 enableIntegrityMonitoring?: boolean;
87};
88
89export type GceConfidentialInstanceConfig = {
90 /** Enable Confidential Compute. */
91 enableConfidentialCompute?: boolean;
92};
93
94export 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
109export 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
178export type Host = {
179 /** Compute Engine VM that backs workstations. */
180 gceInstance?: GceInstance;
181};
182
183export 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
196export 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
210export type EphemeralDirectory = {
211 /** Mount path inside the workstation. */
212 mountPath?: string;
213 /** Persistent-disk backing for the ephemeral directory. */
214 gcePd?: GcePersistentDisk;
215};
216
217export 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
251export 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
274export 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
283export 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
290export 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
385export 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 */
496export const WorkstationClustersWorkstationConfig =
497 Resource<WorkstationClustersWorkstationConfig>(
498 "GCP.Workstations.WorkstationClustersWorkstationConfig",
499 );
500
501const resourceName = (cluster: string, workstationConfigId: string) =>
502 `${cluster}/workstationConfigs/${workstationConfigId}`;
503
504const 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
518const 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
542const 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.
552const 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
561const 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
577const toPersistent = (
578 value: workstations.PersistentDirectory | PersistentDirectory,
579): PersistentDirectory => ({
580 mountPath: value.mountPath,
581 gcePd: value.gcePd,
582 gceHd: value.gceHd,
583});
584
585const toEphemeral = (
586 value: workstations.EphemeralDirectory | EphemeralDirectory,
587): EphemeralDirectory => ({
588 mountPath: value.mountPath,
589 gcePd: value.gcePd,
590});
591
592const 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
631const getByName = (name: string) =>
632 workstations
633 .getProjectsLocationsWorkstationClustersWorkstationConfigs({ name })
634 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
635
636const 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
650const replicaKey = (zones: string[] | undefined) =>
651 fingerprint([...(zones ?? [])].map((zone) => zone.toLowerCase()).sort());
652
653export 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 });