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 waitUntilAppGone = (appName: string) =>
41 machines.getApp({ app_name: appName }).pipe(
42 Effect.as("found" as const),
43 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
44 Effect.repeat({
45 schedule: Schedule.spaced("1 second"),
46 until: (status) => status === "gone",
47 times: 10,
48 }),
49 );
50
51const listedHas = (appName: string, volumeId: string, snapshotId: string) =>
52 machines
53 .listVolumeSnapshots({
54 app_name: appName,
55 volume_id: volumeId,
56 })
57 .pipe(
58 Effect.map((snapshots) =>
59 snapshots.find((snapshot) => snapshot.id === snapshotId),
60 ),
61 );
62
63const box = (id: string, app: Fly.App, name: string) =>
64 Fly.Machine(id, {
65 app,
66 name,
67 region: "iad",
68 image: "nginx:alpine",
69 guest: { cpus: 1, memoryMb: 256 },
70 mounts: [{ path: "/data", sizeGb: 1, autoBackupEnabled: false }],
71 });
72
73test.provider(
74 "create, update, and destroy a volume snapshot",
75 (stack) =>
76 Effect.gen(function* () {
77 yield* stack.destroy();
78
79 const base = yield* stack.deploy(
80 Effect.gen(function* () {
81 const app = yield* Fly.App("SnapSite");
82 const volume = yield* box("SnapData", app, "snap-data");
83 return { app, volume };
84 }),
85 );
86
87 const created = yield* stack.deploy(
88 Effect.gen(function* () {
89 const app = yield* Fly.App("SnapSite");
90 const volume = yield* box("SnapData", app, "snap-data");
91 const snapshot = yield* Fly.VolumeSnapshot("Nightly", {
92 app,
93 volumeId: base.volume.mounts[0]!.volumeId,
94 });
95 return { app, volume, snapshot };
96 }),
97 );
98
99 expect(created.snapshot.snapshotId).toEqual(expect.any(String));
100 expect(created.snapshot.snapshotId.length).toBeGreaterThan(0);
101 expect(created.snapshot.appName).toEqual(created.app.appName);
102 expect(created.snapshot.volumeId).toEqual(
103 created.volume.mounts[0]?.volumeId,
104 );
105
106 const fetched = yield* listedHas(
107 created.app.appName,
108 created.volume.mounts[0]!.volumeId,
109 created.snapshot.snapshotId,
110 );
111 expect(fetched).toBeDefined();
112 expect(fetched?.id).toEqual(created.snapshot.snapshotId);
113
114 const updated = yield* stack.deploy(
115 Effect.gen(function* () {
116 const app = yield* Fly.App("SnapSite");
117 const volume = yield* box("SnapData", app, "snap-data");
118 const snapshot = yield* Fly.VolumeSnapshot("Nightly", {
119 app,
120 volumeId: created.volume.mounts[0]!.volumeId,
121 });
122 return { app, volume, snapshot };
123 }),
124 );
125
126 expect(updated.snapshot.snapshotId).toEqual(created.snapshot.snapshotId);
127 expect(updated.snapshot.volumeId).toEqual(
128 created.volume.mounts[0]?.volumeId,
129 );
130 expect(updated.snapshot.appName).toEqual(created.app.appName);
131
132 const provider = yield* Provider.findProvider(Fly.VolumeSnapshot);
133 const all = yield* provider.list();
134 const listed = all.find(
135 (row) => row.snapshotId === created.snapshot.snapshotId,
136 );
137 expect(listed).toBeDefined();
138 expect(listed?.appName).toEqual(created.app.appName);
139 expect(listed?.volumeId).toEqual(created.volume.mounts[0]?.volumeId);
140
141 yield* stack.destroy();
142
143 const volumeGone = yield* waitUntilVolumeGone(
144 created.volume.appName,
145 created.volume.mounts[0]!.volumeId,
146 );
147 expect(volumeGone).toEqual("gone");
148 const appGone = yield* waitUntilAppGone(created.app.appName);
149 expect(appGone).toEqual("gone");
150 }).pipe(logLevel),
151 {
152 tags: [
153 "provider:fly",
154 "provider:fly:app",
155 "provider:fly:machine",
156 "provider:fly:volumesnapshot",
157 "live",
158 ],
159 timeout: 180_000,
160 },
161);
162
163test.provider(
164 "replace when the volume changes",
165 (stack) =>
166 Effect.gen(function* () {
167 yield* stack.destroy();
168
169 const base = yield* stack.deploy(
170 Effect.gen(function* () {
171 const app = yield* Fly.App("SnapReplaceSite");
172 const volumeA = yield* box("SnapReplaceA", app, "snap-a");
173 const volumeB = yield* box("SnapReplaceB", app, "snap-b");
174 return { app, volumeA, volumeB };
175 }),
176 );
177
178 const created = yield* stack.deploy(
179 Effect.gen(function* () {
180 const app = yield* Fly.App("SnapReplaceSite");
181 const volumeA = yield* box("SnapReplaceA", app, "snap-a");
182 const volumeB = yield* box("SnapReplaceB", app, "snap-b");
183 const snapshot = yield* Fly.VolumeSnapshot("Retarget", {
184 app,
185 volumeId: base.volumeA.mounts[0]!.volumeId,
186 });
187 return { app, volumeA, volumeB, snapshot };
188 }),
189 );
190
191 expect(created.snapshot.volumeId).toEqual(
192 created.volumeA.mounts[0]?.volumeId,
193 );
194
195 const replaced = yield* stack.deploy(
196 Effect.gen(function* () {
197 const app = yield* Fly.App("SnapReplaceSite");
198 const volumeA = yield* box("SnapReplaceA", app, "snap-a");
199 const volumeB = yield* box("SnapReplaceB", app, "snap-b");
200 const snapshot = yield* Fly.VolumeSnapshot("Retarget", {
201 app,
202 volumeId: created.volumeB.mounts[0]!.volumeId,
203 });
204 return { app, volumeA, volumeB, snapshot };
205 }),
206 );
207
208 expect(replaced.snapshot.snapshotId).not.toEqual(
209 created.snapshot.snapshotId,
210 );
211 expect(replaced.snapshot.volumeId).toEqual(
212 replaced.volumeB.mounts[0]?.volumeId,
213 );
214 expect(replaced.snapshot.volumeId).not.toEqual(
215 created.volumeA.mounts[0]?.volumeId,
216 );
217 expect(replaced.snapshot.appName).toEqual(created.app.appName);
218
219 const fetched = yield* listedHas(
220 replaced.app.appName,
221 replaced.volumeB.mounts[0]!.volumeId,
222 replaced.snapshot.snapshotId,
223 );
224 expect(fetched).toBeDefined();
225 expect(fetched?.id).toEqual(replaced.snapshot.snapshotId);
226
227 yield* stack.destroy();
228
229 const oldGone = yield* waitUntilVolumeGone(
230 created.volumeA.appName,
231 created.volumeA.mounts[0]!.volumeId,
232 );
233 expect(oldGone).toEqual("gone");
234 const newGone = yield* waitUntilVolumeGone(
235 replaced.volumeB.appName,
236 replaced.volumeB.mounts[0]!.volumeId,
237 );
238 expect(newGone).toEqual("gone");
239 const appGone = yield* waitUntilAppGone(created.app.appName);
240 expect(appGone).toEqual("gone");
241 }).pipe(logLevel),
242 {
243 tags: [
244 "provider:fly",
245 "provider:fly:app",
246 "provider:fly:machine",
247 "provider:fly:volumesnapshot",
248 "live",
249 ],
250 timeout: 180_000,
251 },
252);