1import * as GCP from "@/GCP";
2import { GcpEnvironment } from "@/GCP/Environment";
3import * as Test from "@/Test/Alchemy";
4import * as compute from "@distilled.cloud/gcp/compute_v1";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import type * as HttpClientError from "effect/http/HttpClientError";
11import { DATA_DEVICE, MARKER, REGION, webVm, ZONE } from "./fixtures/web-vm.ts";
12import { DEFAULT_NETWORK } from "../networkQuota.ts";
13
14const { test } = Test.make({ providers: GCP.providers() });
15
16class PageNotServed extends Data.TaggedError("PageNotServed")<{
17 readonly reason: string;
18}> {}
19
20class StillExists extends Data.TaggedError("StillExists")<{
21 readonly what: string;
22}> {}
23
24// Poll the static IP until the startup script's HTTP server answers with the
25// marker page. Transport errors (VM still booting) count as "not ready".
26const fetchPage = (ip: string) =>
27 HttpClient.get(`http://${ip}/index.html`).pipe(
28 Effect.timeout("5 seconds"),
29 Effect.flatMap(
30 (
31 res,
32 ): Effect.Effect<
33 string,
34 HttpClientError.HttpClientError | PageNotServed
35 > =>
36 res.status === 200
37 ? res.text
38 : Effect.fail(new PageNotServed({ reason: `status ${res.status}` })),
39 ),
40 Effect.flatMap((body) =>
41 body.includes(MARKER)
42 ? Effect.succeed(body)
43 : Effect.fail(new PageNotServed({ reason: `body ${body}` })),
44 ),
45 Effect.mapError((error) =>
46 error instanceof PageNotServed
47 ? error
48 : new PageNotServed({ reason: String(error) }),
49 ),
50 Effect.retry({
51 while: (error) => error._tag === "PageNotServed",
52 schedule: Schedule.spaced("5 seconds"),
53 times: 60,
54 }),
55 );
56
57// Bounded wait until an out-of-band probe reports the resource gone.
58const waitUntilGone = <E, R>(
59 what: string,
60 probe: Effect.Effect<boolean, E, R>,
61) =>
62 probe.pipe(
63 Effect.flatMap((gone) =>
64 gone ? Effect.void : Effect.fail(new StillExists({ what })),
65 ),
66 Effect.retry({
67 while: (error) => error instanceof StillExists,
68 schedule: Schedule.spaced("3 seconds"),
69 times: 20,
70 }),
71 );
72
73// GCP counterpart of the AWS EC2 Instance + Storage smokes: a Debian VM in its
74// project's `default` network behind a tcp:80 firewall, reachable on a reserved static IP, with an
75// extra persistent disk attached. Heavy (VM boot), so skipped under `FAST=1`.
76test.provider.skipIf(!!process.env.FAST)(
77 "deploys a VM with a static IP and data disk that serves HTTP, updates in place, and tears down",
78 (stack) =>
79 Effect.gen(function* () {
80 const { project } = yield* GcpEnvironment.current;
81 yield* stack.destroy();
82
83 const created = yield* stack.deploy(webVm("initial"));
84
85 expect(created.status).toEqual("RUNNING");
86 expect(created.address).toEqual(expect.any(String));
87 expect(created.natIP).toEqual(created.address);
88 expect(created.attachedDisks).toEqual([
89 expect.objectContaining({
90 source: expect.stringContaining(`/disks/${created.diskName}`),
91 deviceName: DATA_DEVICE,
92 mode: "READ_WRITE",
93 }),
94 ]);
95
96 // Out-of-band: the VM runs with the static IP and the data disk attached.
97 const vm = yield* compute.getInstances({
98 project,
99 zone: ZONE,
100 instance: created.instanceName,
101 });
102 expect(vm.status).toEqual("RUNNING");
103 expect(vm.networkInterfaces?.[0]?.accessConfigs?.[0]?.natIP).toEqual(
104 created.address,
105 );
106 expect(vm.networkInterfaces?.[0]?.subnetwork).toContain(
107 `/subnetworks/${DEFAULT_NETWORK}`,
108 );
109 expect(
110 vm.disks?.some(
111 (d) =>
112 d.boot !== true &&
113 d.deviceName === DATA_DEVICE &&
114 d.source?.endsWith(`/disks/${created.diskName}`),
115 ),
116 ).toBe(true);
117 const disk = yield* compute.getDisks({
118 project,
119 zone: ZONE,
120 disk: created.diskName,
121 });
122 expect(disk.users ?? []).toEqual([
123 expect.stringContaining(`/instances/${created.instanceName}`),
124 ]);
125 const address = yield* compute.getAddresses({
126 project,
127 region: REGION,
128 address: created.addressName,
129 });
130 expect(address.status).toEqual("IN_USE");
131
132 // In-band: the startup script serves the marker page on the static IP
133 // and sees the data disk as a guest block device.
134 const page = yield* fetchPage(created.address!);
135 expect(page).toContain(`host=${created.instanceName}`);
136 expect(page).toContain("disk=present");
137
138 // Label + metadata change reconciles in place (same instance id).
139 const updated = yield* stack.deploy(webVm("updated"));
140 expect(updated.instanceId).toEqual(created.instanceId);
141 expect(updated.instanceName).toEqual(created.instanceName);
142 expect(updated.labels).toMatchObject({
143 smoke: "instance",
144 phase: "updated",
145 });
146 expect(updated.metadata).toMatchObject({ "smoke-phase": "updated" });
147 expect(updated.natIP).toEqual(created.address);
148
149 const vm2 = yield* compute.getInstances({
150 project,
151 zone: ZONE,
152 instance: created.instanceName,
153 });
154 expect(vm2.id).toEqual(created.instanceId);
155 expect(vm2.labels?.phase).toEqual("updated");
156 expect(
157 vm2.metadata?.items?.find((item) => item.key === "smoke-phase")?.value,
158 ).toEqual("updated");
159 expect(vm2.disks?.filter((d) => d.boot !== true)).toHaveLength(1);
160 const updatedPage = yield* fetchPage(created.address!);
161 expect(updatedPage).toContain(MARKER);
162
163 yield* stack.destroy();
164
165 // Zero-orphan proof for every billed / named resource.
166 yield* waitUntilGone(
167 "instance",
168 compute
169 .getInstances({
170 project,
171 zone: ZONE,
172 instance: created.instanceName,
173 })
174 .pipe(
175 Effect.as(false),
176 Effect.catchTag("NotFound", () => Effect.succeed(true)),
177 ),
178 );
179 yield* waitUntilGone(
180 "disk",
181 compute.getDisks({ project, zone: ZONE, disk: created.diskName }).pipe(
182 Effect.as(false),
183 Effect.catchTag("NotFound", () => Effect.succeed(true)),
184 ),
185 );
186 yield* waitUntilGone(
187 "address",
188 compute
189 .getAddresses({
190 project,
191 region: REGION,
192 address: created.addressName,
193 })
194 .pipe(
195 Effect.as(false),
196 Effect.catchTag("NotFound", () => Effect.succeed(true)),
197 ),
198 );
199 yield* waitUntilGone(
200 "firewall",
201 compute.getFirewalls({ project, firewall: created.firewallName }).pipe(
202 Effect.as(false),
203 Effect.catchTag("NotFound", () => Effect.succeed(true)),
204 ),
205 );
206 }),
207 {
208 tags: ["provider:gcp", "provider:gcp:compute", "smoke", "live"],
209 timeout: 900_000,
210 },
211);