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 * as Layer from "effect/Layer";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as Result from "effect/Result";
11import * as HttpClient from "effect/http/HttpClient";
12import Api from "./fixtures/api.ts";
13import ChecksApi, { ChecksSite } from "./fixtures/checks-api.ts";
14import UnhealthyApi, { UnhealthySite } from "./fixtures/unhealthy-api.ts";
15import { API_PORT, MARKER, Site, VOLUME_PATH } from "./fixtures/shared.ts";
16import { ECHO_BODY, Echo } from "./fixtures/echo.ts";
17import { fetchFrom, fetchOnce, nginx } from "./fixtures/flycast.ts";
18import { Ping, Pong } from "./fixtures/rpc-cycle.ts";
19import PingLive from "./fixtures/rpc-ping.ts";
20import PongLive from "./fixtures/rpc-pong.ts";
21import RegionalApi from "./fixtures/regional-api.ts";
22import RpcGateway from "./fixtures/rpc-gateway.ts";
23import RpcOrders, { ORDERS } from "./fixtures/rpc-orders.ts";
24import RpcStranger from "./fixtures/rpc-stranger.ts";
25import RpcUsers, { USERS } from "./fixtures/rpc-users.ts";
26import SecureGateway from "./fixtures/secure-gateway.ts";
27import SecureUsers, { SECURE_USERS_BODY } from "./fixtures/secure-users.ts";
28
29const { test } = Test.make({ providers: Fly.providers() });
30
31const logLevel = Effect.provideService(
32 MinimumLogLevel,
33 process.env.DEBUG ? "Debug" : "Info",
34);
35
36const waitUntilGone = (appName: string, machineId: string) =>
37 machines
38 .getMachine({
39 app_name: appName,
40 machine_id: machineId,
41 })
42 .pipe(
43 Effect.map((machine) =>
44 machine.state === "destroyed" ? ("gone" as const) : ("found" as const),
45 ),
46 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
47 Effect.repeat({
48 schedule: Schedule.spaced("2 seconds"),
49 until: (status) => status === "gone",
50 times: 10,
51 }),
52 );
53
54test.provider(
55 "deploy token probe is typed",
56 (stack) =>
57 Effect.gen(function* () {
58 yield* stack.destroy();
59
60 const app = yield* stack.deploy(
61 Effect.gen(function* () {
62 return yield* Fly.App("TokenSite");
63 }),
64 );
65
66 const minted = yield* machines.createAppDeployToken({
67 app_name: app.appName,
68 });
69 expect(minted.token).toEqual(expect.any(String));
70 expect(minted.token!.length).toBeGreaterThan(0);
71
72 yield* stack.destroy();
73 }).pipe(logLevel),
74 {
75 tags: [
76 "provider:fly",
77 "provider:fly:app",
78 "provider:fly:machine",
79 "provider:fly:service",
80 "live",
81 ],
82 timeout: 90_000,
83 },
84);
85
86test.provider(
87 "create, serve, mount, and delete a service",
88 (stack) =>
89 Effect.gen(function* () {
90 yield* stack.destroy();
91
92 const deployed = yield* stack.deploy(
93 Effect.gen(function* () {
94 const app = yield* Site;
95 const ip = yield* Fly.IpAssignment("Shared", {
96 app,
97 type: "shared_v4",
98 });
99 const api = yield* Api;
100 return { app, ip, api };
101 }),
102 );
103
104 expect(deployed.api.machineId).toEqual(expect.any(String));
105 expect(deployed.api.machineId.length).toBeGreaterThan(0);
106 expect(deployed.api.machineIds).toEqual([deployed.api.machineId]);
107 expect(deployed.api.count).toEqual(1);
108 expect(deployed.api.appName).toEqual(deployed.app.appName);
109 expect(deployed.api.name).toEqual(expect.any(String));
110 expect(deployed.api.region).toEqual("iad");
111 expect(deployed.api.state).toEqual("started");
112 expect(deployed.api.url).toEqual(
113 `https://${deployed.app.appName}.fly.dev`,
114 );
115 expect(deployed.api.code.hash).toEqual(expect.any(String));
116 expect(deployed.api.code.hash.length).toBeGreaterThan(0);
117 expect(deployed.api.mounts[0]?.path).toEqual(VOLUME_PATH);
118 expect(deployed.api.mounts[0]?.volumeId).toEqual(expect.any(String));
119
120 const fetched = yield* machines.getMachine({
121 app_name: deployed.api.appName,
122 machine_id: deployed.api.machineId,
123 });
124 expect(fetched.id).toEqual(deployed.api.machineId);
125 expect(fetched.name).toEqual(deployed.api.name);
126 expect(fetched.region).toEqual("iad");
127 expect(fetched.state).toEqual("started");
128 expect(fetched.config?.metadata?.["alchemy.type"]).toEqual("Fly.Service");
129 expect(fetched.config?.metadata?.["alchemy.stack"]).toEqual(
130 expect.any(String),
131 );
132 expect(fetched.config?.image).toEqual(
133 expect.stringContaining("registry.fly.io/"),
134 );
135 expect(fetched.config?.image).toEqual(
136 expect.stringContaining(deployed.api.code.hash),
137 );
138 expect(fetched.config?.mounts?.[0]?.path).toEqual(VOLUME_PATH);
139 expect(fetched.config?.mounts?.[0]?.volume).toEqual(
140 deployed.api.mounts[0]?.volumeId,
141 );
142 expect(fetched.config?.metadata?.["alchemy.replica"]).toEqual("0");
143 expect(fetched.config?.guest?.cpus).toEqual(1);
144 expect(fetched.config?.guest?.memory_mb).toEqual(256);
145 const defaultCheck = fetched.config?.services?.[0]?.checks?.[0];
146 expect(defaultCheck?.type).toEqual("tcp");
147 expect(defaultCheck?.port).toEqual(API_PORT);
148 expect(defaultCheck?.interval).toEqual("10s");
149 expect(defaultCheck?.timeout).toEqual("2s");
150 expect(defaultCheck?.grace_period).toEqual("30s");
151
152 const liveVolume = yield* machines.getVolumeById({
153 app_name: deployed.api.appName,
154 volume_id: deployed.api.mounts[0]!.volumeId,
155 });
156 expect(liveVolume.attached_machine_id).toEqual(deployed.api.machineId);
157
158 const provider = yield* Provider.findProvider(Fly.Service);
159 const all = yield* provider.list();
160 const found = all.find(
161 (service) => service.machineId === deployed.api.machineId,
162 );
163 expect(found).toBeDefined();
164 expect(found?.appName).toEqual(deployed.api.appName);
165 expect(found?.name).toEqual(deployed.api.name);
166 expect(found?.region).toEqual("iad");
167
168 const body = yield* HttpClient.get(deployed.api.url!).pipe(
169 Effect.flatMap((res) =>
170 res.status === 200
171 ? res.json
172 : Effect.fail(new Error(`api returned ${res.status}`)),
173 ),
174 Effect.retry({
175 schedule: Schedule.spaced("4 seconds"),
176 times: 10,
177 }),
178 Effect.map((value) => value as { text: string; path: string }),
179 );
180 expect(body.path).toEqual(VOLUME_PATH);
181 expect(body.text).toEqual(MARKER);
182
183 yield* stack.destroy();
184
185 const gone = yield* waitUntilGone(
186 deployed.api.appName,
187 deployed.api.machineId,
188 );
189 expect(gone).toEqual("gone");
190 }).pipe(logLevel),
191 {
192 tags: [
193 "provider:fly",
194 "provider:fly:app",
195 "provider:fly:ipassignment",
196 "provider:fly:machine",
197 "provider:fly:service",
198 "provider:fly:volume",
199 "live",
200 ],
201 timeout: 180_000,
202 },
203);
204
205test.provider(
206 "destroy recovers a service and its volumes after initial checks fail",
207 (stack) =>
208 Effect.gen(function* () {
209 yield* stack.destroy();
210 const app = yield* stack.deploy(UnhealthySite);
211 const result = yield* stack.deploy(UnhealthyApi).pipe(Effect.result);
212 expect(Result.isFailure(result)).toBe(true);
213 if (Result.isFailure(result)) {
214 expect(result.failure).toMatchObject({
215 _tag: "Fly.ReplicaChecksNotPassing",
216 });
217 }
218 const live = yield* machines.listMachines({ app_name: app.appName });
219 expect(
220 live.filter((machine) => machine.state !== "destroyed"),
221 ).toHaveLength(1);
222 const volumes = yield* machines.listVolumes({ app_name: app.appName });
223 expect(volumes).toHaveLength(2);
224 const attached = volumes.find(
225 (volume) => volume.attached_machine_id === live[0]?.id,
226 );
227 expect(attached).toBeDefined();
228 const blocked = yield* machines
229 .deleteVolume({
230 app_name: app.appName,
231 volume_id: attached!.id!,
232 })
233 .pipe(Effect.flip);
234 expect(blocked._tag).toBe("VolumeAttached");
235 yield* stack.destroy();
236 for (const machine of live) {
237 expect(yield* waitUntilGone(app.appName, machine.id!)).toBe("gone");
238 }
239 for (const volume of volumes) {
240 const missing = yield* machines
241 .getVolumeById({
242 app_name: app.appName,
243 volume_id: volume.id!,
244 })
245 .pipe(Effect.flip);
246 expect(missing._tag).toBe("NotFound");
247 }
248 }).pipe(logLevel),
249 {
250 tags: [
251 "provider:fly",
252 "provider:fly:app",
253 "provider:fly:machine",
254 "provider:fly:service",
255 "provider:fly:volume",
256 "live",
257 ],
258 timeout: 180_000,
259 },
260);
261
262test.provider(
263 "creates a custom service check and reports it passing",
264 (stack) =>
265 Effect.gen(function* () {
266 yield* stack.destroy();
267
268 const deployed = yield* stack.deploy(
269 Effect.gen(function* () {
270 const app = yield* ChecksSite;
271 const service = yield* ChecksApi;
272 return { app, service };
273 }),
274 );
275
276 const live = yield* machines.getMachine({
277 app_name: deployed.app.appName,
278 machine_id: deployed.service.machineId,
279 });
280 const check = live.config?.services?.[0]?.checks?.[0];
281 expect(check?.type).toEqual("http");
282 expect(check?.port).toEqual(API_PORT);
283 expect(check?.method).toEqual("GET");
284 expect(check?.path).toEqual("/health");
285 expect(check?.protocol).toEqual("http");
286 expect(check?.interval).toEqual("15s");
287 expect(check?.timeout).toEqual("3s");
288 expect(check?.grace_period).toEqual("20s");
289
290 const serviceChecks =
291 live.checks?.filter((check) =>
292 check.name?.startsWith("servicecheck-"),
293 ) ?? [];
294 expect(serviceChecks).toHaveLength(1);
295 expect(serviceChecks[0]?.name).toEqual(
296 `servicecheck-00-http-${API_PORT}`,
297 );
298 expect(serviceChecks[0]?.status).toEqual("passing");
299
300 yield* stack.destroy();
301
302 const gone = yield* waitUntilGone(
303 deployed.service.appName,
304 deployed.service.machineId,
305 );
306 expect(gone).toEqual("gone");
307 }).pipe(logLevel),
308 {
309 tags: [
310 "provider:fly",
311 "provider:fly:app",
312 "provider:fly:machine",
313 "provider:fly:service",
314 "live",
315 ],
316 timeout: 180_000,
317 },
318);
319
320const ownedTags = [
321 "provider:fly",
322 "provider:fly:app",
323 "provider:fly:machine",
324 "provider:fly:service",
325 "live",
326];
327
328/** Observed address kinds on an App. */
329const addressKinds = (appName: string) =>
330 machines
331 .listAppIPAssignments({ app_name: appName })
332 .pipe(
333 Effect.map((res) =>
334 (res.ips ?? [])
335 .map((ip) =>
336 ip.network !== undefined && ip.network !== null
337 ? "flycast"
338 : ip.shared === true
339 ? "shared_v4"
340 : (ip.ip ?? "").includes(":")
341 ? "v6"
342 : "v4",
343 )
344 .sort(),
345 ),
346 );
347
348const appGone = (appName: string) =>
349 machines.getApp({ app_name: appName }).pipe(
350 Effect.as(false),
351 Effect.catchTag("NotFound", () => Effect.succeed(true)),
352 Effect.repeat({
353 schedule: Schedule.spaced("2 seconds"),
354 until: (gone) => gone,
355 times: 10,
356 }),
357 );
358
359const getText = (url: string) =>
360 HttpClient.get(url).pipe(
361 Effect.flatMap((res) =>
362 res.status === 200
363 ? res.text
364 : res.text.pipe(
365 Effect.flatMap((body) =>
366 Effect.fail(new Error(`${url} returned ${res.status}: ${body}`)),
367 ),
368 ),
369 ),
370 Effect.retry({ schedule: Schedule.spaced("4 seconds"), times: 15 }),
371 );
372
373test.provider(
374 "a Service owns its App and serves its public url",
375 (stack) =>
376 Effect.gen(function* () {
377 yield* stack.destroy();
378
379 const owned = yield* stack.deploy(Echo());
380 expect(owned.ownsApp).toBe(true);
381 expect(owned.url).toEqual(`https://${owned.appName}.fly.dev`);
382 // Public Services get a plain-HTTP binding port for bound callers.
383 expect(owned.privateUrl).toEqual(`http://${owned.appName}.flycast:7780`);
384 // Port 80 only redirects to HTTPS, so it is not an endpoint.
385 expect(owned.endpoints).toEqual([
386 {
387 host: `${owned.appName}.fly.dev`,
388 port: 443,
389 internalPort: 3000,
390 protocol: "tcp",
391 handlers: ["tls", "http"],
392 url: `https://${owned.appName}.fly.dev`,
393 },
394 ]);
395 expect(yield* addressKinds(owned.appName)).toEqual([
396 "flycast",
397 "shared_v4",
398 "v6",
399 ]);
400 expect(yield* getText(owned.url!)).toEqual(ECHO_BODY);
401
402 // Moving into a shared App replaces the Service and deletes its App.
403 const moved = yield* stack.deploy(
404 Effect.gen(function* () {
405 const site = yield* Fly.App("EchoSharedSite");
406 return yield* Echo({ app: site });
407 }),
408 );
409 expect(moved.ownsApp).toBe(false);
410 expect(moved.appName).not.toEqual(owned.appName);
411 expect(moved.url).toEqual(`https://${moved.appName}.fly.dev`);
412 expect(moved.privateUrl).toEqual(`http://${moved.appName}.flycast:7780`);
413 expect(yield* appGone(owned.appName)).toBe(true);
414
415 yield* stack.destroy();
416 expect(yield* appGone(moved.appName)).toBe(true);
417 }).pipe(logLevel),
418 { tags: ownedTags, timeout: 300_000 },
419);
420
421test.provider(
422 "a private Service answers over Flycast and turning it public keeps its App",
423 (stack) =>
424 Effect.gen(function* () {
425 yield* stack.destroy();
426 const deploy = (isPublic: boolean) =>
427 stack.deploy(
428 Effect.gen(function* () {
429 const client = yield* Fly.App("EchoCallerSite");
430 const caller = yield* Fly.Machine("Caller", {
431 app: client,
432 ...nginx,
433 });
434 const echo = yield* Echo({ public: isPublic });
435 return { caller, echo };
436 }),
437 );
438
439 const hidden = yield* deploy(false);
440 expect(hidden.echo.ownsApp).toBe(true);
441 expect(hidden.echo.url).toBeUndefined();
442 expect(hidden.echo.privateUrl).toEqual(
443 `http://${hidden.echo.appName}.flycast`,
444 );
445 expect(hidden.echo.endpoints).toEqual([
446 {
447 host: `${hidden.echo.appName}.flycast`,
448 port: 80,
449 internalPort: 3000,
450 protocol: "tcp",
451 handlers: ["http"],
452 url: `http://${hidden.echo.appName}.flycast`,
453 },
454 ]);
455 expect(yield* addressKinds(hidden.echo.appName)).toEqual(["flycast"]);
456 expect(
457 yield* fetchFrom(hidden.caller, hidden.echo.privateUrl!, ECHO_BODY),
458 ).toContain(ECHO_BODY);
459
460 const shown = yield* deploy(true);
461 expect(shown.echo.appName).toEqual(hidden.echo.appName);
462 expect(shown.echo.url).toEqual(`https://${shown.echo.appName}.fly.dev`);
463 expect(yield* addressKinds(shown.echo.appName)).toEqual([
464 "flycast",
465 "shared_v4",
466 "v6",
467 ]);
468 expect(yield* getText(shown.echo.url!)).toEqual(ECHO_BODY);
469
470 const hiddenAgain = yield* deploy(false);
471 expect(hiddenAgain.echo.appName).toEqual(hidden.echo.appName);
472 expect(hiddenAgain.echo.url).toBeUndefined();
473 expect(yield* addressKinds(hiddenAgain.echo.appName)).toEqual([
474 "flycast",
475 ]);
476
477 yield* stack.destroy();
478 expect(yield* appGone(hidden.echo.appName)).toBe(true);
479 expect(yield* appGone(hidden.caller.appName)).toBe(true);
480 }).pipe(logLevel),
481 { tags: ownedTags, timeout: 400_000 },
482);
483
484test.provider(
485 "public or network on a Service in a shared App is rejected before anything is created",
486 (stack) =>
487 Effect.gen(function* () {
488 yield* stack.destroy();
489 const app = yield* stack.deploy(Fly.App("EchoInvalidSite"));
490 for (const invalid of [{ public: false }, { network: "any-network" }]) {
491 const failed = yield* stack
492 .deploy(
493 Effect.gen(function* () {
494 const site = yield* Fly.App("EchoInvalidSite");
495 return yield* Echo({ app: site, ...invalid });
496 }),
497 )
498 .pipe(Effect.flip);
499 expect(failed).toMatchObject({ _tag: "Fly.InvalidServiceProps" });
500 }
501 expect(yield* machines.listMachines({ app_name: app.appName })).toEqual(
502 [],
503 );
504 yield* stack.destroy();
505 expect(yield* appGone(app.appName)).toBe(true);
506 }).pipe(logLevel),
507 { tags: ownedTags, timeout: 120_000 },
508);
509
510test.provider(
511 "Services on the stack network reach each other and nothing else reaches them",
512 (stack) =>
513 Effect.gen(function* () {
514 yield* stack.destroy();
515 const deployed = yield* stack.deploy(
516 Effect.gen(function* () {
517 const users = yield* SecureUsers;
518 const gateway = yield* SecureGateway;
519 const outside = yield* Fly.App("OutsideSite");
520 const outsider = yield* Fly.Machine("Outsider", {
521 app: outside,
522 ...nginx,
523 });
524 return {
525 users,
526 gateway,
527 outsider,
528 network: yield* Fly.stackNetwork,
529 };
530 }),
531 );
532 const { users, gateway, outsider, network } = deployed;
533 expect(users.network).toEqual(network);
534 expect(gateway.network).toEqual(network);
535 expect(users.url).toBeUndefined();
536 expect(users.privateUrl).toEqual(`http://${users.appName}.flycast`);
537 for (const appName of [users.appName, gateway.appName]) {
538 const app = yield* machines.getApp({ app_name: appName });
539 expect(app.network).toEqual(network);
540 }
541 const flycast = (yield* machines.listAppIPAssignments({
542 app_name: users.appName,
543 })).ips;
544 expect(flycast?.map((ip) => ip.network?.name)).toEqual([network]);
545
546 // The public gateway reaches the private Service over the stack network.
547 expect(yield* getText(gateway.url!)).toEqual(SECURE_USERS_BODY);
548
549 // An App on the default network cannot resolve either private name.
550 for (const url of [
551 users.privateUrl!,
552 `http://${users.appName}.internal:3000`,
553 ]) {
554 const response = yield* fetchOnce(outsider, url);
555 expect(response).not.toContain(SECURE_USERS_BODY);
556 expect(response).toContain("bad address");
557 }
558
559 yield* stack.destroy();
560 for (const appName of [users.appName, gateway.appName, outsider.appName])
561 expect(yield* appGone(appName)).toBe(true);
562 }).pipe(logLevel),
563 { tags: ownedTags, timeout: 400_000 },
564);
565
566test.provider(
567 "Services in one App publish separate ports and each url reaches its own Service",
568 (stack) =>
569 Effect.gen(function* () {
570 yield* stack.destroy();
571 const deployed = yield* stack.deploy(
572 Effect.gen(function* () {
573 const site = yield* Fly.App("PortsSite");
574 yield* Fly.IpAssignment("PortsV4", { app: site, type: "shared_v4" });
575 const api = yield* Echo(
576 {
577 app: site,
578 env: { ECHO_BODY: "api" },
579 services: [
580 {
581 protocol: "tcp",
582 internalPort: 3000,
583 autostop: "off",
584 ports: [
585 { port: 80, handlers: ["http"], forceHttps: true },
586 { port: 443, handlers: ["tls", "http"] },
587 ],
588 },
589 ],
590 },
591 "PortsApi",
592 );
593 const admin = yield* Echo(
594 {
595 app: site,
596 env: { ECHO_BODY: "admin" },
597 // The API already uses the default binding port.
598 bindingPort: 7781,
599 services: [
600 {
601 protocol: "tcp",
602 internalPort: 3000,
603 autostop: "off",
604 ports: [{ port: 8443, handlers: ["tls", "http"] }],
605 },
606 {
607 protocol: "tcp",
608 internalPort: 3000,
609 autostop: "off",
610 ports: [{ port: 7000 }],
611 },
612 ],
613 },
614 "PortsAdmin",
615 );
616 return { api, admin };
617 }),
618 );
619 const { api, admin } = deployed;
620 const host = `${api.appName}.fly.dev`;
621 expect(admin.appName).toEqual(api.appName);
622 expect(api.url).toEqual(`https://${host}`);
623 expect(admin.url).toEqual(`https://${host}:8443`);
624 expect(api.privateUrl).toEqual(`http://${api.appName}.flycast:7780`);
625 expect(admin.privateUrl).toEqual(`http://${api.appName}.flycast:7781`);
626 expect(admin.endpoints).toEqual([
627 {
628 host,
629 port: 8443,
630 internalPort: 3000,
631 protocol: "tcp",
632 handlers: ["tls", "http"],
633 url: `https://${host}:8443`,
634 },
635 {
636 host,
637 port: 7000,
638 internalPort: 3000,
639 protocol: "tcp",
640 handlers: [],
641 url: undefined,
642 },
643 ]);
644 // TLS on a non-standard port is served over the shared IPv4.
645 expect(yield* getText(api.url!)).toEqual("api");
646 expect(yield* getText(admin.url!)).toEqual("admin");
647
648 yield* stack.destroy();
649 expect(yield* appGone(api.appName)).toBe(true);
650 }).pipe(logLevel),
651 { tags: ownedTags, timeout: 400_000 },
652);
653
654/** POST to an RPC path from inside a Machine, printing the status line. */
655const postRpc = (caller: { appName: string; machineId: string }, url: string) =>
656 machines
657 .execMachine({
658 app_name: caller.appName,
659 machine_id: caller.machineId,
660 command: [
661 "sh",
662 "-c",
663 `wget -S -qO- -T 5 --post-data='[]' ${url}/__rpc__/listUsers 2>&1 || true`,
664 ],
665 timeout: 15,
666 })
667 .pipe(Effect.map((result) => result.stdout ?? ""));
668
669test.provider(
670 "bound Services call methods, stream, fetch, and a published endpoint",
671 (stack) =>
672 Effect.gen(function* () {
673 yield* stack.destroy();
674 const deployed = yield* stack.deploy(
675 Effect.gen(function* () {
676 const users = yield* RpcUsers;
677 const orders = yield* RpcOrders;
678 const gateway = yield* RpcGateway;
679 const network = yield* Fly.stackNetwork;
680 // On the stack network, but binds nothing.
681 const snoopApp = yield* Fly.App("SnoopSite", { network });
682 const snoop = yield* Fly.Machine("Snoop", {
683 app: snoopApp,
684 ...nginx,
685 });
686 return { users, orders, gateway, snoop };
687 }),
688 );
689 const { users, orders, gateway, snoop } = deployed;
690 const base = gateway.url!;
691 expect(users.url).toBeUndefined();
692 expect(users.privateUrl).toEqual(`http://${users.appName}.flycast`);
693 expect(orders.privateUrl).toEqual(`http://${orders.appName}.flycast`);
694 expect(gateway.privateUrl).toEqual(
695 `http://${gateway.appName}.flycast:7780`,
696 );
697
698 // A method call returns a typed result.
699 expect(JSON.parse(yield* getText(`${base}/users`))).toEqual(USERS);
700 // A private Service calls another through its own binding.
701 expect(JSON.parse(yield* getText(`${base}/orders`))).toEqual(
702 ORDERS.map((order) => ({
703 ...order,
704 user: USERS.find((user) => user.id === order.userId),
705 })),
706 );
707 // A streamed method delivers every element.
708 expect(JSON.parse(yield* getText(`${base}/stream`))).toEqual(USERS);
709 // `fetch` reaches the Service's HTTP routes on its private address.
710 expect(yield* getText(`${base}/http`)).toEqual("users-http 80 /hello");
711 // `bindEndpoint` reaches the admin port.
712 expect(yield* getText(`${base}/admin`)).toEqual(
713 `${users.appName}.flycast:9000 users-http 9000 /stats`,
714 );
715
716 // Same network, no binding, no token: the method is refused.
717 const snooped = yield* postRpc(snoop, users.privateUrl!);
718 expect(snooped).toContain("401");
719 expect(snooped).not.toContain("Ada");
720
721 // A public request never passes the Fly-Src check.
722 const publicCall = yield* HttpClient.post(
723 `${base}/__rpc__/listUsers`,
724 ).pipe(Effect.map((response) => response.status));
725 expect(publicCall).toEqual(401);
726
727 yield* stack.destroy();
728 for (const appName of [
729 users.appName,
730 orders.appName,
731 gateway.appName,
732 snoop.appName,
733 ])
734 expect(yield* appGone(appName)).toBe(true);
735 }).pipe(logLevel),
736 { tags: ownedTags, timeout: 600_000 },
737);
738
739test.provider(
740 "binding a Service on another network fails before the caller is created",
741 (stack) =>
742 Effect.gen(function* () {
743 yield* stack.destroy();
744 const users = yield* stack.deploy(RpcUsers);
745 const failed = yield* stack
746 .deploy(
747 Effect.gen(function* () {
748 yield* RpcUsers;
749 return yield* RpcStranger;
750 }),
751 )
752 .pipe(Effect.flip);
753 expect(failed).toMatchObject({
754 _tag: "Fly.ServiceUnreachable",
755 target: "RpcUsers",
756 network: undefined,
757 targetNetwork: users.network,
758 });
759 yield* stack.destroy();
760 expect(yield* appGone(users.appName)).toBe(true);
761 }).pipe(logLevel),
762 { tags: ownedTags, timeout: 400_000 },
763);
764
765test.provider(
766 "two Services that bind each other deploy and call each other",
767 (stack) =>
768 Effect.gen(function* () {
769 yield* stack.destroy();
770 const deployed = yield* stack.deploy(
771 Effect.gen(function* () {
772 const ping = yield* Ping;
773 const pong = yield* Pong;
774 return { ping, pong };
775 }).pipe(Effect.provide(Layer.mergeAll(PingLive, PongLive))),
776 );
777 expect(yield* getText(deployed.ping.url!)).toEqual("ping hears pong");
778 expect(yield* getText(deployed.pong.url!)).toEqual("pong hears ping");
779 yield* stack.destroy();
780 expect(yield* appGone(deployed.ping.appName)).toBe(true);
781 expect(yield* appGone(deployed.pong.appName)).toBe(true);
782 }).pipe(logLevel),
783 { tags: ownedTags, timeout: 600_000 },
784);
785
786test.provider(
787 "Services on the same port in one App are rejected before any Machine exists",
788 (stack) =>
789 Effect.gen(function* () {
790 yield* stack.destroy();
791 const program = (second: Partial<Parameters<typeof Echo>[0]>) =>
792 Effect.gen(function* () {
793 const site = yield* Fly.App("ConflictSite");
794 const first = yield* Echo({ app: site }, "ConflictFirst");
795 const other = yield* Echo({ app: site, ...second }, "ConflictSecond");
796 return { site, first, other };
797 });
798 const machinesIn = (appName: string) =>
799 machines
800 .listMachines({ app_name: appName })
801 .pipe(
802 Effect.map((listed) =>
803 listed.filter((machine) => machine.state !== "destroyed"),
804 ),
805 );
806
807 // Two new Services on the same default ports in one deploy.
808 const both = yield* stack.deploy(program({})).pipe(Effect.flip);
809 expect(both).toMatchObject({
810 _tag: "Fly.ServicePortConflict",
811 port: 80,
812 });
813
814 // One deployed Service, then a second one on its ports.
815 const first = yield* stack.deploy(
816 Effect.gen(function* () {
817 const site = yield* Fly.App("ConflictSite");
818 return yield* Echo({ app: site }, "ConflictFirst");
819 }),
820 );
821 expect(yield* machinesIn(first.appName)).toHaveLength(1);
822 const added = yield* stack.deploy(program({})).pipe(Effect.flip);
823 expect(added).toMatchObject({ _tag: "Fly.ServicePortConflict" });
824 expect(yield* machinesIn(first.appName)).toHaveLength(1);
825
826 // Distinct ports deploy.
827 const fixed = yield* stack.deploy(
828 program({
829 services: [
830 {
831 protocol: "tcp",
832 internalPort: 3000,
833 autostop: "off",
834 ports: [{ port: 8443, handlers: ["tls", "http"] }],
835 },
836 ],
837 bindingPort: 7781,
838 }),
839 );
840 expect(yield* machinesIn(fixed.site.appName)).toHaveLength(2);
841 yield* stack.destroy();
842 expect(yield* appGone(fixed.site.appName)).toBe(true);
843 }).pipe(logLevel),
844 { tags: ownedTags, timeout: 600_000 },
845);
846
847/** Region of every running Machine in an App, sorted. */
848const machineRegions = (appName: string) =>
849 machines.listMachines({ app_name: appName }).pipe(
850 Effect.map((listed) =>
851 listed
852 .filter((machine) => machine.state !== "destroyed")
853 .map((machine) => machine.region ?? "")
854 .sort(),
855 ),
856 );
857
858const machineIdsIn = (appName: string, region: string) =>
859 machines.listMachines({ app_name: appName }).pipe(
860 Effect.map((listed) =>
861 listed
862 .filter(
863 (machine) =>
864 machine.state !== "destroyed" && machine.region === region,
865 )
866 .map((machine) => machine.id ?? "")
867 .sort(),
868 ),
869 );
870
871test.provider(
872 "a Service runs in several regions and adds, scales, and drops regions in place",
873 (stack) =>
874 Effect.gen(function* () {
875 yield* stack.destroy();
876 const deploy = (options: Parameters<typeof Echo>[0]) =>
877 stack.deploy(Echo(options, "Regional"));
878
879 const two = yield* deploy({ region: ["iad", "lhr"] });
880 expect(two.region).toEqual("iad");
881 expect(two.regions).toEqual(["iad", "lhr"]);
882 expect(two.count).toEqual(1);
883 expect(yield* machineRegions(two.appName)).toEqual(["iad", "lhr"]);
884 expect(yield* getText(two.url!)).toEqual(ECHO_BODY);
885 const iad = yield* machineIdsIn(two.appName, "iad");
886 const lhr = yield* machineIdsIn(two.appName, "lhr");
887
888 // Adding a region keeps the App and the existing Machines.
889 const three = yield* deploy({ region: ["iad", "lhr", "sin"] });
890 expect(three.appName).toEqual(two.appName);
891 expect(three.regions).toEqual(["iad", "lhr", "sin"]);
892 expect(yield* machineRegions(three.appName)).toEqual([
893 "iad",
894 "lhr",
895 "sin",
896 ]);
897 expect(yield* machineIdsIn(three.appName, "iad")).toEqual(iad);
898 expect(yield* machineIdsIn(three.appName, "lhr")).toEqual(lhr);
899
900 // `count` is per region.
901 const scaled = yield* deploy({ region: ["iad", "lhr", "sin"], count: 2 });
902 expect(scaled.count).toEqual(2);
903 expect(scaled.machineIds).toHaveLength(6);
904 expect(yield* machineRegions(scaled.appName)).toEqual([
905 "iad",
906 "iad",
907 "lhr",
908 "lhr",
909 "sin",
910 "sin",
911 ]);
912
913 // Dropping a region deletes its Machines and keeps the hostname.
914 const dropped = yield* deploy({ region: ["iad", "sin"], count: 2 });
915 expect(dropped.appName).toEqual(two.appName);
916 expect(dropped.url).toEqual(two.url);
917 expect(yield* machineRegions(dropped.appName)).toEqual([
918 "iad",
919 "iad",
920 "sin",
921 "sin",
922 ]);
923 expect(yield* getText(dropped.url!)).toEqual(ECHO_BODY);
924
925 // A single region string still works.
926 const single = yield* deploy({ region: "sin" });
927 expect(single.regions).toEqual(["sin"]);
928 expect(yield* machineRegions(single.appName)).toEqual(["sin"]);
929
930 yield* stack.destroy();
931 expect(yield* appGone(two.appName)).toBe(true);
932 }).pipe(logLevel),
933 { tags: ownedTags, timeout: 600_000 },
934);
935
936test.provider(
937 "a multi-region Service mounts a Volume in each Machine's region",
938 (stack) =>
939 Effect.gen(function* () {
940 yield* stack.destroy();
941 const api = yield* stack.deploy(RegionalApi);
942 expect(api.regions).toEqual(["iad", "lhr"]);
943 expect(["iad", "lhr"]).toContain(yield* getText(api.url!));
944 const volumes = yield* machines.listVolumes({ app_name: api.appName });
945 const live = (yield* machines.listMachines({
946 app_name: api.appName,
947 })).filter((machine) => machine.state !== "destroyed");
948 expect(volumes.map((volume) => volume.region).sort()).toEqual([
949 "iad",
950 "lhr",
951 ]);
952 for (const volume of volumes) {
953 const attached = live.find(
954 (machine) => machine.id === volume.attached_machine_id,
955 );
956 expect(attached?.region).toEqual(volume.region);
957 }
958 yield* stack.destroy();
959 expect(yield* appGone(api.appName)).toBe(true);
960 }).pipe(logLevel),
961 { tags: ownedTags, timeout: 400_000 },
962);
963
964test.provider(
965 "a blue/green Service replaces its generation in every region",
966 (stack) =>
967 Effect.gen(function* () {
968 yield* stack.destroy();
969 const deploy = (body: string) =>
970 stack.deploy(
971 Echo(
972 {
973 region: ["iad", "lhr"],
974 env: { ECHO_BODY: body },
975 deploy: { strategy: "bluegreen", healthTimeout: "60 seconds" },
976 },
977 "RegionalBlueGreen",
978 ),
979 );
980 const first = yield* deploy("one");
981 expect(yield* machineRegions(first.appName)).toEqual(["iad", "lhr"]);
982 const second = yield* deploy("two");
983 expect(second.appName).toEqual(first.appName);
984 expect(
985 second.machineIds.some((id) => first.machineIds.includes(id)),
986 ).toBe(false);
987 expect(yield* machineRegions(second.appName)).toEqual(["iad", "lhr"]);
988 expect(yield* getText(second.url!)).toEqual("two");
989 yield* stack.destroy();
990 expect(yield* appGone(first.appName)).toBe(true);
991 }).pipe(logLevel),
992 { tags: ownedTags, timeout: 500_000 },
993);