1import { RailwayAuth } from "@/Railway/AuthProvider.ts";
2import { fromAuthProvider } from "@/Railway/Credentials.ts";
3import { fromCredentials } from "@distilled.cloud/aws/Credentials";
4import * as AwsEndpoint from "@distilled.cloud/aws/Endpoint";
5import type { RegionName } from "@distilled.cloud/aws/Region";
6import * as S3 from "@distilled.cloud/aws/s3";
7import { Query } from "@distilled.cloud/core/query";
8import { GraphQLLive, Railway as RailwayApi } from "@distilled.cloud/railway";
9import * as Alchemy from "@/index.ts";
10import * as Railway from "@/Railway";
11import * as Test from "@/Test/Alchemy";
12import { describe, expect } from "alchemy-test";
13import * as Data from "effect/Data";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import { MinimumLogLevel } from "effect/References";
17import * as Schedule from "effect/Schedule";
18import * as Stream from "effect/Stream";
19import * as HttpClient from "effect/http/HttpClient";
20import type * as HttpClientResponse from "effect/http/HttpClientResponse";
21import BucketApi, {
22 Data as BucketData,
23 OBJECT_BODY,
24 OBJECT_KEY,
25} from "./fixtures/bucket-api.ts";
26import RedisApi, { Cache, REDIS_VALUE } from "./fixtures/redis-api.ts";
27import { Site } from "./fixtures/bindings-shared.ts";
28
29const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
30 providers: Railway.providers(),
31});
32
33const logLevel = Effect.provideService(
34 MinimumLogLevel,
35 process.env.DEBUG ? "Debug" : "Info",
36);
37
38const distilled = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
39 effect.pipe(
40 Effect.provide(
41 Layer.merge(
42 GraphQLLive,
43 fromAuthProvider().pipe(Layer.provide(RailwayAuth)),
44 ),
45 ),
46 );
47
48class NotReady extends Data.TaggedError("NotReady")<{
49 status: number;
50 body?: unknown;
51}> {
52 override get message() {
53 return this.body === undefined
54 ? `status ${this.status}`
55 : `status ${this.status}: ${JSON.stringify(this.body)}`;
56 }
57}
58
59const asVariableMap = (value: unknown): Record<string, string> => {
60 if (value === null || typeof value !== "object" || Array.isArray(value)) {
61 return {};
62 }
63 const out: Record<string, string> = {};
64 for (const [key, item] of Object.entries(value)) {
65 if (typeof item === "string") {
66 out[key] = item;
67 }
68 }
69 return out;
70};
71
72const readBucketCredentials = Query.fn(
73 (bucketId: string, environmentId: string, projectId: string) =>
74 RailwayApi.bucketS3Credentials({ bucketId, environmentId, projectId }).pipe(
75 Query.map((creds) => ({
76 bucketName: creds.bucketName,
77 endpoint: creds.endpoint,
78 accessKeyId: creds.accessKeyId,
79 secretAccessKey: creds.secretAccessKey,
80 region: creds.region,
81 })),
82 ),
83);
84
85const readVariables = Query.fn(
86 (projectId: string, environmentId: string, serviceId: string) =>
87 RailwayApi.variables({
88 projectId,
89 environmentId,
90 serviceId,
91 unrendered: true,
92 }),
93);
94
95const readServiceVariables = (
96 projectId: string,
97 environmentId: string,
98 serviceId: string,
99) =>
100 readVariables(projectId, environmentId, serviceId).pipe(
101 Effect.map(asVariableMap),
102 Effect.catchTag("RailwayNotFound", () =>
103 Effect.succeed({} as Record<string, string>),
104 ),
105 );
106
107const firstCredentials = (
108 bucketId: string,
109 environmentId: string,
110 projectId: string,
111) =>
112 readBucketCredentials(bucketId, environmentId, projectId).pipe(
113 Effect.flatMap((items) => {
114 const first = items[0];
115 return first !== undefined
116 ? Effect.succeed(first)
117 : Effect.fail(new Error("missing bucket credentials"));
118 }),
119 Effect.retry({
120 schedule: Schedule.spaced("2 seconds"),
121 times: 8,
122 }),
123 );
124
125const withBucketS3 = <A, E, R>(
126 creds: {
127 accessKeyId: string;
128 secretAccessKey: string;
129 endpoint: string;
130 region: string;
131 },
132 operation: Effect.Effect<A, E, R>,
133) =>
134 operation.pipe(
135 Effect.provide(
136 Layer.mergeAll(
137 fromCredentials(
138 {
139 accessKeyId: creds.accessKeyId,
140 secretAccessKey: creds.secretAccessKey,
141 },
142 creds.region as RegionName,
143 ),
144 AwsEndpoint.of(creds.endpoint),
145 ),
146 ),
147 );
148
149const Stack = Alchemy.Stack(
150 "RailwayBindingsFixture",
151 {
152 providers: Railway.providers(),
153 state: Alchemy.localState(),
154 },
155 Effect.gen(function* () {
156 const project = yield* Site;
157 const cache = yield* Cache;
158 const proxy = yield* Railway.TcpProxy("CacheProxy", {
159 redis: cache,
160 environment: cache,
161 applicationPort: Railway.REDIS_PORT,
162 });
163 const redisApi = yield* RedisApi;
164 const bucket = yield* BucketData;
165 const bucketApi = yield* BucketApi;
166 return {
167 redisProjectId: project.projectId,
168 redisEnvironmentId: cache.environmentId,
169 cacheServiceId: cache.serviceId,
170 proxyDomain: proxy.domain,
171 proxyPort: proxy.proxyPort,
172 redisUrl: redisApi.url,
173 redisServiceId: redisApi.serviceId,
174 bucketProjectId: project.projectId,
175 bucketEnvironmentId: bucket.environmentId,
176 bucketId: bucket.bucketId,
177 bucketUrl: bucketApi.url,
178 bucketServiceId: bucketApi.serviceId,
179 mode: "effect" as const,
180 };
181 }),
182);
183
184// Setup provisions Redis and deploys two runtime builds before tests can run.
185const stack = beforeAll(deploy(Stack), { timeout: 180_000 });
186afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
187 timeout: 120_000,
188});
189
190const retryTransient = {
191 while: (e: { _tag?: string; status?: number }) =>
192 e._tag === "NotReady" &&
193 (e.status === 0 ||
194 e.status === 404 ||
195 e.status === 500 ||
196 e.status === 502 ||
197 e.status === 503),
198 schedule: Schedule.exponential("500 millis").pipe(
199 Schedule.upTo({ duration: "45 seconds" }),
200 ),
201 times: 10,
202} as const;
203
204const readJson = (
205 res: HttpClientResponse.HttpClientResponse,
206): Effect.Effect<unknown, NotReady> =>
207 res.json.pipe(
208 Effect.catch(() => Effect.fail(new NotReady({ status: res.status }))),
209 Effect.flatMap((body) =>
210 res.status === 200
211 ? Effect.succeed(body)
212 : Effect.fail(new NotReady({ status: res.status, body })),
213 ),
214 );
215
216const getJson = (url: string, path: string) =>
217 Effect.gen(function* () {
218 const client = yield* HttpClient.HttpClient;
219 return yield* client.get(`${url}${path}`).pipe(
220 Effect.timeoutOrElse({
221 duration: "8 seconds",
222 orElse: () => Effect.fail(new NotReady({ status: 0 })),
223 }),
224 Effect.flatMap(readJson),
225 Effect.mapError((e) =>
226 e instanceof NotReady ? e : new NotReady({ status: 0, body: e }),
227 ),
228 Effect.retry(retryTransient),
229 );
230 });
231
232const getText = (url: string) =>
233 Effect.gen(function* () {
234 const client = yield* HttpClient.HttpClient;
235 return yield* client.get(url).pipe(
236 Effect.timeoutOrElse({
237 duration: "8 seconds",
238 orElse: () => Effect.fail(new NotReady({ status: 0 })),
239 }),
240 Effect.flatMap((res) =>
241 res.status === 200
242 ? res.text.pipe(
243 Effect.mapError(() => new NotReady({ status: res.status })),
244 )
245 : Effect.fail(new NotReady({ status: res.status })),
246 ),
247 Effect.mapError((e) =>
248 e instanceof NotReady ? e : new NotReady({ status: 0, body: e }),
249 ),
250 Effect.retry(retryTransient),
251 );
252 });
253
254describe(
255 "Railway Bindings",
256 {
257 tags: [
258 "provider:railway",
259 "provider:railway:bucket",
260 "provider:railway:project",
261 "provider:railway:projectenvironment",
262 "provider:railway:redis",
263 "provider:railway:service",
264 "provider:railway:tcpproxy",
265 "live",
266 ],
267 },
268 () => {
269 test(
270 "fixture is reachable over up.railway.app",
271 Effect.gen(function* () {
272 const out = yield* stack;
273 expect(out.redisUrl).toEqual(expect.any(String));
274 expect(out.redisUrl).toContain("up.railway.app");
275 expect(out.bucketUrl).toEqual(expect.any(String));
276 expect(out.bucketUrl).toContain("up.railway.app");
277
278 if (out.mode === "effect") {
279 const redisHealth = (yield* getJson(out.redisUrl!, "/health")) as {
280 pong?: boolean;
281 };
282 expect(redisHealth.pong).toEqual(true);
283 const bucketHealth = (yield* getJson(out.bucketUrl!, "/health")) as {
284 ok?: boolean;
285 };
286 expect(bucketHealth.ok).toEqual(true);
287 } else {
288 const redisBody = yield* getText(out.redisUrl!);
289 expect(redisBody.length).toBeGreaterThan(0);
290 const bucketBody = yield* getText(out.bucketUrl!);
291 expect(bucketBody.length).toBeGreaterThan(0);
292 }
293 }).pipe(logLevel),
294 { timeout: 120_000 },
295 );
296
297 describe("ReadWriteRedis", { tags: ["provider:railway:variable"] }, () => {
298 test(
299 "sets and gets a key",
300 Effect.gen(function* () {
301 const out = yield* stack;
302 if (out.mode === "effect") {
303 const written = (yield* getJson(out.redisUrl!, "/set")) as {
304 ok?: boolean;
305 };
306 expect(written.ok).toEqual(true);
307 const read = (yield* getJson(out.redisUrl!, "/get")) as {
308 ok?: boolean;
309 value?: string;
310 };
311 expect(read.ok).toEqual(true);
312 expect(read.value).toEqual(REDIS_VALUE);
313 return;
314 }
315
316 // Public image fallback: docker push is impossible without a
317 // registry. ReadWriteRedis still packed REDIS_URL onto the
318 // Service; set/get runs over the public TCP proxy.
319 const cacheVars = yield* distilled(
320 readServiceVariables(
321 out.redisProjectId,
322 out.redisEnvironmentId,
323 out.cacheServiceId,
324 ),
325 );
326 const password = cacheVars[Railway.REDIS_PASSWORD_ENV];
327 expect(password !== undefined && password.length > 0).toEqual(true);
328
329 const url = Railway.redisConnectionUrl({
330 host: out.proxyDomain,
331 port: out.proxyPort,
332 password: password!,
333 });
334 const pong = yield* Railway.runRedisCommand(url, "PING").pipe(
335 Effect.retry({
336 schedule: Schedule.spaced("4 seconds"),
337 times: 10,
338 }),
339 );
340 expect(String(pong).toUpperCase()).toContain("PONG");
341 yield* Railway.runRedisCommand(url, "SET", ["marker", REDIS_VALUE]);
342 const got = yield* Railway.runRedisCommand(url, "GET", ["marker"]);
343 expect(got).toEqual(REDIS_VALUE);
344 }).pipe(logLevel),
345 { timeout: 120_000 },
346 );
347 });
348
349 describe(
350 "PutObject / GetObject",
351 {
352 tags: ["provider:aws", "provider:aws:s3", "provider:railway:variable"],
353 },
354 () => {
355 test(
356 "puts and gets an object",
357 Effect.gen(function* () {
358 const out = yield* stack;
359 if (out.mode === "effect") {
360 const put = (yield* getJson(out.bucketUrl!, "/put")) as {
361 ok?: boolean;
362 };
363 expect(put.ok).toEqual(true);
364 const got = (yield* getJson(out.bucketUrl!, "/get")) as {
365 ok?: boolean;
366 text?: string;
367 };
368 expect(got.ok).toEqual(true);
369 expect(got.text).toEqual(OBJECT_BODY);
370 return;
371 }
372
373 // Public image fallback: docker push is impossible without a
374 // registry. PutObject/GetObject still packed AWS_* onto the
375 // Service; put/get runs over the S3 API from the test process.
376 const vars = yield* distilled(
377 readServiceVariables(
378 out.bucketProjectId,
379 out.bucketEnvironmentId,
380 out.bucketServiceId,
381 ),
382 );
383 expect((vars.AWS_ACCESS_KEY_ID ?? "").length).toBeGreaterThan(0);
384 expect((vars.BUCKET_NAME ?? "").length).toBeGreaterThan(0);
385
386 const creds = yield* distilled(
387 firstCredentials(
388 out.bucketId,
389 out.bucketEnvironmentId,
390 out.bucketProjectId,
391 ),
392 );
393 yield* withBucketS3(
394 creds,
395 S3.putObject({
396 Bucket: creds.bucketName,
397 Key: OBJECT_KEY,
398 Body: OBJECT_BODY,
399 ContentType: "text/plain",
400 }),
401 );
402 const got = yield* withBucketS3(
403 creds,
404 S3.getObject({
405 Bucket: creds.bucketName,
406 Key: OBJECT_KEY,
407 }),
408 );
409 const text =
410 got.Body === undefined
411 ? ""
412 : yield* Stream.mkString(Stream.decodeText(got.Body));
413 expect(text).toEqual(OBJECT_BODY);
414 }).pipe(logLevel),
415 { timeout: 120_000 },
416 );
417 },
418 );
419 },
420);