1import * as machines from "@distilled.cloud/fly-io/machines";
2import * as Fly from "@/Fly";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: Fly.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17const waitUntilVolumeGone = (appName: string, volumeId: string) =>
18 machines
19 .getVolumeById({
20 app_name: appName,
21 volume_id: volumeId,
22 })
23 .pipe(
24 Effect.map((volume) => {
25 const state = volume.state;
26 return state === "destroyed" ||
27 state === "pending_destroy" ||
28 state === "scheduled_for_destruction"
29 ? ("gone" as const)
30 : ("found" as const);
31 }),
32 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
33 Effect.repeat({
34 schedule: Schedule.spaced("2 seconds"),
35 until: (status) => status === "gone",
36 times: 10,
37 }),
38 );
39
40const waitUntilMachineGone = (appName: string, machineId: string) =>
41 machines
42 .getMachine({
43 app_name: appName,
44 machine_id: machineId,
45 })
46 .pipe(
47 Effect.map((machine) =>
48 machine.state === "destroyed" ? ("gone" as const) : ("found" as const),
49 ),
50 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
51 Effect.repeat({
52 schedule: Schedule.spaced("2 seconds"),
53 until: (status) => status === "gone",
54 times: 10,
55 }),
56 );
57
58test.provider(
59 "create, extend, and delete a mounted disk",
60 (stack) =>
61 Effect.gen(function* () {
62 yield* stack.destroy();
63
64 const created = yield* stack.deploy(
65 Effect.gen(function* () {
66 const app = yield* Fly.App("Site");
67 return yield* Fly.Machine("Box", {
68 app,
69 region: "iad",
70 image: "nginx:alpine",
71 guest: { cpus: 1, memoryMb: 256 },
72 mounts: [{ path: "/data", sizeGb: 1, encrypted: true }],
73 });
74 }),
75 );
76
77 expect(created.mounts).toHaveLength(1);
78 expect(created.mounts[0]?.path).toEqual("/data");
79 expect(created.mounts[0]?.volumeId).toEqual(expect.any(String));
80 expect(created.mounts[0]?.sizeGb).toEqual(1);
81 expect(created.mounts[0]?.name).toMatch(/^[a-z][a-z0-9_]*$/);
82
83 const fetched = yield* machines.getVolumeById({
84 app_name: created.appName,
85 volume_id: created.mounts[0]!.volumeId,
86 });
87 expect(fetched.id).toEqual(created.mounts[0]?.volumeId);
88 expect(fetched.name).toEqual(created.mounts[0]?.name);
89 expect(fetched.region).toEqual("iad");
90 expect(fetched.size_gb).toEqual(1);
91 expect(fetched.encrypted).toEqual(true);
92 expect(fetched.attached_machine_id).toEqual(created.machineId);
93
94 const updated = yield* stack.deploy(
95 Effect.gen(function* () {
96 const app = yield* Fly.App("Site");
97 return yield* Fly.Machine("Box", {
98 app,
99 region: "iad",
100 image: "nginx:alpine",
101 guest: { cpus: 1, memoryMb: 256 },
102 mounts: [
103 {
104 path: "/data",
105 sizeGb: 2,
106 encrypted: true,
107 autoBackupEnabled: false,
108 snapshotRetention: 2,
109 },
110 ],
111 });
112 }),
113 );
114
115 expect(updated.machineId).toEqual(created.machineId);
116 expect(updated.mounts[0]?.volumeId).toEqual(created.mounts[0]?.volumeId);
117 expect(updated.mounts[0]?.sizeGb).toEqual(2);
118
119 const refetched = yield* machines.getVolumeById({
120 app_name: updated.appName,
121 volume_id: updated.mounts[0]!.volumeId,
122 });
123 expect(refetched.id).toEqual(created.mounts[0]?.volumeId);
124 expect(refetched.size_gb).toEqual(2);
125 expect(refetched.auto_backup_enabled).toEqual(false);
126 expect(refetched.snapshot_retention).toEqual(2);
127
128 yield* stack.destroy();
129
130 const volumeGone = yield* waitUntilVolumeGone(
131 created.appName,
132 created.mounts[0]!.volumeId,
133 );
134 expect(volumeGone).toEqual("gone");
135 const machineGone = yield* waitUntilMachineGone(
136 created.appName,
137 created.machineId,
138 );
139 expect(machineGone).toEqual("gone");
140 }).pipe(logLevel),
141 {
142 tags: [
143 "provider:fly",
144 "provider:fly:app",
145 "provider:fly:machine",
146 "provider:fly:volume",
147 "live",
148 ],
149 timeout: 180_000,
150 },
151);
152
153test.provider(
154 "replace disks when the machine region changes",
155 (stack) =>
156 Effect.gen(function* () {
157 yield* stack.destroy();
158
159 const created = yield* stack.deploy(
160 Effect.gen(function* () {
161 const app = yield* Fly.App("ReplaceSite");
162 return yield* Fly.Machine("ReplaceBox", {
163 app,
164 region: "iad",
165 image: "nginx:alpine",
166 guest: { cpus: 1, memoryMb: 256 },
167 mounts: [{ path: "/data", sizeGb: 1 }],
168 });
169 }),
170 );
171
172 expect(created.region).toEqual("iad");
173 const oldVolumeId = created.mounts[0]!.volumeId;
174
175 const replaced = yield* stack.deploy(
176 Effect.gen(function* () {
177 const app = yield* Fly.App("ReplaceSite");
178 return yield* Fly.Machine("ReplaceBox", {
179 app,
180 region: "ewr",
181 image: "nginx:alpine",
182 guest: { cpus: 1, memoryMb: 256 },
183 mounts: [{ path: "/data", sizeGb: 1 }],
184 });
185 }),
186 );
187
188 expect(replaced.machineId).not.toEqual(created.machineId);
189 expect(replaced.region).toEqual("ewr");
190 expect(replaced.mounts[0]?.volumeId).not.toEqual(oldVolumeId);
191 expect(replaced.mounts[0]?.sizeGb).toEqual(1);
192
193 const fetched = yield* machines.getVolumeById({
194 app_name: replaced.appName,
195 volume_id: replaced.mounts[0]!.volumeId,
196 });
197 expect(fetched.id).toEqual(replaced.mounts[0]?.volumeId);
198 expect(fetched.region).toEqual("ewr");
199
200 const oldGone = yield* waitUntilVolumeGone(created.appName, oldVolumeId);
201 expect(oldGone).toEqual("gone");
202
203 yield* stack.destroy();
204
205 const gone = yield* waitUntilVolumeGone(
206 replaced.appName,
207 replaced.mounts[0]!.volumeId,
208 );
209 expect(gone).toEqual("gone");
210 }).pipe(logLevel),
211 {
212 tags: [
213 "provider:fly",
214 "provider:fly:app",
215 "provider:fly:machine",
216 "provider:fly:volume",
217 "live",
218 ],
219 timeout: 180_000,
220 },
221);
222
223test.provider(
224 "count creates one volume per replica",
225 (stack) =>
226 Effect.gen(function* () {
227 yield* stack.destroy();
228
229 const created = yield* stack.deploy(
230 Effect.gen(function* () {
231 const app = yield* Fly.App("CountSite");
232 return yield* Fly.Machine("CountBox", {
233 app,
234 region: "iad",
235 image: "nginx:alpine",
236 guest: { cpus: 1, memoryMb: 256 },
237 count: 2,
238 mounts: [{ path: "/data", sizeGb: 1 }],
239 });
240 }),
241 );
242
243 expect(created.count).toEqual(2);
244 expect(created.machineIds).toHaveLength(2);
245 expect(created.machineIds[0]).toEqual(created.machineId);
246 expect(created.replicas).toHaveLength(2);
247 expect(created.replicas[0]?.mounts[0]?.volumeId).toEqual(
248 expect.any(String),
249 );
250 expect(created.replicas[1]?.mounts[0]?.volumeId).toEqual(
251 expect.any(String),
252 );
253 expect(created.replicas[0]?.mounts[0]?.volumeId).not.toEqual(
254 created.replicas[1]?.mounts[0]?.volumeId,
255 );
256 expect(created.replicas[0]?.mounts[0]?.name).toEqual(
257 created.replicas[1]?.mounts[0]?.name,
258 );
259
260 const left = yield* machines.getVolumeById({
261 app_name: created.appName,
262 volume_id: created.replicas[0]!.mounts[0]!.volumeId,
263 });
264 const right = yield* machines.getVolumeById({
265 app_name: created.appName,
266 volume_id: created.replicas[1]!.mounts[0]!.volumeId,
267 });
268 expect(left.attached_machine_id).toEqual(created.replicas[0]?.machineId);
269 expect(right.attached_machine_id).toEqual(created.replicas[1]?.machineId);
270 expect(left.name).toEqual(right.name);
271
272 const scaled = yield* stack.deploy(
273 Effect.gen(function* () {
274 const app = yield* Fly.App("CountSite");
275 return yield* Fly.Machine("CountBox", {
276 app,
277 region: "iad",
278 image: "nginx:alpine",
279 guest: { cpus: 1, memoryMb: 256 },
280 count: 1,
281 mounts: [{ path: "/data", sizeGb: 1 }],
282 });
283 }),
284 );
285
286 expect(scaled.count).toEqual(1);
287 expect(scaled.machineIds).toEqual([created.machineId]);
288 expect(scaled.mounts[0]?.volumeId).toEqual(
289 created.replicas[0]?.mounts[0]?.volumeId,
290 );
291
292 const extraGone = yield* waitUntilMachineGone(
293 created.appName,
294 created.replicas[1]!.machineId,
295 );
296 expect(extraGone).toEqual("gone");
297 const extraVolumeGone = yield* waitUntilVolumeGone(
298 created.appName,
299 created.replicas[1]!.mounts[0]!.volumeId,
300 );
301 expect(extraVolumeGone).toEqual("gone");
302
303 const provider = yield* Provider.findProvider(Fly.Machine);
304 const all = yield* provider.list();
305 const found = all.find(
306 (machine) => machine.machineId === scaled.machineId,
307 );
308 expect(found).toBeDefined();
309 expect(found?.count).toEqual(1);
310
311 yield* stack.destroy();
312 }).pipe(logLevel),
313 {
314 tags: [
315 "provider:fly",
316 "provider:fly:app",
317 "provider:fly:machine",
318 "provider:fly:volume",
319 "live",
320 ],
321 timeout: 180_000,
322 },
323);