Skip to content

feat(cli): create environments with environments create - #123

Open
thebrandonlucas wants to merge 1 commit into
masterfrom
brandon/dev-2953-voltage-cli-add-environments-create
Open

thebrandonlucas wants to merge 1 commit into
masterfrom
brandon/dev-2953-voltage-cli-add-environments-create

Conversation

@thebrandonlucas

@thebrandonlucas thebrandonlucas commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Problem

voltage environments has only list. To add an environment, a person had to leave the CLI, even though the auth contract (api/auth-openapi.json) defines POST /api/v1/organizations/{organization_id}/environments.

Closes #118. Linear: DEV-2953.

Behavior

voltage environments create --account me --org ORG_ID --name staging --execute
Run Result Exit
Without --execute (or with VOLTAGE_EXECUTE=0) Describes the request and sends nothing: method, auth-service URL, body, and notes. outcome: dry_run. The name is [OMITTED] unless --show-secrets. 6
With --execute or VOLTAGE_EXECUTE=1 Exchanges the browser login for an organization token, then sends {environment_id, organization_id, name} once. 0
API key (saved or VOLTAGE_API_KEY) environments create needs a browser login, ..., the same wording as environments list. Sends nothing. 3
Auth service rejects the request The auth service did not create the environment (HTTP STATUS, CODE). A 403 also gets a permission hint. 1, or 3 for 401/403
No response, or an answer the CLI cannot use (such as an ingress's HTML 502, or a body over 1 MiB) The error says the environment may have been created, names its ID, and includes an uncertain_submission detail. Without a hint of its own, it says to look for the ID with environments list before a rerun. 4 for no response or an unreadable body; 3 for a body that is not JSON or is over 1 MiB
No --org, no --name, blank --name, or a bad --id Usage error before any request. 2
A login a real run would reject (--account typo, or several saved logins and no --account) The description fails with the same error, before any request. 3 or 2
  • The contract makes environment_id required, so the CLI generates it, or uses --id UUID. Both the description and the result report it in resource_id. The result's data is the environment that was sent; the 201 response has no documented body. On a terminal, the result suggests voltage profiles create PROFILE --org ORG --env ID.
  • The description skips authentication and does not reserve --output-file, as API changes already do. Ctrl-C after sending reports the possible write with the environment ID and no environment IDs, because the change is to the auth service, not to the profile's environments.
  • The help has an example, which the offline help-example test describes (exit 6). The guide example runs in the documented-examples test.

Code changes

  • src/environment.rs (new): describe or send environments create.
  • auth.rs: AuthService::create_environment and Resolver::create_environment. The API-key error moved out of discover into requires_login, so environments list and create share it. send_to_auth lets a write report its own transport failure. The contract test also checks that the request body has exactly the Environment schema's required fields.
  • api.rs: Execution::change, not_sent, and sending_from_environment are shared with the API path. SubmittedWrite now stores payment: bool instead of an OperationId, so an auth-service write can be recorded too.
  • dry_run.rs: announce_change is the terminal "Not sent: this would ..." line, without an ApiInvocation.
  • Docs: guide section in login-and-profiles.md, troubleshooting, docs/commands.md (regenerated), and a CHANGELOG.md line.

Try it on staging

Setup: Try a pull request on staging

alias voltage="$PWD/scripts/voltage-staging"
voltage --build
voltage login --account me --credential-store file

Find an organization ID with voltage organizations list --account me. These runs only describe; nothing is sent:

voltage environments create --account me --org ORG_ID --name try-it; echo "exit $?"
voltage environments create --account me --org ORG_ID --name try-it --json | jq '{outcome, resource_id, body: .data.body}'
voltage environments create --account typo --org ORG_ID --name try-it; echo "exit $?"
voltage environments create --account me --org ORG_ID --name "  "; echo "exit $?"
voltage environments create --help

Expect:

  • ! Not sent: this would create an environment with the generated environment_id, then exit 6.
  • "outcome": "dry_run", a resource_id equal to body.environment_id, and "name": "[OMITTED]".
  • Unknown credential typo with the saved credential names, then exit 3.
  • the name must not be empty, then exit 2.
  • The help shows the --execute example.

Sends a change. This creates a real environment in ORG_ID, and the CLI cannot delete it. Run it only in an organization where a test environment is acceptable:

voltage environments create --account me --org ORG_ID --name try-it --execute
voltage environments list --account me --org ORG_ID

Expect: the result shows environment_id and a Save it in a profile next step. The new ID appears in the list.

Validation

Run on the head after merging master and the review fixes (fc9e3c5):

  • cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (200 unit, 156 integration), and python3 scripts/check-coverage.py: all pass.
  • New wiremock integration tests cover: described and not sent; sent with --execute and with VOLTAGE_EXECUTE=1; generated ID; table output; rejection with 400, 409, 401, and 403; a connection that closes before a response; a 502 HTML page and an oversized body after the request (environments_create_reports_an_unusable_answer_with_its_id); a description with an unknown or ambiguous login; API key (saved and ambient); and missing or invalid flags and scope. A unit test checks the Ctrl-C detail for an auth change.
  • CRAP (scripts/crap-report.sh, cargo-llvm-cov 0.8.5, knots 1.16.0): 863 of 1084 functions measured, 0 violations. The highest is 18.10, wait_for_payment in src/api.rs, which this PR does not change. New or changed functions: environment::create 9.01, describe 5.0, AuthService::create_environment 4.0, auth_url 3.0, NewEnvironment::possibly_created 2.0.
  • Staging, describe only: the first, third, and fourth commands above behave as listed. The --execute run was not done, because the CLI cannot delete an environment.

Limits and open questions

Stack

Stack #135, layer 8 of 9 (bottom targets master):

# PR Change
1 #97 Staging setup and a Try it section for each PR
2 #120 --all on organizations, environments, and profiles list
3 #128 Warn when a profile or flag overrides a set VOLTAGE_* variable
4 #129 voltage context --format env
5 #127 Replace hand-rolled terminal code with crossterm
6 #126 Pick the wallet, organization, or environment to show
7 #130 Prefilled GitHub issue links from voltage feedback and panics
8 #123 (this PR) voltage environments create
9 #131 Replace flake.nix with a Kaifile

The layers are chained with merge commits, because history is not rewritten. Use squash and merge, bottom first.

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is close. The request body matches the contract's Environment schema, a unit test pins it, and the 400, 409, 401, and 403 paths, the closed connection, and both kinds of API key each have an integration test. The changelog and guides are updated, and the description reports the gate and the CRAP result.

CI: The aarch64-apple-darwin failure is a broken runner, not this PR. xcrun --sdk macosx --show-sdk-path could not find the SDK, so the build script failed to link before any test ran. Rerun the job.

Inline:

  1. Should fix: an answer that cannot be read after the request arrived loses the "may have been created" detail.
  2. Nit: Ctrl-C records the profile's environment IDs.
  3. Nit: the description hides an --account error.

Needs a decision before merge (your open question): docs/quality.md says to record a mutation that carries an ID before sending it, and to reject a reused ID with a different payload. This PR does neither, so --id X sent twice with different names is not caught locally. Either add the journal entry, or record the exception in docs/quality.md with the reason: the auth service rejects a duplicate ID with a 409.

Comment thread src/auth.rs Resolved
Comment thread src/api.rs Resolved
Comment thread src/environment.rs Resolved
@thebrandonlucas

Copy link
Copy Markdown
Contributor Author

On the open journal question from your review: this still needs a decision. Here is what I found to help make it.

What the auth service does with the ID, from auth-service master (create_environment in src/domains/organizations/routes.rs, service.rs, and src/stores/organizations.rs):

  • It stores the client's environment_id as the environment's uuid (INSERT INTO environments (organization_id, name, uuid) ... $3). So the resource_id the CLI reports is the real ID, which answers the "client-chosen ID" question in the description.
  • environments.uuid is UNIQUE (migration 20240930173110_environments), so a reused ID never creates a second environment, whatever the name.
  • A duplicate does not return 409. The unique violation becomes StoreError::UnknownSQL → DataError::QueryIssue → HTTP 500 Internal error. The contract lists no 409 for this route. So the reason "the auth service rejects a duplicate ID with a 409" is not accurate today.

The options:

  1. Journal it. --id X reused with a different name fails locally before sending, as API changes do. Cost: JournalRecord stores a registry OperationId, and this is not a registry operation. It needs a new variant or a separate record kind, and the journal's reconciliation reads the Voltage API, not the auth service. This PR is already at about 980 changed lines, so this would likely be a stacked follow-up.
  2. Record an exception in docs/quality.md. The reason would be: the server's unique constraint prevents a duplicate, a generated ID is fresh each run, and recovery is environments list plus the reported ID. Cost: a reused --id gets a bare HTTP 500 instead of a clear local error. Optionally, ask auth-service to map the unique violation to 409.

My recommendation is option 2, plus an auth-service issue for the 409. I have left this unresolved for you and Brandon to decide.

@thebrandonlucas

Copy link
Copy Markdown
Contributor Author

Decision on the journal question: option 2. The exception is recorded under Approved exceptions in docs/quality.md (1748a5d). voltagecloud/auth-service#428 tracks returning 409 instead of 500 for a reused environment ID. I updated the description to match.

@thebrandonlucas
thebrandonlucas marked this pull request as ready for review November 5, 2026 14:50

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking good, just some small stuff

Comment thread src/api.rs Resolved
Comment thread src/auth.rs Resolved
@thebrandonlucas
thebrandonlucas force-pushed the brandon/dev-2953-voltage-cli-add-environments-create branch 2 times, most recently from 4859ddc to db2ff91 Compare November 5, 2026 15:27
@thebrandonlucas
thebrandonlucas requested a review from tee8z November 5, 2026 15:35

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed current head db2ff91 against #130 (c94b96e), including the newly pushed auth/execution refactor.

Requesting changes for the inline recovery bug: a valid JSON 5xx response is reported as a definite failure and loses the environment ID, although a gateway can return that after upstream creation. A loopback-only reproduction recorded two different environment IDs on two manual runs while both CLI results said “did not create,” exit 1, with detail: null. The suggested fix preserves uncertainty and the recovery ID; please add regression coverage.

Repository requirement: git diff --shortstat c94b96e...db2ff91 reports 1,017 additions + 73 deletions = 1,090 changed lines. Root AGENTS.md requires the full PR diff to remain under 1,000; split out a preparatory layer or reduce the diff before merge.

Local verification passed on db2ff91: formatting, Clippy with warnings denied, dependency hygiene, cargo test --locked (222 unit + 166 integration), and contract/docs checks. Required CRAP measurement also passed: 928/1162 functions measured, 234 unmeasured, zero scores >=20; highest 18.10 (wait_for_payment). Passing tests currently miss the JSON-5xx recovery case.

Limits: no live staging creation; the recovery reproduction uses a synthetic loopback service. No manual Windows/macOS run.

Comment thread src/auth.rs
Comment on lines +503 to +505
let code = response
.error_code()
.unwrap_or_else(|| "request_rejected".into());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve uncertainty for JSON 5xx creation responses

A gateway can return a valid JSON 502/504 after the upstream has committed the create. This path currently treats that as a definitive rejection, says “did not create,” and drops the generated ID (detail: null). The HTML-502 path immediately above correctly preserves uncertainty, so changing the gateway's response format changes the recovery guarantee.

Reproduced at db2ff91 using only a loopback mock: the mock recorded each submitted environment before returning 502 {"error":"bad_gateway"}. The CLI returned exit 1 with no recovery ID; repeating the same command without --id submitted a second, different ID. No live service was contacted.

Treat server/gateway 5xx answers as uncertain, preserve the ID and list-before-rerun hint, and return transport/unknown exit 4. For example, insert the following before the definitive rejection handling, then add integration cases for JSON and empty 5xx bodies that assert the ID, unknown outcome, and no automatic resend:

Suggested change
let code = response
.error_code()
.unwrap_or_else(|| "request_rejected".into());
let code = response
.error_code()
.unwrap_or_else(|| "request_rejected".into());
if response.status >= 500 {
return Err(environment.possibly_created(Error::transport(format!(
"Environment creation failed (HTTP {}, {code})",
response.status
))));
}

Base automatically changed from brandon/dev-2955-add-prepopulated-github-issue-links-to-the-cli to master November 5, 2026 18:07
Add `voltage environments create --org ORG --name NAME [--id UUID]`, which posts the auth contract's Environment to the auth service with the login's organization token. Without --execute or VOLTAGE_EXECUTE=1 it describes the request and exits 6. The CLI generates the environment ID and reports it in resource_id.
@thebrandonlucas
thebrandonlucas force-pushed the brandon/dev-2953-voltage-cli-add-environments-create branch from db2ff91 to d6a4f61 Compare November 5, 2026 18:07

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed d6a4f611bb749183eec2f410004ff9f54bbfb533 against master 2f7ca726f3cd2807fe13b9e5f68c0b076c5cf7b0. The JSON-5xx recovery finding remains on this head; see the inline suggestion and loopback reproduction. A JSON gateway response must retain the same uncertainty and generated recovery ID as an unreadable response. Manual reruns otherwise create fresh IDs while the CLI says nothing was created.

The full PR remains above the repository limit: 1,017 additions + 73 deletions = 1,090 changed lines. Root AGENTS.md requires fewer than 1,000 against the actual PR base. Split a preparatory change or reduce this diff before merge.

Independent verification on this head: formatting, full-target Clippy with warnings denied, dependency hygiene, contract/docs check, and full cargo test --locked passed (222 unit + 166 integration). These passing tests omit JSON 5xx creation responses. Required whole-package CRAP measurement passed: 928 measured functions, 234 unmeasured, zero scores >=20; highest 18.099272 (wait_for_payment). Scope: src, build.rs, tests; cargo-llvm-cov 0.8.5 and knots 1.16.0, all targets/features, locked/offline.

No live staging creation, production request, or cross-platform manual run. The reproduction used only synthetic credentials and a loopback mock.

Comment thread src/auth.rs
}
let code = response
.error_code()
.unwrap_or_else(|| "request_rejected".into());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve uncertainty and the environment ID for JSON 5xx responses

The current head still reaches the definitive-rejection branch for a valid JSON 502/504. A gateway can return that after the upstream committed creation. I reproduced this on the unmodified head with a loopback service that records each POST then returns 502 {"error":"bad_gateway"}: two manual runs exited 1 with detail:null and “did not create,” while the mock recorded two distinct environment IDs. No automatic retry occurred, but the lost recovery ID makes a manual rerun unsafe.

Insert a server-error branch before the definitive rejection, and cover JSON and empty 5xx bodies with regression tests asserting unknown outcome, the original ID, a list-before-rerun hint, and exactly one POST:

Suggested change
.unwrap_or_else(|| "request_rejected".into());
.unwrap_or_else(|| "request_rejected".into());
if response.status >= 500 {
return Err(environment.possibly_created(Error::transport(format!(
"Environment creation failed (HTTP {}, {code})",
response.status
))));
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No command creates an environment

2 participants