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
// ---------------------------------------------------------------------------------------