1import * as baremetalsolution from "@distilled.cloud/gcp/baremetalsolution_v2";
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 { waitForOperation as waitForGcpOperation } from "../Operation.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import { tagRecord } from "../../Tags.ts";
9import {
10 alchemyLabelKeys,
11 createInternalLabels,
12 hasAlchemyLabels,
13 stripInternalLabels,
14 toLabels,
15} from "../Labels.ts";
16
17export const MAX_NAME_LENGTH = 63;
18export const DEFAULT_SIZE_GIB = "100";
19export const DEFAULT_STORAGE_TYPE = "SSD";
20
21export {
22 createInternalLabels,
23 hasAlchemyLabels,
24 stripInternalLabels,
25 toLabels,
26};
27
28export class ResourceNotResolved extends Data.TaggedError(
29 "GCP.BareMetalSolution.ResourceNotResolved",
30)<{
31 name: string;
32}> {}
33
34export class ResourceStillExists extends Data.TaggedError(
35 "GCP.BareMetalSolution.ResourceStillExists",
36)<{
37 name: string;
38}> {}
39
40export class ResourceNotReady extends Data.TaggedError(
41 "GCP.BareMetalSolution.ResourceNotReady",
42)<{
43 name: string;
44 state: string;
45}> {}
46
47export class ResourceFailed extends Data.TaggedError(
48 "GCP.BareMetalSolution.ResourceFailed",
49)<{
50 name: string;
51 state: string;
52 details: string | undefined;
53}> {}
54
55export const lastSegment = (value: string) => {
56 const trimmed = value.replace(/\/+$/, "");
57 const parts = trimmed.split("/");
58 return parts[parts.length - 1] || trimmed;
59};
60
61export const rfc1035 = (name: string, fallback = "baremetal"): string => {
62 let next = name
63 .toLowerCase()
64 .replace(/[^a-z0-9-]/g, "-")
65 .replace(/-+/g, "-")
66 .replace(/^-+|-+$/g, "");
67 if (!/^[a-z]/.test(next)) next = `b${next}`;
68 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
69 if (next.length === 0) return fallback;
70 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
71 if (next.length < 3) next = `${next}xxx`.slice(0, 3);
72 return next.slice(0, MAX_NAME_LENGTH);
73};
74
75export const normalizeLocation = (
76 location: string | undefined,
77 defaultLocation: string,
78) => lastSegment(location ?? defaultLocation).toLowerCase();
79
80export const parentOf = (project: string, location: string) =>
81 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
82
83export const toPhysicalId = (
84 id: string,
85 explicit: string | undefined,
86 existing: string | undefined,
87 fallback = "baremetal",
88) =>
89 Effect.gen(function* () {
90 if (explicit !== undefined) return rfc1035(explicit, fallback);
91 if (existing !== undefined) return existing;
92 return rfc1035(
93 yield* createPhysicalName({
94 id,
95 maxLength: MAX_NAME_LENGTH,
96 lowercase: true,
97 }),
98 fallback,
99 );
100 });
101
102export const parseName = (
103 name: string,
104 collection: string,
105 defaultLocation: string,
106) => {
107 const parts = name.split("/").filter((part) => part.length > 0);
108 const collectionAt = parts.lastIndexOf(collection);
109 const locationsAt = parts.lastIndexOf("locations");
110 const projectsAt = parts.lastIndexOf("projects");
111 return {
112 project:
113 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
114 location:
115 locationsAt >= 0 && parts[locationsAt + 1]
116 ? parts[locationsAt + 1]!
117 : defaultLocation,
118 id:
119 collectionAt >= 0 && parts[collectionAt + 1]
120 ? parts[collectionAt + 1]!
121 : lastSegment(name),
122 parent:
123 collectionAt > 0
124 ? parts.slice(0, collectionAt).join("/")
125 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
126 };
127};
128
129export const expandParent = (
130 value: string,
131 project: string,
132 location: string,
133 collection: string,
134) => {
135 if (value.includes("/")) return value.replace(/\/+$/, "");
136 return `projects/${project}/locations/${location}/${collection}/${rfc1035(value, collection)}`;
137};
138
139export const volumeOf = (value: string, project: string, location: string) =>
140 expandParent(value, project, location, "volumes");
141
142export const networkOf = (value: string, project: string, location: string) => {
143 const next = value.replace(/\/+$/, "");
144 if (next.includes("/")) return next;
145 return `projects/${project}/locations/${location}/networks/${rfc1035(next, "network")}`;
146};
147
148export const gibOf = (
149 value: number | string | undefined,
150): string | undefined => (value === undefined ? undefined : String(value));
151
152export const userLabels = (
153 labels: Record<string, string | undefined> | null | undefined,
154): Record<string, string> => stripInternalLabels(tagRecord(labels));
155
156export const hasAlchemyLabelMap = (
157 labels: Record<string, string | undefined> | null | undefined,
158) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
159
160export const encodeOwnership = (
161 labels: Record<string, string>,
162 text: string | undefined,
163): string => {
164 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
165 const trimmed = text?.trim();
166 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
167};
168
169export const parseOwnership = (
170 text: string | undefined,
171): {
172 labels: Record<string, string>;
173 text: string | undefined;
174} => {
175 if (!text?.startsWith("[alchemy ")) {
176 return { labels: {}, text };
177 }
178 const end = text.indexOf("]");
179 if (end < 0) return { labels: {}, text };
180 const labels: Record<string, string> = {};
181 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
182 const eq = part.indexOf("=");
183 if (eq > 0) {
184 labels[part.slice(0, eq)] = part.slice(eq + 1);
185 }
186 }
187 const rest = text.slice(end + 1).replace(/^\s+/, "");
188 return { labels, text: rest.length > 0 ? rest : undefined };
189};
190
191export const hasOwnershipMarker = (text: string | undefined) =>
192 Object.keys(parseOwnership(text).labels).some((key) =>
193 key.startsWith("alchemy-"),
194 );
195
196export const canonical = (value: unknown): unknown => {
197 if (value === undefined || value === null) return undefined;
198 if (typeof value === "boolean" || typeof value === "number") return value;
199 if (typeof value === "string") {
200 return value.length === 0 ? undefined : value;
201 }
202 if (Array.isArray(value)) {
203 const items = value.map(canonical).filter((item) => item !== undefined);
204 return items.length === 0 ? undefined : items;
205 }
206 if (typeof value === "object") {
207 const entries = Object.entries(value as Record<string, unknown>)
208 .map(([key, item]) => [key, canonical(item)] as const)
209 .filter(([, item]) => item !== undefined)
210 .sort(([left], [right]) => left.localeCompare(right));
211 if (entries.length === 0) return undefined;
212 return Object.fromEntries(entries);
213 }
214 return undefined;
215};
216
217export const fingerprint = (value: unknown): string =>
218 JSON.stringify(canonical(value) ?? null);
219
220const pickDefined = (observed: unknown, desired: unknown): unknown => {
221 if (desired === undefined || desired === null) return undefined;
222 if (Array.isArray(desired)) {
223 if (!Array.isArray(observed)) return observed;
224 return desired.map((item, index) => pickDefined(observed[index], item));
225 }
226 if (typeof desired === "object") {
227 if (typeof observed !== "object" || observed === null) return observed;
228 const rec = observed as Record<string, unknown>;
229 return Object.fromEntries(
230 Object.entries(desired as Record<string, unknown>).map(([key, item]) => [
231 key,
232 pickDefined(rec[key], item),
233 ]),
234 );
235 }
236 return observed;
237};
238
239export const differs = (observed: unknown, desired: unknown): boolean => {
240 if (desired === undefined) return false;
241 return fingerprint(pickDefined(observed, desired)) !== fingerprint(desired);
242};
243
244export const fieldMask = (fields: Array<string | false | undefined>) =>
245 fields
246 .filter((field): field is string => typeof field === "string")
247 .join(",");
248
249export const sameText = (left: string | undefined, right: string | undefined) =>
250 (left ?? "") === (right ?? "");
251
252export const replaceOnIdentity = (input: {
253 previousId: string | undefined;
254 nextId: string | undefined;
255 previousLocation: string;
256 nextLocation: string;
257 extra?: boolean;
258 previousParent?: string;
259 nextParent?: string;
260}) => {
261 const parentChanged =
262 (input.previousParent ?? "") !== "" &&
263 (input.nextParent ?? "") !== "" &&
264 (input.previousParent ?? "") !== (input.nextParent ?? "");
265 const replace =
266 (input.extra ?? false) ||
267 parentChanged ||
268 (input.previousId !== undefined &&
269 input.nextId !== undefined &&
270 input.nextId !== input.previousId) ||
271 input.previousLocation !== input.nextLocation;
272 if (!replace) return undefined;
273 const samePhysical =
274 input.previousLocation === input.nextLocation &&
275 !parentChanged &&
276 input.previousId !== undefined &&
277 input.nextId === input.previousId;
278 return {
279 action: "replace" as const,
280 deleteFirst: samePhysical,
281 };
282};
283
284const READY_STATES = new Set(["PROVISIONED", "READY"]);
285const FAILED_STATES = new Set(["ERROR", "FAILED"]);
286
287export const isReadyState = (state: string | undefined) =>
288 READY_STATES.has((state ?? "").toUpperCase());
289
290export const isFailedState = (state: string | undefined) =>
291 FAILED_STATES.has((state ?? "").toUpperCase());
292
293export const isDeletingState = (state: string | undefined) =>
294 (state ?? "").toUpperCase() === "DELETING";
295
296/**
297 * Wait for a Bare Metal Solution operation. NFS shares and volumes take
298 * several minutes to provision. An operation that finished with
299 * `ALREADY_EXISTS` (6) is a lost create race; `notFoundOk` also accepts
300 * `NOT_FOUND` (5).
301 */
302export const waitForOperation = (
303 operation: baremetalsolution.Operation,
304 options?: { notFoundOk?: boolean },
305) =>
306 waitForGcpOperation(
307 operation,
308 (name) =>
309 baremetalsolution
310 .getProjectsLocationsOperations({ name })
311 .pipe(
312 Effect.catchTag("NotFound", (error) =>
313 options?.notFoundOk === true
314 ? Effect.succeed<baremetalsolution.Operation>({
315 name,
316 done: true,
317 })
318 : Effect.fail(error),
319 ),
320 ),
321 { budget: "30 minutes" },
322 ).pipe(
323 Effect.catchIf(
324 (error) =>
325 error._tag === "GCP.OperationFailed" &&
326 (error.code === 6 ||
327 (options?.notFoundOk === true && error.code === 5)),
328 () => Effect.void,
329 ),
330 Effect.asVoid,
331 );
332
333export const waitUntilExists = <A, E, R>(
334 get: Effect.Effect<A, E, R>,
335 name: string,
336): Effect.Effect<Exclude<A, undefined>, E | ResourceNotResolved, R> =>
337 get.pipe(
338 Effect.filterOrFail(
339 (value): value is Exclude<A, undefined> => value !== undefined,
340 () => new ResourceNotResolved({ name }),
341 ),
342 Effect.retry({
343 while: (error) => error instanceof ResourceNotResolved,
344 times: 8,
345 schedule: Schedule.spaced("1 second"),
346 }),
347 );
348
349export const waitUntilGone = <A, E, R>(
350 get: Effect.Effect<A, E, R>,
351 name: string,
352): Effect.Effect<void, E | ResourceStillExists, R> =>
353 get.pipe(
354 Effect.filterOrFail(
355 (value) => value === undefined,
356 () => new ResourceStillExists({ name }),
357 ),
358 Effect.asVoid,
359 Effect.retry({
360 while: (error) => error instanceof ResourceStillExists,
361 times: 10,
362 schedule: Schedule.spaced("2 seconds"),
363 }),
364 );
365
366export const waitUntilReady = <A, E, R>(
367 get: Effect.Effect<A, E, R>,
368 name: string,
369 stateOf: (value: Exclude<A, undefined>) => string | undefined,
370 detailsOf?: (value: Exclude<A, undefined>) => string | undefined,
371 options?: {
372 times?: number;
373 interval?: `${number} seconds`;
374 },
375): Effect.Effect<
376 Exclude<A, undefined>,
377 E | ResourceNotResolved | ResourceNotReady | ResourceFailed,
378 R
379> =>
380 get.pipe(
381 Effect.filterOrFail(
382 (value): value is Exclude<A, undefined> => value !== undefined,
383 () => new ResourceNotResolved({ name }),
384 ),
385 Effect.filterOrFail(
386 (value) => !isFailedState(stateOf(value)),
387 (value) =>
388 new ResourceFailed({
389 name,
390 state: stateOf(value) ?? "",
391 details: detailsOf?.(value),
392 }),
393 ),
394 Effect.filterOrFail(
395 (value) => {
396 const state = stateOf(value) ?? "";
397 return isReadyState(state) || state.length === 0;
398 },
399 (value) => new ResourceNotReady({ name, state: stateOf(value) ?? "" }),
400 ),
401 Effect.retry({
402 while: (error) =>
403 error instanceof ResourceNotReady ||
404 error instanceof ResourceNotResolved,
405 times: options?.times ?? 10,
406 schedule: Schedule.spaced(options?.interval ?? "5 seconds"),
407 }),
408 );
409
410export const collectPages = <Page, A, E, R>(
411 pages: Stream.Stream<Page, E, R>,
412 items: (page: Page) => readonly A[] | undefined,
413): Effect.Effect<A[], E, R> =>
414 pages.pipe(
415 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
416 Stream.runCollect,
417 Effect.map((chunk) => Array.from(chunk)),
418 );
419
420export const listAtLocation = <A, E extends { readonly _tag: string }, R>(
421 project: string,
422 region: string,
423 list: (parent: string) => Effect.Effect<A[], E, R>,
424) =>
425 list(`projects/${project}/locations/${region}`).pipe(
426 Effect.catchIf(
427 (error): error is E & { readonly _tag: "NotFound" } =>
428 error._tag === "NotFound",
429 (): Effect.Effect<A[]> => Effect.succeed([]),
430 ),
431 );
432
433export const listLabeledPages = <Page, A, E, R>(
434 pages: Stream.Stream<Page, E, R>,
435 items: (page: Page) => readonly A[] | undefined,
436 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
437): Effect.Effect<A[], E, R> =>
438 pages.pipe(
439 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
440 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
441 Stream.runCollect,
442 Effect.map((chunk) => Array.from(chunk)),
443 );
444
445export const listVolumes = (project: string, region: string) =>
446 listAtLocation(project, region, (parent) =>
447 collectPages(
448 baremetalsolution.listProjectsLocationsVolumes.pages({
449 parent,
450 pageSize: 1000,
451 }),
452 (page) => page.volumes,
453 ),
454 );
455
456export const listVolumeSnapshots = (project: string, region: string) =>
457 listVolumes(project, region).pipe(
458 Effect.flatMap((volumes) =>
459 Effect.forEach(
460 volumes.filter((volume) => (volume.name ?? "").length > 0),
461 (volume) =>
462 collectPages(
463 baremetalsolution.listProjectsLocationsVolumesSnapshots.pages({
464 parent: volume.name ?? "",
465 pageSize: 1000,
466 }),
467 (page) => page.volumeSnapshots,
468 ).pipe(
469 Effect.catchTag("NotFound", () =>
470 Effect.succeed([] as baremetalsolution.VolumeSnapshot[]),
471 ),
472 ),
473 { concurrency: 4 },
474 ),
475 ),
476 Effect.map((groups) => groups.flat()),
477 );