1import * as artifactregistry from "@distilled.cloud/gcp/artifactregistry_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 { tagRecord } from "../../Tags.ts";
8import {
9 alchemyLabelKeys,
10 hasAlchemyLabels,
11 stripInternalLabels,
12} from "../Labels.ts";
13import {
14 waitForOperation as waitForLongRunning,
15 type LongRunningOperation,
16} from "../Operation.ts";
17
18export const MAX_NAME_LENGTH = 63;
19export const PAGE_SIZE = 1000;
20
21export class ResourceNotResolved extends Data.TaggedError(
22 "GCP.ArtifactRegistry.ResourceNotResolved",
23)<{
24 name: string;
25}> {}
26
27export class ResourceStillExists extends Data.TaggedError(
28 "GCP.ArtifactRegistry.ResourceStillExists",
29)<{
30 name: string;
31}> {}
32
33export const lastSegment = (value: string) => {
34 const trimmed = value.replace(/\/+$/, "");
35 const parts = trimmed.split("/");
36 return parts[parts.length - 1] || trimmed;
37};
38
39export const rfc1035 = (name: string, fallback = "artifact"): string => {
40 let next = name
41 .toLowerCase()
42 .replace(/[^a-z0-9-]/g, "-")
43 .replace(/-+/g, "-")
44 .replace(/^-+|-+$/g, "");
45 if (!/^[a-z]/.test(next)) next = `${fallback[0] ?? "a"}${next}`;
46 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
47 if (next.length === 0) next = fallback;
48 if (!/[a-z0-9]$/.test(next)) {
49 next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
50 }
51 return next.slice(0, MAX_NAME_LENGTH);
52};
53
54export const normalizeLocation = (
55 location: string | undefined,
56 fallback: string,
57) => lastSegment(location ?? fallback).toLowerCase();
58
59export const parseName = (
60 name: string,
61 collection: string,
62 fallbackLocation: string,
63) => {
64 const parts = name.split("/").filter((part) => part.length > 0);
65 const collectionAt = parts.lastIndexOf(collection);
66 const locationsAt = parts.lastIndexOf("locations");
67 const projectsAt = parts.lastIndexOf("projects");
68 return {
69 project:
70 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
71 location:
72 locationsAt >= 0 && parts[locationsAt + 1]
73 ? parts[locationsAt + 1]!
74 : fallbackLocation,
75 id:
76 collectionAt >= 0 && parts[collectionAt + 1]
77 ? parts[collectionAt + 1]!
78 : lastSegment(name),
79 parent:
80 collectionAt > 0
81 ? parts.slice(0, collectionAt).join("/")
82 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
83 };
84};
85
86export const expandRepository = (
87 value: string,
88 project: string,
89 location: string,
90) => {
91 const next = value.replace(/\/+$/, "");
92 if (next.includes("/repositories/")) return next;
93 if (next.includes("/")) return next;
94 return `projects/${project}/locations/${location}/repositories/${next}`;
95};
96
97export const locationFromRepository = (
98 repository: string | undefined,
99 fallback: string,
100) => {
101 if (repository === undefined || !repository.includes("/locations/")) {
102 return fallback;
103 }
104 return parseName(repository, "repositories", fallback).location;
105};
106
107export const toPhysicalId = (
108 id: string,
109 explicit: string | undefined,
110 existing: string | undefined,
111 fallback = "artifact",
112) =>
113 Effect.gen(function* () {
114 if (explicit !== undefined) return explicit;
115 if (existing !== undefined) return existing;
116 return rfc1035(
117 yield* createPhysicalName({
118 id,
119 maxLength: MAX_NAME_LENGTH,
120 lowercase: true,
121 }),
122 fallback,
123 );
124 });
125
126export const userLabels = (
127 labels: Record<string, string | undefined> | null | undefined,
128): Record<string, string> => stripInternalLabels(tagRecord(labels));
129
130export const hasAlchemyLabelMap = (
131 labels: Record<string, string | undefined> | null | undefined,
132) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
133
134export const sameText = (left: string | undefined, right: string | undefined) =>
135 (left ?? "") === (right ?? "");
136
137export const fingerprint = (value: unknown): string =>
138 JSON.stringify(value ?? null);
139
140export const sameJson = (left: unknown, right: unknown) =>
141 fingerprint(left) === fingerprint(right);
142
143export const sortedStrings = (values: readonly string[] | undefined) =>
144 [...(values ?? [])].slice().sort();
145
146export const fieldMask = (fields: Array<string | false | undefined>) =>
147 fields
148 .filter((field): field is string => typeof field === "string")
149 .join(",");
150
151export const replaceOnIdentity = (input: {
152 previousId: string | undefined;
153 nextId: string | undefined;
154 previousLocation: string;
155 nextLocation: string;
156 extra?: boolean;
157 previousParent?: string;
158 nextParent?: string;
159}) => {
160 const parentChanged =
161 (input.previousParent ?? "") !== "" &&
162 (input.nextParent ?? "") !== "" &&
163 lastSegment(input.previousParent ?? "") !==
164 lastSegment(input.nextParent ?? "");
165 const replace =
166 (input.extra ?? false) ||
167 parentChanged ||
168 (input.previousId !== undefined &&
169 input.nextId !== undefined &&
170 input.nextId !== input.previousId) ||
171 input.previousLocation !== input.nextLocation;
172 if (!replace) return undefined;
173 const samePhysical =
174 input.previousLocation === input.nextLocation &&
175 !parentChanged &&
176 input.previousId !== undefined &&
177 input.nextId === input.previousId;
178 return {
179 action: "replace" as const,
180 deleteFirst: samePhysical,
181 };
182};
183
184const markerOf = (stack: string, stage: string, id: string) =>
185 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
186
187export const encodeOwnership = (
188 labels: Record<string, string>,
189 text: string | undefined,
190): string => {
191 const marker = markerOf(
192 labels[alchemyLabelKeys.stack] ?? "x",
193 labels[alchemyLabelKeys.stage] ?? "x",
194 labels[alchemyLabelKeys.id] ?? "x",
195 );
196 return text && text.length > 0 ? `${marker}\n${text}` : marker;
197};
198
199export const parseOwnership = (
200 text: string | undefined,
201): {
202 labels: Record<string, string>;
203 text: string | undefined;
204} => {
205 if (!text?.startsWith("[alchemy ")) {
206 return { labels: {}, text };
207 }
208 const end = text.indexOf("]");
209 if (end < 0) return { labels: {}, text };
210 const labels: Record<string, string> = {};
211 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
212 const eq = part.indexOf("=");
213 if (eq > 0) {
214 labels[part.slice(0, eq)] = part.slice(eq + 1);
215 }
216 }
217 const rest = text.slice(end + 1).replace(/^\n/, "");
218 return { labels, text: rest.length > 0 ? rest : undefined };
219};
220
221export const hasOwnershipMarker = (text: string | undefined) =>
222 Object.keys(parseOwnership(text).labels).some((key) =>
223 key.startsWith("alchemy-"),
224 );
225
226export const ownedByAlchemy = (id: string, text: string | undefined) =>
227 Effect.gen(function* () {
228 const { labels } = parseOwnership(text);
229 return yield* hasAlchemyLabels(id, labels);
230 });
231
232export const waitUntilExists = <A, E, R>(
233 get: Effect.Effect<A | undefined, E, R>,
234 name: string,
235) =>
236 get.pipe(
237 Effect.flatMap((value) =>
238 value
239 ? Effect.succeed(value)
240 : Effect.fail(new ResourceNotResolved({ name })),
241 ),
242 Effect.retry({
243 while: (error) => error instanceof ResourceNotResolved,
244 times: 8,
245 schedule: Schedule.spaced("1 second"),
246 }),
247 );
248
249export const waitUntilGone = <A, E, R>(
250 get: Effect.Effect<A | undefined, E, R>,
251 name: string,
252) =>
253 get.pipe(
254 Effect.flatMap((value) =>
255 value === undefined
256 ? Effect.void
257 : Effect.fail(new ResourceStillExists({ name })),
258 ),
259 Effect.retry({
260 while: (error) => error instanceof ResourceStillExists,
261 times: 10,
262 schedule: Schedule.spaced("1 second"),
263 }),
264 );
265
266const emptyList = <A>() => Effect.succeed<A[]>([]);
267
268export const collectPages = <
269 Page,
270 Item,
271 E extends { readonly _tag: string },
272 R,
273>(
274 pages: Stream.Stream<Page, E, R>,
275 items: (page: Page) => readonly Item[] | null | undefined,
276) =>
277 pages.pipe(
278 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
279 Stream.runCollect,
280 Effect.map((chunk) => Array.from(chunk) as Item[]),
281 Effect.catchIf(
282 (error): error is E & { readonly _tag: "NotFound" } =>
283 error._tag === "NotFound",
284 () => emptyList<Item>(),
285 ),
286 );
287
288export const listChildResources = <A, E, R>(
289 parents: readonly { name?: string }[],
290 list: (name: string) => Effect.Effect<A[], E, R>,
291) =>
292 Effect.forEach(
293 parents.filter((parent) => (parent.name ?? "").length > 0),
294 (parent) => list(parent.name!),
295 { concurrency: 4 },
296 ).pipe(Effect.map((groups) => groups.flat()));
297
298export const listAlchemyRepositories = (project: string) =>
299 collectPages(
300 artifactregistry.listProjectsLocationsRepositories.pages({
301 parent: `projects/${project}/locations/-`,
302 pageSize: PAGE_SIZE,
303 }),
304 (page) => page.repositories,
305 ).pipe(
306 Effect.map((repos) =>
307 repos.filter((repo) => hasAlchemyLabelMap(repo.labels)),
308 ),
309 );
310
311export const listPackages = (parent: string) =>
312 parent.length === 0
313 ? emptyList<artifactregistry.Package>()
314 : collectPages(
315 artifactregistry.listProjectsLocationsRepositoriesPackages.pages({
316 parent,
317 pageSize: PAGE_SIZE,
318 }),
319 (page) => page.packages,
320 );
321
322export const listTags = (parent: string) =>
323 parent.length === 0
324 ? emptyList<artifactregistry.Tag>()
325 : collectPages(
326 artifactregistry.listProjectsLocationsRepositoriesPackagesTags.pages({
327 parent,
328 pageSize: PAGE_SIZE,
329 }),
330 (page) => page.tags,
331 );
332
333export const listRules = (parent: string) =>
334 parent.length === 0
335 ? emptyList<artifactregistry.GoogleDevtoolsArtifactregistryV1Rule>()
336 : collectPages(
337 artifactregistry.listProjectsLocationsRepositoriesRules.pages({
338 parent,
339 pageSize: PAGE_SIZE,
340 }),
341 (page) => page.rules,
342 );
343
344export const listAttachments = (parent: string) =>
345 parent.length === 0
346 ? emptyList<artifactregistry.Attachment>()
347 : collectPages(
348 artifactregistry.listProjectsLocationsRepositoriesAttachments.pages({
349 parent,
350 pageSize: PAGE_SIZE,
351 }),
352 (page) => page.attachments,
353 );
354
355export const missingGet =
356 <A, E extends { readonly _tag: string }, R>(
357 effect: (input: { name: string }) => Effect.Effect<A, E, R>,
358 ) =>
359 (name: string) =>
360 name.length === 0
361 ? Effect.succeed(undefined)
362 : effect({ name }).pipe(
363 Effect.catchIf(
364 (error): error is E & { readonly _tag: "NotFound" } =>
365 error._tag === "NotFound",
366 () => Effect.succeed(undefined),
367 ),
368 );
369
370/** Repository and package operations finish within a few minutes. */
371const OPERATION_BUDGET = "10 minutes";
372
373/**
374 * Wait for a long-running operation. ALREADY_EXISTS (code 6) means a
375 * concurrent create won the race; reconcile observes the resource next.
376 */
377export const waitForOperation = (operation: LongRunningOperation) =>
378 waitForLongRunning(
379 operation,
380 (name) => artifactregistry.getProjectsLocationsOperations({ name }),
381 {
382 budget: OPERATION_BUDGET,
383 },
384 ).pipe(
385 Effect.catchIf(
386 (error) => error._tag === "GCP.OperationFailed" && error.code === 6,
387 () => Effect.succeed(operation),
388 ),
389 );
390
391/**
392 * Wait for a delete operation. A vanished operation or NOT_FOUND (code 5)
393 * means the resource is already gone.
394 */
395export const waitForDeleteOperation = (operation: LongRunningOperation) =>
396 waitForOperation(operation).pipe(
397 Effect.catchIf(
398 (error) =>
399 error._tag === "NotFound" ||
400 (error._tag === "GCP.OperationFailed" && error.code === 5),
401 () => Effect.succeed(operation),
402 ),
403 );