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";
12
13const { test } = Test.make({ providers: Railway.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20// Volume backups are Pro-plan gated. Railway rejects Hobby/unentitled
21// workspaces with GraphQL `Not Authorized`, already typed as
22// RailwayForbidden. The probe always runs and pins that tag. The
23// create+list+delete lifecycle is opt-in via RAILWAY_TEST_VOLUME_BACKUP=1.
24const backupEntitled = !!process.env.RAILWAY_TEST_VOLUME_BACKUP;
25
26const VolumeStack = Effect.gen(function* () {
27 const { project, environment } = yield* suitePartition;
28 const api = yield* Railway.Service("Api", {
29 project,
30 environment,
31 image: "hashicorp/http-echo",
32 });
33 const volume = yield* Railway.Volume("Data", {
34 project,
35 environment,
36 mountPath: "/data",
37 service: api,
38 });
39 return { project, environment, api, volume };
40});
41
42const listVolumeInstanceBackups = Query.fn((volumeInstanceId: string) =>
43 RailwayApi.volumeInstanceBackupList({ volumeInstanceId }).pipe(
44 Query.map((backup) => ({
45 id: backup.id,
46 name: backup.name,
47 createdAt: backup.createdAt,
48 })),
49 ),
50);
51
52const readVolumeInstanceState = Query.fn((id: string) => {
53 const instance = RailwayApi.volumeInstance({ id });
54 return { deletedAt: instance.deletedAt, state: instance.state };
55});
56
57const createVolumeInstanceBackup = Query.fn((volumeInstanceId: string) => {
58 const created = RailwayApi.volumeInstanceBackupCreate({ volumeInstanceId });
59 return { workflowId: created.workflowId };
60});
61
62const readWorkflowStatus = Query.fn((workflowId: string) => {
63 const workflow = RailwayApi.workflowStatus({ workflowId });
64 return { status: workflow.status };
65});
66
67const deleteVolumeInstanceBackup = Query.fn(
68 (volumeInstanceBackupId: string, volumeInstanceId: string) => {
69 const deleted = RailwayApi.volumeInstanceBackupDelete({
70 volumeInstanceBackupId,
71 volumeInstanceId,
72 });
73 return { workflowId: deleted.workflowId };
74 },
75);
76
77const listLive = (volumeInstanceId: string) =>
78 listVolumeInstanceBackups(volumeInstanceId).pipe(
79 Effect.catchTag(["RailwayNotFound", "RailwayForbidden"], () =>
80 Effect.succeed([]),
81 ),
82 );
83
84const waitUntilReady = (volumeInstanceId: string) =>
85 readVolumeInstanceState(volumeInstanceId).pipe(
86 Effect.map((instance) =>
87 instance.deletedAt == null &&
88 instance.state !== "DELETED" &&
89 instance.state !== "DELETING" &&
90 instance.state !== "UPDATING" &&
91 instance.state !== "MIGRATING" &&
92 instance.state !== "MIGRATION_PENDING" &&
93 instance.state !== "RESTORING" &&
94 instance.state !== "ERROR"
95 ? ("ready" as const)
96 : ("pending" as const),
97 ),
98 Effect.catchTag("RailwayNotFound", () =>
99 Effect.succeed("pending" as const),
100 ),
101 Effect.repeat({
102 schedule: Schedule.spaced("2 seconds"),
103 until: (status) => status === "ready",
104 times: 10,
105 }),
106 );
107
108const waitUntilBackupGone = (
109 volumeInstanceId: string,
110 volumeInstanceBackupId: string,
111) =>
112 listLive(volumeInstanceId).pipe(
113 Effect.map((items) =>
114 items.some((backup) => backup.id === volumeInstanceBackupId)
115 ? ("found" as const)
116 : ("gone" as const),
117 ),
118 Effect.repeat({
119 schedule: Schedule.spaced("1 second"),
120 until: (status) => status === "gone",
121 times: 10,
122 }),
123 );
124
125test.provider(
126 "volume backup create surfaces a typed entitlement error",
127 (stack) =>
128 Effect.gen(function* () {
129 yield* stack.destroy();
130
131 const created = yield* stack.deploy(VolumeStack);
132 yield* waitUntilReady(created.volume.volumeInstanceId);
133
134 const result = yield* Effect.result(
135 createVolumeInstanceBackup(created.volume.volumeInstanceId),
136 );
137 if (Result.isSuccess(result)) {
138 yield* Effect.logInfo(
139 "volume backups are entitled on this token; probe is a no-op",
140 );
141 if (
142 result.success.workflowId != null &&
143 result.success.workflowId.length > 0
144 ) {
145 yield* readWorkflowStatus(result.success.workflowId).pipe(
146 Effect.catchTag("RailwayForbidden", () => Effect.void),
147 );
148 }
149 const extras = yield* listLive(created.volume.volumeInstanceId);
150 for (const extra of extras) {
151 yield* deleteVolumeInstanceBackup(
152 extra.id,
153 created.volume.volumeInstanceId,
154 ).pipe(
155 Effect.catchTag(
156 ["RailwayNotFound", "RailwayForbidden"],
157 () => Effect.void,
158 ),
159 );
160 }
161 yield* stack.destroy();
162 return;
163 }
164
165 expect(result.failure._tag === "RailwayForbidden").toBe(true);
166
167 yield* stack.destroy();
168 }).pipe(logLevel),
169 {
170 tags: [
171 "provider:railway",
172 "provider:railway:project",
173 "provider:railway:projectenvironment",
174 "provider:railway:service",
175 "provider:railway:volume",
176 "provider:railway:volumebackup",
177 "live",
178 ],
179 timeout: 120_000,
180 },
181);
182
183test.provider.skipIf(!backupEntitled)(
184 "create, list, and delete a volume backup",
185 (stack) =>
186 Effect.gen(function* () {
187 yield* stack.destroy();
188
189 const base = yield* stack.deploy(VolumeStack);
190 yield* waitUntilReady(base.volume.volumeInstanceId);
191
192 const created = yield* stack.deploy(
193 Effect.gen(function* () {
194 const { project, environment } = yield* suitePartition;
195 const api = yield* Railway.Service("Api", {
196 project,
197 environment,
198 image: "hashicorp/http-echo",
199 });
200 const volume = yield* Railway.Volume("Data", {
201 project,
202 environment,
203 mountPath: "/data",
204 service: api,
205 });
206 const backup = yield* Railway.VolumeBackup("Snapshot", {
207 volume,
208 environment,
209 });
210 return { project, environment, api, volume, backup };
211 }),
212 );
213
214 expect(created.backup.volumeInstanceBackupId).toEqual(expect.any(String));
215 expect(created.backup.volumeInstanceBackupId.length).toBeGreaterThan(0);
216 expect(created.backup.volumeInstanceId).toEqual(
217 created.volume.volumeInstanceId,
218 );
219 expect(created.backup.volumeId).toEqual(created.volume.volumeId);
220 expect(created.backup.projectId).toEqual(created.project.projectId);
221 expect(created.backup.environmentId).toEqual(
222 created.environment.environmentId,
223 );
224 expect(created.backup.name).toEqual(expect.any(String));
225 expect(created.backup.name.length).toBeGreaterThan(0);
226 expect(created.backup.createdAt).toEqual(expect.any(String));
227
228 const listed = yield* listLive(created.volume.volumeInstanceId);
229 const fetched = listed.find(
230 (backup) => backup.id === created.backup.volumeInstanceBackupId,
231 );
232 expect(fetched).toBeDefined();
233 expect(fetched?.name).toEqual(created.backup.name);
234 expect(fetched?.createdAt).toEqual(created.backup.createdAt);
235
236 const provider = yield* Provider.findProvider(Railway.VolumeBackup);
237 const fromProvider = yield* provider.list();
238 const found = fromProvider.find(
239 (backup) =>
240 backup.volumeInstanceBackupId ===
241 created.backup.volumeInstanceBackupId,
242 );
243 expect(found).toBeDefined();
244 expect(found?.volumeInstanceId).toEqual(created.volume.volumeInstanceId);
245 expect(found?.name).toEqual(created.backup.name);
246
247 yield* stack.destroy();
248
249 const backupGone = yield* waitUntilBackupGone(
250 created.volume.volumeInstanceId,
251 created.backup.volumeInstanceBackupId,
252 );
253 expect(backupGone).toEqual("gone");
254 }).pipe(logLevel),
255 {
256 tags: [
257 "provider:railway",
258 "provider:railway:project",
259 "provider:railway:projectenvironment",
260 "provider:railway:service",
261 "provider:railway:volume",
262 "provider:railway:volumebackup",
263 "live",
264 ],
265 timeout: 120_000,
266 },
267);