1import { Query } from "@distilled.cloud/core/query";
2import { Railway as RailwayApi } from "@distilled.cloud/railway";
3import * as Railway from "@/Railway";
4import { suitePartition } from "./suiteProject.ts";
5import * as Test from "@/Test/Alchemy";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Result from "effect/Result";
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 TEST_DOMAIN = process.env.RAILWAY_TEST_DOMAIN;
20
21const readCustomDomains = Query.fn(
22 (input: { environmentId: string; projectId: string; serviceId: string }) => ({
23 customDomains: RailwayApi.domains(input).customDomains.pipe(
24 Query.map((domain) => ({
25 id: domain.id,
26 domain: domain.domain,
27 targetPort: domain.targetPort,
28 deletedAt: domain.deletedAt,
29 syncStatus: domain.syncStatus,
30 })),
31 ),
32 }),
33);
34
35const readCustomDomainDeletion = Query.fn((id: string, projectId: string) => {
36 const domain = RailwayApi.customDomain({ id, projectId });
37 return { deletedAt: domain.deletedAt, syncStatus: domain.syncStatus };
38});
39
40const readCustomDomain = Query.fn((id: string, projectId: string) => {
41 const domain = RailwayApi.customDomain({ id, projectId });
42 return {
43 id: domain.id,
44 domain: domain.domain,
45 targetPort: domain.targetPort,
46 };
47});
48
49const readCustomDomainVerification = Query.fn(
50 (id: string, projectId: string) => {
51 const domain = RailwayApi.customDomain({ id, projectId });
52 return {
53 domain: domain.domain,
54 status: { verified: domain.status.verified },
55 };
56 },
57);
58
59const createCustomDomain = Query.fn(
60 (input: {
61 domain: string;
62 environmentId: string;
63 projectId: string;
64 serviceId: string;
65 }) => ({ id: RailwayApi.customDomainCreate({ input }).id }),
66);
67
68const createService = Query.fn(
69 (input: { projectId: string; environmentId: string; image: string }) => ({
70 id: RailwayApi.serviceCreate({
71 input: {
72 projectId: input.projectId,
73 environmentId: input.environmentId,
74 source: { image: input.image },
75 },
76 }).id,
77 }),
78);
79
80const listLive = (
81 environmentId: string,
82 projectId: string,
83 serviceId: string,
84) =>
85 readCustomDomains({ environmentId, projectId, serviceId }).pipe(
86 Effect.map((result) =>
87 result.customDomains.filter(
88 (domain) => domain.deletedAt == null && domain.syncStatus !== "DELETED",
89 ),
90 ),
91 Effect.catchTag("RailwayNotFound", () => Effect.succeed([])),
92 );
93
94const waitUntilDomainGone = (customDomainId: string, projectId: string) =>
95 readCustomDomainDeletion(customDomainId, projectId).pipe(
96 Effect.map((domain) =>
97 domain.deletedAt != null || domain.syncStatus === "DELETED"
98 ? ("gone" as const)
99 : ("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 createTargetService = (projectId: string, environmentId: string) =>
110 createService({ projectId, environmentId, image: "hashicorp/http-echo" });
111
112test.provider(
113 "create, update targetPort, and delete a custom domain",
114 (stack) =>
115 Effect.gen(function* () {
116 yield* stack.destroy();
117
118 const { project, environment } = yield* stack.deploy(suitePartition);
119
120 const service = yield* createTargetService(
121 project.projectId,
122 environment.environmentId,
123 );
124
125 const rejected = yield* Effect.result(
126 createCustomDomain({
127 domain: "not a hostname",
128 environmentId: environment.environmentId,
129 projectId: project.projectId,
130 serviceId: service.id,
131 }),
132 );
133 expect(Result.isFailure(rejected)).toBe(true);
134 if (Result.isFailure(rejected)) {
135 expect(rejected.failure._tag === "RailwayValidationError").toBe(true);
136 }
137
138 // The suite project is shared. Derive the hostname from this test's
139 // environment so another partition's domain cannot collide with it.
140 const hostname = `graphql-${environment.environmentId.slice(0, 8)}.alchemy-test-2.us`;
141
142 const created = yield* stack.deploy(
143 Effect.gen(function* () {
144 const { project: site, environment } = yield* suitePartition;
145 const domain = yield* Railway.CustomDomain("Www", {
146 service: { serviceId: service.id },
147 environment,
148 domain: hostname,
149 targetPort: 5678,
150 });
151 return { project: site, domain };
152 }),
153 );
154
155 expect(created.domain.customDomainId).toEqual(expect.any(String));
156 expect(created.domain.customDomainId.length).toBeGreaterThan(0);
157 expect(created.domain.domain).toEqual(hostname);
158 expect(created.domain.serviceId).toEqual(service.id);
159 expect(created.domain.projectId).toEqual(project.projectId);
160 expect(created.domain.environmentId).toEqual(environment.environmentId);
161 expect(created.domain.targetPort).toEqual(5678);
162 expect(created.domain.url).toEqual(`https://${hostname}`);
163
164 const listed = yield* listLive(
165 environment.environmentId,
166 project.projectId,
167 service.id,
168 );
169 const fetched = listed.find(
170 (item) => item.id === created.domain.customDomainId,
171 );
172 expect(fetched).toBeDefined();
173 expect(fetched?.domain).toEqual(hostname);
174 expect(fetched?.targetPort).toEqual(5678);
175
176 const outOfBand = yield* readCustomDomain(
177 created.domain.customDomainId,
178 project.projectId,
179 );
180 expect(outOfBand.id).toEqual(created.domain.customDomainId);
181 expect(outOfBand.domain).toEqual(hostname);
182 expect(outOfBand.targetPort).toEqual(5678);
183
184 const updated = yield* stack.deploy(
185 Effect.gen(function* () {
186 const { project: site, environment } = yield* suitePartition;
187 const domain = yield* Railway.CustomDomain("Www", {
188 service: { serviceId: service.id },
189 environment,
190 domain: hostname,
191 targetPort: 8080,
192 });
193 return { project: site, domain };
194 }),
195 );
196
197 expect(updated.domain.customDomainId).toEqual(
198 created.domain.customDomainId,
199 );
200 expect(updated.domain.targetPort).toEqual(8080);
201 expect(updated.domain.domain).toEqual(hostname);
202 expect(updated.project.projectId).toEqual(project.projectId);
203
204 const fetchedUpdate = yield* readCustomDomain(
205 updated.domain.customDomainId,
206 project.projectId,
207 );
208 expect(fetchedUpdate.targetPort).toEqual(8080);
209
210 yield* stack.destroy();
211
212 const domainGone = yield* waitUntilDomainGone(
213 created.domain.customDomainId,
214 project.projectId,
215 );
216 expect(domainGone).toEqual("gone");
217 }).pipe(logLevel),
218 {
219 tags: [
220 "provider:railway",
221 "provider:railway:customdomain",
222 "provider:railway:project",
223 "provider:railway:projectenvironment",
224 "live",
225 ],
226 timeout: 120_000,
227 },
228);
229
230test.provider.skipIf(!TEST_DOMAIN)(
231 "ACME verifies when RAILWAY_TEST_DOMAIN is set",
232 (stack) =>
233 Effect.gen(function* () {
234 yield* stack.destroy();
235
236 const hostname = TEST_DOMAIN!;
237
238 const { project, environment } = yield* stack.deploy(suitePartition);
239
240 const service = yield* createTargetService(
241 project.projectId,
242 environment.environmentId,
243 );
244
245 const created = yield* stack.deploy(
246 Effect.gen(function* () {
247 const { project: site, environment } = yield* suitePartition;
248 const domain = yield* Railway.CustomDomain("Www", {
249 service: { serviceId: service.id },
250 environment,
251 domain: hostname,
252 });
253 return { project: site, domain };
254 }),
255 );
256
257 expect(created.domain.domain).toEqual(hostname);
258 expect(created.domain.customDomainId.length).toBeGreaterThan(0);
259
260 const fetched = yield* readCustomDomainVerification(
261 created.domain.customDomainId,
262 project.projectId,
263 );
264 expect(fetched.domain).toEqual(hostname);
265 expect(
266 fetched.status.verified === true || fetched.status.verified === false,
267 ).toBe(true);
268
269 yield* stack.destroy();
270
271 const domainGone = yield* waitUntilDomainGone(
272 created.domain.customDomainId,
273 project.projectId,
274 );
275 expect(domainGone).toEqual("gone");
276 }).pipe(logLevel),
277 {
278 tags: [
279 "provider:railway",
280 "provider:railway:customdomain",
281 "provider:railway:project",
282 "provider:railway:projectenvironment",
283 "live",
284 ],
285 timeout: 120_000,
286 },
287);