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 Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: Railway.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19const redisCommand = (...args: Parameters<typeof Railway.runRedisCommand>) =>
20 Railway.runRedisCommand(...args).pipe(Effect.timeout("5 seconds"));
21
22const REDIS_VALUE = "alchemy-railway-redis";
23
24const readVariables = Query.fn(
25 (projectId: string, environmentId: string, serviceId: string) =>
26 RailwayApi.variables({
27 projectId,
28 environmentId,
29 serviceId,
30 unrendered: true,
31 }),
32);
33
34const readServiceDeletedAt = Query.fn((id: string) => ({
35 deletedAt: RailwayApi.service({ id }).deletedAt,
36}));
37
38const readService = Query.fn((id: string) => {
39 const service = RailwayApi.service({ id });
40 return {
41 id: service.id,
42 name: service.name,
43 projectId: service.projectId,
44 deletedAt: service.deletedAt,
45 };
46});
47
48const readServiceInstance = Query.fn(
49 (environmentId: string, serviceId: string) => {
50 const instance = RailwayApi.serviceInstance({ environmentId, serviceId });
51 return {
52 serviceId: instance.serviceId,
53 image: instance.source.image,
54 };
55 },
56);
57
58const readTcpProxies = Query.fn((environmentId: string, serviceId: string) =>
59 RailwayApi.tcpProxies({ environmentId, serviceId }).pipe(
60 Query.map((proxy) => ({
61 id: proxy.id,
62 domain: proxy.domain,
63 proxyPort: proxy.proxyPort,
64 applicationPort: proxy.applicationPort,
65 deletedAt: proxy.deletedAt,
66 syncStatus: proxy.syncStatus,
67 })),
68 ),
69);
70
71const asVariableMap = (value: unknown): Record<string, string> => {
72 if (value === null || typeof value !== "object" || Array.isArray(value)) {
73 return {};
74 }
75 const out: Record<string, string> = {};
76 for (const [key, item] of Object.entries(value)) {
77 if (typeof item === "string") {
78 out[key] = item;
79 }
80 }
81 return out;
82};
83
84const readServiceVariables = (
85 projectId: string,
86 environmentId: string,
87 serviceId: string,
88) =>
89 readVariables(projectId, environmentId, serviceId).pipe(
90 Effect.map(asVariableMap),
91 Effect.catchTag("RailwayNotFound", () =>
92 Effect.succeed({} as Record<string, string>),
93 ),
94 );
95
96const waitUntilGone = (serviceId: string) =>
97 readServiceDeletedAt(serviceId).pipe(
98 Effect.map((service) =>
99 service.deletedAt != null ? ("gone" as const) : ("found" as const),
100 ),
101 Effect.catchTag("RailwayNotFound", () => Effect.succeed("gone" as const)),
102 Effect.repeat({
103 schedule: Schedule.spaced("1 second"),
104 until: (status) => status === "gone",
105 times: 10,
106 }),
107 );
108
109const waitUntilProxyGone = (
110 environmentId: string,
111 serviceId: string,
112 id: string,
113) =>
114 readTcpProxies(environmentId, serviceId).pipe(
115 Effect.map((items) =>
116 items.some(
117 (proxy) =>
118 proxy.id === id &&
119 proxy.deletedAt == null &&
120 proxy.syncStatus !== "DELETED",
121 )
122 ? ("found" as const)
123 : ("gone" as const),
124 ),
125 Effect.catchTag("RailwayNotFound", () => Effect.succeed("gone" as const)),
126 Effect.repeat({
127 schedule: Schedule.spaced("1 second"),
128 until: (status) => status === "gone",
129 times: 10,
130 }),
131 );
132
133test.provider(
134 "create, set/get via tcp proxy, update, list, and delete redis",
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 cache = yield* Railway.Redis("Cache", { project, environment });
143 const proxy = yield* Railway.TcpProxy("CacheProxy", {
144 redis: cache,
145 environment,
146 applicationPort: Railway.REDIS_PORT,
147 });
148 return { project, environment, cache, proxy };
149 }),
150 );
151
152 expect(created.cache.serviceId).toEqual(expect.any(String));
153 expect(created.cache.serviceId.length).toBeGreaterThan(0);
154 expect(created.cache.projectId).toEqual(created.project.projectId);
155 expect(created.cache.environmentId).toEqual(
156 created.environment.environmentId,
157 );
158 expect(created.cache.name).toEqual(expect.any(String));
159 expect(created.cache.name.length).toBeGreaterThan(0);
160 expect(created.cache.name.length).toBeLessThanOrEqual(32);
161 expect(created.cache.name).toMatch(/^[a-z][a-z0-9-]*$/);
162 expect(created.cache.port).toEqual(6379);
163 expect(created.cache.privateHost).toEqual(
164 `${created.cache.name}.railway.internal`,
165 );
166 expect(created.cache.image).toEqual(expect.stringContaining("redis"));
167 expect(created.proxy.applicationPort).toEqual(6379);
168 expect(created.proxy.serviceId).toEqual(created.cache.serviceId);
169 expect(created.proxy.domain).toEqual(expect.any(String));
170 expect(created.proxy.proxyPort).toEqual(expect.any(Number));
171
172 const fetched = yield* readService(created.cache.serviceId);
173 expect(fetched.id).toEqual(created.cache.serviceId);
174 expect(fetched.name).toEqual(created.cache.name);
175 expect(fetched.projectId).toEqual(created.cache.projectId);
176 expect(fetched.deletedAt).toBeNull();
177
178 const instance = yield* readServiceInstance(
179 created.cache.environmentId,
180 created.cache.serviceId,
181 );
182 expect(instance.serviceId).toEqual(created.cache.serviceId);
183 expect(instance.image).toEqual(expect.stringContaining("redis"));
184
185 const vars = yield* readServiceVariables(
186 created.cache.projectId,
187 created.cache.environmentId,
188 created.cache.serviceId,
189 );
190 const password = vars[Railway.REDIS_PASSWORD_ENV];
191 expect(password !== undefined && password.length > 0).toEqual(true);
192 expect(vars[Railway.REDIS_URL_ENV] !== undefined).toEqual(true);
193
194 const url = Railway.redisConnectionUrl({
195 host: created.proxy.domain,
196 port: created.proxy.proxyPort,
197 password: password!,
198 });
199
200 yield* Effect.logDebug("Redis test: PING started");
201 const pong = yield* redisCommand(url, "PING").pipe(
202 Effect.retry({
203 schedule: Schedule.spaced("2 seconds"),
204 times: 3,
205 }),
206 );
207 yield* Effect.logDebug("Redis test: PING completed");
208 expect(String(pong).toUpperCase()).toContain("PONG");
209
210 yield* redisCommand(url, "SET", ["marker", REDIS_VALUE]);
211 const got = yield* redisCommand(url, "GET", ["marker"]);
212 expect(got).toEqual(REDIS_VALUE);
213
214 const provider = yield* Provider.findProvider(Railway.Redis);
215 yield* Effect.logDebug("Redis test: provider.list started");
216 const listed = yield* provider.list();
217 yield* Effect.logDebug("Redis test: provider.list completed");
218 const found = listed.find(
219 (row) => row.serviceId === created.cache.serviceId,
220 );
221 expect(found).toBeDefined();
222 expect(found?.name).toEqual(created.cache.name);
223 expect(found?.projectId).toEqual(created.cache.projectId);
224
225 const nextName =
226 created.cache.name.slice(0, -1) +
227 (created.cache.name.endsWith("z") ? "y" : "z");
228
229 yield* Effect.logDebug("Redis test: update started");
230 const updated = yield* stack.deploy(
231 Effect.gen(function* () {
232 const { project, environment } = yield* suitePartition;
233 const cache = yield* Railway.Redis("Cache", {
234 project,
235 environment,
236 name: nextName,
237 });
238 const proxy = yield* Railway.TcpProxy("CacheProxy", {
239 redis: cache,
240 environment,
241 applicationPort: Railway.REDIS_PORT,
242 });
243 return { project, environment, cache, proxy };
244 }),
245 );
246
247 yield* Effect.logDebug("Redis test: update completed");
248 expect(updated.cache.serviceId).toEqual(created.cache.serviceId);
249 expect(updated.cache.name).toEqual(nextName);
250 expect(updated.cache.privateHost).toEqual(`${nextName}.railway.internal`);
251 expect(updated.proxy.id).toEqual(created.proxy.id);
252
253 const fetchedUpdate = yield* readService(updated.cache.serviceId);
254 expect(fetchedUpdate.name).toEqual(nextName);
255
256 yield* stack.destroy();
257
258 const proxyGone = yield* waitUntilProxyGone(
259 created.environment.environmentId,
260 created.cache.serviceId,
261 created.proxy.id,
262 );
263 expect(proxyGone).toEqual("gone");
264 const gone = yield* waitUntilGone(created.cache.serviceId);
265 expect(gone).toEqual("gone");
266 }).pipe(logLevel),
267 {
268 tags: [
269 "provider:railway",
270 "provider:railway:project",
271 "provider:railway:projectenvironment",
272 "provider:railway:redis",
273 "provider:railway:service",
274 "provider:railway:tcpproxy",
275 "provider:railway:variable",
276 "live",
277 ],
278 timeout: 120_000,
279 },
280);