| 1 | import { Query } from "@distilled.cloud/core/query"; |
| 2 | import { Railway as RailwayApi } from "@distilled.cloud/railway"; |
| 3 | import * as Provider from "@/Provider"; |
| 4 | import * as Railway from "@/Railway"; |
| 5 | import { suitePartition } from "./suiteProject.ts"; |
| 6 | import * as Test from "@/Test/Alchemy"; |
| 7 | import { expect } from "alchemy-test"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import { MinimumLogLevel } from "effect/References"; |
| 10 | import * as Result from "effect/Result"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | import * as HttpClient from "effect/http/HttpClient"; |
| 13 | |
| 14 | const { test } = Test.make({ providers: Railway.providers() }); |
| 15 | |
| 16 | const logLevel = Effect.provideService( |
| 17 | MinimumLogLevel, |
| 18 | process.env.DEBUG ? "Debug" : "Info", |
| 19 | ); |
| 20 | |
| 21 | const isGoneStatus = (status: string | undefined) => status === "DESTROYED"; |
| 22 | |
| 23 | // Railway answers a missing sandbox with `null`, not an error. |
| 24 | const readSandboxStatus = Query.fn((environmentId: string, id: string) => |
| 25 | RailwayApi.sandbox({ environmentId, id }).pipe( |
| 26 | Query.map((sandbox) => ({ status: sandbox.status })), |
| 27 | ), |
| 28 | ); |
| 29 | |
| 30 | const readSandbox = Query.fn((environmentId: string, id: string) => |
| 31 | RailwayApi.sandbox({ environmentId, id }).pipe( |
| 32 | Query.map((sandbox) => ({ |
| 33 | id: sandbox.id, |
| 34 | environmentId: sandbox.environmentId, |
| 35 | status: sandbox.status, |
| 36 | idleTimeoutMinutes: sandbox.idleTimeoutMinutes, |
| 37 | })), |
| 38 | ), |
| 39 | ); |
| 40 | |
| 41 | const readSandboxDomains = Query.fn((environmentId: string, id: string) => |
| 42 | RailwayApi.sandbox({ environmentId, id }).pipe( |
| 43 | Query.map((sandbox) => ({ |
| 44 | domains: sandbox.domains.pipe( |
| 45 | Query.map((domain) => ({ |
| 46 | domain: domain.domain, |
| 47 | port: domain.port, |
| 48 | prefix: domain.prefix, |
| 49 | })), |
| 50 | ), |
| 51 | networkIsolation: sandbox.networkIsolation, |
| 52 | })), |
| 53 | ), |
| 54 | ); |
| 55 | |
| 56 | const createSandbox = Query.fn( |
| 57 | (input: { environmentId: string; idleTimeoutMinutes: number }) => { |
| 58 | const sandbox = RailwayApi.sandboxCreate({ input }); |
| 59 | return { environmentId: sandbox.environmentId, id: sandbox.id }; |
| 60 | }, |
| 61 | ); |
| 62 | |
| 63 | const destroySandbox = Query.fn((environmentId: string, id: string) => |
| 64 | RailwayApi.sandboxDestroy({ environmentId, id }).pipe( |
| 65 | Query.map((sandbox) => ({ id: sandbox.id })), |
| 66 | ), |
| 67 | ); |
| 68 | |
| 69 | const waitUntilGone = (environmentId: string, sandboxId: string) => |
| 70 | readSandboxStatus(environmentId, sandboxId).pipe( |
| 71 | Effect.map((sandbox) => |
| 72 | sandbox === null || isGoneStatus(sandbox.status) |
| 73 | ? ("gone" as const) |
| 74 | : ("found" as const), |
| 75 | ), |
| 76 | Effect.repeat({ |
| 77 | schedule: Schedule.spaced("1 second"), |
| 78 | until: (status) => status === "gone", |
| 79 | times: 10, |
| 80 | }), |
| 81 | ); |
| 82 | |
| 83 | const destroyLive = (environmentId: string, sandboxId: string) => |
| 84 | destroySandbox(environmentId, sandboxId).pipe( |
| 85 | Effect.flatMap(() => waitUntilGone(environmentId, sandboxId)), |
| 86 | ); |
| 87 | |
| 88 | test.provider( |
| 89 | "sandbox create succeeds or surfaces a typed authorization error", |
| 90 | (stack) => |
| 91 | Effect.gen(function* () { |
| 92 | yield* stack.destroy(); |
| 93 | |
| 94 | const created = yield* stack.deploy( |
| 95 | Effect.gen(function* () { |
| 96 | const { project, environment } = yield* suitePartition; |
| 97 | return { project, environment }; |
| 98 | }), |
| 99 | ); |
| 100 | |
| 101 | const result = yield* Effect.result( |
| 102 | createSandbox({ |
| 103 | environmentId: created.environment.environmentId, |
| 104 | idleTimeoutMinutes: 5, |
| 105 | }), |
| 106 | ); |
| 107 | |
| 108 | if (Result.isSuccess(result)) { |
| 109 | yield* Effect.logInfo( |
| 110 | "sandboxes are entitled on this token; probe is a no-op", |
| 111 | ); |
| 112 | yield* destroyLive(result.success.environmentId, result.success.id); |
| 113 | yield* stack.destroy(); |
| 114 | return; |
| 115 | } |
| 116 | |
| 117 | expect(result.failure._tag === "RailwayForbidden").toBe(true); |
| 118 | |
| 119 | yield* stack.destroy(); |
| 120 | }).pipe(logLevel), |
| 121 | { |
| 122 | tags: [ |
| 123 | "provider:railway", |
| 124 | "provider:railway:project", |
| 125 | "provider:railway:projectenvironment", |
| 126 | "provider:railway:sandbox", |
| 127 | "live", |
| 128 | ], |
| 129 | timeout: 120_000, |
| 130 | }, |
| 131 | ); |
| 132 | |
| 133 | test.provider( |
| 134 | "create, exec, and destroy a sandbox", |
| 135 | (stack) => |
| 136 | Effect.gen(function* () { |
| 137 | yield* stack.destroy(); |
| 138 | |
| 139 | const created = yield* stack.deploy( |
| 140 | Effect.gen(function* () { |
| 141 | const { project, environment } = yield* suitePartition; |
| 142 | const box = yield* Railway.Sandbox("Box", { |
| 143 | environment, |
| 144 | idleTimeoutMinutes: 5, |
| 145 | }); |
| 146 | return { project, environment, box }; |
| 147 | }), |
| 148 | ); |
| 149 | |
| 150 | expect(created.box.sandboxId).toEqual(expect.any(String)); |
| 151 | expect(created.box.sandboxId.length).toBeGreaterThan(0); |
| 152 | expect(created.box.environmentId).toEqual( |
| 153 | created.environment.environmentId, |
| 154 | ); |
| 155 | expect(created.box.projectId).toEqual(created.project.projectId); |
| 156 | expect(created.box.status).toEqual("RUNNING"); |
| 157 | expect(created.box.region).toEqual(expect.any(String)); |
| 158 | expect(created.box.region.length).toBeGreaterThan(0); |
| 159 | expect(created.box.createdAt).toEqual(expect.any(String)); |
| 160 | expect(created.box.idleTimeoutMinutes).toEqual(5); |
| 161 | expect(created.box.domains).toEqual([]); |
| 162 | |
| 163 | const fetched = yield* readSandbox( |
| 164 | created.box.environmentId, |
| 165 | created.box.sandboxId, |
| 166 | ); |
| 167 | if (fetched === null) { |
| 168 | return yield* Effect.fail( |
| 169 | new Error("Deployed Railway sandbox was not found"), |
| 170 | ); |
| 171 | } |
| 172 | expect(fetched.id).toEqual(created.box.sandboxId); |
| 173 | expect(fetched.environmentId).toEqual(created.box.environmentId); |
| 174 | expect(fetched.status).toEqual("RUNNING"); |
| 175 | expect(fetched.idleTimeoutMinutes).toEqual(5); |
| 176 | |
| 177 | const executed = yield* Railway.execSandbox({ |
| 178 | sandboxId: created.box.sandboxId, |
| 179 | environmentId: created.box.environmentId, |
| 180 | command: "echo hello", |
| 181 | timeoutSec: 30, |
| 182 | }); |
| 183 | expect(executed.exitCode).toEqual(0); |
| 184 | expect(executed.timedOut).toEqual(false); |
| 185 | expect(executed.stdout).toContain("hello"); |
| 186 | |
| 187 | const provider = yield* Provider.findProvider(Railway.Sandbox); |
| 188 | const listed = yield* provider.list(); |
| 189 | const found = listed.find( |
| 190 | (sandbox) => sandbox.sandboxId === created.box.sandboxId, |
| 191 | ); |
| 192 | expect(found).toBeDefined(); |
| 193 | expect(found?.environmentId).toEqual(created.box.environmentId); |
| 194 | expect(found?.status).toEqual("RUNNING"); |
| 195 | |
| 196 | yield* stack.destroy(); |
| 197 | |
| 198 | const gone = yield* waitUntilGone( |
| 199 | created.box.environmentId, |
| 200 | created.box.sandboxId, |
| 201 | ); |
| 202 | expect(gone).toEqual("gone"); |
| 203 | }).pipe(logLevel), |
| 204 | { |
| 205 | tags: [ |
| 206 | "provider:railway", |
| 207 | "provider:railway:project", |
| 208 | "provider:railway:projectenvironment", |
| 209 | "provider:railway:sandbox", |
| 210 | "live", |
| 211 | ], |
| 212 | timeout: 120_000, |
| 213 | }, |
| 214 | ); |
| 215 | |
| 216 | test.provider( |
| 217 | "publishes a domain with custom resources and replaces removed options", |
| 218 | (stack) => |
| 219 | Effect.gen(function* () { |
| 220 | yield* stack.destroy(); |
| 221 | const deploy = (configured: boolean) => |
| 222 | stack.deploy( |
| 223 | Effect.gen(function* () { |
| 224 | const { environment } = yield* suitePartition; |
| 225 | const box = yield* Railway.Sandbox("Preview", { |
| 226 | environment, |
| 227 | idleTimeoutMinutes: 5, |
| 228 | ...(configured |
| 229 | ? { |
| 230 | networkIsolation: "PRIVATE" as const, |
| 231 | publicDomains: [{ port: 8080 }], |
| 232 | resources: { cpu: 0.5, memoryGB: 1 }, |
| 233 | variables: { ALCHEMY_SANDBOX_TEST: "configured" }, |
| 234 | } |
| 235 | : {}), |
| 236 | }); |
| 237 | return { box }; |
| 238 | }), |
| 239 | ); |
| 240 | const { box } = yield* deploy(true); |
| 241 | expect(box.status).toBe("RUNNING"); |
| 242 | expect(box.networkIsolation).toBe("PRIVATE"); |
| 243 | expect(box.domains).toHaveLength(1); |
| 244 | expect(box.domains[0]?.port).toBe(8080); |
| 245 | const observed = yield* readSandboxDomains( |
| 246 | box.environmentId, |
| 247 | box.sandboxId, |
| 248 | ); |
| 249 | expect(observed?.domains).toEqual(box.domains); |
| 250 | const started = yield* Railway.execSandbox({ |
| 251 | ...box, |
| 252 | command: `python3 -c 'import os, pathlib, subprocess; root = pathlib.Path("/tmp/alchemy-http"); root.mkdir(exist_ok=True); (root / "index.html").write_text(os.environ["ALCHEMY_SANDBOX_TEST"]); subprocess.Popen(["python3", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", str(root)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)'`, |
| 253 | timeoutSec: 10, |
| 254 | }); |
| 255 | expect(started.exitCode).toBe(0); |
| 256 | const client = yield* HttpClient.HttpClient; |
| 257 | const body = yield* client.get(`https://${box.domains[0]!.domain}`).pipe( |
| 258 | Effect.flatMap((response) => |
| 259 | response.status === 200 |
| 260 | ? response.text |
| 261 | : Effect.fail(new Error(`HTTP ${response.status}`)), |
| 262 | ), |
| 263 | Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 8 }), |
| 264 | ); |
| 265 | expect(body).toBe("configured"); |
| 266 | const unchanged = yield* deploy(true); |
| 267 | expect(unchanged.box.sandboxId).toBe(box.sandboxId); |
| 268 | const cleared = yield* deploy(false); |
| 269 | expect(cleared.box.sandboxId).not.toBe(box.sandboxId); |
| 270 | expect(cleared.box.domains).toEqual([]); |
| 271 | expect(cleared.box.networkIsolation).toBe("ISOLATED"); |
| 272 | const env = yield* Railway.execSandbox({ |
| 273 | ...cleared.box, |
| 274 | command: "printf '%s' \"${ALCHEMY_SANDBOX_TEST-unset}\"", |
| 275 | timeoutSec: 10, |
| 276 | }); |
| 277 | expect(env.stdout).toBe("unset"); |
| 278 | expect(yield* waitUntilGone(box.environmentId, box.sandboxId)).toBe( |
| 279 | "gone", |
| 280 | ); |
| 281 | yield* stack.destroy(); |
| 282 | expect( |
| 283 | yield* waitUntilGone(cleared.box.environmentId, cleared.box.sandboxId), |
| 284 | ).toBe("gone"); |
| 285 | }).pipe(logLevel), |
| 286 | { |
| 287 | tags: [ |
| 288 | "provider:railway", |
| 289 | "provider:railway:project", |
| 290 | "provider:railway:projectenvironment", |
| 291 | "provider:railway:sandbox", |
| 292 | "live", |
| 293 | ], |
| 294 | timeout: 120_000, |
| 295 | }, |
| 296 | ); |
| 297 | |
| 298 | test.provider( |
| 299 | "forks a running sandbox disk without inheriting variables", |
| 300 | (stack) => |
| 301 | Effect.gen(function* () { |
| 302 | yield* stack.destroy(); |
| 303 | const program = (fork: boolean) => |
| 304 | Effect.gen(function* () { |
| 305 | const { environment } = yield* suitePartition; |
| 306 | const source = yield* Railway.Sandbox("Source", { |
| 307 | environment, |
| 308 | idleTimeoutMinutes: 5, |
| 309 | variables: { SOURCE_ONLY: "source" }, |
| 310 | }); |
| 311 | const copy = fork |
| 312 | ? yield* Railway.Sandbox("Fork", { |
| 313 | environment, |
| 314 | sourceSandboxId: source.sandboxId, |
| 315 | idleTimeoutMinutes: 5, |
| 316 | resources: { cpu: 1 }, |
| 317 | variables: { FORK_ONLY: "fork" }, |
| 318 | }) |
| 319 | : undefined; |
| 320 | return { source, copy }; |
| 321 | }); |
| 322 | const { source } = yield* stack.deploy(program(false)); |
| 323 | expect( |
| 324 | (yield* Railway.execSandbox({ |
| 325 | ...source, |
| 326 | command: "printf disk-content > /tmp/alchemy-fork", |
| 327 | timeoutSec: 10, |
| 328 | })).exitCode, |
| 329 | ).toBe(0); |
| 330 | const { copy } = yield* stack.deploy(program(true)); |
| 331 | expect(copy).toBeDefined(); |
| 332 | expect(copy!.sandboxId).not.toBe(source.sandboxId); |
| 333 | const read = yield* Railway.execSandbox({ |
| 334 | ...copy!, |
| 335 | command: |
| 336 | 'cat /tmp/alchemy-fork; printf \'\\n%s:%s\' "${SOURCE_ONLY-unset}" "$FORK_ONLY"', |
| 337 | timeoutSec: 10, |
| 338 | }); |
| 339 | expect(read.exitCode).toBe(0); |
| 340 | expect(read.stdout).toContain("disk-content"); |
| 341 | expect(read.stdout).toContain("unset:fork"); |
| 342 | expect( |
| 343 | (yield* Railway.execSandbox({ |
| 344 | ...copy!, |
| 345 | command: "printf fork-content > /tmp/alchemy-fork", |
| 346 | timeoutSec: 10, |
| 347 | })).exitCode, |
| 348 | ).toBe(0); |
| 349 | expect( |
| 350 | (yield* Railway.execSandbox({ |
| 351 | ...source, |
| 352 | command: "cat /tmp/alchemy-fork", |
| 353 | timeoutSec: 10, |
| 354 | })).stdout, |
| 355 | ).toBe("disk-content"); |
| 356 | yield* stack.destroy(); |
| 357 | expect(yield* waitUntilGone(source.environmentId, source.sandboxId)).toBe( |
| 358 | "gone", |
| 359 | ); |
| 360 | expect(yield* waitUntilGone(copy!.environmentId, copy!.sandboxId)).toBe( |
| 361 | "gone", |
| 362 | ); |
| 363 | }).pipe(logLevel), |
| 364 | { |
| 365 | tags: [ |
| 366 | "provider:railway", |
| 367 | "provider:railway:project", |
| 368 | "provider:railway:projectenvironment", |
| 369 | "provider:railway:sandbox", |
| 370 | "live", |
| 371 | ], |
| 372 | timeout: 120_000, |
| 373 | }, |
| 374 | ); |