1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import { Credentials } from "@distilled.cloud/gcp/Credentials";
4import * as artifactregistry from "@distilled.cloud/gcp/artifactregistry_v1";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Redacted from "effect/Redacted";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12
13const { test } = Test.make({ providers: GCP.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const PACKAGE_ID = "sbom";
21const VERSION_ID = "1.0.0";
22const FILENAME = "sbom.json";
23const BODY = new TextEncoder().encode('{"spdxVersion":"SPDX-2.3"}');
24
25const waitUntilGone = (name: string) =>
26 artifactregistry.getProjectsLocationsRepositoriesAttachments({ name }).pipe(
27 Effect.as("found" as const),
28 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
29 Effect.repeat({
30 schedule: Schedule.spaced("1 second"),
31 until: (status) => status === "gone",
32 times: 10,
33 }),
34 );
35
36const waitForFile = (name: string) =>
37 artifactregistry.getProjectsLocationsRepositoriesFiles({ name }).pipe(
38 Effect.as("found" as const),
39 Effect.catchTag("NotFound", () => Effect.succeed("missing" as const)),
40 Effect.repeat({
41 schedule: Schedule.spaced("1 second"),
42 until: (status) => status === "found",
43 times: 10,
44 }),
45 );
46
47const uploadFile = (repositoryName: string) =>
48 Effect.gen(function* () {
49 const creds = yield* yield* Credentials;
50 const client = yield* HttpClient.HttpClient;
51 const url =
52 `https://artifactregistry.googleapis.com/upload/v1/${repositoryName}/genericArtifacts:create` +
53 `?uploadType=media&filename=${FILENAME}&packageId=${PACKAGE_ID}&versionId=${VERSION_ID}`;
54 const response = yield* client.execute(
55 HttpClientRequest.post(url).pipe(
56 HttpClientRequest.setHeader(
57 "Authorization",
58 `Bearer ${Redacted.value(creds.accessToken)}`,
59 ),
60 HttpClientRequest.bodyUint8Array(BODY, "application/json"),
61 ),
62 );
63 if (response.status === 409) return;
64 if (response.status < 200 || response.status >= 300) {
65 const body = yield* response.text.pipe(
66 Effect.catch(() => Effect.succeed("")),
67 );
68 return yield* Effect.fail(
69 new Error(`generic artifact upload failed: ${response.status} ${body}`),
70 );
71 }
72 }).pipe(
73 Effect.retry({
74 times: 5,
75 schedule: Schedule.spaced("2 seconds"),
76 }),
77 );
78
79test.provider.skipIf(!!process.env.FAST)(
80 "create, replace, and delete a repository attachment",
81 (stack) =>
82 Effect.gen(function* () {
83 yield* stack.destroy();
84
85 const repo = yield* stack.deploy(
86 Effect.gen(function* () {
87 return yield* GCP.ArtifactRegistry.Repository("Generic", {
88 location: "us-central1",
89 format: "GENERIC",
90 description: "attachment parent",
91 });
92 }),
93 );
94
95 yield* uploadFile(repo.name);
96 const fileName = `${repo.name}/files/${PACKAGE_ID}:${VERSION_ID}:${FILENAME}`;
97 const versionName = `${repo.name}/packages/${PACKAGE_ID}/versions/${VERSION_ID}`;
98 const file = yield* waitForFile(fileName);
99 expect(file).toEqual("found");
100
101 const created = yield* stack.deploy(
102 Effect.gen(function* () {
103 const artifacts = yield* GCP.ArtifactRegistry.Repository("Generic", {
104 repositoryId: repo.repositoryId,
105 location: "us-central1",
106 format: "GENERIC",
107 description: "attachment parent",
108 });
109 const attachment = yield* GCP.ArtifactRegistry.RepositoriesAttachment(
110 "Sbom",
111 {
112 repository: artifacts.name,
113 target: versionName,
114 type: "application/spdx+json",
115 files: [fileName],
116 annotations: { env: "test" },
117 },
118 );
119 return { artifacts, attachment };
120 }),
121 );
122
123 expect(created.attachment.name).toContain("/attachments/");
124 expect(created.attachment.repository).toEqual(created.artifacts.name);
125 expect(created.attachment.target).toEqual(versionName);
126 expect(created.attachment.type).toEqual("application/spdx+json");
127 expect(created.attachment.annotations).toMatchObject({ env: "test" });
128 expect(created.attachment.files.length).toBeGreaterThan(0);
129
130 const fetched =
131 yield* artifactregistry.getProjectsLocationsRepositoriesAttachments({
132 name: created.attachment.name,
133 });
134 expect(fetched.name).toEqual(created.attachment.name);
135 expect(fetched.annotations?.env).toEqual("test");
136 expect(fetched.annotations?.["alchemy-id"]).toEqual(expect.any(String));
137
138 const updated = yield* stack.deploy(
139 Effect.gen(function* () {
140 const artifacts = yield* GCP.ArtifactRegistry.Repository("Generic", {
141 repositoryId: repo.repositoryId,
142 location: "us-central1",
143 format: "GENERIC",
144 description: "attachment parent",
145 });
146 const attachment = yield* GCP.ArtifactRegistry.RepositoriesAttachment(
147 "Sbom",
148 {
149 repository: artifacts.name,
150 attachmentId: created.attachment.attachmentId,
151 target: versionName,
152 type: "application/spdx+json",
153 files: [fileName],
154 annotations: { env: "prod", role: "sbom" },
155 },
156 );
157 return { artifacts, attachment };
158 }),
159 );
160
161 expect(updated.attachment.annotations).toMatchObject({
162 env: "prod",
163 role: "sbom",
164 });
165
166 const refetched =
167 yield* artifactregistry.getProjectsLocationsRepositoriesAttachments({
168 name: updated.attachment.name,
169 });
170 expect(refetched.annotations?.env).toEqual("prod");
171 expect(refetched.annotations?.role).toEqual("sbom");
172
173 yield* stack.destroy();
174
175 const gone = yield* waitUntilGone(updated.attachment.name);
176 expect(gone).toEqual("gone");
177 }).pipe(logLevel),
178 {
179 tags: ["provider:gcp", "provider:gcp:artifactregistry", "live"],
180 timeout: 180_000,
181 },
182);