1import * as workstations from "@distilled.cloud/gcp/workstations_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { tagRecord } from "../../Tags.ts";
8import { stripInternalLabels } from "../Labels.ts";
9import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
10
11export const MAX_NAME_LENGTH = 63;
12
13/**
14 * Generated ids are shorter than the 63-character id limit: GCP rejects a
15 * workstation's full resource name as "Malformed name" once it passes ~280
16 * characters, which three 63-character ids (cluster, config, workstation)
17 * exceed.
18 */
19const GENERATED_NAME_LENGTH = 50;
20export const DEFAULT_NETWORK = "default";
21export const DEFAULT_SUBNETWORK = "default";
22
23export class ResourceNotResolved extends Data.TaggedError(
24 "GCP.Workstations.ResourceNotResolved",
25)<{
26 name: string;
27}> {}
28
29export class ResourceStillExists extends Data.TaggedError(
30 "GCP.Workstations.ResourceStillExists",
31)<{
32 name: string;
33}> {}
34
35export class ResourceNotReady extends Data.TaggedError(
36 "GCP.Workstations.ResourceNotReady",
37)<{
38 name: string;
39 state: string;
40}> {}
41
42export const lastSegment = (value: string) => {
43 const trimmed = value.replace(/\/+$/, "");
44 const parts = trimmed.split("/");
45 return parts[parts.length - 1] || trimmed;
46};
47
48export const rfc1035 = (name: string, fallback = "workstation"): string => {
49 let next = name
50 .toLowerCase()
51 .replace(/[^a-z0-9-]/g, "-")
52 .replace(/-+/g, "-")
53 .replace(/^-+|-+$/g, "");
54 if (!/^[a-z]/.test(next)) next = `w${next}`;
55 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
56 if (next.length === 0) return fallback;
57 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
58 return next.slice(0, MAX_NAME_LENGTH);
59};
60
61export const normalizeLocation = (
62 location: string | undefined,
63 fallback: string,
64) => lastSegment(location ?? fallback).toLowerCase();
65
66export const parentOf = (project: string, location: string) =>
67 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
68
69export const toPhysicalId = (
70 id: string,
71 explicit: string | undefined,
72 existing: string | undefined,
73 fallback = "workstation",
74) =>
75 Effect.gen(function* () {
76 if (explicit !== undefined) return rfc1035(explicit, fallback);
77 if (existing !== undefined) return existing;
78 return rfc1035(
79 yield* createPhysicalName({
80 id,
81 maxLength: GENERATED_NAME_LENGTH,
82 lowercase: true,
83 }),
84 fallback,
85 );
86 });
87
88export const parseName = (
89 name: string,
90 collection: string,
91 fallbackLocation: string,
92) => {
93 const parts = name.split("/").filter((part) => part.length > 0);
94 const collectionAt = parts.lastIndexOf(collection);
95 const locationsAt = parts.lastIndexOf("locations");
96 const projectsAt = parts.lastIndexOf("projects");
97 return {
98 project:
99 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
100 location:
101 locationsAt >= 0 && parts[locationsAt + 1]
102 ? parts[locationsAt + 1]!
103 : fallbackLocation,
104 id:
105 collectionAt >= 0 && parts[collectionAt + 1]
106 ? parts[collectionAt + 1]!
107 : lastSegment(name),
108 parent:
109 collectionAt > 0
110 ? parts.slice(0, collectionAt).join("/")
111 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
112 };
113};
114
115export const expandParent = (
116 value: string,
117 project: string,
118 location: string,
119 collection: string,
120) => {
121 if (value.includes("/")) return value.replace(/\/+$/, "");
122 return `projects/${project}/locations/${location}/${collection}/${value}`;
123};
124
125export const networkName = (value: string | undefined, project: string) => {
126 const next = (value ?? DEFAULT_NETWORK).replace(/\/+$/, "");
127 if (next.includes("/")) return next;
128 return `projects/${project}/global/networks/${next}`;
129};
130
131export const subnetworkName = (
132 value: string | undefined,
133 project: string,
134 location: string,
135) => {
136 const next = (value ?? DEFAULT_SUBNETWORK).replace(/\/+$/, "");
137 if (next.includes("/")) return next;
138 return `projects/${project}/regions/${location}/subnetworks/${next}`;
139};
140
141export const sameResource = (
142 left: string | undefined,
143 right: string | undefined,
144) => {
145 if ((left ?? "") === (right ?? "")) return true;
146 if (left === undefined || right === undefined) return false;
147 return lastSegment(left) === lastSegment(right);
148};
149
150export const userLabels = (
151 labels: Record<string, string | undefined> | null | undefined,
152): Record<string, string> => stripInternalLabels(tagRecord(labels));
153
154export const hasAlchemyLabelMap = (
155 labels: Record<string, string | undefined> | null | undefined,
156) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
157
158export const canonical = (value: unknown): unknown => {
159 if (value === undefined || value === null) return undefined;
160 if (typeof value === "boolean" || typeof value === "number") return value;
161 if (typeof value === "string") {
162 return value.length === 0 ? undefined : value;
163 }
164 if (Array.isArray(value)) {
165 const items = value.map(canonical).filter((item) => item !== undefined);
166 return items.length === 0 ? undefined : items;
167 }
168 if (typeof value === "object") {
169 const entries = Object.entries(value as Record<string, unknown>)
170 .map(([key, item]) => [key, canonical(item)] as const)
171 .filter(([, item]) => item !== undefined)
172 .sort(([left], [right]) => left.localeCompare(right));
173 if (entries.length === 0) return undefined;
174 return Object.fromEntries(entries);
175 }
176 return undefined;
177};
178
179export const fingerprint = (value: unknown): string =>
180 JSON.stringify(canonical(value) ?? null);
181
182export const fieldMask = (fields: Array<string | false | undefined>) =>
183 fields
184 .filter((field): field is string => typeof field === "string")
185 .join(",");
186
187export const sameText = (left: string | undefined, right: string | undefined) =>
188 (left ?? "") === (right ?? "");
189
190export const sameBool = (
191 left: boolean | undefined,
192 right: boolean | undefined,
193) => (left === true) === (right === true);
194
195export const sameNumber = (
196 left: number | undefined,
197 right: number | undefined,
198) => (left ?? 0) === (right ?? 0);
199
200export const replaceOnIdentity = (input: {
201 previousId: string | undefined;
202 nextId: string | undefined;
203 previousLocation: string;
204 nextLocation: string;
205 extra?: boolean;
206 previousParent?: string;
207 nextParent?: string;
208}) => {
209 const parentChanged =
210 (input.previousParent ?? "") !== "" &&
211 (input.nextParent ?? "") !== "" &&
212 (input.previousParent ?? "") !== (input.nextParent ?? "") &&
213 lastSegment(input.previousParent ?? "") !==
214 lastSegment(input.nextParent ?? "");
215 const replace =
216 (input.extra ?? false) ||
217 parentChanged ||
218 (input.previousId !== undefined &&
219 input.nextId !== undefined &&
220 input.nextId !== input.previousId) ||
221 input.previousLocation !== input.nextLocation;
222 if (!replace) return undefined;
223 const samePhysical =
224 input.previousLocation === input.nextLocation &&
225 !parentChanged &&
226 input.previousId !== undefined &&
227 input.nextId === input.previousId;
228 return {
229 action: "replace" as const,
230 deleteFirst: samePhysical,
231 };
232};
233
234/**
235 * Wait for a Cloud Workstations long-running operation. Cluster creates and deletes take up to ~20 minutes.
236 */
237export const waitForOperation = (operation: workstations.Operation) =>
238 waitForGcpOperation(
239 operation,
240 (name) => workstations.getProjectsLocationsOperations({ name }),
241 { budget: "30 minutes" },
242 );
243
244export const waitUntilExists = <A, E extends object, R>(
245 get: Effect.Effect<A | undefined, E, R>,
246 name: string,
247): Effect.Effect<NonNullable<A>, E | ResourceNotResolved, R> =>
248 get.pipe(
249 Effect.filterOrFail(
250 (value): value is NonNullable<A> => value != null,
251 () => new ResourceNotResolved({ name }),
252 ),
253 Effect.retry({
254 while: (error) => error instanceof ResourceNotResolved,
255 times: 8,
256 schedule: Schedule.spaced("1 second"),
257 }),
258 );
259
260export const waitUntilGone = <A, E extends object, R>(
261 get: Effect.Effect<A | undefined, E, R>,
262 name: string,
263): Effect.Effect<void, E | ResourceStillExists, R> =>
264 get.pipe(
265 Effect.filterOrFail(
266 (value) => value === undefined,
267 () => new ResourceStillExists({ name }),
268 ),
269 Effect.retry({
270 while: (error) => error instanceof ResourceStillExists,
271 times: 10,
272 schedule: Schedule.spaced("2 seconds"),
273 }),
274 Effect.asVoid,
275 );
276
277export const listAtLocation = <A, E extends { readonly _tag: string }, R>(
278 project: string,
279 region: string,
280 list: (parent: string) => Effect.Effect<A[], E, R>,
281) =>
282 list(`projects/${project}/locations/-`).pipe(
283 Effect.catchIf(
284 (error): error is E & { readonly _tag: "NotFound" } =>
285 error._tag === "NotFound",
286 () =>
287 list(`projects/${project}/locations/${region}`).pipe(
288 Effect.catchIf(
289 (error): error is E & { readonly _tag: "NotFound" } =>
290 error._tag === "NotFound",
291 () => Effect.succeed([] as A[]),
292 ),
293 ),
294 ),
295 );
296
297export const listAtNested = <A, E extends { readonly _tag: string }, R>(
298 project: string,
299 region: string,
300 nested: string,
301 list: (parent: string) => Effect.Effect<A[], E, R>,
302) =>
303 list(`projects/${project}/locations/-/${nested}`).pipe(
304 Effect.catchIf(
305 (error): error is E & { readonly _tag: "NotFound" } =>
306 error._tag === "NotFound",
307 () =>
308 list(`projects/${project}/locations/${region}/${nested}`).pipe(
309 Effect.catchIf(
310 (error): error is E & { readonly _tag: "NotFound" } =>
311 error._tag === "NotFound",
312 () => Effect.succeed([] as A[]),
313 ),
314 ),
315 ),
316 );
317
318export const listLabeledPages = <Page, A, E, R>(
319 pages: Stream.Stream<Page, E, R>,
320 items: (page: Page) => readonly A[] | undefined,
321 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
322) =>
323 pages.pipe(
324 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
325 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
326 Stream.runCollect,
327 Effect.map((chunk) => Array.from(chunk)),
328 );
329
330export const stringMap = (
331 value: Record<string, string | undefined> | null | undefined,
332): Record<string, string> | undefined => {
333 const next = Object.fromEntries(
334 Object.entries(value ?? {}).filter(
335 (entry): entry is [string, string] => entry[1] !== undefined,
336 ),
337 );
338 return Object.keys(next).length === 0 ? undefined : next;
339};