diff --git a/Cargo.lock b/Cargo.lock index 76aef73..51f2b90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -480,7 +480,7 @@ dependencies = [ [[package]] name = "cloud-openapi" version = "0.1.0" -source = "git+https://github.com/fermyon/cloud-openapi?rev=ce1e916110b9a9e59a1171ac364f0b6e23908428#ce1e916110b9a9e59a1171ac364f0b6e23908428" +source = "git+https://github.com/kate-goldenring/cloud-openapi?rev=cd4561ab3dddc6a94ae7a6d8db378a62b0a9782a#cd4561ab3dddc6a94ae7a6d8db378a62b0a9782a" dependencies = [ "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index d7ac25c..938a496 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ openssl = { version = "0.10" } [workspace.dependencies] tracing = { version = "0.1", features = ["log"] } -cloud-openapi = { git = "https://github.com/fermyon/cloud-openapi", rev = "ce1e916110b9a9e59a1171ac364f0b6e23908428" } +cloud-openapi = { git = "https://github.com/kate-goldenring/cloud-openapi", rev = "cd4561ab3dddc6a94ae7a6d8db378a62b0a9782a" } [build-dependencies] vergen = { version = "^8.2.1", default-features = false, features = [ diff --git a/crates/cloud/src/client.rs b/crates/cloud/src/client.rs index 77e9008..90a9990 100644 --- a/crates/cloud/src/client.rs +++ b/crates/cloud/src/client.rs @@ -10,6 +10,11 @@ use cloud_openapi::{ configuration::{ApiKey, Configuration}, device_codes_api::api_device_codes_post, key_value_pairs_api::api_key_value_pairs_post, + key_value_stores_api::{ + api_key_value_stores_get, api_key_value_stores_store_delete, + api_key_value_stores_store_links_delete, api_key_value_stores_store_links_post, + api_key_value_stores_store_post, + }, revisions_api::{api_revisions_get, api_revisions_post}, sql_databases_api::{ api_sql_databases_create_post, api_sql_databases_database_links_delete, @@ -26,8 +31,8 @@ use cloud_openapi::{ CreateDeviceCodeCommand, CreateKeyValuePairCommand, CreateSqlDatabaseCommand, CreateVariablePairCommand, Database, DeleteSqlDatabaseCommand, DeleteVariablePairCommand, DeviceCodeItem, EnvironmentVariableItem, ExecuteSqlStatementCommand, GetAppLogsVm, - GetAppRawLogsVm, GetSqlDatabasesQuery, GetVariablesQuery, RefreshTokenCommand, - RegisterRevisionCommand, ResourceLabel, RevisionItemPage, TokenInfo, + GetAppRawLogsVm, GetSqlDatabasesQuery, GetVariablesQuery, KeyValueStoreItem, + RefreshTokenCommand, RegisterRevisionCommand, ResourceLabel, RevisionItemPage, TokenInfo, }, }; use reqwest::header; @@ -232,6 +237,7 @@ impl CloudClientInterface for Client { .map_err(format_response_error) } + // Key value API methods async fn add_key_value_pair( &self, app_id: Uuid, @@ -242,7 +248,7 @@ impl CloudClientInterface for Client { api_key_value_pairs_post( &self.configuration, CreateKeyValuePairCommand { - app_id, + app_id: Some(app_id), store_name, key, value, @@ -253,6 +259,66 @@ impl CloudClientInterface for Client { .map_err(format_response_error) } + async fn create_key_value_store( + &self, + store_name: &str, + resource_label: Option, + ) -> anyhow::Result<()> { + api_key_value_stores_store_post(&self.configuration, store_name, None, resource_label) + .await + .map_err(format_response_error) + } + + async fn delete_key_value_store(&self, store_name: &str) -> anyhow::Result<()> { + api_key_value_stores_store_delete(&self.configuration, store_name, None) + .await + .map_err(format_response_error) + } + + async fn get_key_value_stores( + &self, + app_id: Option, + ) -> anyhow::Result> { + let list = api_key_value_stores_get( + &self.configuration, + app_id.map(|id| id.to_string()).as_deref(), + None, + ) + .await + .map_err(format_response_error)?; + Ok(list.key_value_stores) + } + + async fn create_key_value_store_link( + &self, + key_value_store: &str, + resource_label: ResourceLabel, + ) -> anyhow::Result<()> { + api_key_value_stores_store_links_post( + &self.configuration, + key_value_store, + resource_label, + None, + ) + .await + .map_err(format_response_error) + } + + async fn remove_key_value_store_link( + &self, + key_value_store: &str, + resource_label: ResourceLabel, + ) -> anyhow::Result<()> { + api_key_value_stores_store_links_delete( + &self.configuration, + key_value_store, + resource_label, + None, + ) + .await + .map_err(format_response_error) + } + async fn add_variable_pair( &self, app_id: Uuid, @@ -335,10 +401,10 @@ impl CloudClientInterface for Client { async fn get_databases(&self, app_id: Option) -> anyhow::Result> { let list = api_sql_databases_get( &self.configuration, - GetSqlDatabasesQuery { - app_id: Some(app_id), - }, + app_id.map(|id| id.to_string()).as_deref(), None, + // TODO: set to None when the API is updated to not require a body + Some(GetSqlDatabasesQuery { app_id: None }), ) .await .map_err(format_response_error)?; diff --git a/crates/cloud/src/client_interface.rs b/crates/cloud/src/client_interface.rs index 663870f..30293df 100644 --- a/crates/cloud/src/client_interface.rs +++ b/crates/cloud/src/client_interface.rs @@ -1,8 +1,8 @@ use anyhow::Result; use async_trait::async_trait; use cloud_openapi::models::{ - AppItem, AppItemPage, Database, DeviceCodeItem, GetAppLogsVm, GetAppRawLogsVm, ResourceLabel, - RevisionItemPage, TokenInfo, + AppItem, AppItemPage, Database, DeviceCodeItem, GetAppLogsVm, GetAppRawLogsVm, + KeyValueStoreItem, ResourceLabel, RevisionItemPage, TokenInfo, }; use std::string::String; @@ -55,6 +55,31 @@ pub trait CloudClientInterface: Send + Sync { value: String, ) -> anyhow::Result<()>; + async fn create_key_value_store( + &self, + store_name: &str, + resource_label: Option, + ) -> anyhow::Result<()>; + + async fn delete_key_value_store(&self, store_name: &str) -> anyhow::Result<()>; + + async fn get_key_value_stores( + &self, + app_id: Option, + ) -> anyhow::Result>; + + async fn create_key_value_store_link( + &self, + key_value_store: &str, + resource_label: ResourceLabel, + ) -> anyhow::Result<()>; + + async fn remove_key_value_store_link( + &self, + key_value_store: &str, + resource_label: ResourceLabel, + ) -> anyhow::Result<()>; + async fn add_variable_pair( &self, app_id: Uuid, diff --git a/src/commands/key_value.rs b/src/commands/key_value.rs new file mode 100644 index 0000000..f574244 --- /dev/null +++ b/src/commands/key_value.rs @@ -0,0 +1,251 @@ +use crate::commands::links_output::{ + print_json, print_table, prompt_delete_resource, ListFormat, ResourceGroupBy, ResourceLinks, + ResourceType, +}; +use crate::commands::{create_cloud_client, CommonArgs}; +use anyhow::{bail, Context, Result}; +use clap::{Parser, ValueEnum}; +use cloud::CloudClientInterface; + +#[derive(Parser, Debug)] +#[clap(about = "Manage Fermyon Cloud key value stores")] +pub enum KeyValueCommand { + /// Create a new key value store + Create(CreateCommand), + /// Delete a key value store + Delete(DeleteCommand), + /// List key value stores + List(ListCommand), +} + +#[derive(Parser, Debug)] +pub struct CreateCommand { + /// The name of the key value store + #[clap(short, long)] + pub name: String, + + #[clap(flatten)] + common: CommonArgs, +} + +#[derive(Parser, Debug)] +pub struct DeleteCommand { + /// The name of the key value store + #[clap(short, long)] + pub name: String, + + /// Skips prompt to confirm deletion of the key value store + #[clap(short = 'y', long = "yes", takes_value = false)] + yes: bool, + + #[clap(flatten)] + common: CommonArgs, +} + +#[derive(Parser, Debug)] +pub struct ListCommand { + /// Filter list by an app + #[clap(short = 'a', long = "app")] + app: Option, + /// Filter list by a key value store + #[clap(short = 's', long = "store")] + store: Option, + /// Grouping strategy of tabular list [default: app] + #[clap(value_enum, short = 'g', long = "group-by")] + group_by: Option, + /// Format of list + #[clap(value_enum, long = "format", default_value = "table")] + format: ListFormat, + #[clap(flatten)] + common: CommonArgs, +} + +#[derive(Debug, Clone, Copy, ValueEnum, Default)] +enum GroupBy { + #[default] + App, + Store, +} + +impl From for ResourceGroupBy { + fn from(group_by: GroupBy) -> Self { + match group_by { + GroupBy::App => Self::App, + GroupBy::Store => Self::Resource(ResourceType::KeyValueStore), + } + } +} + +impl KeyValueCommand { + pub async fn run(&self) -> Result<()> { + match self { + KeyValueCommand::Create(cmd) => { + let client = create_cloud_client(cmd.common.deployment_env_id.as_deref()).await?; + cmd.run(client).await + } + KeyValueCommand::Delete(cmd) => { + let client = create_cloud_client(cmd.common.deployment_env_id.as_deref()).await?; + cmd.run(client).await + } + KeyValueCommand::List(cmd) => { + let client = create_cloud_client(cmd.common.deployment_env_id.as_deref()).await?; + cmd.run(client).await + } + } + } +} + +impl CreateCommand { + pub async fn run(&self, client: impl CloudClientInterface) -> Result<()> { + let list = client + .get_key_value_stores(None) + .await + .with_context(|| format!("Error listing key value stores '{}'", self.name))?; + if list.iter().any(|kv| kv.name == self.name) { + bail!(r#"Key value store "{}" already exists"#, self.name) + } + client + .create_key_value_store(&self.name, None) + .await + .with_context(|| format!("Error creating key value store '{}'", self.name))?; + println!(r#"Key value store "{}" created"#, self.name); + Ok(()) + } +} + +impl DeleteCommand { + pub async fn run(&self, client: impl CloudClientInterface) -> Result<()> { + let list = client + .get_key_value_stores(None) + .await + .with_context(|| format!("Error listing key value stores '{}'", self.name))?; + let kv = list + .iter() + .find(|kv| kv.name == self.name) + .with_context(|| format!("No key value store found with name \"{}\"", self.name))?; + if self.yes || prompt_delete_resource(&self.name, &kv.links, ResourceType::KeyValueStore)? { + client + .delete_key_value_store(&self.name) + .await + .with_context(|| format!("Problem deleting key value store '{}'", self.name))?; + println!("Key value store \"{}\" deleted", self.name); + } + Ok(()) + } +} + +impl ListCommand { + pub async fn run(&self, client: impl CloudClientInterface) -> Result<()> { + if let (ListFormat::Json, Some(_)) = (&self.format, self.group_by) { + bail!("Grouping is not supported with JSON format output") + } + let key_value_stores = client + .get_key_value_stores(None) + .await + .with_context(|| "Error listing key value stores")?; + + if key_value_stores.is_empty() { + println!("No key value stores found"); + return Ok(()); + } + let resource_links = key_value_stores + .into_iter() + .map(|kv| ResourceLinks::new(kv.name, kv.links)) + .collect(); + match self.format { + ListFormat::Json => print_json( + resource_links, + self.app.as_deref(), + ResourceType::KeyValueStore, + ), + ListFormat::Table => print_table( + resource_links, + self.app.as_deref(), + self.group_by.map(Into::into), + ), + } + } +} +#[cfg(test)] +mod key_value_tests { + use super::*; + use cloud::MockCloudClientInterface; + use cloud_openapi::models::KeyValueStoreItem; + + #[tokio::test] + async fn test_create_if_store_already_exists_then_error() -> Result<()> { + let command = CreateCommand { + name: "kv1".to_string(), + common: Default::default(), + }; + let stores = vec![ + KeyValueStoreItem::new("kv1".to_string(), vec![]), + KeyValueStoreItem::new("kv2".to_string(), vec![]), + ]; + + let mut mock = MockCloudClientInterface::new(); + mock.expect_get_key_value_stores() + .return_once(move |_| Ok(stores)); + + let result = command.run(mock).await; + assert_eq!( + result.unwrap_err().to_string(), + r#"Key value store "kv1" already exists"# + ); + Ok(()) + } + + #[tokio::test] + async fn test_create_if_store_does_not_exist_store_is_created() -> Result<()> { + let command = CreateCommand { + name: "kv1".to_string(), + common: Default::default(), + }; + let dbs = vec![KeyValueStoreItem::new("kv2".to_string(), vec![])]; + + let mut mock = MockCloudClientInterface::new(); + mock.expect_get_key_value_stores() + .return_once(move |_| Ok(dbs)); + mock.expect_create_key_value_store() + .withf(move |db, rl| db == "kv1" && rl.is_none()) + .returning(|_, _| Ok(())); + + command.run(mock).await + } + + #[tokio::test] + async fn test_delete_if_store_does_not_exist_then_error() -> Result<()> { + let command = DeleteCommand { + name: "kv1".to_string(), + common: Default::default(), + yes: true, + }; + + let mut mock = MockCloudClientInterface::new(); + mock.expect_get_key_value_stores() + .returning(move |_| Ok(vec![])); + + let result = command.run(mock).await; + assert_eq!( + result.unwrap_err().to_string(), + r#"No key value store found with name "kv1""# + ); + Ok(()) + } + + #[tokio::test] + async fn test_delete_if_store_exists_then_it_is_deleted() -> Result<()> { + let command = DeleteCommand { + name: "kv1".to_string(), + common: Default::default(), + yes: true, + }; + + let mut mock = MockCloudClientInterface::new(); + mock.expect_get_key_value_stores() + .returning(move |_| Ok(vec![KeyValueStoreItem::new("kv1".to_string(), vec![])])); + mock.expect_delete_key_value_store().returning(|_| Ok(())); + + command.run(mock).await + } +} diff --git a/src/commands/link.rs b/src/commands/link.rs index 063bd12..7654ec9 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -1,16 +1,17 @@ +use crate::commands::links_output::{capitalize, find_resource_link, ResourceLinks, ResourceType}; +use crate::commands::{client_and_app_id, CommonArgs}; use anyhow::{Context, Result}; use clap::Parser; use cloud::CloudClientInterface; -use cloud_openapi::models::{Database, ResourceLabel}; +use cloud_openapi::models::ResourceLabel; use uuid::Uuid; -use crate::commands::{client_and_app_id, sqlite::find_database_link, CommonArgs}; - /// Manage how apps and resources are linked together #[derive(Parser, Debug)] pub enum LinkCommand { /// Link an app to a SQLite database Sqlite(SqliteLinkCommand), + KeyValueStore(KeyValueStoreLinkCommand), } #[derive(Parser, Debug)] @@ -27,6 +28,20 @@ pub struct SqliteLinkCommand { database: String, } +#[derive(Parser, Debug)] +pub struct KeyValueStoreLinkCommand { + #[clap(flatten)] + common: CommonArgs, + /// The name by which the application will refer to the key value store + label: String, + #[clap(short = 'a', long = "app")] + /// The app that will be using the key value store + app: String, + /// The key value store that the app will refer to by the label + #[clap(short = 's', long = "key-value-store")] + key_value_store: String, +} + impl LinkCommand { pub async fn run(self) -> Result<()> { match self { @@ -35,91 +50,179 @@ impl LinkCommand { client_and_app_id(cmd.common.deployment_env_id.as_deref(), &cmd.app).await?; cmd.link(client, app_id).await } + Self::KeyValueStore(cmd) => { + let (client, app_id) = + client_and_app_id(cmd.common.deployment_env_id.as_deref(), &cmd.app).await?; + cmd.link(client, app_id).await + } } } } impl SqliteLinkCommand { async fn link(self, client: impl CloudClientInterface, app_id: Uuid) -> Result<()> { - let databases = client + let stores = client .get_databases(None) .await - .context("could not fetch databases")?; - let database = databases.iter().find(|d| d.name == self.database); - if database.is_none() { - anyhow::bail!(r#"Database "{}" does not exist"#, self.database) - } - let databases_for_app = databases + .context("could not fetch key value stores")?; + let resources = stores + .into_iter() + .map(|s| ResourceLinks::new(s.name, s.links)) + .collect::>(); + link( + client, + &self.database, + &self.app, + &self.label, + app_id, + resources, + ResourceType::Database, + ) + .await + } +} + +impl KeyValueStoreLinkCommand { + async fn link(self, client: impl CloudClientInterface, app_id: Uuid) -> Result<()> { + let stores = client + .get_key_value_stores(None) + .await + .context("could not fetch key value stores")?; + let resources = stores .into_iter() - .filter(|d| d.links.iter().any(|l| l.app_id == app_id)) - .collect::>(); - let (this_db, other_dbs): (Vec<&Database>, Vec<&Database>) = databases_for_app - .iter() - .partition(|d| d.name == self.database); - let existing_link_for_database = this_db - .iter() - .find_map(|d| find_database_link(d, &self.label)); - let existing_link_for_other_database = other_dbs - .iter() - .find_map(|d| find_database_link(d, &self.label)); - let success_msg = format!( - r#"Database "{}" is now linked to app "{}" with the label "{}""#, - self.database, self.app, self.label + .map(|s| ResourceLinks::new(s.name, s.links)) + .collect::>(); + link( + client, + &self.key_value_store, + &self.app, + &self.label, + app_id, + resources, + ResourceType::KeyValueStore, + ) + .await + } +} + +async fn link( + client: impl CloudClientInterface, + resource_name: &str, + app: &str, + label: &str, + app_id: Uuid, + resources: Vec, + resource_type: ResourceType, +) -> Result<()> { + let exists = resources.iter().any(|s| s.name == resource_name); + if !exists { + anyhow::bail!( + r#"{} "{}" does not exist"#, + capitalize(&resource_type.to_string()), + resource_name ); - match (existing_link_for_database, existing_link_for_other_database) { - (Some(link), _) => { - anyhow::bail!( - r#"Database "{}" is already linked to app "{}" with the label "{}""#, - link.resource, - link.app_name(), - link.resource_label.label, - ); - } - (_, Some(link)) => { - let prompt = format!( - r#"App "{}"'s "{}" label is currently linked to "{}". Change to link to database "{}" instead?"#, - link.app_name(), - link.resource_label.label, - link.resource, - self.database, - ); - if dialoguer::Confirm::new() - .with_prompt(prompt) - .default(false) - .interact_opt()? - .unwrap_or_default() - { - // TODO: use a relink API to remove any downtime - client - .remove_database_link(&link.resource, link.resource_label) - .await?; - let resource_label = ResourceLabel { - app_id, - label: self.label, - app_name: None, - }; - client - .create_database_link(&self.database, resource_label) - .await?; - println!("{success_msg}"); - } else { - println!("The link has not been updated"); + } + let stores_for_app = resources + .into_iter() + .filter(|s| s.links.iter().any(|l| l.app_id == app_id)) + .collect::>(); + let (this_store, other_stores): (Vec<&ResourceLinks>, Vec<&ResourceLinks>) = + stores_for_app.iter().partition(|d| d.name == resource_name); + let existing_link_for_store = this_store.iter().find_map(|s| find_resource_link(s, label)); + let existing_link_for_other_store = other_stores + .iter() + .find_map(|s| find_resource_link(s, label)); + + let success_msg = format!( + r#"{} "{}" is now linked to app "{}" with the label "{}""#, + capitalize(&resource_type.to_string()), + resource_name, + app, + label + ); + match (existing_link_for_store, existing_link_for_other_store) { + (Some(link), _) => { + anyhow::bail!( + r#"{} "{}" is already linked to app "{}" with the label "{}""#, + capitalize(&resource_type.to_string()), + link.resource, + link.app_name(), + link.resource_label.label, + ); + } + (_, Some(link)) => { + let prompt = format!( + r#"App "{}"'s "{}" label is currently linked to "{}". Change to link to {} "{}" instead?"#, + link.app_name(), + link.resource_label.label, + link.resource, + resource_type, + resource_name, + ); + if dialoguer::Confirm::new() + .with_prompt(prompt) + .default(false) + .interact_opt()? + .unwrap_or_default() + { + match resource_type { + ResourceType::Database => { + client + .remove_database_link(&link.resource, link.resource_label) + .await? + } + ResourceType::KeyValueStore => { + client + .remove_key_value_store_link(&link.resource, link.resource_label) + .await? + } } - } - (None, None) => { + let resource_label = ResourceLabel { app_id, - label: self.label, + label: label.to_string(), app_name: None, }; - client - .create_database_link(&self.database, resource_label) - .await?; + + match resource_type { + ResourceType::Database => { + client + .create_database_link(resource_name, resource_label) + .await? + } + ResourceType::KeyValueStore => { + client + .create_key_value_store_link(resource_name, resource_label) + .await? + } + } println!("{success_msg}"); + } else { + println!("The link has not been updated"); } } - Ok(()) + (None, None) => { + let resource_label = ResourceLabel { + app_id, + label: label.to_string(), + app_name: None, + }; + match resource_type { + ResourceType::Database => { + client + .create_database_link(resource_name, resource_label) + .await? + } + ResourceType::KeyValueStore => { + client + .create_key_value_store_link(resource_name, resource_label) + .await? + } + } + println!("{success_msg}"); + } } + Ok(()) } /// Manage unlinking apps and resources @@ -127,12 +230,23 @@ impl SqliteLinkCommand { pub enum UnlinkCommand { /// Unlink an app from a SQLite database Sqlite(SqliteUnlinkCommand), + /// Unlink an app from a key value store + KeyValueStore(KeyValueStoreUnlinkCommand), } impl UnlinkCommand { pub async fn run(self) -> Result<()> { match self { - Self::Sqlite(cmd) => cmd.unlink().await, + Self::Sqlite(cmd) => { + let (client, app_id) = + client_and_app_id(cmd.common.deployment_env_id.as_deref(), &cmd.app).await?; + cmd.unlink(client, app_id).await + } + Self::KeyValueStore(cmd) => { + let (client, app_id) = + client_and_app_id(cmd.common.deployment_env_id.as_deref(), &cmd.app).await?; + cmd.unlink(client, app_id).await + } } } } @@ -149,36 +263,95 @@ pub struct SqliteUnlinkCommand { } impl SqliteUnlinkCommand { - async fn unlink(self) -> Result<()> { - let (client, app_id) = - client_and_app_id(self.common.deployment_env_id.as_deref(), &self.app).await?; - let (database, label) = client + async fn unlink(self, client: impl CloudClientInterface, app_id: Uuid) -> Result<()> { + let databases = client .get_databases(Some(app_id)) .await - .context("could not fetch databases")? + .context("could not fetch databases")?; + let resources = databases .into_iter() - .find_map(|d| { - d.links - .into_iter() - .find(|l| { - matches!(&l.app_name, Some(app_name) if app_name == &self.app) - && l.label == self.label - }) - .map(|l| (d.name, l)) - }) - .with_context(|| { - format!( - "no database was linked to app '{}' with label '{}'", - self.app, self.label - ) - })?; - - client.remove_database_link(&database, label).await?; - println!("Database '{database}' no longer linked to app {}", self.app); - Ok(()) + .map(|s| ResourceLinks::new(s.name, s.links)) + .collect::>(); + unlink( + client, + &self.app, + &self.label, + resources, + ResourceType::Database, + ) + .await + } +} + +#[derive(Parser, Debug)] +pub struct KeyValueStoreUnlinkCommand { + #[clap(flatten)] + common: CommonArgs, + /// The name by which the application refers to the key value store + label: String, + #[clap(short = 'a', long = "app")] + /// The app that will be using the key value store + app: String, +} + +impl KeyValueStoreUnlinkCommand { + async fn unlink(self, client: impl CloudClientInterface, app_id: Uuid) -> Result<()> { + let stores = client + .get_key_value_stores(Some(app_id)) + .await + .context("could not fetch key value stores")?; + let resources = stores + .into_iter() + .map(|s| ResourceLinks::new(s.name, s.links)) + .collect::>(); + unlink( + client, + &self.app, + &self.label, + resources, + ResourceType::KeyValueStore, + ) + .await } } +pub async fn unlink( + client: impl CloudClientInterface, + app: &str, + label: &str, + resources: Vec, + resource_type: ResourceType, +) -> Result<()> { + let (resource_name, resource_label) = resources + .into_iter() + .find_map(|d| { + d.links + .into_iter() + .find(|l| { + matches!(&l.app_name, Some(app_name) if app_name == app) && l.label == label + }) + .map(|l| (d.name, l)) + }) + .with_context(|| format!("no database was linked to app '{app}' with label '{label}'"))?; + match resource_type { + ResourceType::Database => { + client + .remove_database_link(&resource_name, resource_label) + .await? + } + ResourceType::KeyValueStore => { + client + .remove_key_value_store_link(&resource_name, resource_label) + .await? + } + } + println!( + "{} '{resource_name}' no longer linked to app {app}", + capitalize(&resource_type.to_string()) + ); + Ok(()) +} + /// A Link structure to ease grouping a resource with it's app and label #[derive(Clone, PartialEq)] pub struct Link { @@ -206,6 +379,7 @@ impl Link { mod link_tests { use super::*; use cloud::MockCloudClientInterface; + use cloud_openapi::models::{Database, KeyValueStoreItem}; #[tokio::test] async fn test_sqlite_link_error_database_does_not_exist() -> Result<()> { let command = SqliteLinkCommand { @@ -291,6 +465,122 @@ mod link_tests { Ok(()) } + #[tokio::test] + async fn test_key_value_store_link_error_store_does_not_exist() -> Result<()> { + let command = KeyValueStoreLinkCommand { + app: "app".to_string(), + key_value_store: "does-not-exist".to_string(), + label: "label".to_string(), + common: Default::default(), + }; + let app_id = Uuid::new_v4(); + let dbs = vec![ + KeyValueStoreItem::new("kv1".to_string(), vec![]), + KeyValueStoreItem::new("kv2".to_string(), vec![]), + ]; + + let mut mock = MockCloudClientInterface::new(); + mock.expect_get_key_value_stores() + .return_once(move |_| Ok(dbs)); + + let result = command.link(mock, app_id).await; + assert_eq!( + result.unwrap_err().to_string(), + r#"Key value store "does-not-exist" does not exist"# + ); + Ok(()) + } + + #[tokio::test] + async fn test_key_value_store_link_succeeds_when_store_exists() -> Result<()> { + let command = KeyValueStoreLinkCommand { + app: "app".to_string(), + key_value_store: "kv1".to_string(), + label: "label".to_string(), + common: Default::default(), + }; + let app_id = Uuid::new_v4(); + let dbs = vec![ + KeyValueStoreItem::new("kv1".to_string(), vec![]), + KeyValueStoreItem::new("kv2".to_string(), vec![]), + ]; + let expected_resource_label = ResourceLabel { + app_id, + label: command.label.clone(), + app_name: None, + }; + + let mut mock = MockCloudClientInterface::new(); + mock.expect_get_key_value_stores() + .return_once(move |_| Ok(dbs)); + mock.expect_create_key_value_store_link() + .withf(move |db, rl| db == "kv1" && rl == &expected_resource_label) + .returning(|_, _| Ok(())); + + command.link(mock, app_id).await + } + + #[tokio::test] + async fn test_key_value_store_unlink_error_store_does_not_exist() -> Result<()> { + let command = KeyValueStoreUnlinkCommand { + app: "app".to_string(), + label: "label".to_string(), + common: Default::default(), + }; + let app_id = Uuid::new_v4(); + let dbs = vec![ + KeyValueStoreItem::new( + "kv1".to_string(), + vec![ResourceLabel { + app_id, + label: "other".to_string(), + app_name: Some("bar".to_string()), + }], + ), + KeyValueStoreItem::new("kv2".to_string(), vec![]), + ]; + + let mut mock = MockCloudClientInterface::new(); + mock.expect_get_key_value_stores() + .return_once(move |_| Ok(dbs)); + + let result = command.unlink(mock, app_id).await; + assert_eq!( + result.unwrap_err().to_string(), + "no database was linked to app 'app' with label 'label'" + ); + Ok(()) + } + + #[tokio::test] + async fn test_key_value_store_unlink_succeeds_when_link_exists() -> Result<()> { + let command = KeyValueStoreUnlinkCommand { + app: "app".to_string(), + label: "label".to_string(), + common: Default::default(), + }; + let app_id = Uuid::new_v4(); + let dbs = vec![ + KeyValueStoreItem::new( + "kv1".to_string(), + vec![ResourceLabel { + app_id, + label: command.label.clone(), + app_name: Some("app".to_string()), + }], + ), + KeyValueStoreItem::new("kv2".to_string(), vec![]), + ]; + + let mut mock = MockCloudClientInterface::new(); + mock.expect_get_key_value_stores() + .return_once(move |_| Ok(dbs)); + mock.expect_remove_key_value_store_link() + .returning(|_, _| Ok(())); + + command.unlink(mock, app_id).await + } + // TODO: add test test_sqlite_link_errors_when_link_exists_with_different_database() // once there is a flag to avoid prompts } diff --git a/src/commands/links_output.rs b/src/commands/links_output.rs new file mode 100644 index 0000000..6883a68 --- /dev/null +++ b/src/commands/links_output.rs @@ -0,0 +1,312 @@ +/// This module provides functions for printing links in various formats +use anyhow::Result; +use clap::ValueEnum; +use cloud_openapi::models::ResourceLabel; +use comfy_table::presets::ASCII_BORDERS_ONLY_CONDENSED; +use dialoguer::Input; +use serde::Serialize; +use std::collections::BTreeMap; + +use super::link::Link; + +#[derive(ValueEnum, Clone, Debug)] +pub enum ListFormat { + Table, + Json, +} + +pub struct ResourceLinks { + pub name: String, + pub links: Vec, +} + +impl ResourceLinks { + pub fn new(name: String, links: Vec) -> Self { + Self { name, links } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum ResourceGroupBy { + App, + Resource(ResourceType), +} + +impl std::fmt::Display for ResourceGroupBy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ResourceGroupBy::App => f.write_str("app"), + ResourceGroupBy::Resource(ResourceType::Database) => f.write_str("database"), + // TODO consider renaming to "key_value_store" + ResourceGroupBy::Resource(ResourceType::KeyValueStore) => { + f.write_str("key_value_store") + } + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum ResourceType { + Database, + KeyValueStore, +} + +impl std::fmt::Display for ResourceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ResourceType::Database => f.write_str("database"), + ResourceType::KeyValueStore => f.write_str("key value store"), + } + } +} + +pub fn print_json( + mut links: Vec, + app_filter: Option<&str>, + resource_type: ResourceType, +) -> Result<()> { + if let Some(app) = app_filter { + links.retain(|d| { + d.links + .iter() + .any(|l| l.app_name.as_deref().unwrap_or("UNKNOWN") == app) + }); + } + let json_vals: Vec<_> = links + .iter() + .map(|l| json_list_format(l, resource_type)) + .collect(); + let json_text = serde_json::to_string_pretty(&json_vals)?; + println!("{}", json_text); + Ok(()) +} + +pub fn print_table( + links: Vec, + app_filter: Option<&str>, + group_by: Option, +) -> Result<()> { + let resources_without_links = links.iter().filter(|db| db.links.is_empty()); + + let mut links = links + .iter() + .flat_map(|db| { + db.links.iter().map(|l| Link { + resource: db.name.clone(), + resource_label: l.clone(), + }) + }) + .collect::>(); + let grouping = group_by.unwrap_or(ResourceGroupBy::App); + if let Some(name) = app_filter { + links.retain(|l| l.app_name() == name); + if links.is_empty() { + println!("No {} linked to an app named '{}'", grouping, name); + return Ok(()); + } + } + match grouping { + ResourceGroupBy::App => print_apps(links, resources_without_links), + ResourceGroupBy::Resource(r) => print_resources(links, resources_without_links, r), + } + Ok(()) +} + +fn json_list_format( + resource: &ResourceLinks, + resource_type: ResourceType, +) -> ResourceLinksJson<'_> { + let links = resource + .links + .iter() + .map(|l| ResourceLabelJson { + label: l.label.as_str(), + app: l.app_name.as_deref().unwrap_or("UNKNOWN"), + }) + .collect(); + match resource_type { + ResourceType::Database => ResourceLinksJson::Database(DatabaseLinksJson { + database: resource.name.as_str(), + links, + }), + ResourceType::KeyValueStore => ResourceLinksJson::KeyValueStore(KeyValueStoreLinksJson { + key_value_store: resource.name.as_str(), + links, + }), + } +} + +#[derive(Serialize)] +#[serde(untagged)] +enum ResourceLinksJson<'a> { + Database(DatabaseLinksJson<'a>), + KeyValueStore(KeyValueStoreLinksJson<'a>), +} + +#[derive(Serialize)] +struct KeyValueStoreLinksJson<'a> { + key_value_store: &'a str, + links: Vec>, +} + +#[derive(Serialize)] +struct DatabaseLinksJson<'a> { + database: &'a str, + links: Vec>, +} + +/// A ResourceLabel type without app ID for JSON output +#[derive(Serialize)] +struct ResourceLabelJson<'a> { + label: &'a str, + app: &'a str, +} + +/// Print apps optionally filtering to a specifically supplied app and/or database +fn print_apps<'a>( + mut links: Vec, + resources_without_links: impl Iterator, +) { + links.sort_by(|l1, l2| l1.app_name().cmp(l2.app_name())); + + let mut table = comfy_table::Table::new(); + table.load_preset(ASCII_BORDERS_ONLY_CONDENSED); + table.set_header(vec!["App", "Label", "Database"]); + + let rows = links.iter().map(|link| { + [ + link.app_name(), + link.resource_label.label.as_str(), + link.resource.as_str(), + ] + }); + table.add_rows(rows); + println!("{table}"); + + let mut databases_without_links = resources_without_links.peekable(); + if databases_without_links.peek().is_none() { + return; + } + + let mut table = comfy_table::Table::new(); + println!("Databases not linked to any app"); + table.set_header(vec!["Database"]); + table.add_rows(databases_without_links.map(|d| [&d.name])); + println!("{table}"); +} + +/// Print databases optionally filtering to a specifically supplied app and/or database +fn print_resources<'a>( + mut links: Vec, + resources_without_links: impl Iterator, + resource_type: ResourceType, +) { + links.sort_by(|l1, l2| l1.resource.cmp(&l2.resource)); + + let mut table = comfy_table::Table::new(); + table.load_preset(ASCII_BORDERS_ONLY_CONDENSED); + table.set_header(vec![&titlecase(&resource_type.to_string()), "Links"]); + table.add_rows(resources_without_links.map(|d| [&d.name, "-"])); + + let mut map = BTreeMap::new(); + for link in &links { + let app_name = link.app_name(); + map.entry(&link.resource) + .and_modify(|v| *v = format!("{}, {}:{}", *v, app_name, link.resource_label.label)) + .or_insert(format!("{}:{}", app_name, link.resource_label.label)); + } + table.add_rows(map.iter().map(|(d, l)| [d, l])); + println!("{table}"); +} + +// Uppercase the first letter of each word in a string +pub fn titlecase(s: &str) -> String { + s.split_whitespace() + .map(capitalize) + .collect::>() + .join(" ") +} + +// Uppercase the first letter of a string +pub fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().chain(chars).collect(), + } +} + +pub fn find_resource_link(store: &ResourceLinks, label: &str) -> Option { + store.links.iter().find_map(|r| { + if r.label == label { + Some(Link::new(r.clone(), store.name.clone())) + } else { + None + } + }) +} + +pub fn prompt_delete_resource( + name: &str, + links: &[ResourceLabel], + resource_type: ResourceType, +) -> std::io::Result { + let existing_links = links + .iter() + .map(|l| l.app_name.as_deref().unwrap_or("UNKNOWN")) + .collect::>() + .join(", "); + let mut prompt = String::new(); + if !existing_links.is_empty() { + // TODO: use warning color text + prompt.push_str(&format!("{} \"{name}\" is currently linked to the following apps: {existing_links}.\n\ + It is recommended to use `spin cloud link sqlite` to link another {resource_type} to those apps before deleting.\n", capitalize(&resource_type.to_string()))) + } + prompt.push_str(&format!( + "The action is irreversible. Please type \"{name}\" for confirmation" + )); + let mut input = Input::::new(); + input.with_prompt(prompt); + let answer = input.interact_text()?; + if answer != name { + println!("Invalid confirmation. Will not delete {resource_type}."); + Ok(false) + } else { + println!("Deleting {resource_type} ..."); + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_titlecase() { + assert_eq!(titlecase("hello world"), "Hello World"); + assert_eq!(titlecase("hello"), "Hello"); + assert_eq!(titlecase("Hello"), "Hello"); + assert_eq!(titlecase("HELLO"), "HELLO"); + assert_eq!(titlecase(""), ""); + } + + #[test] + fn test_json_list_format() { + let link = ResourceLinks::new( + "db1".to_string(), + vec![ResourceLabel { + app_id: uuid::Uuid::new_v4(), + label: "label1".to_string(), + app_name: Some("app1".to_string()), + }], + ); + if let ResourceLinksJson::Database(json) = json_list_format(&link, ResourceType::Database) { + assert_eq!(json.database, "db1"); + assert_eq!(json.links.len(), 1); + assert_eq!(json.links[0].label, "label1"); + assert_eq!(json.links[0].app, "app1"); + } else { + panic!("Expected Database type") + } + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a4eb848..36bc09b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,8 @@ pub mod apps; pub mod deploy; +pub mod key_value; pub mod link; +pub mod links_output; pub mod login; pub mod logs; pub mod sqlite; diff --git a/src/commands/sqlite.rs b/src/commands/sqlite.rs index 6a7d67d..45e9d4c 100644 --- a/src/commands/sqlite.rs +++ b/src/commands/sqlite.rs @@ -1,18 +1,17 @@ -use crate::commands::create_cloud_client; -use crate::commands::link::Link; -use crate::opts::*; +use crate::commands::{create_cloud_client, CommonArgs}; use anyhow::bail; use anyhow::{Context, Result}; -use clap::{Args, Parser, ValueEnum}; +use clap::{Parser, ValueEnum}; use cloud::CloudClientInterface; use cloud_openapi::models::Database; -use cloud_openapi::models::ResourceLabel; -use comfy_table::presets::ASCII_BORDERS_ONLY_CONDENSED; -use dialoguer::Input; -use serde::Serialize; -use std::collections::BTreeMap; + use std::str::FromStr; +use crate::commands::links_output::{ + print_json, print_table, prompt_delete_resource, ListFormat, ResourceGroupBy, ResourceLinks, + ResourceType, +}; + /// Manage Fermyon Cloud SQLite databases #[derive(Parser, Debug)] #[clap(about = "Manage Fermyon Cloud SQLite databases")] @@ -132,29 +131,19 @@ impl FromStr for GroupBy { fn from_str(s: &str) -> std::result::Result { match s.trim().to_lowercase().as_str() { "app" => Ok(Self::App), - "database" => Ok(Self::App), + "database" => Ok(Self::Database), s => Err(format!("Unrecognized group-by option: '{s}'")), } } } -#[derive(ValueEnum, Clone, Debug)] -pub enum ListFormat { - Table, - Json, -} - -#[derive(Debug, Default, Args)] -struct CommonArgs { - /// Deploy to the Fermyon instance saved under the specified name. - /// If omitted, Spin deploys to the default unnamed instance. - #[clap( - name = "environment-name", - long = "environment-name", - env = DEPLOYMENT_ENV_NAME_ENV, - hidden = true - )] - pub deployment_env_id: Option, +impl From for ResourceGroupBy { + fn from(group_by: GroupBy) -> Self { + match group_by { + GroupBy::App => Self::App, + GroupBy::Database => Self::Resource(ResourceType::Database), + } + } } impl SqliteCommand { @@ -207,7 +196,9 @@ impl DeleteCommand { None => anyhow::bail!("No database found with name \"{}\"", self.name), Some(db) => { // TODO: Fail if apps exist that are currently using a database - if self.yes || prompt_delete_database(&self.name, &db.links)? { + if self.yes + || prompt_delete_resource(&self.name, &db.links, ResourceType::Database)? + { client .delete_database(self.name.clone()) .await @@ -299,80 +290,23 @@ impl ListCommand { } } + let resource_links = databases + .into_iter() + .map(|db| ResourceLinks::new(db.name, db.links)) + .collect(); match self.format { - ListFormat::Json => self.print_json(databases), - ListFormat::Table => self.print_table(databases), - } - } - - fn print_json(&self, mut databases: Vec) -> Result<()> { - if let Some(app) = &self.app { - databases.retain(|d| { - d.links - .iter() - .any(|l| l.app_name.as_deref().unwrap_or("UNKNOWN") == app) - }); - } - let json_vals: Vec<_> = databases.iter().map(json_list_format).collect(); - let json_text = serde_json::to_string_pretty(&json_vals)?; - println!("{}", json_text); - Ok(()) - } - - fn print_table(&self, databases: Vec) -> Result<()> { - let databases_without_links = databases.iter().filter(|db| db.links.is_empty()); - - let mut links = databases - .iter() - .flat_map(|db| { - db.links.iter().map(|l| Link { - resource: db.name.clone(), - resource_label: l.clone(), - }) - }) - .collect::>(); - if let Some(name) = &self.app { - links.retain(|l| l.app_name() == *name); - if links.is_empty() { - println!("No databases linked to an app named '{name}'"); - return Ok(()); + ListFormat::Json => { + print_json(resource_links, self.app.as_deref(), ResourceType::Database) } + ListFormat::Table => print_table( + resource_links, + self.app.as_deref(), + self.group_by.map(Into::into), + ), } - match self.group_by.unwrap_or_default() { - GroupBy::App => print_apps(links, databases_without_links), - GroupBy::Database => print_databases(links, databases_without_links), - } - Ok(()) } } -fn json_list_format(database: &Database) -> DatabasesListJson<'_> { - DatabasesListJson { - database: &database.name, - links: database - .links - .iter() - .map(|l| ResourceLabelJson { - label: &l.label, - app: l.app_name.as_deref().unwrap_or("UNKNOWN"), - }) - .collect(), - } -} - -#[derive(Serialize)] -struct DatabasesListJson<'a> { - database: &'a str, - links: Vec>, -} - -/// A ResourceLabel type without app ID for JSON output -#[derive(Serialize)] -struct ResourceLabelJson<'a> { - label: &'a str, - app: &'a str, -} - impl RenameCommand { pub async fn run(self) -> Result<()> { let client = create_cloud_client(self.common.deployment_env_id.as_deref()).await?; @@ -395,99 +329,6 @@ impl RenameCommand { } } -/// Print apps optionally filtering to a specifically supplied app and/or database -fn print_apps<'a>( - mut links: Vec, - databases_without_links: impl Iterator, -) { - links.sort_by(|l1, l2| l1.app_name().cmp(l2.app_name())); - - let mut table = comfy_table::Table::new(); - table.load_preset(ASCII_BORDERS_ONLY_CONDENSED); - table.set_header(vec!["App", "Label", "Database"]); - - let rows = links.iter().map(|link| { - [ - link.app_name(), - link.resource_label.label.as_str(), - link.resource.as_str(), - ] - }); - table.add_rows(rows); - println!("{table}"); - - let mut databases_without_links = databases_without_links.peekable(); - if databases_without_links.peek().is_none() { - return; - } - - let mut table = comfy_table::Table::new(); - println!("Databases not linked to any app"); - table.set_header(vec!["Database"]); - table.add_rows(databases_without_links.map(|d| [&d.name])); - println!("{table}"); -} - -/// Print databases optionally filtering to a specifically supplied app and/or database -fn print_databases<'a>( - mut links: Vec, - databases_without_links: impl Iterator, -) { - links.sort_by(|l1, l2| l1.resource.cmp(&l2.resource)); - - let mut table = comfy_table::Table::new(); - table.load_preset(ASCII_BORDERS_ONLY_CONDENSED); - table.set_header(vec!["Database", "Links"]); - table.add_rows(databases_without_links.map(|d| [&d.name, "-"])); - - let mut map = BTreeMap::new(); - for link in &links { - let app_name = link.app_name(); - map.entry(&link.resource) - .and_modify(|v| *v = format!("{}, {}:{}", *v, app_name, link.resource_label.label)) - .or_insert(format!("{}:{}", app_name, link.resource_label.label)); - } - table.add_rows(map.iter().map(|(d, l)| [d, l])); - println!("{table}"); -} - -fn prompt_delete_database(database: &str, links: &[ResourceLabel]) -> std::io::Result { - let existing_links = links - .iter() - .map(|l| l.app_name.as_deref().unwrap_or("UNKNOWN")) - .collect::>() - .join(", "); - let mut prompt = String::new(); - if !existing_links.is_empty() { - // TODO: use warning color text - prompt.push_str(&format!("Database \"{database}\" is currently linked to the following apps: {existing_links}.\n\ - It is recommended to use `spin cloud link sqlite` to link to another database to those apps before deleting.\n")) - } - prompt.push_str(&format!( - "The action is irreversible. Please type \"{database}\" for confirmation" - )); - let mut input = Input::::new(); - input.with_prompt(prompt); - let answer = input.interact_text()?; - if answer != database { - println!("Invalid confirmation. Will not delete database."); - Ok(false) - } else { - println!("Deleting database ..."); - Ok(true) - } -} - -pub fn find_database_link(db: &Database, label: &str) -> Option { - db.links.iter().find_map(|r| { - if r.label == label { - Some(Link::new(r.clone(), db.name.clone())) - } else { - None - } - }) -} - pub fn database_has_link(database: &Database, label: &str, app: Option<&str>) -> bool { database .links @@ -499,6 +340,7 @@ pub fn database_has_link(database: &Database, label: &str, app: Option<&str>) -> mod sqlite_tests { use super::*; use cloud::MockCloudClientInterface; + use cloud_openapi::models::ResourceLabel; #[tokio::test] async fn test_create_if_db_already_exists_then_error() -> Result<()> { diff --git a/src/main.rs b/src/main.rs index a760fb7..1d6b274 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use clap::{FromArgMatches, Parser}; use commands::{ apps::AppsCommand, deploy::DeployCommand, + key_value::KeyValueCommand, link::{LinkCommand, UnlinkCommand}, login::{LoginCommand, LogoutCommand}, logs::LogsCommand, @@ -52,6 +53,9 @@ enum CloudCli { /// Unlink apps from resources #[clap(subcommand)] Unlink(UnlinkCommand), + /// Manage Fermyon Cloud key value stores + #[clap(subcommand, alias = "kv")] + KeyValue(KeyValueCommand), } #[tokio::main] @@ -73,5 +77,6 @@ async fn main() -> Result<(), Error> { CloudCli::Sqlite(cmd) => cmd.run().await, CloudCli::Link(cmd) => cmd.run().await, CloudCli::Unlink(cmd) => cmd.run().await, + CloudCli::KeyValue(cmd) => cmd.run().await, } }