1import * as machines from "@distilled.cloud/fly-io/machines";
2import * as mpg from "@distilled.cloud/fly-io/mpg";
3import * as Fly from "@/Fly";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11import * as HttpClient from "effect/http/HttpClient";
12import PostgresApi, { Db, MpgIp, MpgSite } from "./fixtures/postgres-api.ts";
13
14const { test } = Test.make({ providers: Fly.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21const waitUntilClusterGone = (clusterId: string) =>
22 mpg.getClusterById({ id: clusterId }).pipe(
23 Effect.map((res) =>
24 res.data === undefined || res.data.status === "deleted"
25 ? ("gone" as const)
26 : ("found" as const),
27 ),
28 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
29 Effect.repeat({
30 schedule: Schedule.spaced("2 seconds"),
31 until: (status) => status === "gone",
32 times: 10,
33 }),
34 );
35
36const waitUntilReady = (clusterId: string) =>
37 mpg.getClusterById({ id: clusterId }).pipe(
38 Effect.map((res) => res.data?.status),
39 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
40 Effect.repeat({
41 schedule: Schedule.spaced("5 seconds"),
42 until: (status) => status === "ready" || status === "error",
43 times: 24,
44 }),
45 );
46
47const waitUntilAppGone = (appName: string) =>
48 machines.getApp({ app_name: appName }).pipe(
49 Effect.as("found" as const),
50 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
51 Effect.repeat({
52 schedule: Schedule.spaced("1 second"),
53 until: (status) => status === "gone",
54 times: 10,
55 }),
56 );
57
58const secretNames = (appName: string) =>
59 machines
60 .listSecrets({
61 app_name: appName,
62 show_secrets: false,
63 })
64 .pipe(
65 Effect.map((res) => (res.secrets ?? []).map((secret) => secret.name)),
66 Effect.catchTag(["NotFound", "Forbidden"], () =>
67 Effect.succeed([] as Array<string | undefined>),
68 ),
69 );
70
71test.provider(
72 "createCluster with an invalid region is a typed BadRequest",
73 (stack) =>
74 Effect.gen(function* () {
75 yield* stack.destroy();
76
77 const orgSlug = yield* Fly.currentOrgSlug();
78 const error = yield* mpg
79 .createCluster({
80 org_slug: orgSlug,
81 name: "alchemy-mpg-invalid-region",
82 region: "not-a-region",
83 plan: "basic",
84 storage_in_gb: 10,
85 })
86 .pipe(Effect.flip);
87
88 expect(error._tag).toEqual("BadRequest");
89 expect(error.message).toEqual("region is invalid");
90
91 yield* stack.destroy();
92 }).pipe(logLevel),
93 { tags: ["provider:fly", "provider:fly:postgres", "live"], timeout: 90_000 },
94);
95
96test.provider(
97 "create, attach, list, and destroy a managed postgres cluster",
98 (stack) =>
99 Effect.gen(function* () {
100 yield* stack.destroy();
101
102 const created = yield* stack.deploy(
103 Effect.gen(function* () {
104 const app = yield* Fly.App("MpgSite");
105 const db = yield* Fly.Postgres("Db", {
106 region: "iad",
107 plan: "basic",
108 volumeSizeGb: 10,
109 });
110 return { app, db };
111 }),
112 );
113
114 expect(created.db.clusterId).toEqual(expect.any(String));
115 expect(created.db.clusterId.length).toBeGreaterThan(0);
116 expect(created.db.name).toEqual(expect.any(String));
117 expect(created.db.region).toEqual("iad");
118 expect(created.db.plan).toEqual("basic");
119
120 const status = yield* waitUntilReady(created.db.clusterId);
121 expect(status).toEqual("ready");
122
123 const fetched = yield* mpg.getClusterById({
124 id: created.db.clusterId,
125 });
126 expect(fetched.data?.id).toEqual(created.db.clusterId);
127 expect(fetched.data?.name).toEqual(created.db.name);
128 expect(fetched.data?.region).toEqual("iad");
129 expect(fetched.data?.status).toEqual("ready");
130
131 const updated = yield* stack.deploy(
132 Effect.gen(function* () {
133 const app = yield* Fly.App("MpgSite");
134 const db = yield* Fly.Postgres("Db", {
135 region: "iad",
136 plan: "basic",
137 volumeSizeGb: 10,
138 });
139 return { app, db };
140 }),
141 );
142 expect(updated.db.clusterId).toEqual(created.db.clusterId);
143 expect(updated.db.name).toEqual(created.db.name);
144 expect(created.db.pooledConnectionUri.length).toBeGreaterThan(0);
145
146 const provider = yield* Provider.findProvider(Fly.Postgres);
147 const all = yield* provider.list();
148 const listed = all.find((row) => row.clusterId === created.db.clusterId);
149 expect(listed).toBeDefined();
150 expect(listed?.name).toEqual(created.db.name);
151 expect(listed?.region).toEqual("iad");
152
153 yield* stack.destroy();
154
155 const clusterGone = yield* waitUntilClusterGone(created.db.clusterId);
156 expect(clusterGone).toEqual("gone");
157 const appGone = yield* waitUntilAppGone(created.app.appName);
158 expect(appGone).toEqual("gone");
159 }).pipe(logLevel),
160 {
161 tags: [
162 "provider:fly",
163 "provider:fly:app",
164 "provider:fly:machine",
165 "provider:fly:postgres",
166 "live",
167 ],
168 timeout: 180_000,
169 },
170);
171
172test.provider(
173 "a Service connects and SELECTs through ConnectPostgres",
174 (stack) =>
175 Effect.gen(function* () {
176 yield* stack.destroy();
177
178 const deployed = yield* stack.deploy(
179 Effect.gen(function* () {
180 const app = yield* MpgSite;
181 const db = yield* Db;
182 const ip = yield* MpgIp;
183 const api = yield* PostgresApi;
184 return { app, db, ip, api };
185 }),
186 );
187
188 expect(deployed.db.clusterId).toEqual(expect.any(String));
189 expect(deployed.api.url).toEqual(
190 `https://${deployed.app.appName}.fly.dev`,
191 );
192
193 const names = yield* secretNames(deployed.app.appName);
194 expect(names).toContain("DATABASE_URL");
195
196 yield* Effect.logInfo("ConnectPostgres deployed", {
197 url: deployed.api.url,
198 machineId: deployed.api.machineId,
199 state: deployed.api.state,
200 });
201
202 class NotReady extends Data.TaggedError("NotReady")<{
203 status: number;
204 body?: unknown;
205 }> {
206 override get message() {
207 return this.body === undefined
208 ? `status ${this.status}`
209 : `status ${this.status}: ${JSON.stringify(this.body)}`;
210 }
211 }
212
213 const diagnose = Effect.gen(function* () {
214 const appName = deployed.api.appName;
215 const machineId = deployed.api.machineId;
216 const machine = yield* machines.getMachine({
217 app_name: appName,
218 machine_id: machineId,
219 });
220 const events = yield* machines.listMachineEvents({
221 app_name: appName,
222 machine_id: machineId,
223 limit: 20,
224 });
225 const listed = yield* machines.listMachineProcesses({
226 app_name: appName,
227 machine_id: machineId,
228 });
229 const processes = (Array.isArray(listed) ? listed : []).filter(
230 (proc) => typeof proc.command === "string" && proc.command.length > 0,
231 );
232 const ips = yield* machines.listAppIPAssignments({
233 app_name: appName,
234 });
235 const exec = yield* machines
236 .execMachine({
237 app_name: appName,
238 machine_id: machineId,
239 command: [
240 "bun",
241 "-e",
242 "const r = await fetch('http://127.0.0.1:3000/ping'); console.log(r.status, await r.text())",
243 ],
244 timeout: 10,
245 })
246 .pipe(
247 Effect.catchCause((cause) =>
248 Effect.succeed({
249 stdout: undefined,
250 stderr: String(cause),
251 exit_code: -1,
252 }),
253 ),
254 );
255 yield* Effect.logError("ConnectPostgres HTTP diagnose", {
256 state: machine.state,
257 checks: machine.checks,
258 events,
259 processes,
260 ips,
261 exec,
262 });
263 }).pipe(Effect.ignore);
264
265 const getJson = (path: string) =>
266 HttpClient.get(`${deployed.api.url}${path}`).pipe(
267 Effect.flatMap((res) =>
268 res.json.pipe(
269 Effect.flatMap((body) =>
270 res.status === 200
271 ? Effect.succeed(body as { ok?: boolean; rows?: unknown })
272 : Effect.fail(new NotReady({ status: res.status, body })),
273 ),
274 ),
275 ),
276 Effect.timeoutOrElse({
277 duration: "8 seconds",
278 orElse: () => Effect.fail(new NotReady({ status: 0 })),
279 }),
280 Effect.retry({
281 while: (e) =>
282 e._tag === "NotReady" &&
283 (e.status === 0 ||
284 e.status === 404 ||
285 e.status === 502 ||
286 e.status === 503),
287 schedule: Schedule.spaced("3 seconds"),
288 times: 8,
289 }),
290 Effect.timeout("60 seconds"),
291 Effect.tapError(() => diagnose),
292 );
293
294 const ping = yield* getJson("/ping");
295 expect(ping.ok).toEqual(true);
296
297 for (const path of ["/health", "/direct"]) {
298 const health = yield* getJson(path);
299 const executed = health.rows;
300 const list = Array.isArray(executed)
301 ? executed
302 : executed !== null &&
303 typeof executed === "object" &&
304 Array.isArray((executed as { rows?: unknown }).rows)
305 ? (executed as { rows: unknown[] }).rows
306 : [];
307 const first = list[0] as { ok?: unknown } | undefined;
308 expect(Number(first?.ok)).toEqual(1);
309 }
310
311 yield* stack.destroy();
312
313 const clusterGone = yield* waitUntilClusterGone(deployed.db.clusterId);
314 expect(clusterGone).toEqual("gone");
315 const appGone = yield* waitUntilAppGone(deployed.app.appName);
316 expect(appGone).toEqual("gone");
317 }).pipe(logLevel),
318 {
319 tags: [
320 "provider:fly",
321 "provider:fly:app",
322 "provider:fly:ipassignment",
323 "provider:fly:machine",
324 "provider:fly:postgres",
325 "provider:fly:service",
326 "live",
327 ],
328 timeout: 300_000,
329 },
330);