Skip to content

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's Args structs, Command enum, and run() dispatch
  • src/cli.rs -- the top-level openstack/team/marketplace/auth group wiring
  • src/schema.rs -- the CLI's command surface as JSON, embedded for waldur-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. Unlike src/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
cargo run -- --api-url https://api.github.com --token dummy team customer list

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
cargo build --locked
cargo test --locked
cargo clippy --all-targets

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
cargo run -- waldur-openapi-schema.yaml ../waldur-cli

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):

  1. Cargo.toml -- bump version
  2. Cargo.lock -- run a plain cargo build (not --locked) to pick up the new version
  3. src/schema.rs -- its embedded "version" field. Easiest way: regenerate via waldur-cli-generator again (it reads the version straight out of this repo's Cargo.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
git tag vX.Y.Z
git push origin main && git push origin vX.Y.Z
git push github main && git push github vX.Y.Z

Why two remotes

  • origin (code.opennodecloud.com) -- the internal GitLab, used for MR checks and as the push target for waldur-cli-generator's automated regeneration commits.
  • github (github.com/waldur/waldur-cli) -- public, and the only remote that matters for releases: pushing a vX.Y.Z tag there triggers .github/workflows/release.yml (cargo-dist-generated), which builds binaries for every target in dist-workspace.toml and publishes them as a GitHub Release. origin has 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.