From 8826381fcb72d9ed4f46b8d053cb9dcefa1f556d Mon Sep 17 00:00:00 2001 From: Sascha Eglau Date: Mon, 12 Feb 2024 10:21:12 +0100 Subject: [PATCH 1/3] move init command to apps, add environments --- src/api/mod.rs | 35 ++++++++++++ src/api/types.rs | 1 - src/commands/{initialize.rs => apps.rs} | 74 +++++++++++++++++++++++-- src/commands/environments.rs | 48 ++++++++++++++++ src/commands/mod.rs | 3 +- src/main.rs | 16 ++++-- 6 files changed, 164 insertions(+), 13 deletions(-) rename src/commands/{initialize.rs => apps.rs} (79%) create mode 100644 src/commands/environments.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index 187f9e6..aeeb696 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -36,6 +36,41 @@ impl APIClient { response.json() } + pub fn get_application( + &self, + token: &str, + name: &str + ) -> Result { + let url = format!("{}/organization", self.base_url); + + let response = self.client + .get(url) + .header("User-Agent", self.user_agent.as_str()) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/json") + .send()? + .error_for_status()?; + + response.json() + } + + pub fn get_applications( + &self, + token: &str, + ) -> Result { + let url = format!("{}/organization", self.base_url); + + let response = self.client + .get(url) + .header("User-Agent", self.user_agent.as_str()) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/json") + .send()? + .error_for_status()?; + + response.json() + } + pub fn create_organization( &self, token: &str, diff --git a/src/api/types.rs b/src/api/types.rs index 6f1d05a..72f71a3 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -15,7 +15,6 @@ pub struct ListOrganizationResponse { #[derive(Serialize, Deserialize, Debug)] pub struct Application { - id: String, name: String, organization_id: String, } diff --git a/src/commands/initialize.rs b/src/commands/apps.rs similarity index 79% rename from src/commands/initialize.rs rename to src/commands/apps.rs index 6003b2a..1e77ef3 100644 --- a/src/commands/initialize.rs +++ b/src/commands/apps.rs @@ -1,18 +1,81 @@ -use std::path::Path; - use anyhow::{anyhow, Result}; -use clap::Parser; +use clap::{Parser, Subcommand}; use dialoguer::{FuzzySelect, Input}; +use std::path::Path; +use super::CommandBase; use crate::config::{ application::{Build, HttpService}, scan::{scan_directory_for_type, ApplicationType}, }; -use super::CommandBase; +#[derive(Parser)] +#[derive(Debug)] +#[command( + author, + version, + about, + long_about, + subcommand_required = true, + arg_required_else_help = true +)] +pub struct Apps { + #[command(subcommand)] + pub command: Option, +} + +impl Apps { + pub fn execute(&self, base: &mut CommandBase) -> Result<()> { + match &self.command { + Some(Commands::Deploy(depl)) => depl.execute(base), + Some(Commands::Initialize(init)) => init.execute(base), + None => Ok(()) + } + } +} + +#[derive(Subcommand)] +#[derive(Debug)] +pub enum Commands { + /// Deploy a service + #[command(arg_required_else_help = true)] + Deploy(Deploy), + /// Generate Dockerfile and Molnett manifest + Initialize(Initialize), +} + +#[derive(Parser)] +#[derive(Debug)] +pub struct Deploy { + #[arg(help = "Name of the app to deploy")] + name: String, + #[arg(long, help = "The image to deploy, e.g. yourimage:v1")] + image: Option, + #[arg(long, help = "Skip confirmation")] + no_confirm: Option, +} + +impl Deploy { + pub fn execute(&self, base: &CommandBase) -> Result<()> { + // flags: image, no-confirm + // 1. check if authenticated + let token = base + .user_config() + .get_token() + .ok_or_else(|| anyhow!("No token found. Please login first."))?; + // 2. get existing application from API + let response = base.api_client()?.get_application( + token, + &self.name, + )?; + // 3. show user what changed + // 4. submit change + Ok(()) + } + +} #[derive(Parser, Debug)] -#[command(author, version)] #[clap(aliases = ["init"])] pub struct Initialize { #[clap(short, long)] @@ -41,7 +104,6 @@ impl Initialize { .ok_or_else(|| anyhow!("No token found. Please login first."))?; let init_plan = InitPlan::builder(base) - .app_name(self.app_name.as_deref()) .organization_id(self.organization_id.as_deref()) .app_name(self.app_name.as_deref()) .cpus(self.cpus) diff --git a/src/commands/environments.rs b/src/commands/environments.rs new file mode 100644 index 0000000..3bb8e9d --- /dev/null +++ b/src/commands/environments.rs @@ -0,0 +1,48 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use super::CommandBase; + +#[derive(Parser)] +#[derive(Debug)] +#[command( + author, + version, + about, + long_about, + subcommand_required = true, + arg_required_else_help = true +)] +pub struct Environments { + #[command(subcommand)] + pub command: Option, +} + +impl Environments { + pub fn execute(&self, base: &mut CommandBase) -> Result<()> { + match &self.command { + Some(Commands::Create(create)) => create.execute(base), + None => Ok(()) + } + } +} + +#[derive(Subcommand)] +#[derive(Debug)] +pub enum Commands { + /// Create an environment + #[command(arg_required_else_help = true)] + Create(Create), +} + +#[derive(Parser)] +#[derive(Debug)] +pub struct Create { + #[arg(help = "Name of the environment to create")] + name: String, +} + +impl Create { + pub fn execute(&self, base: &CommandBase) -> Result<()> { + Ok(()) + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index be881cf..55753b3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -11,8 +11,9 @@ use crate::{ }, }; +pub mod apps; pub mod auth; -pub mod initialize; +pub mod environments; pub mod orgs; pub struct CommandBase<'a> { diff --git a/src/main.rs b/src/main.rs index 9bb819e..68d374a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use anyhow::Result; use camino::Utf8PathBuf; use clap::{Parser, Subcommand}; use crate::config::user::UserConfig; -use commands::CommandBase; +use commands::{CommandBase, environments}; mod api; mod commands; mod config; @@ -19,7 +19,7 @@ mod config; arg_required_else_help = true )] struct Cli { - #[arg(short, long, value_name = "FILE", env("MOLNETT_CONFIG"))] + #[arg(short, long, value_name = "FILE", env("MOLNETT_CONFIG"), help = "config file, default is $HOME/.config/molnett/config.json")] config: Option, #[arg(long, env("MOLNETT_API_URL"), help = "Url of the Molnett API, default is https://api.molnett.org")] @@ -32,9 +32,14 @@ struct Cli { #[derive(Debug)] #[derive(Subcommand)] enum Commands { + /// Manage organizations Orgs(commands::orgs::Orgs), + /// Login to Molnett Auth(commands::auth::Auth), - Initialize(commands::initialize::Initialize), + /// Deploy and manage apps + Apps(commands::apps::Apps), + /// Create and manage environments + Environments(commands::environments::Environments), } fn main() -> Result<()> { @@ -48,9 +53,10 @@ fn main() -> Result<()> { let mut base = CommandBase::new(&mut config); match cli.command { - Some(Commands::Orgs(orgs)) => orgs.execute(&mut base), + Some(Commands::Apps(apps)) => apps.execute(&mut base), Some(Commands::Auth(auth)) => auth.execute(&mut base), - Some(Commands::Initialize(init)) => init.execute(&mut base), + Some(Commands::Environments(environments)) => environments.execute(&mut base), + Some(Commands::Orgs(orgs)) => orgs.execute(&mut base), None => Ok(()), } } From ece9d9b435c25032c391685135c875037d48f9ea Mon Sep 17 00:00:00 2001 From: Sascha Eglau Date: Tue, 13 Feb 2024 10:35:49 +0100 Subject: [PATCH 2/3] feat: add command to set default org in config --- src/commands/apps.rs | 3 +-- src/commands/mod.rs | 4 ++-- src/commands/orgs.rs | 57 ++++++++++++++++++++++++++++++++++++++++---- src/config/user.rs | 6 +++++ 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/commands/apps.rs b/src/commands/apps.rs index 1e77ef3..0b8a80a 100644 --- a/src/commands/apps.rs +++ b/src/commands/apps.rs @@ -64,7 +64,7 @@ impl Deploy { .get_token() .ok_or_else(|| anyhow!("No token found. Please login first."))?; // 2. get existing application from API - let response = base.api_client()?.get_application( + let response = base.api_client().get_application( token, &self.name, )?; @@ -201,7 +201,6 @@ impl InitPlanBuilder<'_> { let orgs = self .base .api_client() - .unwrap() .get_organizations(self.base.user_config().get_token().unwrap()) .unwrap(); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 55753b3..fa0b8d8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -29,9 +29,9 @@ impl CommandBase<'_> { } } - pub fn api_client(&self) -> Result { + pub fn api_client(&self) -> APIClient { let url = self.user_config.get_url(); - Ok(APIClient::new(url)) + APIClient::new(url) } pub fn user_config(&self) -> &UserConfig { diff --git a/src/commands/orgs.rs b/src/commands/orgs.rs index 61aa055..08ee0ad 100644 --- a/src/commands/orgs.rs +++ b/src/commands/orgs.rs @@ -1,6 +1,6 @@ use anyhow::{anyhow, Result}; use clap::{Parser, Subcommand}; -use dialoguer::Input; +use dialoguer::{FuzzySelect, Input}; use tabled::Table; use super::CommandBase; @@ -20,10 +20,11 @@ pub struct Orgs { } impl Orgs { - pub fn execute(&self, base: &CommandBase) -> Result<()> { + 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::Switch(switch)) => switch.execute(base), None => Ok(()), } } @@ -32,8 +33,12 @@ impl Orgs { #[derive(Subcommand)] #[derive(Debug)] pub enum Commands { + /// List your orgs List(List), + /// Create a new org Create(Create), + /// Switch default org for all commands + Switch(Switch), } #[derive(Parser)] @@ -47,7 +52,7 @@ impl List { .get_token() .ok_or_else(|| anyhow!("No token found. Please login first."))?; - let response = base.api_client()?.get_organizations(token)?; + let response = base.api_client().get_organizations(token)?; let table = Table::new(response.organizations).to_string(); println!("{}", table); @@ -78,7 +83,7 @@ impl Create { .billing_email(self.billing_email.as_deref()) .build()?; - let response = base.api_client()?.create_organization( + let response = base.api_client().create_organization( token, plan.name.as_str(), plan.billing_email.as_str(), @@ -159,3 +164,47 @@ impl CreatePlan { CreatePlanBuilder::new() } } + +#[derive(Parser)] +#[derive(Debug)] +pub struct Switch { + #[arg(help = "Name of the org to switch to")] + org: Option, +} + +impl Switch { + pub fn execute(&self, base: &mut CommandBase) -> Result<()> { + let orgs = base + .api_client() + .get_organizations(base.user_config().get_token().unwrap())?; + let org_names = orgs + .organizations + .iter() + .map(|o| o.name.as_str()) + .collect::>(); + + let org_name = if self.org.is_some() { + let arg_org = self.org.clone().unwrap(); + 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)) + } + } else { + let selection = FuzzySelect::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("Please select your organization: ") + .items(&org_names[..]) + .interact() + .unwrap(); + org_names[selection].to_string() + }; + + match base.user_config_mut().write_default_org(org_name) { + Ok(_) => Ok(()), + Err(err) => { + println!("Error while writing config: {}", err); + Ok(()) + } + } + } +} diff --git a/src/config/user.rs b/src/config/user.rs index cdcebdc..c5c4d17 100644 --- a/src/config/user.rs +++ b/src/config/user.rs @@ -15,6 +15,7 @@ pub struct UserConfig { #[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] pub struct UserConfigInner { token: Option, + default_org: Option, #[serde(default = "default_url")] url: String, } @@ -71,6 +72,11 @@ impl UserConfig { write_to_disk_json(&self.path, &self.disk_config) } + pub fn write_default_org(&mut self, org_name: String) -> Result<(), super::Error> { + self.disk_config.default_org = Some(org_name.clone()); + self.config.default_org = Some(org_name); + write_to_disk_json(&self.path, &self.disk_config) + } pub fn get_url(&self) -> &str { self.config.url.as_ref() } From 634e3343a9e799b020a13b2d51117eaeb8b56641 Mon Sep 17 00:00:00 2001 From: Sascha Eglau Date: Fri, 16 Feb 2024 11:52:22 +0100 Subject: [PATCH 3/3] feat: add environment creation --- src/api/mod.rs | 48 +++++++++++++++++++++++++++++++++--- src/api/types.rs | 10 ++++++++ src/commands/environments.rs | 24 +++++++++++++++++- src/config/user.rs | 3 +++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index aeeb696..0fb0309 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use self::types::{ListOrganizationResponse, Organization}; +use self::types::{ListOrganizationResponse, Organization, CreateEnvironmentResponse, ListEnvironmentsResponse}; pub mod types; @@ -23,7 +23,7 @@ impl APIClient { &self, token: &str, ) -> Result { - let url = format!("{}/organization", self.base_url); + let url = format!("{}/orgs", self.base_url); let response = self.client .get(url) @@ -77,7 +77,7 @@ impl APIClient { name: &str, billing_email: &str, ) -> Result { - let url = format!("{}/organization", self.base_url); + let url = format!("{}/orgs", self.base_url); let mut body = HashMap::new(); body.insert("name", name); @@ -96,6 +96,48 @@ impl APIClient { response.json() } + pub fn get_environments( + &self, + token: &str, + org_name: &str + ) -> Result { + let url = format!("{}/orgs", self.base_url); + + let response = self.client + .get(url) + .header("User-Agent", self.user_agent.as_str()) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/json") + .send()? + .error_for_status()?; + + response.json() + } + + pub fn create_environment( + &self, + token: &str, + name: &str, + org_name: &str + ) -> Result { + let url = format!("{}/orgs/{}/envs", self.base_url, org_name); + + let mut body = HashMap::new(); + body.insert("name", name); + + let response = self + .client + .post(url) + .header("User-Agent", self.user_agent.as_str()) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/json") + .json(&body) + .send()? + .error_for_status()?; + + response.json() + } + pub fn initialize_application(&self) -> Result<(), reqwest::Error> { let url = format!("{}/application", self.base_url); diff --git a/src/api/types.rs b/src/api/types.rs index 72f71a3..1c9bd61 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -13,6 +13,16 @@ pub struct ListOrganizationResponse { pub organizations: Vec, } +#[derive(Serialize, Deserialize, Debug)] +pub struct ListEnvironmentsResponse { + pub environments: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Tabled)] +pub struct CreateEnvironmentResponse { + pub name: String +} + #[derive(Serialize, Deserialize, Debug)] pub struct Application { name: String, diff --git a/src/commands/environments.rs b/src/commands/environments.rs index 3bb8e9d..5216728 100644 --- a/src/commands/environments.rs +++ b/src/commands/environments.rs @@ -1,6 +1,7 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use clap::{Parser, Subcommand}; use super::CommandBase; +use tabled::Table; #[derive(Parser)] #[derive(Debug)] @@ -39,10 +40,31 @@ pub enum Commands { pub struct Create { #[arg(help = "Name of the environment to create")] name: String, + #[arg(long, help = "Organization to create the environment in")] + org: Option, } impl Create { pub fn execute(&self, base: &CommandBase) -> Result<()> { + let org_name = if self.org.is_some() { + self.org.clone().unwrap() + } else { + base.user_config().get_default_org().unwrap().to_string() + }; + let token = base + .user_config() + .get_token() + .ok_or_else(|| anyhow!("No token found. Please login first."))?; + + let response = base.api_client().create_environment( + token, + &self.name, + &org_name + )?; + + let table = Table::new([response]).to_string(); + println!("{}", table); + Ok(()) } } diff --git a/src/config/user.rs b/src/config/user.rs index c5c4d17..9a2c6a2 100644 --- a/src/config/user.rs +++ b/src/config/user.rs @@ -77,6 +77,9 @@ impl UserConfig { self.config.default_org = Some(org_name); write_to_disk_json(&self.path, &self.disk_config) } + pub fn get_default_org(&self) -> Option<&str> { + self.config.default_org.as_deref() + } pub fn get_url(&self) -> &str { self.config.url.as_ref() }