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 { waitUntilVolumeGone } from "./waitUntilVolumeGone.ts";
7import * as Test from "@/Test/Alchemy";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Result from "effect/Result";
12import * as Schedule from "effect/Schedule";
13import * as HttpClient from "effect/http/HttpClient";
14import * as HttpClientRequest from "effect/http/HttpClientRequest";
15import VolumeApi, {
16 Data,
17 MARKER,
18 MARKER_FILE,
19 VOLUME_PATH,
20} from "./fixtures/volume-api.ts";
21
22const { test } = Test.make({ providers: Railway.providers() });
23
24const readVolumeInstance = Query.fn((id: string) => {
25 const instance = RailwayApi.volumeInstance({ id });
26 return {
27 id: instance.id,
28 volumeId: instance.volumeId,
29 mountPath: instance.mountPath,
30 environmentId: instance.environmentId,
31 volume: {
32 name: instance.volume.name,
33 projectId: instance.volume.projectId,
34 },
35 serviceId: instance.serviceId,
36 };
37});
38
39const logLevel = Effect.provideService(
40 MinimumLogLevel,
41 process.env.DEBUG ? "Debug" : "Info",
42);
43
44test.provider(
45 "create, update, list, and delete a volume",
46 (stack) =>
47 Effect.gen(function* () {
48 yield* stack.destroy();
49
50 const created = yield* stack.deploy(
51 Effect.gen(function* () {
52 const { project, environment } = yield* suitePartition;
53 const volume = yield* Railway.Volume("Data", {
54 project,
55 environment,
56 mountPath: "/data",
57 });
58 return { project, environment, volume };
59 }),
60 );
61
62 expect(created.volume.volumeId).toEqual(expect.any(String));
63 expect(created.volume.volumeId.length).toBeGreaterThan(0);
64 expect(created.volume.volumeInstanceId).toEqual(expect.any(String));
65 expect(created.volume.volumeInstanceId.length).toBeGreaterThan(0);
66 expect(created.volume.projectId).toEqual(created.project.projectId);
67 expect(created.volume.environmentId).toEqual(
68 created.environment.environmentId,
69 );
70 expect(created.volume.mountPath).toEqual("/data");
71 expect(created.volume.serviceId).toBeUndefined();
72 expect(created.volume.name).toEqual(expect.any(String));
73 expect(created.volume.name.length).toBeGreaterThan(0);
74 expect(created.volume.name.length).toBeLessThanOrEqual(32);
75 expect(created.volume.name).toMatch(/^[a-z][a-z0-9-]*$/);
76 expect(created.volume.sizeMB).toEqual(expect.any(Number));
77 expect(created.volume.createdAt).toEqual(expect.any(String));
78
79 const fetched = yield* readVolumeInstance(
80 created.volume.volumeInstanceId,
81 );
82 expect(fetched.id).toEqual(created.volume.volumeInstanceId);
83 expect(fetched.volumeId).toEqual(created.volume.volumeId);
84 expect(fetched.mountPath).toEqual("/data");
85 expect(fetched.environmentId).toEqual(created.volume.environmentId);
86 expect(fetched.volume.name).toEqual(created.volume.name);
87 expect(fetched.volume.projectId).toEqual(created.volume.projectId);
88 expect(fetched.serviceId).toBeNull();
89
90 const provider = yield* Provider.findProvider(Railway.Volume);
91 const listed = yield* provider.list();
92 const found = listed.find(
93 (volume) => volume.volumeId === created.volume.volumeId,
94 );
95 expect(found).toBeDefined();
96 expect(found?.volumeInstanceId).toEqual(created.volume.volumeInstanceId);
97 expect(found?.mountPath).toEqual("/data");
98 expect(found?.name).toEqual(created.volume.name);
99
100 const updated = yield* stack.deploy(
101 Effect.gen(function* () {
102 const { project, environment } = yield* suitePartition;
103 const volume = yield* Railway.Volume("Data", {
104 project,
105 environment,
106 mountPath: "/app/data",
107 });
108 return { project, environment, volume };
109 }),
110 );
111
112 expect(updated.volume.volumeId).toEqual(created.volume.volumeId);
113 expect(updated.volume.volumeInstanceId).toEqual(
114 created.volume.volumeInstanceId,
115 );
116 expect(updated.volume.projectId).toEqual(created.volume.projectId);
117 expect(updated.volume.environmentId).toEqual(
118 created.volume.environmentId,
119 );
120 expect(updated.volume.mountPath).toEqual("/app/data");
121 expect(updated.volume.name).toEqual(created.volume.name);
122
123 const fetchedUpdate = yield* readVolumeInstance(
124 updated.volume.volumeInstanceId,
125 );
126 expect(fetchedUpdate.id).toEqual(updated.volume.volumeInstanceId);
127 expect(fetchedUpdate.mountPath).toEqual("/app/data");
128 expect(fetchedUpdate.volume.name).toEqual(updated.volume.name);
129
130 yield* stack.destroy();
131
132 const gone = yield* waitUntilVolumeGone(created.volume.volumeInstanceId);
133 expect(gone).toEqual("gone");
134 }).pipe(logLevel),
135 {
136 tags: [
137 "provider:railway",
138 "provider:railway:project",
139 "provider:railway:projectenvironment",
140 "provider:railway:volume",
141 "live",
142 ],
143 timeout: 120_000,
144 },
145);
146
147test.provider(
148 "refuse a second volume on a service",
149 (stack) =>
150 Effect.gen(function* () {
151 yield* stack.destroy();
152
153 const created = yield* stack.deploy(
154 Effect.gen(function* () {
155 const { project, environment } = yield* suitePartition;
156 const api = yield* Railway.Service("Api", {
157 project,
158 environment,
159 image: "hashicorp/http-echo",
160 port: 5678,
161 });
162 const data = yield* Railway.Volume("Data", {
163 project,
164 environment,
165 mountPath: "/data",
166 service: api,
167 });
168 return { project, environment, api, data };
169 }),
170 );
171
172 const result = yield* Effect.result(
173 stack.deploy(
174 Effect.gen(function* () {
175 const { project, environment } = yield* suitePartition;
176 const api = yield* Railway.Service("Api", {
177 project,
178 environment,
179 image: "hashicorp/http-echo",
180 port: 5678,
181 });
182 const data = yield* Railway.Volume("Data", {
183 project,
184 environment,
185 mountPath: "/data",
186 service: api,
187 });
188 const cache = yield* Railway.Volume("Cache", {
189 project,
190 environment,
191 mountPath: "/cache",
192 service: api,
193 });
194 return { project, environment, api, data, cache };
195 }),
196 ),
197 );
198
199 expect(created.data.serviceId).toEqual(created.api.serviceId);
200 expect(Result.isFailure(result)).toEqual(true);
201 if (Result.isFailure(result)) {
202 expect(result.failure._tag).toEqual("Railway.MultipleVolumes");
203 }
204
205 yield* stack.destroy();
206 }).pipe(logLevel),
207 {
208 tags: [
209 "provider:railway",
210 "provider:railway:project",
211 "provider:railway:projectenvironment",
212 "provider:railway:service",
213 "provider:railway:volume",
214 "live",
215 ],
216 timeout: 120_000,
217 },
218);
219
220test.provider(
221 "MountVolume attaches a disk to a hosted Service",
222 (stack) =>
223 Effect.gen(function* () {
224 yield* stack.destroy();
225
226 const created = yield* stack.deploy(
227 Effect.gen(function* () {
228 const data = yield* Data;
229 const api = yield* VolumeApi;
230 return { data, api };
231 }),
232 );
233
234 expect(created.data.volumeId.length).toBeGreaterThan(0);
235 // Volume is created disconnected; MountVolume attaches it from
236 // the Service. Resource attrs are not refreshed after that bind.
237 expect(created.data.serviceId).toBeUndefined();
238 expect(created.data.mountPath).toEqual(VOLUME_PATH);
239 expect(created.api.url).toEqual(expect.any(String));
240 expect(created.api.url).toContain("up.railway.app");
241
242 const fetched = yield* readVolumeInstance(created.data.volumeInstanceId);
243 expect(fetched.serviceId).toEqual(created.api.serviceId);
244 expect(fetched.mountPath).toEqual(VOLUME_PATH);
245 expect(fetched.volumeId).toEqual(created.data.volumeId);
246
247 const client = yield* HttpClient.HttpClient;
248 const health = yield* client.get(`${created.api.url}/health`).pipe(
249 Effect.flatMap((res) =>
250 res.status === 200
251 ? Effect.succeed(res)
252 : Effect.fail(new Error(`health returned ${res.status}`)),
253 ),
254 Effect.retry({
255 schedule: Schedule.spaced("4 seconds"),
256 times: 10,
257 }),
258 );
259 expect(health.status).toEqual(200);
260
261 const put = yield* client.execute(
262 HttpClientRequest.put(`${created.api.url}/${MARKER_FILE}`).pipe(
263 HttpClientRequest.bodyText(MARKER),
264 ),
265 );
266 expect(put.status).toEqual(204);
267
268 const got = yield* client.get(`${created.api.url}/${MARKER_FILE}`);
269 expect(got.status).toEqual(200);
270 expect(yield* got.text).toEqual(MARKER);
271
272 yield* stack.destroy();
273
274 const gone = yield* waitUntilVolumeGone(created.data.volumeInstanceId);
275 expect(gone).toEqual("gone");
276 }).pipe(logLevel),
277 {
278 tags: [
279 "provider:railway",
280 "provider:railway:mountvolume",
281 "provider:railway:project",
282 "provider:railway:projectenvironment",
283 "provider:railway:service",
284 "provider:railway:volume",
285 "live",
286 ],
287 timeout: 120_000,
288 },
289);