1import { projectServices as fetchProjectServices } from "./GraphQL.ts";
2import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
3import {
4 Railway,
5 type PrivateNetwork as RailwayPrivateNetwork,
6 type PrivateNetworkEndpoint as RailwayPrivateNetworkEndpoint,
7 type PrivateNetworkEndpointSyncStatus,
8} from "@distilled.cloud/railway";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as Schedule from "effect/Schedule";
12import * as Schema from "effect/Schema";
13import { isResolved } from "../Diff.ts";
14import * as Provider from "../Provider.ts";
15import { Resource } from "../Resource.ts";
16import { sanitizeRailwayName } from "./Metadata.ts";
17import { withEnvironmentConfigLock } from "./transient.ts";
18import { ownedProjects, projectEnvironmentIds } from "./Project.ts";
19import type { Providers } from "./Providers.ts";
20
21const networkFields = <E>(network: Query<RailwayPrivateNetwork, E>) => ({
22 createdAt: network.createdAt,
23 deletedAt: network.deletedAt,
24 dnsName: network.dnsName,
25 environmentId: network.environmentId,
26 name: network.name,
27 networkId: network.networkId,
28 projectId: network.projectId,
29 publicId: network.publicId,
30 tags: network.tags,
31});
32type PrivateNetworksResultItem = UnwrapPlan<ReturnType<typeof networkFields>>;
33
34const endpointFields = <E>(
35 endpoint: Query<RailwayPrivateNetworkEndpoint, E>,
36) => ({
37 createdAt: endpoint.createdAt,
38 deletedAt: endpoint.deletedAt,
39 dnsName: endpoint.dnsName,
40 newDnsName: endpoint.newDnsName,
41 privateIps: endpoint.privateIps,
42 publicId: endpoint.publicId,
43 serviceInstanceId: endpoint.serviceInstanceId,
44 syncStatus: endpoint.syncStatus,
45 tags: endpoint.tags,
46});
47type PrivateNetworkEndpointValue = UnwrapPlan<
48 ReturnType<typeof endpointFields>
49>;
50
51const readEnvironmentConfig = Query.fn((id: string) => ({
52 config: Railway.environment({ id }).config,
53}));
54
55const readPrivateNetworks = Query.fn((environmentId: string) =>
56 Railway.privateNetworks({ environmentId }).pipe(Query.map(networkFields)),
57);
58
59const readPrivateNetworkEndpoint = Query.fn(
60 (input: {
61 environmentId: string;
62 privateNetworkId: string;
63 serviceId: string;
64 }) => Railway.privateNetworkEndpoint(input).pipe(Query.map(endpointFields)),
65);
66
67const readServiceName = Query.fn((id: string) => ({
68 name: Railway.service({ id }).name,
69}));
70
71const endpointNameAvailable = Query.fn(
72 (input: {
73 environmentId: string;
74 privateNetworkId: string;
75 prefix: string;
76 }) => Railway.privateNetworkEndpointNameAvailable(input),
77);
78
79const environmentPatchCommit = Query.fn(
80 (input: { environmentId: string; commitMessage: string; patch: unknown }) =>
81 Railway.environmentPatchCommit(input),
82);
83
84/**
85 * A resource-valued prop: the resource itself, or an Effect that produces
86 * it (so `yield* Project(...)` and `Project(...)` both type-check).
87 */
88type Ref<T> = T | Effect.Effect<T, never, Providers>;
89
90const PLATFORM_NETWORK_NAME = "railway";
91
92const NetworkConfig = Schema.Struct({
93 privateNetworkDisabled: Schema.optional(Schema.NullOr(Schema.Boolean)),
94 services: Schema.optional(
95 Schema.NullOr(
96 Schema.Record(
97 Schema.String,
98 Schema.NullOr(
99 Schema.Struct({
100 networking: Schema.optional(
101 Schema.NullOr(
102 Schema.Struct({
103 privateNetworkEndpoint: Schema.optional(
104 Schema.NullOr(Schema.String),
105 ),
106 }),
107 ),
108 ),
109 }),
110 ),
111 ),
112 ),
113 ),
114});
115
116const readNetworkConfig = Effect.fn(function* (environmentId: string) {
117 const environment = yield* readEnvironmentConfig(environmentId);
118 return yield* Schema.decodeUnknownEffect(NetworkConfig)(environment.config);
119});
120
121export class PrivateNetworkConfigPending extends Data.TaggedError(
122 "Railway.PrivateNetworkConfigPending",
123)<{ environmentId: string; message: string }> {}
124
125const waitForNetworkConfig = (
126 environmentId: string,
127 ready: (config: typeof NetworkConfig.Type) => boolean,
128) =>
129 readNetworkConfig(environmentId).pipe(
130 Effect.flatMap((config) =>
131 ready(config)
132 ? Effect.void
133 : Effect.fail(
134 new PrivateNetworkConfigPending({
135 environmentId,
136 message: "Waiting for Railway private-network configuration",
137 }),
138 ),
139 ),
140 Effect.retry({
141 while: (error) => error._tag === "Railway.PrivateNetworkConfigPending",
142 schedule: Schedule.spaced("1 second"),
143 times: 8,
144 }),
145 );
146
147export class PrivateNetworkNameUnsupported extends Data.TaggedError(
148 "Railway.PrivateNetworkNameUnsupported",
149)<{ name: string }> {
150 override get message() {
151 return `Railway manages one private network per environment. Custom network '${this.name}' is no longer supported; remove the legacy declaration and use PrivateNetwork without a name.`;
152 }
153}
154
155/**
156 * Environment identity a private network lives in. Accepts a
157 * `Railway.Project` (its primary environment), a `Railway.Environment`,
158 * or an `{ environmentId, projectId? }` stub.
159 */
160export type PrivateNetworkEnvironment = {
161 readonly environmentId: string;
162 readonly projectId?: string;
163};
164
165export interface PrivateNetworkProps {
166 /**
167 * Environment whose platform-managed private network is enabled. Accepts a
168 * `Railway.Project`, `Railway.Environment`, or `{ environmentId, projectId }`.
169 * Changing it replaces the configuration resource.
170 */
171 environment: Ref<PrivateNetworkEnvironment>;
172 /**
173 * Only the platform name `railway` is supported. Custom names are rejected.
174 * @deprecated Omit this property; Railway names the environment's network.
175 */
176 name?: string;
177}
178
179export type PrivateNetwork = Resource<
180 "Railway.PrivateNetwork",
181 PrivateNetworkProps,
182 {
183 /** Railway public network id (string identity used by endpoint APIs). */
184 publicId: string;
185 /** Private-networking setting to restore when this resource is removed. */
186 previousPrivateNetworkDisabled?: boolean;
187 /** Numeric WireGuard network id, as a decimal string. */
188 networkId: string;
189 /** Physical network name (unique per environment). */
190 name: string;
191 /** Network DNS label reported by Railway. */
192 dnsName: string;
193 /** Parent Railway project id. */
194 projectId: string;
195 /** Environment the network lives in. */
196 environmentId: string;
197 /** Observed tags. */
198 tags: string[];
199 /** RFC3339 creation timestamp, if Railway reported one. */
200 createdAt: string | undefined;
201 },
202 never,
203 Providers
204>;
205
206const resolvePrivateNetworkProps = (
207 props:
208 | PrivateNetworkProps
209 | Effect.Effect<PrivateNetworkProps, never, Providers>,
210): Effect.Effect<PrivateNetworkProps, never, Providers> =>
211 Effect.gen(function* () {
212 const resolved = Effect.isEffect(props) ? yield* props : props;
213 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
214 const environment = Effect.isEffect(resolved.environment)
215 ? yield* resolved.environment as Effect.Effect<
216 PrivateNetworkEnvironment,
217 never,
218 Providers
219 >
220 : resolved.environment;
221 return { ...resolved, environment };
222 });
223
224const PrivateNetworkResource = Resource<PrivateNetwork>(
225 "Railway.PrivateNetwork",
226);
227
228/**
229 * Enable Railway's platform-managed private network for an environment.
230 * This resource manages the environment's networking setting, not a separately
231 * created network. Use one PrivateNetwork resource per environment.
232 *
233 * Destroy restores the setting captured before reconciliation. A network that
234 * was already enabled stays enabled; one enabled by this resource is disabled.
235 * Platform network objects follow the environment's lifecycle.
236 *
237 * :::caution[Named networks are no longer supported]
238 * Railway removed named-network creation. Remove legacy named-network
239 * declarations before adding this environment-managed resource. Custom names
240 * are rejected rather than silently mapped to the platform network.
241 * :::
242 *
243 * ### Enable private networking
244 * Pass a Project or Environment. `dnsName` is Railway's network DNS label.
245 *
246 * **Example:** Environment-managed network
247 * ```typescript
248 * const site = yield* Railway.Project("Site");
249 * const net = yield* Railway.PrivateNetwork("Mesh", {
250 * environment: site,
251 * });
252 * ```
253 *
254 * ### Endpoints
255 * Configure a deployed service's DNS prefix via
256 * {@link PrivateNetworkEndpoint}.
257 *
258 * **Example:** Endpoint on the network
259 * ```typescript
260 * const endpoint = yield* Railway.PrivateNetworkEndpoint("ApiDns", {
261 * network: net,
262 * service: { serviceId: "deployed-service-id" },
263 * name: "api",
264 * });
265 * ```
266 *
267 * ### Module-scope declarations
268 * Resource-valued props accept the resource or an Effect producing it.
269 *
270 * **Example:** Module-scope network
271 * ```typescript
272 * // src/network.ts
273 * import * as Railway from "alchemy/Railway";
274 *
275 * export const Site = Railway.Project("Site");
276 * export const Mesh = Railway.PrivateNetwork("Mesh", {
277 * environment: Site,
278 * });
279 * ```
280 *
281 * @resource
282 * @product Networking
283 */
284export const PrivateNetwork: typeof PrivateNetworkResource = Object.assign(
285 (
286 id: string,
287 props:
288 | PrivateNetworkProps
289 | Effect.Effect<PrivateNetworkProps, never, Providers>,
290 ) => PrivateNetworkResource(id, resolvePrivateNetworkProps(props)),
291 PrivateNetworkResource,
292);
293
294export class PrivateNetworkNotCreated extends Data.TaggedError(
295 "Railway.PrivateNetworkNotCreated",
296)<{
297 name: string;
298 environmentId: string;
299}> {}
300
301export class PrivateNetworkEnvironmentRequired extends Data.TaggedError(
302 "Railway.PrivateNetworkEnvironmentRequired",
303)<{
304 message: string;
305}> {}
306
307type CloudNetwork = PrivateNetworksResultItem;
308
309const environmentIdOf = (value: unknown): string | undefined => {
310 if (value === null || typeof value !== "object") return undefined;
311 const rec = value as { environmentId?: unknown };
312 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
313 ? rec.environmentId
314 : undefined;
315};
316
317const projectIdOf = (value: unknown): string | undefined => {
318 if (value === null || typeof value !== "object") return undefined;
319 const rec = value as { projectId?: unknown };
320 return typeof rec.projectId === "string" && rec.projectId.length > 0
321 ? rec.projectId
322 : undefined;
323};
324
325const publicIdOf = (value: unknown): string | undefined => {
326 if (value === null || typeof value !== "object") return undefined;
327 const rec = value as { publicId?: unknown };
328 return typeof rec.publicId === "string" && rec.publicId.length > 0
329 ? rec.publicId
330 : undefined;
331};
332
333const isGoneNetwork = (network: CloudNetwork | undefined) =>
334 network === undefined || network.deletedAt != null;
335
336const toNetworkAttrs = (
337 network: CloudNetwork,
338 fallback?: { name?: string; projectId?: string },
339): PrivateNetwork["Attributes"] => ({
340 publicId: network.publicId,
341 networkId: String(network.networkId),
342 name: network.name || fallback?.name || "",
343 dnsName: network.dnsName,
344 projectId: network.projectId || fallback?.projectId || "",
345 environmentId: network.environmentId,
346 tags: [...network.tags],
347 createdAt: network.createdAt ?? undefined,
348});
349
350const resolveNetworkName = Effect.fn(function* (name?: string) {
351 if (name !== undefined && name !== PLATFORM_NETWORK_NAME) {
352 return yield* new PrivateNetworkNameUnsupported({ name });
353 }
354 return PLATFORM_NETWORK_NAME;
355});
356
357const listNetworks = (environmentId: string) =>
358 readPrivateNetworks(environmentId).pipe(
359 Effect.map((items) => items.filter((network) => !isGoneNetwork(network))),
360 Effect.catchTag("RailwayNotFound", () =>
361 Effect.succeed([] as PrivateNetworksResultItem[]),
362 ),
363 );
364
365const findNetwork = (
366 environmentId: string,
367 match: (network: CloudNetwork) => boolean,
368) =>
369 listNetworks(environmentId).pipe(
370 Effect.map((networks) => networks.find(match)),
371 );
372
373const observeNetwork = Effect.fn(function* (input: {
374 environmentId: string;
375 publicId?: string;
376 name?: string;
377}) {
378 if (input.publicId !== undefined && input.publicId.length > 0) {
379 const byId = yield* findNetwork(
380 input.environmentId,
381 (network) => network.publicId === input.publicId,
382 );
383 if (byId !== undefined) return byId;
384 }
385 if (input.name !== undefined && input.name.length > 0) {
386 return yield* findNetwork(
387 input.environmentId,
388 (network) => network.name === input.name,
389 );
390 }
391 return undefined;
392});
393
394const ensureNetwork = Effect.fn(function* (environmentId: string) {
395 const previousPrivateNetworkDisabled = yield* withEnvironmentConfigLock(
396 environmentId,
397 Effect.gen(function* () {
398 const config = yield* readNetworkConfig(environmentId);
399 const network = yield* observeNetwork({
400 environmentId,
401 name: PLATFORM_NETWORK_NAME,
402 });
403 if (config.privateNetworkDisabled === true || network === undefined) {
404 yield* environmentPatchCommit({
405 environmentId,
406 commitMessage: "Enable private networking",
407 patch: { privateNetworkDisabled: false },
408 });
409 yield* waitForNetworkConfig(
410 environmentId,
411 (observed) => observed.privateNetworkDisabled === false,
412 );
413 }
414 return config.privateNetworkDisabled === true;
415 }),
416 );
417 const network = yield* observeNetwork({
418 environmentId,
419 name: PLATFORM_NETWORK_NAME,
420 }).pipe(
421 Effect.flatMap((network) =>
422 network === undefined
423 ? Effect.fail(
424 new PrivateNetworkNotCreated({
425 name: PLATFORM_NETWORK_NAME,
426 environmentId,
427 }),
428 )
429 : Effect.succeed(network),
430 ),
431 Effect.retry({
432 while: (error) => error._tag === "Railway.PrivateNetworkNotCreated",
433 schedule: Schedule.spaced("1 second"),
434 times: 8,
435 }),
436 );
437 return { network, previousPrivateNetworkDisabled };
438});
439
440export const PrivateNetworkProvider = () =>
441 Provider.succeed(PrivateNetwork, {
442 stables: ["projectId", "environmentId"],
443 // Platform-managed networking is released, never independently deleted.
444 nuke: { skip: true, dependsOn: ["Railway.Project"] },
445
446 diff: Effect.fn(function* ({ news, output }) {
447 if (news === undefined || !isResolved(news)) return undefined;
448 if (output === undefined) return undefined;
449 const environmentId = environmentIdOf(news.environment);
450 const environmentChanged =
451 environmentId !== undefined && environmentId !== output.environmentId;
452 if (environmentChanged) {
453 return { action: "replace" as const };
454 }
455 return undefined;
456 }),
457
458 read: Effect.fn(function* ({ olds, output }) {
459 const environmentId =
460 output?.environmentId ?? environmentIdOf(olds?.environment);
461 const name = output?.name ?? olds?.name ?? PLATFORM_NETWORK_NAME;
462 if (environmentId === undefined) return undefined;
463 const config = yield* readNetworkConfig(environmentId).pipe(
464 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
465 );
466 if (config === undefined || config.privateNetworkDisabled === true) {
467 return undefined;
468 }
469 const found = yield* observeNetwork({
470 environmentId,
471 publicId: output?.publicId,
472 name,
473 });
474 if (found === undefined) return undefined;
475 const attrs = toNetworkAttrs(found, {
476 name,
477 projectId: output?.projectId ?? projectIdOf(olds?.environment),
478 });
479 return {
480 ...attrs,
481 previousPrivateNetworkDisabled: output?.previousPrivateNetworkDisabled,
482 };
483 }),
484
485 list: Effect.fn(function* () {
486 const projects = yield* ownedProjects();
487 const rows = yield* Effect.forEach(projects, (project) =>
488 Effect.gen(function* () {
489 const envIds = yield* projectEnvironmentIds(project);
490 const nested = yield* Effect.forEach(envIds, (environmentId) =>
491 listNetworks(environmentId).pipe(
492 Effect.map((networks) =>
493 networks
494 .filter((network) => network.name === PLATFORM_NETWORK_NAME)
495 .map((network) =>
496 toNetworkAttrs(network, {
497 projectId: project.projectId,
498 }),
499 ),
500 ),
501 ),
502 );
503 return nested.flat();
504 }),
505 );
506 const seen = new Set<string>();
507 const unique: PrivateNetwork["Attributes"][] = [];
508 for (const row of rows.flat()) {
509 if (seen.has(row.publicId)) continue;
510 seen.add(row.publicId);
511 unique.push(row);
512 }
513 return unique;
514 }),
515
516 reconcile: Effect.fn(function* ({ news, output }) {
517 const props = news ?? ({} as PrivateNetworkProps);
518 const environmentId =
519 environmentIdOf(props.environment) ?? output?.environmentId;
520 const projectId = projectIdOf(props.environment) ?? output?.projectId;
521 if (environmentId === undefined || projectId === undefined) {
522 return yield* new PrivateNetworkEnvironmentRequired({
523 message:
524 "PrivateNetwork requires an environment with environmentId and projectId (pass a Railway.Project or Railway.Environment)",
525 });
526 }
527 const name = yield* resolveNetworkName(props.name ?? output?.name);
528 const current = yield* ensureNetwork(environmentId);
529 return {
530 ...toNetworkAttrs(current.network, { name, projectId }),
531 previousPrivateNetworkDisabled:
532 output?.previousPrivateNetworkDisabled ??
533 current.previousPrivateNetworkDisabled,
534 };
535 }),
536
537 delete: Effect.fn(function* ({ output }) {
538 // Legacy named-network state has no environment setting to restore.
539 if (output.previousPrivateNetworkDisabled !== true) return;
540 yield* withEnvironmentConfigLock(
541 output.environmentId,
542 Effect.gen(function* () {
543 const config = yield* readNetworkConfig(output.environmentId);
544 if (config.privateNetworkDisabled === true) return;
545 yield* environmentPatchCommit({
546 environmentId: output.environmentId,
547 commitMessage: "Restore private networking setting",
548 patch: { privateNetworkDisabled: true },
549 });
550 yield* waitForNetworkConfig(
551 output.environmentId,
552 (observed) => observed.privateNetworkDisabled === true,
553 );
554 }),
555 ).pipe(Effect.catchTag("RailwayNotFound", () => Effect.void));
556 }),
557 });
558
559/**
560 * Network identity an endpoint attaches to. Accepts a
561 * `Railway.PrivateNetwork` or a `{ publicId, environmentId? }` stub.
562 */
563export type PrivateNetworkEndpointNetwork = {
564 readonly publicId: string;
565 readonly environmentId?: string;
566 readonly projectId?: string;
567 readonly dnsName?: string;
568};
569
570/**
571 * Service identity an endpoint attaches to. Accepts a `Railway.Service`
572 * or a `{ serviceId, name? }` stub.
573 */
574export type PrivateNetworkEndpointService = {
575 readonly serviceId: string;
576 readonly name?: string;
577};
578
579export interface PrivateNetworkEndpointProps {
580 /**
581 * Platform-managed network. Accepts a `Railway.PrivateNetwork` or
582 * `{ publicId, environmentId }`. Changing it replaces the configuration resource.
583 */
584 network: Ref<PrivateNetworkEndpointNetwork>;
585 /**
586 * Service the endpoint advertises. Accepts a `Railway.Service` or
587 * `{ serviceId }`. Changing it replaces the endpoint.
588 */
589 service: Ref<PrivateNetworkEndpointService>;
590 /**
591 * Service DNS prefix. Defaults to the service name.
592 * Updates the service's environment configuration without replacing its endpoint.
593 */
594 name?: string;
595}
596
597export type PrivateNetworkEndpoint = Resource<
598 "Railway.PrivateNetworkEndpoint",
599 PrivateNetworkEndpointProps,
600 {
601 /** Railway public endpoint id. */
602 publicId: string;
603 /** Observed DNS prefix, not a fully qualified hostname. */
604 dnsName: string;
605 /** Pending DNS name while a rename is in flight, if any. */
606 newDnsName: string | undefined;
607 /** Internal IPs advertised on the mesh. */
608 privateIps: string[];
609 /** Service instance the endpoint is bound to. */
610 serviceInstanceId: string;
611 /** Previous DNS override to restore on removal; null restores the platform default. */
612 previousDnsPrefix?: string | null;
613 /** Parent service id. */
614 serviceId: string;
615 /** Parent network public id. */
616 privateNetworkId: string;
617 /** Environment the endpoint lives in. */
618 environmentId: string;
619 /** Parent Railway project id, if known. */
620 projectId: string | undefined;
621 /** Observed Railway sync status (`ACTIVE`, `CREATING`, …). */
622 syncStatus: PrivateNetworkEndpointSyncStatus;
623 /** Observed tags. */
624 tags: string[];
625 /** RFC3339 creation timestamp, if Railway reported one. */
626 createdAt: string | undefined;
627 },
628 never,
629 Providers
630>;
631
632const resolvePrivateNetworkEndpointProps = (
633 props:
634 | PrivateNetworkEndpointProps
635 | Effect.Effect<PrivateNetworkEndpointProps, never, Providers>,
636): Effect.Effect<PrivateNetworkEndpointProps, never, Providers> =>
637 Effect.gen(function* () {
638 const resolved = Effect.isEffect(props) ? yield* props : props;
639 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
640 const network = Effect.isEffect(resolved.network)
641 ? yield* resolved.network as Effect.Effect<
642 PrivateNetworkEndpointNetwork,
643 never,
644 Providers
645 >
646 : resolved.network;
647 const service = Effect.isEffect(resolved.service)
648 ? yield* resolved.service as Effect.Effect<
649 PrivateNetworkEndpointService,
650 never,
651 Providers
652 >
653 : resolved.service;
654 return { ...resolved, network, service };
655 });
656
657const PrivateNetworkEndpointResource = Resource<PrivateNetworkEndpoint>(
658 "Railway.PrivateNetworkEndpoint",
659);
660
661/**
662 * Configure the DNS prefix of a service's platform-managed endpoint on a
663 * {@link PrivateNetwork}. The service must already have an instance in the
664 * environment. Use one endpoint configuration resource per service/environment.
665 *
666 * Destroy restores the previous DNS override if the current override still
667 * matches this resource's value. Unrelated or externally changed settings are
668 * preserved. The platform endpoint itself follows the service's lifecycle.
669 * Legacy state without a captured DNS override cannot be restored: deletion
670 * fails explicitly while the endpoint exists, rather than silently forgetting it.
671 *
672 * ### Configure a service
673 * Pass the network and service. Omit `name` to use the service name
674 * as the DNS prefix.
675 *
676 * **Example:** Default prefix
677 * ```typescript
678 * const endpoint = yield* Railway.PrivateNetworkEndpoint("ApiDns", {
679 * network: net,
680 * service: api,
681 * });
682 * ```
683 *
684 * ### Custom DNS name
685 * `name` sets the service's private DNS prefix.
686 * Updating it renames in place.
687 *
688 * **Example:** Custom prefix
689 * ```typescript
690 * const endpoint = yield* Railway.PrivateNetworkEndpoint("ApiDns", {
691 * network: net,
692 * service: api,
693 * name: "api",
694 * });
695 * ```
696 *
697 * :::caution[Changing the target replaces the configuration resource]
698 * Changing `network` or `service` restores the old target's prior DNS override
699 * and applies the desired prefix to the new target. It does not delete either
700 * platform-managed endpoint.
701 * :::
702 *
703 * @resource
704 * @product Networking
705 */
706export const PrivateNetworkEndpoint: typeof PrivateNetworkEndpointResource =
707 Object.assign(
708 (
709 id: string,
710 props:
711 | PrivateNetworkEndpointProps
712 | Effect.Effect<PrivateNetworkEndpointProps, never, Providers>,
713 ) =>
714 PrivateNetworkEndpointResource(
715 id,
716 resolvePrivateNetworkEndpointProps(props),
717 ),
718 PrivateNetworkEndpointResource,
719 );
720
721export class PrivateNetworkEndpointNotCreated extends Data.TaggedError(
722 "Railway.PrivateNetworkEndpointNotCreated",
723)<{
724 privateNetworkId: string;
725 serviceId: string;
726 expectedPrefix?: string;
727 observedPrefix?: string;
728 pendingPrefix?: string;
729}> {
730 override get message() {
731 return `Private endpoint for ${this.serviceId} is not ready: expected ${this.expectedPrefix ?? "an endpoint"}, observed ${this.observedPrefix ?? "missing"}, pending ${this.pendingPrefix ?? "none"}`;
732 }
733}
734
735export class PrivateNetworkEndpointRestoreUnavailable extends Data.TaggedError(
736 "Railway.PrivateNetworkEndpointRestoreUnavailable",
737)<{ serviceId: string; environmentId: string }> {
738 override get message() {
739 return "Cannot restore this endpoint: legacy state has no previous DNS configuration and Railway removed endpoint deletion. Remove its service instance or explicitly release the resource from Alchemy state.";
740 }
741}
742
743export class PrivateNetworkEndpointTargetMissing extends Data.TaggedError(
744 "Railway.PrivateNetworkEndpointTargetMissing",
745)<{
746 message: string;
747}> {}
748
749type CloudEndpoint = PrivateNetworkEndpointValue;
750
751const serviceIdOf = (value: unknown): string | undefined => {
752 if (value === null || typeof value !== "object") return undefined;
753 const rec = value as { serviceId?: unknown };
754 return typeof rec.serviceId === "string" && rec.serviceId.length > 0
755 ? rec.serviceId
756 : undefined;
757};
758
759const serviceNameOf = (value: unknown): string | undefined => {
760 if (value === null || typeof value !== "object") return undefined;
761 const rec = value as { name?: unknown };
762 return typeof rec.name === "string" && rec.name.length > 0
763 ? rec.name
764 : undefined;
765};
766
767const goneEndpointStatus = (status: PrivateNetworkEndpointSyncStatus) =>
768 status === "DELETED" || status === "DELETING";
769
770const isGoneEndpoint = (endpoint: CloudEndpoint | null | undefined) =>
771 endpoint == null ||
772 endpoint.deletedAt != null ||
773 goneEndpointStatus(endpoint.syncStatus);
774
775const dnsPrefix = (dnsName: string) =>
776 dnsName
777 .replace(/\.+$/, "")
778 .replace(/\.railway\.internal$/i, "")
779 .split(".")
780 .filter((part) => part.length > 0)[0] ?? dnsName;
781
782const toEndpointAttrs = (
783 endpoint: NonNullable<CloudEndpoint>,
784 fallback: {
785 serviceId: string;
786 privateNetworkId: string;
787 environmentId: string;
788 projectId?: string;
789 },
790): PrivateNetworkEndpoint["Attributes"] => ({
791 publicId: endpoint.publicId,
792 dnsName: endpoint.dnsName,
793 newDnsName: endpoint.newDnsName ?? undefined,
794 privateIps: [...endpoint.privateIps],
795 serviceInstanceId: endpoint.serviceInstanceId,
796 serviceId: fallback.serviceId,
797 privateNetworkId: fallback.privateNetworkId,
798 environmentId: fallback.environmentId,
799 projectId: fallback.projectId,
800 syncStatus: endpoint.syncStatus,
801 tags: [...endpoint.tags],
802 createdAt: endpoint.createdAt ?? undefined,
803});
804
805const getEndpoint = (input: {
806 environmentId: string;
807 privateNetworkId: string;
808 serviceId: string;
809}) =>
810 readPrivateNetworkEndpoint({
811 environmentId: input.environmentId,
812 privateNetworkId: input.privateNetworkId,
813 serviceId: input.serviceId,
814 }).pipe(
815 Effect.map((endpoint) =>
816 endpoint == null || isGoneEndpoint(endpoint) ? undefined : endpoint,
817 ),
818 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
819 );
820
821const resolveServiceName = (serviceId: string, hint?: string) =>
822 hint !== undefined && hint.length > 0
823 ? Effect.succeed(hint)
824 : readServiceName(serviceId).pipe(
825 Effect.map((service) => service.name),
826 Effect.catchTag("RailwayNotFound", () =>
827 Effect.succeed(sanitizeRailwayName(serviceId)),
828 ),
829 );
830
831export class PrivateNetworkEndpointNameUnavailable extends Data.TaggedError(
832 "Railway.PrivateNetworkEndpointNameUnavailable",
833)<{
834 name: string;
835 privateNetworkId: string;
836 serviceId: string;
837}> {}
838
839const waitUntilEndpointNamed = (input: {
840 environmentId: string;
841 privateNetworkId: string;
842 serviceId: string;
843 prefix: string;
844}) =>
845 getEndpoint(input).pipe(
846 Effect.flatMap((endpoint) => {
847 if (endpoint == null || dnsPrefix(endpoint.dnsName) !== input.prefix) {
848 return Effect.fail(
849 new PrivateNetworkEndpointNotCreated({
850 privateNetworkId: input.privateNetworkId,
851 serviceId: input.serviceId,
852 expectedPrefix: input.prefix,
853 observedPrefix: endpoint?.dnsName,
854 pendingPrefix: endpoint?.newDnsName ?? undefined,
855 }),
856 );
857 }
858 return Effect.succeed(endpoint);
859 }),
860 Effect.retry({
861 while: (e) => e._tag === "Railway.PrivateNetworkEndpointNotCreated",
862 times: 8,
863 schedule: Schedule.spaced("1 second"),
864 }),
865 );
866
867export const PrivateNetworkEndpointProvider = () =>
868 Provider.succeed(PrivateNetworkEndpoint, {
869 stables: [
870 "publicId",
871 "serviceId",
872 "privateNetworkId",
873 "environmentId",
874 "serviceInstanceId",
875 ],
876 nuke: {
877 skip: true,
878 dependsOn: [
879 "Railway.PrivateNetwork",
880 "Railway.Service",
881 "Railway.Project",
882 ],
883 },
884
885 diff: Effect.fn(function* ({ news, output }) {
886 if (news === undefined || !isResolved(news)) return undefined;
887 if (output === undefined) return undefined;
888 const serviceId = serviceIdOf(news.service);
889 const serviceChanged =
890 serviceId !== undefined && serviceId !== output.serviceId;
891 const networkId = publicIdOf(news.network);
892 const networkChanged =
893 networkId !== undefined && networkId !== output.privateNetworkId;
894 if (serviceChanged || networkChanged) {
895 return { action: "replace" as const };
896 }
897 return undefined;
898 }),
899
900 read: Effect.fn(function* ({ olds, output }) {
901 const serviceId = output?.serviceId ?? serviceIdOf(olds?.service);
902 const privateNetworkId =
903 output?.privateNetworkId ?? publicIdOf(olds?.network);
904 const environmentId =
905 output?.environmentId ??
906 environmentIdOf(olds?.network) ??
907 environmentIdOf(olds);
908 if (
909 serviceId === undefined ||
910 privateNetworkId === undefined ||
911 environmentId === undefined
912 ) {
913 return undefined;
914 }
915 const found = yield* getEndpoint({
916 environmentId,
917 privateNetworkId,
918 serviceId,
919 });
920 if (found === undefined) return undefined;
921 return {
922 ...toEndpointAttrs(found, {
923 serviceId,
924 privateNetworkId,
925 environmentId,
926 projectId: output?.projectId ?? projectIdOf(olds?.network),
927 }),
928 previousDnsPrefix: output?.previousDnsPrefix,
929 };
930 }),
931
932 list: Effect.fn(function* () {
933 const projects = yield* ownedProjects();
934 const rows = yield* Effect.forEach(projects, (project) =>
935 Effect.gen(function* () {
936 const environmentIds = yield* projectEnvironmentIds(project);
937 const networks = (yield* Effect.forEach(
938 environmentIds,
939 listNetworks,
940 )).flat();
941 const owned = networks.filter(
942 (network) => network.name === PLATFORM_NETWORK_NAME,
943 );
944 const live = yield* fetchProjectServices(
945 project.projectId,
946 (service) => ({
947 id: service.id,
948 name: service.name,
949 deletedAt: service.deletedAt,
950 }),
951 ).pipe(
952 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
953 );
954 const services = (live ?? []).filter(
955 (service) => service.deletedAt == null,
956 );
957 const nested = yield* Effect.forEach(owned, (network) =>
958 Effect.forEach(services, (service) =>
959 getEndpoint({
960 environmentId: network.environmentId,
961 privateNetworkId: network.publicId,
962 serviceId: service.id,
963 }).pipe(
964 Effect.map((endpoint) =>
965 endpoint === undefined
966 ? undefined
967 : toEndpointAttrs(endpoint, {
968 serviceId: service.id,
969 privateNetworkId: network.publicId,
970 environmentId: network.environmentId,
971 projectId: project.projectId,
972 }),
973 ),
974 ),
975 ).pipe(
976 Effect.map((items) =>
977 items.filter(
978 (item): item is PrivateNetworkEndpoint["Attributes"] =>
979 item !== undefined,
980 ),
981 ),
982 ),
983 );
984 return nested.flat();
985 }),
986 );
987 return rows.flat();
988 }),
989
990 reconcile: Effect.fn(function* ({ news, output }) {
991 const props = news ?? ({} as PrivateNetworkEndpointProps);
992 const serviceId = serviceIdOf(props.service) ?? output?.serviceId;
993 const privateNetworkId =
994 publicIdOf(props.network) ?? output?.privateNetworkId;
995 const environmentId =
996 environmentIdOf(props.network) ?? output?.environmentId;
997 const projectId = projectIdOf(props.network) ?? output?.projectId;
998 if (
999 serviceId === undefined ||
1000 privateNetworkId === undefined ||
1001 environmentId === undefined
1002 ) {
1003 return yield* new PrivateNetworkEndpointTargetMissing({
1004 message:
1005 "PrivateNetworkEndpoint requires a network (publicId + environmentId) and a service",
1006 });
1007 }
1008
1009 const serviceName = yield* resolveServiceName(
1010 serviceId,
1011 serviceNameOf(props.service),
1012 );
1013 const desiredOverride =
1014 props.name === undefined ? null : sanitizeRailwayName(props.name);
1015 const desiredPrefix = desiredOverride ?? sanitizeRailwayName(serviceName);
1016
1017 const network = yield* observeNetwork({
1018 environmentId,
1019 publicId: privateNetworkId,
1020 });
1021 if (network?.name !== PLATFORM_NETWORK_NAME) {
1022 return yield* new PrivateNetworkEndpointTargetMissing({
1023 message:
1024 "PrivateNetworkEndpoint requires the platform-managed Railway network; legacy named networks are no longer supported",
1025 });
1026 }
1027
1028 const previousDnsPrefix = yield* withEnvironmentConfigLock(
1029 environmentId,
1030 Effect.gen(function* () {
1031 const config = yield* readNetworkConfig(environmentId);
1032 if (config.privateNetworkDisabled === true) {
1033 return yield* new PrivateNetworkEndpointTargetMissing({
1034 message:
1035 "Enable private networking with PrivateNetwork before configuring an endpoint",
1036 });
1037 }
1038 const configuredPrefix =
1039 config.services?.[serviceId]?.networking?.privateNetworkEndpoint;
1040 const current = yield* getEndpoint({
1041 environmentId,
1042 privateNetworkId,
1043 serviceId,
1044 });
1045 const hasDesiredName =
1046 current !== undefined &&
1047 dnsPrefix(current.dnsName) === desiredPrefix;
1048 if ((configuredPrefix ?? null) === desiredOverride) {
1049 return configuredPrefix ?? null;
1050 }
1051 if (
1052 !hasDesiredName &&
1053 dnsPrefix(current?.newDnsName ?? "") !== desiredPrefix
1054 ) {
1055 const available = yield* endpointNameAvailable({
1056 environmentId,
1057 privateNetworkId,
1058 prefix: desiredPrefix,
1059 });
1060 if (!available) {
1061 return yield* new PrivateNetworkEndpointNameUnavailable({
1062 name: desiredPrefix,
1063 privateNetworkId,
1064 serviceId,
1065 });
1066 }
1067 }
1068 yield* environmentPatchCommit({
1069 environmentId,
1070 commitMessage: "Configure private networking DNS prefix",
1071 patch: {
1072 services: {
1073 [serviceId]: {
1074 networking: { privateNetworkEndpoint: desiredOverride },
1075 },
1076 },
1077 },
1078 });
1079 return configuredPrefix ?? null;
1080 }),
1081 );
1082 const current = yield* waitUntilEndpointNamed({
1083 environmentId,
1084 privateNetworkId,
1085 serviceId,
1086 prefix: desiredPrefix,
1087 });
1088
1089 return {
1090 ...toEndpointAttrs(current, {
1091 serviceId,
1092 privateNetworkId,
1093 environmentId,
1094 projectId,
1095 }),
1096 previousDnsPrefix:
1097 output?.previousDnsPrefix !== undefined
1098 ? output.previousDnsPrefix
1099 : previousDnsPrefix,
1100 };
1101 }),
1102
1103 delete: Effect.fn(function* ({ olds, output }) {
1104 if (output.previousDnsPrefix === undefined) {
1105 const endpoint = yield* getEndpoint({
1106 environmentId: output.environmentId,
1107 privateNetworkId: output.privateNetworkId,
1108 serviceId: output.serviceId,
1109 });
1110 if (endpoint === undefined) return;
1111 return yield* new PrivateNetworkEndpointRestoreUnavailable({
1112 serviceId: output.serviceId,
1113 environmentId: output.environmentId,
1114 });
1115 }
1116 const previousDnsPrefix = output.previousDnsPrefix;
1117 const managedPrefix =
1118 olds.name === undefined ? null : sanitizeRailwayName(olds.name);
1119 yield* withEnvironmentConfigLock(
1120 output.environmentId,
1121 Effect.gen(function* () {
1122 const config = yield* readNetworkConfig(output.environmentId);
1123 const configuredPrefix =
1124 config.services?.[output.serviceId]?.networking
1125 ?.privateNetworkEndpoint;
1126 if (
1127 (configuredPrefix ?? null) !== managedPrefix ||
1128 (configuredPrefix ?? null) === previousDnsPrefix
1129 ) {
1130 return;
1131 }
1132 yield* environmentPatchCommit({
1133 environmentId: output.environmentId,
1134 commitMessage: "Restore private networking DNS prefix",
1135 patch: {
1136 services: {
1137 [output.serviceId]: {
1138 networking: { privateNetworkEndpoint: previousDnsPrefix },
1139 },
1140 },
1141 },
1142 });
1143 yield* waitForNetworkConfig(
1144 output.environmentId,
1145 (observed) =>
1146 (observed.services?.[output.serviceId]?.networking
1147 ?.privateNetworkEndpoint ?? null) === previousDnsPrefix,
1148 );
1149 }),
1150 ).pipe(Effect.catchTag("RailwayNotFound", () => Effect.void));
1151 }),
1152 });