Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/tower-cmd/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,10 @@ pub async fn update_schedule(
let params = tower_api::apis::default_api::UpdateScheduleParams {
id_or_name: schedule_id.to_string(),
update_schedule_params: tower_api::models::UpdateScheduleParams {
cron: cron.map(|s| s.clone()),
schema: None,
cron: cron.cloned(),
environment: None,
app_version: None,
parameters: run_parameters,
..Default::default()
},
Expand Down
207 changes: 188 additions & 19 deletions crates/tower-cmd/src/schedules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,26 @@ pub fn schedules_cmd() -> Command {
)
.subcommand(
Command::new("delete")
.allow_external_subcommands(true)
.arg(
Arg::new("schedule_id")
.required(true)
.value_parser(value_parser!(String))
.help("The schedule ID to delete")
.action(clap::ArgAction::Set),
Comment on lines +75 to +79
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah this is a good reminder that we need to support deleting schedules by name 😄

)
.override_usage("tower schedules delete [OPTIONS] <SCHEDULE_ID>")
.after_help("Example: tower schedules delete 123")
.about("Delete a schedule"),
)
.subcommand(
Command::new("update")
.arg(
Arg::new("schedule_id")
.required(true)
.value_parser(value_parser!(String))
.help("The schedule ID to update")
.action(clap::ArgAction::Set),
Comment on lines +88 to +92
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This actually can be by id or by name now 😄 https://docs.tower.dev/docs/reference/api/update-schedule. With name, you have to have --environment.

We have a lot of confusion about parameters with our users. People get confused by the differences in how we accept name/ID parameters:

tower schedules create --name=my-schedule --environmnet=production --cron="*/5 * * * *"

and

tower schedules update my-schedule --environmnet=production --cron="*/5 * * * *"

Something for us to address...

)
.arg(
Arg::new("cron")
.short('c')
Expand All @@ -93,7 +106,6 @@ pub fn schedules_cmd() -> Command {
.help("Parameters (key=value) to pass to the app")
.action(clap::ArgAction::Append),
)
.allow_external_subcommands(true)
.override_usage("tower schedules update [OPTIONS] <SCHEDULE_ID>")
.after_help("Example: tower schedules update 123 --cron \"*/15 * * * *\"")
.about("Update an existing schedule"),
Expand Down Expand Up @@ -164,43 +176,31 @@ 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 schedule_id = args.get_one::<String>("schedule_id").unwrap();
let cron = args.get_one::<String>("cron");
let parameters = parse_parameters(args);

output::with_spinner(
"Updating schedule",
api::update_schedule(&config, &schedule_id, cron, parameters),
api::update_schedule(&config, schedule_id, cron, parameters),
)
.await;

output::success(&format!("Schedule {} updated", schedule_id));
}

pub async fn do_delete(config: Config, args: &ArgMatches) {
let schedule_id = extract_schedule_id("delete", args.subcommand());
let schedule_id = args.get_one::<String>("schedule_id").unwrap();

output::with_spinner(
"Deleting schedule",
api::delete_schedule(&config, &schedule_id),
api::delete_schedule(&config, schedule_id),
)
.await;

output::success(&format!("Schedule {} deleted", schedule_id));
}

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 {} <schedule-id>",
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<HashMap<String, String>> {
Expand Down Expand Up @@ -228,8 +228,177 @@ fn parse_parameters(args: &ArgMatches) -> Option<HashMap<String, String>> {
}
}

Some(param_map)
if param_map.is_empty() {
None
} else {
Some(param_map)
}
} else {
None
}
}

#[cfg(test)]
mod tests {
use super::{parse_parameters, schedules_cmd};

#[test]
fn update_accepts_positional_schedule_id_and_flags() {
let matches = schedules_cmd()
.try_get_matches_from([
"schedules",
"update",
"sch_123",
"--cron",
"*/10 * * * *",
"--parameter",
"env=prod",
"-p",
"team=platform",
])
.expect("update args should parse");

let ("update", update_args) = matches.subcommand().expect("expected update subcommand")
else {
panic!("expected update subcommand");
};

assert_eq!(
update_args
.get_one::<String>("schedule_id")
.map(String::as_str),
Some("sch_123")
);
assert_eq!(
update_args.get_one::<String>("cron").map(String::as_str),
Some("*/10 * * * *")
);

let params: Vec<&str> = update_args
.get_many::<String>("parameters")
.expect("expected parameters")
.map(String::as_str)
.collect();
assert_eq!(params, vec!["env=prod", "team=platform"]);
}

#[test]
fn update_accepts_equals_sign_flag_forms() {
let matches = schedules_cmd()
.try_get_matches_from([
"schedules",
"update",
"sch_456",
"--cron=*/5 * * * *",
"--parameter=region=us-east-1",
])
.expect("equals-form args should parse");

let ("update", update_args) = matches.subcommand().expect("expected update subcommand")
else {
panic!("expected update subcommand");
};

assert_eq!(
update_args
.get_one::<String>("schedule_id")
.map(String::as_str),
Some("sch_456")
);
assert_eq!(
update_args.get_one::<String>("cron").map(String::as_str),
Some("*/5 * * * *")
);
assert_eq!(
update_args
.get_many::<String>("parameters")
.expect("expected parameter")
.next()
.map(String::as_str),
Some("region=us-east-1")
);
}

#[test]
fn update_requires_schedule_id() {
let result =
schedules_cmd().try_get_matches_from(["schedules", "update", "--cron", "*/15 * * * *"]);
assert!(result.is_err());
}

#[test]
fn delete_requires_schedule_id() {
let result = schedules_cmd().try_get_matches_from(["schedules", "delete"]);
assert!(result.is_err());
}

#[test]
fn parse_parameters_valid_pairs() {
let matches = schedules_cmd()
.try_get_matches_from([
"schedules",
"update",
"sch_789",
"--parameter",
"env=prod",
"-p",
"team=platform",
])
.expect("update args should parse");

let ("update", update_args) = matches.subcommand().expect("expected update subcommand")
else {
panic!("expected update subcommand");
};

let params = parse_parameters(update_args).expect("expected parsed parameters");
assert_eq!(params.get("env"), Some(&"prod".to_string()));
assert_eq!(params.get("team"), Some(&"platform".to_string()));
}

#[test]
fn parse_parameters_invalid_entries_return_none() {
let matches = schedules_cmd()
.try_get_matches_from([
"schedules",
"update",
"sch_789",
"--parameter",
"invalid",
"-p",
"=missing-key",
])
.expect("update args should parse");

let ("update", update_args) = matches.subcommand().expect("expected update subcommand")
else {
panic!("expected update subcommand");
};

assert_eq!(parse_parameters(update_args), None);
}

#[test]
fn parse_parameters_mixed_valid_and_invalid_keeps_valid() {
let matches = schedules_cmd()
.try_get_matches_from([
"schedules",
"update",
"sch_789",
"--parameter",
"env=prod",
"-p",
"invalid",
])
.expect("update args should parse");

let ("update", update_args) = matches.subcommand().expect("expected update subcommand")
else {
panic!("expected update subcommand");
};

let params = parse_parameters(update_args).expect("expected parsed parameters");
assert_eq!(params.get("env"), Some(&"prod".to_string()));
assert_eq!(params.len(), 1);
}
}