From 9407a0690cc4b5dd88fb5c27d55dfd730e7b5d0f Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Tue, 16 Sep 2025 15:29:26 +0200 Subject: [PATCH 1/5] First pass at manage schedules on the CLI --- crates/tower-cmd/src/api.rs | 128 ++++++++++++++++ crates/tower-cmd/src/lib.rs | 16 ++ crates/tower-cmd/src/schedules.rs | 233 ++++++++++++++++++++++++++++++ 3 files changed, 377 insertions(+) create mode 100644 crates/tower-cmd/src/schedules.rs diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index b47a63bb..03898173 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -8,6 +8,7 @@ use tokio::sync::mpsc; use reqwest_eventsource::{Event, EventSource}; use tower_api::apis::configuration; use futures_util::StreamExt; +use tower_api::models::RunParameter; /// Helper trait to extract the successful response data from API responses pub trait ResponseEntity { @@ -571,3 +572,130 @@ impl ResponseEntity for tower_api::apis::default_api::DescribeRunSuccess { } } } + +pub async fn list_schedules(config: &Config, _app_name: Option<&str>, _environment: Option<&str>) -> Result> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::ListSchedulesParams { + page: None, + page_size: None, + }; + + unwrap_api_response(tower_api::apis::default_api::list_schedules(api_config, params)).await +} + +pub async fn create_schedule( + config: &Config, + app_name: &str, + environment: &str, + cron: &str, + parameters: Option>, +) -> Result> { + let api_config = &config.into(); + + let run_parameters = parameters.map(|params| { + params + .into_iter() + .map(|(key, value)| RunParameter { name: key, value }) + .collect() + }); + + let params = tower_api::apis::default_api::CreateScheduleParams { + create_schedule_params: tower_api::models::CreateScheduleParams { + schema: None, + app_name: app_name.to_string(), + cron: cron.to_string(), + environment: Some(environment.to_string()), + app_version: None, + parameters: run_parameters, + }, + }; + + unwrap_api_response(tower_api::apis::default_api::create_schedule(api_config, params)).await +} + +pub async fn update_schedule( + config: &Config, + schedule_id: &str, + cron: Option<&String>, + parameters: Option>, +) -> Result> { + let api_config = &config.into(); + + let run_parameters = parameters.map(|params| { + params + .into_iter() + .map(|(key, value)| RunParameter { name: key, value }) + .collect() + }); + + let params = tower_api::apis::default_api::UpdateScheduleParams { + id: schedule_id.to_string(), + update_schedule_params: tower_api::models::UpdateScheduleParams { + schema: None, + cron: cron.map(|s| s.clone()), + environment: None, + app_version: None, + parameters: run_parameters, + }, + }; + + unwrap_api_response(tower_api::apis::default_api::update_schedule(api_config, params)).await +} + +pub async fn delete_schedule(config: &Config, schedule_id: &str) -> Result> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::DeleteScheduleParams { + delete_schedule_params: tower_api::models::DeleteScheduleParams { + schema: None, + ids: vec![schedule_id.to_string()], + }, + }; + + unwrap_api_response(tower_api::apis::default_api::delete_schedule(api_config, params)).await +} + +impl ResponseEntity for tower_api::apis::default_api::ListSchedulesSuccess { + type Data = tower_api::models::ListSchedulesResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(data) => Some(data), + Self::UnknownValue(_) => None, + } + } +} + +impl ResponseEntity for tower_api::apis::default_api::CreateScheduleSuccess { + type Data = tower_api::models::CreateScheduleResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status201(data) => Some(data), + Self::UnknownValue(_) => None, + } + } +} + +impl ResponseEntity for tower_api::apis::default_api::UpdateScheduleSuccess { + type Data = tower_api::models::UpdateScheduleResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(data) => Some(data), + Self::UnknownValue(_) => None, + } + } +} + +impl ResponseEntity for tower_api::apis::default_api::DeleteScheduleSuccess { + type Data = tower_api::models::DeleteScheduleResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(data) => Some(data), + Self::UnknownValue(_) => None, + } + } +} diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index b34cb323..940cb630 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -7,6 +7,7 @@ pub mod output; pub mod api; pub mod error; mod run; +mod schedules; mod secrets; mod session; mod teams; @@ -118,6 +119,20 @@ impl App { } } } + Some(("schedules", sub_matches)) => { + let schedules_command = sub_matches.subcommand(); + + match schedules_command { + Some(("list", args)) => schedules::do_list(sessionized_config, args).await, + Some(("create", args)) => schedules::do_create(sessionized_config, args).await, + Some(("update", args)) => schedules::do_update(sessionized_config, args).await, + Some(("delete", args)) => schedules::do_delete(sessionized_config, args).await, + _ => { + schedules::schedules_cmd().print_help().unwrap(); + std::process::exit(2); + } + } + } Some(("deploy", args)) => deploy::do_deploy(sessionized_config, args).await, Some(("run", args)) => run::do_run(sessionized_config, args, args.subcommand()).await, Some(("teams", sub_matches)) => { @@ -162,6 +177,7 @@ fn root_cmd() -> Command { .arg_required_else_help(false) .subcommand(session::login_cmd()) .subcommand(apps::apps_cmd()) + .subcommand(schedules::schedules_cmd()) .subcommand(secrets::secrets_cmd()) .subcommand(deploy::deploy_cmd()) .subcommand(run::run_cmd()) diff --git a/crates/tower-cmd/src/schedules.rs b/crates/tower-cmd/src/schedules.rs new file mode 100644 index 00000000..d2c540b0 --- /dev/null +++ b/crates/tower-cmd/src/schedules.rs @@ -0,0 +1,233 @@ +use clap::{value_parser, Arg, ArgMatches, Command}; +use colored::Colorize; +use config::Config; +use std::collections::HashMap; + +use crate::{ + output, + api, +}; + +pub fn schedules_cmd() -> Command { + Command::new("schedules") + .about("Manage schedules for your Tower apps") + .arg_required_else_help(true) + .subcommand( + Command::new("list") + .arg( + Arg::new("app") + .short('a') + .long("app") + .value_parser(value_parser!(String)) + .help("Filter schedules by app name") + .action(clap::ArgAction::Set), + ) + .arg( + Arg::new("environment") + .short('e') + .long("environment") + .value_parser(value_parser!(String)) + .help("Filter schedules by environment") + .action(clap::ArgAction::Set), + ) + .about("List all schedules"), + ) + .subcommand( + Command::new("create") + .arg( + Arg::new("app") + .short('a') + .long("app") + .value_parser(value_parser!(String)) + .required(true) + .help("The name of the app to schedule") + .action(clap::ArgAction::Set), + ) + .arg( + Arg::new("environment") + .short('e') + .long("environment") + .value_parser(value_parser!(String)) + .required(true) + .help("The environment to run the app in") + .action(clap::ArgAction::Set), + ) + .arg( + Arg::new("cron") + .short('c') + .long("cron") + .value_parser(value_parser!(String)) + .required(true) + .help("The cron expression defining when the app should run") + .action(clap::ArgAction::Set), + ) + .arg( + Arg::new("parameters") + .short('p') + .long("parameters") + .value_parser(value_parser!(String)) + .help("Parameters to pass when running the app (JSON format)") + .action(clap::ArgAction::Set), + ) + .about("Create a new schedule for an app"), + ) + .subcommand( + Command::new("delete") + .allow_external_subcommands(true) + .about("Delete a schedule"), + ) + .subcommand( + Command::new("update") + .arg( + Arg::new("cron") + .short('c') + .long("cron") + .value_parser(value_parser!(String)) + .help("The cron expression defining when the app should run") + .action(clap::ArgAction::Set), + ) + .arg( + Arg::new("parameters") + .short('p') + .long("parameters") + .value_parser(value_parser!(String)) + .help("Parameters to pass when running the app (JSON format)") + .action(clap::ArgAction::Set), + ) + .allow_external_subcommands(true) + .about("Update an existing schedule"), + ) +} + +pub async fn do_list(config: Config, args: &ArgMatches) { + let app = args.get_one::("app").map(|s| s.as_str()); + let environment = args.get_one::("environment").map(|s| s.as_str()); + + match api::list_schedules(&config, app, environment).await { + Ok(response) => { + if response.schedules.is_empty() { + output::write("No schedules found.\n"); + return; + } + + let headers = vec![ + "ID".yellow().to_string(), + "App".yellow().to_string(), + "Environment".yellow().to_string(), + "Cron".yellow().to_string(), + ]; + + let rows: Vec> = response + .schedules + .iter() + .map(|schedule| { + vec![ + schedule.id.clone(), + schedule.app_name.clone(), + schedule.environment.clone(), + schedule.cron.clone(), + ] + }) + .collect(); + + output::table(headers, rows); + } + Err(err) => { + output::tower_error(err); + } + } +} + +pub async fn do_create(config: Config, args: &ArgMatches) { + let app_name = args.get_one::("app").unwrap(); + let environment = args.get_one::("environment").unwrap(); + let cron = args.get_one::("cron").unwrap(); + let parameters_str = args.get_one::("parameters"); + + let parameters = if let Some(params_str) = parameters_str { + match serde_json::from_str::>(params_str) { + Ok(params) => Some(params), + Err(_) => { + output::die("Invalid parameters JSON format. Expected object with string key-value pairs."); + } + } + } else { + None + }; + + let mut spinner = output::spinner("Creating schedule"); + + match api::create_schedule(&config, app_name, environment, cron, parameters).await { + Ok(response) => { + spinner.success(); + output::success(&format!( + "Schedule created with ID: {}", + response.schedule.id + )); + } + Err(err) => { + spinner.failure(); + output::tower_error(err); + } + } +} + +pub async fn do_update(config: Config, args: &ArgMatches) { + let schedule_id = extract_schedule_id("update", args.subcommand()); + let cron = args.get_one::("cron"); + let parameters_str = args.get_one::("parameters"); + + if cron.is_none() { + output::die("You must specify a cron string (--cron) for this schedule"); + } + + // Validate the parameters to send to the server + let parameters = if let Some(params_str) = parameters_str { + match serde_json::from_str::>(params_str) { + Ok(params) => Some(params), + Err(_) => { + output::die("Invalid parameters JSON format. Expected object with string key-value pairs."); + } + } + } else { + None + }; + + let mut spinner = output::spinner("Updating schedule"); + + match api::update_schedule(&config, &schedule_id, cron, parameters).await { + Ok(_) => { + spinner.success(); + output::success(&format!("Schedule {} updated", schedule_id)); + } + Err(err) => { + spinner.failure(); + output::tower_error(err); + } + } +} + +pub async fn do_delete(config: Config, args: &ArgMatches) { + let schedule_id = extract_schedule_id("delete", args.subcommand()); + let mut spinner = output::spinner("Deleting schedule"); + + match api::delete_schedule(&config, &schedule_id).await { + Ok(_) => { + spinner.success(); + output::success(&format!("Schedule {} deleted", schedule_id)); + } + Err(err) => { + spinner.failure(); + output::tower_error(err); + } + } +} + +fn extract_schedule_id(subcmd: &str, cmd: Option<(&str, &ArgMatches)>) -> String { + if let Some((id, _)) = cmd { + return id.to_string(); + } + + let line = format!("Schedule ID is required. Example: tower schedules {} ", subcmd); + output::die(&line); +} From 99cb8ff6578447d684bd27880817cfb6b0e96bc0 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Wed, 17 Sep 2025 11:13:23 +0200 Subject: [PATCH 2/5] Make `cron` optional when updating a schedule --- crates/tower-cmd/src/schedules.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/tower-cmd/src/schedules.rs b/crates/tower-cmd/src/schedules.rs index d2c540b0..95999fa7 100644 --- a/crates/tower-cmd/src/schedules.rs +++ b/crates/tower-cmd/src/schedules.rs @@ -177,10 +177,6 @@ pub async fn do_update(config: Config, args: &ArgMatches) { let cron = args.get_one::("cron"); let parameters_str = args.get_one::("parameters"); - if cron.is_none() { - output::die("You must specify a cron string (--cron) for this schedule"); - } - // Validate the parameters to send to the server let parameters = if let Some(params_str) = parameters_str { match serde_json::from_str::>(params_str) { From 396137f12e0ae6c04fa0d8ca2a5b325dd2ad14b5 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Wed, 17 Sep 2025 11:16:18 +0200 Subject: [PATCH 3/5] chore: Make `default` environment the...default...for schedules --- crates/tower-cmd/src/schedules.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tower-cmd/src/schedules.rs b/crates/tower-cmd/src/schedules.rs index 95999fa7..8fb9dde5 100644 --- a/crates/tower-cmd/src/schedules.rs +++ b/crates/tower-cmd/src/schedules.rs @@ -48,7 +48,7 @@ pub fn schedules_cmd() -> Command { .short('e') .long("environment") .value_parser(value_parser!(String)) - .required(true) + .default_value("default") .help("The environment to run the app in") .action(clap::ArgAction::Set), ) From 4c4d5e226d6619ce4d1b40b3075841d9b586e73c Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Wed, 17 Sep 2025 11:24:01 +0200 Subject: [PATCH 4/5] Reintegrate status for some types --- crates/tower-cmd/src/api.rs | 2 ++ crates/tower-cmd/src/schedules.rs | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index 03898173..479f812b 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -608,6 +608,7 @@ pub async fn create_schedule( environment: Some(environment.to_string()), app_version: None, parameters: run_parameters, + status: None, }, }; @@ -637,6 +638,7 @@ pub async fn update_schedule( environment: None, app_version: None, parameters: run_parameters, + status: None, }, }; diff --git a/crates/tower-cmd/src/schedules.rs b/crates/tower-cmd/src/schedules.rs index 8fb9dde5..a34ecc85 100644 --- a/crates/tower-cmd/src/schedules.rs +++ b/crates/tower-cmd/src/schedules.rs @@ -8,6 +8,8 @@ use crate::{ api, }; +use tower_api::models::schedule::Status; + pub fn schedules_cmd() -> Command { Command::new("schedules") .about("Manage schedules for your Tower apps") @@ -115,17 +117,24 @@ pub async fn do_list(config: Config, args: &ArgMatches) { "App".yellow().to_string(), "Environment".yellow().to_string(), "Cron".yellow().to_string(), + "Status".yellow().to_string(), ]; let rows: Vec> = response .schedules .iter() .map(|schedule| { + let status = match schedule.status { + Status::Active => "active".green(), + Status::Disabled => "disabled".red(), + }; + vec![ schedule.id.clone(), schedule.app_name.clone(), schedule.environment.clone(), schedule.cron.clone(), + status.to_string(), ] }) .collect(); From 7f0ad0c3e41ae5d70653d825343dd6df34a1ad92 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Wed, 17 Sep 2025 11:39:15 +0200 Subject: [PATCH 5/5] Handle parameters the same as in runs --- crates/tower-cmd/src/schedules.rs | 75 +++++++++++++++++-------------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/crates/tower-cmd/src/schedules.rs b/crates/tower-cmd/src/schedules.rs index a34ecc85..90ba385a 100644 --- a/crates/tower-cmd/src/schedules.rs +++ b/crates/tower-cmd/src/schedules.rs @@ -66,10 +66,9 @@ pub fn schedules_cmd() -> Command { .arg( Arg::new("parameters") .short('p') - .long("parameters") - .value_parser(value_parser!(String)) - .help("Parameters to pass when running the app (JSON format)") - .action(clap::ArgAction::Set), + .long("parameter") + .help("Parameters (key=value) to pass to the app") + .action(clap::ArgAction::Append), ) .about("Create a new schedule for an app"), ) @@ -91,10 +90,9 @@ pub fn schedules_cmd() -> Command { .arg( Arg::new("parameters") .short('p') - .long("parameters") - .value_parser(value_parser!(String)) - .help("Parameters to pass when running the app (JSON format)") - .action(clap::ArgAction::Set), + .long("parameter") + .help("Parameters (key=value) to pass to the app") + .action(clap::ArgAction::Append), ) .allow_external_subcommands(true) .about("Update an existing schedule"), @@ -151,18 +149,7 @@ pub async fn do_create(config: Config, args: &ArgMatches) { let app_name = args.get_one::("app").unwrap(); let environment = args.get_one::("environment").unwrap(); let cron = args.get_one::("cron").unwrap(); - let parameters_str = args.get_one::("parameters"); - - let parameters = if let Some(params_str) = parameters_str { - match serde_json::from_str::>(params_str) { - Ok(params) => Some(params), - Err(_) => { - output::die("Invalid parameters JSON format. Expected object with string key-value pairs."); - } - } - } else { - None - }; + let parameters = parse_parameters(args); let mut spinner = output::spinner("Creating schedule"); @@ -184,20 +171,7 @@ pub async fn do_create(config: Config, args: &ArgMatches) { pub async fn do_update(config: Config, args: &ArgMatches) { let schedule_id = extract_schedule_id("update", args.subcommand()); let cron = args.get_one::("cron"); - let parameters_str = args.get_one::("parameters"); - - // Validate the parameters to send to the server - let parameters = if let Some(params_str) = parameters_str { - match serde_json::from_str::>(params_str) { - Ok(params) => Some(params), - Err(_) => { - output::die("Invalid parameters JSON format. Expected object with string key-value pairs."); - } - } - } else { - None - }; - + let parameters = parse_parameters(args); let mut spinner = output::spinner("Updating schedule"); match api::update_schedule(&config, &schedule_id, cron, parameters).await { @@ -236,3 +210,36 @@ fn extract_schedule_id(subcmd: &str, cmd: Option<(&str, &ArgMatches)>) -> String let line = format!("Schedule ID is required. Example: tower schedules {} ", subcmd); output::die(&line); } + +/// Parses `--parameter` arguments into a HashMap of key-value pairs. +/// Handles format like "--parameter key=value" +fn parse_parameters(args: &ArgMatches) -> Option> { + let mut param_map = HashMap::new(); + + if let Some(parameters) = args.get_many::("parameters") { + for param in parameters { + match param.split_once('=') { + Some((key, value)) => { + if key.is_empty() { + output::failure(&format!( + "Invalid parameter format: '{}'. Key cannot be empty.", + param + )); + continue; + } + param_map.insert(key.to_string(), value.to_string()); + } + None => { + output::failure(&format!( + "Invalid parameter format: '{}'. Expected 'key=value'.", + param + )); + } + } + } + + Some(param_map) + } else { + None + } +}