1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as cloudrun from "@distilled.cloud/gcp/run_v2";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import { spawnSync } from "node:child_process";
10import InvokeCallee from "./fixtures/invoke-callee.ts";
11import InvokeCaller from "./fixtures/invoke-caller.ts";
12
13const { test } = Test.make({ providers: GCP.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const dockerAvailable = (() => {
21 try {
22 return (
23 spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 })
24 .status === 0
25 );
26 } catch {
27 return false;
28 }
29})();
30
31interface CallerBody {
32 status: number;
33 body?: { from: string; method: string; path: string; body: string };
34}
35
36/** GET `url`, repeating while the result does not satisfy `until`. */
37const getUntil = <A>(
38 url: string,
39 decode: (status: number, body: string) => A,
40 until: (value: A) => boolean,
41) =>
42 Effect.gen(function* () {
43 const client = yield* HttpClient.HttpClient;
44 const response = yield* client.get(url);
45 return decode(response.status, yield* response.text);
46 }).pipe(
47 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 10 }),
48 // A fresh revision and a fresh run.invoker grant both take a moment
49 // to serve; bounded at ~2 minutes.
50 Effect.repeat({
51 schedule: Schedule.spaced("5 seconds"),
52 until,
53 times: 24,
54 }),
55 );
56
57test.provider.skipIf(!dockerAvailable)(
58 "InvokeService calls a private service with a Google-signed ID token",
59 (stack) =>
60 Effect.gen(function* () {
61 yield* stack.destroy();
62
63 const out = yield* stack.deploy(
64 Effect.gen(function* () {
65 const callee = yield* InvokeCallee;
66 const caller = yield* InvokeCaller;
67 return {
68 callee: {
69 name: callee.name,
70 uri: callee.uri,
71 invokerIamDisabled: callee.invokerIamDisabled,
72 },
73 caller: {
74 name: caller.name,
75 uri: caller.uri,
76 serviceAccount: caller.serviceAccount,
77 },
78 };
79 }),
80 );
81 expect(out.callee.invokerIamDisabled).toEqual(false);
82
83 // run.invoker is granted on the callee's own policy, not the project.
84 const policy = yield* cloudrun.getIamPolicyProjectsLocationsServices({
85 resource: out.callee.name,
86 });
87 const invokers =
88 policy.bindings?.find((binding) => binding.role === "roles/run.invoker")
89 ?.members ?? [];
90 expect(invokers).toContain(`serviceAccount:${out.caller.serviceAccount}`);
91
92 // Without a token Cloud Run rejects the call at its front end.
93 const direct = yield* getUntil(
94 `${out.callee.uri}/hello`,
95 (status) => status,
96 (status) => status === 403,
97 );
98 expect(direct).toEqual(403);
99
100 const viaCaller = yield* getUntil(
101 `${out.caller.uri}/`,
102 (status, body) =>
103 status === 200 ? (JSON.parse(body) as CallerBody) : { status },
104 (value) => value.status === 200,
105 );
106 expect(viaCaller).toEqual({
107 status: 200,
108 body: { from: "callee", method: "GET", path: "/hello", body: "" },
109 });
110
111 const posted = yield* getUntil(
112 `${out.caller.uri}/post`,
113 (status, body) =>
114 status === 200 ? (JSON.parse(body) as CallerBody) : { status },
115 (value) => value.status === 200,
116 );
117 expect(posted.body).toEqual({
118 from: "callee",
119 method: "POST",
120 path: "/echo",
121 body: "ping",
122 });
123
124 yield* stack.destroy();
125
126 for (const name of [out.callee.name, out.caller.name]) {
127 const gone = yield* cloudrun
128 .getProjectsLocationsServices({ name })
129 .pipe(
130 Effect.as("found" as const),
131 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
132 );
133 expect(gone).toEqual("gone");
134 }
135 }).pipe(logLevel),
136 { tags: ["provider:gcp", "provider:gcp:run", "live"], timeout: 600_000 },
137);