diff --git a/CHANGELOG.md b/CHANGELOG.md index 58614d0..8a48329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes ## Unreleased +- `voltage environments create --org ORG_ID --name NAME` adds an environment to an organization with a browser login. Without `--execute` it only describes the request and exits 6. The new environment's ID, generated unless you pass `--id`, is in `resource_id`. - `voltage context --format env` prints the effective organization, environment, and wallet as `VOLTAGE_*` dotenv lines, the names the `voltage-api` agent skill's helper reads, so a profile's scope can be handed to it. It leaves out unset values, never prints a credential, and fails with exit code 2 when more than one environment is selected. - A command that ignores a set `VOLTAGE_*` variable, because `--profile` or a flag such as `--wallet` takes precedence, says so on stderr with one `warning:` line per variable, even with `--quiet`. Before, only `voltage context` reported it, so the CLI and a tool that lets the variable win could act on different wallets in one shell. Stdout is unchanged. `auth import-key` and `profiles create`, which save what their flags name, do not warn about the flags they were given. - `organizations list`, `environments list`, and `profiles list` accept `--all` and ignore it, like the other lists that return every item at once. Before, they rejected it. diff --git a/docs/commands.md b/docs/commands.md index d001c87..2dc429b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -16,6 +16,7 @@ These commands use the auth service (`https://auth.voltage.cloud/api/v1`, overri | `voltage organizations get` | GET `/api/v1/users/current` | | `voltage environments list` | POST `/api/v1/oauth/token`
GET `/api/v1/organizations/{organization_id}/environments` | | `voltage environments get` | POST `/api/v1/oauth/token`
GET `/api/v1/organizations/{organization_id}/environments` | +| `voltage environments create --name NAME` | POST `/api/v1/oauth/token`
POST `/api/v1/organizations/{organization_id}/environments` | | `voltage context` | local only | | `voltage profiles create NAME` | local only | | `voltage profiles get NAME` | local only | diff --git a/docs/guides/login-and-profiles.md b/docs/guides/login-and-profiles.md index d64d7cc..16f00bb 100644 --- a/docs/guides/login-and-profiles.md +++ b/docs/guides/login-and-profiles.md @@ -29,10 +29,20 @@ secret-manager-command | voltage auth import-key --stdin --account ci --org ORG_ VOLTAGE_API_KEY=... voltage wallets list --org ORG_ID ``` -An imported key remembers the organization and environment it belongs to, and a command that selects a different one fails before any request is sent. `VOLTAGE_API_KEY` is used only when neither `--profile` nor `--account` is given. `organizations list|get` and `environments list|get` require a browser login. +An imported key remembers the organization and environment it belongs to, and a command that selects a different one fails before any request is sent. `VOLTAGE_API_KEY` is used only when neither `--profile` nor `--account` is given. `organizations list|get`, `environments list|get`, and `environments create` require a browser login. With several saved credentials, select one with `--account` or through a profile. +## Create an environment + +`environments create` adds an environment to the selected organization through the auth service. Like any change, it only describes the request (exit code 6) until you add `--execute`: + +```sh +voltage environments create --account me --org ORG_ID --name staging --execute +``` + +The CLI generates the environment's ID unless you pass `--id UUID`, and reports it in `resource_id` and in the result's `environment_id`, so the next command can use it. On a terminal, the result suggests a `voltage profiles create` command for the new environment. The description omits the name unless you pass `--show-secrets`. If the request gets no response, the environment may have been created: run `voltage environments list` and look for the reported ID before you run the command again. + ## Scope A resource ID is positional, or passed as `--id ID`, as in `voltage wallets get --id WALLET_ID`. A command that acts on one wallet also takes it as `--wallet WALLET_ID`; a profile's or `VOLTAGE_WALLET_ID`'s wallet never selects it. The enclosing scope comes from `--org`, `--env`, `--wallet`, and `--webhook`: diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 69e294b..3a083f9 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -72,7 +72,7 @@ When you sign in as the same user, the new login replaces the saved one, and the ### needs a browser login -`organizations list` and `environments list` need a browser login; an API key cannot list them. Pass `--account NAME` for a saved login, or unset `VOLTAGE_API_KEY`. +`organizations list`, `environments list`, and `environments create` need a browser login; an API key cannot run them. Pass `--account NAME` for a saved login, or unset `VOLTAGE_API_KEY`. ### HTTP 401 diff --git a/docs/quality.md b/docs/quality.md index 5ea06bd..5c67b7c 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -218,3 +218,11 @@ an existing simpler pattern cannot satisfy it. exported for tests. - `Settings.dir` and `Settings.config` are public record fields so the integration suite can construct a configuration directory directly. +- `environments create` sends an ID-bearing mutation to the auth service + without a journal entry. Journal records name a registry operation and + reconcile through the Voltage API, and an environment is neither. The auth + service stores the sent ID as the environment's unique ID, so a reused ID + never creates a second environment. A generated ID is new on each run, and + after a lost response the error reports the ID to find with + `environments list`. A reused `--id` is rejected by the server, today as an + HTTP 500 rather than a 409 (voltagecloud/auth-service#428), not locally. diff --git a/scripts/check-coverage.py b/scripts/check-coverage.py index 9620f3b..4068313 100644 --- a/scripts/check-coverage.py +++ b/scripts/check-coverage.py @@ -23,6 +23,7 @@ ('voltage organizations get', [('GET', '/api/v1/users/current')]), ('voltage environments list', [('POST', '/api/v1/oauth/token'), ('GET', '/api/v1/organizations/{organization_id}/environments')]), ('voltage environments get', [('POST', '/api/v1/oauth/token'), ('GET', '/api/v1/organizations/{organization_id}/environments')]), + ('voltage environments create --name NAME', [('POST', '/api/v1/oauth/token'), ('POST', '/api/v1/organizations/{organization_id}/environments')]), ('voltage context', []), ('voltage profiles create NAME', []), ('voltage profiles get NAME', []), diff --git a/src/api.rs b/src/api.rs index 0f553f1..5f183b1 100644 --- a/src/api.rs +++ b/src/api.rs @@ -617,16 +617,9 @@ pub async fn execute( dry_run::announce(terminal, invocation, &described); } out.write(described, &[])?; - return Err(Error::new( - ErrorKind::NotExecuted, - format!( - "Described this change without sending it; pass --execute or set {EXECUTE_VARIABLE}=1 to send it" - ), - )); + return Err(not_sent()); } - Execution::SendFromEnvironment => global.terminal().notice(format!( - "Sending this change because {EXECUTE_VARIABLE} is set." - )), + Execution::SendFromEnvironment => sending_from_environment(global.terminal()), Execution::Send => {} } if operation.id.returns_one_time_secret() && !out.secure_destination() { @@ -760,10 +753,10 @@ pub enum Execution { impl Execution { /// The flag decides first, then `VOLTAGE_EXECUTE`, then the default: reads are sent and /// changes are described. - pub fn resolve(invocation: &ApiInvocation, execute_variable: Option) -> Self { - match invocation.requested { + pub fn resolve(requested: Requested, method: Method, execute_variable: Option) -> Self { + match requested { Requested::Execute => Self::Send, - Requested::Unspecified if invocation.operation.method == Method::Get => Self::Send, + Requested::Unspecified if method == Method::Get => Self::Send, Requested::Unspecified if execute_variable == Some(true) => Self::SendFromEnvironment, Requested::Unspecified => Self::DescribeChange, } @@ -775,6 +768,23 @@ impl Execution { } } +/// The error after a described change, whose exit code tells a script nothing was sent. +pub fn not_sent() -> Error { + Error::new( + ErrorKind::NotExecuted, + format!( + "Described this change without sending it; pass --execute or set {EXECUTE_VARIABLE}=1 to send it" + ), + ) +} + +/// Say that `VOLTAGE_EXECUTE`, not the command line, sends this change. +pub fn sending_from_environment(terminal: Terminal) { + terminal.notice(format!( + "Sending this change because {EXECUTE_VARIABLE} is set." + )); +} + pub const OFFSET_PAGINATION_DEPRECATED: &str = "Offset pagination is deprecated by the API; omit --offset and --pagination to page by cursor."; @@ -1359,7 +1369,7 @@ pub(crate) enum SessionChange { } struct SubmittedWrite { - operation: OperationId, + payment: bool, resource_id: Option, organization_id: Option, environment_ids: Vec, @@ -1369,13 +1379,24 @@ impl SubmissionState { /// A command sends at most one mutation, so the first record is the only one. fn record(&self, operation: OperationId, resource_id: Option, scope: &Scope) { let _ = self.write.set(SubmittedWrite { - operation, + payment: operation.submits_payment(), resource_id, organization_id: scope.org, environment_ids: scope.envs.clone(), }); } + /// `record` for a change sent to the auth service, which is never a payment. + pub(crate) fn record_auth_change(&self, resource_id: Uuid, scope: &Scope) { + let _ = self.write.set(SubmittedWrite { + payment: false, + resource_id: Some(resource_id), + organization_id: scope.org, + // An auth-service change is not made in the profile's environments. + environment_ids: Vec::new(), + }); + } + pub(crate) fn session_changing(&self, change: SessionChange) { *self.session.lock().unwrap_or_else(PoisonError::into_inner) = Some(change); } @@ -1442,7 +1463,7 @@ impl SubmissionState { }; }; let message = match write.resource_id { - Some(id) if write.operation.submits_payment() => format!( + Some(id) if write.payment => format!( "Interrupted after payment {id} may have been submitted; query its original ID before resubmitting. The payment was not cancelled." ), _ => "Interrupted after the request may have been submitted; check its result before retrying. The request was not cancelled.".to_owned(), @@ -2420,6 +2441,20 @@ mod tests { assert_eq!(detail["outcome"], "unknown"); } + #[test] + fn interruption_after_an_auth_change_names_its_resource_and_no_environments() { + let submission = SubmissionState::default(); + let created = Uuid::from_u128(7); + let profile = scope(); + assert!(!profile.envs.is_empty()); + submission.record_auth_change(created, &profile); + let detail = serde_json::to_value(submission.interrupted().detail.unwrap()).unwrap(); + assert_eq!(detail["resource_id"], created.to_string()); + assert_eq!(detail["organization_id"], json!(profile.org)); + assert_eq!(detail["environment_ids"], json!([])); + assert_eq!(detail["outcome"], "unknown"); + } + #[test] fn journal_rejects_the_same_id_for_a_different_request() { let dir = tempfile::tempdir().unwrap(); @@ -2488,7 +2523,10 @@ mod tests { #[test] fn the_flag_decides_then_voltage_execute_then_reads_send_and_changes_are_described() { - let resolve = |args: &[&str], variable| Execution::resolve(&invocation(args), variable); + let resolve = |args: &[&str], variable| { + let invocation = invocation(args); + Execution::resolve(invocation.requested, invocation.operation.method, variable) + }; let change = ["wallets", "delete", WALLET]; let execute = ["wallets", "delete", WALLET, "--execute"]; for variable in [None, Some(false), Some(true)] { diff --git a/src/auth.rs b/src/auth.rs index 4904b59..a04248d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -13,7 +13,7 @@ use crate::{ IgnoredVariable, InputSource, LOCK_TIMEOUT, Login, Precedence, Scope, ScopeSources, Settings, Source, name_in_use, read_secret, shell_word, unreadable_name, }, - error::{CredentialRejection, CredentialSources, ErrorDetail, ResponseLimit}, + error::{CredentialRejection, CredentialSources, ErrorDetail, ErrorKind, ResponseLimit}, secret::Secret, style::{Line, Role}, terminal::Terminal, @@ -21,7 +21,10 @@ use crate::{ }; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::{ + fmt, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use uuid::Uuid; use zeroize::{Zeroize, Zeroizing}; @@ -104,6 +107,59 @@ pub enum Discovery { Environments, } +/// A command that only a browser login can run. An API key's error names it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LoginCommand { + Discover(Discovery, DiscoveryCommand), + CreateEnvironment, +} + +impl fmt::Display for LoginCommand { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Discover(Discovery::Organizations, command) => { + write!(f, "organizations {}", command.as_str()) + } + Self::Discover(Discovery::Environments, command) => { + write!(f, "environments {}", command.as_str()) + } + Self::CreateEnvironment => f.write_str("environments create"), + } + } +} + +/// The auth contract's `Environment`, the body of `environments create`. The CLI chooses +/// `environment_id`, so the command can report it before and after the request. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct NewEnvironment { + pub environment_id: Uuid, + pub organization_id: Uuid, + pub name: String, +} + +impl NewEnvironment { + /// `error` came after the creation request was sent, so the environment may exist. The + /// error names it and carries an `uncertain_submission` detail; without a hint of its + /// own, it says how to check before a rerun. + fn possibly_created(&self, mut error: Error) -> Error { + error.message = format!( + "{}; environment {} may have been created", + error.message, self.environment_id + ); + if error.hint.is_none() { + error = error.with_hint(format!( + "The change may have been applied. Look for environment {} with `voltage environments list --org {}` before you run the command again.", + self.environment_id, self.organization_id + )); + } + error.with_detail(ErrorDetail::uncertain_submission( + Some(self.environment_id), + Some(self.organization_id), + Vec::new(), + )) + } +} + /// A response from the auth service with its body in zeroized memory. struct AuthResponse { status: u16, @@ -190,17 +246,28 @@ async fn auth_request( request: reqwest::RequestBuilder, terminal: Terminal, ) -> Result { - let _progress = terminal.progress("Waiting for auth service..."); - let response = request.send().await.map_err(|error| { + send_to_auth(request, terminal, |error| { Error::no_response( - &error, + error, "Authentication request", "; no token request was automatically retried", "auth", ResponseLimit::Fixed(AUTH_TIMEOUT), false, ) - })?; + }) + .await +} + +/// `auth_request` whose transport failure `unanswered` reports, so a change can say that it +/// may have been applied. +async fn send_to_auth( + request: reqwest::RequestBuilder, + terminal: Terminal, + unanswered: impl FnOnce(&reqwest::Error) -> Error, +) -> Result { + let _progress = terminal.progress("Waiting for auth service..."); + let response = request.send().await.map_err(|error| unanswered(&error))?; let status = response.status().as_u16(); // Ingress rate limits can return plain text rather than an OAuth response. // Device polling still needs to back off; token mutations are never retried. @@ -400,6 +467,57 @@ impl AuthService { response.json("Invalid environment response") } + /// Create `environment` once, returning the HTTP status. A request without a usable + /// answer may have created it, so its error carries the environment's ID. + async fn create_environment( + &self, + organization_token: &Secret, + environment: &NewEnvironment, + ) -> Result { + let url = format!( + "{}/organizations/{}/environments", + self.url, environment.organization_id + ); + let request = self + .client + .post(url) + .bearer_auth(organization_token.expose()) + .json(environment); + let response = send_to_auth(request, self.terminal, |error| { + Error::no_response( + error, + "Environment creation request", + "", + "auth", + ResponseLimit::Fixed(AUTH_TIMEOUT), + true, + ) + }) + .await + // Every send failure leaves the outcome unknown: an answer that could not be read or + // was not JSON, such as an ingress's HTML 502, came after the request arrived too. + .map_err(|error| environment.possibly_created(error))?; + if response.is_success() { + return Ok(response.status); + } + let code = response + .error_code() + .unwrap_or_else(|| "request_rejected".into()); + let error = Error::new( + ErrorKind::for_http_status(response.status), + format!( + "The auth service did not create the environment (HTTP {}, {code})", + response.status + ), + ); + Err(match response.status { + 403 => error.with_hint( + "Your login cannot create environments in this organization. Ask an organization admin to create it, or to give you that permission.", + ), + _ => error, + }) + } + /// Revoke a session whose refresh token the CLI holds. async fn revoke(&self, refresh_token: &Secret) -> Result<()> { self.post( @@ -982,42 +1100,62 @@ impl Resolver<'_> { what: Discovery, command: DiscoveryCommand, ) -> Result { - // An API key cannot list organizations or environments; say which key was used. - let requires_login = || { - let noun = match what { - Discovery::Organizations => "organizations", - Discovery::Environments => "environments", - }; - let command = format!("{noun} {}", command.as_str()); - if ambient_api_key(self.flags).is_some() { - return Error::auth(format!( - "{command} needs a browser login, but this command used VOLTAGE_API_KEY from the environment" - )) - .with_hint( - "Pass --account NAME to use a saved browser login (`voltage login` creates one), or unset VOLTAGE_API_KEY.", - ); - } - let used = self.settings.account_name(scope).unwrap_or_default(); - Error::auth(format!( - "{command} needs a browser login, but the selected credential {used} is an API key" - )) - .with_hint( - "Select a browser login with --account NAME, or sign in with `voltage login`.", - ) - }; let token = match what { Discovery::Organizations => match self.resolve(scope).await? { Credential::Login(login) => login.access_token, - Credential::ApiKey(_) => return Err(requires_login()), - }, - Discovery::Environments => match self.resolve_organization(scope).await? { - ApiCredential::OrganizationToken(token) => token, - ApiCredential::ApiKey(_) => return Err(requires_login()), + Credential::ApiKey(_) => { + return Err(self.requires_login(scope, LoginCommand::Discover(what, command))); + } }, + Discovery::Environments => { + self.login_organization(scope, LoginCommand::Discover(what, command)) + .await? + } }; self.list(scope, what, &token).await } + /// Create `environment` in the scope's organization with the login's organization token, + /// returning the HTTP status. The write is recorded first, so Ctrl-C reports its ID. + pub async fn create_environment( + self, + scope: &Scope, + environment: &NewEnvironment, + ) -> Result { + let token = self + .login_organization(scope, LoginCommand::CreateEnvironment) + .await?; + let auth = self.account_auth(scope, self.flags.terminal())?; + self.submission + .record_auth_change(environment.environment_id, scope); + auth.create_environment(&token, environment).await + } + + /// The organization token of a browser login, for `command`, which an API key cannot run. + async fn login_organization(self, scope: &Scope, command: LoginCommand) -> Result { + match self.resolve_organization(scope).await? { + ApiCredential::OrganizationToken(token) => Ok(token), + ApiCredential::ApiKey(_) => Err(self.requires_login(scope, command)), + } + } + + /// `command` needs a browser login; the error says which API key was used instead. + fn requires_login(self, scope: &Scope, command: LoginCommand) -> Error { + if ambient_api_key(self.flags).is_some() { + return Error::auth(format!( + "{command} needs a browser login, but this command used VOLTAGE_API_KEY from the environment" + )) + .with_hint( + "Pass --account NAME to use a saved browser login (`voltage login` creates one), or unset VOLTAGE_API_KEY.", + ); + } + let used = self.settings.account_name(scope).unwrap_or_default(); + Error::auth(format!( + "{command} needs a browser login, but the selected credential {used} is an API key" + )) + .with_hint("Select a browser login with --account NAME, or sign in with `voltage login`.") + } + /// One discovery listing with a token already in hand: the login's access token for /// organizations, or an organization token for environments. pub async fn list(self, scope: &Scope, what: Discovery, token: &Secret) -> Result { @@ -1130,6 +1268,32 @@ pub async fn import_key( mod tests { use super::*; + #[test] + fn a_login_only_command_is_named_as_typed() { + let named = |command: LoginCommand| command.to_string(); + let discover = LoginCommand::Discover; + assert_eq!( + named(discover(Discovery::Organizations, DiscoveryCommand::List)), + "organizations list" + ); + assert_eq!( + named(discover(Discovery::Organizations, DiscoveryCommand::Get)), + "organizations get" + ); + assert_eq!( + named(discover(Discovery::Environments, DiscoveryCommand::List)), + "environments list" + ); + assert_eq!( + named(discover(Discovery::Environments, DiscoveryCommand::Get)), + "environments get" + ); + assert_eq!( + named(LoginCommand::CreateEnvironment), + "environments create" + ); + } + #[test] fn service_urls_require_https_except_on_loopback() { assert_eq!( @@ -1348,10 +1512,34 @@ mod tests { for field in ["id", "email", "organizations"] { assert!(has_property(&user, field), "current user lacks {field}"); } - assert!( - spec["paths"]["/api/v1/organizations/{organization_id}/environments"]["get"] - .is_object() + let environments = &spec["paths"]["/api/v1/organizations/{organization_id}/environments"]; + assert!(environments["get"].is_object()); + // `environments create` sends exactly the contract's required `Environment` fields. + assert_eq!( + environments["post"]["requestBody"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/Environment" ); + let body = serde_json::to_value(NewEnvironment { + environment_id: Uuid::nil(), + organization_id: Uuid::nil(), + name: "staging".into(), + }) + .unwrap(); + let mut sent: Vec<&str> = body + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + let mut required: Vec<&str> = spec["components"]["schemas"]["Environment"]["required"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect(); + sent.sort_unstable(); + required.sort_unstable(); + assert_eq!(sent, required); let errors = &spec["components"]["schemas"]["OAuthError"]["properties"]; assert!(errors.get("error").is_some()); } diff --git a/src/cli.rs b/src/cli.rs index c5ffcd2..1e70842 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -204,7 +204,7 @@ pub enum LocalCommand { /// Environments in the selected organization Environments { #[command(subcommand)] - command: DiscoveryCommand, + command: EnvironmentCommand, }, /// Named organization, environment, and credential selections Profiles { @@ -375,6 +375,47 @@ impl DiscoveryCommand { } } +/// What `environments` does: list or show, as for `organizations`, or create one. +#[derive(Debug, Subcommand)] +pub enum EnvironmentCommand { + #[command(flatten)] + Discovery(DiscoveryCommand), + /// Create an environment in the selected organization + Create(CreateEnvironmentFlags), +} + +/// Flags for `environments create`. The organization comes from the scope. +#[derive(Debug, Args)] +pub struct CreateEnvironmentFlags { + /// Environment name + #[arg(long, value_parser = parse_name, help_heading = "Environment")] + pub name: String, + /// Optional new environment UUID; generated when omitted + #[arg(long, value_name = "UUID", help_heading = "Environment")] + pub id: Option, + /// Send this change. Without it or VOLTAGE_EXECUTE=1, the change is only described + #[arg(long, help_heading = "Safety")] + pub execute: bool, +} + +impl CreateEnvironmentFlags { + pub fn requested(&self) -> Requested { + if self.execute { + Requested::Execute + } else { + Requested::Unspecified + } + } +} + +/// A name with at least one character that is not whitespace. +fn parse_name(value: &str) -> std::result::Result { + if value.trim().is_empty() { + return Err("the name must not be empty".into()); + } + Ok(value.to_owned()) +} + #[derive(Debug, Subcommand, Eq, PartialEq)] pub enum ProfileCommand { /// Show every saved profile @@ -1463,6 +1504,10 @@ fn local_examples(path: &[&str]) -> Option<&'static str> { ["auth", "import-key"] => { " # Save an environment API key read from stdin voltage auth import-key --account ci --stdin < key.txt" + } + ["environments", "create"] => { + " # Show the request without --execute, then create the environment + voltage environments create --account work --org ORG_ID --name staging --execute" } ["profiles", "create"] => { " # Save an organization, environment, and credential as `prod` @@ -1771,7 +1816,7 @@ mod tests { assert!(matches!( parse(&["environments", "list", "--all"]), Ok(Command::Local(LocalCommand::Environments { - command: DiscoveryCommand::List + command: EnvironmentCommand::Discovery(DiscoveryCommand::List) })) )); assert!(matches!( diff --git a/src/dry_run.rs b/src/dry_run.rs index 08c7cab..bef4246 100644 --- a/src/dry_run.rs +++ b/src/dry_run.rs @@ -138,12 +138,18 @@ pub fn describe( /// On a terminal, say in words what the described change would do and the values it would /// use, before the description's full details. It shows only what the description shows. pub fn announce(terminal: Terminal, invocation: &ApiInvocation, described: &Envelope) { + announce_change(terminal, &action(invocation), &facts(invocation, described)); +} + +/// `announce` for any change: what it would do, such as "create an environment", and the +/// values it would use. +pub fn announce_change(terminal: Terminal, action: &str, facts: &Value) { let caution = Line::default() .paint(Role::Caution, "!") .text(" Not sent: this would ") - .styled(Role::Emphasis, &action(invocation)); + .styled(Role::Emphasis, action); terminal.notice_line(caution); - for line in output::field_lines(&facts(invocation, described)) { + for line in output::field_lines(facts) { terminal.notice_line(Line::default().text(&format!(" {line}"))); } terminal.notice_line(Line::default()); diff --git a/src/environment.rs b/src/environment.rs new file mode 100644 index 0000000..5adc95d --- /dev/null +++ b/src/environment.rs @@ -0,0 +1,157 @@ +//! `environments create`: describe the new environment, or send it to the auth service. +//! +//! Environments belong to the auth service, not the Voltage API, so this command is not in +//! the operation registry. It follows the same safety model as an API change: without +//! `--execute` or `VOLTAGE_EXECUTE=1`, it describes the request before authenticating and +//! exits 6. The CLI chooses the environment's ID, as the contract requires, so a described +//! and a sent run both report it in `resource_id`. + +use crate::{ + Result, + api::{self, Execution}, + auth::{self, NewEnvironment, Resolver}, + cli::{CreateEnvironmentFlags, GlobalFlags}, + config::{AUTH_URL, CredentialProblem, Scope, Settings, shell_word}, + dry_run, + output::{Envelope, NextStep, Outcome, Output}, +}; +use serde::Serialize; +use serde_json::{Value, json}; +use uuid::Uuid; + +const COMMAND: &str = "environments create"; +const OMITTED: &str = "[OMITTED]"; + +/// The `data` of a described `environments create` (`outcome: dry_run`). +#[derive(Serialize)] +struct Description { + operation: &'static str, + method: &'static str, + url: String, + body: Value, + /// The name was replaced with `[OMITTED]`. + values_omitted: bool, + /// A browser login's token, exchanged for the organization. + authentication: &'static str, + notes: Vec<&'static str>, +} + +/// Run `environments create`: describe it and fail with exit code 6, or create it once. +pub async fn create( + flags: &CreateEnvironmentFlags, + execution: Execution, + global: &GlobalFlags, + resolver: Resolver<'_>, + scope: &Scope, + out: &mut Output, +) -> Result<()> { + let environment = NewEnvironment { + environment_id: flags.id.unwrap_or_else(Uuid::new_v4), + organization_id: scope.require_org()?, + name: flags.name.clone(), + }; + let terminal = global.terminal(); + match execution { + Execution::DescribeChange => { + let described = describe( + &environment, + flags.id.is_none(), + global, + resolver.settings, + scope, + )?; + if out.is_human() && terminal.decorates() { + let facts = json!({"environment_id": environment.environment_id}); + dry_run::announce_change(terminal, "create an environment", &facts); + } + out.write(described, &[])?; + return Err(api::not_sent()); + } + Execution::SendFromEnvironment => api::sending_from_environment(terminal), + Execution::Send => {} + } + let status = resolver.create_environment(scope, &environment).await?; + let id = environment.environment_id; + let created = Envelope::new( + Some(status), + serde_json::to_value(&environment)?, + Some(id), + Outcome::Succeeded, + ); + out.write( + Envelope { + next: Some(NextStep { + what: "Save it in a profile", + command: format!( + "voltage profiles create PROFILE --org {} --env {id}{}", + environment.organization_id, + account_flag(scope) + ), + }), + ..created + }, + &[], + ) +} + +/// The request a real run would send. The name is private unless `--show-secrets`. +fn describe( + environment: &NewEnvironment, + generated_id: bool, + global: &GlobalFlags, + settings: &Settings, + scope: &Scope, +) -> Result { + let mut body = serde_json::to_value(environment)?; + if !global.show_secrets { + body["name"] = Value::String(OMITTED.into()); + } + let mut notes = vec![ + "No request was sent. Local validation does not prove the auth service will accept it.", + "A real run needs a browser login; an API key cannot create environments.", + ]; + if generated_id { + notes.push("The environment_id was generated for this description; a real run generates a new one unless you pass --id."); + } + let description = Description { + operation: COMMAND, + method: "POST", + url: format!( + "{}/organizations/{}/environments", + auth_url(global, settings, scope)?, + environment.organization_id + ), + body, + values_omitted: !global.show_secrets, + authentication: "browser_login", + notes, + }; + Ok(Envelope::new( + None, + serde_json::to_value(description)?, + Some(environment.environment_id), + Outcome::DryRun, + )) +} + +/// The auth service a real run would call: the selected login's, which is where it was +/// created, else the one `--auth-url` or the default names. A selection a real run would +/// reject, such as an unknown `--account`, fails here too, because the check is local. +fn auth_url(global: &GlobalFlags, settings: &Settings, scope: &Scope) -> Result { + let saved = match settings.select_account(scope) { + Ok(name) => Some(settings.account(&name)?.auth_url.as_str()), + // No saved login yet: name the service `voltage login` would sign in to. + Err(CredentialProblem::NoCredential) => None, + Err(problem) => return Err(problem.error(scope, settings)), + }; + auth::base_url(saved.unwrap_or(global.auth_url.as_deref().unwrap_or(AUTH_URL))) +} + +/// `--account NAME` when the command named its login, so the next command uses it too. +fn account_flag(scope: &Scope) -> String { + scope + .account + .as_deref() + .map(|name| format!(" --account {}", shell_word(name))) + .unwrap_or_default() +} diff --git a/src/lib.rs b/src/lib.rs index f06a578..d47b0c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ mod cli; pub mod config; mod context; mod dry_run; +mod environment; mod error; mod feedback; mod greeting; diff --git a/src/pick.rs b/src/pick.rs index c338f44..a36b410 100644 --- a/src/pick.rs +++ b/src/pick.rs @@ -8,8 +8,8 @@ use crate::{ api::{SubmissionState, profile_offer_interrupted}, auth::{ApiCredential, Discovery, Resolver}, cli::{ - BodySource, Command, DiscoveryCommand, GlobalFlags, LocalCommand, ProfileCommand, - missing_wallet, + BodySource, Command, DiscoveryCommand, EnvironmentCommand, GlobalFlags, LocalCommand, + ProfileCommand, missing_wallet, }, config::{Credential, Profile, Scope, Settings, shell_word}, error::ErrorKind, @@ -66,9 +66,14 @@ fn needs(command: &Command) -> Needs { Command::Local(LocalCommand::Organizations { command: DiscoveryCommand::Get, }) => Needs::new(true, false, false), - Command::Local(LocalCommand::Environments { command }) => { - Needs::new(true, *command == DiscoveryCommand::Get, false) - } + Command::Local(LocalCommand::Environments { command }) => Needs::new( + true, + matches!( + command, + EnvironmentCommand::Discovery(DiscoveryCommand::Get) + ), + false, + ), Command::Local(LocalCommand::Profiles { command: ProfileCommand::Create { .. }, }) => Needs::new(true, true, false), @@ -452,6 +457,10 @@ mod tests { needs_of(&["environments", "list"]), Needs::new(true, false, false) ); + assert_eq!( + needs_of(&["environments", "create", "--name", "staging"]), + Needs::new(true, false, false) + ); assert_eq!( needs_of(&["profiles", "create", "prod"]), Needs::new(true, true, false) diff --git a/src/startup.rs b/src/startup.rs index 663461e..f04542f 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -5,18 +5,19 @@ use crate::{ api::{self, Execution, SubmissionState}, auth::{self, Discovery, Resolver}, cli::{ - self, AuthCommand, Command, ContextFlags, ContextFormat, ConvertFlags, DiscoveryCommand, - FeedbackFlags, GlobalFlags, Invocation, LocalCommand, ParseFailure, PriceFlags, - ProfileCommand, + self, AuthCommand, Command, ContextFlags, ContextFormat, ConvertFlags, + CreateEnvironmentFlags, DiscoveryCommand, EnvironmentCommand, FeedbackFlags, GlobalFlags, + Invocation, LocalCommand, ParseFailure, PriceFlags, ProfileCommand, }, config::{self, Precedence, Profile, Scope, Settings}, - context, + context, environment, error::{CredentialSources, ErrorKind}, feedback::{self, Reporter}, greeting, output::{Envelope, NextStep, Output, OutputFormat, report_error, write_stdout}, pick, price::{PRICE_URL, PriceService}, + registry::Method, style::ColorChoice, wallet_currency, wallet_shortlist, }; @@ -151,13 +152,20 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result } Command::Api(mut api) => { // `VOLTAGE_EXECUTE` and every local check run before the wallet is read. - let execution = Execution::resolve(&api, config::execute_variable()?); + let execution = Execution::resolve( + api.requested, + api.operation.method, + config::execute_variable()?, + ); let resolver = session.resolver(); wallet_shortlist::require(&api, &global, resolver, &session.scope).await?; wallet_currency::fill(&mut api, &global, resolver, &session.scope).await?; let mut out = output(!execution.describes())?; api::execute(&api, execution, &global, resolver, &session.scope, &mut out).await } + Command::Local(LocalCommand::Environments { + command: EnvironmentCommand::Create(flags), + }) => session.create_environment(&flags, output).await, // Dotenv lines for another tool, not a result envelope. Command::Local(LocalCommand::Context(ContextFlags { format: Some(ContextFormat::Env), @@ -254,6 +262,27 @@ impl<'a> Session<'a> { }) } + /// `environments create`, which writes its own output: a description goes to stdout and + /// never reserves `--output-file`, like an API change. + async fn create_environment( + &self, + flags: &CreateEnvironmentFlags, + output: impl FnOnce(bool) -> Result, + ) -> Result<()> { + let execution = + Execution::resolve(flags.requested(), Method::Post, config::execute_variable()?); + let mut out = output(!execution.describes())?; + environment::create( + flags, + execution, + self.global, + self.resolver(), + &self.scope, + &mut out, + ) + .await + } + fn resolver(&self) -> Resolver<'_> { Resolver { settings: &self.settings, @@ -296,9 +325,9 @@ impl<'a> Session<'a> { LocalCommand::Organizations { command } => { discover(self.resolver(), scope, Discovery::Organizations, command).await } - LocalCommand::Environments { command } => { - discover(self.resolver(), scope, Discovery::Environments, command).await - } + LocalCommand::Environments { + command: EnvironmentCommand::Discovery(command), + } => discover(self.resolver(), scope, Discovery::Environments, command).await, LocalCommand::Context(_) => { Envelope::settings(context::inspect(&self.settings, global)?) .map(|context| context.titled("voltage context")) @@ -310,6 +339,10 @@ impl<'a> Session<'a> { | LocalCommand::Feedback(_) | LocalCommand::Price(_) | LocalCommand::Convert(_) => Err(Error::usage("This command needs no configuration")), + // `execute` runs it, because a description must not reserve --output-file. + LocalCommand::Environments { + command: EnvironmentCommand::Create(_), + } => Err(Error::usage("This command writes its own output")), } } } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 1eb429e..b936f43 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -539,6 +539,11 @@ fn settings_at(dir: &Path) -> Settings { /// A saved browser login stored in a private file, expired or good for ten minutes. fn seeded_login(dir: &Path, auth: &MockServer, expired: bool) -> Settings { + seeded_login_at(dir, &auth.uri(), expired) +} + +/// `seeded_login` with an auth service that is not a mock server. +fn seeded_login_at(dir: &Path, auth_url: &str, expired: bool) -> Settings { let mut settings = settings_at(dir); settings.config.accounts.insert( LOGIN_NAME.into(), @@ -548,7 +553,7 @@ fn seeded_login(dir: &Path, auth: &MockServer, expired: bool) -> Settings { email: Some(LOGIN_EMAIL.into()), organization_id: None, environment_id: None, - auth_url: auth.uri(), + auth_url: auth_url.into(), }, ); settings @@ -5143,6 +5148,14 @@ async fn discovery_with_an_api_key_says_which_key_was_used() { .contains("VOLTAGE_API_KEY from the environment"), "{error}" ); + let result = cli(dir.path(), &server) + .args(["environments", "create", "--org", ORG, "--name", "staging"]) + .assert() + .code(3); + assert_eq!( + json_error(&result)["error"]["message"], + "environments create needs a browser login, but this command used VOLTAGE_API_KEY from the environment" + ); // A saved API key selected without the ambient one is named instead. seeded_api_key(dir.path(), "saved", "saved-key"); @@ -5156,6 +5169,18 @@ async fn discovery_with_an_api_key_says_which_key_was_used() { &["environments", "list", "--org", ORG][..], "environments list", ), + ( + &[ + "environments", + "create", + "--org", + ORG, + "--name", + "staging", + "--execute", + ][..], + "environments create", + ), ] { let result = cli(dir.path(), &server) .env_remove("VOLTAGE_API_KEY") @@ -7503,6 +7528,12 @@ fn file_names(dir: &Path) -> BTreeSet { async fn assert_changes_describe_offline(examples: Vec, minimum: usize) { let (server, dir) = fixture().await; let mut settings = seeded_api_key(dir.path(), "key", ACCOUNT_KEY); + // The logins the examples name, so a description that checks `--account` finds them. A + // description reads no secret, so the saved entry is enough. + for name in ["me", "work"] { + let login = settings.config.accounts["key"].clone(); + settings.config.accounts.insert(name.into(), login); + } for name in ["prod", "staging", "test"] { settings.config.profiles.insert( name.into(), @@ -7619,6 +7650,421 @@ async fn help_examples_describe_themselves_offline() { assert_changes_describe_offline(help_examples(dir.path()), 15).await; } +// --------------------------------------------------------------------------------------- +// Environments +// --------------------------------------------------------------------------------------- + +fn environments_path() -> String { + format!("/organizations/{ORG}/environments") +} + +/// `environments create` with the saved login, in `ORG`, followed by `extra`. +fn create_environment_args<'a>(extra: &[&'a str]) -> Vec<&'a str> { + let mut args = vec![ + "environments", + "create", + "--account", + LOGIN_NAME, + "--org", + ORG, + ]; + args.extend_from_slice(extra); + args +} + +#[tokio::test(flavor = "multi_thread")] +async fn environments_create_describes_the_request_without_sending_it() { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + let args = create_environment_args(&["--name", "staging", "--id", RESOURCE]); + for variable in [None, Some("0")] { + let mut command = cli(dir.path(), &api); + command.env_remove("VOLTAGE_EXECUTE"); + if let Some(value) = variable { + command.env("VOLTAGE_EXECUTE", value); + } + let result = command.args(&args).assert().code(6); + let described = json_stdout(&result); + assert_eq!(described["outcome"], "dry_run"); + assert_eq!(described["http_status"], Value::Null); + assert_eq!(described["resource_id"], RESOURCE); + assert_eq!(described["data"]["method"], "POST"); + assert_eq!( + described["data"]["url"], + format!("{}{}", auth.uri(), environments_path()) + ); + // The name is private by default, like any free text in a described body. + assert_eq!( + described["data"]["body"], + json!({"environment_id": RESOURCE, "organization_id": ORG, "name": "[OMITTED]"}) + ); + assert_eq!(described["data"]["values_omitted"], true); + assert_eq!(json_error(&result)["error"]["exit_code"], 6); + } + + // Without --id the description reports the ID it generated; --show-secrets shows the name. + let result = cli(dir.path(), &api) + .env_remove("VOLTAGE_EXECUTE") + .args(create_environment_args(&[ + "--name", + "staging", + "--show-secrets", + ])) + .assert() + .code(6); + let described = json_stdout(&result); + assert_eq!(described["data"]["body"]["name"], "staging"); + assert_eq!(described["data"]["values_omitted"], false); + assert_eq!( + described["data"]["body"]["environment_id"], + described["resource_id"] + ); + assert!( + described["data"]["notes"] + .to_string() + .contains("a real run generates a new one unless you pass --id"), + "{described}" + ); + assert!(auth.received_requests().await.unwrap().is_empty()); + assert!(api.received_requests().await.unwrap().is_empty()); + + // With no saved login, the description names the auth service --auth-url selects. + let empty = private_tempdir(); + let result = cli(empty.path(), &api) + .env_remove("VOLTAGE_EXECUTE") + .env_remove("VOLTAGE_API_KEY") + .args([ + "environments", + "create", + "--org", + ORG, + "--name", + "staging", + "--auth-url", + "http://127.0.0.1:9/api/v1/", + ]) + .assert() + .code(6); + assert_eq!( + json_stdout(&result)["data"]["url"], + format!("http://127.0.0.1:9/api/v1{}", environments_path()) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn environments_create_sends_the_environment_once_and_reports_its_id() { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + organization_exchange(&auth, LOGIN_ACCESS, ORG, "org-access", 3).await; + respond_once( + &auth, + route("POST", environments_path()) + .and(header("authorization", "Bearer org-access")) + .and(body_json( + json!({"environment_id": RESOURCE, "organization_id": ORG, "name": "staging"}), + )), + ResponseTemplate::new(201), + ) + .await; + let result = cli(dir.path(), &api) + .env_remove("VOLTAGE_EXECUTE") + .args(create_environment_args(&[ + "--name", + "staging", + "--id", + RESOURCE, + "--execute", + ])) + .assert() + .success(); + assert_eq!( + json_stdout(&result), + json!({ + "http_status": 201, + "data": {"environment_id": RESOURCE, "organization_id": ORG, "name": "staging"}, + "resource_id": RESOURCE, + "outcome": "succeeded" + }) + ); + + // VOLTAGE_EXECUTE=1 sends it too, and says so; the generated ID is the one sent. + respond_once( + &auth, + route("POST", environments_path()).and(body_string_contains("\"name\":\"preview\"")), + ResponseTemplate::new(201), + ) + .await; + let result = cli(dir.path(), &api) + .args(create_environment_args(&["--name", "preview"])) + .assert() + .success(); + assert!(stderr(&result).contains("Sending this change because VOLTAGE_EXECUTE is set.")); + let created = json_stdout(&result); + let sent = auth + .received_requests() + .await + .unwrap() + .into_iter() + .rfind(|request| request.url.path() == environments_path()) + .unwrap(); + let sent: Value = serde_json::from_slice(&sent.body).unwrap(); + assert_eq!(sent["environment_id"], created["resource_id"]); + assert_eq!(created["data"]["environment_id"], created["resource_id"]); + + // Table output leads with the new environment's ID, for the next command to use. + respond_once( + &auth, + route("POST", environments_path()).and(body_string_contains("\"name\":\"qa\"")), + ResponseTemplate::new(201), + ) + .await; + let output = human_process_at(dir.path(), &api.uri()) + .args(create_environment_args(&["--name", "qa", "--id", WALLET])) + .args(["--output", "table"]) + .output() + .unwrap(); + assert!(output.status.success()); + let table = String::from_utf8(output.stdout).unwrap(); + assert!( + table + .lines() + .any(|line| line.starts_with("environment_id") && line.trim_end().ends_with(WALLET)), + "{table}" + ); + assert!(api.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn environments_create_reports_a_rejection_without_retrying() { + let forbidden_hint = "Your login cannot create environments in this organization. Ask an organization admin to create it, or to give you that permission."; + for (status, body, exit_code, reason, hint) in [ + ( + 400, + json!({"error": "invalid_request"}), + 1, + "invalid_request", + None, + ), + (409, json!({"detail": "taken"}), 1, "request_rejected", None), + ( + 401, + json!({"error": "invalid_token"}), + 3, + "invalid_token", + None, + ), + (403, json!({}), 3, "request_rejected", Some(forbidden_hint)), + ] { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + organization_exchange(&auth, LOGIN_ACCESS, ORG, "org-access", 1).await; + respond_once( + &auth, + route("POST", environments_path()), + ResponseTemplate::new(status).set_body_json(body), + ) + .await; + let result = cli(dir.path(), &api) + .args(create_environment_args(&[ + "--name", "staging", "--id", RESOURCE, + ])) + .assert() + .code(exit_code); + assert!(result.get_output().stdout.is_empty()); + let error = json_error(&result)["error"].clone(); + assert_eq!( + error["message"], + format!("The auth service did not create the environment (HTTP {status}, {reason})") + ); + assert_eq!(error["hint"].as_str(), hint, "{status}"); + auth.verify().await; + } +} + +#[test] +fn environments_create_reports_an_unanswered_request_with_its_id() { + use std::io::{BufRead, BufReader, Read, Write}; + // An auth service that issues the organization token, then drops the creation request. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let auth = format!("http://{}", listener.local_addr().unwrap()); + let peer = std::thread::spawn(move || { + for answer in [true, false] { + let (socket, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(socket); + let mut length = 0; + let mut first = String::new(); + reader.read_line(&mut first).unwrap(); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().unwrap(); + } + if line == "\r\n" { + break; + } + } + reader.read_exact(&mut vec![0; length]).unwrap(); + assert_eq!(first.starts_with("POST /oauth/token "), answer, "{first}"); + if answer { + let body = r#"{"access_token":"org-access","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":600}"#; + write!( + reader.get_mut(), + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + } + } + }); + let dir = private_tempdir(); + seeded_login_at(dir.path(), &auth, false); + let result = cli_at(dir.path(), "http://127.0.0.1:9") + .args(create_environment_args(&[ + "--name", "staging", "--id", RESOURCE, + ])) + .assert() + .code(4); + peer.join().unwrap(); + let error = json_error(&result)["error"].clone(); + assert_eq!( + error["message"], + format!( + "Environment creation request failed: the connection closed before a response arrived; environment {RESOURCE} may have been created" + ) + ); + assert!( + error["hint"] + .as_str() + .unwrap() + .starts_with("The change may have been applied."), + "{error}" + ); + assert_eq!(error["detail"]["resource_id"], RESOURCE); + assert_eq!(error["detail"]["organization_id"], ORG); + assert_eq!(error["detail"]["outcome"], "unknown"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn environments_create_reports_an_unusable_answer_with_its_id() { + // An answer arrived, so the request did too, but the CLI cannot tell whether the + // environment was created: an ingress's HTML error page, or a body over the limit. + for (answer, message) in [ + ( + ResponseTemplate::new(502).set_body_raw("Bad Gateway", "text/html"), + "Invalid authentication response", + ), + ( + ResponseTemplate::new(201).set_body_raw(vec![b' '; (1 << 20) + 1], "application/json"), + "Authentication response exceeds 1 MiB", + ), + ] { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + organization_exchange(&auth, LOGIN_ACCESS, ORG, "org-access", 1).await; + respond_once(&auth, route("POST", environments_path()), answer).await; + let result = cli(dir.path(), &api) + .args(create_environment_args(&[ + "--name", "staging", "--id", RESOURCE, + ])) + .assert() + .code(3); + assert!(result.get_output().stdout.is_empty()); + let error = json_error(&result)["error"].clone(); + assert_eq!( + error["message"], + format!("{message}; environment {RESOURCE} may have been created") + ); + assert_eq!( + error["hint"], + format!( + "The change may have been applied. Look for environment {RESOURCE} with `voltage environments list --org {ORG}` before you run the command again." + ) + ); + assert_eq!(error["detail"]["resource_id"], RESOURCE); + assert_eq!(error["detail"]["organization_id"], ORG); + assert_eq!(error["detail"]["environment_ids"], json!([])); + assert_eq!(error["detail"]["outcome"], "unknown"); + auth.verify().await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn environments_create_description_rejects_a_login_a_real_run_would_reject() { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + let describe = |args: &[&str]| { + cli(dir.path(), &api) + .env_remove("VOLTAGE_EXECUTE") + .env_remove("VOLTAGE_API_KEY") + .args(["environments", "create", "--org", ORG, "--name", "staging"]) + .args(args) + .assert() + }; + let unknown = describe(&["--account", "typo"]).code(3); + assert_eq!( + json_error(&unknown)["error"]["message"], + "Unknown credential typo" + ); + assert!(unknown.get_output().stdout.is_empty()); + + seeded_api_key(dir.path(), "key", "secret-key"); + let ambiguous = describe(&[]).code(2); + assert_eq!( + json_error(&ambiguous)["error"]["message"], + "Multiple credentials are saved" + ); + assert!(ambiguous.get_output().stdout.is_empty()); + assert!(auth.received_requests().await.unwrap().is_empty()); + assert!(api.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn environments_create_checks_its_flags_and_scope_before_any_request() { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + let missing_org = cli(dir.path(), &api) + .args([ + "environments", + "create", + "--account", + LOGIN_NAME, + "--name", + "staging", + ]) + .assert() + .code(2); + assert_eq!( + json_error(&missing_org)["error"]["message"], + "--org is required" + ); + let missing_name = cli(dir.path(), &api) + .args(create_environment_args(&[])) + .assert() + .code(2); + assert!(stderr(&missing_name).contains("--name ")); + let blank_name = cli(dir.path(), &api) + .args(create_environment_args(&["--name", " "])) + .assert() + .code(2); + assert!(stderr(&blank_name).contains("the name must not be empty")); + let bad_id = cli(dir.path(), &api) + .args(create_environment_args(&[ + "--name", "staging", "--id", "nope", + ])) + .assert() + .code(2); + assert!(stderr(&bad_id).contains("--id ")); + assert!(auth.received_requests().await.unwrap().is_empty()); + assert!(api.received_requests().await.unwrap().is_empty()); +} + // --------------------------------------------------------------------------------------- // Prices and conversions // ---------------------------------------------------------------------------------------