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 fieldMask,
19 fingerprint,
20 listAtLocation,
21 listLabeledPages,
22 networkName,
23 normalizeLocation,
24 parentOf,
25 parseName,
26 replaceOnIdentity,
27 ResourceNotResolved,
28 sameText,
29 stringMap,
30 subnetworkName,
31 toPhysicalId,
32 userLabels,
33 waitForOperation,
34 waitUntilExists,
35 waitUntilGone,
36} from "./internal.ts";
37
38export type DomainConfig = {
39 /**
40 * Domain used by Workstations for HTTP ingress. Immutable.
41 */
42 domain?: string;
43};
44
45export type GatewayConfig = {
46 /**
47 * Whether HTTP/2 is enabled for this workstation cluster.
48 * @default false
49 */
50 http2Enabled?: boolean;
51};
52
53export type PrivateClusterConfig = {
54 /**
55 * Whether the Workstations endpoint is private. Immutable.
56 * @default false
57 */
58 enablePrivateEndpoint?: boolean;
59 /**
60 * Additional projects allowed to attach to the cluster's service
61 * attachment. The cluster project and VPC host project are allowed by
62 * default.
63 */
64 allowedProjects?: string[];
65};
66
67export type WorkstationClusterProps = {
68 /**
69 * Cluster id (the `{workstationCluster}` segment of
70 * `projects/{project}/locations/{location}/workstationClusters/{workstationCluster}`).
71 * If omitted, a unique RFC1035 name is generated from the stack, stage,
72 * and logical id. Immutable — changing it replaces the cluster.
73 */
74 workstationClusterId?: string;
75 /**
76 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
77 * replaces the cluster. `US-CENTRAL1` is accepted and normalized to
78 * `us-central1`.
79 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
80 */
81 location?: string;
82 /**
83 * VPC network. Full name
84 * `projects/{project}/global/networks/{network}` or a network id.
85 * Immutable — changing it replaces the cluster.
86 * @default "default"
87 */
88 network?: string;
89 /**
90 * Subnetwork. Full name
91 * `projects/{project}/regions/{region}/subnetworks/{subnetwork}` or a
92 * subnetwork id (combined with `location`). Immutable — changing it
93 * replaces the cluster.
94 * @default "default"
95 */
96 subnetwork?: string;
97 /**
98 * Human-readable display name.
99 */
100 displayName?: string;
101 /**
102 * User labels. Alchemy ownership labels are merged in automatically
103 * and propagated to the underlying Compute Engine resources.
104 */
105 labels?: Record<string, string>;
106 /**
107 * Client-specified annotations.
108 */
109 annotations?: Record<string, string>;
110 /**
111 * Resource Manager tags bound at create time. Immutable.
112 */
113 tags?: Record<string, string>;
114 /**
115 * Launch URL for unstarted workstations in this cluster.
116 */
117 workstationLaunchUrl?: string;
118 /**
119 * Redirect URL for unauthorized requests received by workstation VMs.
120 */
121 workstationAuthorizationUrl?: string;
122 /**
123 * Configuration options for a custom domain. Immutable.
124 */
125 domainConfig?: DomainConfig;
126 /**
127 * Cluster HTTP gateway options.
128 */
129 gatewayConfig?: GatewayConfig;
130 /**
131 * Private cluster configuration. `enablePrivateEndpoint` is immutable.
132 */
133 privateClusterConfig?: PrivateClusterConfig;
134};
135
136export type WorkstationCluster = Resource<
137 "GCP.Workstations.WorkstationCluster",
138 WorkstationClusterProps,
139 {
140 /** Full resource name. */
141 name: string;
142 /** Cluster id (last path segment). */
143 workstationClusterId: string;
144 /** Project id. */
145 project: string;
146 /** Location id. */
147 location: string;
148 /** VPC network name. */
149 network: string | undefined;
150 /** Subnetwork name. */
151 subnetwork: string | undefined;
152 /** Human-readable display name. */
153 displayName: string | undefined;
154 /** User labels (Alchemy ownership labels stripped). */
155 labels: Record<string, string>;
156 /** Client-specified annotations. */
157 annotations: Record<string, string>;
158 /** Launch URL for unstarted workstations. */
159 workstationLaunchUrl: string | undefined;
160 /** Redirect URL for unauthorized workstation requests. */
161 workstationAuthorizationUrl: string | undefined;
162 /** Custom domain configuration. */
163 domainConfig: DomainConfig | undefined;
164 /** HTTP gateway configuration. */
165 gatewayConfig: GatewayConfig | undefined;
166 /** Private cluster configuration. */
167 privateClusterConfig: PrivateClusterConfig | undefined;
168 /** Private IP of the cluster control plane. */
169 controlPlaneIp: string | undefined;
170 /** Whether the cluster is currently reconciling. */
171 reconciling: boolean;
172 /** Whether the cluster is in degraded mode. */
173 degraded: boolean;
174 /** Server-generated resource uid. */
175 uid: string | undefined;
176 /** RFC3339 creation timestamp. */
177 createTime: string | undefined;
178 /** RFC3339 last-update timestamp. */
179 updateTime: string | undefined;
180 },
181 never,
182 Providers
183>;
184
185/**
186 * A Cloud Workstations cluster — a regional group of workstations
187 * attached to a VPC network.
188 *
189 * Changing `workstationClusterId`, `location`, `network`, `subnetwork`,
190 * `domainConfig`, `tags`, or `privateClusterConfig.enablePrivateEndpoint`
191 * replaces the cluster. Display name, labels, annotations, launch URLs,
192 * gateway config, and `allowedProjects` update in place.
193 *
194 * ### Creating a Cluster
195 * **Example:** Generated name on the default VPC
196 * ```typescript
197 * const cluster = yield* GCP.Workstations.WorkstationCluster("Dev", {});
198 * ```
199 *
200 * **Example:** Explicit id, network, and labels
201 * ```typescript
202 * const cluster = yield* GCP.Workstations.WorkstationCluster("Dev", {
203 * workstationClusterId: "app-dev",
204 * location: "us-central1",
205 * network: "default",
206 * subnetwork: "default",
207 * displayName: "app-dev workstations",
208 * labels: { env: "prod" },
209 * });
210 * ```
211 *
212 * ### Updating a Cluster
213 * **Example:** Display name and labels
214 * ```typescript
215 * // Same logical id, changed props: the engine updates it in place.
216 * const cluster = yield* GCP.Workstations.WorkstationCluster("Dev", {
217 * displayName: "app-dev workstations v2",
218 * labels: { env: "prod", team: "platform" },
219 * });
220 * ```
221 *
222 * @resource
223 * @category Workstations
224 */
225export const WorkstationCluster = Resource<WorkstationCluster>(
226 "GCP.Workstations.WorkstationCluster",
227);
228
229const resourceName = (
230 project: string,
231 location: string,
232 workstationClusterId: string,
233) =>
234 `projects/${project}/locations/${location}/workstationClusters/${workstationClusterId}`;
235
236const toDomain = (
237 config: workstations.DomainConfig | DomainConfig | undefined,
238): DomainConfig | undefined =>
239 config === undefined ? undefined : { domain: config.domain };
240
241const toGateway = (
242 config: workstations.GatewayConfig | GatewayConfig | undefined,
243): GatewayConfig | undefined =>
244 config === undefined ? undefined : { http2Enabled: config.http2Enabled };
245
246const toPrivate = (
247 config: workstations.PrivateClusterConfig | PrivateClusterConfig | undefined,
248): PrivateClusterConfig | undefined =>
249 config === undefined
250 ? undefined
251 : {
252 enablePrivateEndpoint: config.enablePrivateEndpoint,
253 allowedProjects: config.allowedProjects,
254 };
255
256const toAttrs = (
257 item: workstations.WorkstationCluster,
258 project: string,
259 region: string,
260) => {
261 const name = item.name ?? "";
262 const parsed = parseName(name, "workstationClusters", region);
263 return {
264 name,
265 workstationClusterId: parsed.id,
266 project: parsed.project || project,
267 location: parsed.location,
268 network: item.network,
269 subnetwork: item.subnetwork,
270 displayName: item.displayName,
271 labels: userLabels(item.labels),
272 annotations: stringMap(item.annotations) ?? {},
273 workstationLaunchUrl: item.workstationLaunchUrl,
274 workstationAuthorizationUrl: item.workstationAuthorizationUrl,
275 domainConfig: toDomain(item.domainConfig),
276 gatewayConfig: toGateway(item.gatewayConfig),
277 privateClusterConfig: toPrivate(item.privateClusterConfig),
278 controlPlaneIp: item.controlPlaneIp,
279 reconciling: item.reconciling === true,
280 degraded: item.degraded === true,
281 uid: item.uid,
282 createTime: item.createTime,
283 updateTime: item.updateTime,
284 };
285};
286
287const getByName = (name: string) =>
288 workstations
289 .getProjectsLocationsWorkstationClusters({ name })
290 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
291
292const listOwned = (project: string, region: string) =>
293 listAtLocation(project, region, (parent) =>
294 listLabeledPages(
295 workstations.listProjectsLocationsWorkstationClusters.pages({
296 parent,
297 pageSize: 1000,
298 }),
299 (page) => page.workstationClusters,
300 (item) => item.labels,
301 ),
302 );
303
304export const WorkstationClusterProvider = () =>
305 Provider.succeed(WorkstationCluster, {
306 stables: [
307 "name",
308 "workstationClusterId",
309 "project",
310 "location",
311 "uid",
312 "createTime",
313 ],
314
315 diff: Effect.fn(function* ({ news, olds, output }) {
316 if (!isResolved(news)) return undefined;
317 const env = yield* GcpEnvironment.current;
318 const previousNetwork = olds?.network ?? output?.network;
319 const previousSubnetwork = olds?.subnetwork ?? output?.subnetwork;
320 const previousPrivate =
321 olds?.privateClusterConfig ?? output?.privateClusterConfig;
322 const previousDomain = olds?.domainConfig ?? output?.domainConfig;
323 const previousTags = olds?.tags;
324 return replaceOnIdentity({
325 previousId: olds?.workstationClusterId ?? output?.workstationClusterId,
326 nextId:
327 news.workstationClusterId ??
328 olds?.workstationClusterId ??
329 output?.workstationClusterId,
330 previousLocation: normalizeLocation(
331 olds?.location ?? output?.location,
332 env.region,
333 ),
334 nextLocation: normalizeLocation(
335 news.location ?? olds?.location ?? output?.location,
336 env.region,
337 ),
338 extra:
339 (previousNetwork !== undefined &&
340 news.network !== undefined &&
341 !sameText(previousNetwork, news.network) &&
342 !previousNetwork.endsWith(`/${news.network}`)) ||
343 (previousSubnetwork !== undefined &&
344 news.subnetwork !== undefined &&
345 !sameText(previousSubnetwork, news.subnetwork) &&
346 !previousSubnetwork.endsWith(`/${news.subnetwork}`)) ||
347 (previousPrivate?.enablePrivateEndpoint !== undefined &&
348 news.privateClusterConfig?.enablePrivateEndpoint !== undefined &&
349 previousPrivate.enablePrivateEndpoint !==
350 news.privateClusterConfig.enablePrivateEndpoint) ||
351 (previousDomain?.domain !== undefined &&
352 news.domainConfig?.domain !== undefined &&
353 previousDomain.domain !== news.domainConfig.domain) ||
354 (previousTags !== undefined &&
355 news.tags !== undefined &&
356 fingerprint(previousTags) !== fingerprint(news.tags)),
357 });
358 }),
359
360 read: Effect.fn(function* ({ id, olds, output }) {
361 const env = yield* GcpEnvironment.current;
362 const workstationClusterId = yield* toPhysicalId(
363 id,
364 olds?.workstationClusterId,
365 output?.workstationClusterId,
366 "cluster",
367 );
368 const location = normalizeLocation(
369 olds?.location ?? output?.location,
370 env.region,
371 );
372 const name =
373 output?.name ??
374 resourceName(env.project, location, workstationClusterId);
375 const existing = yield* getByName(name);
376 if (existing === undefined) return undefined;
377 const attrs = toAttrs(existing, env.project, env.region);
378 // A resource we recorded is ours even while GCP hides its labels
379 // (clusters report none while `reconciling`), so destroy can still
380 // clean it up.
381 if (output !== undefined && output.name === existing.name) return attrs;
382 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
383 ? attrs
384 : Unowned(attrs);
385 }),
386
387 list: () =>
388 Effect.gen(function* () {
389 const env = yield* GcpEnvironment.current;
390 const items = yield* listOwned(env.project, env.region);
391 return items.map((item) => toAttrs(item, env.project, env.region));
392 }),
393
394 reconcile: Effect.fn(function* ({ id, news, output }) {
395 const env = yield* GcpEnvironment.current;
396 const workstationClusterId = yield* toPhysicalId(
397 id,
398 news.workstationClusterId,
399 output?.workstationClusterId,
400 "cluster",
401 );
402 const location = normalizeLocation(
403 news.location ?? output?.location,
404 env.region,
405 );
406 const name = resourceName(env.project, location, workstationClusterId);
407 const network = networkName(news.network, env.project);
408 const subnetwork = subnetworkName(news.subnetwork, env.project, location);
409 const desiredLabels = {
410 ...toLabels(news.labels),
411 ...(yield* createInternalLabels(id)),
412 };
413 const desiredAnnotations = stringMap(news.annotations);
414
415 let current = yield* getByName(output?.name ?? name);
416
417 if (current === undefined) {
418 const created = yield* workstations
419 .createProjectsLocationsWorkstationClusters({
420 parent: parentOf(env.project, location),
421 workstationClusterId,
422 body: {
423 network,
424 subnetwork,
425 displayName: news.displayName,
426 labels: desiredLabels,
427 annotations: desiredAnnotations,
428 tags: stringMap(news.tags),
429 workstationLaunchUrl: news.workstationLaunchUrl,
430 workstationAuthorizationUrl: news.workstationAuthorizationUrl,
431 domainConfig: news.domainConfig,
432 gatewayConfig: news.gatewayConfig,
433 privateClusterConfig: news.privateClusterConfig,
434 },
435 })
436 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
437 if (created !== undefined) {
438 // ALREADY_EXISTS (6): a concurrent create won the race.
439 yield* waitForOperation(created).pipe(
440 Effect.catchTag("GCP.OperationFailed", (error) =>
441 error.code === 6 ? Effect.void : Effect.fail(error),
442 ),
443 );
444 }
445 current = yield* waitUntilExists(getByName(name), name);
446 }
447
448 if (current === undefined) {
449 return yield* new ResourceNotResolved({ name });
450 }
451
452 const observedLabels = tagRecord(current.labels);
453 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
454 const observedPrivate = toPrivate(current.privateClusterConfig);
455 const mask = fieldMask([
456 (upsert.length > 0 || removed.length > 0) && "labels",
457 !sameText(current.displayName, news.displayName) && "displayName",
458 fingerprint(stringMap(current.annotations)) !==
459 fingerprint(desiredAnnotations) && "annotations",
460 // Both URLs default server-side; only converge them when set.
461 news.workstationLaunchUrl !== undefined &&
462 !sameText(current.workstationLaunchUrl, news.workstationLaunchUrl) &&
463 "workstationLaunchUrl",
464 news.workstationAuthorizationUrl !== undefined &&
465 !sameText(
466 current.workstationAuthorizationUrl,
467 news.workstationAuthorizationUrl,
468 ) &&
469 "workstationAuthorizationUrl",
470 news.gatewayConfig !== undefined &&
471 fingerprint(toGateway(current.gatewayConfig)) !==
472 fingerprint(toGateway(news.gatewayConfig)) &&
473 "gatewayConfig",
474 fingerprint(observedPrivate?.allowedProjects) !==
475 fingerprint(news.privateClusterConfig?.allowedProjects) &&
476 "privateClusterConfig.allowedProjects",
477 ]);
478
479 if (mask.length > 0) {
480 const operation = yield* workstations
481 .patchProjectsLocationsWorkstationClusters({
482 name: current.name ?? name,
483 updateMask: mask,
484 body: {
485 etag: current.etag,
486 labels: desiredLabels,
487 displayName: news.displayName,
488 annotations: desiredAnnotations,
489 workstationLaunchUrl: news.workstationLaunchUrl,
490 workstationAuthorizationUrl: news.workstationAuthorizationUrl,
491 gatewayConfig: news.gatewayConfig,
492 privateClusterConfig: {
493 allowedProjects: news.privateClusterConfig?.allowedProjects,
494 },
495 },
496 })
497 .pipe(
498 Effect.retry({
499 while: (error) => error._tag === "Conflict",
500 times: 8,
501 schedule: Schedule.spaced("2 seconds"),
502 }),
503 );
504 yield* waitForOperation(operation);
505 current = yield* waitUntilExists(
506 getByName(current.name ?? name),
507 current.name ?? name,
508 );
509 }
510
511 return toAttrs(current, env.project, env.region);
512 }),
513
514 delete: Effect.fn(function* ({ output }) {
515 const operation = yield* workstations
516 .deleteProjectsLocationsWorkstationClusters({
517 name: output.name,
518 force: true,
519 })
520 .pipe(
521 Effect.retry({
522 while: (error) => error._tag === "Conflict",
523 times: 8,
524 schedule: Schedule.spaced("2 seconds"),
525 }),
526 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
527 );
528 if (operation !== undefined) {
529 // NOT_FOUND (5): already gone.
530 yield* waitForOperation(operation).pipe(
531 Effect.catchTag("GCP.OperationFailed", (error) =>
532 error.code === 5 ? Effect.void : Effect.fail(error),
533 ),
534 );
535 }
536 yield* waitUntilGone(getByName(output.name), output.name);
537 }),
538 });