diff --git a/Cargo.lock b/Cargo.lock index 193617b9..db472e19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -528,6 +528,15 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -596,6 +605,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.9.1", + "crossterm_winapi", + "derive_more 2.1.1", + "document-features", + "mio", + "parking_lot", + "rustix 1.0.8", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto" version = "0.3.65" @@ -672,7 +708,7 @@ dependencies = [ "console", "cucumber-codegen", "cucumber-expressions", - "derive_more", + "derive_more 0.99.20", "drain_filter_polyfill", "either", "futures", @@ -712,7 +748,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d794fed319eea24246fb5f57632f7ae38d61195817b7eb659455aa5bdd7c1810" dependencies = [ - "derive_more", + "derive_more 0.99.20", "either", "nom", "nom_locate", @@ -822,6 +858,28 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.104", +] + [[package]] name = "digest" version = "0.10.7" @@ -892,6 +950,15 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "drain_filter_polyfill" version = "0.1.3" @@ -1131,6 +1198,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1573,6 +1649,20 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags 2.9.1", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "unicode-segmentation", + "unicode-width 0.2.1", +] + [[package]] name = "inventory" version = "0.3.21" @@ -1741,6 +1831,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.13" @@ -1831,6 +1927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -2632,6 +2729,15 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -2799,6 +2905,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -2973,6 +3085,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.5" @@ -3586,6 +3719,7 @@ dependencies = [ "futures-util", "http", "indicatif", + "inquire", "promptly", "reqwest", "reqwest-eventsource", diff --git a/Cargo.toml b/Cargo.toml index dd3620cd..38cb7ab5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } url = { version = "2", features = ["serde"] } webbrowser = "1" +inquire = "0.9.4" # The profile that 'dist' will build with [profile.dist] diff --git a/crates/tower-cmd/Cargo.toml b/crates/tower-cmd/Cargo.toml index cb6a5f4f..dac7d31b 100644 --- a/crates/tower-cmd/Cargo.toml +++ b/crates/tower-cmd/Cargo.toml @@ -39,6 +39,7 @@ schemars = "1.0" toml = { workspace = true } toml_edit = { workspace = true } tracing-subscriber = { workspace = true } +inquire = { workspace = true } [dev-dependencies] tempfile = "3.12" diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index a61eb0be..bdc16909 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -1010,6 +1010,17 @@ impl ResponseEntity for tower_api::apis::default_api::DescribeRunSuccess { } } +impl ResponseEntity for tower_api::apis::default_api::DescribeEnvironmentSuccess { + type Data = tower_api::models::DescribeEnvironmentResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(resp) => Some(resp), + Self::UnknownValue(_) => None, + } + } +} + impl ResponseEntity for tower_api::apis::default_api::ListEnvironmentsSuccess { type Data = tower_api::models::ListEnvironmentsResponse; @@ -1021,6 +1032,17 @@ impl ResponseEntity for tower_api::apis::default_api::ListEnvironmentsSuccess { } } +impl ResponseEntity for tower_api::apis::default_api::DeleteEnvironmentSuccess { + type Data = tower_api::models::DeleteEnvironmentResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(resp) => Some(resp), + Self::UnknownValue(_) => None, + } + } +} + pub async fn list_environments( config: &Config, ) -> Result< @@ -1045,6 +1067,25 @@ pub async fn list_environments( .await } +pub async fn describe_environment( + config: &Config, + name: &str, +) -> Result< + tower_api::models::DescribeEnvironmentResponse, + Error, +> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::DescribeEnvironmentParams { + name: name.to_string(), + }; + + unwrap_api_response(tower_api::apis::default_api::describe_environment( + api_config, params, + )) + .await +} + pub async fn create_environment( config: &Config, name: &str, @@ -1067,6 +1108,25 @@ pub async fn create_environment( .await } +pub async fn delete_environment( + config: &Config, + name: &str, +) -> Result< + tower_api::models::DeleteEnvironmentResponse, + Error, +> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::DeleteEnvironmentParams { + name: name.to_string(), + }; + + unwrap_api_response(tower_api::apis::default_api::delete_environment( + api_config, params, + )) + .await +} + pub async fn list_schedules( config: &Config, app_name: Option<&str>, diff --git a/crates/tower-cmd/src/environments.rs b/crates/tower-cmd/src/environments.rs index a9dfad37..87e2b1fb 100644 --- a/crates/tower-cmd/src/environments.rs +++ b/crates/tower-cmd/src/environments.rs @@ -1,13 +1,32 @@ use clap::{value_parser, Arg, ArgMatches, Command}; use config::Config; +use tower_api::models::DescribeEnvironmentResponse; -use crate::{api, output}; +use crate::{ + api, output, + util::{ + prompt, + text::{join_with_and, pluralize}, + }, +}; pub fn environments_cmd() -> Command { Command::new("environments") .about("Manage the environments in your current Tower account") .arg_required_else_help(true) .subcommand(Command::new("list").about("List all of your environments")) + .subcommand( + Command::new("delete") + .arg( + Arg::new("name") + .short('n') + .long("name") + .value_parser(value_parser!(String)) + .required(true) + .action(clap::ArgAction::Set), + ) + .about("Delete an environment"), + ) .subcommand( Command::new("create") .arg( @@ -50,3 +69,68 @@ pub async fn do_create(config: Config, args: &ArgMatches) { output::success(&format!("Environment '{}' created", name)); } + +fn env_resources_description(env: DescribeEnvironmentResponse) -> Option { + if env.number_catalogs > 0 || env.number_schedules > 0 || env.number_secrets > 0 { + let resources: Vec<_> = vec![ + (env.number_catalogs, "catalog"), + (env.number_schedules, "schedule"), + (env.number_secrets, "secret"), + ] + .into_iter() + .filter(|(count, _)| *count > 0) + .map(|(count, noun)| format!("{} {}", count, pluralize(noun, count, None))) + .collect(); + + return Some(join_with_and(resources)); + } + + return None; +} + +pub async fn do_delete(config: Config, args: &ArgMatches) { + let name = args.get_one::("name").unwrap_or_else(|| { + output::die("Environment name (--name) is required"); + }); + + let env = output::with_spinner( + "Retrieving environment...", + api::describe_environment(&config, name), + ) + .await; + + if !env.environment.is_deletable { + output::error(&format!("You cannot delete the {name} environment.")); + return; + } + + if let Some(desc) = env_resources_description(env) { + output::write(&format!("Warning! Your environment contains {desc}.\n")) + } + + let ans = prompt::confirm( + &format!("Are you sure you want to delete your {name} environment?"), + false, + ); + + match ans { + Ok(true) => { + output::with_spinner( + &format!("Deleting environment {name}"), + api::delete_environment(&config, name), + ) + .await; + + output::success(&format!("Environment '{name}' deleted")); + } + Ok(false) => output::write("Aborting environment deletion.\n"), + Err(prompt::Error::ConfirmationPromptCancelled) => { + output::write("Aborting environment deletion.\n") + } + Err(_) => { + output::error( + "Something went wrong. Please try again, and contact us if the issue persists.", + ); + } + } +} diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index ea7204ab..649de82f 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -170,6 +170,9 @@ impl App { Some(("create", args)) => { environments::do_create(sessionized_config, args).await } + Some(("delete", args)) => { + environments::do_delete(sessionized_config, args).await + } _ => { environments::environments_cmd().print_help().unwrap(); } diff --git a/crates/tower-cmd/src/util/mod.rs b/crates/tower-cmd/src/util/mod.rs index f086032f..3ce9f5bc 100644 --- a/crates/tower-cmd/src/util/mod.rs +++ b/crates/tower-cmd/src/util/mod.rs @@ -3,3 +3,5 @@ pub mod cmd; pub mod dates; pub mod deploy; pub mod progress; +pub mod prompt; +pub mod text; diff --git a/crates/tower-cmd/src/util/prompt.rs b/crates/tower-cmd/src/util/prompt.rs new file mode 100644 index 00000000..b9a220ae --- /dev/null +++ b/crates/tower-cmd/src/util/prompt.rs @@ -0,0 +1,26 @@ +use inquire::{ + Confirm, + InquireError::{OperationCanceled, OperationInterrupted}, +}; +use snafu::Snafu; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Something went wrong while attempting to confirm"))] + ConfirmationPromptError, + + ConfirmationPromptCancelled, +} + +// Create a confirmation prompt with `message` and the default value `default`. +pub fn confirm(message: &str, default: bool) -> Result { + match Confirm::new(message).with_default(default).prompt() { + Ok(response) => Ok(response), + + // Treat cancelled (esc) and interrupted (ctrl+c) the same in this context. + Err(OperationCanceled | OperationInterrupted) => Err(Error::ConfirmationPromptCancelled), + + // Any other error just treat as a generic error. + Err(_) => Err(Error::ConfirmationPromptError), + } +} diff --git a/crates/tower-cmd/src/util/text.rs b/crates/tower-cmd/src/util/text.rs new file mode 100644 index 00000000..10589696 --- /dev/null +++ b/crates/tower-cmd/src/util/text.rs @@ -0,0 +1,63 @@ +pub fn join_with_and(eles: Vec) -> String { + match eles.len() { + 0 | 1 | 2 => { + return eles.join(" and "); + } + l => { + let butlast: Vec<_> = eles.iter().cloned().take(l - 1).collect(); + let last = eles.iter().last().unwrap(); + + return format!("{}, and {}", butlast.join(", "), last); + } + } +} + +pub fn pluralize(noun: &str, count: i64, plural: Option<&str>) -> String { + match count { + 1 => noun.to_string(), + _ => match plural { + Some(plural) => plural.to_string(), + None => format!("{noun}s"), + }, + } +} + +#[cfg(test)] +mod tests { + use super::{join_with_and, pluralize}; + + #[test] + fn test_join_with_and() { + assert_eq!(join_with_and(vec![]), ""); + assert_eq!(join_with_and(vec!["lisa".to_owned()]), "lisa"); + assert_eq!( + join_with_and(vec!["lisa".to_owned(), "bart".to_owned()]), + "lisa and bart" + ); + assert_eq!( + join_with_and(vec![ + "lisa".to_owned(), + "bart".to_owned(), + "homer".to_owned() + ]), + "lisa, bart, and homer" + ); + assert_eq!( + join_with_and(vec![ + "lisa".to_owned(), + "bart".to_owned(), + "homer".to_owned(), + "marge".to_owned() + ]), + "lisa, bart, homer, and marge" + ); + } + + #[test] + fn test_pluralize() { + assert_eq!(pluralize("foo", 0, None), "foos"); + assert_eq!(pluralize("foo", 1, None), "foo"); + assert_eq!(pluralize("foo", 2, None), "foos"); + assert_eq!(pluralize("foo", 2, Some("bars")), "bars"); + } +}