1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
4import * as pubsub from "@distilled.cloud/gcp/pubsub_v1";
5import * as storage from "@distilled.cloud/gcp/storage_v1";
6import * as iam from "@distilled.cloud/gcp/unstable/iam_v1";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10
11/**
12 * Host IAM plumbing without a container build: image-only hosts whose
13 * Effect declares bindings. The bindings' IAM and env land on the host's
14 * runtime service account even though the image is not Alchemy-built,
15 * so this pins grant scoping, concurrent policy writes, revocation, and
16 * account cleanup on any machine with GCP credentials.
17 */
18
19const { test } = Test.make({ providers: GCP.providers() });
20
21const logLevel = Effect.provideService(
22 MinimumLogLevel,
23 process.env.DEBUG ? "Debug" : "Info",
24);
25
26const HELLO_IMAGE = "us-docker.pkg.dev/cloudrun/container/hello";
27
28const Events = GCP.PubSub.Topic("IamEvents", {});
29const Blobs = GCP.Storage.Bucket("IamBlobs", { forceDestroy: true });
30
31class IamService extends GCP.Function<IamService>()(
32 "IamService",
33 {
34 location: "us-central1",
35 template: { containers: [{ image: HELLO_IMAGE }] },
36 },
37 Effect.gen(function* () {
38 yield* GCP.PubSub.Publish(Events);
39 yield* GCP.Storage.GetObject(Blobs);
40 return {};
41 }).pipe(
42 Effect.provide(GCP.PubSub.PublishHttp),
43 Effect.provide(GCP.Storage.GetObjectHttp),
44 ),
45) {}
46
47class IamServicePublishOnly extends GCP.Function<IamServicePublishOnly>()(
48 "IamService",
49 {
50 location: "us-central1",
51 template: { containers: [{ image: HELLO_IMAGE }] },
52 },
53 Effect.gen(function* () {
54 yield* GCP.PubSub.Publish(Events);
55 yield* Blobs;
56 return {};
57 }).pipe(Effect.provide(GCP.PubSub.PublishHttp)),
58) {}
59
60// A second host granting on the same topic in the same deploy: both write
61// the topic's IAM policy concurrently and must both land.
62class IamJob extends GCP.Run.Job<IamJob>()(
63 "IamJob",
64 {
65 location: "us-central1",
66 containers: [{ image: HELLO_IMAGE }],
67 },
68 Effect.gen(function* () {
69 yield* GCP.PubSub.Publish(Events);
70 return {};
71 }).pipe(Effect.provide(GCP.PubSub.PublishHttp)),
72) {}
73
74const membersOf = (
75 policy: {
76 bindings?: ReadonlyArray<{
77 role?: string;
78 members?: ReadonlyArray<string>;
79 }>;
80 },
81 member: string,
82) =>
83 [
84 ...new Set(
85 (policy.bindings ?? [])
86 .filter((binding) => (binding.members ?? []).includes(member))
87 .flatMap((binding) => (binding.role ? [binding.role] : [])),
88 ),
89 ].sort();
90
91test.provider(
92 "bindings grant on the bound resource, converge concurrently, and revoke on removal",
93 (stack) =>
94 Effect.gen(function* () {
95 yield* stack.destroy();
96
97 const deploy = (host: typeof IamService | typeof IamServicePublishOnly) =>
98 stack.deploy(
99 Effect.gen(function* () {
100 const service = yield* host;
101 const job = yield* IamJob;
102 const topic = yield* Events;
103 const bucket = yield* Blobs;
104 return {
105 project: service.project,
106 serviceAccount: service.serviceAccount,
107 managed: service.managedServiceAccount,
108 grants: service.iamGrants,
109 jobAccount: job.serviceAccount,
110 topic: topic.name,
111 bucket: bucket.bucketName,
112 };
113 }),
114 );
115
116 const out = yield* deploy(IamService);
117 expect(out.managed).toEqual(true);
118 expect(out.serviceAccount).toMatch(/^alch-/);
119 expect(out.jobAccount).toMatch(/^alch-/);
120 expect(out.jobAccount).not.toEqual(out.serviceAccount);
121 const service = `serviceAccount:${out.serviceAccount}`;
122 const job = `serviceAccount:${out.jobAccount}`;
123
124 expect(
125 membersOf(
126 yield* resourcemanager.getIamPolicyProjects({
127 resource: `projects/${out.project}`,
128 }),
129 service,
130 ),
131 ).toEqual([]);
132 const topicPolicy = yield* pubsub.getIamPolicyProjectsTopics({
133 resource: out.topic,
134 });
135 expect(membersOf(topicPolicy, service)).toEqual([
136 "roles/pubsub.publisher",
137 ]);
138 expect(membersOf(topicPolicy, job)).toEqual(["roles/pubsub.publisher"]);
139 expect(
140 membersOf(
141 yield* storage.getIamPolicyBuckets({ bucket: out.bucket }),
142 service,
143 ),
144 ).toEqual(["roles/storage.objectViewer"]);
145 expect(out.grants.map((grant) => grant.kind).sort()).toEqual([
146 "pubsub.topic",
147 "storage.bucket",
148 ]);
149
150 const next = yield* deploy(IamServicePublishOnly);
151 expect(next.serviceAccount).toEqual(out.serviceAccount);
152 expect(next.grants.map((grant) => grant.kind)).toEqual(["pubsub.topic"]);
153 expect(
154 membersOf(
155 yield* storage.getIamPolicyBuckets({ bucket: out.bucket }),
156 service,
157 ),
158 ).toEqual([]);
159 expect(
160 membersOf(
161 yield* pubsub.getIamPolicyProjectsTopics({ resource: out.topic }),
162 service,
163 ),
164 ).toEqual(["roles/pubsub.publisher"]);
165
166 yield* stack.destroy();
167
168 for (const email of [out.serviceAccount, out.jobAccount]) {
169 const gone = yield* iam
170 .getProjectsServiceAccounts({
171 name: `projects/${out.project}/serviceAccounts/${email}`,
172 })
173 .pipe(
174 Effect.as("found" as const),
175 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
176 );
177 expect(gone).toEqual("gone");
178 }
179 }).pipe(logLevel),
180 { tags: ["provider:gcp", "provider:gcp:run", "live"], timeout: 240_000 },
181);