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 Result from "effect/Result";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import { fetchFrom, httpService, nginx } from "./fixtures/flycast.ts";
12
13const { test } = Test.make({ providers: Fly.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const waitUntilAppGone = (appName: string) =>
21 machines.getApp({ app_name: appName }).pipe(
22 Effect.as("found" as const),
23 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
24 Effect.repeat({
25 schedule: Schedule.spaced("1 second"),
26 until: (status) => status === "gone",
27 times: 10,
28 }),
29 );
30
31const waitUntilIpGone = (appName: string, ip: string) =>
32 machines.listAppIPAssignments({ app_name: appName }).pipe(
33 Effect.map((res) =>
34 (res.ips ?? []).some((item) => item.ip === ip) ? "found" : "gone",
35 ),
36 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
37 Effect.repeat({
38 schedule: Schedule.spaced("1 second"),
39 until: (status) => status === "gone",
40 times: 10,
41 }),
42 );
43
44const listedHas = (appName: string, ip: string) =>
45 machines
46 .listAppIPAssignments({ app_name: appName })
47 .pipe(Effect.map((res) => (res.ips ?? []).find((item) => item.ip === ip)));
48
49test.provider(
50 "create, update, and delete a v6 assignment",
51 (stack) =>
52 Effect.gen(function* () {
53 yield* stack.destroy();
54
55 const created = yield* stack.deploy(
56 Effect.gen(function* () {
57 const app = yield* Fly.App("IpApp");
58 const ip = yield* Fly.IpAssignment("Public", {
59 app,
60 type: "v6",
61 });
62 return { app, ip };
63 }),
64 );
65
66 expect(created.ip.ip).toEqual(expect.any(String));
67 expect(created.ip.ip).toContain(":");
68 expect(created.ip.type).toEqual("v6");
69 expect(created.ip.appName).toEqual(created.app.appName);
70 expect(created.ip.shared).toEqual(false);
71
72 const fetched = yield* listedHas(created.app.appName, created.ip.ip);
73 expect(fetched).toBeDefined();
74 expect(fetched?.ip).toEqual(created.ip.ip);
75 expect(fetched?.shared).not.toEqual(true);
76
77 const updated = yield* stack.deploy(
78 Effect.gen(function* () {
79 const app = yield* Fly.App("IpApp");
80 const ip = yield* Fly.IpAssignment("Public", {
81 app,
82 type: "v6",
83 });
84 return { app, ip };
85 }),
86 );
87
88 expect(updated.ip.ip).toEqual(created.ip.ip);
89 expect(updated.ip.type).toEqual("v6");
90 expect(updated.ip.appName).toEqual(created.app.appName);
91 expect(updated.app.appId).toEqual(created.app.appId);
92
93 yield* stack.destroy();
94
95 const ipGone = yield* waitUntilIpGone(created.app.appName, created.ip.ip);
96 expect(ipGone).toEqual("gone");
97 const appGone = yield* waitUntilAppGone(created.app.appName);
98 expect(appGone).toEqual("gone");
99 }).pipe(logLevel),
100 {
101 tags: [
102 "provider:fly",
103 "provider:fly:app",
104 "provider:fly:ipassignment",
105 "provider:fly:machine",
106 "live",
107 ],
108 timeout: 120_000,
109 },
110);
111
112test.provider(
113 "replace when type changes to shared_v4",
114 (stack) =>
115 Effect.gen(function* () {
116 yield* stack.destroy();
117
118 const created = yield* stack.deploy(
119 Effect.gen(function* () {
120 const app = yield* Fly.App("IpReplaceApp");
121 const ip = yield* Fly.IpAssignment("Public", {
122 app,
123 type: "v6",
124 });
125 return { app, ip };
126 }),
127 );
128
129 expect(created.ip.type).toEqual("v6");
130
131 const replaced = yield* stack.deploy(
132 Effect.gen(function* () {
133 const app = yield* Fly.App("IpReplaceApp");
134 const ip = yield* Fly.IpAssignment("Public", {
135 app,
136 type: "shared_v4",
137 });
138 return { app, ip };
139 }),
140 );
141
142 expect(replaced.ip.ip).not.toEqual(created.ip.ip);
143 expect(replaced.ip.type).toEqual("shared_v4");
144 expect(replaced.ip.shared).toEqual(true);
145 expect(replaced.ip.appName).toEqual(created.app.appName);
146 expect(replaced.ip.ip).not.toContain(":");
147
148 const fetched = yield* listedHas(replaced.app.appName, replaced.ip.ip);
149 expect(fetched).toBeDefined();
150 expect(fetched?.ip).toEqual(replaced.ip.ip);
151 expect(fetched?.ip).not.toContain(":");
152
153 const oldGone = yield* waitUntilIpGone(
154 created.app.appName,
155 created.ip.ip,
156 );
157 expect(oldGone).toEqual("gone");
158
159 yield* stack.destroy();
160
161 const ipGone = yield* waitUntilIpGone(
162 replaced.app.appName,
163 replaced.ip.ip,
164 );
165 expect(ipGone).toEqual("gone");
166 const appGone = yield* waitUntilAppGone(replaced.app.appName);
167 expect(appGone).toEqual("gone");
168 }).pipe(logLevel),
169 {
170 tags: [
171 "provider:fly",
172 "provider:fly:app",
173 "provider:fly:ipassignment",
174 "provider:fly:machine",
175 "live",
176 ],
177 timeout: 120_000,
178 },
179);
180
181/** Fly marks Flycast addresses with a `network`; public addresses have none. */
182const observedNetwork = (appName: string, ip: string) =>
183 listedHas(appName, ip).pipe(Effect.map((found) => found?.network));
184
185const tags = [
186 "provider:fly",
187 "provider:fly:app",
188 "provider:fly:ipassignment",
189 "provider:fly:machine",
190 "live",
191];
192
193test.provider(
194 "private_v6, v6, and shared_v4 on one App keep distinct addresses",
195 (stack) =>
196 Effect.gen(function* () {
197 yield* stack.destroy();
198
199 const program = Effect.gen(function* () {
200 const app = yield* Fly.App("IpFlycastApp");
201 const flycast = yield* Fly.IpAssignment("Flycast", {
202 app,
203 type: "private_v6",
204 });
205 const publicV6 = yield* Fly.IpAssignment("PublicV6", {
206 app,
207 type: "v6",
208 });
209 const sharedV4 = yield* Fly.IpAssignment("SharedV4", {
210 app,
211 type: "shared_v4",
212 });
213 return { app, flycast, publicV6, sharedV4 };
214 });
215
216 const created = yield* stack.deploy(program);
217 expect(created.flycast.type).toEqual("private_v6");
218 expect(created.flycast.shared).toBe(false);
219 expect(created.flycast.network).toBeUndefined();
220 expect(
221 yield* observedNetwork(created.app.appName, created.flycast.ip),
222 ).toMatchObject({ name: "" });
223 expect(
224 yield* observedNetwork(created.app.appName, created.publicV6.ip),
225 ).toBeNull();
226 expect(created.publicV6.type).toEqual("v6");
227 expect(created.publicV6.ip).toContain(":");
228 expect(created.sharedV4.type).toEqual("shared_v4");
229 expect(created.sharedV4.shared).toBe(true);
230
231 const listed = yield* machines.listAppIPAssignments({
232 app_name: created.app.appName,
233 });
234 expect((listed.ips ?? []).map(({ ip }) => ip).sort()).toEqual(
235 [created.flycast.ip, created.publicV6.ip, created.sharedV4.ip].sort(),
236 );
237
238 const updated = yield* stack.deploy(program);
239 expect(updated.flycast).toEqual(created.flycast);
240 expect(updated.publicV6).toEqual(created.publicV6);
241 expect(updated.sharedV4).toEqual(created.sharedV4);
242
243 const provider = yield* Provider.findProvider(Fly.IpAssignment);
244 const all = yield* provider.list();
245 const typeOf = (ip: string) => all.find((row) => row.ip === ip)?.type;
246 expect(typeOf(created.flycast.ip)).toEqual("private_v6");
247 expect(typeOf(created.publicV6.ip)).toEqual("v6");
248 expect(typeOf(created.sharedV4.ip)).toEqual("shared_v4");
249
250 yield* stack.destroy();
251 for (const ip of [created.flycast.ip, created.publicV6.ip])
252 expect(yield* waitUntilIpGone(created.app.appName, ip)).toEqual("gone");
253 expect(yield* waitUntilAppGone(created.app.appName)).toEqual("gone");
254 }).pipe(logLevel),
255 { tags, timeout: 120_000 },
256);
257
258test.provider(
259 "an existing Flycast address is adopted and a public v6 is still allocated",
260 (stack) =>
261 Effect.gen(function* () {
262 yield* stack.destroy();
263 const app = yield* stack.deploy(Fly.App("IpFlycastAdoptApp"));
264 // Allocate the Flycast address directly through the Fly API.
265 const existing = yield* machines.createAppIPAssignment({
266 app_name: app.appName,
267 type: "private_v6",
268 });
269 expect(existing.network).toMatchObject({ name: "" });
270
271 const deployed = yield* stack.deploy(
272 Effect.gen(function* () {
273 const app = yield* Fly.App("IpFlycastAdoptApp");
274 const flycast = yield* Fly.IpAssignment("Flycast", {
275 app,
276 type: "private_v6",
277 });
278 const publicV6 = yield* Fly.IpAssignment("PublicV6", {
279 app,
280 type: "v6",
281 });
282 return { flycast, publicV6 };
283 }),
284 );
285 expect(deployed.flycast.ip).toEqual(existing.ip);
286 expect(deployed.flycast.type).toEqual("private_v6");
287 expect(deployed.publicV6.type).toEqual("v6");
288 expect(deployed.publicV6.ip).not.toEqual(existing.ip);
289 expect(
290 yield* observedNetwork(app.appName, deployed.publicV6.ip),
291 ).toBeNull();
292 const listed = yield* machines.listAppIPAssignments({
293 app_name: app.appName,
294 });
295 expect(listed.ips ?? []).toHaveLength(2);
296
297 yield* stack.destroy();
298 expect(yield* waitUntilAppGone(app.appName)).toEqual("gone");
299 }).pipe(logLevel),
300 { tags, timeout: 120_000 },
301);
302
303test.provider(
304 "changing type between private_v6 and v6 replaces the address",
305 (stack) =>
306 Effect.gen(function* () {
307 yield* stack.destroy();
308 const deploy = (type: "private_v6" | "v6") =>
309 stack.deploy(
310 Effect.gen(function* () {
311 const app = yield* Fly.App("IpFlycastReplaceApp");
312 const ip = yield* Fly.IpAssignment("Ip", { app, type });
313 return { app, ip };
314 }),
315 );
316 const flycast = yield* deploy("private_v6");
317 expect(flycast.ip.type).toEqual("private_v6");
318 expect(
319 yield* observedNetwork(flycast.app.appName, flycast.ip.ip),
320 ).toBeDefined();
321 const publicV6 = yield* deploy("v6");
322 expect(publicV6.ip.type).toEqual("v6");
323 expect(publicV6.ip.ip).not.toEqual(flycast.ip.ip);
324 expect(
325 yield* observedNetwork(publicV6.app.appName, publicV6.ip.ip),
326 ).toBeNull();
327 expect(
328 yield* waitUntilIpGone(flycast.app.appName, flycast.ip.ip),
329 ).toEqual("gone");
330 const back = yield* deploy("private_v6");
331 expect(back.ip.type).toEqual("private_v6");
332 expect(back.ip.ip).not.toEqual(publicV6.ip.ip);
333 expect(
334 yield* waitUntilIpGone(publicV6.app.appName, publicV6.ip.ip),
335 ).toEqual("gone");
336 yield* stack.destroy();
337 expect(yield* waitUntilAppGone(flycast.app.appName)).toEqual("gone");
338 }).pipe(logLevel),
339 { tags, timeout: 120_000 },
340);
341
342test.provider(
343 "Flycast serves another App through the proxy and not the internet",
344 (stack) =>
345 Effect.gen(function* () {
346 yield* stack.destroy();
347
348 const deployed = yield* stack.deploy(
349 Effect.gen(function* () {
350 const backend = yield* Fly.App("FlycastBackend");
351 const flycast = yield* Fly.IpAssignment("BackendFlycast", {
352 app: backend,
353 type: "private_v6",
354 });
355 const web = yield* Fly.Machine("BackendWeb", {
356 app: backend,
357 ...nginx,
358 services: [httpService],
359 });
360 const client = yield* Fly.App("FlycastClient");
361 const caller = yield* Fly.Machine("Caller", {
362 app: client,
363 ...nginx,
364 });
365 return { backend, flycast, web, caller };
366 }),
367 );
368 expect(deployed.flycast.type).toEqual("private_v6");
369 const flycastUrl = `http://${deployed.backend.appName}.flycast/`;
370 expect(yield* fetchFrom(deployed.caller, flycastUrl)).toContain(
371 "Welcome to nginx",
372 );
373
374 // No public address: the fly.dev hostname does not serve the backend.
375 const publicResult = yield* HttpClient.get(
376 `http://${deployed.backend.appName}.fly.dev/`,
377 ).pipe(
378 Effect.flatMap((response) => response.text),
379 Effect.timeout("10 seconds"),
380 Effect.result,
381 );
382 if (Result.isSuccess(publicResult))
383 expect(publicResult.success).not.toContain("Welcome to nginx");
384
385 // Requests still pass through Fly's proxy, so a stopped Machine autostarts.
386 const target = {
387 app_name: deployed.web.appName,
388 machine_id: deployed.web.machineId,
389 };
390 yield* machines.stopMachine(target);
391 yield* machines.waitMachine({ ...target, state: "stopped", timeout: 30 });
392 expect(yield* fetchFrom(deployed.caller, flycastUrl)).toContain(
393 "Welcome to nginx",
394 );
395 expect((yield* machines.getMachine(target)).state).toEqual("started");
396
397 yield* stack.destroy();
398 expect(yield* waitUntilAppGone(deployed.backend.appName)).toEqual("gone");
399 expect(yield* waitUntilAppGone(deployed.caller.appName)).toEqual("gone");
400 }).pipe(logLevel),
401 { tags, timeout: 180_000 },
402);
403
404test.provider(
405 "network places Flycast on a named network and changing it replaces",
406 (stack) =>
407 Effect.gen(function* () {
408 yield* stack.destroy();
409 const networks = {
410 a: "alchemy-flycast-tenant-a",
411 b: "alchemy-flycast-tenant-b",
412 };
413 const deploy = (network: string | undefined) =>
414 stack.deploy(
415 Effect.gen(function* () {
416 const backend = yield* Fly.App("NetworkBackend");
417 yield* Fly.Machine("BackendWeb", {
418 app: backend,
419 ...nginx,
420 services: [httpService],
421 });
422 const tenantA = yield* Fly.App("TenantA", { network: networks.a });
423 const tenantB = yield* Fly.App("TenantB", { network: networks.b });
424 const callerA = yield* Fly.Machine("CallerA", {
425 app: tenantA,
426 ...nginx,
427 });
428 const flycast = yield* Fly.IpAssignment("BackendFlycast", {
429 app: backend,
430 type: "private_v6",
431 network,
432 });
433 return { backend, tenantA, tenantB, callerA, flycast };
434 }),
435 );
436
437 const onA = yield* deploy(networks.a);
438 expect(onA.tenantA.network).toEqual(networks.a);
439 expect(onA.flycast.network).toEqual(networks.a);
440 expect(
441 yield* observedNetwork(onA.backend.appName, onA.flycast.ip),
442 ).toMatchObject({ name: networks.a });
443 expect(
444 yield* fetchFrom(onA.callerA, `http://${onA.backend.appName}.flycast/`),
445 ).toContain("Welcome to nginx");
446
447 const onB = yield* deploy(networks.b);
448 expect(onB.flycast.ip).not.toEqual(onA.flycast.ip);
449 expect(onB.flycast.network).toEqual(networks.b);
450 expect(
451 yield* waitUntilIpGone(onA.backend.appName, onA.flycast.ip),
452 ).toEqual("gone");
453
454 const onDefault = yield* deploy(undefined);
455 expect(onDefault.flycast.ip).not.toEqual(onB.flycast.ip);
456 expect(onDefault.flycast.network).toBeUndefined();
457 expect(
458 yield* waitUntilIpGone(onB.backend.appName, onB.flycast.ip),
459 ).toEqual("gone");
460
461 yield* stack.destroy();
462 for (const app of [onA.backend, onA.tenantA, onA.tenantB])
463 expect(yield* waitUntilAppGone(app.appName)).toEqual("gone");
464 }).pipe(logLevel),
465 { tags, timeout: 300_000 },
466);
467
468test.provider(
469 "an unknown network is rejected without allocating an address",
470 (stack) =>
471 Effect.gen(function* () {
472 yield* stack.destroy();
473 const app = yield* stack.deploy(Fly.App("IpUnknownNetworkApp"));
474 const failed = yield* stack
475 .deploy(
476 Effect.gen(function* () {
477 const app = yield* Fly.App("IpUnknownNetworkApp");
478 return yield* Fly.IpAssignment("Flycast", {
479 app,
480 type: "private_v6",
481 network: "alchemy-flycast-missing-network",
482 });
483 }),
484 )
485 .pipe(Effect.flip);
486 expect(failed).toMatchObject({ _tag: "NetworkNotFound" });
487 const listed = yield* machines.listAppIPAssignments({
488 app_name: app.appName,
489 });
490 expect(listed.ips ?? []).toEqual([]);
491 yield* stack.destroy();
492 expect(yield* waitUntilAppGone(app.appName)).toEqual("gone");
493 }).pipe(logLevel),
494 { tags, timeout: 120_000 },
495);
496
497test.provider(
498 "list enumerates the deployed assignment",
499 (stack) =>
500 Effect.gen(function* () {
501 yield* stack.destroy();
502
503 const deployed = yield* stack.deploy(
504 Effect.gen(function* () {
505 const app = yield* Fly.App("IpListApp");
506 const ip = yield* Fly.IpAssignment("Public", {
507 app,
508 type: "v6",
509 });
510 return { app, ip };
511 }),
512 );
513
514 const provider = yield* Provider.findProvider(Fly.IpAssignment);
515 const all = yield* provider.list();
516 const found = all.find((row) => row.ip === deployed.ip.ip);
517 expect(found).toBeDefined();
518 expect(found?.appName).toEqual(deployed.app.appName);
519 expect(found?.type).toEqual("v6");
520
521 yield* stack.destroy();
522
523 const ipGone = yield* waitUntilIpGone(
524 deployed.app.appName,
525 deployed.ip.ip,
526 );
527 expect(ipGone).toEqual("gone");
528 const appGone = yield* waitUntilAppGone(deployed.app.appName);
529 expect(appGone).toEqual("gone");
530 }).pipe(logLevel),
531 {
532 tags: [
533 "provider:fly",
534 "provider:fly:app",
535 "provider:fly:ipassignment",
536 "provider:fly:machine",
537 "live",
538 ],
539 timeout: 120_000,
540 },
541);
542
543test.provider(
544 "dedicated v4 is rejected with a typed quota error",
545 (stack) =>
546 Effect.gen(function* () {
547 yield* stack.destroy();
548
549 const app = yield* stack.deploy(
550 Effect.gen(function* () {
551 return yield* Fly.App("IpV4ProbeApp");
552 }),
553 );
554
555 const result = yield* Effect.result(
556 machines.createAppIPAssignment({
557 app_name: app.appName,
558 type: "v4",
559 }),
560 );
561
562 if (Result.isFailure(result)) {
563 expect(result.failure._tag).toEqual("BadRequest");
564 } else {
565 const ip = result.success.ip;
566 if (ip !== undefined && ip.length > 0) {
567 yield* machines
568 .deleteAppIPAssignment({
569 app_name: app.appName,
570 ip,
571 })
572 .pipe(Effect.catchTag("NotFound", () => Effect.void));
573 }
574 }
575
576 yield* stack.destroy();
577
578 const gone = yield* waitUntilAppGone(app.appName);
579 expect(gone).toEqual("gone");
580 }).pipe(logLevel),
581 {
582 tags: [
583 "provider:fly",
584 "provider:fly:app",
585 "provider:fly:ipassignment",
586 "provider:fly:machine",
587 "live",
588 ],
589 timeout: 120_000,
590 },
591);