cloudalone
A single-machine "cloud in a box" that speaks the Hetzner Cloud (hcloud) API. It exposes an hcloud-compatible REST surface (servers, actions, ssh-keys, a fixed catalog) so the official hcloud CLI and client libraries can drive it unchanged: you point them at your endpoint instead of api.hetzner.cloud. Under the hood it boots lightweight VMs with libvirt/QEMU on one Linux host, and gives each VM its own public IPv6 address from a prefix routed to the host.
It is deliberately minimal: a small REST API, SQLite for state, a fixed catalog of server types and images, and a thin libvirt backend. No control plane, no clustering, no message broker.
Status
The happy path works end to end: hcloud server create provisions a VM, it boots from a real cloud image, cloud-init brings up its static IPv6, and list/describe/delete/power actions behave as the CLI expects. The wire format is checked against the real hcloud Python client, and the REST surface has been audited against the Go CLI's actual request/response/action-polling behaviour.
It is not production-hardened yet; see Limitations & roadmap. Treat it as a working prototype.
How it works
Stack. Litestar (ASGI) + SQLAlchemy async via Advanced Alchemy on SQLite (aiosqlite), served by uvicorn. VMs run on libvirt + QEMU/KVM, seeded with cloud-init. The hcloud Python client is a dependency only because it's the most convenient oracle for the wire format (used by the conformance test); the server itself doesn't use it.
Actions model. hcloud is asynchronous: every mutating call returns an action that the client polls until it reaches success/error. cloudalone mirrors this: a request writes the server + action rows and returns 201 immediately, then a background task runs the (blocking) libvirt work off the event loop and flips the action's status. The CLI polls GET /v1/actions?id=… exactly as it would against Hetzner.
IPv6 model. Each VM gets its own public IPv6 address from a prefix routed to the host. cloudalone's core is provider-agnostic: it takes one routed prefix (CLOUDALONE_IPV6_SLICE), gives the br-cloud bridge its ::1, enables IPv6 forwarding, and assigns each VM a static address via cloud-init, a /96 sub-prefix whose ::1 the hcloud CLI renders as the VM's real, unique address. It's plain L3 routing; no NDP proxying. The only per-environment part is acquiring the routed prefix: on a Scaleway Dedibox you order a /48 in the console and a DHCPv6-PD client sends the console-issued DUID to keep a /56 routed to the box (the deploy sets this up); where a provider routes you a /64 directly (e.g. Hetzner) or you add a static route yourself, just point ipv6_prefix at it. A provider's on-link SLAAC /64 is usually not routable for extra addresses (Dedibox's upstream drops it), which is why a routed prefix is required. Guests are IPv6-only (public_net.ipv4 is null, which the hcloud client tolerates). The control API itself is reached over the host's IPv4/IPv6 (see TLS endpoint).
Backends. CLOUDALONE_BACKEND=stub (default) does no real virtualization. It's used for development and tests on any platform, including macOS. CLOUDALONE_BACKEND=libvirt is the real backend and only runs on a Linux host with libvirt/KVM. The libvirt bits live behind the optional libvirt extra so macOS dev never needs libvirt-python.
Requirements
- Development: Python ≥ 3.12 and
uv. Works on macOS or Linux with the stub backend. - Production host: a Linux box with KVM, libvirt, QEMU, and
cloud-image-utils(cloud-localds), plus an IPv6 prefix routed to it (DHCPv6-PD on a Scaleway Dedibox, where the deploy runs the client; a routed/64or a static route elsewhere). The pyinfra deploy installs the packages.
Quickstart (development)
uv sync # install dependencies
make test # run the fast test suite (stub backend, no VMs)
make lint # ruff (format + check) + ty
Run the API locally against the stub backend and talk to it with curl:
CLOUDALONE_BACKEND=stub CLOUDALONE_TOKEN=dev-token uv run cloudalone serve &
curl -s -H "Authorization: Bearer dev-token" http://localhost:8000/v1/server_types | python3 -m json.tool
To exercise the real hcloud client against the stub server end to end (create → poll → list → power → delete):
uv run python scripts/conformance.py # prints "CONFORMANCE OK" on success
The CLI
cloudalone is both the server entrypoint and a small management CLI:
cloudalone init # create /home/cloudalone dirs + config.toml with a fresh token
cloudalone token # rotate the bearer token
cloudalone image list # show catalog images and whether they're downloaded
cloudalone image pull debian-13 # download a base cloud image (or `all`)
cloudalone serve # run the API server
The fixed catalog ships server types small / medium / large and images debian-13 and ubuntu-24.04.
Deploying to a real host
Deployment is a single pyinfra script that takes a bare Linux box to a running service: it installs the virtualization stack, creates the cloudalone service user, sets up the br-cloud bridge + IPv6 forwarding (with accept_ra=2 so the host keeps its RA-learned default route) and, on Dedibox, a DHCPv6-PD client that keeps the routed prefix active, syncs the source, runs uv sync --extra libvirt, generates the config + token, pulls the debian-13 base image, and installs the systemd unit. It's idempotent.
-
Edit
deploy/inventory.py: set your host, the routedipv6_prefix, and (optionally)api_hostfor TLS plusdhcpv6_duidfor Dedibox PD. Per-host settings (uplink,ipv6_prefix,dhcpv6_duid,bridge,backend) are read from inventoryhost.data; the bridge gateway is derived as<prefix>::1. -
Deploy:
uv run pyinfra deploy/inventory.py deploy/deploy.py
Before trusting it on a real box, smoke-test the whole deploy against a throwaway systemd container (no KVM, but it exercises everything else):
uv run pytest tests/c_e2e/test_deploy_docker.py # deploy into a throwaway systemd container, assert the API serves
TLS endpoint
When api_host is set in the inventory, the deploy also installs Caddy as a TLS reverse proxy (https://<api_host>/v1 → 127.0.0.1:8000, auto Let's Encrypt) and binds cloudalone to localhost behind it. Add a DNS A record for <api_host> pointing at the box's public IPv4 (and open ports 80/443) before deploying, so Caddy can complete the ACME challenge.
Using it with the hcloud CLI
Point the official CLI at your endpoint:
export HCLOUD_TOKEN=$(ssh root@<host> grep token /home/cloudalone/config.toml | cut -d'"' -f2)
export HCLOUD_ENDPOINT=https://api.example.com/v1 # or http://[<host-ipv6>]:8000/v1
hcloud ssh-key create --name me --public-key-from-file ~/.ssh/id_ed25519.pub
hcloud server create --name t1 --type small --image debian-13 --ssh-key me
hcloud server list
ssh -6 root@$(hcloud server ip -6 t1) # your key is installed for root
hcloud server delete t1
NB: A bare IPv6 literal may need ssh -6 <addr> (or AddressFamily inet6 in ~/.ssh/config).
Configuration
Settings resolve TOML file < environment variable < default (env wins). The TOML path is CLOUDALONE_CONFIG (default /home/cloudalone/config.toml); cloudalone init writes one. Key settings (env var / TOML key):
| Setting | Env var | Default |
|---|---|---|
| Bearer token | CLOUDALONE_TOKEN |
dev-token |
| Backend | CLOUDALONE_BACKEND |
stub |
| Routed IPv6 prefix for VMs | CLOUDALONE_IPV6_SLICE |
2001:bc8:1200:19:c10d::/80 (dev placeholder; deploy sets the real routed prefix) |
| Base directory | CLOUDALONE_HOME |
/home/cloudalone |
| SQLite path | CLOUDALONE_DB |
cloudalone.sqlite |
| Host bridge | CLOUDALONE_BRIDGE |
br-cloud |
| libvirt URI | CLOUDALONE_LIBVIRT_URI |
qemu:///system |
| Bind host / port | CLOUDALONE_HOST / CLOUDALONE_PORT |
:: / 8000 |
See config.toml.example for the full file.
Testing
The suite follows a pyramid (uv run pytest runs the fast tiers; markers select tiers):
tests/a_unit/: fast, isolated (renderers, CLI/config, pure logic). Runs everywhere.tests/b_integration/: the real libvirt backend, layered by capability and skip-gated: domain-XML validity (needslibvirtd), overlay creation (needsqemu-img), cloud-init seed (needscloud-localds), and a full debian-13 boot (needs/dev/kvm+ image + bridge, i.e. the box). On macOS/CI without KVM these skip cleanly.tests/c_e2e/: the API driven throughTestClient(stub backend), plus a Docker test that runs the suite inside a Linux container.
Useful targets: make test, make lint, make test-cov. CI runs on GitHub Actions and SourceHut.
Project layout
src/cloudalone/
app.py Litestar app, bearer-auth guard, hcloud error envelope
controllers/ hcloud-compatible REST controllers (one module per controller)
schemas.py hcloud wire-format builders (the external contract)
catalog.py static server types / images / locations / datacenters
models.py SQLAlchemy models (Server, Action, SSHKey; integer IDs)
worker.py background action runner (the async Actions model)
backend.py VM backend Protocol + stub
libvirt_backend.py real backend: QEMU/KVM + cloud-init (Linux-only, optional extra)
net.py IPv6 allocation from the routed prefix
config.py TOML < env < default
cli.py the `cloudalone` CLI
deploy/ pyinfra deploy + Docker-based deploy test
scripts/ conformance check, IPv6 probe, host setup
Limitations & roadmap
Done since the initial audit: reboot durability (VM autostart + boot-time reconciliation), input safety (RFC1123 name validation, yaml.safe_dump cloud-init metadata, key validation), safe defaults (serve refuses the default token, constant-time token compare, idempotent power ops), and the deploy's accept_ra=2 + systemd-networkd bridge persistence.
Still open:
- Conformance polish: pagination (
page/per_page) is a stub;start_after_createand per-server action locks aren't implemented. - State/scale: schema changes need a fresh DB (no alembic migrations yet); single bearer token, no projects.
- Ergonomics: VMs have no DNS names; you reach them by raw IPv6 literal. AAAA records or a small DNS integration would smooth that over.
- Dedibox prerequisite: the routed prefix needs the
/48block ordered and "Activate IPv6 SLAAC" enabled in the console before DHCPv6-PD will answer.
Scope is intentionally narrow: a minimal subset of the hcloud API (server CRUD + actions, ssh-keys, a fixed catalog), IPv6-only guests, single host, single token.
License
© Abilian SAS. Apache 2 licensed.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cloudalone-0.1.1.tar.gz.
File metadata
- Download URL: cloudalone-0.1.1.tar.gz
- Upload date:
- Size: 28.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.30 {"installer":{"name":"uv","version":"0.11.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
00b725fdff9bf153ba6ab04ade5ae6e967e141969dc90c78d1a73cca26a829b1
|
|
| MD5 |
9248f9bed39b3f671dce88ce4b6810f4
|
|
| BLAKE2b-256 |
e31472a1d75decb12129cb2e45d3aa4f169538a8870d1502326a853dfc91cfad
|
File details
Details for the file cloudalone-0.1.1-py3-none-any.whl.
File metadata
- Download URL: cloudalone-0.1.1-py3-none-any.whl
- Upload date:
- Size: 35.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.30 {"installer":{"name":"uv","version":"0.11.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
be3bbe430cb6caac1ba02a7ad5402d9ea8e04799be3e8e09e6bad37e8b012cfb
|
|
| MD5 |
c463488544426bd495c2090453503687
|
|
| BLAKE2b-256 |
84221970a128ccab808935df693f5b26c3f6e870a08862d3ead8f3fff09eb51a
|