Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
888 changes: 862 additions & 26 deletions src/commands/sandbox.rs

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/commands/ssh/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,12 @@ fn render_config_block(
writeln!(block, "# BEGIN railway:{rendered_marker}").expect("writing to String cannot fail");
writeln!(block, "# Railway service: {rendered_service_name}")
.expect("writing to String cannot fail");
let (relay_host, relay_port) = native::ssh_relay();
writeln!(block, "Host {alias}").expect("writing to String cannot fail");
writeln!(block, " HostName {}", native::SSH_HOST).expect("writing to String cannot fail");
writeln!(block, " HostName {relay_host}").expect("writing to String cannot fail");
if let Some(port) = relay_port {
writeln!(block, " Port {port}").expect("writing to String cannot fail");
}
writeln!(block, " User {service_instance_id}").expect("writing to String cannot fail");

if let Some(identity_file) = identity_file {
Expand Down
5 changes: 3 additions & 2 deletions src/commands/ssh/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ mod common;
mod config;
mod keys;
mod native;
mod tel;
// `pub(crate)` so `sandbox ssh` can emit the same stage-failure telemetry.
pub(crate) mod tel;

use common::*;

// Re-exported for the `sandbox` command, which reuses the same native SSH
// transport (key registration + `ssh <target>@ssh.railway.com`).
// transport (key registration + `ssh <target>@<env relay host>`).
pub use native::{ensure_ssh_key, run_native_ssh};

/// Connect to a service via SSH or manage SSH keys
Expand Down
29 changes: 25 additions & 4 deletions src/commands/ssh/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,22 @@ use crate::controllers::ssh::keys::{SshKeySource, find_local_ssh_keys, register_
use crate::gql::queries::{ServiceInstance, service_instance};
use crate::util::prompt::{prompt_confirm_with_default, prompt_select};

pub(super) const SSH_HOST: &str = "ssh.railway.com";
/// SSH relay endpoint (host, non-default port) for the current environment —
/// must track `Configs::get_backboard()`'s environment, or key registration
/// is checked against one backboard while the relay authenticates against
/// another (dev-mode CLIs used to dial the prod relay and get publickey
/// denials for keys that were registered fine).
pub(super) fn ssh_relay() -> (&'static str, Option<u16>) {
Configs::get_ssh_relay()
}

/// Append `-p <port>` when the relay listens on a non-default port (the
/// develop relay uses 2222).
fn apply_relay_port(cmd: &mut Command, port: Option<u16>) {
if let Some(port) = port {
cmd.args(["-p", &port.to_string()]);
}
}

/// Get the service instance ID for a service in an environment
pub async fn get_service_instance_id(
Expand Down Expand Up @@ -149,10 +164,12 @@ fn identity_for(key: &crate::controllers::ssh::keys::LocalSshKey) -> Option<Path
/// Split out from the session loop so that a tmux-install failure is
/// distinguishable from a session connect failure in telemetry.
pub fn ensure_tmux_installed(ssh_target: &str, identity_file: Option<&Path>) -> Result<()> {
let target = format!("{}@{}", ssh_target, SSH_HOST);
let (host, port) = ssh_relay();
let target = format!("{ssh_target}@{host}");

eprintln!("Ensuring tmux is installed...");
let mut install_cmd = Command::new("ssh");
apply_relay_port(&mut install_cmd, port);
if let Some(key) = identity_file {
install_cmd.arg("-i").arg(key);
}
Expand Down Expand Up @@ -183,14 +200,16 @@ pub fn run_tmux_session(
session_name: &str,
identity_file: Option<&Path>,
) -> Result<()> {
let target = format!("{}@{}", ssh_target, SSH_HOST);
let (host, port) = ssh_relay();
let target = format!("{ssh_target}@{host}");
let tmux_cmd = format!(
"exec tmux new-session -A -s {} \\; set -g mouse on",
session_name
);

loop {
let mut session_cmd = Command::new("ssh");
apply_relay_port(&mut session_cmd, port);
if let Some(key) = identity_file {
session_cmd.arg("-i").arg(key);
}
Expand Down Expand Up @@ -228,11 +247,13 @@ pub fn run_native_ssh(
command: Option<&[String]>,
identity_file: Option<&Path>,
) -> Result<i32> {
let target = format!("{}@{}", service_instance_id, SSH_HOST);
let (host, port) = ssh_relay();
let target = format!("{service_instance_id}@{host}");
let stdin_tty = std::io::stdin().is_terminal();
let stdout_tty = std::io::stdout().is_terminal();

let mut ssh_cmd = Command::new("ssh");
apply_relay_port(&mut ssh_cmd, port);

if let Some(key) = identity_file {
ssh_cmd.arg("-i").arg(key);
Expand Down
20 changes: 18 additions & 2 deletions src/commands/ssh/tel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@ use crate::telemetry::{self, CliTrackEvent};
/// *which stage* failed. Use lowercase_snake_case stage names; they appear
/// in telemetry as `sub_command = "stage_<name>_failed"`.
pub async fn report_failure(stage: &str, message: &str) {
report_failure_for("ssh", stage, message).await;
}

/// Like [`report_failure`] but under an arbitrary command namespace, so other
/// SSH-backed commands (e.g. `sandbox ssh`) land in the same stage-failure
/// dashboards with their own command tag.
pub async fn report_failure_for(command: &str, stage: &str, message: &str) {
let mut truncated = message.to_string();
if truncated.len() > 256 {
truncated.truncate(256);
}

telemetry::send(CliTrackEvent {
command: "ssh".to_string(),
command: command.to_string(),
sub_command: Some(format!("stage_{stage}_failed")),
success: false,
error_message: Some(truncated),
Expand All @@ -31,8 +38,17 @@ pub async fn report_failure(stage: &str, message: &str) {
/// unchanged. Intended to wrap each step of an SSH flow so failures are
/// categorized without replacing the existing `?`-propagation.
pub async fn track<T>(stage: &str, result: anyhow::Result<T>) -> anyhow::Result<T> {
track_for("ssh", stage, result).await
}

/// [`track`] under an arbitrary command namespace.
pub async fn track_for<T>(
command: &str,
stage: &str,
result: anyhow::Result<T>,
) -> anyhow::Result<T> {
if let Err(ref e) = result {
report_failure(stage, &format!("{e}")).await;
report_failure_for(command, stage, &format!("{e}")).await;
}
result
}
13 changes: 10 additions & 3 deletions src/commands/volume/sftp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,13 @@ impl VolumeSftpHandler {
}
}

const ADDR: &str = "ssh.railway.com";
/// SSH relay endpoint for the current environment (host, port). Tracks the
/// same env switching as `Configs::get_backboard()`; the develop relay
/// listens on 2222.
fn relay_addr() -> (&'static str, u16) {
let (host, port) = crate::config::Configs::get_ssh_relay();
(host, port.unwrap_or(22))
}
pub(crate) const DEFAULT_TRANSFER_CONCURRENCY: usize = 32;
const DOWNLOAD_TRANSFER_BUFFER_SIZE: usize = 2 * 1024 * 1024;
const DIRECTORY_UPLOAD_TRANSFER_BUFFER_SIZE: usize = 2 * 1024 * 1024;
Expand Down Expand Up @@ -197,13 +203,14 @@ impl VolumeSftp {
if self.session.is_none() || self.is_disconnected() {
self.disconnected.store(false, Ordering::SeqCst);

let (relay_host, relay_port) = relay_addr();
let mut session = russh::client::connect(
Arc::new(russh::client::Config::default()),
(ADDR, 22),
(relay_host, relay_port),
VolumeSftpHandler::new(Arc::clone(&self.disconnected)),
)
.await
.with_context(|| format!("Failed to connect to Railway SFTP at {ADDR}"))?;
.with_context(|| format!("Failed to connect to Railway SFTP at {relay_host}"))?;

crate::controllers::ssh::authenticate(&mut session, &self.service_instance_id).await?;

Expand Down
103 changes: 103 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ pub struct StoredSandbox {
pub created_at: Option<String>,
}

/// A sandbox template recipe the CLI has built. Templates are
/// content-addressed server-side (the id is a hash of the recipe) and
/// `sandboxCreate` needs the full recipe — not just the id — so the CLI keeps
/// the instructions locally to make `railway sandbox create --template <name>`
/// possible.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde_with::skip_serializing_none]
#[serde(rename_all = "camelCase")]
pub struct StoredSandboxTemplate {
/// Server-side template id (sha256 of the recipe).
pub id: String,
/// Optional local-only name for friendlier lookup.
pub name: Option<String>,
pub environment_id: String,
pub instructions: Vec<String>,
pub base_image_digest: Option<String>,
pub created_at: Option<String>,
}

#[derive(Serialize, Deserialize, Debug, Default)]
#[serde_with::skip_serializing_none]
#[serde(rename_all = "camelCase")]
Expand All @@ -78,6 +97,9 @@ pub struct RailwayConfig {
/// The most recently created/used sandbox; the default target for
/// `railway sandbox ssh` when no id is given.
pub active_sandbox: Option<String>,
/// Sandbox template recipes the CLI has built (id is server-side hash;
/// instructions kept locally because sandboxCreate needs the full recipe).
pub sandbox_templates: Option<Vec<StoredSandboxTemplate>>,
}

#[derive(Debug)]
Expand Down Expand Up @@ -255,6 +277,17 @@ impl Configs {
format!("https://backboard.{}/graphql/v2", self.get_host())
}

/// SSH relay host and non-default port for the current environment.
/// Mirrors backboard's `controllers/ssh` mapping: only the develop relay
/// is separate (and listens on 2222); staging falls through to the
/// production relay, same as backboard's IS_DEV-only branch.
pub fn get_ssh_relay() -> (&'static str, Option<u16>) {
match Self::get_environment_id() {
Environment::Dev => ("ssh.railway-develop.com", Some(2222)),
Environment::Production | Environment::Staging => ("ssh.railway.com", None),
}
}

pub fn get_current_directory(&self) -> Result<String> {
let current_dir = std::env::current_dir()?;
let path = current_dir
Expand Down Expand Up @@ -446,6 +479,76 @@ impl Configs {
}
}

/// Record a sandbox template recipe (upsert by template id within the same
/// environment). When a name is given, any other template in the
/// environment holding that name loses it — names are unique handles.
/// Caller persists with `write()`.
pub fn upsert_sandbox_template(&mut self, template: StoredSandboxTemplate) {
let templates = self
.root_config
.sandbox_templates
.get_or_insert_with(Vec::new);
if let Some(name) = &template.name {
for other in templates.iter_mut() {
if other.environment_id == template.environment_id
&& other.id != template.id
&& other.name.as_deref() == Some(name)
{
other.name = None;
}
}
}
match templates
.iter_mut()
.find(|t| t.id == template.id && t.environment_id == template.environment_id)
{
Some(existing) => *existing = template,
None => templates.push(template),
}
}

/// Look up a stored template by local name or id (exact or unambiguous id
/// prefix), optionally scoped to an environment.
pub fn find_sandbox_template(
&self,
name_or_id: &str,
environment_id: Option<&str>,
) -> Option<StoredSandboxTemplate> {
let templates = self.root_config.sandbox_templates.as_ref()?;
let in_env =
|t: &&StoredSandboxTemplate| environment_id.is_none_or(|env| t.environment_id == env);
if let Some(t) = templates
.iter()
.filter(in_env)
.find(|t| t.name.as_deref() == Some(name_or_id))
{
return Some(t.clone());
}
let mut matches = templates
.iter()
.filter(in_env)
.filter(|t| t.id.starts_with(name_or_id));
match (matches.next(), matches.next()) {
(Some(t), None) => Some(t.clone()),
_ => None,
}
}

/// All stored templates, optionally scoped to an environment.
pub fn list_sandbox_templates(
&self,
environment_id: Option<&str>,
) -> Vec<StoredSandboxTemplate> {
self.root_config
.sandbox_templates
.as_deref()
.unwrap_or_default()
.iter()
.filter(|t| environment_id.is_none_or(|env| t.environment_id == env))
.cloned()
.collect()
}

pub fn link_service(&mut self, service_id: String) -> Result<()> {
let linked_project = self.get_linked_project_mut()?;
linked_project.service = Some(service_id);
Expand Down
2 changes: 1 addition & 1 deletion src/controllers/variables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub async fn get_service_variables(
Ok(variables)
}

#[derive(Clone, Default)]
#[derive(Clone, Debug, Default)]
pub struct Variable {
pub key: String,
pub value: String,
Expand Down
9 changes: 9 additions & 0 deletions src/gql/mutations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,15 @@ pub struct SshPublicKeyDelete;
)]
pub struct SandboxCreate;

#[derive(GraphQLQuery)]
#[graphql(
schema_path = "src/gql/schema.json",
query_path = "src/gql/mutations/strings/SandboxTemplateBuild.graphql",
response_derives = "Debug, Serialize, Clone",
skip_serializing_none
)]
pub struct SandboxTemplateBuild;

#[derive(GraphQLQuery)]
#[graphql(
schema_path = "src/gql/schema.json",
Expand Down
10 changes: 10 additions & 0 deletions src/gql/mutations/strings/SandboxTemplateBuild.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
mutation SandboxTemplateBuild(
$environmentId: String!
$input: SandboxTemplateInput!
) {
sandboxTemplateBuild(environmentId: $environmentId, input: $input) {
id
status
environmentId
}
}
9 changes: 9 additions & 0 deletions src/gql/queries/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,15 @@ pub struct SshPublicKeys;
)]
pub struct Sandboxes;

#[derive(GraphQLQuery)]
#[graphql(
schema_path = "src/gql/schema.json",
query_path = "src/gql/queries/strings/SandboxTemplate.graphql",
response_derives = "Debug, Serialize, Clone",
skip_serializing_none
)]
pub struct SandboxTemplate;

#[derive(GraphQLQuery)]
#[graphql(
schema_path = "src/gql/schema.json",
Expand Down
7 changes: 7 additions & 0 deletions src/gql/queries/strings/SandboxTemplate.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
query SandboxTemplate($environmentId: String!, $id: ID!) {
sandboxTemplate(environmentId: $environmentId, id: $id) {
id
status
environmentId
}
}
Loading
Loading