From b6303fe681c480bd8a07333a121487ac63977186 Mon Sep 17 00:00:00 2001 From: jzeuzs <75403863+jzeuzs@users.noreply.github.com> Date: Sun, 9 Nov 2025 16:30:49 +0800 Subject: [PATCH] Add a command to restart a deployment --- src/commands/logs.rs | 30 ++-- src/commands/mod.rs | 1 + src/commands/restart.rs | 141 ++++++++++++++++++ src/commands/ssh/common.rs | 38 +++-- src/commands/ssh/mod.rs | 2 +- src/controllers/terminal/connection.rs | 9 +- src/gql/mod.rs | 2 + src/gql/mutations/mod.rs | 9 ++ .../strings/DeploymentRestart.graphql | 3 + src/main.rs | 1 + src/util/prompt.rs | 2 +- src/util/retry.rs | 4 +- 12 files changed, 196 insertions(+), 46 deletions(-) create mode 100644 src/commands/restart.rs create mode 100644 src/gql/mutations/strings/DeploymentRestart.graphql diff --git a/src/commands/logs.rs b/src/commands/logs.rs index a8eec68ec..19af7b906 100644 --- a/src/commands/logs.rs +++ b/src/commands/logs.rs @@ -153,23 +153,21 @@ pub async fn command(args: Args) -> Result<()> { ) .await?; } + } else if should_stream { + stream_deploy_logs(deployment_id.clone(), args.filter.clone(), |log| { + print_log(log, args.json, true) // Deploy logs use formatted output + }) + .await?; } else { - if should_stream { - stream_deploy_logs(deployment_id.clone(), args.filter.clone(), |log| { - print_log(log, args.json, true) // Deploy logs use formatted output - }) - .await?; - } else { - fetch_deploy_logs( - &client, - &configs.get_backboard(), - deployment_id.clone(), - args.lines.or(Some(500)), - args.filter.clone(), - |log| print_log(log, args.json, true), // Deploy logs use formatted output - ) - .await?; - } + fetch_deploy_logs( + &client, + &configs.get_backboard(), + deployment_id.clone(), + args.lines.or(Some(500)), + args.filter.clone(), + |log| print_log(log, args.json, true), // Deploy logs use formatted output + ) + .await?; } Ok(()) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 6c8bebba3..0138c50af 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -26,6 +26,7 @@ pub mod logout; pub mod logs; pub mod open; pub mod redeploy; +pub mod restart; pub mod run; pub mod scale; pub mod service; diff --git a/src/commands/restart.rs b/src/commands/restart.rs new file mode 100644 index 000000000..1bf72d940 --- /dev/null +++ b/src/commands/restart.rs @@ -0,0 +1,141 @@ +use colored::*; +use std::time::Duration; + +use crate::{ + consts::TICK_STRING, + controllers::project::{ensure_project_and_environment_exist, get_project}, + errors::RailwayError, + util::prompt::prompt_confirm_with_default, +}; + +use super::*; +use anyhow::{anyhow, bail}; + +/// Restart (no image pull) the latest deployment of a service and wait for healthchecks +#[derive(Parser)] +pub struct Args { + /// The service ID/name to restart + #[clap(long, short)] + service: Option, + + /// Skip confirmation dialog + #[clap(short = 'y', long = "yes")] + bypass: bool, +} + +pub async fn command(args: Args) -> Result<()> { + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + let linked_project = configs.get_linked_project().await?; + + ensure_project_and_environment_exist(&client, &configs, &linked_project).await?; + + let project = get_project(&client, &configs, linked_project.project.clone()).await?; + + let service_id = args.service.or_else(|| linked_project.service.clone()).ok_or_else(|| anyhow!("No service found. Please link one via `railway link` or specify one via the `--service` flag."))?; + let service = project + .services + .edges + .iter() + .find(|s| { + s.node.id == service_id || s.node.name.to_lowercase() == service_id.to_lowercase() + }) + .ok_or_else(|| anyhow!(RailwayError::ServiceNotFound(service_id)))?; + + let service_in_env = service + .node + .service_instances + .edges + .iter() + .find(|a| a.node.environment_id == linked_project.environment) + .ok_or_else(|| anyhow!("The service specified doesn't exist in the current environment"))?; + + if let Some(ref latest) = service_in_env.node.latest_deployment { + if latest.can_redeploy { + if !args.bypass { + let env_name = linked_project + .environment_name + .clone() + .unwrap_or("unknown".to_string()); + + let confirmed = prompt_confirm_with_default( + format!( + "Restart the container for service {} in environment {}?", + service.node.name, env_name + ) + .as_str(), + false, + )?; + + if !confirmed { + return Ok(()); + } + } + + let spinner = indicatif::ProgressBar::new_spinner() + .with_style( + indicatif::ProgressStyle::default_spinner() + .tick_chars(TICK_STRING) + .template("{spinner:.green} {msg}")?, + ) + .with_message(format!("Restarting service {}...", service.node.name)); + spinner.enable_steady_tick(Duration::from_millis(100)); + + // Call restart mutation + post_graphql::( + &client, + configs.get_backboard(), + mutations::deployment_restart::Variables { + id: latest.id.clone(), + }, + ) + .await?; + + // Wait for healthchecks via latest deployment status + let max_wait = Duration::from_secs(300); + let poll_interval = Duration::from_secs(2); + let start = std::time::Instant::now(); + loop { + if start.elapsed() > max_wait { + spinner.finish_and_clear(); + bail!("Timed out waiting for health checks after restart"); + } + + let resp = post_graphql::( + &client, + configs.get_backboard(), + queries::latest_deployment::Variables { + service_id: service.node.id.clone(), + environment_id: linked_project.environment.clone(), + }, + ) + .await?; + + let si = resp.service_instance; + if let Some(ld) = si.latest_deployment { + match ld.status { + queries::latest_deployment::DeploymentStatus::SUCCESS => { + spinner.finish_with_message(format!( + "Restart successful for service {}", + service.node.name.green() + )); + return Ok(()); + } + queries::latest_deployment::DeploymentStatus::FAILED + | queries::latest_deployment::DeploymentStatus::CRASHED => { + spinner.finish_and_clear(); + bail!("Restart completed but health checks failed"); + } + _ => {} + } + } + + tokio::time::sleep(poll_interval).await; + } + } + } else { + bail!("No deployment found for service") + } + + Ok(()) +} diff --git a/src/commands/ssh/common.rs b/src/commands/ssh/common.rs index c0ab968bd..29bb88259 100644 --- a/src/commands/ssh/common.rs +++ b/src/commands/ssh/common.rs @@ -1,6 +1,5 @@ use std::io::Cursor; -use anyhow::bail; use anyhow::{anyhow, Context, Result}; use indicatif::ProgressBar; use reqwest::Client; @@ -76,7 +75,7 @@ pub async fn find_service_by_name( project: &RailwayProject, service_id_or_name: &str, ) -> Result { - let project = get_project(&client, &configs, project.id.clone()).await?; + let project = get_project(client, configs, project.id.clone()).await?; let services = project.services.edges.iter().collect::>(); @@ -92,7 +91,7 @@ pub async fn find_service_by_name( .id .to_owned(); - return Ok(service); + Ok(service) } pub async fn get_ssh_connect_params( @@ -105,31 +104,28 @@ pub async fn get_ssh_connect_params( let has_environment = args.environment.is_some(); let linked_project = configs.get_linked_project().await?; - let project_id; - if has_project { - project_id = args.project.unwrap(); + let project_id = if has_project { + args.project.unwrap() } else { - project_id = linked_project.project.clone(); - } - let project = get_project(client, configs, project_id.clone()).await?; + linked_project.project.clone() + }; - let environment; - if has_environment { - environment = args.environment.unwrap(); + let project = get_project(client, configs, project_id.clone()).await?; + let environment = if has_environment { + args.environment.unwrap() } else { - environment = linked_project.environment.clone(); - } - let environment_id = get_matched_environment(&project, environment)?.id; + linked_project.environment.clone() + }; - let service_id; - if has_service { + let environment_id = get_matched_environment(&project, environment)?.id; + let service_id = if has_service { let service_id_or_name = args.service.unwrap(); - service_id = find_service_by_name(&client, &configs, &project, &service_id_or_name).await? + find_service_by_name(client, configs, &project, &service_id_or_name).await? } else { - service_id = get_or_prompt_service(linked_project.clone(), project, None) + get_or_prompt_service(linked_project.clone(), project, None) .await? - .ok_or_else(|| anyhow!("No service found. Please specify a service to connect to via the `--service` flag."))?; - } + .ok_or_else(|| anyhow!("No service found. Please specify a service to connect to via the `--service` flag."))? + }; Ok(SSHConnectParams { project_id, diff --git a/src/commands/ssh/mod.rs b/src/commands/ssh/mod.rs index 8562ed962..7c66588de 100644 --- a/src/commands/ssh/mod.rs +++ b/src/commands/ssh/mod.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use clap::Parser; use indicatif::ProgressBar; diff --git a/src/controllers/terminal/connection.rs b/src/controllers/terminal/connection.rs index 8cae15c93..79de7b32e 100644 --- a/src/controllers/terminal/connection.rs +++ b/src/controllers/terminal/connection.rs @@ -6,13 +6,10 @@ use indicatif::ProgressBar; use tokio::time::{sleep, timeout, Duration}; use url::Url; -use crate::consts::get_user_agent; -use crate::{ - commands::ssh::{ - AuthKind, SSH_CONNECTION_TIMEOUT_SECS, SSH_CONNECT_DELAY_SECS, SSH_MAX_CONNECT_ATTEMPTS, - }, - errors::RailwayError, +use crate::commands::ssh::{ + AuthKind, SSH_CONNECTION_TIMEOUT_SECS, SSH_CONNECT_DELAY_SECS, SSH_MAX_CONNECT_ATTEMPTS, }; +use crate::consts::get_user_agent; #[derive(Clone, Debug)] pub struct SSHConnectParams { diff --git a/src/gql/mod.rs b/src/gql/mod.rs index 9f27eeefb..411b610f4 100644 --- a/src/gql/mod.rs +++ b/src/gql/mod.rs @@ -1,3 +1,5 @@ +#![allow(unused_imports, dead_code)] + pub mod mutations; pub mod queries; pub mod subscriptions; diff --git a/src/gql/mutations/mod.rs b/src/gql/mutations/mod.rs index 7f1341373..ab8ec3caa 100644 --- a/src/gql/mutations/mod.rs +++ b/src/gql/mutations/mod.rs @@ -136,6 +136,15 @@ pub struct VolumeAttach; )] pub struct DeploymentRedeploy; +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "src/gql/schema.json", + query_path = "src/gql/mutations/strings/DeploymentRestart.graphql", + response_derives = "Debug, Serialize, Clone", + skip_serializing_none +)] +pub struct DeploymentRestart; + #[derive(GraphQLQuery)] #[graphql( schema_path = "src/gql/schema.json", diff --git a/src/gql/mutations/strings/DeploymentRestart.graphql b/src/gql/mutations/strings/DeploymentRestart.graphql new file mode 100644 index 000000000..79a1f88b4 --- /dev/null +++ b/src/gql/mutations/strings/DeploymentRestart.graphql @@ -0,0 +1,3 @@ +mutation DeploymentRestart($id: String!) { + deploymentRestart(id: $id) +} diff --git a/src/main.rs b/src/main.rs index dab510dc1..656aeafcd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,7 @@ commands!( whoami, volume, redeploy, + restart, scale, check_updates, functions(function, func, fn, funcs, fns) diff --git a/src/util/prompt.rs b/src/util/prompt.rs index ec9d33a51..8c52fe3e9 100644 --- a/src/util/prompt.rs +++ b/src/util/prompt.rs @@ -160,7 +160,7 @@ pub struct PathAutocompleter; impl PathAutocompleter { /// Parse input path and extract directory and filename prefix - fn parse_input(input: &str) -> (Cow, Cow) { + fn parse_input(input: &'_ str) -> (Cow<'_, Path>, Cow<'_, str>) { if input.is_empty() { return (Cow::Borrowed(Path::new(".")), Cow::Borrowed("")); } diff --git a/src/util/retry.rs b/src/util/retry.rs index f86419800..7ad70af91 100644 --- a/src/util/retry.rs +++ b/src/util/retry.rs @@ -3,12 +3,14 @@ use std::future::Future; use std::time::Duration; use tokio::time::sleep; +type OnRetryFn = Option>; + pub struct RetryConfig { pub max_attempts: u32, pub initial_delay_ms: u64, pub max_delay_ms: u64, pub backoff_multiplier: f64, - pub on_retry: Option>, + pub on_retry: OnRetryFn, } impl Default for RetryConfig {