Skip to main content

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 just 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 — 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 — the deploy runs the client; a routed /64 or 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.

  1. Edit deploy/inventory.py — set your host, the routed ipv6_prefix, and (optionally) api_host for TLS plus dhcpv6_duid for Dedibox PD. Per-host settings (uplink, ipv6_prefix, dhcpv6_duid, bridge, backend) are read from inventory host.data; the bridge gateway is derived as <prefix>::1.

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

./deploy/test-in-docker.sh       # builds a systemd container, runs the deploy, checks the API → PASS

TLS endpoint

When api_host is set in the inventory, the deploy also installs Caddy as a TLS reverse proxy (https://<api_host>/v1127.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 (needs libvirtd), overlay creation (needs qemu-img), cloud-init seed (needs cloud-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 through TestClient (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_create and 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 / a small DNS integration would smooth that over.
  • Dedibox prerequisite: the routed prefix needs the /48 block 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

cloudalone-0.1.0.tar.gz (24.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cloudalone-0.1.0-py3-none-any.whl (31.3 kB view details)

Uploaded Python 3

File details

Details for the file cloudalone-0.1.0.tar.gz.

File metadata

  • Download URL: cloudalone-0.1.0.tar.gz
  • Upload date:
  • Size: 24.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","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

Hashes for cloudalone-0.1.0.tar.gz
Algorithm Hash digest
SHA256 7bdd9bfc07c8456031d9cbf58185c747b1e772ea336b4db8a85788ec98eea5f0
MD5 c47e395aae68a8a09b06a1a8c360a99b
BLAKE2b-256 a189e853aa6395b9d875f9e69e2099ebe4d659a02191b93760d05c7a710bc46f

See more details on using hashes here.

File details

Details for the file cloudalone-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: cloudalone-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 31.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","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

Hashes for cloudalone-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4fa06743e6e283182bc4d8e30a6fc039b3464221bfaaec23178cc5806bc41b3e
MD5 93dc14184151492bcefcdb81c2c2eb6e
BLAKE2b-256 fe443045cab89c3d7407651ee28562d557985f851eda4c53c9ce2f3a74b09aec

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page