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 {
6 activeReplicaRegions,
7 serviceRegionPlacement,
8} from "@/Railway/ServiceRegion.ts";
9import { suitePartition } from "./suiteProject.ts";
10import * as Test from "@/Test/Alchemy";
11import { expect } from "alchemy-test";
12import * as Effect from "effect/Effect";
13import { MinimumLogLevel } from "effect/References";
14import * as Schedule from "effect/Schedule";
15import * as HttpClient from "effect/http/HttpClient";
16import { AsyncPing } from "./fixtures/async-ping-fn.ts";
17import Ping from "./fixtures/ping.ts";
18
19const { test } = Test.make({ providers: Railway.providers() });
20
21const logLevel = Effect.provideService(
22 MinimumLogLevel,
23 process.env.DEBUG ? "Debug" : "Info",
24);
25
26const HTTP_SOURCE = `
27Bun.serve({
28 hostname: "0.0.0.0",
29 port: Number(process.env.PORT ?? 3000),
30 fetch() {
31 return new Response("ok");
32 },
33});
34`;
35
36const readServiceDeletedAt = Query.fn((id: string) => ({
37 deletedAt: RailwayApi.service({ id }).deletedAt,
38}));
39
40const readService = Query.fn((id: string) => {
41 const service = RailwayApi.service({ id });
42 return {
43 id: service.id,
44 name: service.name,
45 projectId: service.projectId,
46 deletedAt: service.deletedAt,
47 };
48});
49
50const readServiceInstance = Query.fn(
51 (environmentId: string, serviceId: string) => {
52 const instance = RailwayApi.serviceInstance({ environmentId, serviceId });
53 return {
54 serviceId: instance.serviceId,
55 environmentId: instance.environmentId,
56 source: instance.source.pipe(
57 Query.map((source) => ({ image: source.image })),
58 ),
59 startCommand: instance.startCommand,
60 cronSchedule: instance.cronSchedule,
61 };
62 },
63);
64
65const readBunRuntime = Query.fn(() => {
66 const runtime = RailwayApi.functionRuntime({ name: "bun" });
67 return {
68 name: runtime.name,
69 latestVersion: { image: runtime.latestVersion.image },
70 };
71});
72
73const readEnvironmentConfig = Query.fn((id: string, projectId: string) => ({
74 config: RailwayApi.environment({ id, projectId }).config,
75}));
76
77const waitUntilGone = (serviceId: string) =>
78 readServiceDeletedAt(serviceId).pipe(
79 Effect.map((service) =>
80 service.deletedAt != null ? ("gone" as const) : ("found" as const),
81 ),
82 Effect.catchTag("RailwayNotFound", () => Effect.succeed("gone" as const)),
83 Effect.repeat({
84 schedule: Schedule.spaced("1 second"),
85 until: (status) => status === "gone",
86 times: 10,
87 }),
88 );
89
90test.provider(
91 "create, list, and delete a canvas Function",
92 (stack) =>
93 Effect.gen(function* () {
94 yield* stack.destroy();
95
96 const created = yield* stack.deploy(
97 Effect.gen(function* () {
98 const { project, environment } = yield* suitePartition;
99 const ping = yield* Railway.Function("Ping", {
100 project,
101 environment,
102 source: HTTP_SOURCE,
103 });
104 const job = yield* Railway.Function("Cleanup", {
105 project,
106 environment,
107 source: `console.log("tick");`,
108 cronSchedule: "0 * * * *",
109 });
110 return { project, environment, ping, job };
111 }),
112 );
113
114 expect(created.ping.serviceId).toEqual(expect.any(String));
115 expect(created.ping.serviceId.length).toBeGreaterThan(0);
116 expect(created.ping.projectId).toEqual(created.project.projectId);
117 expect(created.ping.environmentId).toEqual(
118 created.environment.environmentId,
119 );
120 expect(created.ping.name).toEqual(expect.any(String));
121 expect(created.ping.name.length).toBeGreaterThan(0);
122 expect(created.ping.name.length).toBeLessThanOrEqual(32);
123 expect(created.ping.name).toMatch(/^[a-z][a-z0-9-]*$/);
124 expect(created.ping.runtime).toEqual("bun");
125 expect(created.ping.image).toEqual(expect.any(String));
126 expect(Railway.isFunctionImage(created.ping.image)).toEqual(true);
127 expect(created.ping.code.hash).toEqual(expect.any(String));
128 expect(created.ping.code.hash.length).toBeGreaterThan(0);
129 expect(created.ping.domain).toEqual(expect.any(String));
130 expect(created.ping.domain).toContain("up.railway.app");
131 expect(created.ping.url).toEqual(`https://${created.ping.domain}`);
132 expect(created.ping.dnsName).toEqual(
133 `${created.ping.name}.railway.internal`,
134 );
135 expect(created.ping.rpcToken.length).toBeGreaterThanOrEqual(32);
136 expect(created.ping.domainId).toEqual(expect.any(String));
137 expect(created.ping.domainId!.length).toBeGreaterThan(0);
138
139 const fetched = yield* readService(created.ping.serviceId);
140 expect(fetched.id).toEqual(created.ping.serviceId);
141 expect(fetched.name).toEqual(created.ping.name);
142 expect(fetched.projectId).toEqual(created.ping.projectId);
143 expect(fetched.deletedAt).toBeNull();
144
145 const instance = yield* readServiceInstance(
146 created.ping.environmentId,
147 created.ping.serviceId,
148 );
149 expect(instance.serviceId).toEqual(created.ping.serviceId);
150 expect(instance.environmentId).toEqual(created.ping.environmentId);
151 expect(Railway.isFunctionImage(instance.source?.image)).toEqual(true);
152 expect(instance.startCommand).toEqual(
153 expect.stringMatching(/^\.\/run\.sh /),
154 );
155
156 const runtime = yield* readBunRuntime();
157 expect(runtime.name).toEqual("bun");
158 expect(runtime.latestVersion.image.length).toBeGreaterThan(0);
159 expect(instance.source?.image).toEqual(runtime.latestVersion.image);
160
161 const provider = yield* Provider.findProvider(Railway.Function);
162 const listed = yield* provider.list();
163 const found = listed.find(
164 (fn) => fn.serviceId === created.ping.serviceId,
165 );
166 expect(found).toBeDefined();
167 expect(found?.name).toEqual(created.ping.name);
168 expect(found?.projectId).toEqual(created.ping.projectId);
169 expect(Railway.isFunctionImage(found?.image)).toEqual(true);
170
171 const client = yield* HttpClient.HttpClient;
172 const body = yield* client.get(created.ping.url!).pipe(
173 Effect.flatMap((res) =>
174 res.status === 200
175 ? res.text
176 : Effect.fail(new Error(`function returned ${res.status}`)),
177 ),
178 Effect.retry({
179 schedule: Schedule.spaced("4 seconds"),
180 times: 10,
181 }),
182 );
183 expect(body).toEqual("ok");
184
185 expect(created.job.serviceId).toEqual(expect.any(String));
186 expect(created.job.cronSchedule).toEqual("0 * * * *");
187 expect(created.job.url).toBeUndefined();
188 expect(created.job.domain).toBeUndefined();
189 const jobInstance = yield* readServiceInstance(
190 created.job.environmentId,
191 created.job.serviceId,
192 );
193 expect(jobInstance.cronSchedule).toEqual("0 * * * *");
194 expect(Railway.isFunctionImage(jobInstance.source?.image)).toEqual(true);
195
196 yield* stack.destroy();
197
198 const gone = yield* waitUntilGone(created.ping.serviceId);
199 expect(gone).toEqual("gone");
200 }).pipe(logLevel),
201 {
202 tags: [
203 "provider:railway",
204 "provider:railway:function",
205 "provider:railway:project",
206 "provider:railway:projectenvironment",
207 "provider:railway:service",
208 "live",
209 ],
210 timeout: 120_000,
211 },
212);
213
214test.provider(
215 "create, serve, and delete an async Function (main + fetch, no Effect)",
216 (stack) =>
217 Effect.gen(function* () {
218 yield* stack.destroy();
219
220 const created = yield* stack.deploy(
221 Effect.gen(function* () {
222 const ping = yield* AsyncPing;
223 return { ping };
224 }),
225 );
226
227 expect(created.ping.serviceId).toEqual(expect.any(String));
228 expect(created.ping.runtime).toEqual("bun");
229 expect(Railway.isFunctionImage(created.ping.image)).toEqual(true);
230 expect(created.ping.code.hash).toEqual(expect.any(String));
231 expect(created.ping.domain).toContain("up.railway.app");
232 expect(created.ping.url).toEqual(`https://${created.ping.domain}`);
233 expect(created.ping.dnsName).toEqual(
234 `${created.ping.name}.railway.internal`,
235 );
236
237 const instance = yield* readServiceInstance(
238 created.ping.environmentId,
239 created.ping.serviceId,
240 );
241 expect(Railway.isFunctionImage(instance.source?.image)).toEqual(true);
242 expect(instance.startCommand).toEqual(
243 expect.stringMatching(/^\.\/run\.sh /),
244 );
245 expect(instance.startCommand!.length).toBeLessThanOrEqual(
246 Railway.FUNCTION_MAX_BYTES,
247 );
248
249 const client = yield* HttpClient.HttpClient;
250 const body = yield* client.get(created.ping.url!).pipe(
251 Effect.flatMap((res) =>
252 res.status === 200
253 ? res.text
254 : res.text.pipe(
255 Effect.flatMap((text) =>
256 Effect.fail(
257 new Error(`function returned ${res.status}: ${text}`),
258 ),
259 ),
260 ),
261 ),
262 Effect.retry({
263 schedule: Schedule.spaced("4 seconds"),
264 times: 10,
265 }),
266 );
267 expect(body).toEqual("ok");
268
269 yield* stack.destroy();
270
271 const gone = yield* waitUntilGone(created.ping.serviceId);
272 expect(gone).toEqual("gone");
273 }).pipe(logLevel),
274 {
275 tags: [
276 "provider:railway",
277 "provider:railway:function",
278 "provider:railway:project",
279 "provider:railway:projectenvironment",
280 "provider:railway:service",
281 "live",
282 ],
283 timeout: 120_000,
284 },
285);
286
287test.provider.skip(
288 "create, serve, and delete an Effect-native Function (canvas start command max 96KB)",
289 (stack) =>
290 Effect.gen(function* () {
291 yield* stack.destroy();
292
293 const created = yield* stack.deploy(
294 Effect.gen(function* () {
295 const ping = yield* Ping;
296 return { ping };
297 }),
298 );
299
300 expect(created.ping.serviceId).toEqual(expect.any(String));
301 expect(created.ping.runtime).toEqual("bun");
302 expect(Railway.isFunctionImage(created.ping.image)).toEqual(true);
303 expect(created.ping.code.hash).toEqual(expect.any(String));
304 expect(created.ping.domain).toContain("up.railway.app");
305 expect(created.ping.url).toEqual(`https://${created.ping.domain}`);
306 expect(created.ping.dnsName).toEqual(
307 `${created.ping.name}.railway.internal`,
308 );
309 expect(created.ping.rpcToken.length).toBeGreaterThanOrEqual(32);
310
311 const instance = yield* readServiceInstance(
312 created.ping.environmentId,
313 created.ping.serviceId,
314 );
315 expect(Railway.isFunctionImage(instance.source?.image)).toEqual(true);
316 expect(instance.startCommand).toEqual(
317 expect.stringMatching(/^\.\/run\.sh /),
318 );
319
320 const client = yield* HttpClient.HttpClient;
321 const body = yield* client.get(created.ping.url!).pipe(
322 Effect.timeout("10 seconds"),
323 Effect.flatMap((res) =>
324 res.status === 200
325 ? res.text
326 : res.text.pipe(
327 Effect.flatMap((text) =>
328 Effect.fail(
329 new Error(`function returned ${res.status}: ${text}`),
330 ),
331 ),
332 ),
333 ),
334 Effect.retry({
335 schedule: Schedule.spaced("3 seconds"),
336 times: 5,
337 }),
338 );
339 expect(body).toEqual("ok");
340
341 yield* stack.destroy();
342
343 const gone = yield* waitUntilGone(created.ping.serviceId);
344 expect(gone).toEqual("gone");
345 }).pipe(logLevel),
346 {
347 tags: [
348 "provider:railway",
349 "provider:railway:function",
350 "provider:railway:project",
351 "provider:railway:projectenvironment",
352 "provider:railway:service",
353 "live",
354 ],
355 timeout: 120_000,
356 },
357);
358
359const placedRegions = (config: unknown, serviceId: string) =>
360 activeReplicaRegions(serviceRegionPlacement(config, serviceId)).map(
361 (row) => row.region,
362 );
363
364test.provider(
365 "pin a function to a region and move it",
366 (stack) =>
367 Effect.gen(function* () {
368 yield* stack.destroy();
369
370 const region = "europe-west4-drams3a";
371 const created = yield* stack.deploy(
372 Effect.gen(function* () {
373 const { project, environment } = yield* suitePartition;
374 const ping = yield* Railway.Function("FnRegion", {
375 project,
376 environment,
377 source: `console.log("tick");`,
378 http: false,
379 region,
380 });
381 return { ping };
382 }),
383 );
384
385 expect(created.ping.region).toEqual(region);
386 const createdConfig = yield* readEnvironmentConfig(
387 created.ping.environmentId,
388 created.ping.projectId,
389 );
390 expect(
391 placedRegions(createdConfig.config, created.ping.serviceId),
392 ).toEqual([region]);
393
394 const moved = "us-west2";
395 const updated = yield* stack.deploy(
396 Effect.gen(function* () {
397 const { project, environment } = yield* suitePartition;
398 const ping = yield* Railway.Function("FnRegion", {
399 project,
400 environment,
401 source: `console.log("tick");`,
402 http: false,
403 region: moved,
404 });
405 return { ping };
406 }),
407 );
408
409 expect(updated.ping.serviceId).toEqual(created.ping.serviceId);
410 expect(updated.ping.region).toEqual(moved);
411 const updatedConfig = yield* readEnvironmentConfig(
412 updated.ping.environmentId,
413 updated.ping.projectId,
414 );
415 expect(
416 placedRegions(updatedConfig.config, updated.ping.serviceId),
417 ).toEqual([moved]);
418
419 yield* stack.destroy();
420
421 const gone = yield* waitUntilGone(created.ping.serviceId);
422 expect(gone).toEqual("gone");
423 }).pipe(logLevel),
424 { timeout: 180_000 },
425);