1import * as GCP from "@/GCP";
2import { GcpEnvironment } from "@/GCP/Environment";
3import * as Test from "@/Test/Alchemy";
4import * as Core from "@/Test/Core";
5import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
6import * as compute from "@distilled.cloud/gcp/compute_v1";
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 ComputeBindingsHost, { Vm } from "./fixtures/bindings-host.ts";
12
13const testOptions = { providers: GCP.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "ComputeBindings");
16
17// Stop→TERMINATED→RUNNING on e2-micro takes several minutes end to end.
18const runLifecycle = !!process.env.GCP_TEST_SLOW && !process.env.FAST;
19
20let baseUrl: string;
21let hostAccount: string;
22let vm: { project: string; zone: string; instanceName: string };
23
24/** Out-of-band instance status, read with the deployer's credentials. */
25const liveStatus = Effect.suspend(() =>
26 compute
27 .getInstances({
28 project: vm.project,
29 zone: vm.zone,
30 instance: vm.instanceName,
31 })
32 .pipe(Effect.map((instance) => instance.status)),
33);
34
35const waitForStatus = (status: string) =>
36 liveStatus.pipe(
37 Effect.repeat({
38 schedule: Schedule.spaced("5 seconds"),
39 until: (current) => current === status,
40 times: 60,
41 }),
42 );
43
44/**
45 * The instance-level roles the host's service account holds. GetInstance
46 * grants compute.viewer; Start/Stop share compute.instanceAdmin.v1.
47 */
48const expectInstanceGrants = Effect.gen(function* () {
49 const policy = yield* compute.getIamPolicyInstances({
50 project: vm.project,
51 zone: vm.zone,
52 resource: vm.instanceName,
53 });
54 const roles = (policy.bindings ?? [])
55 .filter((binding) =>
56 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
57 )
58 .map((binding) => binding.role)
59 .sort();
60 expect(roles).toEqual([
61 "roles/compute.instanceAdmin.v1",
62 "roles/compute.viewer",
63 ]);
64
65 const { project } = yield* GcpEnvironment.current;
66 const projectPolicy = yield* resourcemanager.getIamPolicyProjects({
67 resource: `projects/${project}`,
68 body: { options: { requestedPolicyVersion: 3 } },
69 });
70 const projectRoles = (projectPolicy.bindings ?? []).filter((binding) =>
71 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
72 );
73 expect(projectRoles).toEqual([]);
74});
75
76describe.skipIf(!dockerAvailable || !runLifecycle)(
77 "Compute Bindings",
78 {
79 tags: ["provider:gcp", "provider:gcp:compute", "provider:gcp:run", "live"],
80 },
81 () => {
82 beforeAll(
83 Effect.gen(function* () {
84 yield* sharedStack.destroy();
85 const out = yield* sharedStack.deploy(
86 Effect.gen(function* () {
87 const host = yield* ComputeBindingsHost;
88 const instance = yield* Vm;
89 return {
90 uri: host.uri,
91 serviceAccount: host.serviceAccount,
92 vm: {
93 project: instance.project,
94 zone: instance.zone,
95 instanceName: instance.instanceName,
96 },
97 };
98 }),
99 );
100 baseUrl = out.uri!;
101 hostAccount = out.serviceAccount!;
102 vm = out.vm;
103 }),
104 { timeout: 900_000 },
105 );
106
107 afterAll(sharedStack.destroy(), { timeout: 900_000 });
108
109 // Describes run in declaration order: Get, then Stop, then Start.
110 describe("GetInstance", () => {
111 test.provider(
112 "reads the instance as the host's service account",
113 (_stack) =>
114 Effect.gen(function* () {
115 const live = yield* expectProbe<{
116 name?: string;
117 id?: string;
118 status?: string;
119 }>(baseUrl, "getInstance");
120 const expected = yield* compute.getInstances({
121 project: vm.project,
122 zone: vm.zone,
123 instance: vm.instanceName,
124 });
125 expect(live.name).toEqual(vm.instanceName);
126 expect(live.id).toEqual(expected.id);
127 expect(live.status).toEqual(expected.status);
128 yield* expectInstanceGrants;
129 }),
130 {
131 tags: ["provider:gcp", "provider:gcp:compute", "live"],
132 timeout: 600_000,
133 },
134 );
135 });
136
137 describe("StopInstance", () => {
138 test.provider(
139 "stops the instance as the host's service account",
140 (_stack) =>
141 Effect.gen(function* () {
142 yield* waitForStatus("RUNNING");
143 const op = yield* expectProbe<{
144 operationType?: string;
145 targetLink?: string;
146 }>(baseUrl, "stopInstance");
147 expect(op.operationType).toEqual("stop");
148 expect(op.targetLink).toContain(`/instances/${vm.instanceName}`);
149 expect(yield* waitForStatus("TERMINATED")).toEqual("TERMINATED");
150 const seen = yield* expectProbe<{ status?: string }>(
151 baseUrl,
152 "getInstance",
153 );
154 expect(seen.status).toEqual("TERMINATED");
155 yield* expectInstanceGrants;
156 }),
157 {
158 tags: ["provider:gcp", "provider:gcp:compute", "live"],
159 timeout: 600_000,
160 },
161 );
162 });
163
164 describe("StartInstance", () => {
165 test.provider(
166 "starts the instance as the host's service account",
167 (_stack) =>
168 Effect.gen(function* () {
169 const op = yield* expectProbe<{
170 operationType?: string;
171 targetLink?: string;
172 }>(baseUrl, "startInstance");
173 expect(op.operationType).toEqual("start");
174 expect(op.targetLink).toContain(`/instances/${vm.instanceName}`);
175 expect(yield* waitForStatus("RUNNING")).toEqual("RUNNING");
176 const seen = yield* expectProbe<{ status?: string }>(
177 baseUrl,
178 "getInstance",
179 );
180 expect(seen.status).toEqual("RUNNING");
181 yield* expectInstanceGrants;
182 }),
183 {
184 tags: ["provider:gcp", "provider:gcp:compute", "live"],
185 timeout: 600_000,
186 },
187 );
188 });
189 },
190);