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 { noopSession } from "@/Report";
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";
11import * as Schema from "effect/Schema";
12import { suitePartition } from "./suiteProject.ts";
13
14const { test } = Test.make({ providers: Railway.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21const NetworkConfig = Schema.Struct({
22 privateNetworkDisabled: Schema.optional(Schema.NullOr(Schema.Boolean)),
23 services: Schema.optional(
24 Schema.NullOr(
25 Schema.Record(
26 Schema.String,
27 Schema.NullOr(
28 Schema.Struct({
29 networking: Schema.optional(
30 Schema.NullOr(
31 Schema.Struct({
32 privateNetworkEndpoint: Schema.optional(
33 Schema.NullOr(Schema.String),
34 ),
35 serviceDomains: Schema.optional(
36 Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)),
37 ),
38 }),
39 ),
40 ),
41 }),
42 ),
43 ),
44 ),
45 ),
46});
47
48const readEnvironmentConfig = Query.fn((id: string) => {
49 const environment = RailwayApi.environment({ id });
50 return { config: environment.config, configEtag: environment.configEtag };
51});
52
53const createService = Query.fn(
54 (input: {
55 projectId: string;
56 environmentId: string;
57 name: string;
58 image: string;
59 }) => {
60 const service = RailwayApi.serviceCreate({
61 input: {
62 projectId: input.projectId,
63 environmentId: input.environmentId,
64 name: input.name,
65 source: { image: input.image },
66 },
67 });
68 return { id: service.id, name: service.name };
69 },
70);
71
72const deleteService = Query.fn((id: string) =>
73 RailwayApi.serviceDelete({ id }),
74);
75
76const commitEnvironmentPatch = Query.fn(
77 (environmentId: string, patch: unknown) =>
78 RailwayApi.environmentPatchCommit({ environmentId, patch }),
79);
80
81const deployServiceInstance = Query.fn(
82 (environmentId: string, serviceId: string) =>
83 RailwayApi.serviceInstanceDeploy({ environmentId, serviceId }),
84);
85
86const readPrivateNetworks = Query.fn((environmentId: string) =>
87 RailwayApi.privateNetworks({ environmentId }).pipe(
88 Query.map((network) => ({
89 publicId: network.publicId,
90 name: network.name,
91 dnsName: network.dnsName,
92 deletedAt: network.deletedAt,
93 })),
94 ),
95);
96
97const readEnvironmentPatches = Query.fn((environmentId: string) =>
98 RailwayApi.environmentPatches({ environmentId, first: 5 }).pipe(
99 Query.map((patch) => ({
100 status: patch.status,
101 lastAppliedError: patch.lastAppliedError,
102 })),
103 ),
104);
105
106const readPrivateNetworkEndpoint = Query.fn(
107 (input: {
108 environmentId: string;
109 privateNetworkId: string;
110 serviceId: string;
111 }) =>
112 RailwayApi.privateNetworkEndpoint(input).pipe(
113 Query.map((endpoint) => ({
114 publicId: endpoint.publicId,
115 dnsName: endpoint.dnsName,
116 deletedAt: endpoint.deletedAt,
117 syncStatus: endpoint.syncStatus,
118 })),
119 ),
120);
121
122const readConfig = Effect.fn(function* (environmentId: string) {
123 const environment = yield* readEnvironmentConfig(environmentId);
124 return {
125 config: yield* Schema.decodeUnknownEffect(NetworkConfig)(
126 environment.config,
127 ),
128 etag: environment.configEtag,
129 };
130});
131
132const lifecycle = (id: string) => ({
133 id,
134 fqn: id,
135 instanceId: id,
136 bindings: [],
137 session: { ...noopSession, note: () => Effect.void },
138});
139
140const networkStack = Effect.gen(function* () {
141 const { project, environment } = yield* suitePartition;
142 const network = yield* Railway.PrivateNetwork("Mesh", { environment });
143 return { project, environment, network };
144});
145
146const readEndpoint = (input: {
147 environmentId: string;
148 privateNetworkId: string;
149 serviceId: string;
150}) =>
151 readPrivateNetworkEndpoint(input).pipe(
152 Effect.catchTag("RailwayNotFound", () => Effect.succeed(null)),
153 Effect.map((endpoint) =>
154 endpoint?.deletedAt == null &&
155 endpoint?.syncStatus !== "DELETED" &&
156 endpoint?.syncStatus !== "DELETING"
157 ? endpoint
158 : null,
159 ),
160 );
161
162const waitForEndpoint = (
163 input: Parameters<typeof readEndpoint>[0],
164 dnsName?: string,
165) =>
166 readEndpoint(input).pipe(
167 Effect.repeat({
168 schedule: Schedule.spaced("2 seconds"),
169 times: 8,
170 until: (endpoint) =>
171 endpoint != null &&
172 (dnsName === undefined || endpoint.dnsName === dnsName),
173 }),
174 Effect.tap((endpoint) =>
175 Effect.sync(() => {
176 expect(endpoint).not.toBeNull();
177 if (dnsName !== undefined) expect(endpoint?.dnsName).toBe(dnsName);
178 }),
179 ),
180 );
181
182const setPrefix = (
183 environmentId: string,
184 serviceId: string,
185 prefix: string | null,
186) =>
187 commitEnvironmentPatch(environmentId, {
188 services: {
189 [serviceId]: { networking: { privateNetworkEndpoint: prefix } },
190 },
191 });
192
193test.provider(
194 "private network enables the environment and restores its previous disabled flag",
195 (stack) =>
196 Effect.gen(function* () {
197 yield* stack.destroy();
198 const partition = yield* stack.deploy(suitePartition);
199 const environmentId = partition.environment.environmentId;
200 const service = yield* Effect.acquireRelease(
201 createService({
202 projectId: partition.project.projectId,
203 environmentId,
204 name: "private-network-disable-fixture",
205 image: "nginx:alpine",
206 }),
207 (service) =>
208 deleteService(service.id).pipe(
209 Effect.catchTag("RailwayNotFound", () => Effect.void),
210 Effect.orDie,
211 ),
212 );
213 const [platformNetwork] = yield* readPrivateNetworks(environmentId);
214 yield* waitForEndpoint({
215 environmentId,
216 privateNetworkId: platformNetwork!.publicId,
217 serviceId: service.id,
218 });
219 const disablePatch = yield* commitEnvironmentPatch(environmentId, {
220 privateNetworkDisabled: true,
221 });
222 yield* Effect.logInfo("Private-network disable commit", { disablePatch });
223 const disabled = yield* readConfig(environmentId).pipe(
224 Effect.repeat({
225 schedule: Schedule.spaced("1 second"),
226 times: 8,
227 until: ({ config }) => config.privateNetworkDisabled === true,
228 }),
229 );
230 yield* Effect.logInfo("Private-network disabled state", {
231 privateNetworkDisabled: disabled.config.privateNetworkDisabled,
232 patches: yield* readEnvironmentPatches(environmentId),
233 networks: yield* readPrivateNetworks(environmentId),
234 });
235 expect(disabled.config.privateNetworkDisabled).toBe(true);
236
237 const provider = yield* Provider.findProvider(Railway.PrivateNetwork);
238 for (const name of ["backend", "legacy-network"]) {
239 const rejected = yield* provider
240 .reconcile({
241 ...lifecycle("UnsupportedMesh"),
242 news: { environment: partition.environment, name },
243 olds: undefined,
244 output: undefined,
245 })
246 .pipe(Effect.result);
247 expect(rejected).toMatchObject({
248 _tag: "Failure",
249 failure: { _tag: "Railway.PrivateNetworkNameUnsupported", name },
250 });
251 }
252 expect((yield* readConfig(environmentId)).etag).toBe(disabled.etag);
253
254 const created = yield* stack.deploy(networkStack);
255 expect(created.network.name).toBe("railway");
256 expect(created.network.environmentId).toBe(environmentId);
257 expect(created.network.projectId).toBe(partition.project.projectId);
258 expect(created.network.previousPrivateNetworkDisabled).toBe(true);
259 const enabled = yield* readConfig(environmentId);
260 expect(enabled.config.privateNetworkDisabled).toBe(false);
261 const networks = yield* readPrivateNetworks(environmentId);
262 expect(
263 networks.find(
264 (network) =>
265 network.publicId === created.network.publicId &&
266 network.deletedAt == null,
267 ),
268 ).toMatchObject({ name: "railway", dnsName: created.network.dnsName });
269
270 const props = { environment: partition.environment };
271 const refreshed = yield* provider.read!({
272 ...lifecycle("Mesh"),
273 olds: props,
274 output: created.network,
275 });
276 expect(refreshed?.previousPrivateNetworkDisabled).toBe(true);
277 const repeated = yield* stack.deploy(networkStack);
278 expect(repeated.network.publicId).toBe(created.network.publicId);
279 expect(repeated.network.previousPrivateNetworkDisabled).toBe(true);
280 expect((yield* readConfig(environmentId)).etag).toBe(enabled.etag);
281
282 // Legacy state cannot establish what the environment used to contain.
283 const { previousPrivateNetworkDisabled: _, ...legacy } = repeated.network;
284 yield* provider.delete({
285 ...lifecycle("Mesh"),
286 olds: props,
287 output: legacy,
288 });
289 expect((yield* readConfig(environmentId)).etag).toBe(enabled.etag);
290 expect(
291 (yield* readConfig(environmentId)).config.privateNetworkDisabled,
292 ).toBe(false);
293
294 const retained = yield* stack.deploy(suitePartition);
295 expect(retained.environment.environmentId).toBe(environmentId);
296 const restored = yield* readConfig(environmentId);
297 expect(restored.config.privateNetworkDisabled).toBe(true);
298 for (let attempt = 0; attempt < 2; attempt++) {
299 yield* provider.delete({
300 ...lifecycle("Mesh"),
301 olds: props,
302 output: repeated.network,
303 });
304 }
305 expect((yield* readConfig(environmentId)).etag).toBe(restored.etag);
306 }).pipe(
307 Effect.scoped,
308 Effect.ensuring(stack.destroy().pipe(Effect.orDie)),
309 logLevel,
310 ),
311 {
312 tags: [
313 "provider:railway",
314 "provider:railway:privatenetwork",
315 "provider:railway:project",
316 "provider:railway:projectenvironment",
317 "live",
318 ],
319 timeout: 120_000,
320 },
321);
322
323test.provider(
324 "private endpoint updates platform DNS and restores only its own changes",
325 (stack) =>
326 Effect.gen(function* () {
327 yield* stack.destroy();
328 const base = yield* stack.deploy(networkStack);
329 const environmentId = base.environment.environmentId;
330
331 yield* Effect.acquireUseRelease(
332 createService({
333 projectId: base.project.projectId,
334 environmentId,
335 name: "private-network-fixture",
336 image: "nginx:alpine",
337 }),
338 (service) =>
339 Effect.gen(function* () {
340 const serviceRef = { serviceId: service.id, name: service.name };
341 const input = {
342 environmentId,
343 privateNetworkId: base.network.publicId,
344 serviceId: service.id,
345 };
346 if ((yield* readEndpoint(input)) === null) {
347 yield* deployServiceInstance(environmentId, service.id);
348 }
349 const platformEndpoint = yield* waitForEndpoint(input);
350 const domainKey = yield* Effect.sync(() => crypto.randomUUID());
351 yield* commitEnvironmentPatch(environmentId, {
352 services: {
353 [service.id]: {
354 networking: {
355 privateNetworkEndpoint: "original",
356 serviceDomains: { [domainKey]: {} },
357 },
358 },
359 },
360 });
361 yield* waitForEndpoint(input, "original");
362 const baseline = yield* readConfig(environmentId);
363 const baselineDomains =
364 baseline.config.services?.[service.id]?.networking
365 ?.serviceDomains;
366 expect(baselineDomains).toBeDefined();
367 expect(Object.keys(baselineDomains ?? {}).length).toBeGreaterThan(
368 0,
369 );
370
371 const deployEndpoint = (name?: string) =>
372 stack
373 .deploy(
374 Effect.gen(function* () {
375 const partition = yield* networkStack;
376 const endpoint = yield* Railway.PrivateNetworkEndpoint(
377 "ApiDns",
378 {
379 network: partition.network,
380 service: serviceRef,
381 ...(name === undefined ? {} : { name }),
382 },
383 );
384 return { ...partition, endpoint };
385 }),
386 )
387 .pipe(
388 Effect.tapError(() =>
389 readConfig(environmentId).pipe(
390 Effect.tap(({ config }) =>
391 Effect.logInfo("Endpoint configuration after failure", {
392 requested: name ?? null,
393 observed:
394 config.services?.[service.id]?.networking
395 ?.privateNetworkEndpoint ?? null,
396 }),
397 ),
398 ),
399 ),
400 );
401 const assertConfig = (prefix: string | null) =>
402 Effect.gen(function* () {
403 const { config } = yield* readConfig(environmentId);
404 const networking = config.services?.[service.id]?.networking;
405 expect(networking?.privateNetworkEndpoint ?? null).toBe(prefix);
406 expect(networking?.serviceDomains).toEqual(baselineDomains);
407 expect(config.privateNetworkDisabled).toBe(
408 baseline.config.privateNetworkDisabled,
409 );
410 });
411 const provider = yield* Provider.findProvider(
412 Railway.PrivateNetworkEndpoint,
413 );
414 const created = yield* deployEndpoint("api");
415 expect(created.endpoint.publicId).toBe(platformEndpoint?.publicId);
416 expect(created.endpoint.serviceId).toBe(service.id);
417 expect(created.endpoint.environmentId).toBe(environmentId);
418 expect(created.endpoint.privateNetworkId).toBe(
419 base.network.publicId,
420 );
421 expect(created.endpoint.previousDnsPrefix).toBe("original");
422 expect(created.endpoint.dnsName).toBe("api");
423 yield* waitForEndpoint(input, created.endpoint.dnsName);
424 yield* assertConfig("api");
425
426 const props = {
427 network: created.network,
428 service: serviceRef,
429 name: "api",
430 };
431 const refreshed = yield* provider.read!({
432 ...lifecycle("ApiDns"),
433 olds: props,
434 output: created.endpoint,
435 });
436 expect(refreshed?.previousDnsPrefix).toBe("original");
437 const repeated = yield* deployEndpoint("api");
438 expect(repeated.endpoint.publicId).toBe(created.endpoint.publicId);
439 expect(repeated.endpoint.previousDnsPrefix).toBe("original");
440 yield* assertConfig("api");
441
442 const beforeLegacyDelete = yield* readConfig(environmentId);
443 const { previousDnsPrefix: _, ...legacy } = repeated.endpoint;
444 const legacyDeletion = yield* provider
445 .delete({
446 ...lifecycle("ApiDns"),
447 olds: props,
448 output: legacy,
449 })
450 .pipe(Effect.result);
451 expect(legacyDeletion).toMatchObject({
452 _tag: "Failure",
453 failure: {
454 _tag: "Railway.PrivateNetworkEndpointRestoreUnavailable",
455 },
456 });
457 expect((yield* readConfig(environmentId)).etag).toBe(
458 beforeLegacyDelete.etag,
459 );
460 yield* assertConfig("api");
461
462 const renamed = yield* deployEndpoint("gateway");
463 expect(renamed.endpoint.publicId).toBe(created.endpoint.publicId);
464 expect(renamed.endpoint.previousDnsPrefix).toBe("original");
465 expect(renamed.endpoint.dnsName).toBe("gateway");
466 yield* waitForEndpoint(input, renamed.endpoint.dnsName);
467 yield* assertConfig("gateway");
468
469 const reset = yield* deployEndpoint();
470 expect(reset.endpoint.publicId).toBe(created.endpoint.publicId);
471 expect(reset.endpoint.previousDnsPrefix).toBe("original");
472 expect(reset.endpoint.dnsName).toBe(service.name);
473 yield* waitForEndpoint(input, reset.endpoint.dnsName);
474 yield* assertConfig(null);
475
476 const retained = yield* stack.deploy(networkStack);
477 expect(retained.environment.environmentId).toBe(environmentId);
478 expect(retained.network.publicId).toBe(base.network.publicId);
479 yield* assertConfig("original");
480 const restored = yield* waitForEndpoint(input, "original");
481 expect(restored?.publicId).toBe(created.endpoint.publicId);
482
483 const afterRestore = yield* readConfig(environmentId);
484 for (let attempt = 0; attempt < 2; attempt++) {
485 yield* provider.delete({
486 ...lifecycle("ApiDns"),
487 olds: { network: reset.network, service: serviceRef },
488 output: reset.endpoint,
489 });
490 }
491 expect((yield* readConfig(environmentId)).etag).toBe(
492 afterRestore.etag,
493 );
494 yield* assertConfig("original");
495
496 const managedAgain = yield* deployEndpoint("api");
497 expect(managedAgain.endpoint.previousDnsPrefix).toBe("original");
498 yield* setPrefix(environmentId, service.id, "external");
499 yield* waitForEndpoint(input, "external");
500 yield* stack.deploy(networkStack);
501 yield* assertConfig("external");
502 const external = yield* waitForEndpoint(input, "external");
503 expect(external?.publicId).toBe(created.endpoint.publicId);
504 }),
505 (service) =>
506 deleteService(service.id).pipe(
507 Effect.catchTag("RailwayNotFound", () => Effect.void),
508 Effect.orDie,
509 ),
510 );
511 }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.orDie)), logLevel),
512 {
513 tags: [
514 "provider:railway",
515 "provider:railway:privatenetwork",
516 "provider:railway:project",
517 "provider:railway:projectenvironment",
518 "live",
519 ],
520 timeout: 120_000,
521 },
522);