Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`<br>GET `/api/v1/organizations/{organization_id}/environments` |
| `voltage environments get` | POST `/api/v1/oauth/token`<br>GET `/api/v1/organizations/{organization_id}/environments` |
| `voltage environments create --name NAME` | POST `/api/v1/oauth/token`<br>POST `/api/v1/organizations/{organization_id}/environments` |
| `voltage context` | local only |
| `voltage profiles create NAME` | local only |
| `voltage profiles get NAME` | local only |
Expand Down
12 changes: 11 additions & 1 deletion docs/guides/login-and-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions docs/quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions scripts/check-coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', []),
Expand Down
70 changes: 54 additions & 16 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<bool>) -> Self {
match invocation.requested {
pub fn resolve(requested: Requested, method: Method, execute_variable: Option<bool>) -> 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,
}
Expand All @@ -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.";

Expand Down Expand Up @@ -1359,7 +1369,7 @@ pub(crate) enum SessionChange {
}

struct SubmittedWrite {
operation: OperationId,
payment: bool,
resource_id: Option<Uuid>,
organization_id: Option<Uuid>,
environment_ids: Vec<Uuid>,
Expand All @@ -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<Uuid>, 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);
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)] {
Expand Down
Loading
Loading