mirror of
https://github.com/railwayapp/cli
synced 2026-04-21 14:07:23 +00:00
Add dev command for local development with Docker Compose (#710)
* init develop command
* develop: use service names, random ports, project-specific dir
- extract environment config types to controllers/environment_config.rs
- use slugified service names instead of IDs in compose
- generate deterministic external ports (10000-60000) to avoid conflicts
- save compose to ~/.railway/develop/<project_id>/
- always run detached
* develop: show service names and URLs in output
* develop: resolve variables and override Railway networking vars
- Fetch resolved variables via VariablesForServiceDeployment API
- Override RAILWAY_PRIVATE_DOMAIN with docker-compose service slug
- Override RAILWAY_PUBLIC_DOMAIN and RAILWAY_TCP_PROXY_DOMAIN with localhost
- Override RAILWAY_TCP_PROXY_PORT with mapped local port
- Replace *.railway.internal refs in values with service slugs
- Filter deprecated vars (RAILWAY_STATIC_URL, RAILWAY_SERVICE_*_URL)
- Add docker-compose networking for inter-container communication
- Escape $ as $$ in commands for docker-compose interpolation
* develop: add volume support and down subcommand
- Add volumes field to docker-compose output (persist through up/down)
- Add `develop down` subcommand to stop containers
- Use environment ID for compose dir and volume names
- Show volume mount paths in service summary
* run: use local services when develop is active
Add --no-local flag to skip. Extracts shared variable override logic
into controllers/local_override.rs for reuse between run and develop.
* develop down: add --clean flag to remove volumes and files
* restart services on failure
* develop: add local HTTPS with mkcert and pretty URLs
- auto-generate TLS certs via mkcert for {project}.railway.dev
- add Caddy reverse proxy for HTTPS termination
- set RAILWAY_PUBLIC_DOMAIN to https://{domain}:{port}
- auto-add /etc/hosts entry on develop up
- remove hosts entry on develop down --clean
- skip cert generation if already exists
* develop: use .localhost TLD instead of .railway.dev
.localhost resolves to 127.0.0.1 per RFC 6761, eliminating need for
/etc/hosts manipulation and sudo prompts
* process manager
* develop: support port 443 for prettier public URLs
- Try binding to port 443 at startup for cleaner URLs
- Port 443 mode: https://{service}.{project}.railway.localhost
- Fallback mode: https://{project}.railway.localhost:{port}
- Generate wildcard certs (*.project.railway.localhost) for port 443
- SNI-based routing in Caddy when using port 443
- Add http:// prefix to private domain display
* develop: add session lock to prevent concurrent code service runs
- Add DevelopSessionLock using fs2 file locking to prevent multiple
develop sessions running code services for the same environment
- Check existing https_mode before falling back from port 443
- Lock auto-releases on drop or process crash
* develop: refactor into testable module structure
- Extract shared types to controllers/config/ (EnvironmentConfig)
- Create controllers/develop/ with submodules:
- ports.rs: slugify, generate_port + tests
- variables.rs: override_railway_vars + tests
- session.rs: DevelopSessionLock + tests
- traits.rs: EnvironmentDataProvider, CommandRunner + mocks
- compose.rs: DockerCompose types, build_port_infos
- https_proxy.rs: HttpsConfig, certs, Caddyfile gen
- code_runner.rs: ProcessManager
- local_config.rs: LocalDevConfig
- Add async-trait dependency for trait abstractions
- Old controller files re-export for backward compat
* develop: remove unused traits and cleanup backward compat stubs
- Delete unused EnvironmentDataProvider and CommandRunner traits (371 lines)
- Add fetch_environment_config() function to controllers/config
- Update develop.rs to use centralized fetch function
- Delete empty stub files: process_manager.rs, develop_lock.rs,
local_https.rs, local_dev_config.rs
- Update local_override.rs to import directly from develop module
* develop: remove unused get_env_vars, cleanup dead_code attributes
- Remove unused get_env_vars method from ServiceInstance
- Remove unnecessary #![allow(dead_code)] from compose.rs and output.rs
- Add explanatory comment for dead_code allow in environment.rs
(needed for API deserialization struct fields)
* dev: rename develop to dev, add clean subcommand
- rename command from `develop` to `dev` (with `develop` alias)
- extract `--clean` flag from `down` into separate `clean` subcommand
- update output: remove "Using port 443" msg, add checkmark to "Started X image services"
* dev: store data in ~/.railway/develop/{projectId} instead of environmentId
* remove unnecessary comments
* dev: add unit tests for compose, https_proxy, local_config
Tests for port building, caddyfile generation, cert existence, and config operations.
* dev: fix cross-platform compatibility for Linux/Windows
- Add extra_hosts to Caddy service for host.docker.internal on Linux
- Use shell execution (sh -c / cmd /C) for proper command parsing
- Replace path unwraps with to_string_lossy()
* dev: check docker compose availability before running
* fix: handle Windows error kind for file lock conflicts
* dev configure: add service menu and --remove <name> support
* dev: clarify comments in up_command
* fix: use fs2 lock_contended_error for cross-platform lock check
* fix: use privateNetworkEndpoint for private domain resolution
Previously used slugified service name which may not match the actual
private network endpoint configured in the environment.
* refactor: extract build_service_endpoints helper for privateNetworkEndpoint
DRYs up duplicate logic between dev.rs and local_override.rs.
Fixes run command using environment_id instead of project_id for
is_local_develop_active check.
* dev: improve empty state messages
Distinguish "no services at all" from "no code services" so users
get actionable guidance (railway add vs railway develop configure)
* fix: only show networking for code services when port configured
* fix: skip canonicalize on windows to avoid UNC path prefix
canonicalize() returns \\?\C:\... paths on Windows which cmd.exe rejects
* dev: improve empty state messages with color
* dev: show command as first log line for code services
* dev: add port selection to configure and detect port conflicts
- prompt for port during service configuration (default: inferred from Railway)
- skip port prompt if service has no networking config
- detect and warn about port conflicts during configure
- force reconfigure conflicting ports during `railway dev up`
- only set PORT env var for services with networking configured
* dev: allow configuring multiple services on first-time setup
Use multi-select prompt instead of single-select, with summary at end
* dev: show OS-specific Docker install URL
* dev: format Docker not found message
* remove test dir
* remove test and to gitignore
* dev: fix cross-service public domain replacement
When service A references service B's RAILWAY_PUBLIC_DOMAIN via variable
interpolation, the production domain wasn't being replaced with the local
equivalent. Now builds a mapping of production -> local public domains
and replaces them in all variable values.
* dev: show next steps when only image services running
* dev: extract constants and refactor up_command
- Add port range constants (PORT_RANGE_MIN, PORT_RANGE_SIZE, RANDOM_PORT_MIN, RANDOM_PORT_MAX)
- Add resolve_path() for cross-platform path canonicalization
- Move generate_random_port() to ports.rs
- Extract helper functions from up_command:
- print_image_service_summary()
- print_code_service_summary()
- build_public_domain_mapping()
- build_image_service_compose()
- setup_caddy_proxy()
This commit is contained in:
parent
4e830ff507
commit
c65c909420
23 changed files with 4431 additions and 1155 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ bin/railway
|
|||
.direnv/*
|
||||
.env
|
||||
.DS_Store
|
||||
test
|
||||
|
|
|
|||
115
Cargo.lock
generated
115
Cargo.lock
generated
|
|
@ -93,6 +93,17 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.93",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-tungstenite"
|
||||
version = "0.28.2"
|
||||
|
|
@ -511,7 +522,7 @@ version = "3.4.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90eeab0aa92f3f9b4e87f258c72b139c207d251f9cbc1080a0086b86a8870dd3"
|
||||
dependencies = [
|
||||
"nix",
|
||||
"nix 0.29.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
|
|
@ -803,6 +814,16 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
|
|
@ -960,6 +981,18 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.15"
|
||||
|
|
@ -1704,7 +1737,7 @@ version = "0.7.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.2.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1719,6 +1752,18 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "8.0.0"
|
||||
|
|
@ -1932,7 +1977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d"
|
||||
dependencies = [
|
||||
"bytes 1.9.0",
|
||||
"getrandom",
|
||||
"getrandom 0.2.15",
|
||||
"rand",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
|
|
@ -1968,11 +2013,18 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "railwayapp"
|
||||
version = "4.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"async-tungstenite",
|
||||
"base64",
|
||||
"box_drawing",
|
||||
|
|
@ -1988,6 +2040,7 @@ dependencies = [
|
|||
"ctrlc",
|
||||
"derive-new",
|
||||
"dirs",
|
||||
"fs2",
|
||||
"futures 0.3.31",
|
||||
"futures-util",
|
||||
"graphql-ws-client",
|
||||
|
|
@ -2004,6 +2057,7 @@ dependencies = [
|
|||
"inquire",
|
||||
"is-terminal",
|
||||
"json_dotpath",
|
||||
"nix 0.30.1",
|
||||
"notify",
|
||||
"num_cpus",
|
||||
"open",
|
||||
|
|
@ -2014,12 +2068,14 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"serde_yaml",
|
||||
"similar",
|
||||
"struct-field-names-as-array",
|
||||
"structstruck",
|
||||
"strum 0.26.3",
|
||||
"synchronized-writer",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"textwrap",
|
||||
"thiserror 2.0.9",
|
||||
"tokio",
|
||||
|
|
@ -2056,7 +2112,7 @@ version = "0.6.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.2.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2074,7 +2130,7 @@ version = "0.4.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.2.15",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
|
@ -2159,7 +2215,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
|||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"getrandom 0.2.15",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
|
|
@ -2385,6 +2441,19 @@ dependencies = [
|
|||
"syn 2.0.93",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.7.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
|
|
@ -2641,6 +2710,19 @@ dependencies = [
|
|||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.1"
|
||||
|
|
@ -2934,6 +3016,12 @@ version = "0.5.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
|
@ -3016,6 +3104,15 @@ version = "0.11.0+wasi-snapshot-preview1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.1+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.99"
|
||||
|
|
@ -3447,6 +3544,12 @@ version = "0.0.19"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ colored = "2.2.0"
|
|||
dirs = "5.0.1"
|
||||
serde = { version = "1.0.217", features = ["derive"] }
|
||||
serde_json = "1.0.134"
|
||||
serde_yaml = "0.9"
|
||||
reqwest = { version = "0.12.12", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"json",
|
||||
|
|
@ -96,8 +97,14 @@ tokio-util = "0.7.15"
|
|||
similar = "2.7.0"
|
||||
pathdiff = "0.2.3"
|
||||
pastey = "0.1.1"
|
||||
nix = { version = "0.30.1", features = ["signal"] }
|
||||
fs2 = "0.4.3"
|
||||
async-trait = "0.1.89"
|
||||
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
opt-level = "z"
|
||||
panic = "abort"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.23.0"
|
||||
|
|
|
|||
1729
src/commands/dev.rs
Normal file
1729
src/commands/dev.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@ pub mod completion;
|
|||
pub mod connect;
|
||||
pub mod deploy;
|
||||
pub mod deployment;
|
||||
pub mod dev;
|
||||
pub mod docs;
|
||||
pub mod domain;
|
||||
pub mod down;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ use is_terminal::IsTerminal;
|
|||
use crate::{
|
||||
controllers::{
|
||||
environment::get_matched_environment,
|
||||
local_override::{
|
||||
apply_local_overrides, build_local_override_context, is_local_develop_active,
|
||||
},
|
||||
project::{ensure_project_and_environment_exist, get_project},
|
||||
variables::get_service_variables,
|
||||
},
|
||||
|
|
@ -24,6 +27,10 @@ pub struct Args {
|
|||
#[clap(short, long)]
|
||||
environment: Option<String>,
|
||||
|
||||
/// Skip local develop overrides even if docker-compose.yml exists
|
||||
#[clap(long)]
|
||||
no_local: bool,
|
||||
|
||||
/// Args to pass to the command
|
||||
#[clap(trailing_var_arg = true)]
|
||||
args: Vec<String>,
|
||||
|
|
@ -89,17 +96,24 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
.unwrap_or(linked_project.environment.clone());
|
||||
|
||||
let environment_id = get_matched_environment(&project, environment)?.id;
|
||||
let service = get_service(&configs, &project, args.service).await?;
|
||||
let service = get_service(&configs, &project, args.service.clone()).await?;
|
||||
|
||||
let variables = get_service_variables(
|
||||
let mut variables = get_service_variables(
|
||||
&client,
|
||||
&configs,
|
||||
linked_project.project.clone(),
|
||||
environment_id,
|
||||
service,
|
||||
environment_id.clone(),
|
||||
service.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !args.no_local && is_local_develop_active(&project.id) {
|
||||
let ctx =
|
||||
build_local_override_context(&client, &configs, &project, &environment_id).await?;
|
||||
variables = apply_local_overrides(variables, &service, &ctx);
|
||||
eprintln!("{}", "Using local develop services".yellow());
|
||||
}
|
||||
|
||||
// a bit janky :/
|
||||
ctrlc::set_handler(move || {
|
||||
// do nothing, we just want to ignore CTRL+C
|
||||
|
|
|
|||
183
src/controllers/config/environment.rs
Normal file
183
src/controllers/config/environment.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
// Fields on deserialization structs may not all be read
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{client::post_graphql, config::Configs, gql::queries};
|
||||
|
||||
/// Root environment config from `environment.config` GraphQL field
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnvironmentConfig {
|
||||
#[serde(default)]
|
||||
pub services: BTreeMap<String, ServiceInstance>,
|
||||
#[serde(default)]
|
||||
pub shared_variables: BTreeMap<String, Variable>,
|
||||
#[serde(default)]
|
||||
pub volumes: BTreeMap<String, VolumeInstance>,
|
||||
#[serde(default)]
|
||||
pub buckets: BTreeMap<String, BucketInstance>,
|
||||
#[serde(default)]
|
||||
pub private_network_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceInstance {
|
||||
#[serde(default)]
|
||||
pub source: Option<ServiceSource>,
|
||||
#[serde(default)]
|
||||
pub networking: Option<ServiceNetworking>,
|
||||
#[serde(default)]
|
||||
pub variables: BTreeMap<String, Variable>,
|
||||
#[serde(default)]
|
||||
pub deploy: Option<DeployConfig>,
|
||||
#[serde(default)]
|
||||
pub build: Option<BuildConfig>,
|
||||
#[serde(default)]
|
||||
pub volume_mounts: BTreeMap<String, VolumeMount>,
|
||||
#[serde(default)]
|
||||
pub is_deleted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceSource {
|
||||
pub image: Option<String>,
|
||||
pub repo: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
pub root_directory: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceNetworking {
|
||||
#[serde(default)]
|
||||
pub service_domains: BTreeMap<String, Option<DomainConfig>>,
|
||||
#[serde(default)]
|
||||
pub custom_domains: BTreeMap<String, Option<DomainConfig>>,
|
||||
#[serde(default)]
|
||||
pub tcp_proxies: BTreeMap<String, Option<TcpProxyConfig>>,
|
||||
pub private_network_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct DomainConfig {
|
||||
pub port: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct TcpProxyConfig {}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Variable {
|
||||
pub value: Option<String>,
|
||||
pub default_value: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub is_optional: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeployConfig {
|
||||
pub start_command: Option<String>,
|
||||
pub healthcheck_path: Option<String>,
|
||||
pub num_replicas: Option<i64>,
|
||||
pub cron_schedule: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildConfig {
|
||||
pub builder: Option<String>,
|
||||
pub build_command: Option<String>,
|
||||
pub dockerfile_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VolumeInstance {
|
||||
pub size_mb: Option<i64>,
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BucketInstance {
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VolumeMount {
|
||||
pub mount_path: Option<String>,
|
||||
}
|
||||
|
||||
impl ServiceInstance {
|
||||
pub fn is_image_based(&self) -> bool {
|
||||
self.source
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.image.is_some() && s.repo.is_none())
|
||||
}
|
||||
|
||||
pub fn is_code_based(&self) -> bool {
|
||||
self.source.as_ref().is_none_or(|s| s.image.is_none())
|
||||
}
|
||||
|
||||
pub fn get_ports(&self) -> Vec<i64> {
|
||||
let mut ports = Vec::new();
|
||||
if let Some(networking) = &self.networking {
|
||||
for config in networking.service_domains.values().flatten() {
|
||||
if let Some(port) = config.port {
|
||||
if !ports.contains(&port) {
|
||||
ports.push(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
for port_str in networking.tcp_proxies.keys() {
|
||||
if let Ok(port) = port_str.parse::<i64>() {
|
||||
if !ports.contains(&port) {
|
||||
ports.push(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ports
|
||||
}
|
||||
}
|
||||
|
||||
/// Response from fetch_environment_config containing config and metadata
|
||||
pub struct EnvironmentConfigResponse {
|
||||
pub config: EnvironmentConfig,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Fetch environment config from Railway API
|
||||
pub async fn fetch_environment_config(
|
||||
client: &Client,
|
||||
configs: &Configs,
|
||||
environment_id: &str,
|
||||
decrypt_variables: bool,
|
||||
) -> Result<EnvironmentConfigResponse> {
|
||||
let vars = queries::get_environment_config::Variables {
|
||||
id: environment_id.to_string(),
|
||||
decrypt_variables: Some(decrypt_variables),
|
||||
};
|
||||
|
||||
let data =
|
||||
post_graphql::<queries::GetEnvironmentConfig, _>(client, configs.get_backboard(), vars)
|
||||
.await?;
|
||||
|
||||
let config: EnvironmentConfig = serde_json::from_value(data.environment.config)
|
||||
.context("Failed to parse environment config")?;
|
||||
|
||||
Ok(EnvironmentConfigResponse {
|
||||
config,
|
||||
name: data.environment.name,
|
||||
})
|
||||
}
|
||||
3
src/controllers/config/mod.rs
Normal file
3
src/controllers/config/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod environment;
|
||||
|
||||
pub use environment::*;
|
||||
177
src/controllers/develop/code_runner.rs
Normal file
177
src/controllers/develop/code_runner.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
use std::{collections::BTreeMap, path::PathBuf, process::Stdio};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use colored::{Color, Colorize};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, BufReader},
|
||||
process::{Child, Command},
|
||||
sync::mpsc,
|
||||
};
|
||||
|
||||
const COLORS: &[Color] = &[
|
||||
Color::Cyan,
|
||||
Color::Green,
|
||||
Color::Yellow,
|
||||
Color::Magenta,
|
||||
Color::Blue,
|
||||
Color::Red,
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LogLine {
|
||||
pub service_name: String,
|
||||
pub message: String,
|
||||
pub is_stderr: bool,
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
struct ManagedProcess {
|
||||
#[allow(dead_code)]
|
||||
service_name: String,
|
||||
child: Child,
|
||||
#[allow(dead_code)]
|
||||
color: Color,
|
||||
}
|
||||
|
||||
pub struct ProcessManager {
|
||||
processes: Vec<ManagedProcess>,
|
||||
}
|
||||
|
||||
impl ProcessManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
processes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn spawn_service(
|
||||
&mut self,
|
||||
service_name: String,
|
||||
command: &str,
|
||||
working_dir: PathBuf,
|
||||
env_vars: BTreeMap<String, String>,
|
||||
log_tx: mpsc::Sender<LogLine>,
|
||||
) -> Result<()> {
|
||||
let color = COLORS[self.processes.len() % COLORS.len()];
|
||||
|
||||
#[cfg(unix)]
|
||||
let mut child = Command::new("sh")
|
||||
.args(["-c", command])
|
||||
.current_dir(&working_dir)
|
||||
.envs(env_vars)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to spawn '{}'", command))?;
|
||||
|
||||
#[cfg(windows)]
|
||||
let mut child = Command::new("cmd")
|
||||
.args(["/C", command])
|
||||
.current_dir(&working_dir)
|
||||
.envs(env_vars)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to spawn '{}'", command))?;
|
||||
|
||||
let cmd_log = LogLine {
|
||||
service_name: service_name.clone(),
|
||||
message: format!("$ {}", command),
|
||||
is_stderr: false,
|
||||
color,
|
||||
};
|
||||
let _ = log_tx.send(cmd_log).await;
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
let stderr = child.stderr.take().expect("stderr piped");
|
||||
|
||||
let name = service_name.clone();
|
||||
let tx = log_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
stream_output(stdout, name, color, false, tx).await;
|
||||
});
|
||||
|
||||
let name2 = service_name.clone();
|
||||
tokio::spawn(async move {
|
||||
stream_output(stderr, name2, color, true, log_tx).await;
|
||||
});
|
||||
|
||||
self.processes.push(ManagedProcess {
|
||||
service_name,
|
||||
child,
|
||||
color,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn shutdown(&mut self) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::sys::signal::{Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
|
||||
for proc in &self.processes {
|
||||
if let Some(pid) = proc.child.id() {
|
||||
let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
for proc in &mut self.processes {
|
||||
let _ = proc.child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
for proc in &mut self.processes {
|
||||
let _ =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), proc.child.wait()).await;
|
||||
}
|
||||
|
||||
for proc in &mut self.processes {
|
||||
let _ = proc.child.kill().await;
|
||||
}
|
||||
|
||||
self.processes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProcessManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_output<R: tokio::io::AsyncRead + Unpin>(
|
||||
reader: R,
|
||||
service_name: String,
|
||||
color: Color,
|
||||
is_stderr: bool,
|
||||
tx: mpsc::Sender<LogLine>,
|
||||
) {
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let log = LogLine {
|
||||
service_name: service_name.clone(),
|
||||
message: line,
|
||||
is_stderr,
|
||||
color,
|
||||
};
|
||||
if tx.send(log).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_log_line(log: &LogLine) {
|
||||
let prefix = format!("[{}]", log.service_name).color(log.color);
|
||||
if log.is_stderr {
|
||||
eprintln!("{} {}", prefix, log.message);
|
||||
} else {
|
||||
println!("{} {}", prefix, log.message);
|
||||
}
|
||||
}
|
||||
219
src/controllers/develop/compose.rs
Normal file
219
src/controllers/develop/compose.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ports::generate_port;
|
||||
use crate::controllers::config::ServiceInstance;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PortType {
|
||||
Http,
|
||||
Tcp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortInfo {
|
||||
pub internal: i64,
|
||||
pub external: u16,
|
||||
pub public_port: u16,
|
||||
pub port_type: PortType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DockerComposeFile {
|
||||
pub services: BTreeMap<String, DockerComposeService>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub networks: Option<DockerComposeNetworks>,
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub volumes: BTreeMap<String, DockerComposeVolume>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DockerComposeVolume {}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DockerComposeNetworks {
|
||||
pub railway: DockerComposeNetwork,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DockerComposeNetwork {
|
||||
pub driver: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DockerComposeService {
|
||||
pub image: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub command: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub restart: Option<String>,
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub environment: BTreeMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub ports: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub volumes: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub networks: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub extra_hosts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ComposeServiceStatus {
|
||||
#[serde(rename = "Service")]
|
||||
pub service: String,
|
||||
#[serde(rename = "State")]
|
||||
pub state: String,
|
||||
#[serde(rename = "Health")]
|
||||
pub health: String,
|
||||
#[serde(rename = "ExitCode")]
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
pub fn volume_name(environment_id: &str, volume_id: &str) -> String {
|
||||
format!("railway_{}_{}", &environment_id[..8], &volume_id[..8])
|
||||
}
|
||||
|
||||
pub fn build_port_infos(service_id: &str, svc: &ServiceInstance) -> Vec<PortInfo> {
|
||||
let mut port_infos = Vec::new();
|
||||
if let Some(networking) = &svc.networking {
|
||||
for config in networking.service_domains.values().flatten() {
|
||||
if let Some(port) = config.port {
|
||||
if !port_infos.iter().any(|p: &PortInfo| p.internal == port) {
|
||||
let private_port = generate_port(service_id, port);
|
||||
let public_port = generate_port(service_id, port + 10000);
|
||||
port_infos.push(PortInfo {
|
||||
internal: port,
|
||||
external: private_port,
|
||||
public_port,
|
||||
port_type: PortType::Http,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for port_str in networking.tcp_proxies.keys() {
|
||||
if let Ok(port) = port_str.parse::<i64>() {
|
||||
if !port_infos.iter().any(|p| p.internal == port) {
|
||||
let ext_port = generate_port(service_id, port);
|
||||
port_infos.push(PortInfo {
|
||||
internal: port,
|
||||
external: ext_port,
|
||||
public_port: ext_port,
|
||||
port_type: PortType::Tcp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
port_infos
|
||||
}
|
||||
|
||||
pub fn build_slug_port_mapping(service_id: &str, svc: &ServiceInstance) -> HashMap<i64, u16> {
|
||||
let mut mapping = HashMap::new();
|
||||
if let Some(networking) = &svc.networking {
|
||||
for config in networking.service_domains.values().flatten() {
|
||||
if let Some(port) = config.port {
|
||||
mapping
|
||||
.entry(port)
|
||||
.or_insert_with(|| generate_port(service_id, port));
|
||||
}
|
||||
}
|
||||
for port_str in networking.tcp_proxies.keys() {
|
||||
if let Ok(port) = port_str.parse::<i64>() {
|
||||
mapping
|
||||
.entry(port)
|
||||
.or_insert_with(|| generate_port(service_id, port));
|
||||
}
|
||||
}
|
||||
}
|
||||
mapping
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::controllers::config::{DomainConfig, ServiceInstance, ServiceNetworking};
|
||||
|
||||
#[test]
|
||||
fn test_volume_name() {
|
||||
assert_eq!(
|
||||
volume_name("env-12345678-xxxx", "vol-abcdefgh-yyyy"),
|
||||
"railway_env-1234_vol-abcd"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_port_infos_with_http_domain() {
|
||||
let svc = ServiceInstance {
|
||||
networking: Some(ServiceNetworking {
|
||||
service_domains: BTreeMap::from([(
|
||||
"example.up.railway.app".to_string(),
|
||||
Some(DomainConfig { port: Some(8080) }),
|
||||
)]),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let ports = build_port_infos("svc-123", &svc);
|
||||
assert_eq!(ports.len(), 1);
|
||||
assert_eq!(ports[0].internal, 8080);
|
||||
assert!(matches!(ports[0].port_type, PortType::Http));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_port_infos_with_tcp_proxy() {
|
||||
let svc = ServiceInstance {
|
||||
networking: Some(ServiceNetworking {
|
||||
tcp_proxies: BTreeMap::from([("6379".to_string(), None)]),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let ports = build_port_infos("redis-svc", &svc);
|
||||
assert_eq!(ports.len(), 1);
|
||||
assert_eq!(ports[0].internal, 6379);
|
||||
assert!(matches!(ports[0].port_type, PortType::Tcp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_port_infos_deduplicates() {
|
||||
let svc = ServiceInstance {
|
||||
networking: Some(ServiceNetworking {
|
||||
service_domains: BTreeMap::from([
|
||||
(
|
||||
"a.railway.app".to_string(),
|
||||
Some(DomainConfig { port: Some(3000) }),
|
||||
),
|
||||
(
|
||||
"b.railway.app".to_string(),
|
||||
Some(DomainConfig { port: Some(3000) }),
|
||||
),
|
||||
]),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let ports = build_port_infos("svc", &svc);
|
||||
assert_eq!(ports.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_slug_port_mapping() {
|
||||
let svc = ServiceInstance {
|
||||
networking: Some(ServiceNetworking {
|
||||
service_domains: BTreeMap::from([(
|
||||
"example.railway.app".to_string(),
|
||||
Some(DomainConfig { port: Some(8080) }),
|
||||
)]),
|
||||
tcp_proxies: BTreeMap::from([("5432".to_string(), None)]),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mapping = build_slug_port_mapping("svc-123", &svc);
|
||||
assert!(mapping.contains_key(&8080));
|
||||
assert!(mapping.contains_key(&5432));
|
||||
}
|
||||
}
|
||||
284
src/controllers/develop/https_proxy.rs
Normal file
284
src/controllers/develop/https_proxy.rs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
use std::net::TcpListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct HttpsConfig {
|
||||
pub project_slug: String,
|
||||
pub base_domain: String,
|
||||
pub cert_path: PathBuf,
|
||||
pub key_path: PathBuf,
|
||||
pub use_port_443: bool,
|
||||
}
|
||||
|
||||
/// Check if port 443 is available for binding.
|
||||
/// On macOS Mojave+, unprivileged processes can bind to 443 on 0.0.0.0.
|
||||
pub fn is_port_443_available() -> bool {
|
||||
TcpListener::bind("0.0.0.0:443").is_ok()
|
||||
}
|
||||
|
||||
pub fn check_mkcert_installed() -> bool {
|
||||
Command::new("mkcert")
|
||||
.arg("-help")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn check_docker_compose_installed() -> bool {
|
||||
Command::new("docker")
|
||||
.args(["compose", "version"])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn ensure_mkcert_ca() -> Result<()> {
|
||||
let output = Command::new("mkcert")
|
||||
.arg("-install")
|
||||
.output()
|
||||
.context("Failed to run mkcert -install")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("mkcert -install failed: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if certs already exist for a project with the required type
|
||||
pub fn certs_exist(output_dir: &Path, use_port_443: bool) -> bool {
|
||||
let cert_path = output_dir.join("cert.pem");
|
||||
let key_path = output_dir.join("key.pem");
|
||||
let mode_file = output_dir.join("https_mode");
|
||||
|
||||
if !cert_path.exists() || !key_path.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(mode) = std::fs::read_to_string(&mode_file) {
|
||||
let stored_443 = mode.trim() == "port_443";
|
||||
stored_443 == use_port_443
|
||||
} else {
|
||||
// No mode file = old certs, need regeneration for port 443
|
||||
!use_port_443
|
||||
}
|
||||
}
|
||||
|
||||
/// Get config for existing certs without regenerating
|
||||
pub fn get_existing_certs(
|
||||
project_slug: &str,
|
||||
output_dir: &Path,
|
||||
use_port_443: bool,
|
||||
) -> HttpsConfig {
|
||||
let base_domain = format!("{}.railway.localhost", project_slug);
|
||||
HttpsConfig {
|
||||
project_slug: project_slug.to_string(),
|
||||
base_domain,
|
||||
cert_path: output_dir.join("cert.pem"),
|
||||
key_path: output_dir.join("key.pem"),
|
||||
use_port_443,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_certs(
|
||||
project_slug: &str,
|
||||
output_dir: &Path,
|
||||
use_port_443: bool,
|
||||
) -> Result<HttpsConfig> {
|
||||
let base_domain = format!("{}.railway.localhost", project_slug);
|
||||
let wildcard_domain = format!("*.{}", base_domain);
|
||||
|
||||
let cert_path = output_dir.join("cert.pem");
|
||||
let key_path = output_dir.join("key.pem");
|
||||
|
||||
std::fs::create_dir_all(output_dir)?;
|
||||
|
||||
let mut cmd = Command::new("mkcert");
|
||||
cmd.arg("-cert-file")
|
||||
.arg(&cert_path)
|
||||
.arg("-key-file")
|
||||
.arg(&key_path);
|
||||
|
||||
// For port 443 mode, generate wildcard cert for all service subdomains
|
||||
if use_port_443 {
|
||||
cmd.arg(&wildcard_domain);
|
||||
}
|
||||
cmd.arg(&base_domain);
|
||||
|
||||
let output = cmd.output().context("Failed to run mkcert")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("mkcert failed: {}", stderr);
|
||||
}
|
||||
|
||||
// Save the mode so we know what type of certs we have
|
||||
let mode_file = output_dir.join("https_mode");
|
||||
let mode = if use_port_443 { "port_443" } else { "fallback" };
|
||||
std::fs::write(&mode_file, mode)?;
|
||||
|
||||
Ok(HttpsConfig {
|
||||
project_slug: project_slug.to_string(),
|
||||
base_domain,
|
||||
cert_path,
|
||||
key_path,
|
||||
use_port_443,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct ServicePort {
|
||||
pub slug: String,
|
||||
pub internal_port: i64,
|
||||
pub external_port: u16,
|
||||
pub is_http: bool,
|
||||
pub is_code_service: bool,
|
||||
}
|
||||
|
||||
pub fn generate_caddyfile(services: &[ServicePort], https_config: &HttpsConfig) -> String {
|
||||
let mut caddyfile = String::new();
|
||||
|
||||
caddyfile.push_str("{\n");
|
||||
caddyfile.push_str(" auto_https off\n");
|
||||
caddyfile.push_str("}\n\n");
|
||||
|
||||
for svc in services.iter().filter(|s| s.is_http) {
|
||||
// Port 443 mode: SNI routing with subdomains (no port in URL)
|
||||
// Fallback mode: per-service ports (current behavior)
|
||||
let site_address = if https_config.use_port_443 {
|
||||
format!("{}.{}", svc.slug, https_config.base_domain)
|
||||
} else {
|
||||
format!("{}:{}", https_config.base_domain, svc.external_port)
|
||||
};
|
||||
|
||||
caddyfile.push_str(&format!("{} {{\n", site_address));
|
||||
caddyfile.push_str(" tls /certs/cert.pem /certs/key.pem\n");
|
||||
|
||||
// Code services run on host network, image services run in Docker network
|
||||
let upstream = if svc.is_code_service {
|
||||
format!("host.docker.internal:{}", svc.internal_port)
|
||||
} else {
|
||||
format!("{}:{}", svc.slug, svc.internal_port)
|
||||
};
|
||||
|
||||
caddyfile.push_str(&format!(" reverse_proxy {}\n", upstream));
|
||||
caddyfile.push_str("}\n\n");
|
||||
}
|
||||
|
||||
caddyfile
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_generate_caddyfile_port_443_mode() {
|
||||
let services = vec![ServicePort {
|
||||
slug: "api".to_string(),
|
||||
internal_port: 3000,
|
||||
external_port: 12345,
|
||||
is_http: true,
|
||||
is_code_service: false,
|
||||
}];
|
||||
let config = HttpsConfig {
|
||||
project_slug: "myproj".to_string(),
|
||||
base_domain: "myproj.railway.localhost".to_string(),
|
||||
cert_path: PathBuf::from("/certs/cert.pem"),
|
||||
key_path: PathBuf::from("/certs/key.pem"),
|
||||
use_port_443: true,
|
||||
};
|
||||
let result = generate_caddyfile(&services, &config);
|
||||
assert!(result.contains("api.myproj.railway.localhost {"));
|
||||
assert!(result.contains("reverse_proxy api:3000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_caddyfile_fallback_mode() {
|
||||
let services = vec![ServicePort {
|
||||
slug: "api".to_string(),
|
||||
internal_port: 3000,
|
||||
external_port: 12345,
|
||||
is_http: true,
|
||||
is_code_service: false,
|
||||
}];
|
||||
let config = HttpsConfig {
|
||||
project_slug: "myproj".to_string(),
|
||||
base_domain: "myproj.railway.localhost".to_string(),
|
||||
cert_path: PathBuf::from("/certs/cert.pem"),
|
||||
key_path: PathBuf::from("/certs/key.pem"),
|
||||
use_port_443: false,
|
||||
};
|
||||
let result = generate_caddyfile(&services, &config);
|
||||
assert!(result.contains("myproj.railway.localhost:12345 {"));
|
||||
assert!(result.contains("reverse_proxy api:3000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_caddyfile_code_service() {
|
||||
let services = vec![ServicePort {
|
||||
slug: "web".to_string(),
|
||||
internal_port: 8080,
|
||||
external_port: 54321,
|
||||
is_http: true,
|
||||
is_code_service: true,
|
||||
}];
|
||||
let config = HttpsConfig {
|
||||
project_slug: "myproj".to_string(),
|
||||
base_domain: "myproj.railway.localhost".to_string(),
|
||||
cert_path: PathBuf::from("/certs/cert.pem"),
|
||||
key_path: PathBuf::from("/certs/key.pem"),
|
||||
use_port_443: false,
|
||||
};
|
||||
let result = generate_caddyfile(&services, &config);
|
||||
assert!(result.contains("reverse_proxy host.docker.internal:8080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_caddyfile_filters_non_http() {
|
||||
let services = vec![
|
||||
ServicePort {
|
||||
slug: "api".to_string(),
|
||||
internal_port: 3000,
|
||||
external_port: 12345,
|
||||
is_http: true,
|
||||
is_code_service: false,
|
||||
},
|
||||
ServicePort {
|
||||
slug: "redis".to_string(),
|
||||
internal_port: 6379,
|
||||
external_port: 54321,
|
||||
is_http: false,
|
||||
is_code_service: false,
|
||||
},
|
||||
];
|
||||
let config = HttpsConfig {
|
||||
project_slug: "myproj".to_string(),
|
||||
base_domain: "myproj.railway.localhost".to_string(),
|
||||
cert_path: PathBuf::from("/certs/cert.pem"),
|
||||
key_path: PathBuf::from("/certs/key.pem"),
|
||||
use_port_443: false,
|
||||
};
|
||||
let result = generate_caddyfile(&services, &config);
|
||||
assert!(result.contains("api"));
|
||||
assert!(!result.contains("redis"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_certs_exist_returns_false_when_missing() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
assert!(!certs_exist(temp.path(), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_certs_exist_returns_true_when_present() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
std::fs::write(temp.path().join("cert.pem"), "cert").unwrap();
|
||||
std::fs::write(temp.path().join("key.pem"), "key").unwrap();
|
||||
assert!(certs_exist(temp.path(), false));
|
||||
}
|
||||
}
|
||||
98
src/controllers/develop/local_config.rs
Normal file
98
src/controllers/develop/local_config.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use std::{collections::HashMap, fs, path::PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ports::get_develop_dir;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct LocalDevConfig {
|
||||
pub version: u32,
|
||||
pub services: HashMap<String, CodeServiceConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CodeServiceConfig {
|
||||
pub command: String,
|
||||
pub directory: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
impl LocalDevConfig {
|
||||
pub fn path(project_id: &str) -> PathBuf {
|
||||
get_develop_dir(project_id).join("local-dev.json")
|
||||
}
|
||||
|
||||
pub fn load(project_id: &str) -> Result<Self> {
|
||||
let path = Self::path(project_id);
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
serde_json::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse {}", path.display()))
|
||||
}
|
||||
|
||||
pub fn save(&self, project_id: &str) -> Result<()> {
|
||||
let path = Self::path(project_id);
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
fs::write(&tmp_path, content)
|
||||
.with_context(|| format!("Failed to write {}", tmp_path.display()))?;
|
||||
fs::rename(&tmp_path, &path).with_context(|| {
|
||||
format!(
|
||||
"Failed to rename {} to {}",
|
||||
tmp_path.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_service(&self, service_id: &str) -> Option<&CodeServiceConfig> {
|
||||
self.services.get(service_id)
|
||||
}
|
||||
|
||||
pub fn set_service(&mut self, service_id: String, config: CodeServiceConfig) {
|
||||
self.services.insert(service_id, config);
|
||||
}
|
||||
|
||||
pub fn remove_service(&mut self, service_id: &str) -> Option<CodeServiceConfig> {
|
||||
self.services.remove(service_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_service_operations() {
|
||||
let mut config = LocalDevConfig::default();
|
||||
assert!(config.get_service("svc-123").is_none());
|
||||
|
||||
config.set_service(
|
||||
"svc-123".to_string(),
|
||||
CodeServiceConfig {
|
||||
command: "npm start".to_string(),
|
||||
directory: "/app".to_string(),
|
||||
port: Some(3000),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(config.get_service("svc-123").is_some());
|
||||
assert_eq!(config.get_service("svc-123").unwrap().command, "npm start");
|
||||
|
||||
config.remove_service("svc-123");
|
||||
assert!(config.get_service("svc-123").is_none());
|
||||
}
|
||||
}
|
||||
17
src/controllers/develop/mod.rs
Normal file
17
src/controllers/develop/mod.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
pub mod code_runner;
|
||||
pub mod compose;
|
||||
pub mod https_proxy;
|
||||
pub mod local_config;
|
||||
pub mod output;
|
||||
pub mod ports;
|
||||
pub mod session;
|
||||
pub mod variables;
|
||||
|
||||
pub use code_runner::*;
|
||||
pub use compose::*;
|
||||
pub use https_proxy::*;
|
||||
pub use local_config::*;
|
||||
pub use output::*;
|
||||
pub use ports::*;
|
||||
pub use session::*;
|
||||
pub use variables::*;
|
||||
9
src/controllers/develop/output.rs
Normal file
9
src/controllers/develop/output.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
use super::compose::PortInfo;
|
||||
|
||||
pub struct ServiceSummary {
|
||||
pub name: String,
|
||||
pub image: String,
|
||||
pub var_count: usize,
|
||||
pub ports: Vec<PortInfo>,
|
||||
pub volumes: Vec<String>,
|
||||
}
|
||||
196
src/controllers/develop/ports.rs
Normal file
196
src/controllers/develop/ports.rs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::controllers::config::EnvironmentConfig;
|
||||
|
||||
/// Port range for deterministic port generation (10000-59999)
|
||||
pub const PORT_RANGE_MIN: u16 = 10000;
|
||||
pub const PORT_RANGE_SIZE: u16 = 50000;
|
||||
|
||||
/// Port range for random port generation during configuration
|
||||
pub const RANDOM_PORT_MIN: u16 = 3000;
|
||||
pub const RANDOM_PORT_MAX: u16 = 9000;
|
||||
|
||||
/// Converts a service name to a slug (lowercase, alphanumeric, dashes)
|
||||
pub fn slugify(name: &str) -> String {
|
||||
let s: String = name
|
||||
.chars()
|
||||
.filter_map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
Some(c.to_ascii_lowercase())
|
||||
} else if c == ' ' || c == '-' || c == '_' {
|
||||
Some('-')
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
s.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
/// Generates a deterministic external port from service_id and internal_port
|
||||
pub fn generate_port(service_id: &str, internal_port: i64) -> u16 {
|
||||
let mut hash: u32 = 5381;
|
||||
for b in service_id.bytes() {
|
||||
hash = hash.wrapping_mul(33).wrapping_add(b as u32);
|
||||
}
|
||||
hash = hash.wrapping_add(internal_port as u32);
|
||||
PORT_RANGE_MIN + (hash % PORT_RANGE_SIZE as u32) as u16
|
||||
}
|
||||
|
||||
/// Generates a random port for user configuration prompts
|
||||
pub fn generate_random_port() -> u16 {
|
||||
use rand::Rng;
|
||||
rand::thread_rng().gen_range(RANDOM_PORT_MIN..RANDOM_PORT_MAX)
|
||||
}
|
||||
|
||||
/// Resolves a path, canonicalizing on Unix but not on Windows (to avoid UNC path prefix)
|
||||
pub fn resolve_path(path: std::path::PathBuf) -> std::path::PathBuf {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
path.canonicalize().unwrap_or(path)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the develop directory for a given project
|
||||
pub fn get_develop_dir(project_id: &str) -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("Unable to get home directory")
|
||||
.join(".railway")
|
||||
.join("develop")
|
||||
.join(project_id)
|
||||
}
|
||||
|
||||
/// Returns the path to the docker-compose.yml for a given project
|
||||
pub fn get_compose_path(project_id: &str) -> PathBuf {
|
||||
get_develop_dir(project_id).join("docker-compose.yml")
|
||||
}
|
||||
|
||||
/// Check if local develop mode is active (compose file exists)
|
||||
pub fn is_local_develop_active(project_id: &str) -> bool {
|
||||
get_compose_path(project_id).exists()
|
||||
}
|
||||
|
||||
/// Reads the HTTPS domain from the https_domain file if it exists
|
||||
pub fn get_https_domain(project_id: &str) -> Option<String> {
|
||||
let domain_file = get_develop_dir(project_id).join("https_domain");
|
||||
std::fs::read_to_string(domain_file).ok()
|
||||
}
|
||||
|
||||
/// Reads the HTTPS mode from the https_mode file
|
||||
pub fn get_https_mode(project_id: &str) -> bool {
|
||||
let mode_file = get_develop_dir(project_id).join("certs").join("https_mode");
|
||||
std::fs::read_to_string(mode_file)
|
||||
.map(|m| m.trim() == "port_443")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build service_id -> private endpoint mapping.
|
||||
/// Uses privateNetworkEndpoint from config when available, falls back to slugified name.
|
||||
pub fn build_service_endpoints(
|
||||
service_names: &HashMap<String, String>,
|
||||
config: &EnvironmentConfig,
|
||||
) -> HashMap<String, String> {
|
||||
service_names
|
||||
.iter()
|
||||
.map(|(id, name)| {
|
||||
let endpoint = config
|
||||
.services
|
||||
.get(id)
|
||||
.and_then(|svc| svc.networking.as_ref())
|
||||
.and_then(|n| n.private_network_endpoint.clone())
|
||||
.unwrap_or_else(|| slugify(name));
|
||||
(id.clone(), endpoint)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_slugify() {
|
||||
assert_eq!(slugify("My Service"), "my-service");
|
||||
assert_eq!(slugify("api-server"), "api-server");
|
||||
assert_eq!(slugify("API_SERVER"), "api-server");
|
||||
assert_eq!(slugify(" Test "), "test");
|
||||
assert_eq!(slugify("hello@world!"), "helloworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_port_deterministic() {
|
||||
let port1 = generate_port("service-123", 3000);
|
||||
let port2 = generate_port("service-123", 3000);
|
||||
assert_eq!(port1, port2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_port_in_range() {
|
||||
let port = generate_port("test-service", 8080);
|
||||
assert!((PORT_RANGE_MIN..(PORT_RANGE_MIN + PORT_RANGE_SIZE)).contains(&port));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_port_different_services() {
|
||||
let port1 = generate_port("service-a", 3000);
|
||||
let port2 = generate_port("service-b", 3000);
|
||||
assert_ne!(port1, port2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_port_different_internal_ports() {
|
||||
let port1 = generate_port("service-a", 3000);
|
||||
let port2 = generate_port("service-a", 8080);
|
||||
assert_ne!(port1, port2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_service_endpoints() {
|
||||
use crate::controllers::config::{EnvironmentConfig, ServiceInstance, ServiceNetworking};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let mut service_names = HashMap::new();
|
||||
service_names.insert("svc-1".to_string(), "My PostgreSQL".to_string());
|
||||
service_names.insert("svc-2".to_string(), "Redis Cache".to_string());
|
||||
service_names.insert("svc-3".to_string(), "api-server".to_string());
|
||||
|
||||
let mut services = BTreeMap::new();
|
||||
// svc-1: has privateNetworkEndpoint set
|
||||
services.insert(
|
||||
"svc-1".to_string(),
|
||||
ServiceInstance {
|
||||
networking: Some(ServiceNetworking {
|
||||
private_network_endpoint: Some("postgres".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
// svc-2: no privateNetworkEndpoint, should fall back to slugified name
|
||||
services.insert("svc-2".to_string(), ServiceInstance::default());
|
||||
// svc-3: has networking but no privateNetworkEndpoint
|
||||
services.insert(
|
||||
"svc-3".to_string(),
|
||||
ServiceInstance {
|
||||
networking: Some(ServiceNetworking::default()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let config = EnvironmentConfig {
|
||||
services,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = build_service_endpoints(&service_names, &config);
|
||||
|
||||
assert_eq!(result.get("svc-1"), Some(&"postgres".to_string()));
|
||||
assert_eq!(result.get("svc-2"), Some(&"redis-cache".to_string()));
|
||||
assert_eq!(result.get("svc-3"), Some(&"api-server".to_string()));
|
||||
}
|
||||
}
|
||||
81
src/controllers/develop/session.rs
Normal file
81
src/controllers/develop/session.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use fs2::FileExt;
|
||||
|
||||
use super::ports::get_develop_dir;
|
||||
|
||||
pub struct DevelopSessionLock {
|
||||
_file: File,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl DevelopSessionLock {
|
||||
/// Try to acquire exclusive lock for code services in this project.
|
||||
/// Returns Ok(lock) if acquired, Err if another session is running.
|
||||
pub fn try_acquire(project_id: &str) -> Result<Self> {
|
||||
let develop_dir = get_develop_dir(project_id);
|
||||
Self::try_acquire_at(&develop_dir)
|
||||
}
|
||||
|
||||
/// Try to acquire lock at a specific directory (for testing)
|
||||
pub fn try_acquire_at(develop_dir: &Path) -> Result<Self> {
|
||||
std::fs::create_dir_all(develop_dir)?;
|
||||
|
||||
let path = develop_dir.join("session.lock");
|
||||
let file = File::create(&path)?;
|
||||
|
||||
match file.try_lock_exclusive() {
|
||||
Ok(()) => Ok(Self { _file: file, path }),
|
||||
Err(e) if e.kind() == fs2::lock_contended_error().kind() => {
|
||||
bail!(
|
||||
"Another develop session is already running for this project.\n\
|
||||
Stop it with Ctrl+C before starting a new one."
|
||||
)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DevelopSessionLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_acquire_lock() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let lock = DevelopSessionLock::try_acquire_at(temp.path());
|
||||
assert!(lock.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_concurrent_lock_fails() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _lock1 = DevelopSessionLock::try_acquire_at(temp.path()).unwrap();
|
||||
let lock2 = DevelopSessionLock::try_acquire_at(temp.path());
|
||||
match lock2 {
|
||||
Ok(_) => panic!("should fail to acquire lock"),
|
||||
Err(e) => assert!(e.to_string().contains("Another develop session")),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lock_released_on_drop() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
{
|
||||
let _lock = DevelopSessionLock::try_acquire_at(temp.path()).unwrap();
|
||||
}
|
||||
// Lock should be released after drop
|
||||
let lock2 = DevelopSessionLock::try_acquire_at(temp.path());
|
||||
assert!(lock2.is_ok());
|
||||
}
|
||||
}
|
||||
374
src/controllers/develop/variables.rs
Normal file
374
src/controllers/develop/variables.rs
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
/// Mode for variable overrides - affects how domains/ports are transformed
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OverrideMode {
|
||||
/// For docker-compose services - use service slugs for inter-container communication
|
||||
DockerNetwork,
|
||||
/// For host commands - use localhost with external ports
|
||||
HostNetwork,
|
||||
}
|
||||
|
||||
/// HTTPS configuration for local development
|
||||
pub struct HttpsOverride<'a> {
|
||||
pub domain: &'a str,
|
||||
pub port: u16,
|
||||
/// Service slug for subdomain-based routing (port 443 mode)
|
||||
pub slug: Option<String>,
|
||||
/// Whether using port 443 mode (prettier URLs without port numbers)
|
||||
pub use_port_443: bool,
|
||||
}
|
||||
|
||||
/// Check if a Railway variable is deprecated and should be filtered out
|
||||
pub fn is_deprecated_railway_var(key: &str) -> bool {
|
||||
if key == "RAILWAY_STATIC_URL" {
|
||||
return true;
|
||||
}
|
||||
// RAILWAY_SERVICE_{name}_URL is deprecated, but RAILWAY_SERVICE_ID and RAILWAY_SERVICE_NAME are not
|
||||
if key.starts_with("RAILWAY_SERVICE_") && key.ends_with("_URL") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Maps production public domain -> local public domain for cross-service references
|
||||
pub type PublicDomainMapping = HashMap<String, String>;
|
||||
|
||||
/// Transform Railway variables for local development
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn override_railway_vars(
|
||||
vars: BTreeMap<String, String>,
|
||||
service_slug: &str,
|
||||
port_mapping: &HashMap<i64, u16>,
|
||||
service_slugs: &HashMap<String, String>,
|
||||
slug_port_mappings: &HashMap<String, HashMap<i64, u16>>,
|
||||
public_domain_mapping: &PublicDomainMapping,
|
||||
mode: OverrideMode,
|
||||
https: Option<HttpsOverride>,
|
||||
) -> BTreeMap<String, String> {
|
||||
vars.into_iter()
|
||||
.filter(|(key, _)| !is_deprecated_railway_var(key))
|
||||
.map(|(key, value)| {
|
||||
let new_value = match key.as_str() {
|
||||
"RAILWAY_PRIVATE_DOMAIN" => match mode {
|
||||
OverrideMode::DockerNetwork => service_slug.to_string(),
|
||||
OverrideMode::HostNetwork => "localhost".to_string(),
|
||||
},
|
||||
"RAILWAY_PUBLIC_DOMAIN" => match &https {
|
||||
Some(h) if h.use_port_443 => {
|
||||
// Port 443 mode: use subdomain (no port in URL)
|
||||
match &h.slug {
|
||||
Some(slug) => format!("{}.{}", slug, h.domain),
|
||||
None => format!("{}.{}", service_slug, h.domain),
|
||||
}
|
||||
}
|
||||
Some(h) => format!("{}:{}", h.domain, h.port),
|
||||
None => "localhost".to_string(),
|
||||
},
|
||||
"RAILWAY_TCP_PROXY_DOMAIN" => "localhost".to_string(),
|
||||
"RAILWAY_TCP_PROXY_PORT" => port_mapping
|
||||
.values()
|
||||
.next()
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or(value),
|
||||
_ => replace_domain_refs(
|
||||
&value,
|
||||
service_slugs,
|
||||
slug_port_mappings,
|
||||
public_domain_mapping,
|
||||
mode,
|
||||
),
|
||||
};
|
||||
(key, new_value)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn replace_domain_refs(
|
||||
value: &str,
|
||||
service_slugs: &HashMap<String, String>,
|
||||
slug_port_mappings: &HashMap<String, HashMap<i64, u16>>,
|
||||
public_domain_mapping: &PublicDomainMapping,
|
||||
mode: OverrideMode,
|
||||
) -> String {
|
||||
let mut result = value.to_string();
|
||||
|
||||
for slug in service_slugs.values() {
|
||||
let port_mapping = slug_port_mappings.get(slug);
|
||||
|
||||
// Replace {slug}.railway.internal:{port} patterns
|
||||
let railway_domain = format!("{}.railway.internal", slug);
|
||||
if result.contains(&railway_domain) {
|
||||
match mode {
|
||||
OverrideMode::DockerNetwork => {
|
||||
// For docker network, just use the slug (containers resolve by name)
|
||||
result = result.replace(&railway_domain, slug);
|
||||
}
|
||||
OverrideMode::HostNetwork => {
|
||||
// For host network, replace with localhost and map ports
|
||||
if let Some(ports) = port_mapping {
|
||||
result = replace_domain_with_port_mapping(&result, &railway_domain, ports);
|
||||
} else {
|
||||
result = result.replace(&railway_domain, "localhost");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For host network mode, also replace bare {slug}:{port} patterns
|
||||
// Only replace exact patterns to avoid replacing protocol schemes like redis://
|
||||
if mode == OverrideMode::HostNetwork {
|
||||
if let Some(ports) = port_mapping {
|
||||
for (internal, external) in ports {
|
||||
let old_pattern = format!("{}:{}", slug, internal);
|
||||
let new_pattern = format!("localhost:{}", external);
|
||||
result = result.replace(&old_pattern, &new_pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace production public domains with local equivalents
|
||||
for (prod_domain, local_domain) in public_domain_mapping {
|
||||
result = result.replace(prod_domain, local_domain);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Replace domain:port patterns with localhost:external_port
|
||||
fn replace_domain_with_port_mapping(
|
||||
value: &str,
|
||||
domain: &str,
|
||||
port_mapping: &HashMap<i64, u16>,
|
||||
) -> String {
|
||||
let mut result = value.to_string();
|
||||
|
||||
for (internal, external) in port_mapping {
|
||||
let old_pattern = format!("{}:{}", domain, internal);
|
||||
let new_pattern = format!("localhost:{}", external);
|
||||
result = result.replace(&old_pattern, &new_pattern);
|
||||
}
|
||||
|
||||
// Replace any remaining bare domain references with localhost
|
||||
result = result.replace(domain, "localhost");
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_deprecated_railway_var() {
|
||||
assert!(is_deprecated_railway_var("RAILWAY_STATIC_URL"));
|
||||
assert!(is_deprecated_railway_var("RAILWAY_SERVICE_API_URL"));
|
||||
assert!(!is_deprecated_railway_var("RAILWAY_SERVICE_ID"));
|
||||
assert!(!is_deprecated_railway_var("RAILWAY_SERVICE_NAME"));
|
||||
assert!(!is_deprecated_railway_var("DATABASE_URL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_override_private_domain_docker() {
|
||||
let mut vars = BTreeMap::new();
|
||||
vars.insert(
|
||||
"RAILWAY_PRIVATE_DOMAIN".to_string(),
|
||||
"old.value".to_string(),
|
||||
);
|
||||
|
||||
let result = override_railway_vars(
|
||||
vars,
|
||||
"my-service",
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
OverrideMode::DockerNetwork,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.get("RAILWAY_PRIVATE_DOMAIN"),
|
||||
Some(&"my-service".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_override_private_domain_host() {
|
||||
let mut vars = BTreeMap::new();
|
||||
vars.insert(
|
||||
"RAILWAY_PRIVATE_DOMAIN".to_string(),
|
||||
"old.value".to_string(),
|
||||
);
|
||||
|
||||
let result = override_railway_vars(
|
||||
vars,
|
||||
"my-service",
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
OverrideMode::HostNetwork,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.get("RAILWAY_PRIVATE_DOMAIN"),
|
||||
Some(&"localhost".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_override_public_domain_with_https_port_443() {
|
||||
let mut vars = BTreeMap::new();
|
||||
vars.insert("RAILWAY_PUBLIC_DOMAIN".to_string(), "old.value".to_string());
|
||||
|
||||
let https = HttpsOverride {
|
||||
domain: "myproject.localhost",
|
||||
port: 443,
|
||||
slug: Some("api".to_string()),
|
||||
use_port_443: true,
|
||||
};
|
||||
|
||||
let result = override_railway_vars(
|
||||
vars,
|
||||
"api",
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
OverrideMode::HostNetwork,
|
||||
Some(https),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.get("RAILWAY_PUBLIC_DOMAIN"),
|
||||
Some(&"api.myproject.localhost".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_deprecated_vars() {
|
||||
let mut vars = BTreeMap::new();
|
||||
vars.insert("RAILWAY_STATIC_URL".to_string(), "value".to_string());
|
||||
vars.insert("RAILWAY_SERVICE_API_URL".to_string(), "value".to_string());
|
||||
vars.insert("DATABASE_URL".to_string(), "postgres://...".to_string());
|
||||
|
||||
let result = override_railway_vars(
|
||||
vars,
|
||||
"service",
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
OverrideMode::HostNetwork,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!result.contains_key("RAILWAY_STATIC_URL"));
|
||||
assert!(!result.contains_key("RAILWAY_SERVICE_API_URL"));
|
||||
assert!(result.contains_key("DATABASE_URL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_cross_service_domains() {
|
||||
let mut vars = BTreeMap::new();
|
||||
// Private domain reference
|
||||
vars.insert(
|
||||
"REDIS_URL".to_string(),
|
||||
"redis://redis.railway.internal:6379".to_string(),
|
||||
);
|
||||
// Public domain references (railway + custom)
|
||||
vars.insert(
|
||||
"API_URL".to_string(),
|
||||
"https://api-prod.up.railway.app/v1".to_string(),
|
||||
);
|
||||
vars.insert(
|
||||
"CUSTOM_URL".to_string(),
|
||||
"https://api.mycompany.io/graphql".to_string(),
|
||||
);
|
||||
// Multiple domains in one var
|
||||
vars.insert(
|
||||
"COMBINED".to_string(),
|
||||
"api=https://api-prod.up.railway.app,custom=https://api.mycompany.io".to_string(),
|
||||
);
|
||||
|
||||
let mut service_slugs = HashMap::new();
|
||||
service_slugs.insert("svc-redis".to_string(), "redis".to_string());
|
||||
|
||||
let mut slug_port_mappings = HashMap::new();
|
||||
let mut redis_ports = HashMap::new();
|
||||
redis_ports.insert(6379i64, 16379u16);
|
||||
slug_port_mappings.insert("redis".to_string(), redis_ports);
|
||||
|
||||
let mut public_domain_mapping = HashMap::new();
|
||||
public_domain_mapping.insert(
|
||||
"api-prod.up.railway.app".to_string(),
|
||||
"api.local.railway.localhost".to_string(),
|
||||
);
|
||||
public_domain_mapping.insert(
|
||||
"api.mycompany.io".to_string(),
|
||||
"custom.local.railway.localhost".to_string(),
|
||||
);
|
||||
|
||||
let result = override_railway_vars(
|
||||
vars,
|
||||
"my-service",
|
||||
&HashMap::new(),
|
||||
&service_slugs,
|
||||
&slug_port_mappings,
|
||||
&public_domain_mapping,
|
||||
OverrideMode::HostNetwork,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.get("REDIS_URL"),
|
||||
Some(&"redis://localhost:16379".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
result.get("API_URL"),
|
||||
Some(&"https://api.local.railway.localhost/v1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
result.get("CUSTOM_URL"),
|
||||
Some(&"https://custom.local.railway.localhost/graphql".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
result.get("COMBINED"),
|
||||
Some(
|
||||
&"api=https://api.local.railway.localhost,custom=https://custom.local.railway.localhost"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_private_domain_docker_mode() {
|
||||
let mut vars = BTreeMap::new();
|
||||
vars.insert(
|
||||
"REDIS_URL".to_string(),
|
||||
"redis://redis.railway.internal:6379".to_string(),
|
||||
);
|
||||
|
||||
let mut service_slugs = HashMap::new();
|
||||
service_slugs.insert("svc-redis".to_string(), "redis".to_string());
|
||||
|
||||
let result = override_railway_vars(
|
||||
vars,
|
||||
"my-service",
|
||||
&HashMap::new(),
|
||||
&service_slugs,
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
OverrideMode::DockerNetwork,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.get("REDIS_URL"),
|
||||
Some(&"redis://redis:6379".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
333
src/controllers/local_override.rs
Normal file
333
src/controllers/local_override.rs
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::{
|
||||
config::Configs,
|
||||
controllers::{
|
||||
config::{ServiceInstance, fetch_environment_config},
|
||||
develop::{
|
||||
HttpsOverride, LocalDevConfig, OverrideMode, PublicDomainMapping,
|
||||
build_service_endpoints, generate_port, get_https_domain, get_https_mode,
|
||||
override_railway_vars,
|
||||
},
|
||||
},
|
||||
gql::queries::project::ProjectProject,
|
||||
};
|
||||
|
||||
pub use crate::controllers::develop::ports::is_local_develop_active;
|
||||
|
||||
/// Context for applying local variable overrides
|
||||
pub struct LocalOverrideContext {
|
||||
/// service_id -> service slug
|
||||
pub service_slugs: HashMap<String, String>,
|
||||
/// service_id -> (internal_port -> external_port)
|
||||
pub port_mappings: HashMap<String, HashMap<i64, u16>>,
|
||||
/// slug -> (internal_port -> external_port) for value substitution
|
||||
pub slug_port_mappings: HashMap<String, HashMap<i64, u16>>,
|
||||
/// HTTPS domain for pretty URLs (e.g., "myproject.railway.localhost")
|
||||
pub https_domain: Option<String>,
|
||||
/// Whether using port 443 mode (prettier URLs without port numbers)
|
||||
pub use_port_443: bool,
|
||||
}
|
||||
|
||||
/// Build context from environment config (fetches from API)
|
||||
pub async fn build_local_override_context(
|
||||
client: &reqwest::Client,
|
||||
configs: &Configs,
|
||||
project: &ProjectProject,
|
||||
environment_id: &str,
|
||||
) -> Result<LocalOverrideContext> {
|
||||
build_local_override_context_with_config(client, configs, project, environment_id, None).await
|
||||
}
|
||||
|
||||
/// Build context from environment config with optional LocalDevConfig for code services
|
||||
pub async fn build_local_override_context_with_config(
|
||||
client: &reqwest::Client,
|
||||
configs: &Configs,
|
||||
project: &ProjectProject,
|
||||
environment_id: &str,
|
||||
local_dev_config: Option<&LocalDevConfig>,
|
||||
) -> Result<LocalOverrideContext> {
|
||||
let env_response = fetch_environment_config(client, configs, environment_id, false).await?;
|
||||
let config = env_response.config;
|
||||
|
||||
let service_names: HashMap<String, String> = project
|
||||
.services
|
||||
.edges
|
||||
.iter()
|
||||
.map(|e| (e.node.id.clone(), e.node.name.clone()))
|
||||
.collect();
|
||||
|
||||
let service_slugs = build_service_endpoints(&service_names, &config);
|
||||
|
||||
let mut port_mappings = HashMap::new();
|
||||
let mut slug_port_mappings = HashMap::new();
|
||||
|
||||
for (service_id, svc) in config.services.iter() {
|
||||
if svc.is_image_based() {
|
||||
let mapping = build_port_mapping(service_id, svc);
|
||||
if let Some(slug) = service_slugs.get(service_id) {
|
||||
slug_port_mappings.insert(slug.clone(), mapping.clone());
|
||||
}
|
||||
port_mappings.insert(service_id.clone(), mapping);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(dev_config) = local_dev_config {
|
||||
for (service_id, svc) in config.services.iter() {
|
||||
if svc.is_code_based() {
|
||||
if let Some(code_config) = dev_config.services.get(service_id) {
|
||||
let port = code_config
|
||||
.port
|
||||
.map(|p| p as i64)
|
||||
.or_else(|| svc.get_ports().first().copied())
|
||||
.unwrap_or(3000);
|
||||
|
||||
let external_port = code_config
|
||||
.port
|
||||
.unwrap_or_else(|| generate_port(service_id, port));
|
||||
|
||||
let mut mapping = HashMap::new();
|
||||
// For code services, map all internal ports to the configured external port
|
||||
for internal in svc.get_ports() {
|
||||
mapping.insert(internal, external_port);
|
||||
}
|
||||
// Also include the configured port itself
|
||||
mapping.insert(port, external_port);
|
||||
|
||||
if let Some(slug) = service_slugs.get(service_id) {
|
||||
slug_port_mappings.insert(slug.clone(), mapping.clone());
|
||||
}
|
||||
port_mappings.insert(service_id.clone(), mapping);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let https_domain = get_https_domain(environment_id);
|
||||
let use_port_443 = get_https_mode(environment_id);
|
||||
|
||||
Ok(LocalOverrideContext {
|
||||
service_slugs,
|
||||
port_mappings,
|
||||
slug_port_mappings,
|
||||
https_domain,
|
||||
use_port_443,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_port_mapping(service_id: &str, svc: &ServiceInstance) -> HashMap<i64, u16> {
|
||||
let mut mapping = HashMap::new();
|
||||
if let Some(networking) = &svc.networking {
|
||||
for config in networking.service_domains.values().flatten() {
|
||||
if let Some(port) = config.port {
|
||||
mapping
|
||||
.entry(port)
|
||||
.or_insert_with(|| generate_port(service_id, port));
|
||||
}
|
||||
}
|
||||
for port_str in networking.tcp_proxies.keys() {
|
||||
if let Ok(port) = port_str.parse::<i64>() {
|
||||
mapping
|
||||
.entry(port)
|
||||
.or_insert_with(|| generate_port(service_id, port));
|
||||
}
|
||||
}
|
||||
}
|
||||
mapping
|
||||
}
|
||||
|
||||
/// Apply local overrides to variables for the run command (host network mode)
|
||||
pub fn apply_local_overrides(
|
||||
vars: BTreeMap<String, String>,
|
||||
service_id: &str,
|
||||
ctx: &LocalOverrideContext,
|
||||
) -> BTreeMap<String, String> {
|
||||
let service_slug = ctx
|
||||
.service_slugs
|
||||
.get(service_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let port_mapping = ctx
|
||||
.port_mappings
|
||||
.get(service_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Get HTTPS override for this service
|
||||
let https = ctx.https_domain.as_ref().map(|domain| {
|
||||
let port = port_mapping
|
||||
.values()
|
||||
.next()
|
||||
.copied()
|
||||
.unwrap_or_else(|| generate_port(service_id, 3000));
|
||||
HttpsOverride {
|
||||
domain,
|
||||
port,
|
||||
slug: Some(service_slug.clone()),
|
||||
use_port_443: ctx.use_port_443,
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: For full cross-service public domain replacement in `run` command,
|
||||
// we'd need to fetch all service variables upfront and build the mapping.
|
||||
// For now, use empty mapping - cross-service refs won't be replaced.
|
||||
let public_domain_mapping: PublicDomainMapping = HashMap::new();
|
||||
|
||||
override_railway_vars(
|
||||
vars,
|
||||
&service_slug,
|
||||
&port_mapping,
|
||||
&ctx.service_slugs,
|
||||
&ctx.slug_port_mappings,
|
||||
&public_domain_mapping,
|
||||
OverrideMode::HostNetwork,
|
||||
https,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_context(
|
||||
https_domain: Option<&str>,
|
||||
use_port_443: bool,
|
||||
service_slugs: HashMap<String, String>,
|
||||
port_mappings: HashMap<String, HashMap<i64, u16>>,
|
||||
) -> LocalOverrideContext {
|
||||
LocalOverrideContext {
|
||||
service_slugs,
|
||||
port_mappings,
|
||||
slug_port_mappings: HashMap::new(),
|
||||
https_domain: https_domain.map(String::from),
|
||||
use_port_443,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_local_overrides_public_domain_with_port_mapping() {
|
||||
let mut vars = BTreeMap::new();
|
||||
vars.insert(
|
||||
"RAILWAY_PUBLIC_DOMAIN".to_string(),
|
||||
"old.railway.app".to_string(),
|
||||
);
|
||||
|
||||
let mut service_slugs = HashMap::new();
|
||||
service_slugs.insert("svc-1".to_string(), "api".to_string());
|
||||
|
||||
let mut port_mappings = HashMap::new();
|
||||
let mut mapping = HashMap::new();
|
||||
mapping.insert(3000, 12345u16);
|
||||
port_mappings.insert("svc-1".to_string(), mapping);
|
||||
|
||||
let ctx = make_context(
|
||||
Some("myproject.localhost"),
|
||||
true,
|
||||
service_slugs,
|
||||
port_mappings,
|
||||
);
|
||||
|
||||
let result = apply_local_overrides(vars, "svc-1", &ctx);
|
||||
assert_eq!(
|
||||
result.get("RAILWAY_PUBLIC_DOMAIN"),
|
||||
Some(&"api.myproject.localhost".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_local_overrides_public_domain_without_port_mapping() {
|
||||
let mut vars = BTreeMap::new();
|
||||
vars.insert(
|
||||
"RAILWAY_PUBLIC_DOMAIN".to_string(),
|
||||
"old.railway.app".to_string(),
|
||||
);
|
||||
|
||||
let mut service_slugs = HashMap::new();
|
||||
service_slugs.insert("svc-1".to_string(), "api".to_string());
|
||||
|
||||
let ctx = make_context(
|
||||
Some("myproject.localhost"),
|
||||
true,
|
||||
service_slugs,
|
||||
HashMap::new(),
|
||||
);
|
||||
|
||||
let result = apply_local_overrides(vars, "svc-1", &ctx);
|
||||
assert_eq!(
|
||||
result.get("RAILWAY_PUBLIC_DOMAIN"),
|
||||
Some(&"api.myproject.localhost".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_local_overrides_public_domain_port_mode() {
|
||||
let mut vars = BTreeMap::new();
|
||||
vars.insert(
|
||||
"RAILWAY_PUBLIC_DOMAIN".to_string(),
|
||||
"old.railway.app".to_string(),
|
||||
);
|
||||
|
||||
let mut service_slugs = HashMap::new();
|
||||
service_slugs.insert("svc-1".to_string(), "api".to_string());
|
||||
|
||||
let mut port_mappings = HashMap::new();
|
||||
let mut mapping = HashMap::new();
|
||||
mapping.insert(3000, 12345u16);
|
||||
port_mappings.insert("svc-1".to_string(), mapping);
|
||||
|
||||
let ctx = make_context(
|
||||
Some("myproject.localhost"),
|
||||
false,
|
||||
service_slugs,
|
||||
port_mappings,
|
||||
);
|
||||
|
||||
let result = apply_local_overrides(vars, "svc-1", &ctx);
|
||||
assert_eq!(
|
||||
result.get("RAILWAY_PUBLIC_DOMAIN"),
|
||||
Some(&"myproject.localhost:12345".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_local_overrides_no_https_domain() {
|
||||
let mut vars = BTreeMap::new();
|
||||
vars.insert(
|
||||
"RAILWAY_PUBLIC_DOMAIN".to_string(),
|
||||
"old.railway.app".to_string(),
|
||||
);
|
||||
|
||||
let mut service_slugs = HashMap::new();
|
||||
service_slugs.insert("svc-1".to_string(), "api".to_string());
|
||||
|
||||
let ctx = make_context(None, false, service_slugs, HashMap::new());
|
||||
|
||||
let result = apply_local_overrides(vars, "svc-1", &ctx);
|
||||
assert_eq!(
|
||||
result.get("RAILWAY_PUBLIC_DOMAIN"),
|
||||
Some(&"localhost".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_local_overrides_private_domain() {
|
||||
let mut vars = BTreeMap::new();
|
||||
vars.insert(
|
||||
"RAILWAY_PRIVATE_DOMAIN".to_string(),
|
||||
"old.railway.internal".to_string(),
|
||||
);
|
||||
|
||||
let mut service_slugs = HashMap::new();
|
||||
service_slugs.insert("svc-1".to_string(), "api".to_string());
|
||||
|
||||
let ctx = make_context(None, false, service_slugs, HashMap::new());
|
||||
|
||||
let result = apply_local_overrides(vars, "svc-1", &ctx);
|
||||
assert_eq!(
|
||||
result.get("RAILWAY_PRIVATE_DOMAIN"),
|
||||
Some(&"localhost".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
pub mod config;
|
||||
pub mod database;
|
||||
pub mod deployment;
|
||||
pub mod develop;
|
||||
pub mod environment;
|
||||
pub mod local_override;
|
||||
pub mod project;
|
||||
pub mod service;
|
||||
pub mod terminal;
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ pub struct Domains;
|
|||
pub struct ProjectToken;
|
||||
|
||||
pub type SerializedTemplateConfig = serde_json::Value;
|
||||
pub type EnvironmentConfig = serde_json::Value;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
|
|
@ -156,6 +157,14 @@ pub struct LatestFunctionVersion;
|
|||
)]
|
||||
pub struct EnvironmentStagedChanges;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "src/gql/schema.json",
|
||||
query_path = "src/gql/queries/strings/EnvironmentConfig.graphql",
|
||||
response_derives = "Debug, Serialize, Clone"
|
||||
)]
|
||||
pub struct GetEnvironmentConfig;
|
||||
|
||||
type SubscriptionDeploymentStatus = super::subscriptions::deployment::DeploymentStatus;
|
||||
impl From<project::DeploymentStatus> for SubscriptionDeploymentStatus {
|
||||
fn from(value: project::DeploymentStatus) -> Self {
|
||||
|
|
|
|||
7
src/gql/queries/strings/EnvironmentConfig.graphql
Normal file
7
src/gql/queries/strings/EnvironmentConfig.graphql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
query GetEnvironmentConfig($id: String!, $decryptVariables: Boolean) {
|
||||
environment(id: $id) {
|
||||
id
|
||||
name
|
||||
config(decryptVariables: $decryptVariables)
|
||||
}
|
||||
}
|
||||
1717
src/gql/schema.json
1717
src/gql/schema.json
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,7 @@ commands!(
|
|||
connect,
|
||||
deploy,
|
||||
deployment,
|
||||
dev(develop),
|
||||
domain,
|
||||
docs,
|
||||
down,
|
||||
|
|
|
|||
Loading…
Reference in a new issue