One async trait for instance, block-storage, and network provisioning across cloud providers — Hetzner, Vultr, OVHcloud, DigitalOcean, Scaleway, and Linode today. Same call, same types, different backend.
use nimbus_cloud::{CloudProvider, CreateInstance, providers::Hetzner};
let provider = Hetzner::new(std::env::var("HCLOUD_TOKEN")?);
let regions = provider.regions().await?;
let sizes = provider.instance_types(®ions[0].id).await?;
let instance = provider
.create_instance(CreateInstance {
name: "web-1".into(),
region: regions[0].id.clone(),
instance_type: sizes[0].id.clone(),
image: "ubuntu-24.04".into(),
ssh_public_key: std::fs::read_to_string("~/.ssh/id_ed25519.pub")?,
network_id: None,
user_data: None,
})
.await?;Swap Hetzner for Vultr or Ovh and the rest of the call is unchanged —
that's the point.
- Discovery — regions, instance types (with monthly price)
- Instances — create, get, list, delete
- Storage — block volumes: create, list, attach, detach, delete
- Networks — private networks/VPCs: create, list, delete
Every call is a plain REST request over reqwest; there's no external
runtime dependency (no Terraform/Pulumi binary, no state file).
export HCLOUD_TOKEN=...
cargo run -p nimbus -- --provider hetzner regions
cargo run -p nimbus -- --provider hetzner sizes fsn1
cargo run -p nimbus -- --provider hetzner instance create web-1 fsn1 \
--type cx22 --image ubuntu-24.04 --ssh-key ~/.ssh/id_ed25519.pubProvider selection is --provider hetzner|vultr|ovh|digitalocean|scaleway|linode (or NIMBUS_PROVIDER
env var); credentials come from provider-specific env vars:
| Provider | Env vars |
|---|---|
| Hetzner | HCLOUD_TOKEN |
| Vultr | VULTR_API_KEY |
| OVH | OVH_APPLICATION_KEY, OVH_APPLICATION_SECRET, OVH_CONSUMER_KEY, OVH_PROJECT_ID |
| DigitalOcean | DIGITALOCEAN_TOKEN |
| Scaleway | SCW_SECRET_KEY, SCW_DEFAULT_PROJECT_ID, SCW_DEFAULT_ZONE (default fr-par-1) |
| Linode | LINODE_TOKEN |
lib/—nimbus-cloudcrate: theCloudProvidertrait, shared types, and the provider adapters (lib/src/providers/)cli/—nimbusbinary: thin CLI over the traitmock/—nimbus-mock: in-memory mock servers for all providers, for offline testing without real credentials or spend
There's no LocalStack for these providers, so mock/ is a small
purpose-built one: an Axum server that fakes just the endpoints the
adapters call (create/get/list/delete instances, volumes, networks;
region/size discovery), in-memory, on an ephemeral port.
# standalone, for manual poking (e.g. with curl or the CLI)
cargo run -p nimbus-mock
# -> Hetzner :8090/v1 · Vultr :8090/v2 · OVH :8090/1.0 · DO :8090/do/v2
# Linode :8090/v4 · Scaleway :8090 (full /instance/v1/... paths)
HCLOUD_TOKEN=anything cargo run -p nimbus -- \
--provider hetzner --base-url http://127.0.0.1:8090/v1 regionsIn tests, spawn it in-process and point an adapter's with_base_url at it:
let base = nimbus_mock::spawn().await;
let provider = Hetzner::new("mock-token").with_base_url(format!("{base}/v1"));lib/tests/mock_providers.rs runs the full instance → volume → network
create/attach/list/delete flow against the mock for every provider, and
lib/tests/mock_providers_unhappy.rs covers the failure paths: rejected
credentials (any missing token or the sentinel bad-token → 401 →
Error::Auth), unknown resource ids (404), create-time validation errors
(each provider's real error status and body shape, surfaced in
Error::Api), and unreachable hosts (Error::Transport).
cargo test --workspace runs everything. The mock is a stand-in for each
provider's API shape, not a faithful emulation (fixed catalogs, no
pagination/rate limits, instant state transitions) — good enough to catch
adapter bugs, not a substitute for testing against a real account before
depending on this in production.
Early. All six adapters are complete for instance/volume/network CRUD
against their documented REST APIs, with two gaps: OVH does not yet resolve
flavor pricing (monthly_price is 0.0 pending a price-catalog
integration), and Linode does not yet support attaching a VPC at instance
create time (returns an explicit error rather than silently ignoring it).
Scaleway's Instance API is zone-scoped, so a Scaleway client is bound to
one zone at construction. None of the adapters have been exercised against
live provider accounts yet — treat as unverified until that happens.
Implement CloudProvider in lib/src/providers/<name>.rs and register it
in the CLI's build_provider. Nothing provider-specific should leak past
the trait — no provider-specific enum variants or fields on the shared
Instance/Volume/Network types. See CONTRIBUTING.md.
The library crate is nimbus-cloud; the CLI binary is nimbus. The bare
nimbus name on crates.io belongs to an unrelated crate, so if/when this is
published to crates.io the CLI package will need a distinct name
(nimbus-cloud-cli or similar).
Apache-2.0 — see LICENSE.