Add deployment command with subcommands for list, up and redeploy (#673)

* Fix

* Fix

* Add env arg

* local time
This commit is contained in:
Michael Hadley 2025-09-26 14:22:46 -05:00 committed by GitHub
parent f71df85dc5
commit 280f2d74ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 214 additions and 10 deletions

199
src/commands/deployment.rs Normal file
View file

@ -0,0 +1,199 @@
use super::*;
use crate::client::post_graphql;
use crate::controllers::environment::get_matched_environment;
use crate::controllers::project::{ensure_project_and_environment_exist, get_project};
use crate::gql::queries::deployments::{DeploymentStatus, ResponseData, Variables};
use chrono::{DateTime, Local, Utc};
use serde::Serialize;
/// Manage deployments
#[derive(Parser)]
pub struct Args {
#[clap(subcommand)]
command: Commands,
}
// Aliasing some of our root commands that should be "deployment"
// subcommands. This allows us to deprecate the root commands without
// breaking existing workflows.
#[derive(Parser)]
enum Commands {
/// List deployments for a service with IDs, statuses and other metadata
#[clap(alias = "ls")]
List(ListArgs),
/// Upload and deploy project from the current directory
Up(crate::commands::up::Args),
/// Redeploy the latest deployment of a service
Redeploy(crate::commands::redeploy::Args),
}
#[derive(Parser)]
struct ListArgs {
/// Service name or ID to list deployments for (defaults to linked service)
#[clap(short, long)]
service: Option<String>,
/// Environment to list deployments from (defaults to linked environment)
#[clap(short, long)]
environment: Option<String>,
/// Maximum number of deployments to show (default: 20, max: 1000)
#[clap(long, default_value = "20")]
limit: i64,
/// Output in JSON format
#[clap(long)]
json: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DeploymentOutput {
id: String,
status: String,
created_at: DateTime<Utc>,
meta: Option<serde_json::Value>,
}
pub async fn command(args: Args) -> Result<()> {
match args.command {
Commands::List(list_args) => {
list_deployments(
list_args.service,
list_args.environment,
list_args.limit,
list_args.json,
)
.await
}
Commands::Up(deploy_args) => {
// Call the existing up command implementation
crate::commands::up::command(deploy_args).await
}
Commands::Redeploy(redeploy_args) => {
// Call the existing redeploy command implementation
crate::commands::redeploy::command(redeploy_args).await
}
}
}
async fn list_deployments(
service: Option<String>,
environment: Option<String>,
limit: i64,
json: bool,
) -> 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 limit = if limit > 1000 {
eprintln!("Warning: limit cannot exceed 1000, using 1000 instead");
1000
} else if limit < 1 {
eprintln!("Warning: limit must be at least 1, using 1 instead");
1
} else {
limit
};
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
let environment = environment.unwrap_or(linked_project.environment.clone());
let environment_id = get_matched_environment(&project, environment)?.id;
let service_id = if let Some(service_name_or_id) = service {
let service = project
.services
.edges
.iter()
.find(|s| {
s.node.name.to_lowercase() == service_name_or_id.to_lowercase()
|| s.node.id == service_name_or_id
})
.ok_or_else(|| anyhow::anyhow!("Service '{}' not found", service_name_or_id))?;
service.node.id.clone()
} else if let Some(linked_service_id) = linked_project.service {
linked_service_id
} else {
bail!("No service specified and no service linked. Use 'railway link' to link a service or specify one with the service argument.");
};
let variables = Variables {
input: crate::gql::queries::deployments::DeploymentListInput {
service_id: Some(service_id.clone()),
environment_id: Some(environment_id),
project_id: None,
status: None,
include_deleted: None,
},
first: Some(limit),
};
let response: ResponseData = post_graphql::<crate::gql::queries::Deployments, _>(
&client,
configs.get_backboard(),
variables,
)
.await?;
let deployments = response
.deployments
.edges
.into_iter()
.map(|edge| edge.node)
.collect::<Vec<_>>();
if json {
let output: Vec<DeploymentOutput> = deployments
.into_iter()
.map(|d| DeploymentOutput {
id: d.id,
status: format!("{:?}", d.status),
created_at: d.created_at,
meta: d.meta,
})
.collect();
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
if deployments.is_empty() {
println!("No deployments found");
return Ok(());
}
println!("{}", "Recent Deployments".bold());
for deployment in deployments {
let status_colored = match deployment.status {
DeploymentStatus::SUCCESS => format!("{:?}", deployment.status).green(),
DeploymentStatus::FAILED | DeploymentStatus::CRASHED => {
format!("{:?}", deployment.status).red()
}
DeploymentStatus::BUILDING
| DeploymentStatus::DEPLOYING
| DeploymentStatus::INITIALIZING
| DeploymentStatus::WAITING
| DeploymentStatus::QUEUED => format!("{:?}", deployment.status).blue(),
DeploymentStatus::REMOVED | DeploymentStatus::REMOVING => {
format!("{:?}", deployment.status).dimmed()
}
_ => format!("{:?}", deployment.status).white(),
};
// Convert UTC time to local timezone
let local_time: DateTime<Local> = DateTime::from(deployment.created_at);
let created_at = local_time.format("%Y-%m-%d %H:%M:%S %Z");
println!(
" {} | {} | {}",
deployment.id,
status_colored,
created_at.to_string().dimmed()
);
}
}
Ok(())
}

View file

@ -67,6 +67,7 @@ pub async fn command(args: Args) -> Result<()> {
include_deleted: None, include_deleted: None,
status: None, status: None,
}, },
first: None,
}; };
let linked_project_name = linked_project let linked_project_name = linked_project

View file

@ -107,6 +107,7 @@ pub async fn command(args: Args) -> Result<()> {
include_deleted: None, include_deleted: None,
status: None, status: None,
}, },
first: None,
}; };
let deployments = let deployments =
post_graphql::<queries::Deployments, _>(&client, configs.get_backboard(), vars) post_graphql::<queries::Deployments, _>(&client, configs.get_backboard(), vars)

View file

@ -1,5 +1,5 @@
pub(super) use crate::{client::*, config::*, gql::*}; pub(super) use crate::{client::*, config::*, gql::*};
pub(super) use anyhow::{Context, Result}; pub(super) use anyhow::{bail, Context, Result};
pub(super) use clap::{Parser, Subcommand}; pub(super) use clap::{Parser, Subcommand};
pub(super) use colored::Colorize; pub(super) use colored::Colorize;
@ -12,6 +12,7 @@ pub mod add;
pub mod completion; pub mod completion;
pub mod connect; pub mod connect;
pub mod deploy; pub mod deploy;
pub mod deployment;
pub mod docs; pub mod docs;
pub mod domain; pub mod domain;
pub mod down; pub mod down;

View file

@ -1,11 +1,12 @@
query Deployments($input: DeploymentListInput!) { query Deployments($input: DeploymentListInput!, $first: Int) {
deployments(input: $input) { deployments(input: $input, first: $first) {
edges { edges {
node { node {
id id
createdAt createdAt
status status
} meta
} }
} }
} }
}

View file

@ -29,6 +29,7 @@ commands!(
completion, completion,
connect, connect,
deploy, deploy,
deployment,
domain, domain,
docs, docs,
down, down,