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