1import * as workloadmanager from "@distilled.cloud/gcp/workloadmanager_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;
12export const ACTUATION_ID_PREFIX = "alch-";
13
14export class ResourceNotResolved extends Data.TaggedError(
15 "GCP.WorkloadManager.ResourceNotResolved",
16)<{
17 name: string;
18}> {}
19
20export class ResourceStillExists extends Data.TaggedError(
21 "GCP.WorkloadManager.ResourceStillExists",
22)<{
23 name: string;
24}> {}
25
26export class ResourceNotReady extends Data.TaggedError(
27 "GCP.WorkloadManager.ResourceNotReady",
28)<{
29 name: string;
30 state: string;
31}> {}
32
33export class ResourceFailed extends Data.TaggedError(
34 "GCP.WorkloadManager.ResourceFailed",
35)<{
36 name: string;
37 state: string;
38 details: string | undefined;
39}> {}
40
41export const lastSegment = (value: string) => {
42 const trimmed = value.replace(/\/+$/, "");
43 const parts = trimmed.split("/");
44 return parts[parts.length - 1] || trimmed;
45};
46
47export const rfc1035 = (name: string, fallback = "workloadmanager"): string => {
48 let next = name
49 .toLowerCase()
50 .replace(/[^a-z0-9-]/g, "-")
51 .replace(/-+/g, "-")
52 .replace(/^-+|-+$/g, "");
53 if (!/^[a-z]/.test(next)) next = `w${next}`;
54 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
55 if (next.length === 0) return fallback;
56 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
57 return next.slice(0, MAX_NAME_LENGTH);
58};
59
60export const normalizeLocation = (
61 location: string | undefined,
62 fallback: string,
63) => lastSegment(location ?? fallback).toLowerCase();
64
65export const parentOf = (project: string, location: string) =>
66 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
67
68export const toPhysicalId = (
69 id: string,
70 explicit: string | undefined,
71 existing: string | undefined,
72 fallback = "workloadmanager",
73) =>
74 Effect.gen(function* () {
75 if (explicit !== undefined) return rfc1035(explicit, fallback);
76 if (existing !== undefined) return existing;
77 return rfc1035(
78 yield* createPhysicalName({
79 id,
80 maxLength: MAX_NAME_LENGTH,
81 lowercase: true,
82 }),
83 fallback,
84 );
85 });
86
87/**
88 * Actuations have no labels or description. Generated ids are prefixed
89 * `alch-` so `list` / nuke can find Alchemy-owned rows.
90 */
91export const toActuationId = (
92 id: string,
93 explicit: string | undefined,
94 existing: string | undefined,
95) =>
96 Effect.gen(function* () {
97 if (explicit !== undefined) return rfc1035(explicit, "alch");
98 if (existing !== undefined) return existing;
99 const generated = rfc1035(
100 yield* createPhysicalName({
101 id,
102 maxLength: MAX_NAME_LENGTH - ACTUATION_ID_PREFIX.length,
103 lowercase: true,
104 }),
105 "actuation",
106 );
107 const prefixed = generated.startsWith(ACTUATION_ID_PREFIX)
108 ? generated
109 : `${ACTUATION_ID_PREFIX}${generated}`;
110 return prefixed.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
111 });
112
113export const hasAlchemyActuationId = (actuationId: string) =>
114 actuationId.startsWith(ACTUATION_ID_PREFIX);
115
116export const parseName = (
117 name: string,
118 collection: string,
119 fallbackLocation: string,
120) => {
121 const parts = name.split("/").filter((part) => part.length > 0);
122 const collectionAt = parts.lastIndexOf(collection);
123 const locationsAt = parts.lastIndexOf("locations");
124 const projectsAt = parts.lastIndexOf("projects");
125 return {
126 project:
127 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
128 location:
129 locationsAt >= 0 && parts[locationsAt + 1]
130 ? parts[locationsAt + 1]!
131 : fallbackLocation,
132 id:
133 collectionAt >= 0 && parts[collectionAt + 1]
134 ? parts[collectionAt + 1]!
135 : lastSegment(name),
136 parent:
137 collectionAt > 0
138 ? parts.slice(0, collectionAt).join("/")
139 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
140 };
141};
142
143export const expandNamed = (
144 value: string,
145 project: string,
146 location: string,
147 collection: string,
148) => {
149 if (value.includes("/")) return value.replace(/\/+$/, "");
150 return `projects/${project}/locations/${location}/${collection}/${value}`;
151};
152
153export const userLabels = (
154 labels: Record<string, string | undefined> | null | undefined,
155): Record<string, string> => stripInternalLabels(tagRecord(labels));
156
157export const stringMap = (
158 value: Record<string, string | undefined> | null | undefined,
159): Record<string, string> =>
160 Object.fromEntries(
161 Object.entries(value ?? {}).filter(
162 (entry): entry is [string, string] => typeof entry[1] === "string",
163 ),
164 );
165
166export const hasAlchemyLabelMap = (
167 labels: Record<string, string | undefined> | null | undefined,
168) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
169
170export const canonical = (value: unknown): unknown => {
171 if (value === undefined || value === null) return undefined;
172 if (typeof value === "boolean" || typeof value === "number") return value;
173 if (typeof value === "string") {
174 return value.length === 0 ? undefined : value;
175 }
176 if (Array.isArray(value)) {
177 const items = value.map(canonical).filter((item) => item !== undefined);
178 return items.length === 0 ? undefined : items;
179 }
180 if (typeof value === "object") {
181 const entries = Object.entries(value as Record<string, unknown>)
182 .map(([key, item]) => [key, canonical(item)] as const)
183 .filter(([, item]) => item !== undefined)
184 .sort(([left], [right]) => left.localeCompare(right));
185 if (entries.length === 0) return undefined;
186 return Object.fromEntries(entries);
187 }
188 return undefined;
189};
190
191export const fingerprint = (value: unknown): string =>
192 JSON.stringify(canonical(value) ?? null);
193
194export const fieldMask = (fields: Array<string | false | undefined>) =>
195 fields
196 .filter((field): field is string => typeof field === "string")
197 .join(",");
198
199export const sameText = (left: string | undefined, right: string | undefined) =>
200 (left ?? "") === (right ?? "");
201
202export const sameStringList = (
203 left: readonly string[] | undefined,
204 right: readonly string[] | undefined,
205) =>
206 fingerprint([...(left ?? [])].slice().sort()) ===
207 fingerprint([...(right ?? [])].slice().sort());
208
209export const replaceOnIdentity = (input: {
210 previousId: string | undefined;
211 nextId: string | undefined;
212 previousLocation: string;
213 nextLocation: string;
214 extra?: boolean;
215 previousParent?: string;
216 nextParent?: string;
217}) => {
218 const parentChanged =
219 (input.previousParent ?? "") !== "" &&
220 (input.nextParent ?? "") !== "" &&
221 (input.previousParent ?? "") !== (input.nextParent ?? "");
222 const replace =
223 (input.extra ?? false) ||
224 parentChanged ||
225 (input.previousId !== undefined &&
226 input.nextId !== undefined &&
227 input.nextId !== input.previousId) ||
228 input.previousLocation !== input.nextLocation;
229 if (!replace) return undefined;
230 const samePhysical =
231 input.previousLocation === input.nextLocation &&
232 !parentChanged &&
233 input.previousId !== undefined &&
234 input.nextId === input.previousId;
235 return {
236 action: "replace" as const,
237 deleteFirst: samePhysical,
238 };
239};
240
241const READY_STATES = new Set(["READY", "SUCCEEDED", "ACTIVE"]);
242const FAILED_STATES = new Set(["FAILED", "ERROR", "TIMEOUT"]);
243
244export const isReadyState = (state: string | undefined) =>
245 READY_STATES.has((state ?? "").toUpperCase());
246
247export const isFailedState = (state: string | undefined) =>
248 FAILED_STATES.has((state ?? "").toUpperCase());
249
250export const resourceNameFromOperation = (
251 operation: workloadmanager.Operation,
252): string | undefined => {
253 const response = operation.response;
254 if (
255 response &&
256 typeof response.name === "string" &&
257 response.name.length > 0
258 ) {
259 return response.name;
260 }
261 const metadata = operation.metadata;
262 if (
263 metadata &&
264 typeof metadata.target === "string" &&
265 metadata.target.length > 0
266 ) {
267 return metadata.target;
268 }
269 if (
270 metadata &&
271 typeof metadata.name === "string" &&
272 metadata.name.length > 0
273 ) {
274 return metadata.name;
275 }
276 return undefined;
277};
278
279/**
280 * Wait for a Workload Manager long-running operation (evaluations settle in minutes; deployment actuations run infrastructure changes).
281 */
282export const waitForOperation = (operation: workloadmanager.Operation) =>
283 waitForGcpOperation(
284 operation,
285 (name) => workloadmanager.getProjectsLocationsOperations({ name }),
286 { budget: "20 minutes" },
287 );
288
289export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
290 get: Effect.Effect<A, E, R>,
291 name: string,
292): Effect.Effect<Exclude<A, undefined>, E | ResourceNotResolved, R> =>
293 get.pipe(
294 Effect.filterOrFail(
295 (value): value is Exclude<A, undefined> => value !== undefined,
296 () => new ResourceNotResolved({ name }),
297 ),
298 Effect.retry({
299 while: (error) =>
300 error._tag === "GCP.WorkloadManager.ResourceNotResolved",
301 times: 8,
302 schedule: Schedule.spaced("1 second"),
303 }),
304 );
305
306export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
307 get: Effect.Effect<A, E, R>,
308 name: string,
309): Effect.Effect<void, E | ResourceStillExists, R> =>
310 get.pipe(
311 Effect.filterOrFail(
312 (value) => value === undefined,
313 () => new ResourceStillExists({ name }),
314 ),
315 Effect.retry({
316 while: (error) =>
317 error._tag === "GCP.WorkloadManager.ResourceStillExists",
318 times: 10,
319 schedule: Schedule.spaced("2 seconds"),
320 }),
321 Effect.asVoid,
322 );
323
324export const waitUntilReady = <A, E extends { readonly _tag: string }, R>(
325 get: Effect.Effect<A, E, R>,
326 name: string,
327 stateOf: (value: Exclude<A, undefined>) => string | undefined,
328 detailsOf?: (value: Exclude<A, undefined>) => string | undefined,
329 options?: {
330 times?: number;
331 interval?: `${number} seconds`;
332 },
333): Effect.Effect<
334 Exclude<A, undefined>,
335 E | ResourceNotResolved | ResourceFailed | ResourceNotReady,
336 R
337> =>
338 get.pipe(
339 Effect.filterOrFail(
340 (value): value is Exclude<A, undefined> => value !== undefined,
341 () => new ResourceNotResolved({ name }),
342 ),
343 Effect.filterOrFail(
344 (value) => !isFailedState(stateOf(value)),
345 (value) =>
346 new ResourceFailed({
347 name,
348 state: stateOf(value) ?? "",
349 details: detailsOf?.(value),
350 }),
351 ),
352 Effect.filterOrFail(
353 (value) => {
354 const state = stateOf(value) ?? "";
355 return isReadyState(state) || state.length === 0;
356 },
357 (value) => new ResourceNotReady({ name, state: stateOf(value) ?? "" }),
358 ),
359 Effect.retry({
360 while: (error) =>
361 error._tag === "GCP.WorkloadManager.ResourceNotReady" ||
362 error._tag === "GCP.WorkloadManager.ResourceNotResolved",
363 times: options?.times ?? 10,
364 schedule: Schedule.spaced(options?.interval ?? "2 seconds"),
365 }),
366 );
367
368const isNotFound = <E extends { readonly _tag: string }>(
369 error: E,
370): error is E & { readonly _tag: "NotFound" } => error._tag === "NotFound";
371
372/** List across `locations/-`, falling back to the region if the wildcard 404s. */
373export const listAtLocation = <A, E extends { readonly _tag: string }, R>(
374 project: string,
375 region: string,
376 list: (parent: string) => Effect.Effect<A[], E, R>,
377) =>
378 list(`projects/${project}/locations/-`).pipe(
379 Effect.catchIf(isNotFound, () =>
380 list(`projects/${project}/locations/${region}`),
381 ),
382 );
383
384export const listAtNested = <A, E extends { readonly _tag: string }, R>(
385 project: string,
386 region: string,
387 nested: string,
388 list: (parent: string) => Effect.Effect<A[], E, R>,
389) =>
390 list(`projects/${project}/locations/-/${nested}`).pipe(
391 Effect.catchIf(isNotFound, () =>
392 list(`projects/${project}/locations/${region}/${nested}`),
393 ),
394 );
395
396export const listLabeledPages = <Page, A, E, R>(
397 pages: Stream.Stream<Page, E, R>,
398 items: (page: Page) => readonly A[] | undefined,
399 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
400): Effect.Effect<A[], E, R> =>
401 pages.pipe(
402 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
403 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
404 Stream.runCollect,
405 Effect.map((chunk) => Array.from(chunk)),
406 );
407
408export const listPages = <Page, A, E, R>(
409 pages: Stream.Stream<Page, E, R>,
410 items: (page: Page) => readonly A[] | undefined,
411 keep: (item: A) => boolean,
412): Effect.Effect<A[], E, R> =>
413 pages.pipe(
414 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
415 Stream.filter(keep),
416 Stream.runCollect,
417 Effect.map((chunk) => Array.from(chunk)),
418 );