use std::collections::BTreeMap;
use is_terminal::IsTerminal;
use serde::Serialize;
use crate::controllers::database_engines::DatabaseEngine;
use crate::controllers::{config::EnvironmentConfig, database_plugins, project::ServiceContext};
use crate::util::prompt::prompt_confirm_with_default;
use super::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ResourceRef {
pub id: String,
pub name: String,
}
pub mod ha;
pub mod ops_log;
pub mod pitr;
pub mod pool;
#[derive(Parser, Clone, Default)]
pub struct Selectors {
#[clap(short, long, global = true)]
pub service: Option<String>,
#[clap(short, long, global = true)]
pub environment: Option<String>,
#[clap(short = 'p', long, value_name = "PROJECT_ID", global = true)]
pub project: Option<String>,
#[clap(long, global = true)]
pub json: bool,
}
pub enum Action {
Ha(ha::Args),
Pitr(pitr::Args),
Pooling(pool::Args),
History(HistoryArgs),
}
#[derive(Parser)]
pub struct HistoryArgs {
#[clap(long, default_value_t = 50, value_parser = clap::value_parser!(usize))]
pub limit: usize,
}
pub async fn dispatch(
engine: &'static DatabaseEngine,
selectors: Selectors,
action: Action,
) -> Result<()> {
let Selectors {
service,
environment,
project,
json,
} = selectors;
crate::util::reporter::set_mode(json);
if let Action::History(history_args) = &action {
return history(engine, history_args, json);
}
let started = std::time::Instant::now();
let result = match action {
Action::Ha(sub) => {
ha::command(
engine,
sub,
project.clone(),
service.clone(),
environment.clone(),
json,
)
.await
}
Action::Pitr(sub) => {
pitr::command(
engine,
sub,
project.clone(),
service.clone(),
environment.clone(),
json,
)
.await
}
Action::Pooling(sub) => {
pool::command(
engine,
sub,
project.clone(),
service.clone(),
environment.clone(),
json,
)
.await
}
Action::History(_) => unreachable!("handled above"),
};
let result = result.map_err(add_api_mismatch_guidance);
let (project, environment, service) =
resolved_selectors_for_log(project, service, environment).await;
ops_log::record(
engine,
&ops_log::OpsLogEntry {
timestamp: chrono::Utc::now(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
args: std::env::args().skip(1).collect(),
project,
environment,
service,
success: result.is_ok(),
error: result.as_ref().err().map(|e| {
let message = format!("{e:#}");
if message.len() > 512 {
message[..512].to_string()
} else {
message
}
}),
duration_ms: started.elapsed().as_millis() as u64,
},
);
result
}
async fn resolved_selectors_for_log(
project: Option<String>,
service: Option<String>,
environment: Option<String>,
) -> (Option<String>, Option<String>, Option<String>) {
if project.is_some() && environment.is_some() && service.is_some() {
return (project, environment, service);
}
let linked = match crate::config::Configs::new() {
Ok(configs) => configs.get_linked_project().await.ok(),
Err(_) => None,
};
(
project.or_else(|| linked.as_ref().map(|l| l.project.clone())),
environment.or_else(|| linked.as_ref().and_then(|l| l.environment.clone())),
service.or_else(|| linked.as_ref().and_then(|l| l.service.clone())),
)
}
fn history(engine: &DatabaseEngine, args: &HistoryArgs, json: bool) -> Result<()> {
let entries = ops_log::read_entries(engine);
let start = entries.len().saturating_sub(args.limit);
let window = &entries[start..];
if json {
println!("{}", serde_json::to_string_pretty(window)?);
return Ok(());
}
if window.is_empty() {
println!(
"No {} operations recorded yet (the trail lives at {}).",
engine.key,
ops_log::log_path(engine)
.map(|p| p.display().to_string())
.unwrap_or_else(|| format!("~/.railway/{}-ops.jsonl", engine.key)),
);
return Ok(());
}
println!(
"{:<21} {:<7} {:<9} {:<37} COMMAND",
"WHEN (UTC)", "OUTCOME", "DURATION", "PROJECT/SERVICE"
);
for entry in window {
let outcome = if entry.success {
"ok".green().to_string()
} else {
"FAIL".red().to_string()
};
let target = format!(
"{}/{}",
entry.project.as_deref().unwrap_or("-"),
entry.service.as_deref().unwrap_or("-")
);
let target = if target.len() > 37 {
format!("{}…", &target[..36])
} else {
target
};
println!(
"{:<21} {:<7} {:<9} {:<37} railway {}",
entry.timestamp.format("%Y-%m-%d %H:%M:%S"),
outcome,
format!("{}ms", entry.duration_ms),
target,
entry.args.join(" ")
);
if let Some(error) = &entry.error {
println!("{:<40} {}", "", error.lines().next().unwrap_or("").red());
}
}
Ok(())
}
const UPGRADE_REQUIRED_MARKERS: &[&str] = &[
"update your railway cli",
"upgrade your railway cli",
"update the railway cli",
"upgrade the railway cli",
"newer version of the railway cli",
"railway cli is out of date",
];
fn is_schema_mismatch_message(lower_chain: &str) -> bool {
lower_chain.contains("cannot query field")
|| lower_chain.contains("is not defined by type")
|| lower_chain.contains("unknown argument")
|| lower_chain.contains("unknown field")
}
pub(crate) fn add_api_mismatch_guidance(err: anyhow::Error) -> anyhow::Error {
let lower_chain = format!("{err:#}").to_ascii_lowercase();
if UPGRADE_REQUIRED_MARKERS
.iter()
.any(|marker| lower_chain.contains(marker))
{
return err.context(
"The Railway API requires a newer CLI for this command. Update with `railway upgrade` (or your package manager) and try again.",
);
}
if is_schema_mismatch_message(&lower_chain) {
return err.context(
"This CLI build no longer matches the Railway API -- an operation this command depends on is missing or has changed. Update with `railway upgrade` and try again; if the latest CLI still fails, the operation may have been removed (check the Railway changelog).",
);
}
err
}
pub(crate) fn confirm_or_bail(message: &str, yes: bool) -> Result<bool> {
if yes {
return Ok(true);
}
if std::io::stdout().is_terminal() {
prompt_confirm_with_default(message, false)
} else {
bail!(
"Cannot prompt for confirmation in non-interactive mode. Use --yes to skip confirmation."
);
}
}
pub(crate) fn service_name_map(ctx: &ServiceContext) -> BTreeMap<String, String> {
ctx.project
.services
.edges
.iter()
.map(|edge| (edge.node.id.clone(), edge.node.name.clone()))
.collect()
}
pub(crate) struct RootContext {
pub root_id: String,
pub root_name: String,
}
pub(crate) const FIELD_LABEL_WIDTH: usize = 20;
pub(crate) fn print_field(label: &str, value: &dyn std::fmt::Display) {
let padded = format!("{label:<FIELD_LABEL_WIDTH$}");
println!("{} {value}", padded.dimmed());
}
pub(crate) fn status_label(enabled: bool) -> colored::ColoredString {
if enabled {
"enabled".green().bold()
} else {
"disabled".yellow().bold()
}
}
pub(crate) fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
pub(crate) fn resolve_root(ctx: &ServiceContext, config: &EnvironmentConfig) -> RootContext {
let root_id = database_plugins::resolve_root_service_id(config, &ctx.service_id);
let root_name = if root_id == ctx.service_id {
ctx.service_name.clone()
} else {
service_name_map(ctx)
.get(&root_id)
.cloned()
.unwrap_or_else(|| root_id.clone())
};
RootContext { root_id, root_name }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_mismatch_guidance_translates_missing_field_validation_errors() {
let err = anyhow::anyhow!(
"Cannot query field \"volumeInstanceBackupCreateForHaConversion\" on type \"Mutation\"."
)
.context("Failed to enable PITR");
let wrapped = add_api_mismatch_guidance(err);
assert!(format!("{wrapped:#}").contains("railway upgrade"));
let err = anyhow::anyhow!(
"Variable \"$input\" got invalid value; Field \"stageOnly\" is not defined by type \"TemplateDeployV2Input\"."
);
let wrapped = add_api_mismatch_guidance(err);
assert!(format!("{wrapped:#}").contains("no longer matches the Railway API"));
}
#[test]
fn api_mismatch_guidance_surfaces_explicit_upgrade_user_errors() {
let err = anyhow::anyhow!(
"This operation has moved. Please update your Railway CLI to continue managing PITR."
)
.context("Failed to enable PITR");
let wrapped = add_api_mismatch_guidance(err);
let rendered = format!("{wrapped:#}");
assert!(rendered.contains("requires a newer CLI"));
assert!(rendered.contains("railway upgrade"));
assert!(rendered.contains("This operation has moved"));
}
#[test]
fn api_mismatch_guidance_passes_unrelated_errors_through() {
let err = anyhow::anyhow!("Problem processing request").context("Failed to enable PITR");
let before = format!("{err:#}");
let after = format!("{:#}", add_api_mismatch_guidance(err));
assert_eq!(before, after);
let err = anyhow::anyhow!("connection reset by peer");
let after = format!("{:#}", add_api_mismatch_guidance(err));
assert_eq!(after, "connection reset by peer");
}
}