Metadata-Version: 2.3
Name: cloudalone
Version: 0.1.0
Summary: Add your description here
Author: Stefane Fermigier
Author-email: Stefane Fermigier <sf@abilian.com>
Requires-Dist: advanced-alchemy>=1.11.0
Requires-Dist: aiosqlite>=0.20.0
Requires-Dist: asyncpg>=0.31.0
Requires-Dist: hcloud>=2.22.0
Requires-Dist: libvirt-python>=12.4.0
Requires-Dist: litestar[standard]>=2.24.0
Requires-Dist: pydantic>=2.13.4
Requires-Dist: pyyaml>=6.0
Requires-Dist: sqlalchemy>=2.0.51
Requires-Dist: uvicorn>=0.49.0
Requires-Dist: libvirt-python>=10.0.0 ; extra == 'libvirt'
Requires-Python: >=3.12
Provides-Extra: libvirt
Description-Content-Type: text/markdown

# cloudalone

A single-machine "cloud in a box" that speaks the [Hetzner Cloud (hcloud) API](https://docs.hetzner.cloud/). 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](#limitations--roadmap). Treat it as a working prototype.

## How it works

**Stack.** [Litestar](https://litestar.dev/) (ASGI) + SQLAlchemy async via [Advanced Alchemy](https://docs.advanced-alchemy.litestar.dev/) 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](#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`](https://docs.astral.sh/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)

```bash
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`:

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

```bash
uv run python scripts/conformance.py     # prints "CONFORMANCE OK" on success
```

## The CLI

`cloudalone` is both the server entrypoint and a small management CLI:

```bash
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](https://pyinfra.com/) 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:

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

```bash
./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](https://caddyserver.com/) 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:

```bash
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.
