use std::collections::{BTreeMap, BTreeSet};
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Result, anyhow, bail};
use clap::Parser;
use is_terminal::IsTerminal;
use crate::client::{GQLClient, post_graphql};
use crate::commands::ssh::{
DurableResume, PortForward, ensure_ssh_key, run_native_ssh, run_native_ssh_forward, tel,
};
use crate::config::{Configs, StoredSandbox, StoredSandboxTemplate};
use crate::controllers::environment::get_matched_environment;
use crate::controllers::project::get_project;
use crate::controllers::sandbox_exec::{self, ExecOutcome};
use crate::controllers::variables::Variable;
use crate::gql::{mutations, queries};
use crate::util::progress::{create_shimmer_spinner, fail_spinner};
use crate::util::prompt::{
prompt_confirm_with_default_with_cancel, prompt_options, prompt_options_skippable,
};
use crate::util::shell::shell_join;
#[derive(Parser)]
#[clap(after_help = r#"Examples:
railway sandbox create # create and select the active sandbox
railway sandbox ssh # connect to the active sandbox
railway sandbox exec -- ls -la # run a command
railway sandbox fork # fork and select the new sandbox
railway sandbox destroy # delete the active sandbox
Commands use the active sandbox unless you specify an ID.
Use railway sandbox <command> --help for options and examples.
Requires Sandboxes access."#)]
pub struct Args {
#[clap(subcommand)]
command: Commands,
#[clap(long, short, global = true)]
environment: Option<String>,
#[clap(long, short, global = true)]
project: Option<String>,
}
#[derive(Parser)]
enum Commands {
#[clap(visible_alias = "new")]
Create(CreateArgs),
Fork(ForkArgs),
Template(TemplateArgs),
Checkpoint(CheckpointArgs),
#[clap(visible_alias = "ls")]
List(ListArgs),
#[clap(visible_alias = "connect")]
Ssh(SshArgs),
Exec(ExecArgs),
#[clap(visible_alias = "port-forward", visible_alias = "fwd")]
Forward(ForwardArgs),
#[clap(visible_alias = "rm", visible_alias = "delete")]
Destroy(DestroyArgs),
}
#[derive(Parser)]
#[clap(after_help = r#"Examples:
railway sandbox create --private-network --domain 3000
railway sandbox create --template dev
railway sandbox create --checkpoint my-setup
railway sandbox create --env-file .env --variable MODE=dev
--domain publishes an HTTP port and requires --private-network.
Use --domain web:3000 for a hostname prefix; repeat for multiple ports.
Build templates with railway sandbox template build; capture checkpoints with
railway sandbox checkpoint create."#)]
struct CreateArgs {
#[clap(long)]
idle_timeout_minutes: Option<i64>,
#[clap(long = "variable", value_name = "KEY=VALUE[,KEY=VALUE...]")]
variables: Vec<String>,
#[clap(long = "env-file", value_name = "PATH")]
env_files: Vec<std::path::PathBuf>,
#[clap(long, value_name = "NAME_OR_ID")]
template: Option<String>,
#[clap(long, value_name = "NAME", conflicts_with = "template")]
checkpoint: Option<String>,
#[clap(long)]
private_network: bool,
#[clap(
long = "domain",
value_name = "[PREFIX:]PORT",
requires = "private_network"
)]
domains: Vec<PublicDomainSpec>,
#[clap(long)]
json: bool,
}
#[derive(Parser)]
struct TemplateArgs {
#[clap(subcommand)]
command: TemplateCommands,
}
#[derive(Parser)]
enum TemplateCommands {
#[clap(visible_alias = "create", visible_alias = "new")]
Build(TemplateBuildArgs),
Status(TemplateStatusArgs),
#[clap(visible_alias = "ls")]
List(TemplateListArgs),
}
#[derive(Parser)]
#[clap(after_help = r#"Examples:
railway sandbox template build --name dev -c 'npm i -g pnpm' --wait
railway sandbox create --template dev"#)]
struct TemplateBuildArgs {
#[clap(
short = 'c',
long = "command",
value_name = "SHELL_COMMAND",
required = true
)]
commands: Vec<String>,
#[clap(long)]
name: Option<String>,
#[clap(long, value_name = "DIGEST")]
base_image_digest: Option<String>,
#[clap(long)]
wait: bool,
#[clap(long)]
json: bool,
}
#[derive(Parser)]
struct CheckpointArgs {
#[clap(subcommand)]
command: CheckpointCommands,
}
#[derive(Parser)]
enum CheckpointCommands {
#[clap(visible_alias = "capture", visible_alias = "save")]
Create(CheckpointCreateArgs),
#[clap(visible_alias = "ls")]
List(CheckpointListArgs),
Rename(CheckpointRenameArgs),
#[clap(visible_alias = "rm")]
Delete(CheckpointDeleteArgs),
}
#[derive(Parser)]
#[clap(after_help = r#"Examples:
railway sandbox checkpoint create my-setup
railway sandbox create --checkpoint my-setup
Reusing a name replaces the previous checkpoint."#)]
struct CheckpointCreateArgs {
#[clap(value_name = "NAME")]
name: String,
#[clap(long = "id", value_name = "ID")]
id: Option<String>,
#[clap(long)]
json: bool,
}
#[derive(Parser)]
struct CheckpointListArgs {
#[clap(long)]
json: bool,
}
#[derive(Parser)]
struct CheckpointRenameArgs {
#[clap(value_name = "NAME")]
name: String,
#[clap(value_name = "NEW_NAME")]
new_name: String,
#[clap(long)]
json: bool,
}
#[derive(Parser)]
struct CheckpointDeleteArgs {
#[clap(value_name = "NAME")]
name: String,
}
#[derive(Parser)]
struct TemplateStatusArgs {
#[clap(value_name = "ID_OR_NAME")]
template: String,
#[clap(long)]
json: bool,
}
#[derive(Parser)]
struct TemplateListArgs {
#[clap(long)]
json: bool,
}
#[derive(Parser)]
#[clap(after_help = r#"Examples:
railway sandbox fork
railway sandbox fork <id> --private-network --domain web:3000
railway sandbox fork <id> --env-file .env
The fork becomes active. Variables, private-network mode, and public domains
are not inherited; supply them again as needed."#)]
struct ForkArgs {
#[clap(value_name = "ID")]
id_positional: Option<String>,
#[clap(long = "id", value_name = "ID")]
id: Option<String>,
#[clap(long)]
idle_timeout_minutes: Option<i64>,
#[clap(long = "variable", value_name = "KEY=VALUE[,KEY=VALUE...]")]
variables: Vec<String>,
#[clap(long = "env-file", value_name = "PATH")]
env_files: Vec<std::path::PathBuf>,
#[clap(long)]
private_network: bool,
#[clap(
long = "domain",
value_name = "[PREFIX:]PORT",
requires = "private_network"
)]
domains: Vec<PublicDomainSpec>,
#[clap(long)]
json: bool,
}
impl ForkArgs {
fn explicit_id(&self) -> Option<String> {
self.id.clone().or_else(|| self.id_positional.clone())
}
}
#[derive(Parser)]
struct ListArgs {
#[clap(long)]
json: bool,
#[clap(long)]
all: bool,
}
#[derive(Parser)]
struct SshArgs {
#[clap(long = "id", value_name = "ID")]
id: Option<String>,
#[clap(short = 'i', long = "identity-file", value_name = "PATH")]
identity_file: Option<std::path::PathBuf>,
#[clap(long, value_name = "NAME")]
session: Option<String>,
#[clap(long, requires = "session")]
resume_from_last_read: bool,
#[clap(trailing_var_arg = true)]
command: Vec<String>,
}
#[derive(Parser)]
#[clap(after_help = r#"Examples:
railway sandbox exec -- ls -la
railway sandbox exec --detach -- npm run build
railway sandbox exec --session <name>"#)]
struct ExecArgs {
#[clap(long = "id", value_name = "ID")]
id: Option<String>,
#[clap(long)]
timeout: Option<i64>,
#[clap(long, value_name = "NAME", conflicts_with = "detach")]
session: Option<String>,
#[clap(long, requires = "session")]
resume_from_last_read: bool,
#[clap(long)]
detach: bool,
#[clap(trailing_var_arg = true)]
command: Vec<String>,
}
#[derive(Parser)]
#[clap(after_help = r#"Examples:
railway sandbox forward 3000 # localhost:3000 to sandbox port 3000
railway sandbox forward 8080:3000 # localhost:8080 to sandbox port 3000
railway sandbox forward 3000 5432 # forward several ports"#)]
struct ForwardArgs {
#[clap(value_name = "[LOCAL:]REMOTE", required = true)]
ports: Vec<String>,
#[clap(long = "id", value_name = "ID")]
id: Option<String>,
#[clap(short = 'i', long = "identity-file", value_name = "PATH")]
identity_file: Option<std::path::PathBuf>,
#[clap(long)]
strict: bool,
}
#[derive(Parser)]
struct DestroyArgs {
#[clap(value_name = "ID")]
id_positional: Option<String>,
#[clap(long = "id", value_name = "ID")]
id: Option<String>,
}
impl DestroyArgs {
fn explicit_id(&self) -> Option<String> {
self.id.clone().or_else(|| self.id_positional.clone())
}
}
pub async fn command(args: Args) -> Result<()> {
use colored::Colorize;
eprintln!(
"{}",
"Warning: Railway sandboxes are experimental and APIs may change or break during testing."
.yellow()
);
let mut configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let project = args.project;
let environment = args.environment;
match args.command {
Commands::Create(sub) => create(&mut configs, &client, project, environment, sub).await,
Commands::Fork(sub) => fork(&mut configs, &client, project, environment, sub).await,
Commands::Template(sub) => template(&mut configs, &client, project, environment, sub).await,
Commands::Checkpoint(sub) => {
checkpoint(&mut configs, &client, project, environment, sub).await
}
Commands::List(sub) => list(&mut configs, &client, project, environment, sub).await,
Commands::Ssh(sub) => ssh(&mut configs, &client, project, environment, sub).await,
Commands::Exec(sub) => exec(&mut configs, &client, project, environment, sub).await,
Commands::Forward(sub) => forward(&mut configs, &client, project, environment, sub).await,
Commands::Destroy(sub) => destroy(&mut configs, &client, project, environment, sub).await,
}
}
struct Choice {
id: String,
name: String,
}
impl std::fmt::Display for Choice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name)
}
}
pub(crate) async fn resolve_project_and_env(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
) -> Result<(String, String)> {
let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
let linked = if project.is_none() || environment.is_none() {
configs.get_linked_project().await.ok()
} else {
None
};
let project_id = match project.or_else(|| linked.as_ref().map(|l| l.project.clone())) {
Some(id) => id,
None if interactive => return prompt_workspace_project_env(client, configs).await,
None => {
bail!("No project selected. Pass --project and --environment, or run `railway link`.")
}
};
let project_obj = get_project(client, configs, project_id).await?;
let environment_id = if let Some(env) = environment {
get_matched_environment(&project_obj, env)?.id
} else if let Some(env_id) = linked
.as_ref()
.filter(|l| l.project == project_obj.id)
.and_then(|l| l.environment.clone())
{
get_matched_environment(&project_obj, env_id)?.id
} else if interactive {
prompt_environment(&project_obj)?
} else {
bail!("No environment selected. Pass --environment, or run `railway link`.");
};
Ok((project_obj.id, environment_id))
}
pub(crate) async fn prompt_workspace_project_env(
client: &reqwest::Client,
configs: &mut Configs,
) -> Result<(String, String)> {
let workspaces = crate::workspace::workspaces_with_client(client, configs).await?;
if workspaces.is_empty() {
bail!("No workspaces found. Create a project at https://railway.com/new");
}
loop {
let ws_choices: Vec<Choice> = workspaces
.iter()
.map(|w| Choice {
id: w.id().to_string(),
name: w.name().to_string(),
})
.collect();
let ws_id = match prompt_options_skippable("Select a workspace", ws_choices)? {
Some(choice) => choice.id,
None => bail!("Cancelled."),
};
let workspace = workspaces
.iter()
.find(|w| w.id() == ws_id)
.expect("selected workspace exists");
let projects = workspace.projects();
if projects.is_empty() {
eprintln!("That workspace has no projects.");
continue; }
'project: loop {
let proj_choices: Vec<Choice> = projects
.iter()
.map(|p| Choice {
id: p.id().to_string(),
name: p.name().to_string(),
})
.collect();
let project_id = match prompt_options_skippable("Select a project", proj_choices)? {
Some(choice) => choice.id,
None => break 'project,
};
let project_obj = get_project(client, configs, project_id).await?;
let env_choices: Vec<Choice> = project_obj
.environments
.edges
.iter()
.filter(|e| e.node.can_access)
.map(|e| Choice {
id: e.node.id.clone(),
name: e.node.name.clone(),
})
.collect();
if env_choices.is_empty() {
eprintln!("That project has no accessible environments.");
continue 'project;
}
match prompt_options_skippable("Select an environment", env_choices)? {
Some(choice) => {
offer_to_link(configs, &project_obj.id, &project_obj.name, &choice)?;
return Ok((project_obj.id, choice.id));
}
None => continue 'project,
}
}
}
}
fn offer_to_link(
configs: &mut Configs,
project_id: &str,
project_name: &str,
environment: &Choice,
) -> Result<()> {
let confirmed = prompt_confirm_with_default_with_cancel(
&format!(
"Link this directory to {project_name} ({})?",
environment.name
),
true,
)?
.unwrap_or(false);
if !confirmed {
return Ok(());
}
configs.link_project(
project_id.to_string(),
Some(project_name.to_string()),
environment.id.clone(),
Some(environment.name.clone()),
)?;
configs.write()?;
eprintln!(
"Linked to {project_name} ({}). Run `railway unlink` to undo.",
environment.name
);
Ok(())
}
fn prompt_environment(project: &queries::RailwayProject) -> Result<String> {
let choices: Vec<Choice> = project
.environments
.edges
.iter()
.filter(|e| e.node.can_access)
.map(|e| Choice {
id: e.node.id.clone(),
name: e.node.name.clone(),
})
.collect();
if choices.is_empty() {
bail!("No accessible environments in this project.");
}
Ok(prompt_options("Select an environment", choices)?.id)
}
async fn resolve_target(
configs: &mut Configs,
client: &reqwest::Client,
explicit_id: Option<String>,
project: Option<String>,
environment: Option<String>,
) -> Result<(String, String)> {
match explicit_id {
Some(id) => {
let environment_id = if project.is_some() || environment.is_some() {
resolve_project_and_env(configs, client, project, environment)
.await?
.1
} else if let Some(stored) = configs.get_sandbox(&id) {
stored.environment_id
} else {
resolve_project_and_env(configs, client, None, None)
.await?
.1
};
Ok((id, environment_id))
}
None => {
let stored = configs.get_active_sandbox().ok_or_else(|| {
anyhow!(
"No active sandbox. Create one with `railway sandbox create`, or pass --id <id>."
)
})?;
Ok((stored.id, stored.environment_id))
}
}
}
fn parse_variable_args(args: &[String]) -> Result<Vec<Variable>> {
let mut vars = Vec::new();
for arg in args {
let segments: Vec<&str> = arg.split(',').collect();
if segments.len() > 1 && segments.iter().all(|s| s.contains('=')) {
for segment in segments {
vars.push(Variable::from_str(segment)?);
}
} else {
vars.push(Variable::from_str(arg)?);
}
}
Ok(vars)
}
fn auto_wrap_reference(value: &str) -> String {
if value.contains("${{") {
return value.to_string();
}
let Some((name, var)) = value.split_once('.') else {
return value.to_string();
};
let name_ok = name.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
let var_ok = if name == "shared" {
var.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& var.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
} else {
var.chars().next().is_some_and(|c| c.is_ascii_uppercase())
&& var
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
};
if name_ok && var_ok {
format!("${{{{{name}.{var}}}}}")
} else {
value.to_string()
}
}
fn parse_env_file(path: &std::path::Path) -> Result<Vec<Variable>> {
let contents = std::fs::read_to_string(path)
.map_err(|e| anyhow!("Failed to read env file {}: {e}", path.display()))?;
let mut vars = Vec::new();
for (i, raw_line) in contents.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line).trim_start();
let Some((key, value)) = line.split_once('=') else {
bail!(
"{}:{}: expected KEY=VALUE, got `{raw_line}`",
path.display(),
i + 1
);
};
let key = key.trim();
if key.is_empty() {
bail!("{}:{}: empty variable name", path.display(), i + 1);
}
let value = value.trim();
let value = if (value.starts_with('"') && value.ends_with('"') && value.len() >= 2)
|| (value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2)
{
&value[1..value.len() - 1]
} else {
value.split(" #").next().unwrap_or(value).trim_end()
};
vars.push(Variable {
key: key.to_string(),
value: value.to_string(),
});
}
Ok(vars)
}
pub(crate) fn variables_to_input(
env_files: &[std::path::PathBuf],
args: &[String],
) -> Result<Option<BTreeMap<String, String>>> {
let mut vars = Vec::new();
for path in env_files {
vars.extend(parse_env_file(path)?);
}
vars.extend(parse_variable_args(args)?);
if vars.is_empty() {
return Ok(None);
}
Ok(Some(
vars.into_iter()
.map(|v| (v.key, auto_wrap_reference(&v.value)))
.collect(),
))
}
#[derive(Clone, Debug)]
struct PublicDomainSpec {
prefix: Option<String>,
port: u16,
}
impl FromStr for PublicDomainSpec {
type Err = anyhow::Error;
fn from_str(spec: &str) -> Result<Self> {
let (prefix, port) = match spec.split_once(':') {
Some((prefix, port)) => {
if prefix.is_empty()
|| prefix.len() > 46
|| !prefix
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|| prefix.starts_with('-')
|| prefix.ends_with('-')
{
bail!(
"domain prefix must be 1-46 lowercase letters, digits, or hyphens, with no leading or trailing hyphen"
);
}
(Some(prefix.to_owned()), port)
}
None => (None, spec),
};
let port = port
.parse::<u16>()
.ok()
.filter(|port| *port > 0)
.ok_or_else(|| {
anyhow!("domain port must be between 1 and 65535 (use [PREFIX:]PORT)")
})?;
Ok(Self { prefix, port })
}
}
fn public_domains_input(
domains: &[PublicDomainSpec],
) -> Result<Option<Vec<mutations::sandbox_create::SandboxDomainInput>>> {
if domains.is_empty() {
return Ok(None);
}
if domains.len() > 10 {
bail!("a sandbox supports at most 10 public domains");
}
let mut ports = BTreeSet::new();
let mut prefixes = BTreeSet::new();
for domain in domains {
if !ports.insert(domain.port) {
bail!("public domain ports must be unique: {}", domain.port);
}
if let Some(prefix) = &domain.prefix {
if !prefixes.insert(prefix) {
bail!("public domain prefixes must be unique: {prefix}");
}
}
}
Ok(Some(
domains
.iter()
.map(|domain| mutations::sandbox_create::SandboxDomainInput {
prefix: domain.prefix.clone(),
port: i64::from(domain.port),
})
.collect(),
))
}
pub(crate) enum CreateReport {
Full,
Json,
}
pub(crate) async fn create_and_store(
configs: &mut Configs,
client: &reqwest::Client,
project_id: String,
environment_id: String,
input: mutations::sandbox_create::SandboxCreateInput,
report: CreateReport,
forked: bool,
) -> Result<String> {
let (doing, did, failed) = if forked {
("Forking sandbox", "Forked", "Failed to fork sandbox")
} else {
("Creating sandbox", "Created", "Failed to create sandbox")
};
let requested_domains = input.public_domains.as_ref().map_or(0, Vec::len);
let mut spinner = create_shimmer_spinner(doing);
let sandbox = match post_graphql::<mutations::SandboxCreate, _>(
client,
configs.get_backboard(),
mutations::sandbox_create::Variables { input },
)
.await
{
Ok(res) => res.sandbox_create,
Err(e) => {
fail_spinner(&mut spinner, failed.to_string());
return Err(e.into());
}
};
spinner.finish_and_clear();
configs.upsert_sandbox(
StoredSandbox {
id: sandbox.id.clone(),
environment_id,
project_id: Some(project_id),
created_at: Some(sandbox.created_at.to_rfc3339()),
},
true,
);
configs.write()?;
match report {
CreateReport::Json => println!("{}", serde_json::to_string_pretty(&sandbox)?),
CreateReport::Full => {
println!("✓ {did} sandbox {} (now active)", sandbox.id);
println!(" status: {:?}", sandbox.status);
println!(" region: {}", sandbox.region);
if let Some(idle) = sandbox.idle_timeout_minutes {
println!(" idle timeout: {idle}m");
}
if sandbox.domains.len() < requested_domains {
println!(" domains: publishing (run `railway sandbox list` to see the URLs)");
}
for domain in &sandbox.domains {
println!(
" {}: https://{} -> port {}",
domain.prefix, domain.domain, domain.port
);
}
println!("\nConnect with:\n railway sandbox ssh");
}
}
Ok(sandbox.id)
}
async fn create(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: CreateArgs,
) -> Result<()> {
let public_domains = public_domains_input(&args.domains)?;
let (project_id, environment_id) =
resolve_project_and_env(configs, client, project, environment).await?;
let template = if let Some(name) = &args.checkpoint {
Some(mutations::sandbox_create::SandboxTemplateInput {
instructions: None,
base_image_digest: None,
name: Some(name.clone()),
variables: None,
})
} else if let Some(handle) = &args.template {
let stored = configs
.find_sandbox_template(handle, Some(&environment_id))
.ok_or_else(|| {
anyhow!(
"Unknown template `{handle}` for this environment. Build it first:\n railway sandbox template build --name {handle} -c '<command>' --wait"
)
})?;
Some(mutations::sandbox_create::SandboxTemplateInput {
instructions: Some(stored.instructions),
base_image_digest: stored.base_image_digest,
name: None,
variables: None,
})
} else {
None
};
let input = mutations::sandbox_create::SandboxCreateInput {
environment_id: environment_id.clone(),
idle_timeout_minutes: args.idle_timeout_minutes,
public_domains,
template,
source_sandbox_id: None,
network_isolation: args
.private_network
.then_some(mutations::sandbox_create::SandboxNetworkIsolation::PRIVATE),
variables: variables_to_input(&args.env_files, &args.variables)?,
region: None,
};
create_and_store(
configs,
client,
project_id,
environment_id,
input,
if args.json {
CreateReport::Json
} else {
CreateReport::Full
},
false,
)
.await
.map(|_| ())
}
async fn template(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: TemplateArgs,
) -> Result<()> {
match args.command {
TemplateCommands::Build(sub) => {
template_build(configs, client, project, environment, sub).await
}
TemplateCommands::Status(sub) => {
template_status(configs, client, project, environment, sub).await
}
TemplateCommands::List(sub) => {
template_list(configs, client, project, environment, sub).await
}
}
}
async fn template_build(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: TemplateBuildArgs,
) -> Result<()> {
let (_, environment_id) =
resolve_project_and_env(configs, client, project, environment).await?;
let res = post_graphql::<mutations::SandboxTemplateBuild, _>(
client,
configs.get_backboard(),
mutations::sandbox_template_build::Variables {
environment_id: environment_id.clone(),
input: mutations::sandbox_template_build::SandboxTemplateInput {
instructions: Some(args.commands.clone()),
base_image_digest: args.base_image_digest.clone(),
name: None,
variables: None,
},
},
)
.await?;
let built = res.sandbox_template_build;
configs.upsert_sandbox_template(StoredSandboxTemplate {
id: built.id.clone(),
name: args.name.clone(),
environment_id: environment_id.clone(),
instructions: args.commands,
base_image_digest: args.base_image_digest,
created_at: Some(chrono::Utc::now().to_rfc3339()),
});
configs.write()?;
let already_ready = matches!(
built.status,
mutations::sandbox_template_build::SandboxTemplateBuildStatus::READY
);
let status = if args.wait && !already_ready {
wait_for_template(client, configs, &environment_id, &built.id).await?
} else {
format!("{:?}", built.status)
};
let handle = args.name.unwrap_or_else(|| built.id.clone());
if args.json {
let out = serde_json::json!({
"id": built.id,
"status": status,
"environmentId": environment_id,
"name": handle,
});
println!("{}", serde_json::to_string_pretty(&out)?);
return Ok(());
}
if already_ready {
println!("✓ Template {handle} ready (cached)");
} else if status == "READY" {
println!("✓ Template {handle} built");
} else {
println!("Template {handle} status: {status}");
println!("\nCheck progress with:\n railway sandbox template status {handle}");
}
if status == "READY" {
println!("\nCreate a sandbox from it with:\n railway sandbox create --template {handle}");
}
Ok(())
}
async fn template_status(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: TemplateStatusArgs,
) -> Result<()> {
let stored = configs.find_sandbox_template(&args.template, None);
let (id, environment_id) = match &stored {
Some(t) => (t.id.clone(), t.environment_id.clone()),
None => {
let (_, environment_id) =
resolve_project_and_env(configs, client, project, environment).await?;
(args.template.clone(), environment_id)
}
};
let res = post_graphql::<queries::SandboxTemplateBuild, _>(
client,
configs.get_backboard(),
queries::sandbox_template_build::Variables { environment_id, id },
)
.await?;
let tpl = res.sandbox_template_build;
if args.json {
println!("{}", serde_json::to_string_pretty(&tpl)?);
return Ok(());
}
if let Some(name) = stored.and_then(|t| t.name) {
println!("Template {name} ({})", tpl.id);
} else {
println!("Template {}", tpl.id);
}
println!(" status: {:?}", tpl.status);
Ok(())
}
async fn template_list(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: TemplateListArgs,
) -> Result<()> {
let (_, environment_id) =
resolve_project_and_env(configs, client, project, environment).await?;
let templates = configs.list_sandbox_templates(Some(&environment_id));
if templates.is_empty() {
if args.json {
println!("[]");
} else {
println!(
"No templates built from this CLI for this environment.\nBuild one with:\n railway sandbox template build --name <name> -c '<command>' --wait"
);
}
return Ok(());
}
let mut rows = Vec::new();
for t in &templates {
let status = post_graphql::<queries::SandboxTemplateBuild, _>(
client,
configs.get_backboard(),
queries::sandbox_template_build::Variables {
environment_id: environment_id.clone(),
id: t.id.clone(),
},
)
.await
.map(|r| format!("{:?}", r.sandbox_template_build.status))
.unwrap_or_else(|_| "UNKNOWN".to_string());
rows.push((t, status));
}
if args.json {
let out: Vec<_> = rows
.iter()
.map(|(t, status)| {
serde_json::json!({
"id": t.id,
"name": t.name,
"status": status,
"instructions": t.instructions,
"baseImageDigest": t.base_image_digest,
"createdAt": t.created_at,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&out)?);
return Ok(());
}
println!(
"{:<20} {:<16} {:<10} {:<6}",
"NAME", "ID", "STATUS", "STEPS"
);
for (t, status) in rows {
println!(
"{:<20} {:<16} {:<10} {:<6}",
t.name.as_deref().unwrap_or("-"),
&t.id[..t.id.len().min(16)],
status,
t.instructions.len()
);
}
Ok(())
}
async fn wait_for_template(
client: &reqwest::Client,
configs: &Configs,
environment_id: &str,
id: &str,
) -> Result<String> {
let mut spinner = create_shimmer_spinner("Building template");
let deadline = std::time::Instant::now() + Duration::from_secs(45 * 60);
loop {
tokio::time::sleep(Duration::from_secs(5)).await;
let res = post_graphql::<queries::SandboxTemplateBuild, _>(
client,
configs.get_backboard(),
queries::sandbox_template_build::Variables {
environment_id: environment_id.to_string(),
id: id.to_string(),
},
)
.await?;
match res.sandbox_template_build.status {
queries::sandbox_template_build::SandboxTemplateBuildStatus::READY => {
spinner.finish_and_clear();
return Ok("READY".to_string());
}
queries::sandbox_template_build::SandboxTemplateBuildStatus::FAILED => {
fail_spinner(&mut spinner, "Template build failed".to_string());
bail!(
"Template build failed. Each instruction must exit 0 within 10 minutes; fix the failing step and rebuild."
);
}
_ => {}
}
if std::time::Instant::now() > deadline {
fail_spinner(&mut spinner, "Timed out waiting for template".to_string());
bail!("Timed out waiting for the template build.");
}
}
}
async fn checkpoint(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: CheckpointArgs,
) -> Result<()> {
match args.command {
CheckpointCommands::Create(sub) => {
checkpoint_create(configs, client, project, environment, sub).await
}
CheckpointCommands::List(sub) => {
checkpoint_list(configs, client, project, environment, sub).await
}
CheckpointCommands::Rename(sub) => {
checkpoint_rename(configs, client, project, environment, sub).await
}
CheckpointCommands::Delete(sub) => {
checkpoint_delete(configs, client, project, environment, sub).await
}
}
}
async fn checkpoint_create(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: CheckpointCreateArgs,
) -> Result<()> {
let (sandbox_id, environment_id) =
resolve_target(configs, client, args.id.clone(), project, environment).await?;
let mut spinner = create_shimmer_spinner("Capturing checkpoint");
let created = match post_graphql::<mutations::SandboxCheckpointCreate, _>(
client,
configs.get_backboard(),
mutations::sandbox_checkpoint_create::Variables {
environment_id,
sandbox_id: sandbox_id.clone(),
name: args.name.clone(),
},
)
.await
{
Ok(res) => res.sandbox_checkpoint_create,
Err(e) => {
fail_spinner(&mut spinner, "Failed to capture checkpoint".to_string());
return Err(e.into());
}
};
spinner.finish_and_clear();
if args.json {
println!("{}", serde_json::to_string_pretty(&created)?);
return Ok(());
}
println!("✓ Checkpoint {} captured from {sandbox_id}", created.key);
println!(
"\nBoot a sandbox from it with:\n railway sandbox create --checkpoint {}",
created.key
);
Ok(())
}
async fn checkpoint_list(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: CheckpointListArgs,
) -> Result<()> {
let (_, environment_id) =
resolve_project_and_env(configs, client, project, environment).await?;
let res = post_graphql::<queries::SandboxCheckpoints, _>(
client,
configs.get_backboard(),
queries::sandbox_checkpoints::Variables { environment_id },
)
.await?;
let checkpoints = res.sandbox_checkpoints;
if args.json {
println!("{}", serde_json::to_string_pretty(&checkpoints)?);
return Ok(());
}
if checkpoints.is_empty() {
println!(
"No checkpoints in this environment.\nCapture one with:\n railway sandbox checkpoint create <name>"
);
return Ok(());
}
println!("{:<32} {:<16}", "NAME", "CREATED");
for cp in checkpoints {
println!(
"{:<32} {:<16}",
cp.key,
cp.created_at.format("%Y-%m-%d %H:%M").to_string()
);
}
Ok(())
}
async fn checkpoint_rename(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: CheckpointRenameArgs,
) -> Result<()> {
let (_, environment_id) =
resolve_project_and_env(configs, client, project, environment).await?;
let res = post_graphql::<mutations::SandboxCheckpointRename, _>(
client,
configs.get_backboard(),
mutations::sandbox_checkpoint_rename::Variables {
environment_id,
id: args.name.clone(),
name: args.new_name.clone(),
},
)
.await?;
let renamed = res.sandbox_checkpoint_rename;
if args.json {
println!("{}", serde_json::to_string_pretty(&renamed)?);
return Ok(());
}
println!("✓ Renamed checkpoint {} → {}", args.name, renamed.key);
Ok(())
}
async fn checkpoint_delete(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: CheckpointDeleteArgs,
) -> Result<()> {
let (_, environment_id) =
resolve_project_and_env(configs, client, project, environment).await?;
let res = post_graphql::<mutations::SandboxCheckpointDelete, _>(
client,
configs.get_backboard(),
mutations::sandbox_checkpoint_delete::Variables {
environment_id,
id: args.name.clone(),
},
)
.await?;
if !res.sandbox_checkpoint_delete {
bail!("No checkpoint named `{}` in this environment.", args.name);
}
println!("✓ Deleted checkpoint {}", args.name);
Ok(())
}
async fn fork(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: ForkArgs,
) -> Result<()> {
let public_domains = public_domains_input(&args.domains)?;
let (source_sandbox_id, environment_id) = resolve_target(
configs,
client,
args.explicit_id(),
project.clone(),
environment.clone(),
)
.await?;
let project_id = match configs
.get_sandbox(&source_sandbox_id)
.and_then(|s| s.project_id)
{
Some(id) => id,
None => {
resolve_project_and_env(configs, client, project, environment)
.await?
.0
}
};
let input = mutations::sandbox_create::SandboxCreateInput {
environment_id: environment_id.clone(),
idle_timeout_minutes: args.idle_timeout_minutes,
public_domains,
template: None,
source_sandbox_id: Some(source_sandbox_id),
network_isolation: args
.private_network
.then_some(mutations::sandbox_create::SandboxNetworkIsolation::PRIVATE),
variables: variables_to_input(&args.env_files, &args.variables)?,
region: None,
};
create_and_store(
configs,
client,
project_id,
environment_id,
input,
if args.json {
CreateReport::Json
} else {
CreateReport::Full
},
true,
)
.await
.map(|_| ())
}
async fn list(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: ListArgs,
) -> Result<()> {
let (project_id, environment_id) =
resolve_project_and_env(configs, client, project, environment).await?;
let res = post_graphql::<queries::Sandboxes, _>(
client,
configs.get_backboard(),
queries::sandboxes::Variables {
environment_id: environment_id.clone(),
first: Some(100),
after: None,
},
)
.await?;
let mut nodes: Vec<_> = res.sandboxes.edges.into_iter().map(|e| e.node).collect();
let hidden = if args.all {
0
} else {
let before = nodes.len();
nodes.retain(|n| !matches!(n.status, queries::sandboxes::SandboxStatus::DESTROYED));
before - nodes.len()
};
for node in &nodes {
configs.upsert_sandbox(
StoredSandbox {
id: node.id.clone(),
environment_id: environment_id.clone(),
project_id: Some(project_id.clone()),
created_at: Some(node.created_at.to_rfc3339()),
},
false,
);
}
configs.write()?;
if args.json {
println!("{}", serde_json::to_string_pretty(&nodes)?);
return Ok(());
}
if nodes.is_empty() {
if hidden > 0 {
println!(
"No active sandboxes in this environment ({hidden} destroyed; use --all to show them)."
);
} else {
println!("No sandboxes in this environment.");
}
return Ok(());
}
let active = configs.get_active_sandbox().map(|s| s.id);
println!(
"{:<38} {:<10} {:<10} {:<16}",
"ID", "STATUS", "REGION", "CREATED"
);
for node in nodes {
let marker = if active.as_deref() == Some(node.id.as_str()) {
"*"
} else {
" "
};
println!(
"{marker} {:<38} {:<10} {:<10} {:<16}",
node.id,
format!("{:?}", node.status),
node.region,
node.created_at.format("%Y-%m-%d %H:%M").to_string()
);
for domain in &node.domains {
println!(
" {}: https://{} -> port {}",
domain.prefix, domain.domain, domain.port
);
}
}
if hidden > 0 {
println!("\n({hidden} destroyed sandboxes hidden; use --all to show them)");
}
Ok(())
}
async fn exec(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: ExecArgs,
) -> Result<()> {
use colored::Colorize;
if args.command.is_empty() && args.session.is_none() {
bail!("a command is required (or pass --session <name> to reattach)");
}
let (sandbox_id, environment_id) = tel::track_for(
"sandbox",
"exec_resolve_target",
resolve_target(configs, client, args.id.clone(), project, environment).await,
)
.await?;
configs.set_active_sandbox(&sandbox_id);
configs.write()?;
let mut spinner = create_shimmer_spinner("Connecting");
let jwt = match tel::track_for(
"sandbox",
"exec_mint_token",
mint_shell_token(
client,
configs.get_backboard(),
&environment_id,
&sandbox_id,
)
.await,
)
.await
{
Ok(jwt) => jwt,
Err(e) => {
fail_spinner(&mut spinner, "Failed to authorize command".to_string());
return Err(e);
}
};
let ws =
match tel::track_for("sandbox", "exec_connect", sandbox_exec::connect(&jwt).await).await {
Ok(ws) => ws,
Err(e) => {
fail_spinner(&mut spinner, "Failed to connect to sandbox".to_string());
return Err(e);
}
};
spinner.finish_and_clear();
let heartbeat = spawn_heartbeat(
client.clone(),
configs.get_backboard(),
environment_id,
sandbox_id.clone(),
);
let options = sandbox_exec::ExecOptions {
command: match args.command.as_slice() {
[] => None,
[cmd] => Some(cmd.clone()),
argv => Some(shell_join(argv)),
},
session: args.session.clone(),
resume_from_last_read: args.resume_from_last_read,
timeout: args
.timeout
.map(|secs| Duration::from_secs(secs.max(0) as u64)),
detach: args.detach,
stdin_is_tty: std::io::stdin().is_terminal(),
};
let outcome = tel::track_for(
"sandbox",
"exec_stream",
sandbox_exec::run(ws, options).await,
)
.await;
heartbeat.abort();
match outcome? {
ExecOutcome::Exited {
code,
fresh_session_suspected,
} => {
if fresh_session_suspected {
eprintln!(
"{}",
"warning: that session may have expired; the server started a fresh one instead"
.yellow()
);
}
if args.detach {
eprintln!(
"{}",
"warning: durable sessions are unavailable for this sandbox; ran attached"
.yellow()
);
}
if code != 0 {
tel::report_failure_for("sandbox", "exec_exit_nonzero", &format!("exit {code}"))
.await;
}
std::process::exit(code);
}
ExecOutcome::TimedOut { session_name } => {
eprintln!("\n(command timed out)");
if let Some(name) = session_name {
eprintln!("{}", reattach_hint(&sandbox_id, &name).dimmed());
}
std::process::exit(sandbox_exec::TIMEOUT_EXIT_CODE);
}
ExecOutcome::Detached { session_name } => {
println!("{session_name}");
eprintln!("{}", reattach_hint(&sandbox_id, &session_name).dimmed());
Ok(())
}
ExecOutcome::Disconnected { session_name } => {
match session_name {
Some(name) => {
eprintln!("\nDisconnected; the command may still be running.");
eprintln!("{}", reattach_hint(&sandbox_id, &name).dimmed());
}
None => eprintln!("\nConnection lost."),
}
std::process::exit(1);
}
}
}
fn reattach_hint(sandbox_id: &str, session_name: &str) -> String {
format!("Reattach with: railway sandbox exec --id {sandbox_id} --session {session_name}")
}
async fn mint_shell_token(
client: &reqwest::Client,
backboard: String,
environment_id: &str,
sandbox_id: &str,
) -> Result<String> {
let res = post_graphql::<mutations::GenerateShellToken, _>(
client,
backboard,
mutations::generate_shell_token::Variables {
input: mutations::generate_shell_token::ShellTokenInput {
environment_id: environment_id.to_string(),
instance_id: sandbox_id.to_string(),
kind: Some("sandbox".to_string()),
port: None,
scope: "shell".to_string(),
service_id: None,
},
},
)
.await?;
Ok(res.generate_shell_token)
}
async fn destroy(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: DestroyArgs,
) -> Result<()> {
let (sandbox_id, environment_id) =
resolve_target(configs, client, args.explicit_id(), project, environment).await?;
let mut spinner = create_shimmer_spinner("Destroying sandbox");
if let Err(e) = post_graphql::<mutations::SandboxDestroy, _>(
client,
configs.get_backboard(),
mutations::sandbox_destroy::Variables {
id: sandbox_id.clone(),
environment_id,
},
)
.await
{
fail_spinner(&mut spinner, "Failed to destroy sandbox".to_string());
return Err(e.into());
}
spinner.finish_and_clear();
configs.remove_sandbox(&sandbox_id);
configs.write()?;
println!("✓ Destroyed sandbox {sandbox_id}");
Ok(())
}
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
const FORWARD_STABLE_SESSION: Duration = Duration::from_secs(30);
const FORWARD_RECONNECT_DELAY: Duration = Duration::from_secs(1);
const FORWARD_MAX_QUICK_FAILURES: u32 = 5;
async fn ssh(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: SshArgs,
) -> Result<()> {
let (sandbox_id, environment_id) = tel::track_for(
"sandbox",
"ssh_resolve_target",
resolve_target(configs, client, args.id.clone(), project, environment).await,
)
.await?;
let auto_identity = if args.identity_file.is_none() {
tel::track_for(
"sandbox",
"ssh_key_setup",
ensure_ssh_key(client, configs).await,
)
.await?
} else {
None
};
configs.set_active_sandbox(&sandbox_id);
configs.write()?;
let target = format!("sbx:{environment_id}:{sandbox_id}");
let heartbeat = spawn_heartbeat(
client.clone(),
configs.get_backboard(),
environment_id,
sandbox_id,
);
let command = if args.command.is_empty() {
None
} else {
Some(args.command.clone())
};
let identity = args.identity_file.clone().or(auto_identity);
let durable_session = args.session.clone();
let resume_from_last_read = args.resume_from_last_read;
let session = tokio::task::spawn_blocking(move || {
let durable = durable_session.as_deref().map(|name| DurableResume {
session_name: name,
resume_from_last_read,
});
run_native_ssh(&target, command.as_deref(), identity.as_deref(), durable)
})
.await
.map_err(anyhow::Error::from)
.and_then(|r| r);
let exit_code = tel::track_for("sandbox", "ssh_session", session).await?;
heartbeat.abort();
if exit_code != 0 {
tel::report_failure_for(
"sandbox",
"ssh_exit_nonzero",
&format!("ssh exited with code {exit_code}"),
)
.await;
std::process::exit(exit_code);
}
Ok(())
}
struct PortSpec {
local: Option<u16>,
remote: u16,
}
fn parse_port_spec(spec: &str) -> Result<PortSpec> {
let parse_port = |s: &str, what: &str| -> Result<u16> {
let port: u16 = s
.trim()
.parse()
.map_err(|_| anyhow!("Invalid {what} port {s:?} in {spec:?} (expected 1-65535)"))?;
if port == 0 {
bail!("Invalid {what} port 0 in {spec:?} (expected 1-65535)");
}
Ok(port)
};
match spec.split_once(':') {
Some((local, remote)) => Ok(PortSpec {
local: Some(parse_port(local, "local")?),
remote: parse_port(remote, "remote")?,
}),
None => Ok(PortSpec {
local: None,
remote: parse_port(spec, "remote")?,
}),
}
}
fn resolve_local_port(spec: &PortSpec, strict: bool) -> Result<(u16, bool)> {
let is_free = |port: u16| std::net::TcpListener::bind(("127.0.0.1", port)).is_ok();
let requested = spec.local.unwrap_or(spec.remote);
if is_free(requested) {
return Ok((requested, false));
}
if spec.local.is_some() || strict {
bail!(
"Local port {requested} is already in use.\n\
Pick a different one with: railway sandbox forward <local>:{remote}",
remote = spec.remote
);
}
for offset in 1..=100u16 {
if let Some(candidate) = requested.checked_add(offset)
&& is_free(candidate)
{
return Ok((candidate, true));
}
}
bail!("Local port {requested} is in use and no nearby free port was found");
}
async fn forward(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
environment: Option<String>,
args: ForwardArgs,
) -> Result<()> {
use colored::Colorize;
let specs = args
.ports
.iter()
.map(|s| parse_port_spec(s))
.collect::<Result<Vec<_>>>()?;
let (sandbox_id, environment_id) = tel::track_for(
"sandbox",
"forward_resolve_target",
resolve_target(configs, client, args.id.clone(), project, environment).await,
)
.await?;
let auto_identity = if args.identity_file.is_none() {
tel::track_for(
"sandbox",
"forward_key_setup",
ensure_ssh_key(client, configs).await,
)
.await?
} else {
None
};
let mut forwards = Vec::with_capacity(specs.len());
let mut remaps = Vec::new();
let mut seen_local = std::collections::BTreeSet::new();
for spec in &specs {
let (local_port, remapped) = resolve_local_port(spec, args.strict)?;
if !seen_local.insert(local_port) {
bail!("Local port {local_port} is requested more than once");
}
if remapped {
remaps.push((spec.remote, local_port));
}
forwards.push(PortForward {
local_port,
remote_port: spec.remote,
});
}
configs.set_active_sandbox(&sandbox_id);
configs.write()?;
let target = format!("sbx:{environment_id}:{sandbox_id}");
let heartbeat = spawn_heartbeat(
client.clone(),
configs.get_backboard(),
environment_id.clone(),
sandbox_id.clone(),
);
let short_id: String = sandbox_id.chars().take(8).collect();
eprintln!();
eprintln!(
"{} Forwarding to sandbox {}",
"âš¡".yellow(),
short_id.bold()
);
eprintln!();
for (remapped_remote, picked) in &remaps {
eprintln!(
" {} port {remapped_remote} is in use locally, using {picked} instead",
"âš ".yellow()
);
}
for f in &forwards {
eprintln!(
" {} {} {} {}",
"➜".green(),
format!("http://localhost:{}", f.local_port).cyan().bold(),
"→".dimmed(),
format!("sandbox:{}", f.remote_port).dimmed()
);
}
eprintln!();
eprintln!(" {}", "Press Ctrl+C to stop".dimmed());
eprintln!();
let identity = args.identity_file.clone().or(auto_identity);
let mut quick_failures: u32 = 0;
let exit_code = loop {
let session_target = target.clone();
let session_identity = identity.clone();
let session_forwards = forwards.clone();
let started = std::time::Instant::now();
let session = tokio::task::spawn_blocking(move || {
run_native_ssh_forward(
&session_target,
session_identity.as_deref(),
&session_forwards,
)
})
.await
.map_err(anyhow::Error::from)
.and_then(|r| r);
let exit_code = tel::track_for("sandbox", "forward_session", session).await?;
if exit_code == 0 {
break 0;
}
if started.elapsed() >= FORWARD_STABLE_SESSION {
quick_failures = 0;
} else {
quick_failures += 1;
}
if quick_failures > FORWARD_MAX_QUICK_FAILURES {
eprintln!(
"\nForward keeps failing right after connecting (ssh exit code {exit_code}); giving up."
);
break exit_code;
}
match fetch_sandbox_status(client, configs, &environment_id, &sandbox_id).await {
Ok(Some(queries::sandbox::SandboxStatus::RUNNING)) => {
let delay = FORWARD_RECONNECT_DELAY * 2u32.saturating_pow(quick_failures.min(4));
eprintln!(
"\n{} Forward dropped (ssh exit code {exit_code}); sandbox {} is still running — reconnecting in {}s...",
"âš ".yellow(),
short_id.bold(),
delay.as_secs()
);
tokio::time::sleep(delay).await;
}
Ok(status) => {
let status = status
.map(|s| format!("{s:?}"))
.unwrap_or_else(|| "GONE".to_string());
eprintln!(
"\nForward ended: sandbox {short_id} is {status}.\n\
Start a fresh one with `railway sandbox create` or `railway sandbox fork`."
);
break exit_code;
}
Err(_) => {
eprintln!(
"\nForward ended unexpectedly (ssh exit code {exit_code}).\n\
If the sandbox stopped, start a fresh one with `railway sandbox create` or `railway sandbox fork`."
);
break exit_code;
}
}
};
heartbeat.abort();
if exit_code != 0 {
tel::report_failure_for(
"sandbox",
"forward_exit_nonzero",
&format!("ssh exited with code {exit_code}"),
)
.await;
std::process::exit(exit_code);
}
Ok(())
}
async fn fetch_sandbox_status(
client: &reqwest::Client,
configs: &Configs,
environment_id: &str,
sandbox_id: &str,
) -> Result<Option<queries::sandbox::SandboxStatus>> {
let res = post_graphql::<queries::Sandbox, _>(
client,
configs.get_backboard(),
queries::sandbox::Variables {
environment_id: environment_id.to_string(),
id: sandbox_id.to_string(),
},
)
.await?;
Ok(res.sandbox.map(|s| s.status))
}
pub(crate) fn spawn_heartbeat(
client: reqwest::Client,
backboard: String,
environment_id: String,
sandbox_id: String,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL);
interval.tick().await;
loop {
interval.tick().await;
let _ = post_graphql::<mutations::SandboxHeartbeat, _>(
&client,
backboard.clone(),
mutations::sandbox_heartbeat::Variables {
id: sandbox_id.clone(),
environment_id: environment_id.clone(),
},
)
.await;
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn domain_flags_parse_for_create_and_fork() {
for command in ["create", "fork"] {
let parsed = parse_exec(&[
command,
"--private-network",
"--domain",
"8080",
"--domain",
"api:3000",
])
.unwrap();
let domains = match parsed.command {
Commands::Create(args) => args.domains,
Commands::Fork(args) => args.domains,
_ => panic!("expected create or fork"),
};
let input = public_domains_input(&domains).unwrap().unwrap();
assert_eq!(
serde_json::to_value(input).unwrap(),
serde_json::json!([
{ "port": 8080 },
{ "prefix": "api", "port": 3000 },
])
);
}
}
#[test]
fn domain_flags_require_private_network() {
for command in ["create", "fork"] {
assert!(parse_exec(&[command, "--domain", "8080"]).is_err());
assert!(parse_exec(&[command]).is_ok());
}
assert!(public_domains_input(&[]).unwrap().is_none());
}
#[test]
fn domain_specs_reject_invalid_prefixes_and_ports() {
for spec in [
"",
"0",
"65536",
"api:0",
"api:65536",
"api:abc",
":8080",
"API:8080",
"-api:8080",
"api-:8080",
"api.foo:8080",
"a_b:8080",
"api:8080:9000",
"https://api:8080",
"é:8080",
] {
assert!(spec.parse::<PublicDomainSpec>().is_err(), "accepted {spec}");
}
assert!(
format!("{}:8080", "a".repeat(47))
.parse::<PublicDomainSpec>()
.is_err()
);
assert!(
format!("{}:65535", "a".repeat(46))
.parse::<PublicDomainSpec>()
.is_ok()
);
assert!("a-1:1".parse::<PublicDomainSpec>().is_ok());
}
#[test]
fn domain_requests_validate_count_and_uniqueness() {
for specs in [vec!["8080", "api:8080"], vec!["api:8080", "api:3000"]] {
let domains: Vec<_> = specs
.iter()
.map(|s| s.parse::<PublicDomainSpec>().unwrap())
.collect();
assert!(public_domains_input(&domains).is_err());
}
let domains: Vec<_> = (1..=10)
.map(|port| PublicDomainSpec { prefix: None, port })
.collect();
assert_eq!(public_domains_input(&domains).unwrap().unwrap().len(), 10);
let mut too_many = domains;
too_many.push(PublicDomainSpec {
prefix: None,
port: 11,
});
assert!(public_domains_input(&too_many).is_err());
}
#[test]
fn parse_port_spec_bare_remote() {
let spec = parse_port_spec("3000").unwrap();
assert!(spec.local.is_none());
assert_eq!(spec.remote, 3000);
}
#[test]
fn parse_port_spec_local_remote() {
let spec = parse_port_spec("8080:3000").unwrap();
assert_eq!(spec.local, Some(8080));
assert_eq!(spec.remote, 3000);
}
#[test]
fn parse_port_spec_rejects_garbage() {
assert!(parse_port_spec("abc").is_err());
assert!(parse_port_spec("0").is_err());
assert!(parse_port_spec("8080:0").is_err());
assert!(parse_port_spec(":3000").is_err());
assert!(parse_port_spec("70000").is_err());
assert!(parse_port_spec("8080:3000:1").is_err());
}
#[test]
fn parse_single_pair() {
let vars = parse_variable_args(&args(&["FOO=bar"])).unwrap();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].key, "FOO");
assert_eq!(vars[0].value, "bar");
}
#[test]
fn parse_comma_separated_pairs() {
let vars = parse_variable_args(&args(&["FOO=bar,BAZ=qux,N=1"])).unwrap();
assert_eq!(
vars.iter()
.map(|v| (v.key.as_str(), v.value.as_str()))
.collect::<Vec<_>>(),
vec![("FOO", "bar"), ("BAZ", "qux"), ("N", "1")]
);
}
#[test]
fn comma_in_value_stays_single_pair() {
let vars = parse_variable_args(&args(&["ALLOWED=a.com,b.com"])).unwrap();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].key, "ALLOWED");
assert_eq!(vars[0].value, "a.com,b.com");
}
#[test]
fn repeated_flags_accumulate() {
let vars = parse_variable_args(&args(&["A=1", "B=2,C=3"])).unwrap();
assert_eq!(vars.len(), 3);
}
#[test]
fn invalid_pair_errors() {
assert!(parse_variable_args(&args(&["NOVALUE"])).is_err());
assert!(parse_variable_args(&args(&["FOO=bar,=nokey,BAZ=qux"])).is_err());
}
#[test]
fn empty_value_sets_the_empty_string() {
let vars = parse_variable_args(&args(&["FOO=bar,BLANK=,BAZ=qux"])).unwrap();
assert_eq!(vars.len(), 3);
assert_eq!(vars[1].key, "BLANK");
assert_eq!(vars[1].value, "");
}
#[test]
fn wraps_bare_references() {
assert_eq!(
auto_wrap_reference("postgres.DATABASE_URL"),
"${{postgres.DATABASE_URL}}"
);
assert_eq!(auto_wrap_reference("shared.FOO"), "${{shared.FOO}}");
assert_eq!(
auto_wrap_reference("my-api_2.PORT_8080"),
"${{my-api_2.PORT_8080}}"
);
}
#[test]
fn leaves_plain_values_alone() {
for v in ["bar", "1.5", "example.com", "file.txt", "a.b.C", "2.0.1"] {
assert_eq!(auto_wrap_reference(v), v);
}
}
#[test]
fn leaves_existing_references_alone() {
let full = "${{postgres.DATABASE_URL}}";
assert_eq!(auto_wrap_reference(full), full);
let embedded = "postgres://${{postgres.PGUSER}}@host";
assert_eq!(auto_wrap_reference(embedded), embedded);
}
#[test]
fn variables_to_input_wraps_and_collects() {
let input = variables_to_input(&[], &args(&["DB=postgres.DATABASE_URL,FOO=bar"]))
.unwrap()
.unwrap();
assert_eq!(
input.get("DB").map(String::as_str),
Some("${{postgres.DATABASE_URL}}")
);
assert_eq!(input.get("FOO").map(String::as_str), Some("bar"));
}
#[test]
fn variables_to_input_empty_is_none() {
assert!(variables_to_input(&[], &[]).unwrap().is_none());
}
fn parse_exec(argv: &[&str]) -> std::result::Result<Args, clap::Error> {
let full: Vec<&str> = std::iter::once("sandbox")
.chain(argv.iter().copied())
.collect();
<Args as clap::Parser>::try_parse_from(full)
}
#[test]
fn exec_session_without_command_parses() {
let args = parse_exec(&["exec", "--session", "sess-1"]).unwrap();
let Commands::Exec(exec) = args.command else {
panic!("expected exec subcommand");
};
assert_eq!(exec.session.as_deref(), Some("sess-1"));
assert!(exec.command.is_empty());
}
#[test]
fn exec_session_conflicts_with_detach() {
assert!(parse_exec(&["exec", "--session", "s", "--detach", "--", "ls"]).is_err());
}
#[test]
fn exec_resume_requires_session() {
assert!(parse_exec(&["exec", "--resume-from-last-read", "--", "ls"]).is_err());
assert!(parse_exec(&["exec", "--session", "s", "--resume-from-last-read"]).is_ok());
}
#[test]
fn exec_detach_with_command_parses() {
let args = parse_exec(&["exec", "--detach", "--", "sleep", "300"]).unwrap();
let Commands::Exec(exec) = args.command else {
panic!("expected exec subcommand");
};
assert!(exec.detach);
assert_eq!(exec.command, vec!["sleep", "300"]);
}
#[test]
fn manually_wrapped_pairs_split_and_pass_verbatim() {
let input = variables_to_input(
&[],
&args(&["FOO=${{serviceName.FOO}},BAR=${{serviceName.BAR}}"]),
)
.unwrap()
.unwrap();
assert_eq!(
input.get("FOO").map(String::as_str),
Some("${{serviceName.FOO}}")
);
assert_eq!(
input.get("BAR").map(String::as_str),
Some("${{serviceName.BAR}}")
);
let input = variables_to_input(&[], &args(&["URL=http://${{svc.HOST}}:8080"]))
.unwrap()
.unwrap();
assert_eq!(
input.get("URL").map(String::as_str),
Some("http://${{svc.HOST}}:8080")
);
}
#[test]
fn wraps_shared_refs_any_case() {
assert_eq!(auto_wrap_reference("shared.char"), "${{shared.char}}");
assert_eq!(auto_wrap_reference("shared.FOO"), "${{shared.FOO}}");
assert_eq!(auto_wrap_reference("postgres.char"), "postgres.char");
}
fn write_temp_env(name: &str, contents: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("railway-test-{}-{name}", std::process::id()));
std::fs::write(&path, contents).unwrap();
path
}
#[test]
fn env_file_parses_dotenv_format() {
let path = write_temp_env(
"basic.env",
"# comment\n\nFOO=bar\nexport BAZ=qux\nQUOTED=\"hello world\"\nSINGLE='a # not comment'\nTRAIL=value # comment\nREF=postgres.DATABASE_URL\n",
);
let vars = parse_env_file(&path).unwrap();
std::fs::remove_file(&path).ok();
let map: BTreeMap<_, _> = vars.into_iter().map(|v| (v.key, v.value)).collect();
assert_eq!(map.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(map.get("BAZ").map(String::as_str), Some("qux"));
assert_eq!(map.get("QUOTED").map(String::as_str), Some("hello world"));
assert_eq!(
map.get("SINGLE").map(String::as_str),
Some("a # not comment")
);
assert_eq!(map.get("TRAIL").map(String::as_str), Some("value"));
assert_eq!(
map.get("REF").map(String::as_str),
Some("postgres.DATABASE_URL")
);
}
#[test]
fn env_file_invalid_line_errors_with_location() {
let path = write_temp_env("bad.env", "FOO=bar\nNOT A PAIR\n");
let err = parse_env_file(&path).unwrap_err().to_string();
std::fs::remove_file(&path).ok();
assert!(err.contains(":2:"), "error should cite line 2: {err}");
}
#[test]
fn env_file_missing_errors() {
assert!(parse_env_file(std::path::Path::new("/nonexistent/x.env")).is_err());
}
#[test]
fn checkpoint_boot_serializes_name_only() {
let input = mutations::sandbox_create::SandboxTemplateInput {
instructions: None,
base_image_digest: None,
name: Some("my-setup".to_string()),
variables: None,
};
let json = serde_json::to_value(&input).unwrap();
assert_eq!(json, serde_json::json!({ "name": "my-setup" }));
}
#[test]
fn template_boot_serializes_instructions_only() {
let input = mutations::sandbox_create::SandboxTemplateInput {
instructions: Some(vec!["npm i -g pnpm".to_string()]),
base_image_digest: None,
name: None,
variables: None,
};
let json = serde_json::to_value(&input).unwrap();
assert_eq!(
json,
serde_json::json!({ "instructions": ["npm i -g pnpm"] })
);
}
#[test]
fn flags_override_env_file_entries() {
let path = write_temp_env("override.env", "FOO=from-file\nKEEP=file-value\n");
let input = variables_to_input(
std::slice::from_ref(&path),
&args(&["FOO=from-flag,REF=shared.char"]),
)
.unwrap()
.unwrap();
std::fs::remove_file(&path).ok();
assert_eq!(input.get("FOO").map(String::as_str), Some("from-flag"));
assert_eq!(input.get("KEEP").map(String::as_str), Some("file-value"));
assert_eq!(
input.get("REF").map(String::as_str),
Some("${{shared.char}}")
);
}
}