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

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>/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 or 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.1.tar.gz (28.4 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.1-py3-none-any.whl (35.6 kB view details)

Uploaded Python 3

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

Hashes for cloudalone-0.1.1.tar.gz
Algorithm Hash digest
SHA256 00b725fdff9bf153ba6ab04ade5ae6e967e141969dc90c78d1a73cca26a829b1
MD5 9248f9bed39b3f671dce88ce4b6810f4
BLAKE2b-256 e31472a1d75decb12129cb2e45d3aa4f169538a8870d1502326a853dfc91cfad

See more details on using hashes here.

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

Hashes for cloudalone-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 be3bbe430cb6caac1ba02a7ad5402d9ea8e04799be3e8e09e6bad37e8b012cfb
MD5 c463488544426bd495c2090453503687
BLAKE2b-256 84221970a128ccab808935df693f5b26c3f6e870a08862d3ead8f3fff09eb51a

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