From 2e134db30d9a3daeb70a1282be80283c34d38941 Mon Sep 17 00:00:00 2001 From: Jonathan Grahl Date: Thu, 12 Dec 2024 17:00:04 +0100 Subject: [PATCH] feat: support for async deployments (#40) * feat: support for async deployments * fix: clippy --- Cargo.lock | 54 ++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/api/mod.rs | 19 +++++++------- src/api/types.rs | 36 ++++++++++++++++++++++----- src/commands/auth.rs | 49 ++++++++++++++++++------------------ src/commands/orgs.rs | 31 +++++++++++------------ src/commands/secrets.rs | 39 ++++++++++------------------- src/commands/services.rs | 50 ++++++++++++++++++++++++++++--------- src/main.rs | 22 ++++++++-------- 9 files changed, 196 insertions(+), 105 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 64ccdad..1fca54d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -353,6 +353,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + [[package]] name = "dialoguer" version = "0.10.4" @@ -914,6 +924,7 @@ dependencies = [ "tabled", "term", "thiserror", + "time", "tiny_http", "tracing", "tungstenite", @@ -948,6 +959,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.16" @@ -1142,6 +1159,12 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1676,6 +1699,37 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny_http" version = "0.12.0" diff --git a/Cargo.toml b/Cargo.toml index 5259a3f..3a45f1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ serde_yaml = "0.9.25" tabled = "0.14.0" term = "0.7.0" thiserror = "1.0.48" +time = { version = "0.3.36", features = ["serde", "serde-well-known"] } tiny_http = "0.12.0" tracing = "0.1.37" tungstenite = { git = "https://github.com/snapview/tungstenite-rs", rev = "0fa4197", features = [ diff --git a/src/api/mod.rs b/src/api/mod.rs index fb31b4f..32e876e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -21,11 +21,7 @@ impl APIClient { } } - pub fn get_org( - &self, - token: &str, - org_name: &str, - ) -> anyhow::Result { + pub fn get_org(&self, token: &str, org_name: &str) -> anyhow::Result { let url = format!("{}/orgs/{}", self.base_url, org_name); let response = self.get(&url, token)?; match response.status() { @@ -83,6 +79,7 @@ impl APIClient { ) -> anyhow::Result { let url = format!("{}/orgs/{}/envs/{}/svcs", self.base_url, org_name, env_name); let response: String = self.get(&url, token)?.error_for_status()?.text()?; + println!("{}", response.clone()); serde_json::from_str(response.as_str()).with_context(|| "Failed to deserialize response") } @@ -116,12 +113,16 @@ impl APIClient { &self, token: &str, org_name: &str, - ) -> anyhow::Result> { + ) -> anyhow::Result { let url = format!("{}/orgs/{}/envs", self.base_url, org_name); let response = self.get(&url, token)?; match response.status() { - StatusCode::OK => Ok(serde_json::from_str(&response.text()?) - .with_context(|| "Failed to deserialize environments")?), + StatusCode::OK => { + let text = &response.text()?; + println!("{}", text); + Ok(serde_json::from_str(text) + .with_context(|| "Failed to deserialize environments")?) + } StatusCode::UNAUTHORIZED => Err(anyhow!("Unauthorized, please login first")), StatusCode::NOT_FOUND => Err(anyhow!("Organization does not exist")), _ => Err(anyhow!( @@ -186,7 +187,7 @@ impl APIClient { org_name: &str, env_name: &str, service: Service, - ) -> anyhow::Result { + ) -> anyhow::Result { let url = format!("{}/orgs/{}/envs/{}/svcs", self.base_url, org_name, env_name); let body = serde_json::to_string(&service)?; let response = self.post_str(&url, token, body)?; diff --git a/src/api/types.rs b/src/api/types.rs index 82fc74e..a73a657 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -1,7 +1,8 @@ -use std::fmt::{Display, Formatter, Result}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter, Result}; use tabled::Tabled; +use time::OffsetDateTime; #[derive(Serialize, Deserialize, Debug, Tabled)] pub struct Organization { @@ -15,16 +16,28 @@ pub struct ListOrganizationResponse { pub organizations: Vec, } +#[derive(Serialize, Deserialize, Debug, Tabled)] +pub struct Environment { + pub name: String, + pub organization_id: String, + pub created_at: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ListEnvironmentsResponse { + pub environments: Vec, +} + #[derive(Serialize, Deserialize, Debug, Tabled)] pub struct CreateEnvironmentResponse { pub name: String, #[serde(default, skip_serializing_if = "is_default")] - pub copy_from: DisplayOption + pub copy_from: DisplayOption, } #[derive(Serialize, Deserialize, Debug)] pub struct ListServicesResponse { - pub services: Vec + pub services: Vec, } #[derive(Serialize, Deserialize, Debug, Tabled, Clone, PartialEq)] @@ -35,12 +48,23 @@ pub struct Service { #[serde(default, skip_serializing_if = "is_default")] pub env: DisplayOption, #[serde(default, skip_serializing_if = "is_default")] - pub secrets: DisplayOption + pub secrets: DisplayOption, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct DeployServiceResponse { + pub id: String, + pub status: String, + #[serde(with = "time::serde::rfc3339::option")] + pub start_time: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub end_time: Option, + pub error: Option, } #[derive(Serialize, Deserialize, Debug)] pub struct ListSecretsResponse { - pub secrets: Vec + pub secrets: Vec, } #[derive(Serialize, Deserialize, Debug, Tabled)] @@ -61,7 +85,7 @@ fn is_default(t: &T) -> bool { impl Display for DisplayOption { fn fmt(&self, f: &mut Formatter<'_>) -> Result { if self.0.is_none() { - return Ok(()) + return Ok(()); } let hashmap = self.0.as_ref().unwrap(); diff --git a/src/commands/auth.rs b/src/commands/auth.rs index af5662b..40f9bed 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -76,37 +76,38 @@ impl Login { println!("Browse to: {}", auth_url); println!("Listening on {}", server.server_addr()); - for request in server.incoming_requests() { - let url = request.url(); + let request = server + .incoming_requests() + .next() + .expect("server shutting down"); + let url = request.url(); - let code = url.split("?code=").collect::>()[1]; + let code = url.split("?code=").collect::>()[1]; - let oauthtoken = client - .exchange_code(oauth2::AuthorizationCode::new(code.to_string())) - .set_pkce_verifier(pkce_verifier) - .request(http_client) - .unwrap(); + let oauthtoken = client + .exchange_code(oauth2::AuthorizationCode::new(code.to_string())) + .set_pkce_verifier(pkce_verifier) + .request(http_client) + .unwrap(); - let mut token = Token::new(); + let mut token = Token::new(); - token.access_token = oauthtoken.access_token().secret().to_string(); - if let Some(refresh_token) = oauthtoken.refresh_token() { - token.refresh_token = Some(refresh_token.secret().to_string()); - } - // TODO: the api returns "expiry":"2024-01-01T11:03:53.485518152+01:00" - if let Some(expires_in) = oauthtoken.expires_in() { - token.expiry = - Some(Utc::now() + chrono::Duration::seconds(expires_in.as_secs() as i64)); - } else { - token.expiry = Some(Utc::now() + chrono::Duration::hours(1)); - } + token.access_token = oauthtoken.access_token().secret().to_string(); + if let Some(refresh_token) = oauthtoken.refresh_token() { + token.refresh_token = Some(refresh_token.secret().to_string()); + } + // TODO: the api returns "expiry":"2024-01-01T11:03:53.485518152+01:00" + if let Some(expires_in) = oauthtoken.expires_in() { + token.expiry = + Some(Utc::now() + chrono::Duration::seconds(expires_in.as_secs() as i64)); + } else { + token.expiry = Some(Utc::now() + chrono::Duration::hours(1)); + } - base.user_config_mut().write_token(token)?; + base.user_config_mut().write_token(token)?; - request.respond(Response::from_string("Success! You can close this tab now"))?; + request.respond(Response::from_string("Success! You can close this tab now"))?; - return Ok(()); - } Ok(()) } } diff --git a/src/commands/orgs.rs b/src/commands/orgs.rs index 08ee0ad..8d8bf98 100644 --- a/src/commands/orgs.rs +++ b/src/commands/orgs.rs @@ -5,8 +5,7 @@ use tabled::Table; use super::CommandBase; -#[derive(Parser)] -#[derive(Debug)] +#[derive(Parser, Debug)] #[command( author, version, @@ -22,16 +21,15 @@ pub struct Orgs { impl Orgs { pub fn execute(&self, base: &mut CommandBase) -> Result<()> { match &self.command { - Some(Commands::List(list)) => list.execute(&base), - Some(Commands::Create(create)) => create.execute(&base), + Some(Commands::List(list)) => list.execute(base), + Some(Commands::Create(create)) => create.execute(base), Some(Commands::Switch(switch)) => switch.execute(base), None => Ok(()), } } } -#[derive(Subcommand)] -#[derive(Debug)] +#[derive(Subcommand, Debug)] pub enum Commands { /// List your orgs List(List), @@ -41,8 +39,7 @@ pub enum Commands { Switch(Switch), } -#[derive(Parser)] -#[derive(Debug)] +#[derive(Parser, Debug)] pub struct List {} impl List { @@ -61,8 +58,7 @@ impl List { } } -#[derive(Parser)] -#[derive(Debug)] +#[derive(Parser, Debug)] pub struct Create { #[clap(short, long)] name: Option, @@ -123,11 +119,11 @@ impl CreatePlanBuilder { .unwrap(); self.name = input; - return self; } else { self.name = name.unwrap().to_string(); - return self; } + + self } pub fn billing_email(mut self, billing_email: Option<&str>) -> Self { @@ -143,7 +139,8 @@ impl CreatePlanBuilder { .unwrap(); self.billing_email = input; - return self; + + self } fn verify(&self) -> Result<()> { @@ -165,8 +162,7 @@ impl CreatePlan { } } -#[derive(Parser)] -#[derive(Debug)] +#[derive(Parser, Debug)] pub struct Switch { #[arg(help = "Name of the org to switch to")] org: Option, @@ -188,7 +184,10 @@ impl Switch { if org_names.contains(&arg_org.as_str()) { arg_org } else { - return Err(anyhow!("organization {} does not exist or you do not have access to it", arg_org)) + return Err(anyhow!( + "organization {} does not exist or you do not have access to it", + arg_org + )); } } else { let selection = FuzzySelect::with_theme(&dialoguer::theme::ColorfulTheme::default()) diff --git a/src/commands/secrets.rs b/src/commands/secrets.rs index c30d5bd..2a73a1e 100644 --- a/src/commands/secrets.rs +++ b/src/commands/secrets.rs @@ -1,7 +1,7 @@ +use super::CommandBase; use anyhow::{anyhow, Result}; use clap::{Parser, Subcommand}; use dialoguer::{FuzzySelect, Input}; -use super::CommandBase; use std::io::{self, BufRead}; use tabled::Table; @@ -25,7 +25,7 @@ impl Secrets { Some(Commands::Create(create)) => create.execute(base), Some(Commands::List(list)) => list.execute(base), Some(Commands::Delete(delete)) => delete.execute(base), - None => Ok(()) + None => Ok(()), } } } @@ -38,7 +38,7 @@ pub enum Commands { /// List secrets List(List), /// Delete a secret - Delete(Delete) + Delete(Delete), } #[derive(Debug, Parser)] @@ -68,31 +68,26 @@ impl Create { .expect("Failed to get user input") }; - base.api_client().create_secret( - token, - &org_name, - &self.env, - &self.name, - &value - )?; + base.api_client() + .create_secret(token, &org_name, &self.env, &self.name, &value)?; println!("Secret {} created", &self.name); Ok(()) } fn read_stdin(&self) -> Result { - let mut lines = io::stdin().lock().lines(); + let lines = io::stdin().lock().lines(); let mut user_input = String::new(); - while let Some(line) = lines.next() { + for line in lines { let last_input = line.unwrap(); - if last_input.len() == 0 { + if last_input.is_empty() { break; } - if user_input.len() > 0 { - user_input.push_str("\n"); + if !user_input.is_empty() { + user_input.push('\n'); } user_input.push_str(&last_input); @@ -116,11 +111,7 @@ impl List { .get_token() .ok_or_else(|| anyhow!("No token found. Please login first."))?; - let response = base.api_client().get_secrets( - token, - &org_name, - &self.env - )?; + let response = base.api_client().get_secrets(token, &org_name, &self.env)?; let table = Table::new(response.secrets).to_string(); println!("{}", table); @@ -157,12 +148,8 @@ impl Delete { .unwrap(); } - base.api_client().delete_secret( - token, - &org_name, - &self.env, - &self.name - )?; + base.api_client() + .delete_secret(token, &org_name, &self.env, &self.name)?; println!("Secret {} deleted", self.name); Ok(()) diff --git a/src/commands/services.rs b/src/commands/services.rs index 2ababe7..029629f 100644 --- a/src/commands/services.rs +++ b/src/commands/services.rs @@ -88,7 +88,9 @@ impl Deploy { let env_exists = base .api_client() .get_environments(token, &org_name)? - .contains(&manifest.environment); + .environments + .iter() + .any(|env| env.name == manifest.environment); if !env_exists { return Err(anyhow!( "Environment {} does not exist", @@ -123,13 +125,13 @@ impl Deploy { } } - let result = base.api_client().deploy_service( + let _result = base.api_client().deploy_service( token, &org_name, &manifest.environment, manifest.service, )?; - println!("Service {} deployed", result.name); + println!("Service deployed."); Ok(()) } @@ -152,8 +154,8 @@ impl Deploy { )) } }; - for i in 0..diffs.len() { - match diffs[i] { + for diff in &diffs { + match diff { Difference::Same(ref x) => { t.reset().unwrap(); writeln!(t, " {}", x)?; @@ -273,9 +275,21 @@ impl ManifestBuilder { let selection = FuzzySelect::with_theme(&dialoguer::theme::ColorfulTheme::default()) .with_prompt("Please select the environment to deploy the service in: ") - .items(&envs[..]) + .items( + &envs + .environments + .iter() + .map(|env| env.name.clone()) + .collect::>(), + ) .interact()?; - self.manifest.environment = envs[selection].to_string(); + self.manifest.environment = envs + .environments + .iter() + .filter(|env| env.name == selection.to_string()) + .map(|env| env.name.clone()) + .collect::>()[0] + .clone(); Ok(self) } @@ -331,17 +345,23 @@ fn get_image_name( String::from_utf8_lossy(&git_output.stdout).to_string() }; - return Ok(format!( + Ok(format!( "register.molnett.org/{}/{}:{}", - org_id, image_name, image_tag.trim() - )); + org_id, + image_name, + image_tag.trim() + )) } #[derive(Parser, Debug)] pub struct ImageName { #[arg(short, long, help = "Image tag to use")] tag: Option, - #[arg(short, long, help = "Path to a molnett manifest. The manifest's image field will be updated to the returned image name")] + #[arg( + short, + long, + help = "Path to a molnett manifest. The manifest's image field will be updated to the returned image name" + )] update_manifest: Option, #[arg(short, long, help = "Override image name. Default is directory name")] image_name: Option, @@ -354,7 +374,13 @@ impl ImageName { .get_token() .ok_or_else(|| anyhow!("No token found. Please login first."))?; - let image_name = get_image_name(&base.api_client(), token, &base.get_org()?, &self.tag, &self.image_name)?; + let image_name = get_image_name( + &base.api_client(), + token, + &base.get_org()?, + &self.tag, + &self.image_name, + )?; if let Some(path) = self.update_manifest.clone() { let mut manifest = read_manifest(&path)?; manifest.service.image = image_name.clone(); diff --git a/src/main.rs b/src/main.rs index 812903f..dc1494e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,10 +4,9 @@ use camino::Utf8PathBuf; use clap::{Parser, Subcommand}; use commands::CommandBase; use dialoguer::console::style; +use reqwest::blocking::Client; use semver::Version; use serde_json::Value; -use reqwest::blocking::Client; - mod api; mod commands; @@ -74,7 +73,6 @@ enum Commands { } fn main() -> Result<()> { - let current_version = env!("CARGO_PKG_VERSION"); if let Ok(Some(new_version)) = check_newer_release(current_version) { let message = style(format!("A new version ({}) is available at https://github.com/molnett/molnctl - you are running {}\n", new_version, current_version)); @@ -93,8 +91,8 @@ fn main() -> Result<()> { match cli.command { Some(Commands::Auth(auth)) => auth.execute(&mut base), Some(Commands::Environments(environments)) => environments.execute(&mut base), - Some(Commands::Deploy(deploy)) => deploy.execute(&mut base), - Some(Commands::Logs(logs)) => logs.execute(&mut base), + Some(Commands::Deploy(deploy)) => deploy.execute(&base), + Some(Commands::Logs(logs)) => logs.execute(&base), Some(Commands::Initialize(init)) => init.execute(&mut base), Some(Commands::Orgs(orgs)) => orgs.execute(&mut base), Some(Commands::Secrets(secrets)) => secrets.execute(&mut base), @@ -106,14 +104,14 @@ fn main() -> Result<()> { pub fn check_newer_release(current_version: &str) -> Result> { let client = Client::new(); let url = "https://api.github.com/repos/molnett/molnctl/releases/latest"; - - let response = client - .get(url) - .header("User-Agent", "molnctl") - .send()?; + + let response = client.get(url).header("User-Agent", "molnctl").send()?; if !response.status().is_success() { - return Err(anyhow!("Failed to fetch release info: HTTP {}", response.status())); + return Err(anyhow!( + "Failed to fetch release info: HTTP {}", + response.status() + )); } let body: Value = response.json()?; @@ -130,4 +128,4 @@ pub fn check_newer_release(current_version: &str) -> Result> { } else { Ok(None) } -} \ No newline at end of file +}