1import * as apphub from "@distilled.cloud/gcp/apphub_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 {
8 alchemyLabelKeys,
9 createInternalLabels,
10 hasAlchemyLabels,
11} from "../Labels.ts";
12import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
13
14export type Attributes = apphub.Attributes;
15export type Scope = apphub.Scope;
16export type ContactInfo = apphub.ContactInfo;
17export type ServiceReference = apphub.ServiceReference;
18export type ServiceProperties = apphub.ServiceProperties;
19export type WorkloadReference = apphub.WorkloadReference;
20export type WorkloadProperties = apphub.WorkloadProperties;
21
22export const GLOBAL_LOCATION = "global";
23export const MAX_NAME_LENGTH = 63;
24export const MAX_DISPLAY_NAME_LENGTH = 63;
25export const MAX_DESCRIPTION_LENGTH = 2048;
26
27export class ResourceNotResolved extends Data.TaggedError(
28 "GCP.AppHub.ResourceNotResolved",
29)<{
30 name: string;
31}> {}
32
33export class ResourceStillExists extends Data.TaggedError(
34 "GCP.AppHub.ResourceStillExists",
35)<{
36 name: string;
37}> {}
38
39export class ResourceNotReady extends Data.TaggedError(
40 "GCP.AppHub.ResourceNotReady",
41)<{
42 name: string;
43 state: string;
44}> {}
45
46export class ResourceFailed extends Data.TaggedError(
47 "GCP.AppHub.ResourceFailed",
48)<{
49 name: string;
50 state: string;
51}> {}
52
53export const lastSegment = (value: string) => {
54 const trimmed = value.replace(/\/+$/, "");
55 const parts = trimmed.split("/");
56 return parts[parts.length - 1] || trimmed;
57};
58
59export const rfc1035 = (name: string, fallback = "apphub"): string => {
60 let next = name
61 .toLowerCase()
62 .replace(/[^a-z0-9-]/g, "-")
63 .replace(/-+/g, "-")
64 .replace(/^-+|-+$/g, "");
65 if (!/^[a-z]/.test(next)) next = `a${next}`;
66 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
67 if (next.length === 0) return fallback;
68 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
69 return next.slice(0, MAX_NAME_LENGTH);
70};
71
72export const normalizeLocation = (
73 location: string | undefined,
74 defaultLocation: string,
75) => lastSegment(location ?? defaultLocation).toLowerCase();
76
77export const locationParent = (project: string, location: string) =>
78 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
79
80export const toPhysicalId = (
81 id: string,
82 explicit: string | undefined,
83 existing: string | undefined,
84 fallback = "apphub",
85) =>
86 Effect.gen(function* () {
87 if (explicit !== undefined) return rfc1035(explicit, fallback);
88 if (existing !== undefined) return existing;
89 return rfc1035(
90 yield* createPhysicalName({
91 id,
92 maxLength: MAX_NAME_LENGTH,
93 lowercase: true,
94 }),
95 fallback,
96 );
97 });
98
99export const parseName = (
100 name: string,
101 collection: string,
102 defaultLocation: string,
103) => {
104 const parts = name.split("/").filter((part) => part.length > 0);
105 const collectionAt = parts.lastIndexOf(collection);
106 const locationsAt = parts.lastIndexOf("locations");
107 const projectsAt = parts.lastIndexOf("projects");
108 const applicationsAt = parts.lastIndexOf("applications");
109 return {
110 project:
111 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
112 location:
113 locationsAt >= 0 && parts[locationsAt + 1]
114 ? parts[locationsAt + 1]!
115 : defaultLocation,
116 applicationId:
117 applicationsAt >= 0 && parts[applicationsAt + 1]
118 ? parts[applicationsAt + 1]!
119 : "",
120 application:
121 applicationsAt >= 0
122 ? parts.slice(0, applicationsAt + 2).join("/")
123 : parts.slice(0, Math.max(0, parts.length - 2)).join("/"),
124 id:
125 collectionAt >= 0 && parts[collectionAt + 1]
126 ? parts[collectionAt + 1]!
127 : lastSegment(name),
128 parent:
129 collectionAt > 0
130 ? parts.slice(0, collectionAt).join("/")
131 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
132 };
133};
134
135export const expandApplication = (
136 value: string,
137 project: string,
138 location: string,
139) =>
140 value.includes("/applications/")
141 ? value.replace(/\/+$/, "")
142 : `${locationParent(project, location)}/applications/${value}`;
143
144export const expandDiscovered = (
145 value: string,
146 project: string,
147 location: string,
148 collection: "discoveredServices" | "discoveredWorkloads",
149) => {
150 if (value.includes(`/${collection}/`)) return value.replace(/\/+$/, "");
151 if (
152 value.includes("/") ||
153 value.startsWith("https://") ||
154 value.includes(".googleapis.com")
155 ) {
156 return value.replace(/\/+$/, "");
157 }
158 return `projects/${project}/locations/${location}/${collection}/${value}`;
159};
160
161export const isDiscoveredName = (
162 value: string,
163 collection: "discoveredServices" | "discoveredWorkloads",
164) => value.includes(`/${collection}/`);
165
166export const projectNameOf = (value: string | undefined, project: string) => {
167 const next = (value ?? project).replace(/\/+$/, "");
168 if (next.startsWith("projects/")) return next;
169 return `projects/${next}`;
170};
171
172export const projectIdOf = (value: string | undefined, fallback: string) => {
173 if (value === undefined || value.length === 0) return fallback;
174 const parts = value.split("/").filter((part) => part.length > 0);
175 const at = parts.lastIndexOf("projects");
176 if (at >= 0 && parts[at + 1]) return parts[at + 1]!;
177 return lastSegment(value);
178};
179
180export const defaultScope = (location: string): Scope => ({
181 type: location === GLOBAL_LOCATION ? "GLOBAL" : "REGIONAL",
182});
183
184export const canonical = (value: unknown): unknown => {
185 if (value === undefined || value === null) return undefined;
186 if (typeof value === "boolean" || typeof value === "number") return value;
187 if (typeof value === "string") {
188 return value.length === 0 ? undefined : value;
189 }
190 if (Array.isArray(value)) {
191 const items = value.map(canonical).filter((item) => item !== undefined);
192 return items.length === 0 ? undefined : items;
193 }
194 if (typeof value === "object") {
195 const entries = Object.entries(value as Record<string, unknown>)
196 .map(([key, item]) => [key, canonical(item)] as const)
197 .filter(([, item]) => item !== undefined)
198 .sort(([left], [right]) => left.localeCompare(right));
199 if (entries.length === 0) return undefined;
200 return Object.fromEntries(entries);
201 }
202 return undefined;
203};
204
205export const fingerprint = (value: unknown): string =>
206 JSON.stringify(canonical(value) ?? null);
207
208export const sameJson = (left: unknown, right: unknown) =>
209 fingerprint(left) === fingerprint(right);
210
211export const sameText = (left: string | undefined, right: string | undefined) =>
212 (left ?? "") === (right ?? "");
213
214export const fieldMask = (fields: Array<string | false | undefined>) =>
215 fields
216 .filter((field): field is string => typeof field === "string")
217 .join(",");
218
219export const replaceOnIdentity = (input: {
220 previousId: string | undefined;
221 nextId: string | undefined;
222 previousLocation: string;
223 nextLocation: string;
224 extra?: boolean;
225 previousParent?: string;
226 nextParent?: string;
227}) => {
228 const parentChanged =
229 (input.previousParent ?? "") !== "" &&
230 (input.nextParent ?? "") !== "" &&
231 (input.previousParent ?? "") !== (input.nextParent ?? "");
232 const replace =
233 (input.extra ?? false) ||
234 parentChanged ||
235 (input.previousId !== undefined &&
236 input.nextId !== undefined &&
237 input.nextId !== input.previousId) ||
238 input.previousLocation !== input.nextLocation;
239 if (!replace) return undefined;
240 const samePhysical =
241 input.previousLocation === input.nextLocation &&
242 !parentChanged &&
243 input.previousId !== undefined &&
244 input.nextId === input.previousId;
245 return {
246 action: "replace" as const,
247 deleteFirst: samePhysical,
248 };
249};
250
251const markerOf = (
252 labels: Record<string, string>,
253 stack: string,
254 stage: string,
255 id: string,
256) =>
257 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
258
259const fitMarker = (labels: Record<string, string>, maxLength: number) => {
260 let stack = labels[alchemyLabelKeys.stack] ?? "x";
261 let stage = labels[alchemyLabelKeys.stage] ?? "x";
262 let id = labels[alchemyLabelKeys.id] ?? "x";
263 let marker = markerOf(labels, stack, stage, id);
264 while (
265 marker.length > maxLength &&
266 (stack.length > 1 || stage.length > 1 || id.length > 1)
267 ) {
268 if (stack.length >= stage.length && stack.length >= id.length) {
269 stack = stack.slice(0, -1);
270 } else if (stage.length >= id.length) {
271 stage = stage.slice(0, -1);
272 } else {
273 id = id.slice(0, -1);
274 }
275 marker = markerOf(labels, stack, stage, id);
276 }
277 return marker.length <= maxLength ? marker : marker.slice(0, maxLength);
278};
279
280export const encodeOwnership = (
281 labels: Record<string, string>,
282 text: string | undefined,
283 maxLength = MAX_DESCRIPTION_LENGTH,
284): string => {
285 const marker = fitMarker(labels, maxLength);
286 const trimmed = text?.trim();
287 if (!trimmed) return marker;
288 const combined = `${marker}\n${trimmed}`;
289 return combined.length <= maxLength ? combined : combined.slice(0, maxLength);
290};
291
292export const parseOwnership = (
293 text: string | undefined,
294): {
295 labels: Record<string, string>;
296 text: string | undefined;
297} => {
298 if (!text?.includes("[alchemy ")) {
299 return { labels: {}, text };
300 }
301 const start = text.indexOf("[alchemy ");
302 const end = text.indexOf("]", start);
303 if (end < 0) return { labels: {}, text };
304 const labels: Record<string, string> = {};
305 for (const part of text.slice(start + "[alchemy ".length, end).split(/\s+/)) {
306 const eq = part.indexOf("=");
307 if (eq > 0) {
308 labels[part.slice(0, eq)] = part.slice(eq + 1);
309 }
310 }
311 const before = text.slice(0, start).trim();
312 const after = text.slice(end + 1).replace(/^[\s\n]+/, "");
313 const rest = [before, after].filter((part) => part.length > 0).join("\n");
314 return { labels, text: rest.length > 0 ? rest : undefined };
315};
316
317export const hasOwnershipMarker = (text: string | undefined) =>
318 Object.keys(parseOwnership(text).labels).some((key) =>
319 key.startsWith("alchemy-"),
320 );
321
322const prefixMatch = (expected: string, observed: string) =>
323 expected === observed ||
324 expected.startsWith(observed) ||
325 observed.startsWith(expected);
326
327export const ownedByAlchemy = (id: string, text: string | undefined) =>
328 Effect.gen(function* () {
329 const expected = yield* createInternalLabels(id);
330 const { labels } = parseOwnership(text);
331 if (!hasOwnershipMarker(text)) return false;
332 const exact = yield* hasAlchemyLabels(id, labels);
333 if (exact) return true;
334 return (
335 prefixMatch(
336 expected[alchemyLabelKeys.stack] ?? "",
337 labels[alchemyLabelKeys.stack] ?? "",
338 ) &&
339 prefixMatch(
340 expected[alchemyLabelKeys.stage] ?? "",
341 labels[alchemyLabelKeys.stage] ?? "",
342 ) &&
343 prefixMatch(
344 expected[alchemyLabelKeys.id] ?? "",
345 labels[alchemyLabelKeys.id] ?? "",
346 )
347 );
348 });
349
350/** Wait for an operation through the shared GCP waiter. */
351export const waitForOperation = (
352 operation: apphub.Operation,
353 options?: { notFoundOk?: boolean },
354) =>
355 waitForGcpOperation(
356 operation,
357 (name) =>
358 apphub
359 .getProjectsLocationsOperations({ name })
360 .pipe(
361 Effect.catchTag("NotFound", (error) =>
362 options?.notFoundOk === true
363 ? Effect.succeed<apphub.Operation>({ name, done: true })
364 : Effect.fail(error),
365 ),
366 ),
367 { budget: "10 minutes" },
368 ).pipe(
369 // ALREADY_EXISTS (6): a concurrent create won the race. NOT_FOUND (5)
370 // is success for a delete.
371 Effect.catchIf(
372 (error) =>
373 error._tag === "GCP.OperationFailed" &&
374 (error.code === 6 ||
375 (options?.notFoundOk === true && error.code === 5)),
376 () => Effect.void,
377 ),
378 );
379
380export const waitUntilExists = <A, E, R>(
381 get: Effect.Effect<A | undefined, E, R>,
382 name: string,
383): Effect.Effect<NonNullable<A>, E | ResourceNotResolved, R> =>
384 get.pipe(
385 Effect.filterOrFail(
386 (value): value is NonNullable<A> => value !== undefined,
387 () => new ResourceNotResolved({ name }),
388 ),
389 Effect.retry({
390 while: (error) => error instanceof ResourceNotResolved,
391 times: 8,
392 schedule: Schedule.spaced("1 second"),
393 }),
394 );
395
396export const waitUntilGone = <A, E, R>(
397 get: Effect.Effect<A | undefined, E, R>,
398 name: string,
399): Effect.Effect<void, E | ResourceStillExists, R> =>
400 get.pipe(
401 Effect.flatMap((value) =>
402 value === undefined
403 ? Effect.void
404 : Effect.fail(new ResourceStillExists({ name })),
405 ),
406 Effect.retry({
407 while: (error) => error instanceof ResourceStillExists,
408 times: 10,
409 schedule: Schedule.spaced("2 seconds"),
410 }),
411 );
412
413const READY_STATES = new Set(["ACTIVE"]);
414const FAILED_STATES = new Set(["FAILED", "ERROR"]);
415
416export const waitUntilReady = <A, E, R>(
417 get: Effect.Effect<A | undefined, E, R>,
418 name: string,
419 stateOf: (value: NonNullable<A>) => string | undefined,
420 options?: {
421 times?: number;
422 interval?: `${number} seconds`;
423 },
424): Effect.Effect<
425 NonNullable<A>,
426 E | ResourceNotResolved | ResourceNotReady | ResourceFailed,
427 R
428> =>
429 get.pipe(
430 Effect.filterOrFail(
431 (value): value is NonNullable<A> => value !== undefined,
432 () => new ResourceNotResolved({ name }),
433 ),
434 Effect.filterOrFail(
435 (value) => !FAILED_STATES.has((stateOf(value) ?? "").toUpperCase()),
436 (value) => new ResourceFailed({ name, state: stateOf(value) ?? "" }),
437 ),
438 Effect.filterOrFail(
439 (value) => READY_STATES.has((stateOf(value) ?? "").toUpperCase()),
440 (value) => new ResourceNotReady({ name, state: stateOf(value) ?? "" }),
441 ),
442 Effect.retry({
443 while: (error) =>
444 error instanceof ResourceNotReady ||
445 error instanceof ResourceNotResolved,
446 times: options?.times ?? 10,
447 schedule: Schedule.spaced(options?.interval ?? "2 seconds"),
448 }),
449 );
450
451export const collectPages = <Page, A, E, R>(
452 pages: Stream.Stream<Page, E, R>,
453 items: (page: Page) => readonly A[] | undefined,
454) =>
455 pages.pipe(
456 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
457 Stream.runCollect,
458 Effect.map((chunk) => Array.from(chunk)),
459 );
460
461export const listOwnedPages = <Page, A, E, R>(
462 pages: Stream.Stream<Page, E, R>,
463 items: (page: Page) => readonly A[] | undefined,
464 textOf: (item: A) => string | undefined,
465) =>
466 pages.pipe(
467 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
468 Stream.filter((item) => hasOwnershipMarker(textOf(item))),
469 Stream.runCollect,
470 Effect.map((chunk) => Array.from(chunk)),
471 );
472
473/** List at the regional and global locations (App Hub's two scopes). */
474export const listAtLocations = <A, E, R>(
475 project: string,
476 region: string,
477 list: (parent: string) => Effect.Effect<A[], E, R>,
478): Effect.Effect<A[], E, R> =>
479 Effect.all(
480 [
481 list(`projects/${project}/locations/${region}`),
482 list(`projects/${project}/locations/${GLOBAL_LOCATION}`),
483 ],
484 { concurrency: 2 },
485 ).pipe(Effect.map(([regional, global]) => [...regional, ...global]));
486
487export const listAllApplications = (project: string, region: string) =>
488 listAtLocations(project, region, (parent) =>
489 collectPages(
490 apphub.listProjectsLocationsApplications.pages({
491 parent,
492 pageSize: 1000,
493 }),
494 (page) => page.applications,
495 ),
496 );
497
498export const listNestedOwned = <A, E, R>(
499 project: string,
500 region: string,
501 listChildren: (applicationName: string) => Effect.Effect<A[], E, R>,
502) =>
503 Effect.gen(function* () {
504 const apps = yield* listAllApplications(project, region);
505 const groups = yield* Effect.forEach(
506 apps,
507 (app) => {
508 const name = app.name ?? "";
509 if (name.length === 0) return Effect.succeed([] as A[]);
510 return listChildren(name);
511 },
512 { concurrency: 4 },
513 );
514 return groups.flat();
515 });
516
517export const resolveDiscoveredService = (
518 parent: string,
519 value: string,
520 project: string,
521 location: string,
522) =>
523 Effect.gen(function* () {
524 const expanded = expandDiscovered(
525 value,
526 project,
527 location,
528 "discoveredServices",
529 );
530 if (isDiscoveredName(expanded, "discoveredServices")) return expanded;
531 const looked = yield* apphub
532 .lookupProjectsLocationsDiscoveredServices({
533 parent,
534 uri: expanded,
535 })
536 .pipe(
537 Effect.catchTag("NotFound", () =>
538 Effect.succeed<apphub.LookupDiscoveredServiceResponse>({}),
539 ),
540 );
541 return looked.discoveredService?.name ?? expanded;
542 });
543
544export const resolveDiscoveredWorkload = (
545 parent: string,
546 value: string,
547 project: string,
548 location: string,
549) =>
550 Effect.gen(function* () {
551 const expanded = expandDiscovered(
552 value,
553 project,
554 location,
555 "discoveredWorkloads",
556 );
557 if (isDiscoveredName(expanded, "discoveredWorkloads")) return expanded;
558 const looked = yield* apphub
559 .lookupProjectsLocationsDiscoveredWorkloads({
560 parent,
561 uri: expanded,
562 })
563 .pipe(
564 Effect.catchTag("NotFound", () =>
565 Effect.succeed<apphub.LookupDiscoveredWorkloadResponse>({}),
566 ),
567 );
568 return looked.discoveredWorkload?.name ?? expanded;
569 });