1import * as machines from "@distilled.cloud/fly-io/machines";
2import * as Fly from "@/Fly";
3import * as Alchemy from "@/index.ts";
4import { Stack as StackService } from "@/Stack.ts";
5import { Stage } from "@/Stage.ts";
6import * as Test from "@/Test/Alchemy";
7import { expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13import * as HttpClient from "effect/http/HttpClient";
14import Api from "./fixtures/app/api.ts";
15import {
16 Marker,
17 PublicIp,
18 SECRET_NAME,
19 Site,
20 VOLUME_PATH,
21} from "./fixtures/app/shared.ts";
22import Worker from "./fixtures/app/worker.ts";
23
24const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
25 providers: Fly.providers(),
26});
27
28const logLevel = Effect.provideService(
29 MinimumLogLevel,
30 process.env.DEBUG ? "Debug" : "Info",
31);
32
33// Out-of-band verification resolves Fly credentials the same way the stack
34// does — through the Alchemy profile via `Fly.providers()` — not from
35// `FLY_API_TOKEN`, which a laptop running off a profile does not have.
36const distilled = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
37 effect.pipe(
38 Effect.provide(
39 Fly.providers().pipe(
40 Layer.provideMerge(
41 Layer.succeed(StackService, {
42 name: "FlyAppFixtureVerify",
43 stage: "test",
44 resources: {},
45 bindings: {},
46 actions: {},
47 }),
48 ),
49 Layer.provideMerge(Layer.succeed(Stage, "test")),
50 ),
51 ),
52 );
53
54class ApiNotReady extends Data.TaggedError("ApiNotReady")<{
55 status: number;
56}> {}
57
58const Stack = Alchemy.Stack(
59 "FlyAppFixture",
60 {
61 providers: Fly.providers(),
62 state: Alchemy.localState(),
63 },
64 Effect.gen(function* () {
65 const site = yield* Site;
66 const secret = yield* Marker;
67 const ip = yield* PublicIp;
68 const worker = yield* Worker;
69 const api = yield* Api;
70 return {
71 appName: site.appName,
72 appId: site.appId,
73 appUrl: site.url,
74 workerMounts: worker.mounts,
75 secretName: secret.name,
76 ip: ip.ip,
77 workerMachineId: worker.machineId,
78 workerName: worker.name,
79 workerState: worker.state,
80 apiMachineId: api.machineId,
81 apiName: api.name,
82 apiState: api.state,
83 apiUrl: api.url,
84 apiRegion: api.region,
85 };
86 }),
87);
88
89const stack = beforeAll(deploy(Stack), { timeout: 180_000 });
90
91afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
92 timeout: 120_000,
93});
94
95test(
96 "deploys an effectful app and serves over HTTP",
97 Effect.gen(function* () {
98 const out = yield* stack;
99
100 expect(out.appName).toEqual(expect.any(String));
101 expect(out.appName).toMatch(/^[a-z][a-z0-9-]*$/);
102 expect(out.appName.length).toBeLessThanOrEqual(30);
103 expect(out.appId).toEqual(expect.any(String));
104 expect(out.workerMounts[0]?.volumeId).toEqual(expect.any(String));
105 expect(out.secretName).toEqual(SECRET_NAME);
106 expect(out.ip).toEqual(expect.any(String));
107 expect(out.workerMachineId).toEqual(expect.any(String));
108 expect(out.apiMachineId).toEqual(expect.any(String));
109 expect(out.apiMachineId).not.toEqual(out.workerMachineId);
110 expect(out.apiName).not.toEqual(out.workerName);
111 expect(out.workerState).toEqual("started");
112 expect(out.apiState).toEqual("started");
113 expect(out.apiRegion).toEqual("iad");
114 expect(out.apiUrl).toEqual(`https://${out.appName}.fly.dev`);
115
116 const liveApp = yield* distilled(
117 machines.getApp({
118 app_name: out.appName,
119 }),
120 );
121 expect(liveApp.name).toEqual(out.appName);
122 expect(liveApp.id).toEqual(out.appId);
123
124 const liveApi = yield* distilled(
125 machines.getMachine({
126 app_name: out.appName,
127 machine_id: out.apiMachineId,
128 }),
129 );
130 expect(liveApi.id).toEqual(out.apiMachineId);
131 expect(liveApi.state).toEqual("started");
132 expect(liveApi.config?.metadata?.["alchemy.type"]).toEqual("Fly.Service");
133 expect(liveApi.config?.guest?.cpus).toEqual(1);
134 expect(liveApi.config?.guest?.memory_mb).toEqual(256);
135
136 const liveWorker = yield* distilled(
137 machines.getMachine({
138 app_name: out.appName,
139 machine_id: out.workerMachineId,
140 }),
141 );
142 expect(liveWorker.id).toEqual(out.workerMachineId);
143 expect(liveWorker.state).toEqual("started");
144 expect(liveWorker.config?.metadata?.["alchemy.type"]).toEqual(
145 "Fly.Service",
146 );
147 expect(liveWorker.config?.mounts?.[0]?.path).toEqual(VOLUME_PATH);
148 expect(liveWorker.config?.mounts?.[0]?.volume).toEqual(
149 out.workerMounts[0]?.volumeId,
150 );
151
152 const liveVolume = yield* distilled(
153 machines.getVolumeById({
154 app_name: out.appName,
155 volume_id: out.workerMounts[0]!.volumeId,
156 }),
157 );
158 expect(liveVolume.id).toEqual(out.workerMounts[0]?.volumeId);
159 expect(liveVolume.attached_machine_id).toEqual(out.workerMachineId);
160
161 const liveSecret = yield* distilled(
162 machines.getSecret({
163 app_name: out.appName,
164 secret_name: out.secretName,
165 show_secrets: false,
166 }),
167 );
168 expect(liveSecret.name).toEqual(SECRET_NAME);
169 expect(liveSecret.value).toBeUndefined();
170
171 const client = yield* HttpClient.HttpClient;
172 const url = out.apiUrl ?? `https://${out.appName}.fly.dev`;
173
174 const body = yield* client.get(`${url}/health`).pipe(
175 Effect.timeoutOrElse({
176 duration: "8 seconds",
177 orElse: () => Effect.fail(new ApiNotReady({ status: 0 })),
178 }),
179 Effect.flatMap((res) =>
180 res.status === 200
181 ? res.json.pipe(
182 Effect.mapError(() => new ApiNotReady({ status: res.status })),
183 )
184 : Effect.fail(new ApiNotReady({ status: res.status })),
185 ),
186 Effect.retry({
187 while: (e) => e._tag === "ApiNotReady",
188 schedule: Schedule.exponential("500 millis").pipe(
189 Schedule.upTo({ duration: "45 seconds" }),
190 ),
191 times: 10,
192 }),
193 Effect.map((value) => value as { ok: boolean; name: string }),
194 );
195 expect(body.ok).toEqual(true);
196 expect(body.name).toEqual(SECRET_NAME);
197 }).pipe(logLevel),
198 {
199 tags: [
200 "provider:fly",
201 "provider:fly:app",
202 "provider:fly:ipassignment",
203 "provider:fly:machine",
204 "provider:fly:secret",
205 "provider:fly:service",
206 "provider:fly:volume",
207 "live",
208 ],
209 timeout: 120_000,
210 },
211);