1import * as managedidentities from "@distilled.cloud/gcp/managedidentities_v1";
2import * as Data from "effect/Data";
3import type { GcpOpContext } from "@distilled.cloud/gcp/Protocol";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import { tagRecord } from "../../Tags.ts";
9import { stripInternalLabels } from "../Labels.ts";
10import { waitForOperation as waitForLongRunningOperation } from "../Operation.ts";
11
12export const GLOBAL_LOCATION = "global";
13export const DEFAULT_NETWORK = "default";
14export const DEFAULT_ADMIN = "setupadmin";
15export const DOMAIN_SUFFIX = ".alch.test";
16export const FIRST_SEGMENT_MAX = 15;
17export const MAX_NAME_LENGTH = 63;
18export const PAGE_SIZE = 100;
19
20export class ResourceNotResolved extends Data.TaggedError(
21 "GCP.ManagedIdentities.ResourceNotResolved",
22)<{
23 name: string;
24}> {}
25
26export class ResourceStillExists extends Data.TaggedError(
27 "GCP.ManagedIdentities.ResourceStillExists",
28)<{
29 name: string;
30}> {}
31
32export class ResourceNotReady extends Data.TaggedError(
33 "GCP.ManagedIdentities.ResourceNotReady",
34)<{
35 name: string;
36 state: string;
37}> {}
38
39export class ResourceFailed extends Data.TaggedError(
40 "GCP.ManagedIdentities.ResourceFailed",
41)<{
42 name: string;
43 state: string;
44 details: string | undefined;
45}> {}
46
47export const lastSegment = (value: string) => {
48 const trimmed = value.replace(/\/+$/, "");
49 const parts = trimmed.split("/");
50 return parts[parts.length - 1] || trimmed;
51};
52
53export const rfc1035 = (
54 name: string,
55 fallback = "ad",
56 maxLength = MAX_NAME_LENGTH,
57): string => {
58 let next = name
59 .toLowerCase()
60 .replace(/[^a-z0-9-]/g, "-")
61 .replace(/-+/g, "-")
62 .replace(/^-+|-+$/g, "");
63 if (!/^[a-z]/.test(next)) next = `${fallback[0] ?? "a"}${next}`;
64 next = next.slice(0, maxLength).replace(/-+$/g, "");
65 if (next.length === 0) return fallback.slice(0, maxLength);
66 if (!/[a-z0-9]$/.test(next)) {
67 next = `${next.slice(0, maxLength - 1)}0`;
68 }
69 return next.slice(0, maxLength);
70};
71
72export const normalizeLocation = (location: string) =>
73 lastSegment(location).toLowerCase();
74
75export const globalParent = (project: string) =>
76 `projects/${project}/locations/${GLOBAL_LOCATION}`;
77
78export const parseName = (name: string, collection: string) => {
79 const parts = name.split("/").filter((part) => part.length > 0);
80 const collectionAt = parts.lastIndexOf(collection);
81 const locationsAt = parts.lastIndexOf("locations");
82 const projectsAt = parts.lastIndexOf("projects");
83 return {
84 project:
85 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
86 location:
87 locationsAt >= 0 && parts[locationsAt + 1]
88 ? parts[locationsAt + 1]!
89 : GLOBAL_LOCATION,
90 id:
91 collectionAt >= 0 && parts[collectionAt + 1]
92 ? parts[collectionAt + 1]!
93 : lastSegment(name),
94 parent:
95 collectionAt > 0
96 ? parts.slice(0, collectionAt).join("/")
97 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
98 };
99};
100
101export const domainResourceOf = (value: string, project: string) => {
102 const trimmed = value.replace(/\/+$/, "");
103 if (trimmed.includes("/")) return trimmed;
104 return `${globalParent(project)}/domains/${trimmed}`;
105};
106
107export const domainNameOf = (value: string) => {
108 const trimmed = value.replace(/\/+$/, "");
109 if (trimmed.includes("/domains/")) return parseName(trimmed, "domains").id;
110 return trimmed;
111};
112
113export const networkOf = (value: string, project: string) => {
114 const trimmed = value.replace(/\/+$/, "");
115 if (trimmed.length === 0) {
116 return `projects/${project}/global/networks/${DEFAULT_NETWORK}`;
117 }
118 if (trimmed.includes("/")) return trimmed;
119 return `projects/${project}/global/networks/${trimmed}`;
120};
121
122export const toPhysicalId = (
123 id: string,
124 explicit: string | undefined,
125 existing: string | undefined,
126 fallback = "ad",
127) =>
128 Effect.gen(function* () {
129 if (explicit !== undefined) return rfc1035(explicit, fallback);
130 if (existing !== undefined) return existing;
131 return rfc1035(
132 yield* createPhysicalName({
133 id,
134 maxLength: MAX_NAME_LENGTH,
135 lowercase: true,
136 }),
137 fallback,
138 );
139 });
140
141export const toDomainName = (
142 id: string,
143 explicit: string | undefined,
144 existing: string | undefined,
145) =>
146 Effect.gen(function* () {
147 if (explicit !== undefined) return explicit.toLowerCase();
148 if (existing !== undefined) return domainNameOf(existing);
149 const first = rfc1035(
150 yield* createPhysicalName({
151 id,
152 maxLength: FIRST_SEGMENT_MAX,
153 lowercase: true,
154 }),
155 "ad",
156 FIRST_SEGMENT_MAX,
157 );
158 return `${first}${DOMAIN_SUFFIX}`;
159 });
160
161export const userLabels = (
162 labels: Record<string, string | undefined> | null | undefined,
163): Record<string, string> => stripInternalLabels(tagRecord(labels));
164
165export const hasAlchemyLabelMap = (
166 labels: Record<string, string | undefined> | null | undefined,
167) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
168
169export const fieldMask = (fields: Array<string | false | undefined>) =>
170 fields
171 .filter((field): field is string => typeof field === "string")
172 .join(",");
173
174export const sameText = (left: string | undefined, right: string | undefined) =>
175 (left ?? "") === (right ?? "");
176
177export const sameStringList = (
178 left: readonly string[] | undefined,
179 right: readonly string[] | undefined,
180) =>
181 JSON.stringify(
182 [...(left ?? [])].map((value) => value.toLowerCase()).sort(),
183 ) ===
184 JSON.stringify([...(right ?? [])].map((value) => value.toLowerCase()).sort());
185
186export const replaceOnIdentity = (input: {
187 previousId: string | undefined;
188 nextId: string | undefined;
189 extra?: boolean;
190 previousParent?: string;
191 nextParent?: string;
192}) => {
193 const parentChanged =
194 (input.previousParent ?? "") !== "" &&
195 (input.nextParent ?? "") !== "" &&
196 (input.previousParent ?? "") !== (input.nextParent ?? "");
197 const replace =
198 (input.extra ?? false) ||
199 parentChanged ||
200 (input.previousId !== undefined &&
201 input.nextId !== undefined &&
202 input.nextId !== input.previousId);
203 if (!replace) return undefined;
204 const samePhysical =
205 !parentChanged &&
206 input.previousId !== undefined &&
207 input.nextId === input.previousId;
208 return {
209 action: "replace" as const,
210 deleteFirst: samePhysical,
211 };
212};
213
214const READY_STATES = new Set([
215 "READY",
216 "ACTIVE",
217 "CONNECTED",
218 "PERFORMING_MAINTENANCE",
219]);
220const FAILED_STATES = new Set(["ERROR", "FAILED", "UNAVAILABLE"]);
221
222export const isReadyState = (state: string | undefined) =>
223 READY_STATES.has((state ?? "").toUpperCase());
224
225export const isFailedState = (state: string | undefined) =>
226 FAILED_STATES.has((state ?? "").toUpperCase());
227
228/**
229 * Wait on a Managed Microsoft AD long-running operation. Domain creation takes up to ~60 minutes.
230 * `ALREADY_EXISTS` (a create race) counts as success; so does `NOT_FOUND`
231 * when `notFoundOk` (deletes). Returns the final operation.
232 */
233export const waitForOperation = (
234 operation: managedidentities.Operation,
235 options?: { notFoundOk?: boolean },
236) =>
237 Effect.suspend(() => {
238 let latest = operation;
239 return waitForLongRunningOperation(
240 operation,
241 (name) => {
242 const get = managedidentities
243 .getProjectsLocationsGlobalOperations({ name })
244 .pipe(
245 Effect.tap((current) =>
246 Effect.sync(() => {
247 latest = current;
248 }),
249 ),
250 );
251 const observe: Effect.Effect<
252 managedidentities.Operation,
253 managedidentities.GetProjectsLocationsGlobalOperationsError,
254 GcpOpContext
255 > =
256 options?.notFoundOk === true
257 ? get.pipe(
258 Effect.catchTag("NotFound", () =>
259 Effect.succeed<managedidentities.Operation>({
260 name,
261 done: true,
262 }),
263 ),
264 )
265 : get.pipe(
266 // A just-returned operation can briefly 404 on read.
267 Effect.retry({
268 while: (error) => error._tag === "NotFound",
269 times: 5,
270 schedule: Schedule.exponential("250 millis"),
271 }),
272 );
273 return observe;
274 },
275 { budget: "90 minutes", interval: "30 seconds" },
276 ).pipe(
277 Effect.map(() => latest),
278 // google.rpc.Code ALREADY_EXISTS = 6, NOT_FOUND = 5.
279 Effect.catchTag("GCP.OperationFailed", (error) =>
280 error.code === 6 || (options?.notFoundOk === true && error.code === 5)
281 ? Effect.succeed(latest)
282 : Effect.fail(error),
283 ),
284 );
285 });
286
287export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
288 get: Effect.Effect<A | undefined, E, R>,
289 name: string,
290) =>
291 get.pipe(
292 Effect.filterOrFail(
293 (value): value is A => value !== undefined,
294 () => new ResourceNotResolved({ name }),
295 ),
296 Effect.retry({
297 while: (error) =>
298 error._tag === "GCP.ManagedIdentities.ResourceNotResolved",
299 times: 8,
300 schedule: Schedule.spaced("1 second"),
301 }),
302 );
303
304export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
305 get: Effect.Effect<A | undefined, E, R>,
306 name: string,
307) =>
308 get.pipe(
309 Effect.filterOrFail(
310 (value) => value === undefined,
311 () => new ResourceStillExists({ name }),
312 ),
313 Effect.retry({
314 while: (error) =>
315 error._tag === "GCP.ManagedIdentities.ResourceStillExists",
316 times: 10,
317 schedule: Schedule.spaced("5 seconds"),
318 }),
319 Effect.asVoid,
320 );
321
322export const waitUntilReady = <
323 A extends { readonly state?: string; readonly statusMessage?: string },
324 E extends { readonly _tag: string },
325 R,
326>(
327 get: Effect.Effect<A | undefined, E, R>,
328 name: string,
329 stateOf: (value: A) => string | undefined,
330 detailsOf?: (value: A) => string | undefined,
331 options?: {
332 times?: number;
333 interval?: `${number} seconds`;
334 },
335): Effect.Effect<
336 A,
337 E | ResourceNotResolved | ResourceNotReady | ResourceFailed,
338 R
339> =>
340 get.pipe(
341 Effect.filterOrFail(
342 (value): value is A => value !== undefined,
343 () => new ResourceNotResolved({ name }),
344 ),
345 Effect.filterOrFail(
346 (value) => {
347 const state = stateOf(value) ?? "";
348 return (
349 isReadyState(state) || isFailedState(state) || state.length === 0
350 );
351 },
352 (value) => new ResourceNotReady({ name, state: stateOf(value) ?? "" }),
353 ),
354 Effect.filterOrFail(
355 (value) => !isFailedState(stateOf(value)),
356 (value) =>
357 new ResourceFailed({
358 name,
359 state: stateOf(value) ?? "",
360 details: detailsOf?.(value),
361 }),
362 ),
363 Effect.retry({
364 while: (error) =>
365 error._tag === "GCP.ManagedIdentities.ResourceNotReady" ||
366 error._tag === "GCP.ManagedIdentities.ResourceNotResolved",
367 // Domains take up to ~60 minutes to become READY.
368 times: options?.times ?? 180,
369 schedule: Schedule.spaced(options?.interval ?? "30 seconds"),
370 }),
371 );
372
373export const listLabeledPages = <Page, A, E, R>(
374 pages: Stream.Stream<Page, E, R>,
375 items: (page: Page) => readonly A[] | undefined,
376 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
377) =>
378 pages.pipe(
379 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
380 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
381 Stream.runCollect,
382 Effect.map((chunk) => Array.from(chunk)),
383 );
384
385export const listDomains = (project: string) =>
386 listLabeledPages(
387 managedidentities.listProjectsLocationsGlobalDomains.pages({
388 parent: globalParent(project),
389 pageSize: PAGE_SIZE,
390 }),
391 (page) => page.domains,
392 (item) => item.labels,
393 );
394
395export const listPeerings = (project: string) =>
396 listLabeledPages(
397 managedidentities.listProjectsLocationsGlobalPeerings.pages({
398 parent: globalParent(project),
399 pageSize: PAGE_SIZE,
400 }),
401 (page) => page.peerings,
402 (item) => item.labels,
403 );
404
405export const listBackups = (project: string) =>
406 Effect.gen(function* () {
407 const wildcard = yield* listLabeledPages(
408 managedidentities.listProjectsLocationsGlobalDomainsBackups.pages({
409 parent: `${globalParent(project)}/domains/-`,
410 pageSize: PAGE_SIZE,
411 }),
412 (page) => page.backups,
413 (item) => item.labels,
414 );
415 if (wildcard.length > 0) {
416 return wildcard;
417 }
418 const domains = yield* managedidentities.listProjectsLocationsGlobalDomains
419 .pages({
420 parent: globalParent(project),
421 pageSize: PAGE_SIZE,
422 })
423 .pipe(
424 Stream.flatMap((page) => Stream.fromIterable(page.domains ?? [])),
425 Stream.runCollect,
426 Effect.map((chunk) => Array.from(chunk)),
427 );
428 const groups = yield* Effect.forEach(
429 domains.filter((domain) => (domain.name ?? "").length > 0),
430 (domain) =>
431 listLabeledPages(
432 managedidentities.listProjectsLocationsGlobalDomainsBackups.pages({
433 parent: domain.name!,
434 pageSize: PAGE_SIZE,
435 }),
436 (page) => page.backups,
437 (item) => item.labels,
438 ),
439 { concurrency: 4 },
440 );
441 return groups.flat();
442 });