1import * as vm from "@distilled.cloud/gcp/vmmigration_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 {
9 alchemyLabelKeys,
10 createInternalLabels,
11 hasAlchemyLabels,
12 stripInternalLabels,
13} from "../Labels.ts";
14import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
15
16export const GLOBAL_LOCATION = "global";
17export const MAX_NAME_LENGTH = 63;
18export const MAX_DISPLAY_NAME_LENGTH = 63;
19
20export class ResourceNotResolved extends Data.TaggedError(
21 "GCP.VMMigration.ResourceNotResolved",
22)<{
23 name: string;
24}> {}
25
26export class ResourceStillExists extends Data.TaggedError(
27 "GCP.VMMigration.ResourceStillExists",
28)<{
29 name: string;
30}> {}
31
32export const lastSegment = (value: string) => {
33 const trimmed = value.replace(/\/+$/, "");
34 const parts = trimmed.split("/");
35 return parts[parts.length - 1] || trimmed;
36};
37
38export const rfc1035 = (name: string, fallback = "vm"): string => {
39 let next = name
40 .toLowerCase()
41 .replace(/[^a-z0-9-]/g, "-")
42 .replace(/-+/g, "-")
43 .replace(/^-+|-+$/g, "");
44 if (!/^[a-z]/.test(next)) next = `v${next}`;
45 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
46 if (next.length === 0) return fallback;
47 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
48 return next.slice(0, MAX_NAME_LENGTH);
49};
50
51export const normalizeLocation = (
52 location: string | undefined,
53 fallback: string,
54) => lastSegment(location ?? fallback).toLowerCase();
55
56export const locationParent = (project: string, location: string) =>
57 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
58
59export const globalParent = (project: string) =>
60 `projects/${project}/locations/${GLOBAL_LOCATION}`;
61
62export const parentOfName = (name: string) => {
63 const parts = name.split("/").filter((part) => part.length > 0);
64 return parts.slice(0, -2).join("/");
65};
66
67export const toPhysicalId = (
68 id: string,
69 explicit: string | undefined,
70 existing: string | undefined,
71 fallback = "vm",
72) =>
73 Effect.gen(function* () {
74 if (explicit !== undefined) return rfc1035(explicit, fallback);
75 if (existing !== undefined) return existing;
76 return rfc1035(
77 yield* createPhysicalName({
78 id,
79 maxLength: MAX_NAME_LENGTH,
80 lowercase: true,
81 }),
82 fallback,
83 );
84 });
85
86export const parseName = (
87 name: string,
88 collection: string,
89 fallbackLocation: string,
90) => {
91 const parts = name.split("/").filter((part) => part.length > 0);
92 const collectionAt = parts.lastIndexOf(collection);
93 const locationsAt = parts.lastIndexOf("locations");
94 const projectsAt = parts.lastIndexOf("projects");
95 return {
96 project:
97 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
98 location:
99 locationsAt >= 0 && parts[locationsAt + 1]
100 ? parts[locationsAt + 1]!
101 : fallbackLocation,
102 id:
103 collectionAt >= 0 && parts[collectionAt + 1]
104 ? parts[collectionAt + 1]!
105 : lastSegment(name),
106 parent:
107 collectionAt > 0
108 ? parts.slice(0, collectionAt).join("/")
109 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
110 };
111};
112
113export const expandParent = (
114 value: string,
115 project: string,
116 location: string,
117 collection: string,
118) => {
119 if (value.includes("/")) return value.replace(/\/+$/, "");
120 return `projects/${project}/locations/${location}/${collection}/${value}`;
121};
122
123export const sourceOf = (value: string, project: string, location: string) =>
124 expandParent(value, project, location, "sources");
125
126export const userLabels = (
127 labels: Record<string, string | undefined> | null | undefined,
128): Record<string, string> => stripInternalLabels(tagRecord(labels));
129
130export const hasAlchemyLabelMap = (
131 labels: Record<string, string | undefined> | null | undefined,
132) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
133
134export const canonical = (value: unknown): unknown => {
135 if (value === undefined || value === null) return undefined;
136 if (typeof value === "boolean" || typeof value === "number") return value;
137 if (typeof value === "string") {
138 return value.length === 0 ? undefined : value;
139 }
140 if (Array.isArray(value)) {
141 const items = value.map(canonical).filter((item) => item !== undefined);
142 return items.length === 0 ? undefined : items;
143 }
144 if (typeof value === "object") {
145 const entries = Object.entries(value as Record<string, unknown>)
146 .map(([key, item]) => [key, canonical(item)] as const)
147 .filter(([, item]) => item !== undefined)
148 .sort(([left], [right]) => left.localeCompare(right));
149 if (entries.length === 0) return undefined;
150 return Object.fromEntries(entries);
151 }
152 return undefined;
153};
154
155export const fingerprint = (value: unknown): string =>
156 JSON.stringify(canonical(value) ?? null);
157
158export const fieldMask = (fields: Array<string | false | undefined>) =>
159 fields
160 .filter((field): field is string => typeof field === "string")
161 .join(",");
162
163export const replaceOnIdentity = (input: {
164 previousId: string | undefined;
165 nextId: string | undefined;
166 previousLocation: string;
167 nextLocation: string;
168 extra?: boolean;
169 previousParent?: string;
170 nextParent?: string;
171}) => {
172 const parentChanged =
173 (input.previousParent ?? "") !== "" &&
174 (input.nextParent ?? "") !== "" &&
175 (input.previousParent ?? "") !== (input.nextParent ?? "");
176 const replace =
177 (input.extra ?? false) ||
178 parentChanged ||
179 (input.previousId !== undefined &&
180 input.nextId !== undefined &&
181 input.nextId !== input.previousId) ||
182 input.previousLocation !== input.nextLocation;
183 if (!replace) return undefined;
184 const samePhysical =
185 input.previousLocation === input.nextLocation &&
186 !parentChanged &&
187 input.previousId !== undefined &&
188 input.nextId === input.previousId;
189 return {
190 action: "replace" as const,
191 deleteFirst: samePhysical,
192 };
193};
194
195/**
196 * Wait for a Migrate to Virtual Machines long-running operation. Sources, groups, and utilization reports settle in minutes; migrating-VM and image-import operations take longer.
197 */
198export const waitForOperation = (operation: vm.Operation) =>
199 waitForGcpOperation(
200 operation,
201 (name) => vm.getProjectsLocationsOperations({ name }),
202 { budget: "20 minutes" },
203 );
204
205export const waitUntilExists = <A, E, R>(
206 get: Effect.Effect<A | undefined, E, R>,
207 name: string,
208): Effect.Effect<Exclude<A, undefined>, E | ResourceNotResolved, R> =>
209 get.pipe(
210 Effect.filterOrFail(
211 (value): value is Exclude<A, undefined> => value !== undefined,
212 () => new ResourceNotResolved({ name }),
213 ),
214 Effect.retry({
215 while: (error) => error instanceof ResourceNotResolved,
216 times: 8,
217 schedule: Schedule.spaced("1 second"),
218 }),
219 );
220
221export const waitUntilGone = <A, E, R>(
222 get: Effect.Effect<A | undefined, E, R>,
223 name: string,
224): Effect.Effect<void, E | ResourceStillExists, R> =>
225 get.pipe(
226 Effect.filterOrFail(
227 (value) => value === undefined,
228 () => new ResourceStillExists({ name }),
229 ),
230 Effect.asVoid,
231 Effect.retry({
232 while: (error) => error instanceof ResourceStillExists,
233 times: 10,
234 schedule: Schedule.spaced("4 seconds"),
235 }),
236 );
237
238export const collectPages = <Page, A, E, R>(
239 pages: Stream.Stream<Page, E, R>,
240 items: (page: Page) => readonly A[] | undefined,
241) =>
242 pages.pipe(
243 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
244 Stream.runCollect,
245 Effect.map((chunk) => Array.from(chunk)),
246 );
247
248/** List across `locations/-`, falling back to the region if the wildcard 404s. */
249export const listAtLocation = <A, E extends { readonly _tag: string }, R>(
250 project: string,
251 region: string,
252 list: (parent: string) => Effect.Effect<A[], E, R>,
253) =>
254 list(`projects/${project}/locations/-`).pipe(
255 Effect.catchIf(
256 (error): error is E & { readonly _tag: "NotFound" } =>
257 error._tag === "NotFound",
258 () => list(`projects/${project}/locations/${region}`),
259 ),
260 );
261
262export const listSources = (project: string, region: string) =>
263 collectPages(
264 vm.listProjectsLocationsSources.pages({
265 parent: `projects/${project}/locations/-`,
266 pageSize: 1000,
267 }),
268 (page) => page.sources,
269 ).pipe(
270 Effect.catchTag("NotFound", () =>
271 collectPages(
272 vm.listProjectsLocationsSources.pages({
273 parent: locationParent(project, region),
274 pageSize: 1000,
275 }),
276 (page) => page.sources,
277 ).pipe(
278 Effect.catchTag("NotFound", () => Effect.succeed([] as vm.Source[])),
279 ),
280 ),
281 );
282
283export const forEachSource = <A, E, R>(
284 project: string,
285 list: (parent: string) => Effect.Effect<A[], E, R>,
286 region: string,
287) =>
288 Effect.gen(function* () {
289 const sources = yield* listSources(project, region);
290 const named = sources.filter((source) => (source.name ?? "").length > 0);
291 const groups = yield* Effect.forEach(
292 named,
293 (source) => list(source.name!),
294 { concurrency: 4 },
295 );
296 return groups.flat();
297 });
298
299const markerOf = (
300 labels: Record<string, string>,
301 stack: string,
302 stage: string,
303 id: string,
304) =>
305 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
306
307const fitMarker = (labels: Record<string, string>, maxLength: number) => {
308 let stack = labels[alchemyLabelKeys.stack] ?? "x";
309 let stage = labels[alchemyLabelKeys.stage] ?? "x";
310 let id = labels[alchemyLabelKeys.id] ?? "x";
311 let marker = markerOf(labels, stack, stage, id);
312 while (
313 marker.length > maxLength &&
314 (stack.length > 1 || stage.length > 1 || id.length > 1)
315 ) {
316 if (stack.length >= stage.length && stack.length >= id.length) {
317 stack = stack.slice(0, -1);
318 } else if (stage.length >= id.length) {
319 stage = stage.slice(0, -1);
320 } else {
321 id = id.slice(0, -1);
322 }
323 marker = markerOf(labels, stack, stage, id);
324 }
325 return marker.slice(0, maxLength);
326};
327
328export const encodeOwnership = (
329 labels: Record<string, string>,
330 text: string | undefined,
331): string => {
332 const marker = fitMarker(labels, 8000);
333 const trimmed = text?.trim();
334 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
335};
336
337export const encodeOwnershipLine = (
338 labels: Record<string, string>,
339 text: string | undefined,
340 maxLength = MAX_DISPLAY_NAME_LENGTH,
341): string => {
342 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
343 if (!trimmed) return fitMarker(labels, maxLength);
344 const minMarker = 24;
345 const reserved = Math.min(
346 trimmed.length + 1,
347 Math.max(0, maxLength - minMarker),
348 );
349 const marker = fitMarker(labels, maxLength - reserved);
350 return `${marker} ${trimmed}`.slice(0, maxLength);
351};
352
353export const parseOwnership = (
354 text: string | undefined,
355): {
356 labels: Record<string, string>;
357 text: string | undefined;
358} => {
359 if (!text?.startsWith("[alchemy ")) {
360 return { labels: {}, text };
361 }
362 const end = text.indexOf("]");
363 if (end < 0) return { labels: {}, text };
364 const labels: Record<string, string> = {};
365 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
366 const eq = part.indexOf("=");
367 if (eq > 0) {
368 labels[part.slice(0, eq)] = part.slice(eq + 1);
369 }
370 }
371 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
372 return { labels, text: rest.length > 0 ? rest : undefined };
373};
374
375export const hasOwnershipMarker = (text: string | undefined) =>
376 Object.keys(parseOwnership(text).labels).some((key) =>
377 key.startsWith("alchemy-"),
378 );
379
380const prefixMatch = (expected: string, observed: string) =>
381 expected === observed ||
382 expected.startsWith(observed) ||
383 observed.startsWith(expected);
384
385export const ownedByAlchemy = (id: string, text: string | undefined) =>
386 Effect.gen(function* () {
387 const expected = yield* createInternalLabels(id);
388 const { labels } = parseOwnership(text);
389 if (!hasOwnershipMarker(text)) return false;
390 const exact = yield* hasAlchemyLabels(id, labels);
391 if (exact) return true;
392 return (
393 prefixMatch(
394 expected[alchemyLabelKeys.stack] ?? "",
395 labels[alchemyLabelKeys.stack] ?? "",
396 ) &&
397 prefixMatch(
398 expected[alchemyLabelKeys.stage] ?? "",
399 labels[alchemyLabelKeys.stage] ?? "",
400 ) &&
401 prefixMatch(
402 expected[alchemyLabelKeys.id] ?? "",
403 labels[alchemyLabelKeys.id] ?? "",
404 )
405 );
406 });