diff --git a/CHANGELOG.md b/CHANGELOG.md index 39995ce..6386ef5 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`. - Filter `payments list` with `--check-only true|false`, and `wallets ledger list` with `--event-type adjustment`, from the refreshed API contract. Omit `--event-type` for payment ledger entries. Filters carry across pagination. - `webhooks test WEBHOOK_ID` without `--data` sends a test event, with a new `delivery_id` and a payload of type `test` and event `created`. Before, it failed with "This operation requires --data". `--data` still sends your own payload. - A list that returns every item in one response, such as `wallets list`, accepts `--all` and ignores it, because there are no more pages. Before, it rejected `--all`. `--all` still pages through lists that take `--limit`. diff --git a/docs/commands.md b/docs/commands.md index 69fffd8..9e5c0e8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -14,6 +14,7 @@ These commands use the auth service (`https://auth.voltage.cloud/api/v1`, overri | `voltage auth import-key` | local only | | `voltage organizations list` | GET `/api/v1/users/current` | | `voltage environments list` | 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 cfeeeb0..90dd8e0 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` and `environments list` 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`, `environments list`, 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 cc6a5de..03c2de6 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/scripts/check-coverage.py b/scripts/check-coverage.py index c321b5a..b3db32e 100644 --- a/scripts/check-coverage.py +++ b/scripts/check-coverage.py @@ -21,6 +21,7 @@ ('voltage auth import-key', []), ('voltage organizations list', [('GET', '/api/v1/users/current')]), ('voltage environments list', [('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 8e13c73..6c98119 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() { @@ -762,8 +755,15 @@ impl Execution { /// changes are described. pub fn resolve(invocation: &ApiInvocation, execute_variable: Option) -> Self { match invocation.requested { - Requested::Execute => Self::Send, Requested::Unspecified if invocation.operation.method == Method::Get => Self::Send, + requested => Self::change(requested, execute_variable), + } + } + + /// `resolve` for a change, such as `environments create`, which is never a read. + pub fn change(requested: Requested, execute_variable: Option) -> Self { + match requested { + Requested::Execute => Self::Send, Requested::Unspecified if execute_variable == Some(true) => Self::SendFromEnvironment, Requested::Unspecified => Self::DescribeChange, } @@ -775,6 +775,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 +1376,7 @@ pub(crate) enum SessionChange { } struct SubmittedWrite { - operation: OperationId, + payment: bool, resource_id: Option, organization_id: Option, environment_ids: Vec, @@ -1369,13 +1386,23 @@ 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, + environment_ids: scope.envs.clone(), + }); + } + pub(crate) fn session_changing(&self, change: SessionChange) { *self.session.lock().unwrap_or_else(PoisonError::into_inner) = Some(change); } @@ -1442,7 +1469,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(), diff --git a/src/auth.rs b/src/auth.rs index b053228..5293f11 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -13,7 +13,7 @@ use crate::{ InputSource, LOCK_TIMEOUT, Login, 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, @@ -104,6 +104,15 @@ pub enum Discovery { Environments, } +/// 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, +} + /// A response from the auth service with its body in zeroized memory. struct AuthResponse { status: u16, @@ -190,17 +199,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 +420,62 @@ impl AuthService { response.json("Invalid environment response") } + /// Create `environment` once, returning the HTTP status. A request that gets no 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", + &format!( + "; environment {} may have been created", + environment.environment_id + ), + "auth", + ResponseLimit::Fixed(AUTH_TIMEOUT), + true, + ) + .with_detail(ErrorDetail::uncertain_submission( + Some(environment.environment_id), + Some(environment.organization_id), + Vec::new(), + )) + }) + .await?; + 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( @@ -966,41 +1042,59 @@ impl Resolver<'_> { /// List organizations visible to the login, or environments in the selected organization. pub async fn discover(self, scope: &Scope, what: Discovery) -> Result { - // An API key cannot list organizations or environments; say which key was used. - let requires_login = || { - let command = match what { - Discovery::Organizations => "organizations list", - Discovery::Environments => "environments list", - }; - 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, "organizations list")); + } }, + Discovery::Environments => self.login_organization(scope, "environments list").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, "environments create") + .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: &str) -> 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: &str) -> 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 { @@ -1331,10 +1425,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 d3899d4..127c48d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -203,7 +203,7 @@ pub enum LocalCommand { /// Environments in the selected organization Environments { #[command(subcommand)] - command: ListCommand, + command: EnvironmentCommand, }, /// Named organization, environment, and credential selections Profiles { @@ -295,6 +295,46 @@ pub enum ListCommand { List, } +#[derive(Debug, Subcommand)] +pub enum EnvironmentCommand { + /// List the environments in the selected organization + List, + /// 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 @@ -1338,6 +1378,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` 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..72d5ff0 --- /dev/null +++ b/src/environment.rs @@ -0,0 +1,155 @@ +//! `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, 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. +fn auth_url(global: &GlobalFlags, settings: &Settings, scope: &Scope) -> Result { + let saved = settings + .account_name(scope) + .ok() + .and_then(|name| settings.account(&name).ok()) + .map(|account| account.auth_url.as_str()); + 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 2ba5f87..63f5f2a 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 greeting; mod input; diff --git a/src/pick.rs b/src/pick.rs index 2d1efe3..b763058 100644 --- a/src/pick.rs +++ b/src/pick.rs @@ -380,6 +380,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 ab3a316..9c3ab77 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -5,11 +5,11 @@ use crate::{ api::{self, Execution, SubmissionState}, auth::{self, Discovery, Resolver}, cli::{ - self, AuthCommand, Command, ConvertFlags, GlobalFlags, Invocation, LocalCommand, - ParseFailure, PriceFlags, ProfileCommand, + self, AuthCommand, Command, ConvertFlags, EnvironmentCommand, GlobalFlags, Invocation, + LocalCommand, ParseFailure, PriceFlags, ProfileCommand, }, config::{self, Profile, Scope, Settings}, - context, + context, environment, error::{CredentialSources, ErrorKind}, greeting, output::{Envelope, NextStep, Output, OutputFormat, report_error, write_stdout}, @@ -150,6 +150,22 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result 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), + }) => { + let execution = Execution::change(flags.requested(), config::execute_variable()?); + let mut out = output(!execution.describes())?; + let resolver = session.resolver(); + environment::create( + &flags, + execution, + &global, + resolver, + &session.scope, + &mut out, + ) + .await + } Command::Local(local) => { let mut out = output(true)?; let envelope = session.local(local).await?; @@ -240,7 +256,9 @@ impl<'a> Session<'a> { .discover(scope, Discovery::Organizations) .await?, ), - LocalCommand::Environments { .. } => Envelope::local( + LocalCommand::Environments { + command: EnvironmentCommand::List, + } => Envelope::local( self.resolver() .discover(scope, Discovery::Environments) .await?, @@ -253,6 +271,10 @@ impl<'a> Session<'a> { LocalCommand::Completions { .. } | 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 53c22e4..df88a76 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -502,6 +502,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(), @@ -511,7 +516,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 @@ -4903,6 +4908,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"); @@ -4912,6 +4925,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") @@ -7093,6 +7118,345 @@ 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_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 // ---------------------------------------------------------------------------------------