1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as compute from "@distilled.cloud/gcp/compute_v1";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import { GcpEnvironment } from "@/GCP/Environment";
9
10const { test } = Test.make({ providers: GCP.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17const zone = "us-central1-a";
18const vmName = "alchemy-mi-src";
19
20const lastSegment = (value: string | undefined): string => {
21 if (value === undefined || value.length === 0) return "";
22 const parts = value.split("/").filter((part) => part.length > 0);
23 return parts[parts.length - 1] ?? "";
24};
25
26const waitZoneOp = (operation: compute.Operation) => {
27 if (operation.status === "DONE") return Effect.succeed(operation);
28 const name = lastSegment(operation.name);
29 return GcpEnvironment.current.pipe(
30 Effect.flatMap(({ project }) =>
31 compute.getZoneOperations({ project, zone, operation: name }).pipe(
32 Effect.repeat({
33 schedule: Schedule.spaced("2 seconds"),
34 until: (op) => op.status === "DONE",
35 times: 20,
36 }),
37 ),
38 ),
39 );
40};
41
42const waitUntilGone = (machineImage: string) =>
43 GcpEnvironment.current.pipe(
44 Effect.flatMap(({ project }) =>
45 compute.getMachineImages({ project, machineImage }).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 ),
55 );
56
57const waitVmGone = () =>
58 GcpEnvironment.current.pipe(
59 Effect.flatMap(({ project }) =>
60 compute.getInstances({ project, zone, instance: vmName }).pipe(
61 Effect.as("found" as const),
62 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
63 Effect.repeat({
64 schedule: Schedule.spaced("3 seconds"),
65 until: (status) => status === "gone",
66 times: 16,
67 }),
68 ),
69 ),
70 );
71
72const getVm = () =>
73 GcpEnvironment.current.pipe(
74 Effect.flatMap(({ project }) =>
75 compute
76 .getInstances({ project, zone, instance: vmName })
77 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))),
78 ),
79 );
80
81const waitVmRunning = () =>
82 getVm().pipe(
83 Effect.repeat({
84 schedule: Schedule.spaced("3 seconds"),
85 until: (instance) => instance?.status === "RUNNING",
86 times: 10,
87 }),
88 Effect.flatMap((instance) =>
89 instance?.status === "RUNNING"
90 ? Effect.succeed(instance)
91 : Effect.fail(
92 new Error(
93 `source vm ${vmName} is ${instance?.status ?? "MISSING"}`,
94 ),
95 ),
96 ),
97 );
98
99const ensureVm = () =>
100 Effect.gen(function* () {
101 const { project } = yield* GcpEnvironment.current;
102 const existing = yield* getVm();
103 if (
104 existing?.status === "STOPPING" ||
105 existing?.status === "DELETING" ||
106 existing?.status === "TERMINATED"
107 ) {
108 const operation = yield* compute
109 .deleteInstances({ project, zone, instance: vmName })
110 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
111 if (operation !== undefined) {
112 yield* waitZoneOp(operation).pipe(Effect.ignore);
113 }
114 yield* waitVmGone();
115 } else if (existing?.status === "RUNNING") {
116 return existing;
117 }
118 if ((yield* getVm()) === undefined) {
119 const operation = yield* compute
120 .insertInstances({
121 project,
122 zone,
123 body: {
124 name: vmName,
125 machineType: `zones/${zone}/machineTypes/e2-micro`,
126 disks: [
127 {
128 boot: true,
129 autoDelete: true,
130 type: "PERSISTENT",
131 initializeParams: {
132 sourceImage:
133 "projects/debian-cloud/global/images/family/debian-12",
134 diskSizeGb: "10",
135 },
136 },
137 ],
138 networkInterfaces: [{ network: "global/networks/default" }],
139 },
140 })
141 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
142 if (operation !== undefined) {
143 yield* waitZoneOp(operation).pipe(Effect.ignore);
144 }
145 }
146 return yield* waitVmRunning();
147 });
148
149const deleteVm = () =>
150 Effect.gen(function* () {
151 const { project } = yield* GcpEnvironment.current;
152 const operation = yield* compute
153 .deleteInstances({ project, zone, instance: vmName })
154 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
155 if (operation !== undefined) {
156 yield* waitZoneOp(operation).pipe(Effect.ignore);
157 }
158 yield* waitVmGone();
159 });
160
161test.provider.skipIf(!!process.env.FAST)(
162 "create, update, and delete a machine image",
163 (stack) =>
164 Effect.gen(function* () {
165 const { project } = yield* GcpEnvironment.current;
166 yield* stack.destroy();
167
168 const vm = yield* ensureVm();
169 const sourceInstance =
170 vm.selfLink ?? `projects/${project}/zones/${zone}/instances/${vmName}`;
171
172 const created = yield* stack.deploy(
173 Effect.gen(function* () {
174 return yield* GCP.Compute.MachineImage("Backup", {
175 sourceInstance,
176 description: "alchemy test machine image",
177 labels: { env: "test" },
178 storageLocations: ["us-central1"],
179 });
180 }),
181 );
182
183 expect(created.machineImageName).toEqual(expect.any(String));
184 expect(created.status).toEqual("READY");
185 expect(created.description).toEqual("alchemy test machine image");
186 expect(created.labels).toMatchObject({ env: "test" });
187 expect(created.project).toEqual(expect.any(String));
188 expect(created.selfLink).toEqual(expect.any(String));
189 expect(created.sourceInstance).toEqual(expect.any(String));
190
191 const fetched = yield* compute.getMachineImages({
192 project: created.project,
193 machineImage: created.machineImageName,
194 });
195 expect(fetched.name).toEqual(created.machineImageName);
196 expect(fetched.status).toEqual("READY");
197 expect(fetched.description).toEqual("alchemy test machine image");
198 expect(fetched.labels?.env).toEqual("test");
199 expect(fetched.labels?.["alchemy-id"]).toEqual(expect.any(String));
200 expect(lastSegment(fetched.sourceInstance)).toEqual(vmName);
201
202 const updated = yield* stack.deploy(
203 Effect.gen(function* () {
204 return yield* GCP.Compute.MachineImage("Backup", {
205 machineImageName: created.machineImageName,
206 sourceInstance,
207 description: "alchemy test machine image",
208 labels: { env: "prod", role: "golden" },
209 storageLocations: ["us-central1"],
210 });
211 }),
212 );
213
214 expect(updated.machineImageName).toEqual(created.machineImageName);
215 expect(updated.machineImageId).toEqual(created.machineImageId);
216 expect(updated.labels).toMatchObject({
217 env: "prod",
218 role: "golden",
219 });
220
221 const refetched = yield* compute.getMachineImages({
222 project: updated.project,
223 machineImage: updated.machineImageName,
224 });
225 expect(refetched.labels?.env).toEqual("prod");
226 expect(refetched.labels?.role).toEqual("golden");
227 expect(refetched.name).toEqual(created.machineImageName);
228
229 yield* stack.destroy();
230
231 const gone = yield* waitUntilGone(created.machineImageName);
232 expect(gone).toEqual("gone");
233
234 yield* deleteVm();
235 }).pipe(logLevel),
236 { tags: ["provider:gcp", "provider:gcp:compute", "live"], timeout: 240_000 },
237);