1import * as apim from "@distilled.cloud/gcp/apim_v1alpha";
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 { waitForOperation as waitForLongRunning } from "../Operation.ts";
8
9export const MAX_NAME_LENGTH = 63;
10export const MIN_NAME_LENGTH = 4;
11export const ALCHEMY_ID_PREFIX = "alch-";
12
13export type PscNetworkConfig = {
14 /** VPC network `projects/{project}/global/networks/{network}`. */
15 network: string;
16 /**
17 * Subnetwork
18 * `projects/{project}/regions/{region}/subnetworks/{subnet}`.
19 */
20 subnetwork: string;
21};
22
23export type GclbObservationSource = {
24 /** VPC networks whose load balancers are observed. Currently one. */
25 pscNetworkConfigs: PscNetworkConfig[];
26};
27
28export class ResourceNotResolved extends Data.TaggedError(
29 "GCP.Apim.NotResolved",
30)<{
31 name: string;
32}> {}
33
34export class ResourceStillExists extends Data.TaggedError(
35 "GCP.Apim.StillExists",
36)<{
37 name: string;
38}> {}
39
40export class ResourceNotReady extends Data.TaggedError("GCP.Apim.NotReady")<{
41 name: string;
42 state: string;
43}> {}
44
45export class ResourceFailed extends Data.TaggedError("GCP.Apim.Failed")<{
46 name: string;
47 state: string;
48}> {}
49
50export const lastSegment = (value: string) => {
51 const trimmed = value.replace(/\/+$/, "");
52 const parts = trimmed.split("/");
53 return parts[parts.length - 1] || trimmed;
54};
55
56export const rfc1035 = (name: string, fallback = "apim"): string => {
57 let next = name
58 .toLowerCase()
59 .replace(/[^a-z0-9-]/g, "-")
60 .replace(/-+/g, "-")
61 .replace(/^-+|-+$/g, "");
62 if (!/^[a-z]/.test(next)) next = `${fallback[0] ?? "a"}${next}`;
63 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
64 if (next.length === 0) return fallback;
65 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
66 if (next.length < MIN_NAME_LENGTH) {
67 next = `${next}${"x".repeat(MIN_NAME_LENGTH)}`.slice(0, MIN_NAME_LENGTH);
68 }
69 return next.slice(0, MAX_NAME_LENGTH);
70};
71
72export const normalizeLocation = (location: string) =>
73 lastSegment(location).toLowerCase();
74
75export const parentOf = (project: string, location: string) =>
76 `projects/${project}/locations/${normalizeLocation(location)}`;
77
78export const resourceName = (
79 project: string,
80 location: string,
81 collection: string,
82 id: string,
83) => `${parentOf(project, location)}/${collection}/${id}`;
84
85export const toPhysicalId = (
86 id: string,
87 explicit: string | undefined,
88 existing: string | undefined,
89 fallback = "apim",
90) =>
91 Effect.gen(function* () {
92 if (explicit !== undefined) return rfc1035(explicit, fallback);
93 if (existing !== undefined) return existing;
94 const generated = rfc1035(
95 yield* createPhysicalName({
96 id,
97 maxLength: MAX_NAME_LENGTH - ALCHEMY_ID_PREFIX.length,
98 lowercase: true,
99 }),
100 fallback,
101 );
102 const prefixed = generated.startsWith(ALCHEMY_ID_PREFIX)
103 ? generated
104 : `${ALCHEMY_ID_PREFIX}${generated}`;
105 return rfc1035(prefixed, fallback);
106 });
107
108export const parseName = (name: string, collection: string) => {
109 const parts = name.split("/").filter((part) => part.length > 0);
110 const collectionAt = parts.lastIndexOf(collection);
111 const locationsAt = parts.lastIndexOf("locations");
112 const projectsAt = parts.lastIndexOf("projects");
113 return {
114 project:
115 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
116 location:
117 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
118 id:
119 collectionAt >= 0 && parts[collectionAt + 1]
120 ? parts[collectionAt + 1]!
121 : lastSegment(name),
122 parent:
123 collectionAt > 0
124 ? parts.slice(0, collectionAt).join("/")
125 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
126 };
127};
128
129export const hasAlchemyId = (id: string | undefined) =>
130 (id ?? "").startsWith(ALCHEMY_ID_PREFIX);
131
132export const projectIdOf = (value: string | undefined, fallback: string) => {
133 if (value === undefined || value.length === 0) return fallback;
134 const parts = value.split("/").filter((part) => part.length > 0);
135 const at = parts.lastIndexOf("projects");
136 if (at >= 0 && parts[at + 1]) return parts[at + 1]!;
137 return fallback;
138};
139
140export const expandNetwork = (value: string, project: string) => {
141 const trimmed = value.replace(/\/+$/, "");
142 if (trimmed.includes("/networks/")) {
143 const owner = projectIdOf(trimmed, project);
144 return `projects/${owner}/global/networks/${lastSegment(trimmed)}`;
145 }
146 return `projects/${project}/global/networks/${trimmed}`;
147};
148
149export const expandSubnetwork = (
150 value: string,
151 project: string,
152 location: string,
153) => {
154 const trimmed = value.replace(/\/+$/, "");
155 if (trimmed.includes("/subnetworks/")) {
156 const owner = projectIdOf(trimmed, project);
157 const parts = trimmed.split("/").filter((part) => part.length > 0);
158 const regionsAt = parts.lastIndexOf("regions");
159 const region =
160 regionsAt >= 0 && parts[regionsAt + 1] ? parts[regionsAt + 1]! : location;
161 return `projects/${owner}/regions/${region}/subnetworks/${lastSegment(trimmed)}`;
162 }
163 return `projects/${project}/regions/${location}/subnetworks/${trimmed}`;
164};
165
166export const expandObservationSource = (
167 value: string,
168 project: string,
169 location: string,
170) => {
171 const trimmed = value.replace(/\/+$/, "");
172 if (trimmed.includes("/observationSources/")) return trimmed;
173 return resourceName(project, location, "observationSources", trimmed);
174};
175
176export const expandGclb = (
177 source: GclbObservationSource | undefined,
178 project: string,
179 location: string,
180): apim.GclbObservationSource | undefined => {
181 if (source === undefined) return undefined;
182 return {
183 pscNetworkConfigs: (source.pscNetworkConfigs ?? []).map((config) => ({
184 network: expandNetwork(config.network, project),
185 subnetwork: expandSubnetwork(config.subnetwork, project, location),
186 })),
187 };
188};
189
190export const canonical = (value: unknown): unknown => {
191 if (value === undefined || value === null) return undefined;
192 if (typeof value === "boolean" || typeof value === "number") return value;
193 if (typeof value === "string") {
194 return value.length === 0 ? undefined : value;
195 }
196 if (Array.isArray(value)) {
197 const items = value.map(canonical).filter((item) => item !== undefined);
198 return items.length === 0 ? undefined : items;
199 }
200 if (typeof value === "object") {
201 const entries = Object.entries(value as Record<string, unknown>)
202 .map(([key, item]) => [key, canonical(item)] as const)
203 .filter(([, item]) => item !== undefined)
204 .sort(([left], [right]) => left.localeCompare(right));
205 if (entries.length === 0) return undefined;
206 return Object.fromEntries(entries);
207 }
208 return undefined;
209};
210
211export const fingerprint = (value: unknown): string =>
212 JSON.stringify(canonical(value) ?? null);
213
214export const sameJson = (left: unknown, right: unknown) =>
215 fingerprint(left) === fingerprint(right);
216
217export const sameStringList = (
218 left: readonly string[] | undefined,
219 right: readonly string[] | undefined,
220) =>
221 fingerprint([...(left ?? [])].map(lastSegment).sort()) ===
222 fingerprint([...(right ?? [])].map(lastSegment).sort());
223
224export const replaceOnIdentity = (input: {
225 previousId: string | undefined;
226 nextId: string | undefined;
227 previousLocation: string;
228 nextLocation: string;
229 extra?: boolean;
230 deleteFirst?: boolean;
231}) => {
232 const replace =
233 (input.extra ?? false) ||
234 (input.previousId !== undefined &&
235 input.nextId !== undefined &&
236 input.nextId !== input.previousId) ||
237 input.previousLocation !== input.nextLocation;
238 if (!replace) return undefined;
239 const samePhysical =
240 input.previousLocation === input.nextLocation &&
241 input.previousId !== undefined &&
242 input.nextId === input.previousId;
243 return {
244 action: "replace" as const,
245 deleteFirst: input.deleteFirst ?? samePhysical,
246 };
247};
248
249/**
250 * Wait for a API Management long-running operation. Observation jobs and
251 * sources settle within minutes.
252 * `ALREADY_EXISTS` (a concurrent create won) always succeeds;
253 * `notFoundOk` also accepts a `NOT_FOUND` result or an operation that has
254 * already been garbage-collected (deletes).
255 */
256export const waitForOperation = (
257 operation: apim.Operation,
258 options?: { notFoundOk?: boolean },
259) =>
260 waitForLongRunning(
261 operation,
262 (name) => apim.getProjectsLocationsOperations({ name }),
263 { budget: "10 minutes" },
264 ).pipe(
265 Effect.catchIf(
266 (error) =>
267 error._tag === "GCP.OperationFailed" &&
268 (error.code === 6 ||
269 (options?.notFoundOk === true && error.code === 5)),
270 () => Effect.void,
271 ),
272 Effect.catchIf(
273 (error) => options?.notFoundOk === true && error._tag === "NotFound",
274 () => Effect.void,
275 ),
276 );
277
278export const waitUntilExists = <A, E, R>(
279 get: Effect.Effect<A, E, R>,
280 name: string,
281): Effect.Effect<A & {}, E | ResourceNotResolved, R> =>
282 get.pipe(
283 Effect.filterOrFail(
284 (value): value is A & {} => value !== undefined,
285 () => new ResourceNotResolved({ name }),
286 ),
287 Effect.retry({
288 while: (error) => error instanceof ResourceNotResolved,
289 times: 8,
290 schedule: Schedule.spaced("1 second"),
291 }),
292 );
293
294export const waitUntilGone = <A, E, R>(
295 get: Effect.Effect<A, E, R>,
296 name: string,
297) =>
298 get.pipe(
299 Effect.filterOrFail(
300 (value) => value === undefined,
301 () => new ResourceStillExists({ name }),
302 ),
303 Effect.retry({
304 while: (error) => error instanceof ResourceStillExists,
305 times: 10,
306 schedule: Schedule.spaced("2 seconds"),
307 }),
308 Effect.asVoid,
309 );
310
311const FAILED_STATES = new Set(["ERROR", "FAILED"]);
312
313export const waitUntilReady = <A, E, R>(
314 get: Effect.Effect<A, E, R>,
315 name: string,
316 stateOf: (value: A & {}) => string | undefined,
317 ready: ReadonlySet<string>,
318 options?: {
319 times?: number;
320 interval?: `${number} seconds`;
321 },
322): Effect.Effect<
323 A & {},
324 E | ResourceNotResolved | ResourceNotReady | ResourceFailed,
325 R
326> =>
327 get.pipe(
328 Effect.filterOrFail(
329 (value): value is A & {} => value !== undefined,
330 () => new ResourceNotResolved({ name }),
331 ),
332 Effect.filterOrFail(
333 (value) => !FAILED_STATES.has((stateOf(value) ?? "").toUpperCase()),
334 (value) =>
335 new ResourceFailed({
336 name,
337 state: (stateOf(value) ?? "").toUpperCase(),
338 }),
339 ),
340 Effect.filterOrFail(
341 (value) => ready.has((stateOf(value) ?? "").toUpperCase()),
342 (value) =>
343 new ResourceNotReady({
344 name,
345 state: (stateOf(value) ?? "").toUpperCase(),
346 }),
347 ),
348 Effect.retry({
349 while: (error) =>
350 error instanceof ResourceNotReady ||
351 error instanceof ResourceNotResolved,
352 times: options?.times ?? 10,
353 schedule: Schedule.spaced(options?.interval ?? "3 seconds"),
354 }),
355 );
356
357export const collectPages = <Page, A, E, R>(
358 pages: Stream.Stream<Page, E, R>,
359 items: (page: Page) => readonly A[] | undefined,
360) =>
361 pages.pipe(
362 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
363 Stream.runCollect,
364 Effect.map((chunk) => Array.from(chunk)),
365 );
366
367/**
368 * List across every location via the `locations/-` wildcard, falling back to
369 * the default region where the API rejects the wildcard. A missing parent
370 * (`NotFound`) lists as empty; any other error fails.
371 */
372export const listAtLocation = <A, E extends { readonly _tag: string }, R>(
373 project: string,
374 region: string,
375 list: (parent: string) => Effect.Effect<A[], E, R>,
376) =>
377 list(`projects/${project}/locations/-`).pipe(
378 Effect.catchIf(
379 (error) => error._tag === "NotFound" || error._tag === "BadRequest",
380 () =>
381 list(`projects/${project}/locations/${region}`).pipe(
382 Effect.catchIf(
383 (error) => error._tag === "NotFound",
384 () => Effect.succeed<A[]>([]),
385 ),
386 ),
387 ),
388 );