1import * as ssm from "@distilled.cloud/gcp/securesourcemanager_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 {
10 alchemyLabelKeys,
11 hasAlchemyLabels,
12 stripInternalLabels,
13} from "../Labels.ts";
14import { waitForOperation as waitForLongRunningOperation } from "../Operation.ts";
15
16export const MAX_NAME_LENGTH = 63;
17export const PAGE_SIZE = 100;
18
19export class ResourceNotResolved extends Data.TaggedError(
20 "GCP.SecureSourceManager.ResourceNotResolved",
21)<{
22 name: string;
23}> {}
24
25export class ResourceStillExists extends Data.TaggedError(
26 "GCP.SecureSourceManager.ResourceStillExists",
27)<{
28 name: string;
29}> {}
30
31export const lastSegment = (value: string) => {
32 const trimmed = value.replace(/\/+$/, "");
33 const parts = trimmed.split("/");
34 return parts[parts.length - 1] || trimmed;
35};
36
37export const rfc1035 = (name: string, fallback = "ssm"): string => {
38 let next = name
39 .toLowerCase()
40 .replace(/[^a-z0-9-]/g, "-")
41 .replace(/-+/g, "-")
42 .replace(/^-+|-+$/g, "");
43 if (!/^[a-z]/.test(next)) next = `${fallback[0] ?? "s"}${next}`;
44 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
45 if (next.length === 0) next = fallback;
46 if (!/[a-z0-9]$/.test(next)) {
47 next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
48 }
49 return next.slice(0, MAX_NAME_LENGTH);
50};
51
52export const normalizeLocation = (
53 location: string | undefined,
54 defaultLocation: string,
55) => lastSegment(location ?? defaultLocation).toLowerCase();
56
57export const parseName = (
58 name: string,
59 collection: string,
60 defaultLocation: string,
61) => {
62 const parts = name.split("/").filter((part) => part.length > 0);
63 const collectionAt = parts.lastIndexOf(collection);
64 const locationsAt = parts.lastIndexOf("locations");
65 const projectsAt = parts.lastIndexOf("projects");
66 return {
67 project:
68 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
69 location:
70 locationsAt >= 0 && parts[locationsAt + 1]
71 ? parts[locationsAt + 1]!
72 : defaultLocation,
73 id:
74 collectionAt >= 0 && parts[collectionAt + 1]
75 ? parts[collectionAt + 1]!
76 : lastSegment(name),
77 parent:
78 collectionAt > 0
79 ? parts.slice(0, collectionAt).join("/")
80 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
81 };
82};
83
84export const expandName = (
85 value: string,
86 project: string,
87 location: string,
88 collection: string,
89) => {
90 const next = value.replace(/\/+$/, "");
91 if (next.includes("/")) return next;
92 return `projects/${project}/locations/${location}/${collection}/${next}`;
93};
94
95export const toPhysicalId = (
96 id: string,
97 explicit: string | undefined,
98 existing: string | undefined,
99 fallback = "ssm",
100) =>
101 Effect.gen(function* () {
102 if (explicit !== undefined) return rfc1035(explicit, fallback);
103 if (existing !== undefined) return existing;
104 return rfc1035(
105 yield* createPhysicalName({
106 id,
107 maxLength: MAX_NAME_LENGTH,
108 lowercase: true,
109 }),
110 fallback,
111 );
112 });
113
114export const userLabels = (
115 labels: Record<string, string | undefined> | null | undefined,
116): Record<string, string> => stripInternalLabels(tagRecord(labels));
117
118export const hasAlchemyLabelMap = (
119 labels: Record<string, string | undefined> | null | undefined,
120) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
121
122export const sameText = (left: string | undefined, right: string | undefined) =>
123 (left ?? "") === (right ?? "");
124
125export const canonical = (value: unknown): unknown => {
126 if (value === undefined || value === null) return undefined;
127 if (typeof value === "boolean" || typeof value === "number") return value;
128 if (typeof value === "string") {
129 return value.length === 0 ? undefined : value;
130 }
131 if (Array.isArray(value)) {
132 const items = value.map(canonical).filter((item) => item !== undefined);
133 return items.length === 0 ? undefined : items;
134 }
135 if (typeof value === "object") {
136 const entries = Object.entries(value as Record<string, unknown>)
137 .map(([key, item]) => [key, canonical(item)] as const)
138 .filter(([, item]) => item !== undefined)
139 .sort(([left], [right]) => left.localeCompare(right));
140 if (entries.length === 0) return undefined;
141 return Object.fromEntries(entries);
142 }
143 return undefined;
144};
145
146export const fingerprint = (value: unknown): string =>
147 JSON.stringify(canonical(value) ?? null);
148
149export const fieldMask = (fields: Array<string | false | undefined>) =>
150 fields
151 .filter((field): field is string => typeof field === "string")
152 .join(",");
153
154export const replaceOnIdentity = (input: {
155 previousId: string | undefined;
156 nextId: string | undefined;
157 previousLocation: string;
158 nextLocation: string;
159 extra?: boolean;
160 previousParent?: string;
161 nextParent?: string;
162}) => {
163 const parentChanged =
164 (input.previousParent ?? "") !== "" &&
165 (input.nextParent ?? "") !== "" &&
166 lastSegment(input.previousParent ?? "") !==
167 lastSegment(input.nextParent ?? "");
168 const replace =
169 (input.extra ?? false) ||
170 parentChanged ||
171 (input.previousId !== undefined &&
172 input.nextId !== undefined &&
173 input.nextId !== input.previousId) ||
174 input.previousLocation !== input.nextLocation;
175 if (!replace) return undefined;
176 const samePhysical =
177 input.previousLocation === input.nextLocation &&
178 !parentChanged &&
179 input.previousId !== undefined &&
180 input.nextId === input.previousId;
181 return {
182 action: "replace" as const,
183 deleteFirst: samePhysical,
184 };
185};
186
187const markerOf = (stack: string, stage: string, id: string) =>
188 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
189
190export const encodeOwnership = (
191 labels: Record<string, string>,
192 text: string | undefined,
193): string => {
194 const marker = markerOf(
195 labels[alchemyLabelKeys.stack] ?? "x",
196 labels[alchemyLabelKeys.stage] ?? "x",
197 labels[alchemyLabelKeys.id] ?? "x",
198 );
199 return text && text.length > 0 ? `${marker}\n${text}` : marker;
200};
201
202export const parseOwnership = (
203 text: string | undefined,
204): {
205 labels: Record<string, string>;
206 text: string | undefined;
207} => {
208 if (!text?.startsWith("[alchemy ")) {
209 return { labels: {}, text };
210 }
211 const end = text.indexOf("]");
212 if (end < 0) return { labels: {}, text };
213 const labels: Record<string, string> = {};
214 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
215 const eq = part.indexOf("=");
216 if (eq > 0) {
217 labels[part.slice(0, eq)] = part.slice(eq + 1);
218 }
219 }
220 const rest = text.slice(end + 1).replace(/^\n/, "");
221 return { labels, text: rest.length > 0 ? rest : undefined };
222};
223
224export const hasOwnershipMarker = (text: string | undefined) =>
225 Object.keys(parseOwnership(text).labels).some((key) =>
226 key.startsWith("alchemy-"),
227 );
228
229export const ownedByAlchemy = (id: string, text: string | undefined) =>
230 Effect.gen(function* () {
231 const { labels } = parseOwnership(text);
232 return yield* hasAlchemyLabels(id, labels);
233 });
234
235const ALCHEMY_QUERY_PREFIX = "alchemy-";
236
237export const stripAlchemyQuery = (uri: string): string => {
238 const hashIndex = uri.indexOf("#");
239 const hash = hashIndex >= 0 ? uri.slice(hashIndex) : "";
240 const withoutHash = hashIndex >= 0 ? uri.slice(0, hashIndex) : uri;
241 const queryIndex = withoutHash.indexOf("?");
242 if (queryIndex < 0) return `${withoutHash}${hash}`;
243 const base = withoutHash.slice(0, queryIndex);
244 const kept = withoutHash
245 .slice(queryIndex + 1)
246 .split("&")
247 .filter((part) => {
248 const key = decodeURIComponent(
249 (part.split("=")[0] ?? "").replace(/\+/g, " "),
250 );
251 return key.length > 0 && !key.startsWith(ALCHEMY_QUERY_PREFIX);
252 });
253 if (kept.length === 0) return `${base}${hash}`;
254 return `${base}?${kept.join("&")}${hash}`;
255};
256
257export const encodeTargetUri = (
258 uri: string,
259 labels: Record<string, string>,
260): string => {
261 const stripped = stripAlchemyQuery(uri);
262 const params = [
263 `${alchemyLabelKeys.stack}=${encodeURIComponent(labels[alchemyLabelKeys.stack] ?? "x")}`,
264 `${alchemyLabelKeys.stage}=${encodeURIComponent(labels[alchemyLabelKeys.stage] ?? "x")}`,
265 `${alchemyLabelKeys.id}=${encodeURIComponent(labels[alchemyLabelKeys.id] ?? "x")}`,
266 ].join("&");
267 const hashIndex = stripped.indexOf("#");
268 const hash = hashIndex >= 0 ? stripped.slice(hashIndex) : "";
269 const withoutHash = hashIndex >= 0 ? stripped.slice(0, hashIndex) : stripped;
270 return withoutHash.includes("?")
271 ? `${withoutHash}&${params}${hash}`
272 : `${withoutHash}?${params}${hash}`;
273};
274
275export const parseTargetUriOwnership = (
276 uri: string | undefined,
277): Record<string, string> => {
278 if (uri === undefined) return {};
279 const withoutHash = uri.split("#")[0] ?? uri;
280 const queryIndex = withoutHash.indexOf("?");
281 if (queryIndex < 0) return {};
282 const labels: Record<string, string> = {};
283 for (const part of withoutHash.slice(queryIndex + 1).split("&")) {
284 const eq = part.indexOf("=");
285 const key = decodeURIComponent(
286 (eq >= 0 ? part.slice(0, eq) : part).replace(/\+/g, " "),
287 );
288 if (!key.startsWith(ALCHEMY_QUERY_PREFIX)) continue;
289 const value = decodeURIComponent(
290 (eq >= 0 ? part.slice(eq + 1) : "").replace(/\+/g, " "),
291 );
292 labels[key] = value;
293 }
294 return labels;
295};
296
297export const hasTargetUriOwnership = (uri: string | undefined) =>
298 Object.keys(parseTargetUriOwnership(uri)).some((key) =>
299 key.startsWith(ALCHEMY_QUERY_PREFIX),
300 );
301
302export const nameFromOperation = (
303 operation: ssm.Operation,
304): string | undefined => {
305 const from = (value: unknown): string | undefined => {
306 if (value && typeof value === "object" && "name" in value) {
307 const name = (value as { name?: unknown }).name;
308 if (typeof name === "string" && name.length > 0) return name;
309 }
310 if (value && typeof value === "object" && "target" in value) {
311 const target = (value as { target?: unknown }).target;
312 if (typeof target === "string" && target.length > 0) return target;
313 }
314 return undefined;
315 };
316 return from(operation.response) ?? from(operation.metadata);
317};
318
319/**
320 * Wait on a Secure Source Manager long-running operation. Instance creation takes 20–40 minutes; repository children finish in seconds.
321 * `ALREADY_EXISTS` (a create race) counts as success; so does `NOT_FOUND`
322 * when `notFoundOk` (deletes). Returns the final operation.
323 */
324export const waitForOperation = (
325 operation: ssm.Operation,
326 options?: { notFoundOk?: boolean },
327) =>
328 Effect.suspend(() => {
329 let latest = operation;
330 return waitForLongRunningOperation(
331 operation,
332 (name) => {
333 const get = ssm.getProjectsLocationsOperations({ name }).pipe(
334 Effect.tap((current) =>
335 Effect.sync(() => {
336 latest = current;
337 }),
338 ),
339 );
340 const observe: Effect.Effect<
341 ssm.Operation,
342 ssm.GetProjectsLocationsOperationsError,
343 GcpOpContext
344 > =
345 options?.notFoundOk === true
346 ? get.pipe(
347 Effect.catchTag("NotFound", () =>
348 Effect.succeed<ssm.Operation>({ name, done: true }),
349 ),
350 )
351 : get.pipe(
352 // A just-returned operation can briefly 404 on read.
353 Effect.retry({
354 while: (error) => error._tag === "NotFound",
355 times: 5,
356 schedule: Schedule.exponential("250 millis"),
357 }),
358 );
359 return observe;
360 },
361 { budget: "45 minutes", interval: "10 seconds" },
362 ).pipe(
363 Effect.map(() => latest),
364 // google.rpc.Code ALREADY_EXISTS = 6, NOT_FOUND = 5.
365 Effect.catchTag("GCP.OperationFailed", (error) =>
366 error.code === 6 || (options?.notFoundOk === true && error.code === 5)
367 ? Effect.succeed(latest)
368 : Effect.fail(error),
369 ),
370 );
371 });
372
373export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
374 get: Effect.Effect<A | undefined, E, R>,
375 name: string,
376): Effect.Effect<NonNullable<A>, E | ResourceNotResolved, R> =>
377 get.pipe(
378 Effect.filterOrFail(
379 (value): value is NonNullable<A> => value != null,
380 () => new ResourceNotResolved({ name }),
381 ),
382 Effect.retry({
383 while: (error) =>
384 error._tag === "GCP.SecureSourceManager.ResourceNotResolved",
385 times: 8,
386 schedule: Schedule.spaced("1 second"),
387 }),
388 );
389
390export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
391 get: Effect.Effect<A | undefined, E, R>,
392 name: string,
393): Effect.Effect<void, E | ResourceStillExists, R> =>
394 get.pipe(
395 Effect.filterOrFail(
396 (value) => value === undefined,
397 () => new ResourceStillExists({ name }),
398 ),
399 Effect.retry({
400 while: (error) =>
401 error._tag === "GCP.SecureSourceManager.ResourceStillExists",
402 times: 10,
403 schedule: Schedule.spaced("2 seconds"),
404 }),
405 Effect.asVoid,
406 );
407
408export const collectPages = <Page, Item, E, R>(
409 pages: Stream.Stream<Page, E, R>,
410 items: (page: Page) => readonly Item[] | null | undefined,
411): Effect.Effect<Item[], E, R> =>
412 pages.pipe(
413 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
414 Stream.runFold(
415 (): Item[] => [],
416 (acc, item) => {
417 acc.push(item);
418 return acc;
419 },
420 ),
421 );
422
423const listInstancePages = (parent: string) =>
424 ssm.listProjectsLocationsInstances
425 .pages({ parent, pageSize: PAGE_SIZE })
426 .pipe(
427 Stream.flatMap((page) => Stream.fromIterable(page.instances ?? [])),
428 Stream.runFold(
429 (): ssm.Instance[] => [],
430 (acc, item) => {
431 acc.push(item);
432 return acc;
433 },
434 ),
435 );
436
437export const listInstances = (project: string, region: string) =>
438 listInstancePages(`projects/${project}/locations/${region}`);
439
440type ListRepositoriesError =
441 | ssm.ListProjectsLocationsInstancesError
442 | ssm.ListProjectsLocationsRepositoriesError;
443
444export const listRepositories = (
445 project: string,
446 region: string,
447): Effect.Effect<
448 Array<ssm.Repository & { name: string }>,
449 ListRepositoriesError,
450 ssm.GcpOpContext
451> =>
452 Effect.gen(function* () {
453 const instances = yield* listInstances(project, region);
454 const named = instances.filter(
455 (instance): instance is ssm.Instance & { name: string } =>
456 (instance.name ?? "").length > 0,
457 );
458 const pages = yield* Effect.forEach(
459 named,
460 (instance) => {
461 const location = parseName(instance.name, "instances", region).location;
462 return collectPages(
463 ssm.listProjectsLocationsRepositories.pages({
464 parent: `projects/${project}/locations/${location}`,
465 instance: instance.name,
466 pageSize: PAGE_SIZE,
467 }),
468 (page) => page.repositories,
469 );
470 },
471 { concurrency: 4 },
472 );
473 return pages
474 .flat()
475 .filter(
476 (repo): repo is ssm.Repository & { name: string } =>
477 (repo.name ?? "").length > 0,
478 );
479 });
480
481export const forEachRepository = <A, E, R>(
482 project: string,
483 region: string,
484 fn: (repository: string) => Effect.Effect<readonly A[], E, R>,
485): Effect.Effect<A[], E | ListRepositoriesError, R | ssm.GcpOpContext> =>
486 Effect.gen(function* () {
487 const repos = yield* listRepositories(project, region);
488 const pages = yield* Effect.forEach(repos, (repo) => fn(repo.name), {
489 concurrency: 4,
490 });
491 return pages.flat();
492 });
493
494export const catchMissing = <A, E extends { readonly _tag: string }, R>(
495 effect: Effect.Effect<A, E, R>,
496) =>
497 effect.pipe(
498 Effect.catchIf(
499 (error): error is E & { readonly _tag: "NotFound" } =>
500 error._tag === "NotFound",
501 () => Effect.succeed(undefined),
502 ),
503 );
504
505export const retryConflict = <A, E extends { readonly _tag: string }, R>(
506 effect: Effect.Effect<A, E, R>,
507) =>
508 effect.pipe(
509 Effect.retry({
510 while: (error) => error._tag === "Conflict",
511 times: 8,
512 schedule: Schedule.spaced("2 seconds"),
513 }),
514 );
515
516export const desiredAnnotations = (
517 annotations: Record<string, string> | undefined,
518 ownership: Record<string, string>,
519): Record<string, string> => ({
520 ...(annotations ?? {}),
521 ...ownership,
522});