use std::{path::PathBuf, time::Duration};
use anyhow::{Result, bail};
use futures::StreamExt;
use indicatif::{ProgressBar, ProgressFinish, ProgressStyle};
use is_terminal::IsTerminal;
use crate::{
consts::TICK_STRING,
controllers::{
deployment::{stream_build_logs, stream_deploy_logs},
environment::get_matched_environment,
project::get_project,
service::get_or_prompt_service,
upload::{create_deploy_tarball, upload_deploy_tarball},
},
subscription::subscribe_graphql,
subscriptions::deployment::DeploymentStatus,
util::logs::{LogFormat, print_log},
};
use super::*;
#[derive(Parser)]
pub struct Args {
path: Option<PathBuf>,
#[clap(short, long)]
detach: bool,
#[clap(short, long)]
ci: bool,
#[clap(short, long)]
service: Option<String>,
#[clap(short, long)]
environment: Option<String>,
#[clap(short = 'p', long, value_name = "PROJECT_ID")]
project: Option<String>,
#[clap(long)]
no_gitignore: bool,
#[clap(long)]
path_as_root: bool,
#[clap(long)]
verbose: bool,
#[clap(long)]
json: bool,
#[clap(short, long)]
message: Option<String>,
}
pub async fn command(args: Args) -> Result<()> {
let configs = Configs::new()?;
let hostname = configs.get_host();
let client = GQLClient::new_authorized(&configs)?;
if args.project.is_some() && args.environment.is_none() {
bail!("--environment is required when using --project");
}
let linked_project = if args.project.is_none() {
Some(configs.get_linked_project().await?)
} else {
None
};
let linked_project_path = linked_project.as_ref().map(|lp| lp.project_path.clone());
let deploy_paths = get_deploy_paths(&args, linked_project_path)?;
let project_id = args
.project
.clone()
.or_else(|| linked_project.as_ref().map(|lp| lp.project.clone()))
.ok_or_else(|| {
anyhow::anyhow!("No project specified. Use --project or run `railway link` first")
})?;
let project = get_project(&client, &configs, project_id.clone()).await?;
let environment = args
.environment
.clone()
.or_else(|| {
linked_project
.as_ref()
.and_then(|lp| lp.environment.clone())
})
.ok_or_else(|| {
anyhow::anyhow!(
"No environment specified. Set RAILWAY_ENVIRONMENT_ID, use --environment, or run `railway environment` to link one."
)
})?;
let environment_id = get_matched_environment(&project, environment)?.id;
let service = get_or_prompt_service(linked_project, project, args.service).await?;
let is_tty = std::io::stdout().is_terminal() && !args.json;
let spinner = if is_tty {
let spinner = ProgressBar::new_spinner()
.with_style(
ProgressStyle::default_spinner()
.tick_chars(TICK_STRING)
.template("{spinner:.green} {msg:.cyan.bold}")?,
)
.with_message("Indexing");
spinner.enable_steady_tick(Duration::from_millis(100));
Some(spinner)
} else if !args.json {
println!("Indexing...");
None
} else {
None
};
let mut progress_bar: Option<ProgressBar> = None;
let body = create_deploy_tarball(
&deploy_paths.project_path,
&deploy_paths.archive_prefix_path,
args.no_gitignore,
|current, total| {
if current == 0 {
if let Some(s) = &spinner {
s.finish_with_message("Indexed");
}
if is_tty {
let pg = ProgressBar::new(total as u64)
.with_style(
ProgressStyle::default_bar()
.template(
"{spinner:.green} {msg:.cyan.bold} [{bar:20}] {percent}% ",
)
.unwrap()
.progress_chars("=> ")
.tick_chars(TICK_STRING),
)
.with_message("Compressing")
.with_finish(ProgressFinish::WithMessage("Compressed".into()));
pg.enable_steady_tick(Duration::from_millis(100));
progress_bar = Some(pg);
}
} else if let Some(pg) = &progress_bar {
pg.inc(1);
}
},
)?;
drop(progress_bar);
if args.verbose {
println!("railway up");
println!("service: {}", service.as_deref().unwrap_or_default());
println!("environment: {environment_id}");
println!("bytes: {}", body.len());
}
let spinner = if std::io::stdout().is_terminal() && !args.json {
let spinner = ProgressBar::new_spinner()
.with_style(
ProgressStyle::default_spinner()
.tick_chars(TICK_STRING)
.template("{spinner:.green} {msg:.cyan.bold}")?,
)
.with_message("Uploading");
spinner.enable_steady_tick(Duration::from_millis(100));
Some(spinner)
} else if !args.json {
println!("Uploading...");
None
} else {
None
};
let up_result = upload_deploy_tarball(
&client,
hostname,
&project_id,
&environment_id,
service.as_deref(),
args.message.as_deref(),
body,
)
.await;
let body = match up_result {
Err(e) => {
if let Some(spinner) = spinner {
spinner.finish_with_message("Failed");
}
return Err(e);
}
Ok(body) => {
if let Some(spinner) = spinner {
spinner.finish_with_message("Uploaded");
}
body
}
};
let deployment_id = body.deployment_id;
if !args.json {
println!(" {}: {}", "Build Logs".green().bold(), body.logs_url);
}
if args.detach {
if args.json {
println!(
"{}",
serde_json::json!({"deploymentId": deployment_id, "logsUrl": body.logs_url})
);
}
return Ok(());
}
let ci_mode = Configs::env_is_ci() || args.ci || args.json;
if ci_mode && !args.json {
println!("{}", "CI mode enabled".green().bold());
}
if !std::io::stdout().is_terminal() && !ci_mode {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(500)).await;
let build_deployment_id = deployment_id.clone();
let json_mode = args.json;
let ci_flag = args.ci;
let mut tasks = vec![tokio::task::spawn(async move {
if let Err(e) = stream_build_logs(build_deployment_id, None, |log| {
let should_exit =
ci_flag && log.message.starts_with("No changed files matched patterns");
if json_mode {
print_log(log, true, LogFormat::LevelOnly);
} else {
println!("{}", log.message);
}
if should_exit {
std::process::exit(0);
}
})
.await
{
eprintln!("Failed to stream build logs: {e}");
if ci_mode {
std::process::exit(1);
}
}
})];
if !ci_mode {
let deploy_deployment_id = deployment_id.clone();
tasks.push(tokio::task::spawn(async move {
if let Err(e) = stream_deploy_logs(deploy_deployment_id, None, |log| {
print_log(log, false, LogFormat::Full)
})
.await
{
eprintln!("Failed to stream deploy logs: {e}");
}
}));
}
let mut stream =
subscribe_graphql::<subscriptions::Deployment>(subscriptions::deployment::Variables {
id: deployment_id.clone(),
})
.await?;
tokio::task::spawn(async move {
while let Some(Ok(res)) = stream.next().await {
if let Some(errors) = res.errors {
if json_mode {
eprintln!(
"{}",
serde_json::json!({"error": errors.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("; ")})
);
} else {
eprintln!(
"Failed to get deploy status: {}",
errors
.iter()
.map(|err| err.to_string())
.collect::<Vec<String>>()
.join("; ")
);
}
if ci_mode {
std::process::exit(1);
}
}
if let Some(data) = res.data {
match data.deployment.status {
DeploymentStatus::SUCCESS => {
if json_mode {
println!("{}", serde_json::json!({"status": "success"}));
} else {
println!("{}", "Deploy complete".green().bold());
}
if ci_mode {
std::process::exit(0);
}
}
DeploymentStatus::FAILED => {
if json_mode {
println!("{}", serde_json::json!({"status": "failed"}));
} else {
println!("{}", "Deploy failed".red().bold());
}
std::process::exit(1);
}
DeploymentStatus::CRASHED => {
if json_mode {
println!("{}", serde_json::json!({"status": "crashed"}));
} else {
println!("{}", "Deploy crashed".red().bold());
}
std::process::exit(1);
}
_ => {}
}
}
}
});
futures::future::join_all(tasks).await;
Ok(())
}
struct DeployPaths {
project_path: PathBuf,
archive_prefix_path: PathBuf,
}
fn get_deploy_paths(args: &Args, linked_project_path: Option<String>) -> Result<DeployPaths> {
if args.path_as_root {
if args.path.is_none() {
bail!("--path-as-root requires a path to be specified");
}
let path = args.path.clone().unwrap();
Ok(DeployPaths {
project_path: path.clone(),
archive_prefix_path: path,
})
} else {
let project_dir: PathBuf = match linked_project_path {
Some(path) => PathBuf::from(path),
None => std::env::current_dir().context("Failed to get current directory")?,
};
let project_path = match args.path {
Some(ref path) => path.clone(),
None => project_dir.clone(),
};
Ok(DeployPaths {
project_path,
archive_prefix_path: project_dir,
})
}
}