1import { Query } from "@distilled.cloud/core/query";
2import { Railway as RailwayApi } from "@distilled.cloud/railway";
3import * as Provider from "@/Provider";
4import * as Railway from "@/Railway";
5import { suitePartition } from "./suiteProject.ts";
6import * as Test from "@/Test/Alchemy";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Result from "effect/Result";
11import * as Schedule from "effect/Schedule";
12import * as HttpClient from "effect/http/HttpClient";
13
14const { test } = Test.make({ providers: Railway.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21const isGoneStatus = (status: string | undefined) => status === "DESTROYED";
22
23// Railway answers a missing sandbox with `null`, not an error.
24const readSandboxStatus = Query.fn((environmentId: string, id: string) =>
25 RailwayApi.sandbox({ environmentId, id }).pipe(
26 Query.map((sandbox) => ({ status: sandbox.status })),
27 ),
28);
29
30const 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
41const 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
56const 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
63const destroySandbox = Query.fn((environmentId: string, id: string) =>
64 RailwayApi.sandboxDestroy({ environmentId, id }).pipe(
65 Query.map((sandbox) => ({ id: sandbox.id })),
66 ),
67);
68
69const 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
83const destroyLive = (environmentId: string, sandboxId: string) =>
84 destroySandbox(environmentId, sandboxId).pipe(
85 Effect.flatMap(() => waitUntilGone(environmentId, sandboxId)),
86 );
87
88test.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
133test.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
216test.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
298test.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);