1import * as GCP from "@/GCP";
2import { GcpEnvironment } from "@/GCP/Environment";
3import * as Test from "@/Test/Alchemy";
4import * as Core from "@/Test/Core";
5import * as artifactregistry from "@distilled.cloud/gcp/artifactregistry_v1";
6import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import { dockerAvailable, expectProbe } from "../bindingHost.ts";
11import ArtifactRegistryBindingsHost, {
12 Images,
13} from "./fixtures/bindings-host.ts";
14import { IMAGE, pushDockerVersion } from "./registry.ts";
15
16const testOptions = { providers: GCP.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "ArtifactRegistryBindings");
19
20let baseUrl: string;
21let hostAccount: string;
22let repository: {
23 name: string;
24 repositoryId: string;
25 project: string;
26 location: string;
27};
28
29/** Every project-level role (and its IAM Condition) `account` holds. */
30const projectGrantsOf = (account: string) =>
31 Effect.gen(function* () {
32 const { project } = yield* GcpEnvironment.current;
33 const policy = yield* resourcemanager.getIamPolicyProjects({
34 resource: `projects/${project}`,
35 body: { options: { requestedPolicyVersion: 3 } },
36 });
37 return (policy.bindings ?? [])
38 .filter((binding) =>
39 (binding.members ?? []).includes(`serviceAccount:${account}`),
40 )
41 .map((binding) => ({
42 role: binding.role,
43 condition: binding.condition?.expression,
44 }));
45 });
46
47describe.skipIf(!dockerAvailable)(
48 "ArtifactRegistry Bindings",
49 {
50 tags: [
51 "provider:gcp",
52 "provider:gcp:artifactregistry",
53 "provider:gcp:run",
54 "live",
55 ],
56 },
57 () => {
58 beforeAll(
59 Effect.gen(function* () {
60 yield* sharedStack.destroy();
61 const out = yield* sharedStack.deploy(
62 Effect.gen(function* () {
63 const host = yield* ArtifactRegistryBindingsHost;
64 const images = yield* Images;
65 return {
66 uri: host.uri,
67 serviceAccount: host.serviceAccount,
68 repository: {
69 name: images.name,
70 repositoryId: images.repositoryId,
71 project: images.project,
72 location: images.location,
73 },
74 };
75 }),
76 );
77 baseUrl = out.uri!;
78 hostAccount = out.serviceAccount!;
79 repository = out.repository;
80 }),
81 { timeout: 900_000 },
82 );
83
84 afterAll(sharedStack.destroy(), { timeout: 600_000 });
85
86 describe("ListDockerImages", () => {
87 test.provider(
88 "lists a pushed image as the host's service account, granted on the repository only",
89 (_stack) =>
90 Effect.gen(function* () {
91 const version = yield* pushDockerVersion(repository, "v1");
92 const digest = version.split("/versions/")[1]!;
93
94 const page = yield* expectProbe<{
95 dockerImages?: Array<{ name?: string; uri?: string }>;
96 }>(baseUrl, "listDockerImages").pipe(
97 // The pushed image is listed once the registry indexes it.
98 Effect.repeat({
99 schedule: Schedule.spaced("3 seconds"),
100 until: (result) => (result.dockerImages ?? []).length > 0,
101 times: 10,
102 }),
103 );
104 expect((page.dockerImages ?? []).map((image) => image.uri)).toEqual(
105 [
106 `${repository.location}-docker.pkg.dev/${repository.project}/${repository.repositoryId}/${IMAGE}@${digest}`,
107 ],
108 );
109
110 const policy =
111 yield* artifactregistry.getIamPolicyProjectsLocationsRepositories(
112 {
113 resource: repository.name,
114 "options.requestedPolicyVersion": 3,
115 },
116 );
117 const roles = (policy.bindings ?? [])
118 .filter((binding) =>
119 (binding.members ?? []).includes(
120 `serviceAccount:${hostAccount}`,
121 ),
122 )
123 .map((binding) => binding.role);
124 expect(roles).toEqual(["roles/artifactregistry.reader"]);
125 expect(yield* projectGrantsOf(hostAccount)).toEqual([]);
126 }),
127 {
128 tags: ["provider:gcp", "provider:gcp:artifactregistry", "live"],
129 timeout: 600_000,
130 },
131 );
132 });
133 },
134);