diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index b47a63bb..479f812b 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,132 @@ 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, + status: None, + }, + }; + + 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, + status: None, + }, + }; + + 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..90ba385a --- /dev/null +++ b/crates/tower-cmd/src/schedules.rs @@ -0,0 +1,245 @@ +use clap::{value_parser, Arg, ArgMatches, Command}; +use colored::Colorize; +use config::Config; +use std::collections::HashMap; + +use crate::{ + output, + api, +}; + +use tower_api::models::schedule::Status; + +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)) + .default_value("default") + .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("parameter") + .help("Parameters (key=value) to pass to the app") + .action(clap::ArgAction::Append), + ) + .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("parameter") + .help("Parameters (key=value) to pass to the app") + .action(clap::ArgAction::Append), + ) + .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(), + "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(); + + 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 = parse_parameters(args); + + 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 = parse_parameters(args); + 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); +} + +/// 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 + } +}