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 { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import { waitForDeleteOperation, waitForOperation } from "./internal.ts";
22
23const DEFAULT_FORMAT = "DOCKER";
24const DEFAULT_MODE = "STANDARD_REPOSITORY";
25const MAX_NAME_LENGTH = 63;
26
27export type RepositoryProps = {
28 /**
29 * Repository id (the `{repository}` segment of
30 * `projects/{project}/locations/{location}/repositories/{repository}`).
31 * If omitted, a unique name is generated from the stack, stage, and
32 * logical id. Must be 2-63 characters, start with a letter, and match
33 * `[a-z]([a-z0-9-]*[a-z0-9])?`. Immutable — changing it replaces the
34 * repository.
35 */
36 repositoryId?: string;
37 /**
38 * Artifact Registry location (`us-central1`, `us`, `europe`, …).
39 * Immutable — changing it replaces the repository. `US-CENTRAL1` is
40 * accepted and normalized to `us-central1`.
41 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
42 */
43 location?: string;
44 /**
45 * Package format (`DOCKER`, `MAVEN`, `NPM`, `PYTHON`, `APT`, `YUM`,
46 * `GO`, `GENERIC`, `KFP`, `RUBY`, `GOOGET`). Immutable — changing it
47 * replaces the repository.
48 * @default "DOCKER"
49 */
50 format?: artifactregistry.RepositoryFormatEnum | (string & {});
51 /**
52 * Repository mode. Immutable — changing it replaces the repository.
53 * @default "STANDARD_REPOSITORY"
54 */
55 mode?: artifactregistry.RepositoryModeEnum | (string & {});
56 /**
57 * User-provided description of the repository.
58 */
59 description?: string;
60 /**
61 * User labels. Alchemy ownership labels are merged in automatically.
62 */
63 labels?: Record<string, string>;
64 /**
65 * Cloud KMS key used to encrypt repository contents, as
66 * `projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`. Immutable —
67 * changing it replaces the repository.
68 */
69 kmsKeyName?: string;
70 /**
71 * Docker repository config. Only applied when `format` is `DOCKER`.
72 */
73 dockerConfig?: {
74 /**
75 * Prevent tags from being modified, moved, or deleted. Tags can still
76 * be created.
77 */
78 immutableTags?: boolean;
79 };
80 /**
81 * Maven repository config. Only applied when `format` is `MAVEN`.
82 * Immutable — changing it replaces the repository.
83 */
84 mavenConfig?: {
85 /** Allow republishing the same snapshot versions. */
86 allowSnapshotOverwrites?: boolean;
87 /** Versions the registry will accept (`RELEASE`, `SNAPSHOT`). */
88 versionPolicy?:
89 | artifactregistry.MavenRepositoryConfigVersionPolicyEnum
90 | (string & {});
91 };
92 /**
93 * Cleanup policies keyed by user-provided policy id.
94 */
95 cleanupPolicies?: artifactregistry.CleanupPolicyMap;
96 /**
97 * If true, the cleanup pipeline is prevented from deleting versions.
98 * @default false
99 */
100 cleanupPolicyDryRun?: boolean;
101};
102
103export type Repository = Resource<
104 "GCP.ArtifactRegistry.Repository",
105 RepositoryProps,
106 {
107 /** Full resource name `projects/{project}/locations/{location}/repositories/{repository}`. */
108 name: string;
109 /** Repository id (last path segment). */
110 repositoryId: string;
111 /** Project id. */
112 project: string;
113 /** Location id (`us-central1`, `us`, …). */
114 location: string;
115 /** Package format (`DOCKER`, `NPM`, …). */
116 format: string;
117 /** Repository mode (`STANDARD_REPOSITORY`, …). */
118 mode: string;
119 /** User-provided description. */
120 description: string | undefined;
121 /** User labels (Alchemy ownership labels stripped). */
122 labels: Record<string, string>;
123 /** Registry endpoint, e.g. `us-central1-docker.pkg.dev/proj/repo`. */
124 registryUri: string | undefined;
125 /** KMS key used for encryption, if any. */
126 kmsKeyName: string | undefined;
127 /** Whether Docker tags are immutable. */
128 immutableTags: boolean;
129 /** Whether cleanup policies run in dry-run mode. */
130 cleanupPolicyDryRun: boolean;
131 /** Server-reported size of stored artifacts, in bytes. */
132 sizeBytes: string | undefined;
133 /** RFC3339 creation timestamp. */
134 createTime: string | undefined;
135 /** RFC3339 last-update timestamp. */
136 updateTime: string | undefined;
137 },
138 never,
139 Providers
140>;
141
142/**
143 * An Artifact Registry repository for Docker images, language packages, or
144 * generic artifacts.
145 *
146 * Changing `repositoryId`, `location`, `format`, `mode`, `kmsKeyName`, or
147 * Maven version policy replaces the repository.
148 *
149 * ### Creating a Repository
150 * **Example:** Generated name
151 * ```typescript
152 * const images = yield* GCP.ArtifactRegistry.Repository("Images", {});
153 * ```
154 *
155 * **Example:** Explicit id, format, and labels
156 * ```typescript
157 * const images = yield* GCP.ArtifactRegistry.Repository("Images", {
158 * repositoryId: "app-images",
159 * location: "us-central1",
160 * format: "DOCKER",
161 * description: "container images",
162 * labels: { env: "prod" },
163 * dockerConfig: { immutableTags: true },
164 * });
165 * ```
166 *
167 * @resource
168 * @category ArtifactRegistry
169 */
170export const Repository = Resource<Repository>(
171 "GCP.ArtifactRegistry.Repository",
172);
173
174export class RepositoryNotResolved extends Data.TaggedError(
175 "GCP.ArtifactRegistry.RepositoryNotResolved",
176)<{
177 name: string;
178}> {}
179
180export class RepositoryStillExists extends Data.TaggedError(
181 "GCP.ArtifactRegistry.RepositoryStillExists",
182)<{
183 name: string;
184}> {}
185
186const lastSegment = (value: string) => {
187 const trimmed = value.replace(/\/+$/, "");
188 const parts = trimmed.split("/");
189 return parts[parts.length - 1] || trimmed;
190};
191
192const normalizeLocation = (location: string | undefined, fallback: string) =>
193 lastSegment(location ?? fallback).toLowerCase();
194
195const normalizeFormat = (format: string | undefined) =>
196 (format ?? DEFAULT_FORMAT).toUpperCase();
197
198const normalizeMode = (mode: string | undefined) => {
199 const value = (mode ?? DEFAULT_MODE).toUpperCase();
200 return value === "MODE_UNSPECIFIED" ? DEFAULT_MODE : value;
201};
202
203const resourceName = (
204 project: string,
205 location: string,
206 repositoryId: string,
207) => `projects/${project}/locations/${location}/repositories/${repositoryId}`;
208
209const parseName = (name: string, fallbackLocation: string) => {
210 const parts = name.split("/").filter((part) => part.length > 0);
211 const repositoriesAt = parts.lastIndexOf("repositories");
212 const locationsAt = parts.lastIndexOf("locations");
213 const projectsAt = parts.lastIndexOf("projects");
214 return {
215 project:
216 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
217 location:
218 locationsAt >= 0 && parts[locationsAt + 1]
219 ? parts[locationsAt + 1]!
220 : fallbackLocation,
221 repositoryId:
222 repositoriesAt >= 0 && parts[repositoriesAt + 1]
223 ? parts[repositoriesAt + 1]!
224 : lastSegment(name),
225 };
226};
227
228const userLabels = (
229 labels: Record<string, string | undefined> | null | undefined,
230): Record<string, string> => stripInternalLabels(tagRecord(labels));
231
232const toId = (
233 id: string,
234 repositoryId: string | undefined,
235 existing?: string,
236) =>
237 Effect.gen(function* () {
238 return (
239 repositoryId ??
240 existing ??
241 (yield* createPhysicalName({
242 id,
243 maxLength: MAX_NAME_LENGTH,
244 lowercase: true,
245 }))
246 );
247 });
248
249const toAttrs = (
250 repo: artifactregistry.Repository,
251 project: string,
252 region: string,
253) => {
254 const name = repo.name ?? "";
255 const parsed = parseName(name, region);
256 return {
257 name,
258 repositoryId: parsed.repositoryId,
259 project: parsed.project || project,
260 location: parsed.location,
261 format: normalizeFormat(repo.format),
262 mode: normalizeMode(repo.mode),
263 description: repo.description,
264 labels: userLabels(repo.labels),
265 registryUri: repo.registryUri,
266 kmsKeyName: repo.kmsKeyName,
267 immutableTags: repo.dockerConfig?.immutableTags === true,
268 cleanupPolicyDryRun: repo.cleanupPolicyDryRun === true,
269 sizeBytes: repo.sizeBytes,
270 createTime: repo.createTime,
271 updateTime: repo.updateTime,
272 };
273};
274
275const getByName = (name: string) =>
276 artifactregistry
277 .getProjectsLocationsRepositories({ name })
278 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
279
280const mavenKey = (config: RepositoryProps["mavenConfig"] | undefined) =>
281 `${config?.allowSnapshotOverwrites === true}:${(
282 config?.versionPolicy ?? "VERSION_POLICY_UNSPECIFIED"
283 ).toUpperCase()}`;
284
285const cleanupPoliciesJson = (
286 policies: artifactregistry.CleanupPolicyMap | undefined,
287) => JSON.stringify(policies ?? {});
288
289const waitUntilExists = (name: string) =>
290 getByName(name).pipe(
291 Effect.filterOrFail(
292 (repo): repo is artifactregistry.Repository => repo !== undefined,
293 () => new RepositoryNotResolved({ name }),
294 ),
295 Effect.retry({
296 while: (error) =>
297 error._tag === "GCP.ArtifactRegistry.RepositoryNotResolved",
298 times: 8,
299 schedule: Schedule.spaced("1 second"),
300 }),
301 );
302
303const waitUntilGone = (name: string) =>
304 getByName(name).pipe(
305 Effect.filterOrFail(
306 (repo) => repo === undefined,
307 () => new RepositoryStillExists({ name }),
308 ),
309 Effect.asVoid,
310 Effect.retry({
311 while: (error) =>
312 error._tag === "GCP.ArtifactRegistry.RepositoryStillExists",
313 times: 10,
314 schedule: Schedule.spaced("1 second"),
315 }),
316 );
317
318export const RepositoryProvider = () =>
319 Provider.succeed(Repository, {
320 stables: [
321 "name",
322 "repositoryId",
323 "project",
324 "location",
325 "format",
326 "mode",
327 "kmsKeyName",
328 "createTime",
329 ],
330
331 diff: Effect.fn(function* ({ news, olds, output }) {
332 if (!isResolved(news)) return undefined;
333 const env = yield* GcpEnvironment.current;
334
335 const previousId = olds?.repositoryId ?? output?.repositoryId;
336 const nextId = news.repositoryId ?? previousId;
337 const previousLocation = normalizeLocation(
338 olds?.location ?? output?.location,
339 env.region,
340 );
341 const nextLocation = normalizeLocation(
342 news.location ?? output?.location,
343 env.region,
344 );
345 const previousFormat = normalizeFormat(olds?.format ?? output?.format);
346 const nextFormat = normalizeFormat(news.format ?? output?.format);
347 const previousMode = normalizeMode(olds?.mode ?? output?.mode);
348 const nextMode = normalizeMode(news.mode ?? output?.mode);
349 const previousKms = olds?.kmsKeyName ?? output?.kmsKeyName ?? "";
350 const nextKms = news.kmsKeyName ?? previousKms;
351 const mavenChanged =
352 mavenKey(news.mavenConfig) !== mavenKey(olds?.mavenConfig);
353
354 const replace =
355 (previousId !== undefined &&
356 nextId !== undefined &&
357 nextId !== previousId) ||
358 previousLocation !== nextLocation ||
359 previousFormat !== nextFormat ||
360 previousMode !== nextMode ||
361 previousKms !== nextKms ||
362 mavenChanged;
363
364 if (!replace) return undefined;
365 return {
366 action: "replace" as const,
367 deleteFirst:
368 previousLocation === nextLocation &&
369 previousId !== undefined &&
370 nextId === previousId,
371 };
372 }),
373
374 read: Effect.fn(function* ({ id, olds, output }) {
375 const env = yield* GcpEnvironment.current;
376 const repositoryId = yield* toId(
377 id,
378 olds?.repositoryId,
379 output?.repositoryId,
380 );
381 const location = normalizeLocation(
382 olds?.location ?? output?.location,
383 env.region,
384 );
385 const name =
386 output?.name ?? resourceName(env.project, location, repositoryId);
387 const existing = yield* getByName(name);
388 if (existing === undefined) return undefined;
389 const attrs = toAttrs(existing, env.project, env.region);
390 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
391 ? attrs
392 : Unowned(attrs);
393 }),
394
395 list: () =>
396 Effect.gen(function* () {
397 const env = yield* GcpEnvironment.current;
398 const pages = yield* artifactregistry.listProjectsLocationsRepositories
399 .pages({
400 parent: `projects/${env.project}/locations/-`,
401 pageSize: 1000,
402 })
403 .pipe(Stream.runCollect);
404 return Array.from(pages).flatMap((page) =>
405 (page.repositories ?? [])
406 .filter((repo) =>
407 Object.keys(repo.labels ?? {}).some((key) =>
408 key.startsWith("alchemy-"),
409 ),
410 )
411 .map((repo) => toAttrs(repo, env.project, env.region)),
412 );
413 }),
414
415 reconcile: Effect.fn(function* ({ id, news, output }) {
416 const env = yield* GcpEnvironment.current;
417 const repositoryId = yield* toId(
418 id,
419 news.repositoryId,
420 output?.repositoryId,
421 );
422 const location = normalizeLocation(
423 news.location ?? output?.location,
424 env.region,
425 );
426 const format = normalizeFormat(news.format);
427 const mode = normalizeMode(news.mode);
428 const name = resourceName(env.project, location, repositoryId);
429 const desiredLabels = {
430 ...toLabels(news.labels),
431 ...(yield* createInternalLabels(id)),
432 };
433 const desiredImmutableTags = news.dockerConfig?.immutableTags === true;
434 const desiredCleanupDryRun = news.cleanupPolicyDryRun === true;
435 const desiredMaven = format === "MAVEN" ? news.mavenConfig : undefined;
436 const desiredDocker =
437 format === "DOCKER"
438 ? { immutableTags: desiredImmutableTags }
439 : undefined;
440
441 let current = yield* getByName(name);
442
443 if (current === undefined) {
444 const created = yield* artifactregistry
445 .createProjectsLocationsRepositories({
446 parent: `projects/${env.project}/locations/${location}`,
447 repositoryId,
448 body: {
449 format,
450 mode,
451 description: news.description,
452 labels: desiredLabels,
453 kmsKeyName: news.kmsKeyName,
454 dockerConfig: desiredDocker,
455 mavenConfig: desiredMaven,
456 cleanupPolicies: news.cleanupPolicies,
457 cleanupPolicyDryRun: desiredCleanupDryRun,
458 },
459 })
460 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
461 if (created !== undefined) {
462 yield* waitForOperation(created);
463 }
464 current = yield* waitUntilExists(name);
465 }
466
467 if (current === undefined) {
468 return yield* new RepositoryNotResolved({ name });
469 }
470
471 const observedLabels = tagRecord(current.labels);
472 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
473 const labelsChanged = upsert.length > 0 || removed.length > 0;
474 const descriptionChanged =
475 (current.description ?? "") !== (news.description ?? "");
476 const dockerChanged =
477 format === "DOCKER" &&
478 (current.dockerConfig?.immutableTags === true) !== desiredImmutableTags;
479 const cleanupDryRunChanged =
480 (current.cleanupPolicyDryRun === true) !== desiredCleanupDryRun;
481 const cleanupPoliciesChanged =
482 cleanupPoliciesJson(current.cleanupPolicies) !==
483 cleanupPoliciesJson(news.cleanupPolicies);
484
485 if (
486 labelsChanged ||
487 descriptionChanged ||
488 dockerChanged ||
489 cleanupDryRunChanged ||
490 cleanupPoliciesChanged
491 ) {
492 const updateMask = [
493 labelsChanged ? "labels" : undefined,
494 descriptionChanged ? "description" : undefined,
495 dockerChanged ? "dockerConfig" : undefined,
496 cleanupDryRunChanged ? "cleanupPolicyDryRun" : undefined,
497 cleanupPoliciesChanged ? "cleanupPolicies" : undefined,
498 ].filter((field): field is string => field !== undefined);
499
500 current = yield* artifactregistry.patchProjectsLocationsRepositories({
501 name,
502 updateMask: updateMask.join(","),
503 body: {
504 name,
505 labels: desiredLabels,
506 description: news.description,
507 dockerConfig: desiredDocker,
508 cleanupPolicyDryRun: desiredCleanupDryRun,
509 cleanupPolicies: news.cleanupPolicies ?? {},
510 },
511 });
512 }
513
514 return toAttrs(current, env.project, env.region);
515 }),
516
517 delete: Effect.fn(function* ({ output }) {
518 const operation = yield* artifactregistry
519 .deleteProjectsLocationsRepositories({ name: output.name })
520 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
521 if (operation !== undefined) {
522 yield* waitForDeleteOperation(operation);
523 }
524 yield* waitUntilGone(output.name);
525 }),
526 });