1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as Core from "@/Test/Core";
4import * as artifactregistry from "@distilled.cloud/gcp/artifactregistry_v1";
5import * as cloudrun from "@distilled.cloud/gcp/run_v2";
6import * as iam from "@distilled.cloud/gcp/unstable/iam_v1";
7import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
8import { expect } from "alchemy-test";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import { MinimumLogLevel } from "effect/References";
13import * as Schedule from "effect/Schedule";
14import * as HttpClient from "effect/http/HttpClient";
15import { spawnSync } from "node:child_process";
16import * as pubsub from "@distilled.cloud/gcp/pubsub_v1";
17import * as storage from "@distilled.cloud/gcp/storage_v1";
18import { DataBucket, Tweets } from "./fixtures/bound-resources.ts";
19import PublishOnlyService from "./fixtures/service-publish-only.ts";
20import BoundRedisService from "./fixtures/service-redis.ts";
21import BoundService from "./fixtures/service.ts";
22import { GcpEnvironment } from "@/GCP/Environment";
23
24const { test } = Test.make({ providers: GCP.providers() });
25
26const logLevel = Effect.provideService(
27 MinimumLogLevel,
28 process.env.DEBUG ? "Debug" : "Info",
29);
30
31const dockerAvailable = (() => {
32 try {
33 return (
34 spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 })
35 .status === 0
36 );
37 } catch {
38 return false;
39 }
40})();
41
42const HELLO_IMAGE = "us-docker.pkg.dev/cloudrun/container/hello";
43
44const waitUntilGone = (name: string) =>
45 cloudrun.getProjectsLocationsServices({ name }).pipe(
46 Effect.as("found" as const),
47 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
48 Effect.repeat({
49 schedule: Schedule.spaced("2 seconds"),
50 until: (status) => status === "gone",
51 times: 10,
52 }),
53 );
54
55test.provider(
56 "create, update, and delete a Cloud Run service",
57 (stack) =>
58 Effect.gen(function* () {
59 yield* stack.destroy();
60
61 const created = yield* stack.deploy(
62 Effect.gen(function* () {
63 return yield* GCP.Run.Service("Api", {
64 location: "us-central1",
65 description: "test run service",
66 labels: { env: "test" },
67 template: {
68 containers: [{ image: HELLO_IMAGE }],
69 },
70 });
71 }),
72 );
73
74 expect(created.name).toContain("/services/");
75 expect(created.serviceId).toEqual(expect.any(String));
76 expect(created.location).toEqual("us-central1");
77 expect(created.description).toEqual("test run service");
78 expect(created.labels).toMatchObject({ env: "test" });
79 expect(created.uri).toEqual(expect.any(String));
80 expect(created.terminalConditionState).toEqual("CONDITION_SUCCEEDED");
81
82 const fetched = yield* cloudrun.getProjectsLocationsServices({
83 name: created.name,
84 });
85 expect(fetched.name).toEqual(created.name);
86 expect(fetched.labels?.env).toEqual("test");
87 expect(fetched.description).toEqual("test run service");
88 expect(fetched.template?.containers?.[0]?.image).toEqual(HELLO_IMAGE);
89 expect(fetched.uri).toEqual(created.uri);
90
91 const updated = yield* stack.deploy(
92 Effect.gen(function* () {
93 return yield* GCP.Run.Service("Api", {
94 serviceId: created.serviceId,
95 location: "us-central1",
96 description: "prod run service",
97 labels: { env: "prod", role: "api" },
98 ingress: "INGRESS_TRAFFIC_INTERNAL_ONLY",
99 template: {
100 timeout: "60s",
101 containers: [
102 {
103 image: HELLO_IMAGE,
104 env: [{ name: "ENV", value: "prod" }],
105 },
106 ],
107 },
108 });
109 }),
110 );
111
112 expect(updated.name).toEqual(created.name);
113 expect(updated.description).toEqual("prod run service");
114 expect(updated.labels).toMatchObject({ env: "prod", role: "api" });
115 expect(updated.ingress).toEqual("INGRESS_TRAFFIC_INTERNAL_ONLY");
116 expect(updated.uri).toEqual(expect.any(String));
117
118 const refetched = yield* cloudrun.getProjectsLocationsServices({
119 name: created.name,
120 });
121 expect(refetched.description).toEqual("prod run service");
122 expect(refetched.labels?.env).toEqual("prod");
123 expect(refetched.labels?.role).toEqual("api");
124 expect(refetched.ingress).toEqual("INGRESS_TRAFFIC_INTERNAL_ONLY");
125 expect(refetched.template?.timeout).toEqual("60s");
126 expect(
127 refetched.template?.containers?.[0]?.env?.some(
128 (env) => env.name === "ENV" && env.value === "prod",
129 ),
130 ).toEqual(true);
131
132 yield* stack.destroy();
133
134 const gone = yield* waitUntilGone(created.name);
135 expect(gone).toEqual("gone");
136 }).pipe(logLevel),
137 { tags: ["provider:gcp", "provider:gcp:run", "live"], timeout: 120_000 },
138);
139
140class ServiceNotReady extends Data.TaggedError("ServiceNotReady")<{
141 status: number;
142}> {}
143
144const membersOf = (
145 policy: {
146 bindings?: ReadonlyArray<{
147 role?: string;
148 members?: ReadonlyArray<string>;
149 }>;
150 },
151 member: string,
152) =>
153 new Set(
154 (policy.bindings ?? [])
155 .filter((binding) => (binding.members ?? []).includes(member))
156 .map((binding) => binding.role),
157 );
158
159const fetchJson = <A>(url: string) =>
160 Effect.gen(function* () {
161 const client = yield* HttpClient.HttpClient;
162 const response = yield* client.get(url).pipe(
163 Effect.flatMap((response) =>
164 response.status === 200
165 ? Effect.succeed(response)
166 : Effect.fail(new ServiceNotReady({ status: response.status })),
167 ),
168 Effect.retry({
169 while: (e): e is ServiceNotReady => e._tag === "ServiceNotReady",
170 schedule: Schedule.exponential("500 millis"),
171 times: 10,
172 }),
173 );
174 return (yield* response.json) as A;
175 });
176
177test.provider.skipIf(!dockerAvailable)(
178 "effect-native Function grants resource-scoped IAM and revokes it when a binding is removed",
179 (stack) =>
180 Effect.gen(function* () {
181 yield* stack.destroy();
182
183 const deployed = (
184 program: typeof BoundService | typeof PublishOnlyService,
185 ) =>
186 stack.deploy(
187 Effect.gen(function* () {
188 const service = yield* program;
189 const topic = yield* Tweets;
190 const bucket = yield* DataBucket;
191 return {
192 uri: service.uri,
193 name: service.name,
194 project: service.project,
195 serviceAccount: service.serviceAccount,
196 managedServiceAccount: service.managedServiceAccount,
197 topic: topic.name,
198 bucket: bucket.bucketName,
199 };
200 }),
201 );
202
203 const out = yield* deployed(BoundService);
204 expect(out.managedServiceAccount).toEqual(true);
205 expect(out.serviceAccount ?? "").toMatch(/^alch-/);
206 const member = `serviceAccount:${out.serviceAccount}`;
207
208 const live = yield* cloudrun.getProjectsLocationsServices({
209 name: out.name,
210 });
211 expect(live.template?.serviceAccount).toEqual(out.serviceAccount);
212
213 // Grants land on the bound resources, never on the project.
214 const projectPolicy = yield* resourcemanager.getIamPolicyProjects({
215 resource: `projects/${out.project}`,
216 });
217 expect([...membersOf(projectPolicy, member)]).toEqual([]);
218 const topicPolicy = yield* pubsub.getIamPolicyProjectsTopics({
219 resource: out.topic,
220 });
221 expect([...membersOf(topicPolicy, member)]).toEqual([
222 "roles/pubsub.publisher",
223 ]);
224 const bucketPolicy = yield* storage.getIamPolicyBuckets({
225 bucket: out.bucket,
226 });
227 expect([...membersOf(bucketPolicy, member)].sort()).toEqual([
228 "roles/storage.objectUser",
229 "roles/storage.objectViewer",
230 ]);
231
232 // The runtime SA's own token publishes and round-trips object content.
233 const body = yield* fetchJson<{ published: boolean; read: string }>(
234 out.uri!,
235 );
236 expect(body).toEqual({ published: true, read: "stored" });
237
238 // Step 2: drop the Storage bindings. The code change redeploys and the
239 // bucket grants are revoked; the topic grant stays.
240 const next = yield* deployed(PublishOnlyService);
241 expect(next.serviceAccount).toEqual(out.serviceAccount);
242 const bucketAfter = yield* storage.getIamPolicyBuckets({
243 bucket: out.bucket,
244 });
245 expect([...membersOf(bucketAfter, member)]).toEqual([]);
246 const topicAfter = yield* pubsub.getIamPolicyProjectsTopics({
247 resource: out.topic,
248 });
249 expect([...membersOf(topicAfter, member)]).toEqual([
250 "roles/pubsub.publisher",
251 ]);
252 const step2 = yield* fetchJson<{ published: boolean; step?: number }>(
253 next.uri!,
254 ).pipe(
255 Effect.repeat({
256 schedule: Schedule.spaced("2 seconds"),
257 until: (response) => response.step === 2,
258 times: 15,
259 }),
260 );
261 expect(step2).toEqual({ published: true, step: 2 });
262
263 yield* stack.destroy();
264
265 const saGone = yield* iam
266 .getProjectsServiceAccounts({
267 name: `projects/${out.project}/serviceAccounts/${out.serviceAccount}`,
268 })
269 .pipe(
270 Effect.as("found" as const),
271 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
272 );
273 expect(saGone).toEqual("gone");
274 }).pipe(logLevel),
275 { tags: ["provider:gcp", "provider:gcp:run", "live"], timeout: 420_000 },
276);
277
278// Memorystore Redis provisioning takes 5-10 minutes.
279test.provider.skipIf(
280 !dockerAvailable || !process.env.GCP_TEST_SLOW || !!process.env.FAST,
281)(
282 "effect-native Function with Memorystore Redis over Direct VPC",
283 (stack) =>
284 Effect.gen(function* () {
285 yield* stack.destroy();
286
287 const out = yield* stack.deploy(
288 Effect.gen(function* () {
289 const service = yield* BoundRedisService;
290 return { uri: service.uri };
291 }),
292 );
293
294 expect(out.uri).toEqual(expect.any(String));
295 const client = yield* HttpClient.HttpClient;
296 const res = yield* client.get(out.uri!).pipe(
297 Effect.flatMap((response) =>
298 response.status === 200
299 ? Effect.succeed(response)
300 : Effect.fail(new ServiceNotReady({ status: response.status })),
301 ),
302 Effect.retry({
303 while: (e): e is ServiceNotReady => e._tag === "ServiceNotReady",
304 schedule: Schedule.exponential("500 millis"),
305 times: 10,
306 }),
307 );
308 const body = (yield* res.json) as { redis: string | null };
309 expect(body.redis).toEqual("ok");
310
311 yield* stack.destroy();
312 }).pipe(logLevel),
313 { tags: ["provider:gcp", "provider:gcp:run", "live"], timeout: 1_500_000 },
314);
315
316test.provider(
317 "a failed first image build removes the repository it created",
318 (stack) =>
319 Effect.gen(function* () {
320 const { project } = yield* GcpEnvironment.current;
321 yield* stack.destroy();
322
323 const serviceId = `alchemy-test-broken-build-${Core.defaultStage()}`
324 .toLowerCase()
325 .replace(/[^a-z0-9-]/g, "-")
326 .slice(0, 45)
327 .replace(/-+$/, "");
328 const location = "us-central1";
329
330 // The per-host repository is created before bundling, so a `main`
331 // that cannot be bundled fails after the repository exists.
332 const exit = yield* stack
333 .deploy(
334 Effect.gen(function* () {
335 return yield* GCP.Run.Service("BrokenBuild", {
336 serviceId,
337 location,
338 main: new URL("./fixtures/does-not-exist.ts", import.meta.url)
339 .href,
340 });
341 }),
342 )
343 .pipe(Effect.exit);
344 expect(Exit.isFailure(exit)).toEqual(true);
345
346 const repository = yield* artifactregistry
347 .getProjectsLocationsRepositories({
348 name: `projects/${project}/locations/${location}/repositories/${serviceId}-src`,
349 })
350 .pipe(
351 Effect.as("found" as const),
352 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
353 );
354 expect(repository).toEqual("gone");
355
356 yield* stack.destroy();
357 }).pipe(logLevel),
358 { tags: ["provider:gcp", "provider:gcp:run", "live"], timeout: 180_000 },
359);