diff --git a/lib/backend-api/schema.graphql b/lib/backend-api/schema.graphql index 1ca851e4a04..e07abac007b 100644 --- a/lib/backend-api/schema.graphql +++ b/lib/backend-api/schema.graphql @@ -3011,6 +3011,8 @@ type Mutation { updateUserInfo(input: UpdateUserInfoInput!): UpdateUserInfoPayload validateUserPassword(input: ValidateUserPasswordInput!): ValidateUserPasswordPayload generateApiToken(input: GenerateAPITokenInput!): GenerateAPITokenPayload + + """Request To revoke an API token; these start with 'wap_'.""" revokeApiToken(input: RevokeAPITokenInput!): RevokeAPITokenPayload checkUserExists(input: CheckUserExistsInput!): CheckUserExistsPayload readNotification(input: ReadNotificationInput!): ReadNotificationPayload @@ -3656,6 +3658,7 @@ input GenerateAPITokenInput { clientMutationId: String } +"""Request To revoke an API token; these start with 'wap_'.""" type RevokeAPITokenPayload { token: APIToken success: Boolean @@ -3664,7 +3667,7 @@ type RevokeAPITokenPayload { input RevokeAPITokenInput { """The API token ID""" - tokenId: ID! + token: String! clientMutationId: String } diff --git a/lib/backend-api/src/query.rs b/lib/backend-api/src/query.rs index db0aaccfa16..236e5dd26c5 100644 --- a/lib/backend-api/src/query.rs +++ b/lib/backend-api/src/query.rs @@ -15,6 +15,34 @@ use crate::{ GraphQLApiFailure, WasmerClient, }; +/// Revoke an existing token +pub async fn revoke_token( + client: &WasmerClient, + token: String, +) -> Result, anyhow::Error> { + client + .run_graphql_strict(types::RevokeToken::build(RevokeTokenVariables { token })) + .await + .map(|v| v.revoke_api_token.and_then(|v| v.success)) +} + +/// Generate a new Nonce +/// +/// Takes a name and a callbackUrl and returns a nonce +pub async fn create_nonce( + client: &WasmerClient, + name: String, + callback_url: String, +) -> Result, anyhow::Error> { + client + .run_graphql_strict(types::CreateNewNonce::build(CreateNewNonceVariables { + callback_url, + name, + })) + .await + .map(|v| v.new_nonce.map(|v| v.nonce)) +} + pub async fn get_app_secret_value_by_id( client: &WasmerClient, secret_id: impl Into, diff --git a/lib/backend-api/src/types.rs b/lib/backend-api/src/types.rs index 124c3899394..87b8939e9bb 100644 --- a/lib/backend-api/src/types.rs +++ b/lib/backend-api/src/types.rs @@ -41,6 +41,54 @@ mod queries { Viewer, } + #[derive(cynic::QueryVariables, Debug)] + pub struct RevokeTokenVariables { + pub token: String, + } + + #[derive(cynic::QueryFragment, Debug)] + #[cynic(graphql_type = "Mutation", variables = "RevokeTokenVariables")] + pub struct RevokeToken { + #[arguments(input: { token: $token })] + pub revoke_api_token: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + pub struct RevokeAPITokenPayload { + pub success: Option, + } + + #[derive(cynic::QueryVariables, Debug)] + pub struct CreateNewNonceVariables { + pub callback_url: String, + pub name: String, + } + + #[derive(cynic::QueryFragment, Debug)] + #[cynic(graphql_type = "Mutation", variables = "CreateNewNonceVariables")] + pub struct CreateNewNonce { + #[arguments(input: { callbackUrl: $callback_url, name: $name })] + pub new_nonce: Option, + } + + #[derive(cynic::QueryFragment, Debug)] + pub struct NewNoncePayload { + pub client_mutation_id: Option, + pub nonce: Nonce, + } + + #[derive(cynic::QueryFragment, Debug)] + pub struct Nonce { + pub auth_url: String, + pub callback_url: String, + pub created_at: DateTime, + pub expired: bool, + pub id: cynic::Id, + pub is_validated: bool, + pub name: String, + pub secret: String, + } + #[derive(cynic::QueryFragment, Debug)] #[cynic(graphql_type = "Query")] pub struct GetCurrentUser { diff --git a/lib/cli/src/commands/app/create.rs b/lib/cli/src/commands/app/create.rs index eb4e25ab16c..11e1c686d08 100644 --- a/lib/cli/src/commands/app/create.rs +++ b/lib/cli/src/commands/app/create.rs @@ -23,7 +23,8 @@ use wasmer_config::{app::AppConfigV1, package::PackageSource}; use super::{deploy::CmdAppDeploy, util::login_user}; use crate::{ commands::AsyncCliCommand, - opts::{ApiOpts, ItemFormatOpts, WasmerEnv}, + config::WasmerEnv, + opts::ItemFormatOpts, utils::{load_package_manifest, prompts::PackageCheckMode}, }; @@ -111,10 +112,6 @@ pub struct CmdAppCreate { pub no_wait: bool, // Common args. - #[clap(flatten)] - #[allow(missing_docs)] - pub api: ApiOpts, - #[clap(flatten)] pub env: WasmerEnv, @@ -730,7 +727,6 @@ the app:\n" { let cmd_deploy = CmdAppDeploy { quiet: false, - api: self.api.clone(), env: self.env.clone(), fmt: ItemFormatOpts { format: self.fmt.format, @@ -767,7 +763,6 @@ impl AsyncCliCommand for CmdAppCreate { } else { Some( login_user( - &self.api, &self.env, !self.non_interactive, "retrieve informations about the owner of the app", diff --git a/lib/cli/src/commands/app/delete.rs b/lib/cli/src/commands/app/delete.rs index 58b7bd445ce..fb5b23e6e45 100644 --- a/lib/cli/src/commands/app/delete.rs +++ b/lib/cli/src/commands/app/delete.rs @@ -4,13 +4,13 @@ use dialoguer::Confirm; use is_terminal::IsTerminal; use super::util::AppIdentOpts; -use crate::{commands::AsyncCliCommand, opts::ApiOpts}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv}; /// Delete an existing Edge app #[derive(clap::Parser, Debug)] pub struct CmdAppDelete { #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, #[clap(long, default_value_t = !std::io::stdin().is_terminal())] non_interactive: bool, @@ -25,7 +25,7 @@ impl AsyncCliCommand for CmdAppDelete { async fn run_async(self) -> Result<(), anyhow::Error> { let interactive = !self.non_interactive; - let client = self.api.client()?; + let client = self.env.client()?; eprintln!("Looking up the app..."); let (_ident, app) = self.ident.load_app(&client).await?; diff --git a/lib/cli/src/commands/app/deploy.rs b/lib/cli/src/commands/app/deploy.rs index 865dcd86018..dab8dc9c070 100644 --- a/lib/cli/src/commands/app/deploy.rs +++ b/lib/cli/src/commands/app/deploy.rs @@ -1,7 +1,8 @@ use super::{util::login_user, AsyncCliCommand}; use crate::{ commands::{app::create::CmdAppCreate, package::publish::PackagePublish, PublishWait}, - opts::{ApiOpts, ItemFormatOpts, WasmerEnv}, + config::WasmerEnv, + opts::ItemFormatOpts, utils::load_package_manifest, }; use anyhow::Context; @@ -27,9 +28,6 @@ static EDGE_HEADER_APP_VERSION_ID: http::HeaderName = /// Deploy an app to Wasmer Edge. #[derive(clap::Parser, Debug)] pub struct CmdAppDeploy { - #[clap(flatten)] - pub api: ApiOpts, - #[clap(flatten)] pub env: WasmerEnv, @@ -156,7 +154,6 @@ impl CmdAppDeploy { package_namespace: Some(owner), non_interactive: self.non_interactive, bump: self.bump, - api: self.api.clone(), }; publish_cmd @@ -211,7 +208,6 @@ impl CmdAppDeploy { owner: self.owner.clone(), app_name: self.app_name.clone(), no_wait: self.no_wait, - api: self.api.clone(), env: self.env.clone(), fmt: ItemFormatOpts { format: self.fmt.format, @@ -232,8 +228,7 @@ impl AsyncCliCommand for CmdAppDeploy { type Output = (); async fn run_async(self) -> Result { - let client = - login_user(&self.api, &self.env, !self.non_interactive, "deploy an app").await?; + let client = login_user(&self.env, !self.non_interactive, "deploy an app").await?; let base_dir_path = self.dir.clone().unwrap_or_else(|| { self.path @@ -290,7 +285,7 @@ impl AsyncCliCommand for CmdAppDeploy { "Enter the name of the app", default_name.as_deref(), &owner, - self.api.client().ok().as_ref(), + self.env.client().ok().as_ref(), ) .await?; diff --git a/lib/cli/src/commands/app/get.rs b/lib/cli/src/commands/app/get.rs index c3e32d9c3fd..73b4d9de94f 100644 --- a/lib/cli/src/commands/app/get.rs +++ b/lib/cli/src/commands/app/get.rs @@ -3,23 +3,19 @@ use wasmer_api::types::DeployApp; use super::util::AppIdentOpts; -use crate::{ - commands::AsyncCliCommand, - opts::{ApiOpts, ItemFormatOpts}, -}; + +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts}; /// Retrieve detailed informations about an app #[derive(clap::Parser, Debug)] pub struct CmdAppGet { #[clap(flatten)] - #[allow(missing_docs)] - pub api: ApiOpts, + pub env: WasmerEnv, + #[clap(flatten)] - #[allow(missing_docs)] pub fmt: ItemFormatOpts, #[clap(flatten)] - #[allow(missing_docs)] pub ident: AppIdentOpts, } @@ -28,7 +24,7 @@ impl AsyncCliCommand for CmdAppGet { type Output = DeployApp; async fn run_async(self) -> Result { - let client = self.api.client()?; + let client = self.env.client()?; let (_ident, app) = self.ident.load_app(&client).await?; println!("{}", self.fmt.format.render(&app)); diff --git a/lib/cli/src/commands/app/info.rs b/lib/cli/src/commands/app/info.rs index 35c8f2274d2..36a3d458b11 100644 --- a/lib/cli/src/commands/app/info.rs +++ b/lib/cli/src/commands/app/info.rs @@ -1,7 +1,7 @@ //! Show short information about an Edge app. use super::util::AppIdentOpts; -use crate::{commands::AsyncCliCommand, opts::ApiOpts}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv}; /// Show short information about an Edge app. /// @@ -9,7 +9,7 @@ use crate::{commands::AsyncCliCommand, opts::ApiOpts}; #[derive(clap::Parser, Debug)] pub struct CmdAppInfo { #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, #[clap(flatten)] ident: AppIdentOpts, } @@ -19,7 +19,7 @@ impl AsyncCliCommand for CmdAppInfo { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; let (_ident, app) = self.ident.load_app(&client).await?; let app_url = app.url; diff --git a/lib/cli/src/commands/app/list.rs b/lib/cli/src/commands/app/list.rs index 86acc4601b8..d16e9a0f5c1 100644 --- a/lib/cli/src/commands/app/list.rs +++ b/lib/cli/src/commands/app/list.rs @@ -5,18 +5,16 @@ use std::pin::Pin; use futures::{Stream, StreamExt}; use wasmer_api::types::DeployApp; -use crate::{ - commands::AsyncCliCommand, - opts::{ApiOpts, ListFormatOpts}, -}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ListFormatOpts}; /// List apps belonging to a namespace #[derive(clap::Parser, Debug)] pub struct CmdAppList { #[clap(flatten)] fmt: ListFormatOpts, + #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, /// Get apps in a specific namespace. /// @@ -43,7 +41,7 @@ impl AsyncCliCommand for CmdAppList { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; let apps_stream: Pin< Box, anyhow::Error>> + Send + Sync>, diff --git a/lib/cli/src/commands/app/logs.rs b/lib/cli/src/commands/app/logs.rs index 009da797e81..969273a6f81 100644 --- a/lib/cli/src/commands/app/logs.rs +++ b/lib/cli/src/commands/app/logs.rs @@ -7,10 +7,7 @@ use futures::StreamExt; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; use wasmer_api::types::{Log, LogStream}; -use crate::{ - opts::{ApiOpts, ListFormatOpts}, - utils::render::CliRender, -}; +use crate::{config::WasmerEnv, opts::ListFormatOpts, utils::render::CliRender}; use super::util::AppIdentOpts; @@ -24,7 +21,8 @@ pub enum LogStreamArg { #[derive(clap::Parser, Debug)] pub struct CmdAppLogs { #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, + #[clap(flatten)] fmt: ListFormatOpts, @@ -75,7 +73,7 @@ impl crate::commands::AsyncCliCommand for CmdAppLogs { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; let (_ident, app) = self.ident.load_app(&client).await?; diff --git a/lib/cli/src/commands/app/purge_cache.rs b/lib/cli/src/commands/app/purge_cache.rs index 79a20fea57e..8f08b41e8c7 100644 --- a/lib/cli/src/commands/app/purge_cache.rs +++ b/lib/cli/src/commands/app/purge_cache.rs @@ -1,10 +1,7 @@ //! Get information about an edge app. use super::util::AppIdentOpts; -use crate::{ - commands::AsyncCliCommand, - opts::{ApiOpts, ItemFormatOpts}, -}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts}; /// Purge caches for applications. /// @@ -15,14 +12,12 @@ use crate::{ #[derive(clap::Parser, Debug)] pub struct CmdAppPurgeCache { #[clap(flatten)] - #[allow(missing_docs)] - pub api: ApiOpts, + pub env: WasmerEnv, + #[clap(flatten)] - #[allow(missing_docs)] pub fmt: ItemFormatOpts, #[clap(flatten)] - #[allow(missing_docs)] pub ident: AppIdentOpts, } @@ -31,7 +26,7 @@ impl AsyncCliCommand for CmdAppPurgeCache { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; let (_ident, app) = self.ident.load_app(&client).await?; let version_id = app.active_version.id; diff --git a/lib/cli/src/commands/app/regions/list.rs b/lib/cli/src/commands/app/regions/list.rs index ca9e8ac2f68..88b5e398308 100644 --- a/lib/cli/src/commands/app/regions/list.rs +++ b/lib/cli/src/commands/app/regions/list.rs @@ -1,17 +1,10 @@ -use crate::{ - commands::AsyncCliCommand, - opts::{ApiOpts, ListFormatOpts, WasmerEnv}, -}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ListFormatOpts}; use is_terminal::IsTerminal; /// List available Edge regions. #[derive(clap::Parser, Debug)] pub struct CmdAppRegionsList { /* --- Common flags --- */ - #[clap(flatten)] - #[allow(missing_docs)] - pub api: ApiOpts, - #[clap(flatten)] pub env: WasmerEnv, @@ -32,7 +25,7 @@ impl AsyncCliCommand for CmdAppRegionsList { type Output = (); async fn run_async(self) -> Result { - let client = self.api.client()?; + let client = self.env.client()?; let regions = wasmer_api::query::get_all_app_regions(&client).await?; println!("{}", self.fmt.format.render(regions.as_slice())); diff --git a/lib/cli/src/commands/app/secrets/create.rs b/lib/cli/src/commands/app/secrets/create.rs index 4fe5ffd30f2..839de2216c7 100644 --- a/lib/cli/src/commands/app/secrets/create.rs +++ b/lib/cli/src/commands/app/secrets/create.rs @@ -1,7 +1,7 @@ use super::utils::Secret; use crate::{ commands::{app::util::AppIdentFlag, AsyncCliCommand}, - opts::{ApiOpts, WasmerEnv}, + config::WasmerEnv, }; use anyhow::Context; use colored::Colorize; @@ -17,10 +17,6 @@ use wasmer_api::WasmerClient; #[derive(clap::Parser, Debug)] pub struct CmdAppSecretsCreate { /* --- Common flags --- */ - #[clap(flatten)] - #[allow(missing_docs)] - pub api: ApiOpts, - #[clap(flatten)] pub env: WasmerEnv, @@ -172,14 +168,14 @@ impl CmdAppSecretsCreate { async fn create_from_file( &self, + client: &WasmerClient, path: &Path, app_id: &str, ) -> anyhow::Result<(), anyhow::Error> { let secrets = super::utils::read_secrets_from_file(path).await?; - let client = self.api.client()?; - let secrets = self.filter_secrets(&client, app_id, secrets).await?; - self.create(&client, app_id, secrets).await?; + let secrets = self.filter_secrets(client, app_id, secrets).await?; + self.create(client, app_id, secrets).await?; Ok(()) } @@ -190,7 +186,7 @@ impl AsyncCliCommand for CmdAppSecretsCreate { type Output = (); async fn run_async(self) -> Result { - let client = self.api.client()?; + let client = self.env.client()?; let app_id = super::utils::get_app_id( &client, self.app_id.app.as_ref(), @@ -200,7 +196,7 @@ impl AsyncCliCommand for CmdAppSecretsCreate { ) .await?; if let Some(file) = &self.from_file { - self.create_from_file(file, &app_id).await + self.create_from_file(&client, file, &app_id).await } else { let name = self.get_secret_name()?; let value = self.get_secret_value()?; diff --git a/lib/cli/src/commands/app/secrets/delete.rs b/lib/cli/src/commands/app/secrets/delete.rs index 6e6415f2f64..eba87c7eae7 100644 --- a/lib/cli/src/commands/app/secrets/delete.rs +++ b/lib/cli/src/commands/app/secrets/delete.rs @@ -1,6 +1,6 @@ use crate::{ commands::{app::util::AppIdentFlag, AsyncCliCommand}, - opts::{ApiOpts, WasmerEnv}, + config::WasmerEnv, }; use colored::Colorize; use dialoguer::theme::ColorfulTheme; @@ -14,10 +14,6 @@ use super::utils::{self, get_secrets}; #[derive(clap::Parser, Debug)] pub struct CmdAppSecretsDelete { /* --- Common flags --- */ - #[clap(flatten)] - #[allow(missing_docs)] - pub api: ApiOpts, - #[clap(flatten)] pub env: WasmerEnv, @@ -119,12 +115,16 @@ impl CmdAppSecretsDelete { } } - async fn delete_from_file(&self, path: &Path, app_id: String) -> anyhow::Result<()> { + async fn delete_from_file( + &self, + client: &WasmerClient, + path: &Path, + app_id: String, + ) -> anyhow::Result<()> { let secrets = super::utils::read_secrets_from_file(path).await?; - let client = self.api.client()?; for secret in secrets { - self.delete(&client, &app_id, &secret.name).await?; + self.delete(client, &app_id, &secret.name).await?; } Ok(()) @@ -136,7 +136,7 @@ impl AsyncCliCommand for CmdAppSecretsDelete { type Output = (); async fn run_async(self) -> Result { - let client = self.api.client()?; + let client = self.env.client()?; let app_id = super::utils::get_app_id( &client, self.app_id.app.as_ref(), @@ -146,7 +146,7 @@ impl AsyncCliCommand for CmdAppSecretsDelete { ) .await?; if let Some(file) = &self.from_file { - self.delete_from_file(file, app_id).await + self.delete_from_file(&client, file, app_id).await } else if self.all { if self.non_interactive && !self.force { anyhow::bail!("Refusing to delete all secrets in non-interactive mode without the `--force` flag.") diff --git a/lib/cli/src/commands/app/secrets/list.rs b/lib/cli/src/commands/app/secrets/list.rs index ecc95ff24e7..6edb25c3cd9 100644 --- a/lib/cli/src/commands/app/secrets/list.rs +++ b/lib/cli/src/commands/app/secrets/list.rs @@ -1,7 +1,8 @@ use super::utils::{get_secrets, BackendSecretWrapper}; use crate::{ commands::{app::util::AppIdentFlag, AsyncCliCommand}, - opts::{ApiOpts, ListFormatOpts, WasmerEnv}, + config::WasmerEnv, + opts::ListFormatOpts, }; use is_terminal::IsTerminal; use std::path::PathBuf; @@ -10,10 +11,6 @@ use std::path::PathBuf; #[derive(clap::Parser, Debug)] pub struct CmdAppSecretsList { /* --- Common flags --- */ - #[clap(flatten)] - #[allow(missing_docs)] - pub api: ApiOpts, - #[clap(flatten)] pub env: WasmerEnv, @@ -43,7 +40,7 @@ impl AsyncCliCommand for CmdAppSecretsList { type Output = (); async fn run_async(self) -> Result { - let client = self.api.client()?; + let client = self.env.client()?; let app_id = super::utils::get_app_id( &client, self.app_id.app.as_ref(), diff --git a/lib/cli/src/commands/app/secrets/reveal.rs b/lib/cli/src/commands/app/secrets/reveal.rs index 7725cdc548b..ae16d67bfac 100644 --- a/lib/cli/src/commands/app/secrets/reveal.rs +++ b/lib/cli/src/commands/app/secrets/reveal.rs @@ -1,7 +1,8 @@ use super::utils; use crate::{ commands::{app::util::AppIdentFlag, AsyncCliCommand}, - opts::{ApiOpts, ListFormatOpts, WasmerEnv}, + config::WasmerEnv, + opts::ListFormatOpts, utils::render::{ItemFormat, ListFormat}, }; use dialoguer::theme::ColorfulTheme; @@ -12,10 +13,6 @@ use std::path::PathBuf; #[derive(clap::Parser, Debug)] pub struct CmdAppSecretsReveal { /* --- Common flags --- */ - #[clap(flatten)] - #[allow(missing_docs)] - pub api: ApiOpts, - #[clap(flatten)] pub env: WasmerEnv, @@ -70,7 +67,7 @@ impl AsyncCliCommand for CmdAppSecretsReveal { type Output = (); async fn run_async(self) -> Result { - let client = self.api.client()?; + let client = self.env.client()?; let app_id = super::utils::get_app_id( &client, self.app_id.app.as_ref(), diff --git a/lib/cli/src/commands/app/secrets/update.rs b/lib/cli/src/commands/app/secrets/update.rs index ecf438e7641..f45d239db2b 100644 --- a/lib/cli/src/commands/app/secrets/update.rs +++ b/lib/cli/src/commands/app/secrets/update.rs @@ -1,7 +1,7 @@ use super::utils::Secret; use crate::{ commands::{app::util::AppIdentFlag, AsyncCliCommand}, - opts::{ApiOpts, WasmerEnv}, + config::WasmerEnv, }; use anyhow::Context; use colored::Colorize; @@ -17,10 +17,6 @@ use wasmer_api::WasmerClient; #[derive(clap::Parser, Debug)] pub struct CmdAppSecretsUpdate { /* --- Common args --- */ - #[clap(flatten)] - #[allow(missing_docs)] - pub api: ApiOpts, - #[clap(flatten)] pub env: WasmerEnv, @@ -167,14 +163,14 @@ impl CmdAppSecretsUpdate { async fn update_from_file( &self, + client: &WasmerClient, path: &Path, app_id: &str, ) -> anyhow::Result<(), anyhow::Error> { let secrets = super::utils::read_secrets_from_file(path).await?; - let client = self.api.client()?; - let secrets = self.filter_secrets(&client, app_id, secrets).await?; - self.update(&client, app_id, secrets).await?; + let secrets = self.filter_secrets(client, app_id, secrets).await?; + self.update(client, app_id, secrets).await?; Ok(()) } @@ -185,7 +181,7 @@ impl AsyncCliCommand for CmdAppSecretsUpdate { type Output = (); async fn run_async(self) -> Result { - let client = self.api.client()?; + let client = self.env.client()?; let app_id = super::utils::get_app_id( &client, self.app_id.app.as_ref(), @@ -196,7 +192,7 @@ impl AsyncCliCommand for CmdAppSecretsUpdate { .await?; if let Some(file) = &self.from_file { - self.update_from_file(file, &app_id).await + self.update_from_file(&client, file, &app_id).await } else { let name = self.get_secret_name()?; let value = self.get_secret_value()?; diff --git a/lib/cli/src/commands/app/util.rs b/lib/cli/src/commands/app/util.rs index 362d30a4989..df1265a387b 100644 --- a/lib/cli/src/commands/app/util.rs +++ b/lib/cli/src/commands/app/util.rs @@ -11,8 +11,8 @@ use wasmer_api::{ use wasmer_config::app::AppConfigV1; use crate::{ - commands::Login, - opts::{ApiOpts, WasmerEnv}, + commands::{AsyncCliCommand, Login}, + config::WasmerEnv, }; /// App identifier. @@ -214,18 +214,17 @@ pub struct AppIdentFlag { } pub(super) async fn login_user( - api: &ApiOpts, env: &WasmerEnv, interactive: bool, msg: &str, ) -> anyhow::Result { - if let Ok(client) = api.client() { + if let Ok(client) = env.client() { return Ok(client); } let theme = dialoguer::theme::ColorfulTheme::default(); - if api.token.is_none() { + if env.token().is_none() { if interactive { eprintln!( "{}: You need to be logged in to {msg}.", @@ -238,13 +237,10 @@ pub(super) async fn login_user( { Login { no_browser: false, - wasmer_dir: env.wasmer_dir.clone(), - registry: api - .registry - .clone() - .map(|l| wasmer_registry::wasmer_env::Registry::from(l.to_string())), - token: api.token.clone(), - cache_dir: Some(env.cache_dir.clone()), + wasmer_dir: env.dir().to_path_buf(), + cache_dir: env.cache_dir().to_path_buf(), + token: None, + registry: env.registry.clone(), } .run_async() .await?; @@ -263,7 +259,7 @@ pub(super) async fn login_user( } } - api.client() + env.client() } pub fn get_app_config_from_dir( diff --git a/lib/cli/src/commands/app/version/activate.rs b/lib/cli/src/commands/app/version/activate.rs index e54341eef6c..222a45de195 100644 --- a/lib/cli/src/commands/app/version/activate.rs +++ b/lib/cli/src/commands/app/version/activate.rs @@ -1,16 +1,12 @@ -use crate::{ - commands::AsyncCliCommand, - opts::{ApiOpts, ItemFormatOpts}, -}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts}; /// Switch the active version of an app. (rollback / rollforward) #[derive(clap::Parser, Debug)] pub struct CmdAppVersionActivate { #[clap(flatten)] - #[allow(missing_docs)] - pub api: ApiOpts, + pub env: WasmerEnv, + #[clap(flatten)] - #[allow(missing_docs)] pub fmt: ItemFormatOpts, /// App version ID to activate. @@ -25,7 +21,7 @@ impl AsyncCliCommand for CmdAppVersionActivate { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; let app = wasmer_api::query::app_version_activate(&client, self.version).await?; diff --git a/lib/cli/src/commands/app/version/get.rs b/lib/cli/src/commands/app/version/get.rs index c13f5691a87..83d80edffa2 100644 --- a/lib/cli/src/commands/app/version/get.rs +++ b/lib/cli/src/commands/app/version/get.rs @@ -2,17 +2,17 @@ use anyhow::Context; use crate::{ commands::{app::util::AppIdentOpts, AsyncCliCommand}, - opts::{ApiOpts, ItemFormatOpts}, + config::WasmerEnv, + opts::ItemFormatOpts, }; /// Show information for a specific app version. #[derive(clap::Parser, Debug)] pub struct CmdAppVersionGet { #[clap(flatten)] - #[allow(missing_docs)] - pub api: ApiOpts, + pub env: WasmerEnv, + #[clap(flatten)] - #[allow(missing_docs)] pub fmt: ItemFormatOpts, /// *Name* of the version - NOT the unique version id! @@ -29,7 +29,7 @@ impl AsyncCliCommand for CmdAppVersionGet { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; let (_ident, app) = self.ident.load_app(&client).await?; let version = wasmer_api::query::get_app_version( diff --git a/lib/cli/src/commands/app/version/list.rs b/lib/cli/src/commands/app/version/list.rs index c184cd0afe3..895a096c7d8 100644 --- a/lib/cli/src/commands/app/version/list.rs +++ b/lib/cli/src/commands/app/version/list.rs @@ -2,15 +2,16 @@ use wasmer_api::types::{DeployAppVersionsSortBy, GetDeployAppVersionsVars}; use crate::{ commands::{app::util::AppIdentOpts, AsyncCliCommand}, - opts::{ApiOpts, ListFormatOpts}, + config::WasmerEnv, + opts::ListFormatOpts, }; /// List versions of an app. #[derive(clap::Parser, Debug)] pub struct CmdAppVersionList { #[clap(flatten)] - #[allow(missing_docs)] - pub api: ApiOpts, + pub env: WasmerEnv, + #[allow(missing_docs)] #[clap(flatten)] pub fmt: ListFormatOpts, @@ -72,7 +73,7 @@ impl AsyncCliCommand for CmdAppVersionList { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; let (_ident, app) = self.ident.load_app(&client).await?; let versions = if self.all { diff --git a/lib/cli/src/commands/auth/login/auth_server.rs b/lib/cli/src/commands/auth/login/auth_server.rs new file mode 100644 index 00000000000..3942111fec9 --- /dev/null +++ b/lib/cli/src/commands/auth/login/auth_server.rs @@ -0,0 +1,146 @@ +use super::AuthorizationState; +use http_body_util::BodyExt; +use hyper::{body::Incoming, Request, Response, StatusCode}; +use reqwest::{Body, Method}; +use tokio::net::TcpListener; + +/// A utility struct used to manage the local server for browser-based authorization. +#[derive(Clone)] +pub(super) struct BrowserAuthContext { + pub server_shutdown_tx: tokio::sync::mpsc::Sender, + pub token_tx: tokio::sync::mpsc::Sender, +} + +/// Payload from the frontend after the user has authenticated. +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub(super) enum TokenStatus { + /// Signifying that the token is cancelled + Cancelled, + /// Signifying that the token is authorized + Authorized, +} + +#[inline] +pub(super) async fn setup_listener() -> Result<(TcpListener, String), anyhow::Error> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let port = addr.port(); + + let server_url = format!("http://localhost:{}", port); + + Ok((listener, server_url)) +} + +/// Payload from the frontend after the user has authenticated. +/// +/// This has the token that we need to set in the WASMER_TOML file. +#[derive(Clone, Debug, serde::Deserialize)] +pub(super) struct ValidatedNonceOutput { + /// Token Received from the frontend + pub token: Option, + /// Status of the token , whether it is authorized or cancelled + pub status: TokenStatus, +} + +pub(super) async fn service_router( + context: BrowserAuthContext, + req: Request, +) -> Result, anyhow::Error> { + match *req.method() { + Method::OPTIONS => preflight(req).await, + Method::POST => handle_post_save_token(context, req).await, + _ => handle_unknown_method(context).await, + } +} + +async fn preflight(_: Request) -> Result, anyhow::Error> { + let response = Response::builder() + .status(http::StatusCode::OK) + .header("Access-Control-Allow-Origin", "*") // FIXME: this is not secure, Don't allow all origins. @syrusakbary + .header("Access-Control-Allow-Headers", "Content-Type") + .header("Access-Control-Allow-Methods", "POST, OPTIONS") + .body(Body::default())?; + Ok(response) +} + +async fn handle_post_save_token( + context: BrowserAuthContext, + req: Request, +) -> Result, anyhow::Error> { + let BrowserAuthContext { + server_shutdown_tx, + token_tx, + } = context; + let (.., body) = req.into_parts(); + let body = body.collect().await?.to_bytes(); + + let ValidatedNonceOutput { + token, + status: token_status, + } = serde_json::from_slice::(&body)?; + + // send the AuthorizationState based on token_status to the main thread and get the response message + let (response_message, parse_failure) = match token_status { + TokenStatus::Cancelled => { + token_tx + .send(AuthorizationState::Cancelled) + .await + .expect("Failed to send token"); + + ("Token Cancelled by the user", false) + } + TokenStatus::Authorized => { + if let Some(token) = token { + token_tx + .send(AuthorizationState::TokenSuccess(token.clone())) + .await + .expect("Failed to send token"); + ("Token Authorized", false) + } else { + ("Token not found", true) + } + } + }; + + server_shutdown_tx + .send(true) + .await + .expect("Failed to send shutdown signal"); + + let status = if parse_failure { + StatusCode::BAD_REQUEST + } else { + StatusCode::OK + }; + + Ok(Response::builder() + .status(status) + .header("Access-Control-Allow-Origin", "*") // FIXME: this is not secure, Don't allow all origins. @syrusakbary + .header("Access-Control-Allow-Headers", "Content-Type") + .header("Access-Control-Allow-Methods", "POST, OPTIONS") + .body(Body::from(response_message))?) +} + +async fn handle_unknown_method( + context: BrowserAuthContext, +) -> Result, anyhow::Error> { + let BrowserAuthContext { + server_shutdown_tx, + token_tx, + } = context; + + token_tx + .send(AuthorizationState::UnknownMethod) + .await + .expect("Failed to send token"); + + server_shutdown_tx + .send(true) + .await + .expect("Failed to send shutdown signal"); + + Ok(Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Body::from("Method not allowed"))?) +} diff --git a/lib/cli/src/commands/auth/login/mod.rs b/lib/cli/src/commands/auth/login/mod.rs new file mode 100644 index 00000000000..0c3a2bf8dee --- /dev/null +++ b/lib/cli/src/commands/auth/login/mod.rs @@ -0,0 +1,402 @@ +mod auth_server; +use auth_server::*; +use colored::Colorize; +use hyper::{server::conn::http1::Builder, service::service_fn}; +use hyper_util::server::graceful::GracefulShutdown; + +use crate::{ + commands::AsyncCliCommand, + config::{UpdateRegistry, UserRegistry, WasmerConfig, WasmerEnv}, +}; +use futures_util::{stream::FuturesUnordered, StreamExt}; +use std::{path::PathBuf, time::Duration}; +use wasmer_api::{types::Nonce, WasmerClient}; + +#[derive(Debug, Clone)] +enum AuthorizationState { + TokenSuccess(String), + Cancelled, + TimedOut, + UnknownMethod, +} + +/// Subcommand for log in a user into Wasmer (using a browser or provided a token) +#[derive(Debug, Clone, clap::Parser)] +pub struct Login { + /// Variable to login without opening a browser + #[clap(long, name = "no-browser", default_value = "false")] + pub no_browser: bool, + + // This is a copy of [`WasmerEnv`] to allow users to specify + // the token as a parameter rather than as a flag. + /// Set Wasmer's home directory + #[clap(long, env = "WASMER_DIR", default_value = crate::config::DEFAULT_WASMER_DIR.as_os_str())] + pub wasmer_dir: PathBuf, + + /// The directory cached artefacts are saved to. + #[clap(long, env = "WASMER_CACHE_DIR", default_value = crate::config::DEFAULT_WASMER_CACHE_DIR.as_os_str())] + pub cache_dir: PathBuf, + + /// The API token to use when communicating with the registry (inferred from the environment by default) + #[clap(env = "WASMER_TOKEN")] + pub token: Option, + + /// Change the current registry + #[clap(long, env = "WASMER_REGISTRY")] + pub registry: Option, +} + +impl Login { + fn get_token_from_env_or_user( + &self, + env: &WasmerEnv, + ) -> Result { + if let Some(token) = &self.token { + return Ok(AuthorizationState::TokenSuccess(token.clone())); + } + + let registry_host = env.registry_endpoint()?; + let registry_tld = tldextract::TldExtractor::new(tldextract::TldOption::default()) + .extract(registry_host.as_str()) + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Invalid registry for login {}: {e}", registry_host), + ) + })?; + + let login_prompt = match ( + registry_tld.domain.as_deref(), + registry_tld.suffix.as_deref(), + ) { + (Some(d), Some(s)) => { + format!("Please paste the login token from https://{d}.{s}/settings/access-tokens") + } + _ => "Please paste the login token".to_string(), + }; + #[cfg(test)] + { + Ok(AuthorizationState::TokenSuccess(login_prompt)) + } + #[cfg(not(test))] + { + let token = dialoguer::Input::new() + .with_prompt(&login_prompt) + .interact_text()?; + Ok(AuthorizationState::TokenSuccess(token)) + } + } + + async fn get_token_from_browser( + &self, + client: &WasmerClient, + ) -> anyhow::Result { + let (listener, server_url) = setup_listener().await?; + + let (server_shutdown_tx, mut server_shutdown_rx) = tokio::sync::mpsc::channel::(1); + let (token_tx, mut token_rx) = tokio::sync::mpsc::channel::(1); + + // Create a new AppContext + let app_context = BrowserAuthContext { + server_shutdown_tx, + token_tx, + }; + + let Nonce { auth_url, .. } = + wasmer_api::query::create_nonce(client, "wasmer-cli".to_string(), server_url) + .await? + .ok_or_else(|| { + anyhow::anyhow!("The backend did not return any nonce to auth the login!") + })?; + + // if failed to open the browser, then don't error out just print the auth_url with a message + println!("Opening auth link in your default browser: {}", &auth_url); + opener::open_browser(&auth_url).unwrap_or_else(|_| { + println!( + "⚠️ Failed to open the browser.\n + Please open the url: {}", + &auth_url + ); + }); + + // Jump through hyper 1.0's hoops... + let graceful = GracefulShutdown::new(); + + let http = Builder::new(); + + let mut futs = FuturesUnordered::new(); + + let service = service_fn(move |req| service_router(app_context.clone(), req)); + + print!("Waiting for session... "); + + // start the server + loop { + tokio::select! { + Result::Ok((stream, _addr)) = listener.accept() => { + let io = hyper_util::rt::tokio::TokioIo::new(stream); + let conn = http.serve_connection(io, service.clone()); + // watch this connection + let fut = graceful.watch(conn); + futs.push(async move { + if let Err(e) = fut.await { + eprintln!("Error serving connection: {:?}", e); + } + }); + }, + + _ = futs.next() => {} + + _ = server_shutdown_rx.recv() => { + // stop the accept loop + break; + } + } + } + + // receive the token from the server + let token = token_rx + .recv() + .await + .ok_or_else(|| anyhow::anyhow!("❌ Failed to receive token from localhost"))?; + + Ok(token) + } + + async fn do_login(&self, env: &WasmerEnv) -> anyhow::Result { + let client = env.client_unauthennticated()?; + + let should_login = if let Some(user) = wasmer_api::query::current_user(&client).await? { + #[cfg(not(test))] + { + println!( + "You are already logged in as {} in registry {}.", + user.username.bold(), + env.registry_public_url()?.host_str().unwrap().bold() + ); + let theme = dialoguer::theme::ColorfulTheme::default(); + let dialog = dialoguer::Confirm::with_theme(&theme).with_prompt("Login again?"); + + dialog.interact()? + } + #[cfg(test)] + { + false + } + } else { + true + }; + + if !should_login { + Ok(AuthorizationState::Cancelled) + } else if self.no_browser { + self.get_token_from_env_or_user(env) + } else { + // switch between two methods of getting the token. + // start two async processes, 10 minute timeout and get token from browser. Whichever finishes first, use that. + let timeout_future = tokio::time::sleep(Duration::from_secs(60 * 10)); + tokio::select! { + _ = timeout_future => { + Ok(AuthorizationState::TimedOut) + }, + token = self.get_token_from_browser(&client) => { + token + } + } + } + } + + async fn login_and_save(&self, env: &WasmerEnv, token: String) -> anyhow::Result { + let registry = env.registry_endpoint()?; + let mut config = WasmerConfig::from_file(env.dir()) + .map_err(|e| anyhow::anyhow!("config from file: {e}"))?; + config + .registry + .set_current_registry(registry.as_ref()) + .await; + config.registry.set_login_token_for_registry( + &config.registry.get_current_registry(), + &token, + UpdateRegistry::Update, + ); + let path = WasmerConfig::get_file_location(env.dir()); + config.save(path)?; + + // This will automatically read the config again, picking up the new edits. + let client = env.client()?; + + wasmer_api::query::current_user(&client) + .await? + .map(|v| v.username) + .ok_or_else(|| anyhow::anyhow!("Not logged in!")) + } + + pub(crate) fn get_wasmer_env(&self) -> WasmerEnv { + WasmerEnv::new( + self.wasmer_dir.clone(), + self.cache_dir.clone(), + self.token.clone(), + self.registry.clone(), + ) + } +} + +#[async_trait::async_trait] +impl AsyncCliCommand for Login { + type Output = (); + + async fn run_async(self) -> Result { + let env = self.get_wasmer_env(); + + let auth_state = match &self.token { + Some(token) => AuthorizationState::TokenSuccess(token.clone()), + None => self.do_login(&env).await?, + }; + + match auth_state { + AuthorizationState::TokenSuccess(token) => { + match self.login_and_save(&env, token).await { + Ok(s) => { + print!("Done!"); + println!("\n{} Login for Wasmer user {:?} saved","✔".green().bold(), s) + } + Err(_) => print!( + "Warning: no user found on {:?} with the provided token.\nToken saved regardless.", + env.registry_public_url() + ), + } + } + AuthorizationState::TimedOut => { + print!("Timed out (10 mins exceeded)"); + } + AuthorizationState::Cancelled => { + println!("Cancelled by the user"); + } + AuthorizationState::UnknownMethod => { + println!("Error: unknown method\n"); + } + }; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use clap::CommandFactory; + use tempfile::TempDir; + + use crate::commands::CliCommand; + + use super::*; + + #[test] + fn interactive_login() { + let temp = TempDir::new().unwrap(); + let login = Login { + no_browser: true, + registry: Some("wasmer.wtf".into()), + wasmer_dir: temp.path().to_path_buf(), + token: None, + cache_dir: temp.path().join("cache").to_path_buf(), + }; + let env = login.get_wasmer_env(); + + let token = login.get_token_from_env_or_user(&env).unwrap(); + match token { + AuthorizationState::TokenSuccess(token) => { + assert_eq!( + token, + "Please paste the login token from https://wasmer.wtf/settings/access-tokens" + ); + } + AuthorizationState::Cancelled + | AuthorizationState::TimedOut + | AuthorizationState::UnknownMethod => { + panic!("Should not reach here") + } + } + } + + #[test] + fn login_with_token() { + let temp = TempDir::new().unwrap(); + let login = Login { + no_browser: true, + registry: Some("wasmer.wtf".into()), + wasmer_dir: temp.path().to_path_buf(), + token: Some("abc".to_string()), + cache_dir: temp.path().join("cache").to_path_buf(), + }; + let env = login.get_wasmer_env(); + + let token = login.get_token_from_env_or_user(&env).unwrap(); + + match token { + AuthorizationState::TokenSuccess(token) => { + assert_eq!(token, "abc"); + } + AuthorizationState::Cancelled + | AuthorizationState::TimedOut + | AuthorizationState::UnknownMethod => { + panic!("Should not reach here") + } + } + } + + #[test] + fn in_sync_with_wasmer_env() { + let wasmer_env = WasmerEnv::command(); + let login = Login::command(); + + // All options except --token should be the same + let wasmer_env_opts: Vec<_> = wasmer_env + .get_opts() + .filter(|arg| arg.get_id() != "token") + .collect(); + let login_opts: Vec<_> = login.get_opts().collect(); + + assert_eq!(wasmer_env_opts, login_opts); + + // The token argument should have the same message, even if it is an + // argument rather than a --flag. + let wasmer_env_token_help = wasmer_env + .get_opts() + .find(|arg| arg.get_id() == "token") + .unwrap() + .get_help() + .unwrap() + .to_string(); + let login_token_help = login + .get_positionals() + .find(|arg| arg.get_id() == "token") + .unwrap() + .get_help() + .unwrap() + .to_string(); + assert_eq!(wasmer_env_token_help, login_token_help); + } + + /// Regression test for panics on API errors. + /// See https://github.com/wasmerio/wasmer/issues/4147. + #[test] + fn login_with_invalid_token_does_not_panic() { + let cmd = Login { + no_browser: true, + wasmer_dir: crate::config::DEFAULT_WASMER_DIR.clone(), + registry: Some("http://localhost:11".to_string().into()), + token: Some("invalid".to_string()), + cache_dir: crate::config::DEFAULT_WASMER_CACHE_DIR.clone(), + }; + + let res = cmd.run(); + // The CLI notices that either the registry is unreachable or the token is not tied to any + // user. It shows a warning to the user, but does not return with an error code. + // + // ------ i.e. this will fail + // | + // v + // assert!(res.is_err()); + assert!(res.is_ok()); + } +} diff --git a/lib/cli/src/commands/auth/logout.rs b/lib/cli/src/commands/auth/logout.rs new file mode 100644 index 00000000000..5e6b9f76d72 --- /dev/null +++ b/lib/cli/src/commands/auth/logout.rs @@ -0,0 +1,110 @@ +use crate::{ + commands::AsyncCliCommand, + config::{WasmerConfig, WasmerEnv, DEFAULT_PROD_REGISTRY}, +}; +use colored::Colorize; +use is_terminal::IsTerminal; + +/// Subcommand for log in a user into Wasmer (using a browser or provided a token) +#[derive(Debug, Clone, clap::Parser)] +pub struct Logout { + #[clap(flatten)] + env: WasmerEnv, + + /// Do not prompt for user input. + #[clap(long, default_value_t = !std::io::stdin().is_terminal())] + pub non_interactive: bool, + + /// Whether or not to revoke the associated token + #[clap(long)] + pub revoke_token: bool, +} + +#[async_trait::async_trait] +impl AsyncCliCommand for Logout { + type Output = (); + + async fn run_async(self) -> Result { + let registry = self.env.registry_endpoint()?.to_string(); + let host_str = self + .env + .registry_public_url() + .map_err(|_| anyhow::anyhow!("No registry not specified!"))? + .host_str() + .unwrap() + .bold(); + + let client = self + .env + .client() + .map_err(|_| anyhow::anyhow!("Not logged into registry {host_str}"))?; + + let user = wasmer_api::query::current_user(&client) + .await + .map_err(|e| anyhow::anyhow!("Not logged into registry {host_str}: {e}"))? + .ok_or_else(|| anyhow::anyhow!("Not logged into registry {host_str}"))?; + + let theme = dialoguer::theme::ColorfulTheme::default(); + let prompt = dialoguer::Confirm::with_theme(&theme).with_prompt(format!( + "Log user {} out of registry {host_str}?", + user.username + )); + + if prompt.interact()? || self.non_interactive { + let mut config = self.env.config()?; + let token = config + .registry + .get_login_token_for_registry(®istry) + .unwrap(); + config.registry.remove_registry(®istry); + if config.registry.is_current_registry(®istry) { + if config.registry.tokens.is_empty() { + _ = config + .registry + .set_current_registry(DEFAULT_PROD_REGISTRY) + .await; + } else { + let new_reg = config.registry.tokens[0].registry.clone(); + _ = config.registry.set_current_registry(&new_reg).await; + } + } + let path = WasmerConfig::get_file_location(self.env.dir()); + config.save(path)?; + + // Read it again.. + // + let config = self.env.config()?; + if config + .registry + .get_login_token_for_registry(®istry) + .is_none() + { + println!( + "User {} correctly logged out of registry {host_str}", + user.username.bold() + ); + + let should_revoke = self.revoke_token || { + let theme = dialoguer::theme::ColorfulTheme::default(); + dialoguer::Confirm::with_theme(&theme) + .with_prompt("Revoke token?") + .interact()? + }; + + if should_revoke { + wasmer_api::query::revoke_token(&client, token).await?; + println!( + "Token for user {} in registry {host_str} correctly revoked", + user.username.bold() + ); + } + } else { + anyhow::bail!("Something went wrong! User is not logged out.") + } + } else { + println!("No action taken."); + } + + Ok(()) + } +} diff --git a/lib/cli/src/commands/auth/mod.rs b/lib/cli/src/commands/auth/mod.rs new file mode 100644 index 00000000000..803413214b8 --- /dev/null +++ b/lib/cli/src/commands/auth/mod.rs @@ -0,0 +1,5 @@ +mod login; +mod logout; + +pub use login::*; +pub use logout::*; diff --git a/lib/cli/src/commands/connect.rs b/lib/cli/src/commands/connect.rs index 42211ac196c..dee96296ca3 100644 --- a/lib/cli/src/commands/connect.rs +++ b/lib/cli/src/commands/connect.rs @@ -1,13 +1,14 @@ use std::net::IpAddr; +use crate::config::WasmerEnv; + use super::AsyncCliCommand; -use crate::opts::ApiOpts; /// Connects to the Wasmer Edge distributed network. #[derive(clap::Parser, Debug)] pub struct CmdConnect { #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, /// Runs in promiscuous mode #[clap(long)] diff --git a/lib/cli/src/commands/domain/get.rs b/lib/cli/src/commands/domain/get.rs index 81e15a29283..ace53e8d328 100644 --- a/lib/cli/src/commands/domain/get.rs +++ b/lib/cli/src/commands/domain/get.rs @@ -1,15 +1,13 @@ -use crate::{ - commands::AsyncCliCommand, - opts::{ApiOpts, ItemTableFormatOpts}, -}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemTableFormatOpts}; /// Show a domain #[derive(clap::Parser, Debug)] pub struct CmdDomainGet { #[clap(flatten)] fmt: ItemTableFormatOpts, + #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, /// Name of the domain. name: String, @@ -20,7 +18,7 @@ impl AsyncCliCommand for CmdDomainGet { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; if let Some(domain) = wasmer_api::query::get_domain_with_records(&client, self.name).await? { println!("{}", self.fmt.format.render(&domain)); diff --git a/lib/cli/src/commands/domain/list.rs b/lib/cli/src/commands/domain/list.rs index 14ba9f634db..d2858bbd7de 100644 --- a/lib/cli/src/commands/domain/list.rs +++ b/lib/cli/src/commands/domain/list.rs @@ -1,17 +1,15 @@ use wasmer_api::types::GetAllDomainsVariables; -use crate::{ - commands::AsyncCliCommand, - opts::{ApiOpts, ListFormatOpts}, -}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ListFormatOpts}; /// List domains. #[derive(clap::Parser, Debug)] pub struct CmdDomainList { #[clap(flatten)] fmt: ListFormatOpts, + #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, /// Name of the namespace. namespace: Option, @@ -22,7 +20,7 @@ impl AsyncCliCommand for CmdDomainList { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; let domains = wasmer_api::query::get_all_domains( &client, GetAllDomainsVariables { diff --git a/lib/cli/src/commands/domain/register.rs b/lib/cli/src/commands/domain/register.rs index ef2d5f3782e..97c954ca72c 100644 --- a/lib/cli/src/commands/domain/register.rs +++ b/lib/cli/src/commands/domain/register.rs @@ -1,15 +1,13 @@ -use crate::{ - commands::AsyncCliCommand, - opts::{ApiOpts, ItemTableFormatOpts}, -}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemTableFormatOpts}; /// Show a domain #[derive(clap::Parser, Debug)] pub struct CmdDomainRegister { #[clap(flatten)] fmt: ItemTableFormatOpts, + #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, /// Name of the domain. name: String, @@ -28,7 +26,7 @@ impl AsyncCliCommand for CmdDomainRegister { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; let domain = wasmer_api::query::register_domain( &client, self.name, diff --git a/lib/cli/src/commands/domain/zonefile.rs b/lib/cli/src/commands/domain/zonefile.rs index 692e4cb5b3a..830c9f820df 100644 --- a/lib/cli/src/commands/domain/zonefile.rs +++ b/lib/cli/src/commands/domain/zonefile.rs @@ -1,7 +1,4 @@ -use crate::{ - commands::AsyncCliCommand, - opts::{ApiOpts, ItemFormatOpts}, -}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts}; use anyhow::Context; #[derive(clap::Parser, Debug)] @@ -11,7 +8,7 @@ pub struct CmdZoneFileGet { fmt: ItemFormatOpts, #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, /// Name of the domain. domain_name: String, @@ -25,7 +22,7 @@ pub struct CmdZoneFileGet { /// Show a zone file pub struct CmdZoneFileSync { #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, /// filename of zone-file to sync zone_file_path: String, @@ -40,7 +37,7 @@ impl AsyncCliCommand for CmdZoneFileGet { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; if let Some(domain) = wasmer_api::query::get_domain_zone_file(&client, self.domain_name).await? { @@ -66,7 +63,7 @@ impl AsyncCliCommand for CmdZoneFileSync { let data = std::fs::read(&self.zone_file_path).context("Unable to read file")?; let zone_file_contents = String::from_utf8(data).context("Not a valid UTF-8 sequence")?; let domain = wasmer_api::query::upsert_domain_from_zone_file( - &self.api.client()?, + &self.env.client()?, zone_file_contents, !self.no_delete_missing_records, ) diff --git a/lib/cli/src/commands/login.rs b/lib/cli/src/commands/login.rs deleted file mode 100644 index e1040621ddb..00000000000 --- a/lib/cli/src/commands/login.rs +++ /dev/null @@ -1,559 +0,0 @@ -use std::{path::PathBuf, str::FromStr, time::Duration}; - -use anyhow::Ok; -use clap::Parser; -use colored::Colorize; -#[cfg(not(test))] -use dialoguer::{console::style, Input}; -use futures::{stream::FuturesUnordered, StreamExt}; -use http_body_util::BodyExt; -use hyper::{body::Incoming, service::service_fn, Request, Response, StatusCode}; -use reqwest::{Body, Method}; -use serde::Deserialize; -use tokio::net::TcpListener; -use wasmer_registry::{ - types::NewNonceOutput, - wasmer_env::{Registry, WasmerEnv, WASMER_DIR}, - RegistryClient, -}; - -const WASMER_CLI: &str = "wasmer-cli"; - -/// Payload from the frontend after the user has authenticated. -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum TokenStatus { - /// Signifying that the token is cancelled - Cancelled, - /// Signifying that the token is authorized - Authorized, -} - -/// Payload from the frontend after the user has authenticated. -/// -/// This has the token that we need to set in the WASMER_TOML file. -#[derive(Clone, Debug, Deserialize)] -pub struct ValidatedNonceOutput { - /// Token Received from the frontend - pub token: Option, - /// Status of the token , whether it is authorized or cancelled - pub status: TokenStatus, -} - -/// Enum for the boolean like prompt options -#[derive(Debug, Clone, PartialEq)] -pub enum BoolPromptOptions { - /// Signifying a yes/true - using `y/Y` - Yes, - /// Signifying a No/false - using `n/N` - No, -} - -impl FromStr for BoolPromptOptions { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - match s { - "y" | "Y" => Ok(BoolPromptOptions::Yes), - "n" | "N" => Ok(BoolPromptOptions::No), - _ => Err(anyhow::anyhow!("Invalid option")), - } - } -} - -impl ToString for BoolPromptOptions { - fn to_string(&self) -> String { - match self { - BoolPromptOptions::Yes => "y".to_string(), - BoolPromptOptions::No => "n".to_string(), - } - } -} - -type Token = String; - -#[derive(Debug, Clone)] -enum AuthorizationState { - TokenSuccess(Token), - Cancelled, - TimedOut, - UnknownMethod, -} - -#[derive(Clone)] -struct AppContext { - server_shutdown_tx: tokio::sync::mpsc::Sender, - token_tx: tokio::sync::mpsc::Sender, -} - -/// Subcommand for log in a user into Wasmer (using a browser or provided a token) -#[derive(Debug, Clone, Parser)] -pub struct Login { - /// Variable to login without opening a browser - #[clap(long, name = "no-browser", default_value = "false")] - pub no_browser: bool, - // Note: This is essentially a copy of WasmerEnv except the token is - // accepted as a main argument instead of via --token. - /// Set Wasmer's home directory - #[clap(long, env = "WASMER_DIR", default_value = WASMER_DIR.as_os_str())] - pub wasmer_dir: PathBuf, - /// The registry to fetch packages from (inferred from the environment by - /// default) - #[clap(long, env = "WASMER_REGISTRY")] - pub registry: Option, - /// The API token to use when communicating with the registry (inferred from - /// the environment by default) - pub token: Option, - /// The directory cached artefacts are saved to. - #[clap(long, env = "WASMER_CACHE_DIR")] - pub cache_dir: Option, -} - -impl Login { - fn get_token_or_ask_user(&self, env: &WasmerEnv) -> Result { - if let Some(token) = &self.token { - return Ok(AuthorizationState::TokenSuccess(token.clone())); - } - - let registry_host = env.registry_endpoint()?; - let registry_tld = tldextract::TldExtractor::new(tldextract::TldOption::default()) - .extract(registry_host.as_str()) - .map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("Invalid registry for login {}: {e}", registry_host), - ) - })?; - - let login_prompt = match ( - registry_tld.domain.as_deref(), - registry_tld.suffix.as_deref(), - ) { - (Some(d), Some(s)) => { - format!("Please paste the login token from https://{d}.{s}/settings/access-tokens") - } - _ => "Please paste the login token".to_string(), - }; - #[cfg(test)] - { - Ok(AuthorizationState::TokenSuccess(login_prompt)) - } - #[cfg(not(test))] - { - let token = Input::new().with_prompt(&login_prompt).interact_text()?; - Ok(AuthorizationState::TokenSuccess(token)) - } - } - - async fn get_token_from_browser( - &self, - env: &WasmerEnv, - ) -> Result { - let registry = env.registry_endpoint()?; - - let client = RegistryClient::new(registry.clone(), None, None); - - let (listener, server_url) = Self::setup_listener().await?; - - let (server_shutdown_tx, mut server_shutdown_rx) = tokio::sync::mpsc::channel::(1); - let (token_tx, mut token_rx) = tokio::sync::mpsc::channel::(1); - - // Create a new AppContext - let app_context = AppContext { - server_shutdown_tx, - token_tx, - }; - - let NewNonceOutput { auth_url } = - wasmer_registry::api::create_nonce(&client, WASMER_CLI.to_string(), server_url).await?; - - // if failed to open the browser, then don't error out just print the auth_url with a message - println!("Opening auth link in your default browser: {}", &auth_url); - opener::open_browser(&auth_url).unwrap_or_else(|_| { - println!( - "⚠️ Failed to open the browser.\n - Please open the url: {}", - &auth_url - ); - }); - - // Jump through hyper 1.0's hoops... - let graceful = hyper_util::server::graceful::GracefulShutdown::new(); - - let http = hyper::server::conn::http1::Builder::new(); - - let mut futs = FuturesUnordered::new(); - - let service = service_fn(move |req| service_router(app_context.clone(), req)); - - print!("Waiting for session... "); - - // start the server - loop { - tokio::select! { - Result::Ok((stream, _addr)) = listener.accept() => { - let io = hyper_util::rt::tokio::TokioIo::new(stream); - let conn = http.serve_connection(io, service.clone()); - // watch this connection - let fut = graceful.watch(conn); - futs.push(async move { - if let Err(e) = fut.await { - eprintln!("Error serving connection: {:?}", e); - } - }); - }, - - _ = futs.next() => {} - - _ = server_shutdown_rx.recv() => { - // stop the accept loop - break; - } - } - } - - // receive the token from the server - let token = token_rx - .recv() - .await - .ok_or_else(|| anyhow::anyhow!("❌ Failed to receive token from localhost"))?; - - Ok(token) - } - - fn wasmer_env(&self) -> WasmerEnv { - WasmerEnv::new( - self.wasmer_dir.clone(), - self.registry.clone(), - self.token.clone(), - self.cache_dir.clone(), - ) - } - - async fn setup_listener() -> Result<(TcpListener, String), anyhow::Error> { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let port = addr.port(); - - let server_url = format!("http://localhost:{}", port); - - Ok((listener, server_url)) - } - - pub async fn run_async(&self) -> Result<(), anyhow::Error> { - let env = self.wasmer_env(); - let registry = env.registry_endpoint()?; - - let auth_state = match self.token.clone() { - Some(token) => Ok(AuthorizationState::TokenSuccess(token)), - None => { - let person_wants_to_login = - match wasmer_registry::whoami(env.dir(), Some(registry.as_str()), None) { - std::result::Result::Ok((registry, user)) => { - println!( - "You are already logged in as {:?} on registry {:?}", - user, registry - ); - - #[cfg(not(test))] - { - let login_again = Input::new() - .with_prompt(format!( - "{} {} - [y/{}]", - style("?").yellow().bold(), - style("Do you want to login again?").bright().bold(), - style("N").green().bold() - )) - .show_default(false) - .default(BoolPromptOptions::No) - .interact_text()?; - - login_again == BoolPromptOptions::Yes - } - #[cfg(test)] - { - false - } - } - _ => true, - }; - - if !person_wants_to_login { - Ok(AuthorizationState::Cancelled) - } else if self.no_browser { - self.get_token_or_ask_user(&env) - } else { - // switch between two methods of getting the token. - // start two async processes, 10 minute timeout and get token from browser. Whichever finishes first, use that. - let timeout_future = tokio::time::sleep(Duration::from_secs(60 * 10)); - tokio::select! { - _ = timeout_future => { - Ok(AuthorizationState::TimedOut) - }, - token = self.get_token_from_browser(&env) => { - token - } - } - } - } - }?; - - match auth_state { - AuthorizationState::TokenSuccess(token) => { - let res = std::thread::spawn({ - let dir = env.dir().to_owned(); - let registry = registry.clone(); - move || { - wasmer_registry::login::login_and_save_token( - &dir, - registry.as_str(), - &token, - ) - } - }) - .join() - .map_err(|err| anyhow::format_err!("handler thread died: {err:?}"))??; - - match res { - Some(s) => { - print!("Done!"); - println!("\n{} Login for Wasmer user {:?} saved","✔".green().bold(), s) - } - None => print!( - "Warning: no user found on {:?} with the provided token.\nToken saved regardless.", - registry.domain().unwrap_or("registry.wasmer.io") - ), - }; - } - AuthorizationState::TimedOut => { - print!("Timed out (10 mins exceeded)"); - } - AuthorizationState::Cancelled => { - println!("Cancelled by the user"); - } - AuthorizationState::UnknownMethod => { - println!("Error: unknown method\n"); - } - }; - Ok(()) - } - - /// execute [List] - #[tokio::main] - pub async fn execute(&self) -> Result<(), anyhow::Error> { - self.run_async().await - } -} - -async fn preflight(_: Request) -> Result, anyhow::Error> { - let response = Response::builder() - .status(StatusCode::OK) - .header("Access-Control-Allow-Origin", "*") // FIXME: this is not secure, Don't allow all origins. @syrusakbary - .header("Access-Control-Allow-Headers", "Content-Type") - .header("Access-Control-Allow-Methods", "POST, OPTIONS") - .body(Body::default())?; - Ok(response) -} - -async fn handle_post_save_token( - context: AppContext, - req: Request, -) -> Result, anyhow::Error> { - let AppContext { - server_shutdown_tx, - token_tx, - } = context; - let (.., body) = req.into_parts(); - let body = body.collect().await?.to_bytes(); - - let ValidatedNonceOutput { - token, - status: token_status, - } = serde_json::from_slice::(&body)?; - - // send the AuthorizationState based on token_status to the main thread and get the response message - let (response_message, parse_failure) = match token_status { - TokenStatus::Cancelled => { - token_tx - .send(AuthorizationState::Cancelled) - .await - .expect("Failed to send token"); - - ("Token Cancelled by the user", false) - } - TokenStatus::Authorized => { - if let Some(token) = token { - token_tx - .send(AuthorizationState::TokenSuccess(token.clone())) - .await - .expect("Failed to send token"); - ("Token Authorized", false) - } else { - ("Token not found", true) - } - } - }; - - server_shutdown_tx - .send(true) - .await - .expect("Failed to send shutdown signal"); - - let status = if parse_failure { - StatusCode::BAD_REQUEST - } else { - StatusCode::OK - }; - - Ok(Response::builder() - .status(status) - .header("Access-Control-Allow-Origin", "*") // FIXME: this is not secure, Don't allow all origins. @syrusakbary - .header("Access-Control-Allow-Headers", "Content-Type") - .header("Access-Control-Allow-Methods", "POST, OPTIONS") - .body(Body::from(response_message))?) -} - -async fn handle_unknown_method(context: AppContext) -> Result, anyhow::Error> { - let AppContext { - server_shutdown_tx, - token_tx, - } = context; - - token_tx - .send(AuthorizationState::UnknownMethod) - .await - .expect("Failed to send token"); - - server_shutdown_tx - .send(true) - .await - .expect("Failed to send shutdown signal"); - - Ok(Response::builder() - .status(StatusCode::METHOD_NOT_ALLOWED) - .body(Body::from("Method not allowed"))?) -} - -/// Handle the preflight headers first - OPTIONS request -/// Then proceed to handle the actual request - POST request -async fn service_router( - context: AppContext, - req: Request, -) -> Result, anyhow::Error> { - match *req.method() { - Method::OPTIONS => preflight(req).await, - Method::POST => handle_post_save_token(context, req).await, - _ => handle_unknown_method(context).await, - } -} - -#[cfg(test)] -mod tests { - use clap::CommandFactory; - use tempfile::TempDir; - - use super::*; - - #[test] - fn interactive_login() { - let temp = TempDir::new().unwrap(); - let login = Login { - no_browser: true, - registry: Some("wasmer.wtf".into()), - wasmer_dir: temp.path().to_path_buf(), - token: None, - cache_dir: None, - }; - let env = login.wasmer_env(); - - let token = login.get_token_or_ask_user(&env).unwrap(); - match token { - AuthorizationState::TokenSuccess(token) => { - assert_eq!( - token, - "Please paste the login token from https://wasmer.wtf/settings/access-tokens" - ); - } - AuthorizationState::Cancelled - | AuthorizationState::TimedOut - | AuthorizationState::UnknownMethod => { - panic!("Should not reach here") - } - } - } - - #[test] - fn login_with_token() { - let temp = TempDir::new().unwrap(); - let login = Login { - no_browser: true, - registry: Some("wasmer.wtf".into()), - wasmer_dir: temp.path().to_path_buf(), - token: Some("abc".to_string()), - cache_dir: None, - }; - let env = login.wasmer_env(); - - let token = login.get_token_or_ask_user(&env).unwrap(); - - match token { - AuthorizationState::TokenSuccess(token) => { - assert_eq!(token, "abc"); - } - AuthorizationState::Cancelled - | AuthorizationState::TimedOut - | AuthorizationState::UnknownMethod => { - panic!("Should not reach here") - } - } - } - - #[test] - fn in_sync_with_wasmer_env() { - let wasmer_env = WasmerEnv::command(); - let login = Login::command(); - - // All options except --token should be the same - let wasmer_env_opts: Vec<_> = wasmer_env - .get_opts() - .filter(|arg| arg.get_id() != "token") - .collect(); - let login_opts: Vec<_> = login.get_opts().collect(); - - assert_eq!(wasmer_env_opts, login_opts); - - // The token argument should have the same message, even if it is an - // argument rather than a --flag. - let wasmer_env_token_help = wasmer_env - .get_opts() - .find(|arg| arg.get_id() == "token") - .unwrap() - .get_help() - .unwrap() - .to_string(); - let login_token_help = login - .get_positionals() - .find(|arg| arg.get_id() == "token") - .unwrap() - .get_help() - .unwrap() - .to_string(); - assert_eq!(wasmer_env_token_help, login_token_help); - } - - /// Regression test for panics on API errors. - /// See https://github.com/wasmerio/wasmer/issues/4147. - #[test] - fn login_with_invalid_token_does_not_panic() { - let cmd = Login { - no_browser: true, - wasmer_dir: WASMER_DIR.clone(), - registry: Some("http://localhost:11".to_string().into()), - token: Some("invalid".to_string()), - cache_dir: None, - }; - - let res = cmd.execute(); - assert!(res.is_err()); - } -} diff --git a/lib/cli/src/commands/mod.rs b/lib/cli/src/commands/mod.rs index f85cf205257..42f40ed918a 100644 --- a/lib/cli/src/commands/mod.rs +++ b/lib/cli/src/commands/mod.rs @@ -1,6 +1,7 @@ //! The commands available in the Wasmer binary. mod add; mod app; +mod auth; #[cfg(target_os = "linux")] mod binfmt; mod cache; @@ -22,7 +23,6 @@ mod init; mod inspect; #[cfg(feature = "journal")] mod journal; -mod login; pub(crate) mod namespace; mod package; mod run; @@ -50,7 +50,7 @@ pub use {create_obj::*, gen_c_header::*}; #[cfg(feature = "journal")] pub use self::journal::*; pub use self::{ - add::*, cache::*, config::*, container::*, init::*, inspect::*, login::*, package::*, + add::*, auth::*, cache::*, config::*, container::*, init::*, inspect::*, package::*, publish::*, run::Run, self_update::*, validate::*, whoami::*, }; use crate::error::PrettyError; @@ -185,7 +185,8 @@ impl WasmerCmd { Some(Cmd::Config(config)) => config.execute(), Some(Cmd::Inspect(inspect)) => inspect.execute(), Some(Cmd::Init(init)) => init.execute(), - Some(Cmd::Login(login)) => login.execute(), + Some(Cmd::Login(login)) => login.run(), + Some(Cmd::Logout(logout)) => logout.run(), Some(Cmd::Publish(publish)) => publish.run().map(|_| ()), Some(Cmd::Package(cmd)) => match cmd { Package::Download(cmd) => cmd.execute(), @@ -206,7 +207,7 @@ impl WasmerCmd { Some(Cmd::Wast(wast)) => wast.execute(), #[cfg(target_os = "linux")] Some(Cmd::Binfmt(binfmt)) => binfmt.execute(), - Some(Cmd::Whoami(whoami)) => whoami.execute(), + Some(Cmd::Whoami(whoami)) => whoami.run(), Some(Cmd::Add(install)) => install.execute(), // Deploy commands. @@ -289,6 +290,9 @@ enum Cmd { /// Login into a wasmer.io-like registry Login(Login), + /// Log out of the currently selected wasmer.io-like registry + Logout(Logout), + /// Publish a package to a registry [alias: package publish] #[clap(name = "publish")] Publish(crate::commands::package::publish::PackagePublish), diff --git a/lib/cli/src/commands/namespace/create.rs b/lib/cli/src/commands/namespace/create.rs index b7d2be5dbfd..adc6b3017f4 100644 --- a/lib/cli/src/commands/namespace/create.rs +++ b/lib/cli/src/commands/namespace/create.rs @@ -1,15 +1,13 @@ -use crate::{ - commands::AsyncCliCommand, - opts::{ApiOpts, ItemFormatOpts}, -}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts}; /// Create a new namespace. #[derive(clap::Parser, Debug)] pub struct CmdNamespaceCreate { #[clap(flatten)] fmt: ItemFormatOpts, + #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, /// Description of the namespace. #[clap(long)] @@ -24,7 +22,7 @@ impl AsyncCliCommand for CmdNamespaceCreate { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; let vars = wasmer_api::types::CreateNamespaceVars { name: self.name.clone(), diff --git a/lib/cli/src/commands/namespace/get.rs b/lib/cli/src/commands/namespace/get.rs index 1ae680f5381..48138de8309 100644 --- a/lib/cli/src/commands/namespace/get.rs +++ b/lib/cli/src/commands/namespace/get.rs @@ -1,17 +1,15 @@ use anyhow::Context; -use crate::{ - commands::AsyncCliCommand, - opts::{ApiOpts, ItemFormatOpts}, -}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts}; /// Show a namespace. #[derive(clap::Parser, Debug)] pub struct CmdNamespaceGet { #[clap(flatten)] fmt: ItemFormatOpts, + #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, /// Name of the namespace. name: String, @@ -22,7 +20,7 @@ impl AsyncCliCommand for CmdNamespaceGet { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; let namespace = wasmer_api::query::get_namespace(&client, self.name) .await? diff --git a/lib/cli/src/commands/namespace/list.rs b/lib/cli/src/commands/namespace/list.rs index 7804cb8fac5..a5e61fb368b 100644 --- a/lib/cli/src/commands/namespace/list.rs +++ b/lib/cli/src/commands/namespace/list.rs @@ -1,7 +1,4 @@ -use crate::{ - commands::AsyncCliCommand, - opts::{ApiOpts, ListFormatOpts}, -}; +use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ListFormatOpts}; /// List namespaces. #[derive(clap::Parser, Debug)] @@ -9,7 +6,7 @@ pub struct CmdNamespaceList { #[clap(flatten)] fmt: ListFormatOpts, #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, } #[async_trait::async_trait] @@ -17,7 +14,7 @@ impl AsyncCliCommand for CmdNamespaceList { type Output = (); async fn run_async(self) -> Result<(), anyhow::Error> { - let client = self.api.client()?; + let client = self.env.client()?; let namespaces = wasmer_api::query::user_namespaces(&client).await?; diff --git a/lib/cli/src/commands/package/common/mod.rs b/lib/cli/src/commands/package/common/mod.rs index 9e9ca102627..5641f96bfde 100644 --- a/lib/cli/src/commands/package/common/mod.rs +++ b/lib/cli/src/commands/package/common/mod.rs @@ -1,6 +1,6 @@ use crate::{ - commands::Login, - opts::{ApiOpts, WasmerEnv}, + commands::{AsyncCliCommand, Login}, + config::WasmerEnv, utils::load_package_manifest, }; use colored::Colorize; @@ -175,18 +175,17 @@ pub(super) fn get_manifest(path: &Path) -> anyhow::Result<(PathBuf, Manifest)> { } pub(super) async fn login_user( - api: &ApiOpts, env: &WasmerEnv, interactive: bool, msg: &str, ) -> anyhow::Result { - if let Ok(client) = api.client() { + if let Ok(client) = env.client() { return Ok(client); } let theme = dialoguer::theme::ColorfulTheme::default(); - if api.token.is_none() { + if env.token().is_none() { if interactive { eprintln!( "{}: You need to be logged in to {msg}.", @@ -199,17 +198,13 @@ pub(super) async fn login_user( { Login { no_browser: false, - wasmer_dir: env.wasmer_dir.clone(), - registry: api - .registry - .clone() - .map(|l| wasmer_registry::wasmer_env::Registry::from(l.to_string())), - token: api.token.clone(), - cache_dir: Some(env.cache_dir.clone()), + wasmer_dir: env.dir().to_path_buf(), + cache_dir: env.cache_dir().to_path_buf(), + token: None, + registry: env.registry.clone(), } .run_async() .await?; - // self.api = ApiOpts::default(); } else { anyhow::bail!("Stopping the flow as the user is not logged in.") } @@ -220,7 +215,7 @@ pub(super) async fn login_user( } } - api.client() + env.client() } pub(super) fn make_package_url(client: &WasmerClient, pkg: &NamedPackageIdent) -> String { diff --git a/lib/cli/src/commands/package/download.rs b/lib/cli/src/commands/package/download.rs index 2022c9d7c36..8a9a7a48ea9 100644 --- a/lib/cli/src/commands/package/download.rs +++ b/lib/cli/src/commands/package/download.rs @@ -7,14 +7,11 @@ use tempfile::NamedTempFile; use wasmer_config::package::{PackageIdent, PackageSource}; use wasmer_wasix::http::reqwest::get_proxy; -use crate::opts::{ApiOpts, WasmerEnv}; +use crate::config::WasmerEnv; /// Download a package from the registry. #[derive(clap::Parser, Debug)] pub struct PackageDownload { - #[clap(flatten)] - pub api: ApiOpts, - #[clap(flatten)] pub env: WasmerEnv, @@ -97,11 +94,10 @@ impl PackageDownload { let (download_url, ident, filename) = match &self.package { PackageSource::Ident(PackageIdent::Named(id)) => { - let client = if self.api.token.is_some() { - self.api.client() - } else { - self.api.client_unauthennticated() - }?; + // caveat: client_unauthennticated will use a token if provided, it + // just won't fail if none is present. So, _unauthenticated() can actually + // produce an authenticated client. + let client = self.env.client_unauthennticated()?; let version = id.version_or_default().to_string(); let version = if version == "*" { @@ -145,11 +141,10 @@ impl PackageDownload { (download_url, ident, filename) } PackageSource::Ident(PackageIdent::Hash(hash)) => { - let client = if self.api.token.is_some() { - self.api.client() - } else { - self.api.client_unauthennticated() - }?; + // caveat: client_unauthennticated will use a token if provided, it + // just won't fail if none is present. So, _unauthenticated() can actually + // produce an authenticated client. + let client = self.env.client_unauthennticated()?; let rt = tokio::runtime::Runtime::new()?; let pkg = rt.block_on(wasmer_api::query::get_package_release(&client, &hash.to_string()))? @@ -284,8 +279,9 @@ impl PackageDownload { #[cfg(test)] mod tests { + use crate::config::UserRegistry; + use super::*; - use std::str::FromStr; /// Download a package from the dev registry. #[test] @@ -295,11 +291,12 @@ mod tests { let out_path = dir.path().join("hello.webc"); let cmd = PackageDownload { - env: WasmerEnv::default(), - api: ApiOpts { - token: None, - registry: Some(url::Url::from_str("https://registry.wasmer.io/graphql").unwrap()), - }, + env: WasmerEnv::new( + crate::config::DEFAULT_WASMER_CACHE_DIR.clone(), + crate::config::DEFAULT_WASMER_CACHE_DIR.clone(), + None, + Some("https://registry.wasmer.io/graphql".to_owned().into()), + ), validate: true, out_path: Some(out_path.clone()), package: "wasmer/hello@0.1.0".parse().unwrap(), diff --git a/lib/cli/src/commands/package/publish.rs b/lib/cli/src/commands/package/publish.rs index e7e1a2dc3dd..4b3fbdaebe0 100644 --- a/lib/cli/src/commands/package/publish.rs +++ b/lib/cli/src/commands/package/publish.rs @@ -7,7 +7,7 @@ use crate::{ }, AsyncCliCommand, }, - opts::{ApiOpts, WasmerEnv}, + config::WasmerEnv, }; use colored::Colorize; use is_terminal::IsTerminal; @@ -18,9 +18,6 @@ use wasmer_config::package::{Manifest, PackageIdent}; /// Publish (push and tag) a package to the registry. #[derive(Debug, clap::Parser)] pub struct PackagePublish { - #[clap(flatten)] - pub api: ApiOpts, - #[clap(flatten)] pub env: WasmerEnv, @@ -91,7 +88,6 @@ impl PackagePublish { ) -> anyhow::Result { let (package_namespace, package_hash) = { let push_cmd = PackagePush { - api: self.api.clone(), env: self.env.clone(), dry_run: self.dry_run, quiet: self.quiet, @@ -107,7 +103,6 @@ impl PackagePublish { PackageTag { wait: self.wait, - api: self.api.clone(), env: self.env.clone(), dry_run: self.dry_run, quiet: self.quiet, @@ -138,13 +133,7 @@ impl AsyncCliCommand for PackagePublish { async fn run_async(self) -> Result { tracing::info!("Checking if user is logged in"); - let client = login_user( - &self.api, - &self.env, - !self.non_interactive, - "publish a package", - ) - .await?; + let client = login_user(&self.env, !self.non_interactive, "publish a package").await?; tracing::info!("Loading manifest"); let (manifest_path, manifest) = get_manifest(&self.package_path)?; diff --git a/lib/cli/src/commands/package/push.rs b/lib/cli/src/commands/package/push.rs index 38cb607d0c0..03de8e9aad7 100644 --- a/lib/cli/src/commands/package/push.rs +++ b/lib/cli/src/commands/package/push.rs @@ -1,7 +1,7 @@ use super::common::{macros::*, *}; use crate::{ commands::{AsyncCliCommand, PackageBuild}, - opts::{ApiOpts, WasmerEnv}, + config::WasmerEnv, }; use anyhow::Context; use colored::Colorize; @@ -17,9 +17,6 @@ use webc::wasmer_package::Package; /// pushed package. #[derive(Debug, clap::Parser)] pub struct PackagePush { - #[clap(flatten)] - pub api: ApiOpts, - #[clap(flatten)] pub env: WasmerEnv, @@ -223,13 +220,7 @@ impl AsyncCliCommand for PackagePush { async fn run_async(self) -> Result { tracing::info!("Checking if user is logged in"); - let client = login_user( - &self.api, - &self.env, - !self.non_interactive, - "push a package", - ) - .await?; + let client = login_user(&self.env, !self.non_interactive, "push a package").await?; tracing::info!("Loading manifest"); let (manifest_path, manifest) = get_manifest(&self.package_path)?; diff --git a/lib/cli/src/commands/package/tag.rs b/lib/cli/src/commands/package/tag.rs index c3099db0186..248033553ea 100644 --- a/lib/cli/src/commands/package/tag.rs +++ b/lib/cli/src/commands/package/tag.rs @@ -3,7 +3,7 @@ use crate::{ package::common::{macros::*, wait::wait_package, *}, AsyncCliCommand, }, - opts::{ApiOpts, WasmerEnv}, + config::WasmerEnv, }; use anyhow::Context; use colored::Colorize; @@ -23,9 +23,6 @@ use super::PublishWait; /// Tag an existing package. #[derive(Debug, clap::Parser)] pub struct PackageTag { - #[clap(flatten)] - pub api: ApiOpts, - #[clap(flatten)] pub env: WasmerEnv, @@ -571,8 +568,7 @@ impl AsyncCliCommand for PackageTag { async fn run_async(self) -> Result { tracing::info!("Checking if user is logged in"); - let client = - login_user(&self.api, &self.env, !self.non_interactive, "tag a package").await?; + let client = login_user(&self.env, !self.non_interactive, "tag a package").await?; let (manifest_path, manifest) = match get_manifest(&self.package_path) { Ok((manifest_path, manifest)) => { diff --git a/lib/cli/src/commands/ssh.rs b/lib/cli/src/commands/ssh.rs index 4ff84e9cf3d..046838d1fb2 100644 --- a/lib/cli/src/commands/ssh.rs +++ b/lib/cli/src/commands/ssh.rs @@ -4,13 +4,13 @@ use anyhow::Context; use wasmer_api::WasmerClient; use super::AsyncCliCommand; -use crate::{edge_config::EdgeConfig, opts::ApiOpts}; +use crate::{config::WasmerEnv, edge_config::EdgeConfig}; /// Start a remote SSH session. #[derive(clap::Parser, Debug)] pub struct CmdSsh { #[clap(flatten)] - api: ApiOpts, + env: WasmerEnv, /// SSH port to use. #[clap(long, default_value = "22")] pub ssh_port: u16, @@ -39,7 +39,7 @@ impl AsyncCliCommand for CmdSsh { async fn run_async(self) -> Result<(), anyhow::Error> { let mut config = crate::edge_config::load_config(None)?; - let client = self.api.client()?; + let client = self.env.client()?; let (token, is_new) = acquire_ssh_token(&client, &config.config).await?; diff --git a/lib/cli/src/commands/whoami.rs b/lib/cli/src/commands/whoami.rs index 81bc962ac20..d7c10e0f046 100644 --- a/lib/cli/src/commands/whoami.rs +++ b/lib/cli/src/commands/whoami.rs @@ -1,5 +1,9 @@ use clap::Parser; -use wasmer_registry::wasmer_env::WasmerEnv; +use colored::Colorize; + +use crate::config::WasmerEnv; + +use super::AsyncCliCommand; #[derive(Debug, Parser)] /// The options for the `wasmer whoami` subcommand @@ -8,14 +12,21 @@ pub struct Whoami { env: WasmerEnv, } -impl Whoami { +#[async_trait::async_trait] +impl AsyncCliCommand for Whoami { + type Output = (); + /// Execute `wasmer whoami` - pub fn execute(&self) -> Result<(), anyhow::Error> { - let registry = self.env.registry_endpoint()?; - let token = self.env.token(); - let (registry, username) = - wasmer_registry::whoami(self.env.dir(), Some(registry.as_str()), token.as_deref())?; - println!("logged into registry {registry:?} as user {username:?}"); + async fn run_async(self) -> Result { + let client = self.env.client_unauthennticated()?; + let host_str = self.env.registry_public_url()?.host_str().unwrap().bold(); + let user = wasmer_api::query::current_user(&client) + .await? + .ok_or_else(|| anyhow::anyhow!("Not logged in registry {host_str}"))?; + println!( + "Logged into registry {host_str} as user {}", + user.username.bold() + ); Ok(()) } } diff --git a/lib/cli/src/config/env.rs b/lib/cli/src/config/env.rs new file mode 100644 index 00000000000..0048627d1b5 --- /dev/null +++ b/lib/cli/src/config/env.rs @@ -0,0 +1,313 @@ +use super::WasmerConfig; +use anyhow::{Context, Error}; +use lazy_static::lazy_static; +use std::path::{Path, PathBuf}; +use url::Url; +use wasmer_api::WasmerClient; + +lazy_static! { + pub static ref DEFAULT_WASMER_CLI_USER_AGENT: String = + format!("WasmerCLI-v{}", env!("CARGO_PKG_VERSION")); +} + +/// Command-line flags for determining the local "Wasmer Environment". +/// +/// This is where you access `$WASMER_DIR`, the `$WASMER_DIR/wasmer.toml` config +/// file, and specify the current registry. +#[derive(Debug, Clone, PartialEq, clap::Parser)] +pub struct WasmerEnv { + /// Set Wasmer's home directory + #[clap(long, env = "WASMER_DIR", default_value = super::DEFAULT_WASMER_DIR.as_os_str())] + wasmer_dir: PathBuf, + + /// The directory cached artefacts are saved to. + #[clap(long, env = "WASMER_CACHE_DIR", default_value = super::DEFAULT_WASMER_CACHE_DIR.as_os_str())] + pub(crate) cache_dir: PathBuf, + + /// The registry to fetch packages from (inferred from the environment by + /// default) + #[clap(long, env = "WASMER_REGISTRY")] + pub(crate) registry: Option, + + /// The API token to use when communicating with the registry (inferred from + /// the environment by default) + #[clap(long, env = "WASMER_TOKEN")] + token: Option, +} + +impl WasmerEnv { + pub fn new( + wasmer_dir: PathBuf, + cache_dir: PathBuf, + token: Option, + registry: Option, + ) -> Self { + WasmerEnv { + wasmer_dir, + registry, + token, + cache_dir, + } + } + + /// Get the "public" url of the current registry (e.g. "https://wasmer.io" instead of + /// "https://registry.wasmer.io/graphql"). + pub fn registry_public_url(&self) -> Result { + let mut url = self.registry_endpoint()?; + url.set_path(""); + + let domain = url + .host_str() + .context("url has no host")? + .strip_prefix("registry.") + .context("could not derive registry public url")? + .to_string(); + url.set_host(Some(&domain)) + .context("could not derive registry public url")?; + + Ok(url) + } + + /// Get the GraphQL endpoint used to query the registry. + pub fn registry_endpoint(&self) -> Result { + if let Some(registry) = &self.registry { + return registry.graphql_endpoint(); + } + + let config = self.config()?; + let url = config.registry.get_current_registry().parse()?; + + Ok(url) + } + + /// Load the current Wasmer config. + pub fn config(&self) -> Result { + WasmerConfig::from_file(self.dir()) + .map_err(Error::msg) + .with_context(|| { + format!( + "Unable to load the config from the \"{}\" directory", + self.dir().display() + ) + }) + } + + /// The directory all Wasmer artifacts are stored in. + pub fn dir(&self) -> &Path { + &self.wasmer_dir + } + + /// The directory all cached artifacts should be saved to. + pub fn cache_dir(&self) -> &Path { + &self.cache_dir + } + + /// Retrieve the specified token. + /// + /// NOTE: In contrast to [`Self::token`], this will not fall back to loading + /// the token from the confg file. + #[allow(unused)] + pub fn get_token_opt(&self) -> Option<&str> { + self.token.as_deref() + } + + /// The API token for the active registry. + pub fn token(&self) -> Option { + if let Some(token) = &self.token { + return Some(token.clone()); + } + + // Fall back to the config file + + let config = self.config().ok()?; + let registry_endpoint = self.registry_endpoint().ok()?; + config + .registry + .get_login_token_for_registry(registry_endpoint.as_str()) + } + + pub fn client_unauthennticated(&self) -> Result { + let registry_url = self.registry_endpoint()?; + let client = wasmer_api::WasmerClient::new(registry_url, &DEFAULT_WASMER_CLI_USER_AGENT)?; + + let client = if let Some(token) = self.token() { + client.with_auth_token(token) + } else { + client + }; + + Ok(client) + } + + pub fn client(&self) -> Result { + let client = self.client_unauthennticated()?; + if client.auth_token().is_none() { + anyhow::bail!("no token provided - run 'wasmer login', specify --token=XXX, or set the WASMER_TOKEN env var"); + } + + Ok(client) + } +} + +impl Default for WasmerEnv { + fn default() -> Self { + Self { + wasmer_dir: super::DEFAULT_WASMER_DIR.clone(), + cache_dir: super::DEFAULT_WASMER_CACHE_DIR.clone(), + registry: None, + token: None, + } + } +} + +/// A registry as specified by the user. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UserRegistry(String); + +impl UserRegistry { + /// Get the [`Registry`]'s string representation. + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Get the GraphQL endpoint for this [`Registry`]. + pub fn graphql_endpoint(&self) -> Result { + let url = super::format_graphql(self.as_str()).parse()?; + Ok(url) + } +} + +impl From for UserRegistry { + fn from(value: String) -> Self { + UserRegistry(value) + } +} + +impl From<&str> for UserRegistry { + fn from(value: &str) -> Self { + UserRegistry(value.to_string()) + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + const WASMER_TOML: &str = r#" + telemetry_enabled = false + update_notifications_enabled = false + + [registry] + active_registry = "https://registry.wasmer.io/graphql" + + [[registry.tokens]] + registry = "https://registry.wasmer.wtf/graphql" + token = "dev-token" + + [[registry.tokens]] + registry = "https://registry.wasmer.io/graphql" + token = "prod-token" + "#; + + #[test] + fn load_defaults_from_config() { + let temp = TempDir::new().unwrap(); + std::fs::write(temp.path().join("wasmer.toml"), WASMER_TOML).unwrap(); + + let env = WasmerEnv { + wasmer_dir: temp.path().to_path_buf(), + registry: None, + cache_dir: temp.path().join("cache").to_path_buf(), + token: None, + }; + + assert_eq!( + env.registry_endpoint().unwrap().as_str(), + "https://registry.wasmer.io/graphql" + ); + assert_eq!(env.token().unwrap(), "prod-token"); + assert_eq!(env.cache_dir(), temp.path().join("cache")); + } + + #[test] + fn override_token() { + let temp = TempDir::new().unwrap(); + std::fs::write(temp.path().join("wasmer.toml"), WASMER_TOML).unwrap(); + + let env = WasmerEnv { + wasmer_dir: temp.path().to_path_buf(), + registry: None, + cache_dir: temp.path().join("cache").to_path_buf(), + token: Some("asdf".to_string()), + }; + + assert_eq!( + env.registry_endpoint().unwrap().as_str(), + "https://registry.wasmer.io/graphql" + ); + assert_eq!(env.token().unwrap(), "asdf"); + assert_eq!(env.cache_dir(), temp.path().join("cache")); + } + + #[test] + fn override_registry() { + let temp = TempDir::new().unwrap(); + std::fs::write(temp.path().join("wasmer.toml"), WASMER_TOML).unwrap(); + let env = WasmerEnv { + wasmer_dir: temp.path().to_path_buf(), + registry: Some(UserRegistry::from("wasmer.wtf")), + cache_dir: temp.path().join("cache").to_path_buf(), + token: None, + }; + + assert_eq!( + env.registry_endpoint().unwrap().as_str(), + "https://registry.wasmer.wtf/graphql" + ); + assert_eq!(env.token().unwrap(), "dev-token"); + assert_eq!(env.cache_dir(), temp.path().join("cache")); + } + + #[test] + fn override_registry_and_token() { + let temp = TempDir::new().unwrap(); + std::fs::write(temp.path().join("wasmer.toml"), WASMER_TOML).unwrap(); + + let env = WasmerEnv { + wasmer_dir: temp.path().to_path_buf(), + registry: Some(UserRegistry::from("wasmer.wtf")), + cache_dir: temp.path().join("cache").to_path_buf(), + token: Some("asdf".to_string()), + }; + + assert_eq!( + env.registry_endpoint().unwrap().as_str(), + "https://registry.wasmer.wtf/graphql" + ); + assert_eq!(env.token().unwrap(), "asdf"); + assert_eq!(env.cache_dir(), temp.path().join("cache")); + } + + #[test] + fn override_cache_dir() { + let temp = TempDir::new().unwrap(); + std::fs::write(temp.path().join("wasmer.toml"), WASMER_TOML).unwrap(); + let expected_cache_dir = temp.path().join("some-other-cache"); + + let env = WasmerEnv { + wasmer_dir: temp.path().to_path_buf(), + registry: None, + cache_dir: expected_cache_dir.clone(), + token: None, + }; + + assert_eq!( + env.registry_endpoint().unwrap().as_str(), + "https://registry.wasmer.io/graphql" + ); + assert_eq!(env.token().unwrap(), "prod-token"); + assert_eq!(env.cache_dir(), expected_cache_dir); + } +} diff --git a/lib/cli/src/config/mod.rs b/lib/cli/src/config/mod.rs new file mode 100644 index 00000000000..23c95f21f3a --- /dev/null +++ b/lib/cli/src/config/mod.rs @@ -0,0 +1,335 @@ +mod env; +pub use env::*; + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use url::Url; +use wasmer_api::WasmerClient; + +pub static GLOBAL_CONFIG_FILE_NAME: &str = "wasmer.toml"; +pub static DEFAULT_PROD_REGISTRY: &str = "https://registry.wasmer.io/graphql"; + +lazy_static::lazy_static! { + /// The default value for `$WASMER_DIR`. + pub static ref DEFAULT_WASMER_DIR: PathBuf = match WasmerConfig::get_wasmer_dir() { + Ok(path) => path, + Err(e) => { + if let Some(install_prefix) = option_env!("WASMER_INSTALL_PREFIX") { + return PathBuf::from(install_prefix); + } + + panic!("Unable to determine the wasmer dir: {e}"); + } + }; + + /// The default value for `$WASMER_DIR`. + pub static ref DEFAULT_WASMER_CACHE_DIR: PathBuf = DEFAULT_WASMER_DIR.join("cache"); +} + +#[derive(Deserialize, Default, Serialize, Debug, PartialEq, Eq)] +pub struct WasmerConfig { + /// Whether or not telemetry is enabled. + #[serde(default)] + pub telemetry_enabled: bool, + + /// Whether or not updated notifications are enabled. + #[serde(default)] + pub update_notifications_enabled: bool, + + /// The registry that wasmer will connect to. + pub registry: MultiRegistry, + + /// The proxy to use when connecting to the Internet. + #[serde(default)] + pub proxy: Proxy, +} + +#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Default)] +pub struct Proxy { + pub url: Option, +} + +/// Struct to store login tokens for multiple registry URLs +/// inside of the wasmer.toml configuration file +#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)] +pub struct MultiRegistry { + /// Currently active registry + pub active_registry: String, + /// Map from "RegistryUrl" to "LoginToken", in order to + /// be able to be able to easily switch between registries + pub tokens: Vec, +} + +impl Default for MultiRegistry { + fn default() -> Self { + MultiRegistry { + active_registry: format_graphql(DEFAULT_PROD_REGISTRY), + tokens: Vec::new(), + } + } +} + +#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)] +pub struct Registry { + pub url: String, + pub token: Option, +} + +pub fn format_graphql(registry: &str) -> String { + if let Ok(mut url) = Url::parse(registry) { + // Looks like we've got a valid URL. Let's try to use it as-is. + if url.has_host() { + if url.path() == "/" { + // make sure we convert http://registry.wasmer.io/ to + // http://registry.wasmer.io/graphql + url.set_path("/graphql"); + } + + return url.to_string(); + } + } + + if !registry.contains("://") && !registry.contains('/') { + return endpoint_from_domain_name(registry); + } + + // looks like we've received something we can't deal with. Just pass it + // through as-is and hopefully it'll either work or the end user can figure + // it out + registry.to_string() +} + +/// By convention, something like `"wasmer.io"` should be converted to +/// `"https://registry.wasmer.io/graphql"`. +fn endpoint_from_domain_name(domain_name: &str) -> String { + if domain_name.contains("localhost") { + return format!("http://{domain_name}/graphql"); + } + + format!("https://registry.{domain_name}/graphql") +} + +async fn test_if_registry_present(registry: &str) -> anyhow::Result<()> { + let client = WasmerClient::new(url::Url::parse(registry)?, &DEFAULT_WASMER_CLI_USER_AGENT)?; + + wasmer_api::query::current_user(&client).await.map(|_| ()) +} + +#[derive(PartialEq, Eq, Copy, Clone)] +pub enum UpdateRegistry { + Update, + #[allow(unused)] + LeaveAsIs, +} + +impl MultiRegistry { + /// Gets the current (active) registry URL + pub fn remove_registry(&mut self, registry: &str) { + let MultiRegistry { tokens, .. } = self; + tokens.retain(|i| i.registry != registry); + tokens.retain(|i| i.registry != format_graphql(registry)); + } + + #[allow(unused)] + pub fn get_graphql_url(&self) -> String { + self.get_current_registry() + } + + /// Gets the current (active) registry URL + pub fn get_current_registry(&self) -> String { + format_graphql(&self.active_registry) + } + + /// Checks if the current registry equals `registry`. + pub fn is_current_registry(&self, registry: &str) -> bool { + format_graphql(&self.active_registry) == format_graphql(registry) + } + + #[allow(unused)] + pub fn current_login(&self) -> Option<&RegistryLogin> { + self.tokens + .iter() + .find(|login| login.registry == self.active_registry) + } + + /// Sets the current (active) registry URL + pub async fn set_current_registry(&mut self, registry: &str) { + let registry = format_graphql(registry); + if let Err(e) = test_if_registry_present(®istry).await { + println!("Error when trying to ping registry {registry:?}: {e}"); + println!("WARNING: Registry {registry:?} will be used, but commands may not succeed."); + } + self.active_registry = registry; + } + + /// Returns the login token for the registry + pub fn get_login_token_for_registry(&self, registry: &str) -> Option { + let registry_formatted = format_graphql(registry); + self.tokens + .iter() + .filter(|login| login.registry == registry || login.registry == registry_formatted) + .last() + .map(|login| login.token.clone()) + } + + /// Sets the login token for the registry URL + pub fn set_login_token_for_registry( + &mut self, + registry: &str, + token: &str, + update_current_registry: UpdateRegistry, + ) { + let registry_formatted = format_graphql(registry); + self.tokens + .retain(|login| !(login.registry == registry || login.registry == registry_formatted)); + self.tokens.push(RegistryLogin { + registry: format_graphql(registry), + token: token.to_string(), + }); + if update_current_registry == UpdateRegistry::Update { + self.active_registry = format_graphql(registry); + } + } +} + +impl WasmerConfig { + /// Save the config to a file + pub fn save>(&self, to: P) -> anyhow::Result<()> { + use std::{fs::File, io::Write}; + let config_serialized = toml::to_string(&self)?; + let mut file = File::create(to)?; + file.write_all(config_serialized.as_bytes())?; + Ok(()) + } + + pub fn from_file(wasmer_dir: &Path) -> Result { + let path = Self::get_file_location(wasmer_dir); + match std::fs::read_to_string(path) { + Ok(config_toml) => Ok(toml::from_str(&config_toml).unwrap_or_else(|_| Self::default())), + Err(_e) => Ok(Self::default()), + } + } + + /// Creates and returns the `WASMER_DIR` directory (or $HOME/.wasmer as a fallback) + pub fn get_wasmer_dir() -> Result { + Ok( + if let Some(folder_str) = std::env::var("WASMER_DIR").ok().filter(|s| !s.is_empty()) { + let folder = PathBuf::from(folder_str); + std::fs::create_dir_all(folder.clone()) + .map_err(|e| format!("cannot create config directory: {e}"))?; + folder + } else { + let home_dir = + dirs::home_dir().ok_or_else(|| "cannot find home directory".to_string())?; + let mut folder = home_dir; + folder.push(".wasmer"); + std::fs::create_dir_all(folder.clone()) + .map_err(|e| format!("cannot create config directory: {e}"))?; + folder + }, + ) + } + + #[allow(unused)] + /// Load the config based on environment variables and default config file locations. + pub fn from_env() -> Result { + let dir = Self::get_wasmer_dir() + .map_err(|err| anyhow::anyhow!("Could not determine wasmer dir: {err}"))?; + let file_path = Self::get_file_location(&dir); + Self::from_file(&file_path).map_err(|err| { + anyhow::anyhow!( + "Could not load config file at '{}': {}", + file_path.display(), + err + ) + }) + } + + pub fn get_file_location(wasmer_dir: &Path) -> PathBuf { + wasmer_dir.join(GLOBAL_CONFIG_FILE_NAME) + } +} + +#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)] +pub struct RegistryLogin { + /// Registry URL to login to + pub registry: String, + /// Login token for the registry + pub token: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_registries_switch_token() { + let mut registries = MultiRegistry::default(); + + registries + .set_current_registry("https://registry.wasmer.wtf") + .await; + assert_eq!( + registries.get_current_registry(), + "https://registry.wasmer.wtf/graphql".to_string() + ); + registries.set_login_token_for_registry( + "https://registry.wasmer.io", + "token1", + UpdateRegistry::LeaveAsIs, + ); + assert_eq!( + registries.get_current_registry(), + "https://registry.wasmer.wtf/graphql".to_string() + ); + assert_eq!( + registries.get_login_token_for_registry(®istries.get_current_registry()), + None + ); + registries + .set_current_registry("https://registry.wasmer.io") + .await; + assert_eq!( + registries.get_login_token_for_registry(®istries.get_current_registry()), + Some("token1".to_string()) + ); + registries.remove_registry("https://registry.wasmer.io"); + assert_eq!( + registries.get_login_token_for_registry(®istries.get_current_registry()), + None + ); + } + + #[test] + fn format_registry_urls() { + let inputs = [ + // Domain names work + ("wasmer.io", "https://registry.wasmer.io/graphql"), + ("wasmer.wtf", "https://registry.wasmer.wtf/graphql"), + // Plain URLs + ( + "https://registry.wasmer.wtf/graphql", + "https://registry.wasmer.wtf/graphql", + ), + ( + "https://registry.wasmer.wtf/something/else", + "https://registry.wasmer.wtf/something/else", + ), + // We don't automatically prepend the domain name with + // "registry", but we will make sure "/" gets turned into "/graphql" + ("https://wasmer.wtf/", "https://wasmer.wtf/graphql"), + ("https://wasmer.wtf", "https://wasmer.wtf/graphql"), + // local development + ( + "http://localhost:8000/graphql", + "http://localhost:8000/graphql", + ), + ("localhost:8000", "http://localhost:8000/graphql"), + ]; + + for (input, expected) in inputs { + let url = format_graphql(input); + assert_eq!(url, expected); + } + } +} diff --git a/lib/cli/src/lib.rs b/lib/cli/src/lib.rs index 8df9cd1b988..3769d75e11d 100644 --- a/lib/cli/src/lib.rs +++ b/lib/cli/src/lib.rs @@ -20,6 +20,7 @@ mod net; mod commands; mod common; +mod config; #[macro_use] mod error; mod c_gen; diff --git a/lib/cli/src/opts.rs b/lib/cli/src/opts.rs index d943edc681c..cc9844a2197 100644 --- a/lib/cli/src/opts.rs +++ b/lib/cli/src/opts.rs @@ -1,160 +1,3 @@ -use std::path::PathBuf; - -use anyhow::Context; -use wasmer_api::WasmerClient; -use wasmer_registry::WasmerConfig; - -fn parse_registry_url(registry: &str) -> Result { - if let Ok(mut url) = url::Url::parse(registry) { - // Looks like we've got a valid URL. Let's try to use it as-is. - if url.has_host() { - if url.path() == "/" { - // make sure we convert http://registry.wasmer.io/ to - // http://registry.wasmer.io/graphql - url.set_path("/graphql"); - } - - return Ok(url); - } - } - - let raw_registry = if !registry.contains("://") && !registry.contains('/') { - if registry.contains("localhost") { - format!("http://{registry}/graphql") - } else { - format!("https://registry.{registry}/graphql") - } - } else { - registry.to_string() - }; - - url::Url::parse(&raw_registry).map_err(|e| e.to_string()) -} - -#[derive(clap::Parser, Debug, Clone, Default)] -pub struct ApiOpts { - /// The (optional) authorization token to pass to the registry - #[clap(long, env = "WASMER_TOKEN")] - pub token: Option, - - /// Change the current registry - #[clap(long, value_parser = parse_registry_url, env = "WASMER_REGISTRY")] - pub registry: Option, -} - -lazy_static::lazy_static! { - /// The default value for `$WASMER_DIR`. - pub static ref WASMER_DIR: PathBuf = match WasmerConfig::get_wasmer_dir() { - Ok(path) => path, - Err(e) => { - if let Some(install_prefix) = option_env!("WASMER_INSTALL_PREFIX") { - return PathBuf::from(install_prefix); - } - - panic!("Unable to determine the wasmer dir: {e}"); - } - }; - - /// The default value for `$WASMER_DIR`. - pub static ref WASMER_CACHE_DIR: PathBuf = WASMER_DIR.join("cache"); -} - -/// Command-line flags for determining the local "Wasmer Environment". -/// -/// This is where you access `$WASMER_DIR`, the `$WASMER_DIR/wasmer.toml` config -/// file, and specify the current registry. -#[derive(Debug, Clone, PartialEq, clap::Parser, Default)] -pub struct WasmerEnv { - /// Set Wasmer's home directory - #[clap(long, env = "WASMER_DIR", default_value = WASMER_DIR.as_os_str())] - pub wasmer_dir: PathBuf, - /// The directory cached artefacts are saved to. - #[clap(long, env = "WASMER_CACHE_DIR", default_value = WASMER_CACHE_DIR.as_os_str())] - pub cache_dir: PathBuf, -} - -struct Login { - url: url::Url, - token: Option, -} - -impl ApiOpts { - const PROD_REGISTRY: &'static str = "https://registry.wasmer.io/graphql"; - - fn load_config() -> Result { - let wasmer_dir = WasmerConfig::get_wasmer_dir() - .map_err(|e| anyhow::anyhow!("no wasmer dir: '{e}' - did you run 'wasmer login'?"))?; - - let config = WasmerConfig::from_file(&wasmer_dir).map_err(|e| { - anyhow::anyhow!("could not load config '{e}' - did you run wasmer login?") - })?; - - Ok(config) - } - - fn build_login(&self) -> Result { - let config = Self::load_config()?; - - let login = if let Some(reg) = &self.registry { - let token = if let Some(token) = &self.token { - Some(token.clone()) - } else { - config.registry.get_login_token_for_registry(reg.as_str()) - }; - - Login { - url: reg.clone(), - token, - } - } else if let Some(login) = config.registry.current_login() { - let url = login.registry.parse::().with_context(|| { - format!( - "config file specified invalid registry url: '{}'", - login.registry - ) - })?; - - let token = self.token.clone().unwrap_or(login.token.clone()); - - Login { - url, - token: Some(token), - } - } else { - let url = Self::PROD_REGISTRY.parse::().unwrap(); - Login { - url, - token: self.token.clone(), - } - }; - - Ok(login) - } - - pub fn client_unauthennticated(&self) -> Result { - let login = self.build_login()?; - - let client = wasmer_api::WasmerClient::new(login.url, "edge-cli")?; - - let client = if let Some(token) = login.token { - client.with_auth_token(token) - } else { - client - }; - - Ok(client) - } - - pub fn client(&self) -> Result { - let client = self.client_unauthennticated()?; - if client.auth_token().is_none() { - anyhow::bail!("no token provided - run 'wasmer login', specify --token=XXX, or set the WASMER_TOKEN env var"); - } - - Ok(client) - } -} - /// Formatting options for a single item. #[derive(clap::Parser, Debug, Default)] pub struct ItemFormatOpts {