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