| 1 | import * as machines from "@distilled.cloud/fly-io/machines"; |
| 2 | import * as mpg from "@distilled.cloud/fly-io/mpg"; |
| 3 | import * as Fly from "@/Fly"; |
| 4 | import * as Provider from "@/Provider"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Data from "effect/Data"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import { MinimumLogLevel } from "effect/References"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | import * as HttpClient from "effect/http/HttpClient"; |
| 12 | import PostgresApi, { Db, MpgIp, MpgSite } from "./fixtures/postgres-api.ts"; |
| 13 | |
| 14 | const { test } = Test.make({ providers: Fly.providers() }); |
| 15 | |
| 16 | const logLevel = Effect.provideService( |
| 17 | MinimumLogLevel, |
| 18 | process.env.DEBUG ? "Debug" : "Info", |
| 19 | ); |
| 20 | |
| 21 | const 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 | |
| 36 | const 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 | |
| 47 | const 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 | |
| 58 | const 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 | |
| 71 | test.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 | |
| 96 | test.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 | |
| 172 | test.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 | ); |