1import * as cnr from "@distilled.cloud/gcp/cloudnumberregistry_v1alpha";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import * as Schedule from "effect/Schedule";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { tagRecord } from "../../Tags.ts";
8import { stripInternalLabels } from "../Labels.ts";
9import {
10 waitForOperation as waitForLongRunning,
11 type LongRunningOperation,
12} from "../Operation.ts";
13
14export const DEFAULT_LOCATION = "global";
15export const MAX_NAME_LENGTH = 63;
16
17export class ResourceNotResolved extends Data.TaggedError(
18 "GCP.CloudNumberRegistry.NotResolved",
19)<{
20 name: string;
21}> {}
22
23export class ResourceStillExists extends Data.TaggedError(
24 "GCP.CloudNumberRegistry.StillExists",
25)<{
26 name: string;
27}> {}
28
29export const lastSegment = (value: string) => {
30 const trimmed = value.replace(/\/+$/, "");
31 const parts = trimmed.split("/");
32 return parts[parts.length - 1] || trimmed;
33};
34
35export const rfc1035 = (name: string, fallback = "cnr"): string => {
36 let next = name
37 .toLowerCase()
38 .replace(/[^a-z0-9-]/g, "-")
39 .replace(/-+/g, "-")
40 .replace(/^-+|-+$/g, "");
41 if (!/^[a-z]/.test(next)) next = `${fallback[0] ?? "c"}${next}`;
42 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
43 if (next.length === 0) return fallback;
44 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
45 if (next.length < 3) next = `${next}xxx`.slice(0, 3);
46 return next.slice(0, MAX_NAME_LENGTH);
47};
48
49export const normalizeLocation = (location: string | undefined) =>
50 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
51
52export const parentOf = (project: string, location: string | undefined) =>
53 `projects/${project}/locations/${normalizeLocation(location)}`;
54
55export const resourceName = (
56 project: string,
57 location: string,
58 collection: string,
59 id: string,
60) => `${parentOf(project, location)}/${collection}/${id}`;
61
62export const toPhysicalId = (
63 id: string,
64 explicit: string | undefined,
65 existing: string | undefined,
66 fallback = "cnr",
67) =>
68 Effect.gen(function* () {
69 if (explicit !== undefined) return rfc1035(explicit, fallback);
70 if (existing !== undefined) return existing;
71 return rfc1035(
72 yield* createPhysicalName({
73 id,
74 maxLength: MAX_NAME_LENGTH,
75 lowercase: true,
76 }),
77 fallback,
78 );
79 });
80
81export const parseName = (name: string, collection: string) => {
82 const parts = name.split("/").filter((part) => part.length > 0);
83 const collectionAt = parts.lastIndexOf(collection);
84 const locationsAt = parts.lastIndexOf("locations");
85 const projectsAt = parts.lastIndexOf("projects");
86 return {
87 project:
88 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
89 location:
90 locationsAt >= 0 && parts[locationsAt + 1]
91 ? parts[locationsAt + 1]!
92 : DEFAULT_LOCATION,
93 id:
94 collectionAt >= 0 && parts[collectionAt + 1]
95 ? parts[collectionAt + 1]!
96 : lastSegment(name),
97 parent:
98 collectionAt > 0
99 ? parts.slice(0, collectionAt).join("/")
100 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
101 };
102};
103
104export const expandName = (
105 value: string,
106 project: string,
107 location: string,
108 collection: string,
109) => {
110 const trimmed = value.replace(/\/+$/, "");
111 if (trimmed.includes("/")) return trimmed;
112 return resourceName(project, location, collection, trimmed);
113};
114
115export const userLabels = (
116 labels: Record<string, string | undefined> | null | undefined,
117): Record<string, string> => stripInternalLabels(tagRecord(labels));
118
119export const hasAlchemyLabelMap = (
120 labels: Record<string, string | undefined> | null | undefined,
121) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
122
123export const canonical = (value: unknown): unknown => {
124 if (value === undefined || value === null) return undefined;
125 if (typeof value === "boolean" || typeof value === "number") return value;
126 if (typeof value === "string") {
127 return value.length === 0 ? undefined : value;
128 }
129 if (Array.isArray(value)) {
130 const items = value.map(canonical).filter((item) => item !== undefined);
131 return items.length === 0 ? undefined : items;
132 }
133 if (typeof value === "object") {
134 const entries = Object.entries(value as Record<string, unknown>)
135 .map(([key, item]) => [key, canonical(item)] as const)
136 .filter(([, item]) => item !== undefined)
137 .sort(([left], [right]) => left.localeCompare(right));
138 if (entries.length === 0) return undefined;
139 return Object.fromEntries(entries);
140 }
141 return undefined;
142};
143
144export const fingerprint = (value: unknown): string =>
145 JSON.stringify(canonical(value) ?? null);
146
147export const fieldMask = (fields: Array<string | false | undefined>) =>
148 fields
149 .filter((field): field is string => typeof field === "string")
150 .join(",");
151
152export const sameText = (left: string | undefined, right: string | undefined) =>
153 (left ?? "") === (right ?? "");
154
155export const sameRef = (left: string | undefined, right: string | undefined) =>
156 lastSegment(left ?? "") === lastSegment(right ?? "");
157
158export const sameStringList = (
159 left: readonly string[] | undefined,
160 right: readonly string[] | undefined,
161) =>
162 fingerprint([...(left ?? [])].sort()) ===
163 fingerprint([...(right ?? [])].sort());
164
165export const replaceOnIdentity = (input: {
166 previousId: string | undefined;
167 nextId: string | undefined;
168 previousLocation: string;
169 nextLocation: string;
170 extra?: boolean;
171}) => {
172 const replace =
173 (input.extra ?? false) ||
174 (input.previousId !== undefined &&
175 input.nextId !== undefined &&
176 input.nextId !== input.previousId) ||
177 input.previousLocation !== input.nextLocation;
178 if (!replace) return undefined;
179 const samePhysical =
180 input.previousLocation === input.nextLocation &&
181 input.previousId !== undefined &&
182 input.nextId === input.previousId;
183 return {
184 action: "replace" as const,
185 deleteFirst: samePhysical,
186 };
187};
188
189export const waitUntilExists = <A, E, R>(
190 get: Effect.Effect<A | undefined, E, R>,
191 name: string,
192): Effect.Effect<Exclude<A, undefined>, E | ResourceNotResolved, R> =>
193 get.pipe(
194 Effect.filterOrFail(
195 (value): value is Exclude<A, undefined> => value !== undefined,
196 () => new ResourceNotResolved({ name }),
197 ),
198 Effect.retry({
199 while: (error) => error instanceof ResourceNotResolved,
200 times: 8,
201 schedule: Schedule.spaced("1 second"),
202 }),
203 );
204
205export const waitUntilGone = <A, E, R>(
206 get: Effect.Effect<A | undefined, E, R>,
207 name: string,
208): Effect.Effect<void, E | ResourceStillExists, R> =>
209 get.pipe(
210 Effect.filterOrFail(
211 (value) => value === undefined,
212 () => new ResourceStillExists({ name }),
213 ),
214 Effect.asVoid,
215 Effect.retry({
216 while: (error) => error instanceof ResourceStillExists,
217 times: 10,
218 schedule: Schedule.spaced("2 seconds"),
219 }),
220 );
221
222/** The API rejects the `locations/-` wildcard; resources live in `global`. */
223export const listAtLocation = <A, E, R>(
224 project: string,
225 list: (parent: string) => Effect.Effect<A[], E, R>,
226) => list(`projects/${project}/locations/${DEFAULT_LOCATION}`);
227
228export const listLabeledPages = <Page, A, E, R>(
229 pages: Stream.Stream<Page, E, R>,
230 items: (page: Page) => readonly A[] | undefined,
231 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
232) =>
233 pages.pipe(
234 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
235 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
236 Stream.runCollect,
237 Effect.map((chunk) => Array.from(chunk)),
238 );
239
240/** Registry and IPAM scope operations finish within a few minutes. */
241const OPERATION_BUDGET = "10 minutes";
242
243/**
244 * Wait for a long-running operation. ALREADY_EXISTS (code 6) means a
245 * concurrent create won the race; reconcile observes the resource next.
246 */
247export const waitForOperation = (operation: LongRunningOperation) =>
248 waitForLongRunning(
249 operation,
250 (name) => cnr.getProjectsLocationsOperations({ name }),
251 {
252 budget: OPERATION_BUDGET,
253 },
254 ).pipe(
255 Effect.catchIf(
256 (error) => error._tag === "GCP.OperationFailed" && error.code === 6,
257 () => Effect.succeed(operation),
258 ),
259 );
260
261/**
262 * Wait for a delete operation. A vanished operation or NOT_FOUND (code 5)
263 * means the resource is already gone.
264 */
265export const waitForDeleteOperation = (operation: LongRunningOperation) =>
266 waitForOperation(operation).pipe(
267 Effect.catchIf(
268 (error) =>
269 error._tag === "NotFound" ||
270 (error._tag === "GCP.OperationFailed" && error.code === 5),
271 () => Effect.succeed(operation),
272 ),
273 );