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 {
6 activeReplicaRegions,
7 serviceRegionPlacement,
8} from "@/Railway/ServiceRegion.ts";
9import { withEnvironmentConfigLock } from "@/Railway/transient.ts";
10import { suitePartition } from "./suiteProject.ts";
11import * as Test from "@/Test/Alchemy";
12import { expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import { MinimumLogLevel } from "effect/References";
15import * as Result from "effect/Result";
16import * as Schedule from "effect/Schedule";
17import * as HttpClient from "effect/http/HttpClient";
18
19const { test } = Test.make({ providers: Railway.providers() });
20
21const logLevel = Effect.provideService(
22 MinimumLogLevel,
23 process.env.DEBUG ? "Debug" : "Info",
24);
25
26const readServiceDeletedAt = Query.fn((id: string) => ({
27 deletedAt: RailwayApi.service({ id }).deletedAt,
28}));
29
30const readService = Query.fn((id: string) => {
31 const service = RailwayApi.service({ id });
32 return {
33 id: service.id,
34 name: service.name,
35 projectId: service.projectId,
36 deletedAt: service.deletedAt,
37 };
38});
39
40const readServiceInstance = Query.fn(
41 (environmentId: string, serviceId: string) => {
42 const instance = RailwayApi.serviceInstance({ environmentId, serviceId });
43 return {
44 serviceId: instance.serviceId,
45 environmentId: instance.environmentId,
46 source: instance.source.pipe(
47 Query.map((source) => ({ image: source.image, repo: source.repo })),
48 ),
49 healthcheckPath: instance.healthcheckPath,
50 preDeployCommand: instance.preDeployCommand,
51 numReplicas: instance.numReplicas,
52 dockerfilePath: instance.dockerfilePath,
53 };
54 },
55);
56
57const readServiceDomains = Query.fn(
58 (input: { projectId: string; environmentId: string; serviceId: string }) => ({
59 serviceDomains: RailwayApi.domains(input).serviceDomains.pipe(
60 Query.map((domain) => ({
61 id: domain.id,
62 domain: domain.domain,
63 targetPort: domain.targetPort,
64 deletedAt: domain.deletedAt,
65 syncStatus: domain.syncStatus,
66 })),
67 ),
68 }),
69);
70
71const environmentPatchCommit = Query.fn(
72 (args: { environmentId: string; commitMessage: string; patch: unknown }) =>
73 RailwayApi.environmentPatchCommit(args),
74);
75
76const readGithubRepos = Query.fn(() =>
77 RailwayApi.githubRepos().pipe(
78 Query.map((repo) => ({
79 fullName: repo.fullName,
80 defaultBranch: repo.defaultBranch,
81 })),
82 ),
83);
84
85const readEnvironmentConfig = Query.fn((id: string, projectId: string) => ({
86 config: RailwayApi.environment({ id, projectId }).config,
87}));
88
89const waitUntilGone = (serviceId: string) =>
90 readServiceDeletedAt(serviceId).pipe(
91 Effect.map((service) =>
92 service.deletedAt != null ? ("gone" as const) : ("found" as const),
93 ),
94 Effect.catchTag("RailwayNotFound", () => Effect.succeed("gone" as const)),
95 Effect.repeat({
96 schedule: Schedule.spaced("1 second"),
97 until: (status) => status === "gone",
98 times: 10,
99 }),
100 );
101
102const waitUntilNoServiceDomains = (input: {
103 projectId: string;
104 environmentId: string;
105 serviceId: string;
106}) =>
107 readServiceDomains(input).pipe(
108 Effect.map((domains) =>
109 domains.serviceDomains.some(
110 (domain) =>
111 domain.deletedAt == null &&
112 domain.syncStatus !== "DELETED" &&
113 domain.syncStatus !== "DELETING",
114 )
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 "create, serve, list, update, and delete an image service",
127 (stack) =>
128 Effect.gen(function* () {
129 yield* stack.destroy();
130
131 const created = yield* stack.deploy(
132 Effect.gen(function* () {
133 const { project, environment } = yield* suitePartition;
134 const api = yield* Railway.Service("Api", {
135 project,
136 environment,
137 image: "nginx:alpine",
138 port: 80,
139 healthcheckPath: "/",
140 preDeploy: { command: "echo predeploy" },
141 });
142 return { project, environment, api };
143 }),
144 );
145
146 expect(created.api.serviceId).toEqual(expect.any(String));
147 expect(created.api.serviceId.length).toBeGreaterThan(0);
148 expect(created.api.projectId).toEqual(created.project.projectId);
149 expect(created.api.environmentId).toEqual(
150 created.environment.environmentId,
151 );
152 expect(created.api.name).toEqual(expect.any(String));
153 expect(created.api.name.length).toBeGreaterThan(0);
154 expect(created.api.name.length).toBeLessThanOrEqual(32);
155 expect(created.api.name).toMatch(/^[a-z][a-z0-9-]*$/);
156 expect(created.api.port).toEqual(80);
157 expect(created.api.domain).toEqual(expect.any(String));
158 expect(created.api.domain).toContain("up.railway.app");
159 expect(created.api.url).toEqual(`https://${created.api.domain}`);
160 expect(created.api.domainId).toEqual(expect.any(String));
161 expect(created.api.domainId!.length).toBeGreaterThan(0);
162
163 const fetched = yield* readService(created.api.serviceId);
164 expect(fetched.id).toEqual(created.api.serviceId);
165 expect(fetched.name).toEqual(created.api.name);
166 expect(fetched.projectId).toEqual(created.api.projectId);
167 expect(fetched.deletedAt).toBeNull();
168
169 const instance = yield* readServiceInstance(
170 created.api.environmentId,
171 created.api.serviceId,
172 );
173 expect(instance.serviceId).toEqual(created.api.serviceId);
174 expect(instance.environmentId).toEqual(created.api.environmentId);
175 expect(instance.source?.image).toEqual(
176 expect.stringContaining("nginx:alpine"),
177 );
178 expect(instance.healthcheckPath).toEqual("/");
179 expect(
180 instance.preDeployCommand === "echo predeploy" ||
181 (Array.isArray(instance.preDeployCommand) &&
182 instance.preDeployCommand.length === 1 &&
183 instance.preDeployCommand[0] === "echo predeploy"),
184 ).toEqual(true);
185 // Railway omits numReplicas until you scale; default is one replica.
186 expect(
187 instance.numReplicas === null || instance.numReplicas === 1,
188 ).toEqual(true);
189 expect(created.api.healthcheckPath).toEqual("/");
190 expect(
191 created.api.replicas === undefined || created.api.replicas === 1,
192 ).toEqual(true);
193
194 const domains = yield* readServiceDomains({
195 environmentId: created.api.environmentId,
196 projectId: created.api.projectId,
197 serviceId: created.api.serviceId,
198 });
199 const liveDomain = domains.serviceDomains.find(
200 (domain) => domain.deletedAt == null && domain.syncStatus !== "DELETED",
201 );
202 expect(liveDomain).toBeDefined();
203 expect(liveDomain?.domain).toEqual(created.api.domain);
204 expect(liveDomain?.targetPort).toEqual(80);
205
206 const provider = yield* Provider.findProvider(Railway.Service);
207 const listed = yield* provider.list();
208 const found = listed.find(
209 (service) => service.serviceId === created.api.serviceId,
210 );
211 expect(found).toBeDefined();
212 expect(found?.name).toEqual(created.api.name);
213 expect(found?.projectId).toEqual(created.api.projectId);
214
215 const client = yield* HttpClient.HttpClient;
216 const body = yield* client.get(created.api.url!).pipe(
217 Effect.flatMap((res) =>
218 res.status === 200
219 ? res.text
220 : Effect.fail(new Error(`api returned ${res.status}`)),
221 ),
222 Effect.retry({
223 schedule: Schedule.spaced("4 seconds"),
224 times: 10,
225 }),
226 );
227 expect(typeof body).toEqual("string");
228 expect(body.length).toBeGreaterThan(0);
229
230 const nextName =
231 created.api.name.slice(0, -1) +
232 (created.api.name.endsWith("z") ? "y" : "z");
233
234 const updated = yield* stack.deploy(
235 Effect.gen(function* () {
236 const { project, environment } = yield* suitePartition;
237 const api = yield* Railway.Service("Api", {
238 project,
239 environment,
240 image: "nginx:alpine",
241 port: 80,
242 name: nextName,
243 healthcheckPath: "/",
244 preDeploy: { command: null },
245 });
246 return { project, environment, api };
247 }),
248 );
249
250 expect(updated.api.serviceId).toEqual(created.api.serviceId);
251 expect(updated.api.name).toEqual(nextName);
252 expect(updated.api.projectId).toEqual(created.api.projectId);
253 expect(updated.api.url).toEqual(created.api.url);
254
255 const updatedInstance = yield* readServiceInstance(
256 updated.api.environmentId,
257 updated.api.serviceId,
258 );
259 expect(updatedInstance.healthcheckPath).toEqual("/");
260 expect(
261 updatedInstance.preDeployCommand == null ||
262 (Array.isArray(updatedInstance.preDeployCommand) &&
263 updatedInstance.preDeployCommand.length === 0),
264 ).toEqual(true);
265
266 const fetchedUpdate = yield* readService(updated.api.serviceId);
267 expect(fetchedUpdate.id).toEqual(updated.api.serviceId);
268 expect(fetchedUpdate.name).toEqual(nextName);
269
270 const privateUpdate = yield* stack.deploy(
271 Effect.gen(function* () {
272 const { project, environment } = yield* suitePartition;
273 const api = yield* Railway.Service("Api", {
274 project,
275 environment,
276 image: "hashicorp/http-echo",
277 port: 5678,
278 name: nextName,
279 healthcheckPath: "/health",
280 publicDomain: false,
281 });
282 return { api };
283 }),
284 );
285
286 expect(privateUpdate.api.serviceId).toEqual(created.api.serviceId);
287 expect(privateUpdate.api.dnsName).toEqual(`${nextName}.railway.internal`);
288 expect(privateUpdate.api.url).toBeUndefined();
289 expect(privateUpdate.api.domain).toBeUndefined();
290 expect(privateUpdate.api.domainId).toBeUndefined();
291 expect(
292 yield* waitUntilNoServiceDomains({
293 projectId: privateUpdate.api.projectId,
294 environmentId: privateUpdate.api.environmentId,
295 serviceId: privateUpdate.api.serviceId,
296 }),
297 ).toEqual("gone");
298
299 const publicUpdate = yield* stack.deploy(
300 Effect.gen(function* () {
301 const { project, environment } = yield* suitePartition;
302 const api = yield* Railway.Service("Api", {
303 project,
304 environment,
305 image: "hashicorp/http-echo",
306 port: 5678,
307 name: nextName,
308 healthcheckPath: "/health",
309 publicDomain: true,
310 });
311 return { api };
312 }),
313 );
314
315 expect(publicUpdate.api.serviceId).toEqual(created.api.serviceId);
316 expect(publicUpdate.api.domainId).toEqual(expect.any(String));
317 expect(publicUpdate.api.url).toEqual(
318 `https://${publicUpdate.api.domain}`,
319 );
320 expect(publicUpdate.api.dnsName).toEqual(`${nextName}.railway.internal`);
321
322 // Add a foreign generated domain. The config map key may not equal
323 // the GraphQL id, and list order is not ownership — wait for a new
324 // live row rather than assuming `[0] === patch UUID`.
325 const liveDomainIds = (domains: {
326 serviceDomains: ReadonlyArray<{
327 id: string;
328 deletedAt: string | null;
329 syncStatus: string;
330 }>;
331 }) =>
332 domains.serviceDomains
333 .filter(
334 (domain) =>
335 domain.deletedAt == null &&
336 domain.syncStatus !== "DELETED" &&
337 domain.syncStatus !== "DELETING",
338 )
339 .map((domain) => domain.id);
340 const beforeForeign = new Set(
341 liveDomainIds(
342 yield* readServiceDomains({
343 projectId: publicUpdate.api.projectId,
344 environmentId: publicUpdate.api.environmentId,
345 serviceId: publicUpdate.api.serviceId,
346 }),
347 ),
348 );
349 const foreignPatchId = yield* Effect.sync(() => crypto.randomUUID());
350 yield* withEnvironmentConfigLock(
351 publicUpdate.api.environmentId,
352 environmentPatchCommit({
353 environmentId: publicUpdate.api.environmentId,
354 commitMessage: "Arrange generated domains for ownership test",
355 patch: {
356 services: {
357 [publicUpdate.api.serviceId]: {
358 networking: {
359 serviceDomains: {
360 [foreignPatchId]: {},
361 [publicUpdate.api.domainId!]: {},
362 },
363 },
364 },
365 },
366 },
367 }),
368 );
369 const orderedIds = yield* readServiceDomains({
370 projectId: publicUpdate.api.projectId,
371 environmentId: publicUpdate.api.environmentId,
372 serviceId: publicUpdate.api.serviceId,
373 }).pipe(
374 Effect.map(liveDomainIds),
375 Effect.repeat({
376 schedule: Schedule.spaced("1 second"),
377 until: (ids) =>
378 ids.includes(publicUpdate.api.domainId!) &&
379 (ids.includes(foreignPatchId) ||
380 ids.some((id) => !beforeForeign.has(id))),
381 times: 10,
382 }),
383 );
384 const foreignDomainId =
385 orderedIds.find((id) => id === foreignPatchId) ??
386 orderedIds.find((id) => !beforeForeign.has(id));
387 expect(foreignDomainId).toBeDefined();
388 if (foreignDomainId === undefined) {
389 return;
390 }
391 expect(foreignDomainId).not.toEqual(publicUpdate.api.domainId);
392 expect(orderedIds).toContain(publicUpdate.api.domainId);
393
394 const publicWithForeignDomain = yield* stack.deploy(
395 Effect.gen(function* () {
396 const { project, environment } = yield* suitePartition;
397 const api = yield* Railway.Service("Api", {
398 project,
399 environment,
400 image: "hashicorp/http-echo",
401 port: 5678,
402 name: nextName,
403 healthcheckPath: "/health",
404 publicDomain: true,
405 });
406 return { api };
407 }),
408 );
409 expect(publicWithForeignDomain.api.domainId).toEqual(
410 publicUpdate.api.domainId,
411 );
412
413 const privateWithForeignDomain = yield* stack.deploy(
414 Effect.gen(function* () {
415 const { project, environment } = yield* suitePartition;
416 const api = yield* Railway.Service("Api", {
417 project,
418 environment,
419 image: "hashicorp/http-echo",
420 port: 5678,
421 name: nextName,
422 healthcheckPath: "/health",
423 publicDomain: false,
424 });
425 return { api };
426 }),
427 );
428 expect(privateWithForeignDomain.api.domainId).toBeUndefined();
429 const remainingDomains = yield* readServiceDomains({
430 projectId: publicUpdate.api.projectId,
431 environmentId: publicUpdate.api.environmentId,
432 serviceId: publicUpdate.api.serviceId,
433 });
434 const remainingIds = remainingDomains.serviceDomains
435 .filter(
436 (domain) =>
437 domain.deletedAt == null && domain.syncStatus !== "DELETED",
438 )
439 .map((domain) => domain.id);
440 expect(remainingIds).toContain(foreignDomainId);
441 expect(remainingIds).not.toContain(publicUpdate.api.domainId);
442
443 yield* stack.destroy();
444
445 const gone = yield* waitUntilGone(created.api.serviceId);
446 expect(gone).toEqual("gone");
447 }).pipe(logLevel),
448 {
449 tags: [
450 "provider:railway",
451 "provider:railway:project",
452 "provider:railway:projectenvironment",
453 "provider:railway:service",
454 "live",
455 ],
456 // Multiple deployment cycles plus deletion need their own lifecycle budget.
457 // Leave 30 seconds before the 240-second process cap for failure cleanup.
458 timeout: 210_000,
459 },
460);
461
462test.provider(
463 "create, read, and delete a private image service",
464 (stack) =>
465 Effect.gen(function* () {
466 yield* stack.destroy();
467
468 const created = yield* stack.deploy(
469 Effect.gen(function* () {
470 const { project, environment } = yield* suitePartition;
471 const worker = yield* Railway.Service("Worker", {
472 project,
473 environment,
474 image: "hashicorp/http-echo",
475 port: 5678,
476 publicDomain: false,
477 });
478 return { worker };
479 }),
480 );
481
482 expect(created.worker.serviceId).toEqual(expect.any(String));
483 expect(created.worker.dnsName).toEqual(
484 `${created.worker.name}.railway.internal`,
485 );
486 expect(created.worker.url).toBeUndefined();
487 expect(created.worker.domain).toBeUndefined();
488 expect(created.worker.domainId).toBeUndefined();
489 expect(
490 yield* waitUntilNoServiceDomains({
491 projectId: created.worker.projectId,
492 environmentId: created.worker.environmentId,
493 serviceId: created.worker.serviceId,
494 }),
495 ).toEqual("gone");
496
497 // A second deploy exercises refresh/read without claiming or creating a
498 // generated domain.
499 const refreshed = yield* stack.deploy(
500 Effect.gen(function* () {
501 const { project, environment } = yield* suitePartition;
502 const worker = yield* Railway.Service("Worker", {
503 project,
504 environment,
505 image: "hashicorp/http-echo",
506 port: 5678,
507 publicDomain: false,
508 });
509 return { worker };
510 }),
511 );
512 expect(refreshed.worker.serviceId).toEqual(created.worker.serviceId);
513 expect(refreshed.worker.dnsName).toEqual(created.worker.dnsName);
514 expect(refreshed.worker.url).toBeUndefined();
515 expect(refreshed.worker.domain).toBeUndefined();
516 expect(refreshed.worker.domainId).toBeUndefined();
517
518 yield* stack.destroy();
519 expect(yield* waitUntilGone(created.worker.serviceId)).toEqual("gone");
520 }).pipe(logLevel),
521 {
522 tags: [
523 "provider:railway",
524 "provider:railway:project",
525 "provider:railway:projectenvironment",
526 "provider:railway:service",
527 "live",
528 ],
529 timeout: 120_000,
530 },
531);
532
533const localContextDir = `${import.meta.dirname}/fixtures/local-context`;
534
535test.provider(
536 "create, serve, switch to image, and delete a local Docker context service",
537 (stack) =>
538 Effect.gen(function* () {
539 yield* stack.destroy();
540
541 const created = yield* stack.deploy(
542 Effect.gen(function* () {
543 const { project, environment } = yield* suitePartition;
544 const api = yield* Railway.Service("Ctx", {
545 project,
546 environment,
547 context: localContextDir,
548 port: 80,
549 healthcheckPath: "/",
550 });
551 return { api };
552 }),
553 );
554
555 expect(created.api.serviceId).toEqual(expect.any(String));
556 expect(created.api.domain).toContain("up.railway.app");
557 expect(created.api.url).toEqual(`https://${created.api.domain}`);
558
559 const fromContext = yield* readServiceInstance(
560 created.api.environmentId,
561 created.api.serviceId,
562 );
563 expect(fromContext.dockerfilePath).toEqual("Dockerfile");
564
565 const client = yield* HttpClient.HttpClient;
566 const body = yield* client.get(created.api.url!).pipe(
567 Effect.flatMap((res) =>
568 res.status === 200
569 ? res.text
570 : Effect.fail(new Error(`api returned ${res.status}`)),
571 ),
572 Effect.retry({
573 schedule: Schedule.spaced("4 seconds"),
574 times: 10,
575 }),
576 );
577 expect(typeof body).toEqual("string");
578 expect(body.length).toBeGreaterThan(0);
579
580 const updated = yield* stack.deploy(
581 Effect.gen(function* () {
582 const { project, environment } = yield* suitePartition;
583 const api = yield* Railway.Service("Ctx", {
584 project,
585 environment,
586 image: "nginx:alpine",
587 port: 80,
588 healthcheckPath: "/",
589 });
590 return { api };
591 }),
592 );
593 expect(updated.api.serviceId).toEqual(created.api.serviceId);
594
595 const fromImage = yield* readServiceInstance(
596 updated.api.environmentId,
597 updated.api.serviceId,
598 );
599 expect(fromImage.source?.image).toEqual(
600 expect.stringContaining("nginx:alpine"),
601 );
602
603 const afterImage = yield* client.get(updated.api.url!).pipe(
604 Effect.flatMap((res) =>
605 res.status === 200
606 ? res.text
607 : Effect.fail(new Error(`api returned ${res.status}`)),
608 ),
609 Effect.retry({
610 schedule: Schedule.spaced("4 seconds"),
611 times: 10,
612 }),
613 );
614 expect(typeof afterImage).toEqual("string");
615 expect(afterImage.length).toBeGreaterThan(0);
616
617 yield* stack.destroy();
618 expect(yield* waitUntilGone(created.api.serviceId)).toEqual("gone");
619 }).pipe(logLevel),
620 {
621 tags: [
622 "provider:railway",
623 "provider:railway:project",
624 "provider:railway:projectenvironment",
625 "provider:railway:service",
626 "live",
627 ],
628 timeout: 120_000,
629 },
630);
631
632// GitHub repo source requires a GitHub App connection on the Railway
633// account. The probe always runs and pins the typed gate tag. The
634// lifecycle is opt-in via RAILWAY_TEST_GITHUB=1.
635const githubEntitled = !!process.env.RAILWAY_TEST_GITHUB;
636
637test.provider(
638 "unconnected GitHub surfaces a typed entitlement error",
639 (stack) =>
640 Effect.gen(function* () {
641 yield* stack.destroy();
642
643 const result = yield* Effect.result(readGithubRepos());
644 if (Result.isSuccess(result)) {
645 yield* Effect.logInfo(
646 `GitHub is connected (${result.success.length} repos); probe is a no-op`,
647 );
648 yield* stack.destroy();
649 return;
650 }
651
652 // GitHub App is not connected for this token: GraphQL `Not Authorized`
653 // is already the typed `RailwayForbidden` tag (never UnknownRailwayError).
654 expect(result.failure._tag === "RailwayForbidden").toBe(true);
655
656 yield* stack.destroy();
657 }).pipe(logLevel),
658 {
659 tags: ["provider:railway", "provider:railway:service", "live"],
660 timeout: 120_000,
661 },
662);
663
664test.provider.skipIf(!githubEntitled)(
665 "create a github repo service",
666 (stack) =>
667 Effect.gen(function* () {
668 yield* stack.destroy();
669
670 const repos = yield* readGithubRepos();
671 const repo = repos[0];
672 expect(repo).toBeDefined();
673 expect(repo!.fullName.length).toBeGreaterThan(0);
674
675 const created = yield* stack.deploy(
676 Effect.gen(function* () {
677 const { project, environment } = yield* suitePartition;
678 const api = yield* Railway.Service("Api", {
679 project,
680 environment,
681 repo: repo!.fullName,
682 branch: repo!.defaultBranch,
683 });
684 return { project, environment, api };
685 }),
686 );
687
688 expect(created.api.serviceId).toEqual(expect.any(String));
689 expect(created.api.repo).toEqual(repo!.fullName);
690
691 const instance = yield* readServiceInstance(
692 created.api.environmentId,
693 created.api.serviceId,
694 );
695 expect(instance.source?.repo).toEqual(repo!.fullName);
696
697 yield* stack.destroy();
698
699 const gone = yield* waitUntilGone(created.api.serviceId);
700 expect(gone).toEqual("gone");
701 }).pipe(logLevel),
702 {
703 tags: [
704 "provider:railway",
705 "provider:railway:project",
706 "provider:railway:projectenvironment",
707 "provider:railway:service",
708 "live",
709 ],
710 timeout: 120_000,
711 },
712);
713
714const placedRegions = (config: unknown, serviceId: string) =>
715 activeReplicaRegions(serviceRegionPlacement(config, serviceId)).map(
716 (row) => row.region,
717 );
718
719test.provider(
720 "pin a service to a region and move it",
721 (stack) =>
722 Effect.gen(function* () {
723 yield* stack.destroy();
724
725 const region = "europe-west4-drams3a";
726 const created = yield* stack.deploy(
727 Effect.gen(function* () {
728 const { project, environment } = yield* suitePartition;
729 const api = yield* Railway.Service("Region", {
730 project,
731 environment,
732 image: "hashicorp/http-echo",
733 port: 5678,
734 publicDomain: false,
735 region,
736 });
737 return { api };
738 }),
739 );
740
741 expect(created.api.region).toEqual(region);
742 const createdConfig = yield* readEnvironmentConfig(
743 created.api.environmentId,
744 created.api.projectId,
745 );
746 expect(
747 placedRegions(createdConfig.config, created.api.serviceId),
748 ).toEqual([region]);
749
750 const moved = "us-west2";
751 const updated = yield* stack.deploy(
752 Effect.gen(function* () {
753 const { project, environment } = yield* suitePartition;
754 const api = yield* Railway.Service("Region", {
755 project,
756 environment,
757 image: "hashicorp/http-echo",
758 port: 5678,
759 publicDomain: false,
760 region: moved,
761 });
762 return { api };
763 }),
764 );
765
766 expect(updated.api.serviceId).toEqual(created.api.serviceId);
767 expect(updated.api.region).toEqual(moved);
768 const updatedConfig = yield* readEnvironmentConfig(
769 updated.api.environmentId,
770 updated.api.projectId,
771 );
772 expect(
773 placedRegions(updatedConfig.config, updated.api.serviceId),
774 ).toEqual([moved]);
775
776 yield* stack.destroy();
777
778 const gone = yield* waitUntilGone(created.api.serviceId);
779 expect(gone).toEqual("gone");
780 }).pipe(logLevel),
781 { timeout: 180_000 },
782);