Development
This page is for contributors to waldur-cli itself, not for using the CLI -- see the
Manual for that.
Two-repo architecture
waldur-cli's command surface isn't hand-written. It's generated from Waldur's OpenAPI
schema by a separate repo,
waldur-cli-generator, which
parses the schema directly (paths, params, request/response shapes, validation rules) and
emits this repo's src/commands/, src/cli.rs, and src/schema.rs wholesale. The schema is
the single source of truth end to end -- a field nobody reads drifting out of date, or a
stale request-body type, can't silently break a command, because there's no intermediate
hand-maintained layer for it to drift against.
Generated, don't edit by hand:
src/commands/-- every resource'sArgsstructs,Commandenum, andrun()dispatchsrc/cli.rs-- the top-levelopenstack/team/marketplace/authgroup wiringsrc/schema.rs-- the CLI's command surface as JSON, embedded forwaldur-cli schema(a machine-readable tool spec for LLM agents)docs/reference/-- one markdown page per<group> <resource> <verb>command, plus a README index per group. Unlikesrc/commands/, nothing hand-written is ever expected here, so the whole directory is deleted and rewritten on every run rather than merged with existing content -- a resource/verb that goes away can't leave a stale page behind
Hand-written and permanent -- everything else in src/: lib.rs, main.rs, config.rs,
output.rs, pagination.rs, http.rs, web.rs, request.rs, filter.rs, query.rs,
order.rs, wait.rs, progress.rs.
What ends up in the generated surface (which resources, which verbs, which custom actions,
which HomePort routes) is controlled by
commands.toml
in the generator repo -- see that repo's README for the manifest format and how to add a
resource or verb.
HTTP transport
http.rs owns the two shared clients (pagination.rs borrows one for its page loop):
build_client()-- retries transient failures (connection errors, timeouts,5xx,429) with exponential backoff.build_client_no_retry()-- same timeout, no retrying.
call_one picks between them with Method::is_idempotent(), which is the whole point of the
split: replaying a POST/PATCH that timed out after the server applied it would duplicate
the effect, and for provision that's a duplicate marketplace order. GET/PUT/DELETE are safe
to replay; POST/PATCH are not, so they fail fast instead.
Both clients set an explicit request timeout, because reqwest's default is no timeout at all — an unattended run would otherwise hang forever on a stalled connection.
The HTTP stack moves as a unit
reqwest, reqwest-middleware, reqwest-retry, and reqwest-tracing are version-locked to
each other — reqwest-retry 0.9 requires reqwest-middleware 0.5, which requires reqwest
0.13. Bumping any one of them in isolation produces two incompatible copies of
reqwest-middleware in the tree and a type error at ClientBuilder::new, so they're upgraded
together or not at all.
Keeping reqwest on 0.13 specifically also matters: self_update depends on 0.13, so any
older version here means two reqwest crates compiled in, and two rustls crypto providers
(ring for 0.12's rustls-tls, aws-lc-rs for 0.13's rustls). Unifying on 0.13 dropped
ring entirely and took ~0.76 MiB off the shipped binary.
Two renames to know if you touch this: 0.12's rustls-tls feature is rustls in 0.13, and
query became an opt-in feature of reqwest-middleware 0.5 (pagination.rs needs it).
Nothing in tests/ exercises TLS — wiremock serves plain HTTP — so after any change here,
smoke-test a real HTTPS handshake by hand:
1 | |
Any HTTP status coming back (a 403 from GitHub is fine) proves the handshake succeeded; a crypto-provider or TLS misconfiguration fails before that with a connection error instead.
Building and testing
1 2 3 | |
The crate is split into a library (everything except the Cli/main() entry point in
main.rs) and a thin binary, so tests/*.rs can exercise the actual logic directly instead
of only being able to shell out to the compiled binary. Networked code (pagination, http,
order, wait, web, retry/timeout) is tested against an in-process HTTP mock
(wiremock) rather than a live Waldur instance -- see tests/*.rs
for examples. config.rs tests use serial_test plus a
per-test tempfile::TempDir/XDG_CONFIG_HOME override, since env vars and the config file
path are process-global state that would otherwise race across parallel tests.
Live end-to-end tests
The tests in this repo are all mock-backed and never touch a real Waldur instance. Live E2E
coverage -- shelling out to the actual compiled waldur-cli binary and verifying results
against a live API with direct requests calls -- lives in a separate repo,
waldur-integration-testing
(tests/test_cli.py, cli pytest marker). It covers a full create/get/update/delete lifecycle
against team management (customer, then a project under it -- the CLI's one area with full
CRUD) plus a read-only smoke check of the OpenStack command surface and the auth/JSON pipeline
end-to-end. It runs against a published release binary (the INSTALL_CLI=true Docker build
arg pins a specific github.com/waldur/waldur-cli release tag -- bump it there after cutting a
release meant to be covered), not a local build from source, so it also exercises the actual
release artifact rather than only this repo's own cargo test.
GitLab CI (.gitlab-ci.yml) runs all three of the commands above on every merge request and
on main.
Regenerating the command surface locally
From a checkout of waldur-cli-generator, sitting as a sibling directory to this repo:
1 | |
This overwrites src/commands/, src/cli.rs, and src/schema.rs in place, and deletes +
rewrites docs/reference/ wholesale. Review the diff, then cargo build --locked && cargo
test --locked && cargo clippy --all-targets here as usual before committing -- regeneration
itself doesn't run those checks.
In CI, this happens automatically: waldur-cli-generator's pipeline fetches the latest schema
from waldur-mastermind, regenerates, and pushes the result to this repo's main directly
(a "chore: regenerate CLI command surface from OpenAPI schema" commit) whenever the schema
changes -- see that repo's .gitlab-ci.yml (Generate CLI job).
Cutting a release
Three files change together (chore: release vX.Y.Z):
Cargo.toml-- bumpversionCargo.lock-- run a plaincargo build(not--locked) to pick up the new versionsrc/schema.rs-- its embedded"version"field. Easiest way: regenerate viawaldur-cli-generatoragain (it reads the version straight out of this repo'sCargo.toml) -- the diff should be exactly that one field if nothing else changed.
Then tag and push both the commit and the tag to both remotes:
1 2 3 | |
Why two remotes
origin(code.opennodecloud.com) -- the internal GitLab, used for MR checks and as the push target forwaldur-cli-generator's automated regeneration commits.github(github.com/waldur/waldur-cli) -- public, and the only remote that matters for releases: pushing avX.Y.Ztag there triggers.github/workflows/release.yml(cargo-dist-generated), which builds binaries for every target indist-workspace.tomland publishes them as a GitHub Release.originhas no equivalent release pipeline -- pushing the tag there alone does nothing.
dist-workspace.toml pins ci = "github" and hosting = "github"; migrating release
distribution to GitLab isn't a small config flip, it's picking a different release tool
entirely, so this dual-remote setup is intentional rather than a migration in progress.
waldur-cli update
The update command (src/main.rs's run_update) uses the
self_update crate against GitHub Releases. One non-obvious
config: self_update's bin_name() alone assumes the binary sits at the archive root, but
cargo-dist's tarballs nest it under a waldur-cli-{target}/ directory -- so
bin_path_in_archive("waldur-cli-{{ target }}/{{ bin }}") is required, or extraction fails
with Could not find the required path in the archive on every platform, silently (the
command only fails when a user actually runs update, not at build or test time). If you
touch run_update, verify against a real published release tarball rather than trusting a
mock -- that's what caught this bug in the first place.