1import * as GCP from "@/GCP";
2import { makeObjectMedia } from "@/GCP/Storage/ObjectMedia.ts";
3import * as Test from "@/Test/Alchemy";
4import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
5import * as eventarc from "@distilled.cloud/gcp/eventarc_v1";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import { spawnSync } from "node:child_process";
11import EventarcService, {
12 Drops,
13 Markers,
14 markerFor,
15} from "./fixtures/eventarc-service.ts";
16
17const { test } = Test.make({ providers: GCP.providers() });
18
19const logLevel = Effect.provideService(
20 MinimumLogLevel,
21 process.env.DEBUG ? "Debug" : "Info",
22);
23
24const dockerAvailable = (() => {
25 try {
26 return (
27 spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 })
28 .status === 0
29 );
30 } catch {
31 return false;
32 }
33})();
34
35test.provider.skipIf(!dockerAvailable)(
36 "Eventarc routes Storage finalize events to an effect-native Function",
37 (stack) =>
38 Effect.gen(function* () {
39 yield* stack.destroy();
40
41 const program = Effect.gen(function* () {
42 const service = yield* EventarcService;
43 const drops = yield* Drops;
44 const markers = yield* Markers;
45 return {
46 project: service.project,
47 serviceAccount: service.serviceAccount,
48 drops: drops.bucketName,
49 markers: markers.bucketName,
50 };
51 });
52 yield* stack.deploy(program);
53 // A second deploy re-syncs the host's IAM; the event receiver grant
54 // must survive it (it once was revoked as out-of-band).
55 const out = yield* stack.deploy(program);
56 const projectPolicy = yield* resourcemanager.getIamPolicyProjects({
57 resource: `projects/${out.project}`,
58 });
59 expect(
60 (projectPolicy.bindings ?? []).some(
61 (binding) =>
62 binding.role === "roles/eventarc.eventReceiver" &&
63 (binding.members ?? []).includes(
64 `serviceAccount:${out.serviceAccount}`,
65 ),
66 ),
67 ).toEqual(true);
68
69 const triggers = yield* eventarc.listProjectsLocationsTriggers({
70 parent: `projects/${out.project}/locations/us-central1`,
71 });
72 const trigger = (triggers.triggers ?? []).find(
73 (item) =>
74 item.destination?.cloudRun?.path === "/__alchemy/eventarc/drops" &&
75 item.labels?.["alchemy-stack"] !== undefined,
76 );
77 expect(trigger?.name).toEqual(expect.any(String));
78 expect(
79 Object.values(trigger?.conditions ?? {}).every(
80 (condition) =>
81 condition?.code === undefined || condition.code === "OK",
82 ),
83 ).toEqual(true);
84
85 // Deploy returns only after the trigger reports healthy, so an
86 // upload now is delivered.
87 const media = yield* makeObjectMedia;
88 yield* media.upload(out.drops, {
89 name: "drop/hello.txt",
90 body: "eventarc!",
91 });
92 const marker = yield* media
93 .download({ bucket: out.markers, object: markerFor("drop/hello.txt") })
94 .pipe(
95 Effect.retry({
96 while: (error) => error._tag === "GCP.Storage.ObjectNotFound",
97 schedule: Schedule.spaced("5 seconds"),
98 times: 48,
99 }),
100 );
101 const event = JSON.parse(new TextDecoder().decode(marker.body));
102 expect(event.type).toEqual("google.cloud.storage.object.v1.finalized");
103 expect(event.bucket).toEqual(out.drops);
104 expect(event.name).toEqual("drop/hello.txt");
105 expect(event.subject).toEqual("objects/drop/hello.txt");
106
107 yield* stack.destroy();
108
109 const after = yield* eventarc.listProjectsLocationsTriggers({
110 parent: `projects/${out.project}/locations/us-central1`,
111 });
112 expect(
113 (after.triggers ?? []).some((item) => item.name === trigger?.name),
114 ).toEqual(false);
115 }).pipe(logLevel),
116 { tags: ["provider:gcp", "provider:gcp:eventarc", "live"], timeout: 900_000 },
117);