1import * as artifactregistry from "@distilled.cloud/gcp/artifactregistry_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { tagRecord } from "../../Tags.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import {
10 createInternalLabels,
11 hasAlchemyLabels,
12 stripInternalLabels,
13} from "../Labels.ts";
14import type { Providers } from "../Providers.ts";
15import {
16 ResourceNotResolved,
17 expandRepository,
18 hasAlchemyLabelMap,
19 listAlchemyRepositories,
20 listAttachments,
21 listChildResources,
22 locationFromRepository,
23 missingGet,
24 normalizeLocation,
25 parseName,
26 replaceOnIdentity,
27 sameJson,
28 sameText,
29 sortedStrings,
30 toPhysicalId,
31 waitForDeleteOperation,
32 waitForOperation,
33 waitUntilExists,
34 waitUntilGone,
35} from "./internal.ts";
36
37const DEFAULT_TYPE = "application/json";
38
39export type RepositoriesAttachmentProps = {
40 /**
41 * Parent repository. Full name
42 * `projects/{project}/locations/{location}/repositories/{repository}`
43 * or the repository id (combined with `location`). Immutable —
44 * changing it replaces the attachment.
45 */
46 repository: string;
47 /**
48 * Region used when `repository` is a bare id.
49 * @default the repository's location, else the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
50 */
51 location?: string;
52 /**
53 * Attachment id (the `{attachment}` segment of
54 * `.../repositories/{repository}/attachments/{attachment}`). If omitted,
55 * a unique name is generated. Immutable — changing it replaces the
56 * attachment.
57 */
58 attachmentId?: string;
59 /**
60 * Target the attachment is for — a Version, Package, or Repository
61 * resource name. Bare ids are resolved under `repository`. Immutable —
62 * changing it replaces the attachment.
63 */
64 target: string;
65 /**
66 * Files that belong to this attachment, as File resource names or file
67 * ids under `repository`. Immutable — changing them replaces the
68 * attachment.
69 */
70 files: string[];
71 /**
72 * Attachment type, for example `application/vnd.spdx+json`.
73 * @default "application/json"
74 */
75 type?: string;
76 /**
77 * Namespace this attachment belongs to, for example
78 * `artifactanalysis.googleapis.com`.
79 */
80 attachmentNamespace?: string;
81 /**
82 * User annotations. Alchemy ownership keys (`alchemy-stack`,
83 * `alchemy-stage`, `alchemy-id`) are merged in automatically.
84 * Attachments have no labels field and no update API — changing
85 * annotations replaces the attachment.
86 */
87 annotations?: Record<string, string>;
88};
89
90export type RepositoriesAttachment = Resource<
91 "GCP.ArtifactRegistry.RepositoriesAttachment",
92 RepositoriesAttachmentProps,
93 {
94 /** Full resource name `projects/{project}/locations/{location}/repositories/{repository}/attachments/{attachment}`. */
95 name: string;
96 /** Attachment id (last path segment). */
97 attachmentId: string;
98 /** Parent repository resource name. */
99 repository: string;
100 /** Project id. */
101 project: string;
102 /** Location id (`us-central1`). */
103 location: string;
104 /** Target resource name. */
105 target: string | undefined;
106 /** File resource names on this attachment. */
107 files: string[];
108 /** Attachment type. */
109 type: string | undefined;
110 /** Attachment namespace. */
111 attachmentNamespace: string | undefined;
112 /** User annotations (Alchemy ownership keys stripped). */
113 annotations: Record<string, string>;
114 /** OCI version created for Docker attachments, if any. */
115 ociVersionName: string | undefined;
116 /** RFC3339 creation timestamp. */
117 createTime: string | undefined;
118 /** RFC3339 last-update timestamp. */
119 updateTime: string | undefined;
120 },
121 never,
122 Providers
123>;
124
125/**
126 * Artifact Registry metadata attached to a repository, package, or
127 * version. An attachment points at one or more files already stored in
128 * the repository.
129 *
130 * Attachments have no update API — any change replaces the resource.
131 * Alchemy stamps ownership into `annotations` so `list` / nuke can find
132 * them.
133 *
134 * ### Creating a RepositoriesAttachment
135 * **Example:** Attach an SBOM file to a repository
136 * ```typescript
137 * const attachment = yield* GCP.ArtifactRegistry.RepositoriesAttachment(
138 * "Sbom",
139 * {
140 * repository: artifacts.name,
141 * target: artifacts.name,
142 * type: "application/spdx+json",
143 * files: [sbomFileName],
144 * annotations: { env: "prod" },
145 * },
146 * );
147 * ```
148 *
149 * @resource
150 * @category ArtifactRegistry
151 */
152export const RepositoriesAttachment = Resource<RepositoriesAttachment>(
153 "GCP.ArtifactRegistry.RepositoriesAttachment",
154);
155
156const expandTarget = (repository: string, target: string) => {
157 const next = target.replace(/\/+$/, "");
158 if (next.includes("/")) return next;
159 return `${repository}/packages/${next}`;
160};
161
162const expandFile = (repository: string, file: string) => {
163 const next = file.replace(/\/+$/, "");
164 if (next.includes("/files/")) return next;
165 return `${repository}/files/${next}`;
166};
167
168const resourceNameOf = (repository: string, attachmentId: string) =>
169 `${repository}/attachments/${attachmentId}`;
170
171const userAnnotations = (
172 annotations: Record<string, string | undefined> | null | undefined,
173): Record<string, string> => stripInternalLabels(tagRecord(annotations));
174
175const toAttrs = (
176 attachment: artifactregistry.Attachment,
177 project: string,
178 region: string,
179) => {
180 const name = attachment.name ?? "";
181 const parsed = parseName(name, "attachments", region);
182 return {
183 name,
184 attachmentId: parsed.id,
185 repository: parsed.parent,
186 project: parsed.project || project,
187 location: parsed.location,
188 target: attachment.target,
189 files: [...(attachment.files ?? [])],
190 type: attachment.type,
191 attachmentNamespace: attachment.attachmentNamespace,
192 annotations: userAnnotations(attachment.annotations),
193 ociVersionName: attachment.ociVersionName,
194 createTime: attachment.createTime,
195 updateTime: attachment.updateTime,
196 };
197};
198
199const getByName = missingGet(
200 artifactregistry.getProjectsLocationsRepositoriesAttachments,
201);
202
203export const RepositoriesAttachmentProvider = () =>
204 Provider.succeed(RepositoriesAttachment, {
205 stables: [
206 "name",
207 "attachmentId",
208 "repository",
209 "project",
210 "location",
211 "createTime",
212 ],
213
214 diff: Effect.fn(function* ({ news, olds, output }) {
215 if (!isResolved(news)) return undefined;
216 const env = yield* GcpEnvironment.current;
217 const previousId = olds?.attachmentId ?? output?.attachmentId;
218 const nextId = news.attachmentId ?? previousId;
219 const previousLocation = normalizeLocation(
220 olds?.location ?? output?.location,
221 env.region,
222 );
223 const nextLocation = normalizeLocation(
224 news.location ??
225 locationFromRepository(news.repository, previousLocation),
226 env.region,
227 );
228 const extra =
229 !sameText(olds?.target ?? output?.target, news.target) ||
230 JSON.stringify(sortedStrings(olds?.files ?? output?.files)) !==
231 JSON.stringify(sortedStrings(news.files)) ||
232 !sameText(olds?.type ?? output?.type, news.type ?? DEFAULT_TYPE) ||
233 !sameText(
234 olds?.attachmentNamespace ?? output?.attachmentNamespace,
235 news.attachmentNamespace,
236 ) ||
237 !sameJson(
238 olds?.annotations ?? output?.annotations ?? {},
239 news.annotations ?? {},
240 );
241 return replaceOnIdentity({
242 previousId,
243 nextId,
244 previousLocation,
245 nextLocation,
246 previousParent: olds?.repository ?? output?.repository,
247 nextParent: news.repository,
248 extra,
249 });
250 }),
251
252 read: Effect.fn(function* ({ id, olds, output }) {
253 const env = yield* GcpEnvironment.current;
254 const location = normalizeLocation(
255 olds?.location ??
256 output?.location ??
257 locationFromRepository(
258 olds?.repository ?? output?.repository,
259 env.region,
260 ),
261 env.region,
262 );
263 const repository = expandRepository(
264 olds?.repository ?? output?.repository ?? "",
265 env.project,
266 location,
267 );
268 const attachmentId = yield* toPhysicalId(
269 id,
270 olds?.attachmentId,
271 output?.attachmentId,
272 "attachment",
273 );
274 const name = output?.name ?? resourceNameOf(repository, attachmentId);
275 const existing = yield* getByName(name);
276 if (existing === undefined) return undefined;
277 const attrs = toAttrs(existing, env.project, env.region);
278 return (yield* hasAlchemyLabels(id, tagRecord(existing.annotations)))
279 ? attrs
280 : Unowned(attrs);
281 }),
282
283 list: () =>
284 Effect.gen(function* () {
285 const env = yield* GcpEnvironment.current;
286 const repos = yield* listAlchemyRepositories(env.project);
287 const attachments = yield* listChildResources(repos, listAttachments);
288 return attachments
289 .filter((attachment) => hasAlchemyLabelMap(attachment.annotations))
290 .map((attachment) => toAttrs(attachment, env.project, env.region));
291 }),
292
293 reconcile: Effect.fn(function* ({ id, news, output }) {
294 const env = yield* GcpEnvironment.current;
295 const location = normalizeLocation(
296 news.location ??
297 output?.location ??
298 locationFromRepository(news.repository, env.region),
299 env.region,
300 );
301 const repository = expandRepository(
302 news.repository,
303 env.project,
304 location,
305 );
306 const attachmentId = yield* toPhysicalId(
307 id,
308 news.attachmentId,
309 output?.attachmentId,
310 "attachment",
311 );
312 const name = resourceNameOf(repository, attachmentId);
313 const target = expandTarget(repository, news.target);
314 const files = news.files.map((file) => expandFile(repository, file));
315 const type = news.type ?? DEFAULT_TYPE;
316 const attachmentNamespace = news.attachmentNamespace;
317 const annotations = {
318 ...tagRecord(news.annotations),
319 ...(yield* createInternalLabels(id)),
320 };
321
322 let current = yield* getByName(name);
323
324 if (current === undefined) {
325 const created = yield* artifactregistry
326 .createProjectsLocationsRepositoriesAttachments({
327 parent: repository,
328 attachmentId,
329 body: {
330 target,
331 files,
332 type,
333 attachmentNamespace,
334 annotations,
335 },
336 })
337 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
338 if (created !== undefined) {
339 yield* waitForOperation(created);
340 }
341 current = yield* waitUntilExists(getByName(name), name);
342 }
343
344 if (current === undefined) {
345 return yield* new ResourceNotResolved({ name });
346 }
347
348 return toAttrs(current, env.project, env.region);
349 }),
350
351 delete: Effect.fn(function* ({ output }) {
352 const operation = yield* artifactregistry
353 .deleteProjectsLocationsRepositoriesAttachments({
354 name: output.name,
355 })
356 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
357 if (operation !== undefined) {
358 yield* waitForDeleteOperation(operation);
359 }
360 yield* waitUntilGone(getByName(output.name), output.name);
361 }),
362 });