1import * as rma from "@distilled.cloud/gcp/rapidmigrationassessment_v1";
2import { Retry as GcpRetry } from "@distilled.cloud/gcp/Retry";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import { tagRecord } from "../../Tags.ts";
10import { stripInternalLabels } from "../Labels.ts";
11import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
12
13const noRetryLayer = Layer.succeed(GcpRetry, { while: () => false });
14
15export const MAX_NAME_LENGTH = 63;
16
17export class ResourceNotResolved extends Data.TaggedError(
18 "GCP.RapidMigrationAssessment.ResourceNotResolved",
19)<{
20 name: string;
21}> {}
22
23export class ResourceStillExists extends Data.TaggedError(
24 "GCP.RapidMigrationAssessment.ResourceStillExists",
25)<{
26 name: string;
27}> {}
28
29export class ResourceNotReady extends Data.TaggedError(
30 "GCP.RapidMigrationAssessment.ResourceNotReady",
31)<{
32 name: string;
33 state: string;
34}> {}
35
36export class ResourceFailed extends Data.TaggedError(
37 "GCP.RapidMigrationAssessment.ResourceFailed",
38)<{
39 name: string;
40 state: string;
41}> {}
42
43export const lastSegment = (value: string) => {
44 const trimmed = value.replace(/\/+$/, "");
45 const parts = trimmed.split("/");
46 return parts[parts.length - 1] || trimmed;
47};
48
49export const rfc1035 = (name: string, fallback = "collector"): string => {
50 let next = name
51 .toLowerCase()
52 .replace(/[^a-z0-9-]/g, "-")
53 .replace(/-+/g, "-")
54 .replace(/^-+|-+$/g, "");
55 if (!/^[a-z]/.test(next)) next = `c${next}`;
56 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
57 if (next.length === 0) return fallback;
58 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
59 return next.slice(0, MAX_NAME_LENGTH);
60};
61
62export const normalizeLocation = (
63 location: string | undefined,
64 defaultLocation: string,
65) => lastSegment(location ?? defaultLocation).toLowerCase();
66
67export const locationParent = (project: string, location: string) =>
68 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
69
70export const toPhysicalId = (
71 id: string,
72 explicit: string | undefined,
73 existing: string | undefined,
74 fallback = "collector",
75) =>
76 Effect.gen(function* () {
77 if (explicit !== undefined) return rfc1035(explicit, fallback);
78 if (existing !== undefined) return existing;
79 return rfc1035(
80 yield* createPhysicalName({
81 id,
82 maxLength: MAX_NAME_LENGTH,
83 lowercase: true,
84 }),
85 fallback,
86 );
87 });
88
89export const parseName = (name: string, collection = "collectors") => {
90 const parts = name.split("/").filter((part) => part.length > 0);
91 const collectionAt = parts.lastIndexOf(collection);
92 const locationsAt = parts.lastIndexOf("locations");
93 const projectsAt = parts.lastIndexOf("projects");
94 return {
95 project:
96 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
97 location:
98 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
99 id:
100 collectionAt >= 0 && parts[collectionAt + 1]
101 ? parts[collectionAt + 1]!
102 : lastSegment(name),
103 };
104};
105
106export const userLabels = (
107 labels: Record<string, string | undefined> | null | undefined,
108): Record<string, string> => stripInternalLabels(tagRecord(labels));
109
110export const hasAlchemyLabelMap = (
111 labels: Record<string, string | undefined> | null | undefined,
112) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
113
114export const expectedAssetCountOf = (
115 value: number | string | undefined,
116): string | undefined => (value === undefined ? undefined : String(value));
117
118export const sameText = (left: string | undefined, right: string | undefined) =>
119 (left ?? "") === (right ?? "");
120
121export const sameNumber = (
122 left: number | undefined,
123 right: number | undefined,
124) => (left ?? 0) === (right ?? 0);
125
126export const fieldMask = (fields: Array<string | false | undefined>) =>
127 fields
128 .filter((field): field is string => typeof field === "string")
129 .join(",");
130
131export const replaceOnIdentity = (input: {
132 previousId: string | undefined;
133 nextId: string | undefined;
134 previousLocation: string;
135 nextLocation: string;
136}) => {
137 const replace =
138 (input.previousId !== undefined &&
139 input.nextId !== undefined &&
140 input.nextId !== input.previousId) ||
141 input.previousLocation !== input.nextLocation;
142 if (!replace) return undefined;
143 const samePhysical =
144 input.previousLocation === input.nextLocation &&
145 input.previousId !== undefined &&
146 input.nextId === input.previousId;
147 return {
148 action: "replace" as const,
149 deleteFirst: samePhysical,
150 };
151};
152
153const READY_STATES = new Set([
154 "STATE_READY_TO_USE",
155 "STATE_REGISTERED",
156 "STATE_ACTIVE",
157 "STATE_PAUSED",
158]);
159
160const FAILED_STATES = new Set(["STATE_ERROR"]);
161
162const GONE_STATES = new Set(["STATE_DECOMMISSIONED"]);
163
164export const isReadyState = (state: string | undefined) =>
165 READY_STATES.has((state ?? "").toUpperCase());
166
167export const isFailedState = (state: string | undefined) =>
168 FAILED_STATES.has((state ?? "").toUpperCase());
169
170export const isGoneState = (state: string | undefined) =>
171 GONE_STATES.has((state ?? "").toUpperCase());
172
173export const isPausedState = (state: string | undefined) =>
174 (state ?? "").toUpperCase() === "STATE_PAUSED";
175
176export const isRegisteredState = (state: string | undefined) => {
177 const next = (state ?? "").toUpperCase();
178 return (
179 next === "STATE_REGISTERED" ||
180 next === "STATE_ACTIVE" ||
181 next === "STATE_PAUSED"
182 );
183};
184
185/**
186 * Wait for a Rapid Migration Assessment collector operation. An operation
187 * that finished with `ALREADY_EXISTS` (6) is a lost create race;
188 * `notFoundOk` also accepts `NOT_FOUND` (5).
189 */
190export const waitForOperation = (
191 operation: rma.Operation,
192 options?: { notFoundOk?: boolean },
193) =>
194 waitForGcpOperation(
195 operation,
196 (name) =>
197 rma
198 .getProjectsLocationsOperations({ name })
199 .pipe(
200 Effect.catchTag("NotFound", (error) =>
201 options?.notFoundOk === true
202 ? Effect.succeed<rma.Operation>({ name, done: true })
203 : Effect.fail(error),
204 ),
205 ),
206 { budget: "10 minutes" },
207 ).pipe(
208 Effect.catchIf(
209 (error) =>
210 error._tag === "GCP.OperationFailed" &&
211 (error.code === 6 ||
212 (options?.notFoundOk === true && error.code === 5)),
213 () => Effect.void,
214 ),
215 Effect.asVoid,
216 );
217
218export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
219 get: Effect.Effect<A | undefined, E, R>,
220 name: string,
221): Effect.Effect<NonNullable<A>, E | ResourceNotResolved, R> =>
222 get.pipe(
223 Effect.filterOrFail(
224 (value): value is NonNullable<A> => value != null,
225 () => new ResourceNotResolved({ name }),
226 ),
227 Effect.retry({
228 while: (error) =>
229 error._tag === "GCP.RapidMigrationAssessment.ResourceNotResolved",
230 times: 8,
231 schedule: Schedule.spaced("1 second"),
232 }),
233 );
234
235export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
236 get: Effect.Effect<A | undefined, E, R>,
237 name: string,
238): Effect.Effect<void, E | ResourceStillExists, R> =>
239 get.pipe(
240 Effect.filterOrFail(
241 (value) => value === undefined,
242 () => new ResourceStillExists({ name }),
243 ),
244 Effect.retry({
245 while: (error) =>
246 error._tag === "GCP.RapidMigrationAssessment.ResourceStillExists",
247 times: 10,
248 schedule: Schedule.spaced("5 seconds"),
249 }),
250 Effect.asVoid,
251 );
252
253export const waitUntilReady = <A, E extends { readonly _tag: string }, R>(
254 get: Effect.Effect<A | undefined, E, R>,
255 name: string,
256 stateOf: (value: NonNullable<A>) => string | undefined,
257): Effect.Effect<
258 NonNullable<A>,
259 E | ResourceNotResolved | ResourceFailed | ResourceNotReady,
260 R
261> =>
262 get.pipe(
263 Effect.filterOrFail(
264 (value): value is NonNullable<A> => value != null,
265 () => new ResourceNotResolved({ name }),
266 ),
267 Effect.filterOrFail(
268 (value) => !isFailedState(stateOf(value)),
269 (value) =>
270 new ResourceFailed({
271 name,
272 state: stateOf(value) ?? "",
273 }),
274 ),
275 Effect.filterOrFail(
276 (value) => {
277 const state = stateOf(value) ?? "";
278 return isReadyState(state) || state.length === 0;
279 },
280 (value) => new ResourceNotReady({ name, state: stateOf(value) ?? "" }),
281 ),
282 Effect.retry({
283 while: (error) =>
284 error._tag === "GCP.RapidMigrationAssessment.ResourceNotReady" ||
285 error._tag === "GCP.RapidMigrationAssessment.ResourceNotResolved",
286 times: 10,
287 schedule: Schedule.spaced("3 seconds"),
288 }),
289 );
290
291const emptyCollectors = Effect.succeed<rma.Collector[]>([]);
292
293export const listCollectors = (parent: string) =>
294 parent.length === 0
295 ? emptyCollectors
296 : rma.listProjectsLocationsCollectors
297 .pages({ parent, pageSize: 1000 })
298 .pipe(
299 Stream.flatMap((page) => Stream.fromIterable(page.collectors ?? [])),
300 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
301 Stream.runCollect,
302 Effect.map((chunk) => Array.from(chunk)),
303 Effect.provide(noRetryLayer),
304 Effect.catchTag("NotFound", () => emptyCollectors),
305 );
306
307export const listOwnedCollectors = (project: string, _region: string) =>
308 listCollectors(`projects/${project}/locations/-`);