Metadata-Version: 2.4
Name: cloudalone
Version: 0.2.0
Summary: A single-machine mini-cloud that emulates the Hetzner Cloud (hcloud) API.
Author: Stefane Fermigier
Author-email: Stefane Fermigier <sf@abilian.com>
License-Expression: Apache-2.0
License-File: LICENSE
Requires-Dist: advanced-alchemy>=1.11.0
Requires-Dist: aiosqlite>=0.20.0
Requires-Dist: alembic>=1.16.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

cloudalone is a small cloud you run on a single Linux box. It speaks the [Hetzner Cloud API](https://docs.hetzner.cloud/), so the official `hcloud` CLI and the Hetzner client libraries work against it unchanged: point them at your own machine instead of `api.hetzner.cloud`, and `hcloud server create` boots a real VM.

Inside, it's a small REST API backed by SQLite, a fixed catalog of server types and images, and libvirt/QEMU for the actual VMs. Every VM gets its own public IPv6 address. It's a single process on one host: no clustering or message broker.

## Status

It's a working prototype.

The core loop works on a real host: `hcloud server create` boots a Debian VM, cloud-init brings up its IPv6, and `list`, `describe`, `delete`, and the power actions behave the way the CLI expects. Responses are checked against the real `hcloud` Python client, and the request and polling behaviour against the Go CLI. See [Limitations](#limitations) for what's missing.

## How it works

cloudalone is a [Litestar](https://litestar.dev/) app on SQLite (SQLAlchemy async via [Advanced Alchemy](https://docs.advanced-alchemy.litestar.dev/)), served by uvicorn. VMs run on libvirt and QEMU/KVM and are configured with cloud-init.

**The Actions model.** The Hetzner API is asynchronous: a call that changes something returns an *action*, and the client polls that action until it succeeds or fails. cloudalone works the same way. A create request writes the server and action rows, returns `201` straight away, and runs the blocking libvirt work in the background; the CLI polls `GET /v1/actions` just as it would against Hetzner.

**IPv6.** Every VM gets a real, public IPv6 address. cloudalone takes one IPv6 prefix that is *routed* to the host, gives the `br-cloud` bridge the first address, turns on forwarding, and hands each VM its own slice of the prefix through cloud-init. That's ordinary L3 routing, no NDP proxying. The only provider-specific part is obtaining the routed prefix. On a Scaleway Dedibox you order a `/48` and a DHCPv6-PD client keeps a `/56` routed to the box (the deploy sets this up); with a provider that routes you a `/64` directly, or your own static route, you just point `ipv6_prefix` at it. Guests are IPv6-only.

**Backends.** The default `stub` backend does no real virtualization, so you can run and test the whole thing (API, actions, CLI) on a Mac. The `libvirt` backend is the real one and needs a Linux host with KVM. `libvirt-python` is only installed on that host, so Mac development doesn't pull it in.

## Requirements

- To develop: Python 3.12+ and [uv](https://docs.astral.sh/uv/), on macOS or Linux. The stub backend means no VMs are involved.
- To run VMs: a Linux host with KVM, libvirt, QEMU, and `cloud-image-utils`, plus an IPv6 prefix routed to it. The deploy installs the packages.

## Quickstart

Run the API against the stub backend and poke it with curl:

```bash
uv sync
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 drive the real `hcloud` client through a full create → poll → delete against the stub:

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

## The CLI

`cloudalone` is both the server and a small admin tool:

```bash
cloudalone init                  # set up /home/cloudalone and write a config with a fresh token
cloudalone migrate               # create or upgrade the database schema
cloudalone token                 # rotate the bootstrap token in config.toml
cloudalone token create ci       # create a named API token (printed once)
cloudalone token list            # list named tokens
cloudalone token revoke ci       # revoke a named token
cloudalone image list            # list catalog images and which are downloaded
cloudalone image pull debian-13  # download a base image (or `all`)
cloudalone dns                   # print the VM name -> IPv6 mapping (hosts/dnsmasq format)
cloudalone serve                 # run the API
```

The catalog is fixed: server types `small`, `medium`, `large`, and images `debian-13` and `ubuntu-24.04`.

## Dashboard

A read-only web dashboard lives at `/dashboard`: servers, per-server action history, ssh-keys, and the catalog. Log in with a valid API token; it's server-rendered HTML with no build step.

## Deploying to a real host

The deploy is a single [pyinfra](https://pyinfra.com/) run that turns a bare Linux box into a running service. It installs the virtualization stack, creates the `cloudalone` user, sets up the `br-cloud` bridge and IPv6 forwarding (and, on Dedibox, the DHCPv6-PD client that keeps the prefix routed), syncs the code, writes the config and token, pulls the Debian image, and installs the systemd unit. Re-running it upgrades in place; it's idempotent.

1. Edit `deploy/inventory.py`: your host, the routed `ipv6_prefix`, and optionally `api_host` (for TLS) and `dhcpv6_duid` (for Dedibox).
2. Run it:

   ```bash
   uv run pyinfra deploy/inventory.py deploy/deploy.py
   ```

Before pointing it at a real box, you can run the whole deploy against a throwaway container:

```bash
uv run pytest tests/c_e2e/test_deploy_docker.py    # deploys into a systemd container and checks the API serves
```

### TLS

Set `api_host` in the inventory and the deploy also installs [Caddy](https://caddyserver.com/) as a TLS reverse proxy in front of cloudalone (`https://<api_host>/v1`, automatic Let's Encrypt), binding the app to localhost behind it. Point an `A` record at the box and open ports 80 and 443 *before* deploying, so Caddy can complete the certificate challenge.

## Using the hcloud CLI

Point the official CLI at your box and use it normally:

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

You need `-6` on the `ssh` line (or `AddressFamily inet6` in `~/.ssh/config`), because the address is a bare IPv6 literal.

## Configuration

Settings are read from, in order of precedence: an environment variable, the TOML config file, then a built-in default. The config path is `CLOUDALONE_CONFIG` (default `/home/cloudalone/config.toml`), and `cloudalone init` writes one for you.

| 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; the deploy sets the real routed prefix) |
| Base directory | `CLOUDALONE_HOME` | `/home/cloudalone` |
| SQLite path | `CLOUDALONE_DB` | `cloudalone.sqlite` |
| Host bridge | `CLOUDALONE_BRIDGE` | `br-cloud` |
| DNS zone for VM names | `CLOUDALONE_DNS_ZONE` | `vms.local` |
| libvirt URI | `CLOUDALONE_LIBVIRT_URI` | `qemu:///system` |
| Bind host / port | `CLOUDALONE_HOST` / `CLOUDALONE_PORT` | `::1` / `8000` |

See `config.toml.example` for the full file.

## Testing

Tests are split into three tiers; `uv run pytest` runs the fast ones.

- `tests/a_unit/`: fast and isolated (renderers, config, pure logic). Runs anywhere.
- `tests/b_integration/`: the real libvirt backend. Each test skips unless what it needs is present (`libvirtd`, `qemu-img`, `cloud-localds`, `/dev/kvm`), so on a Mac or in CI they skip rather than fail.
- `tests/c_e2e/`: the API through Litestar's test client on the stub backend, plus two Docker tests: one runs the suite inside a Linux container, the other runs the full deploy.

`make test`, `make lint`, and `make test-cov` are the usual entry points. 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 each)
  schemas.py         hcloud wire-format builders (the external contract)
  catalog.py         server types, images, locations, datacenters
  models.py          SQLAlchemy models (Server, Action, SSHKey, Token)
  worker.py          background action runner (the Actions model)
  backend.py         VM backend Protocol + stub
  libvirt_backend.py real backend: QEMU/KVM + cloud-init (Linux-only)
  net.py             IPv6 allocation from the routed prefix
  dns.py             VM DNS naming (<name>.<zone>)
  dashboard.py       server-rendered HTML for the read-only dashboard
  config.py          runtime config (env > TOML > default)
  cli.py             the cloudalone CLI
migrations/          alembic schema migrations
deploy/              pyinfra deploy + the Docker deploy test
scripts/             conformance check, IPv6 probe, host setup
```

## Limitations

cloudalone implements the core of the hcloud API and stops there. The main gaps:

- **No networks, volumes, floating IPs, load balancers, or firewalls.** Guests are IPv6-only.
- **Flat tokens, no projects.** Multiple named tokens work, but there's no project scoping of servers or keys.
- **DNS needs a resolver.** Each server has a name (`<name>.<zone>`) and `cloudalone dns` emits the mapping, but you still have to point a resolver at it (dnsmasq or `/etc/hosts`) and delegate the zone before `ssh <name>` resolves. If the box already runs a resolver on port 53 (systemd-resolved, a stock BIND), pin dnsmasq's bind first; see the [deploy guide](docs/src/guide/deploy.md).

On a Dedibox specifically, you have to order the `/48` block and enable "Activate IPv6 SLAAC" in the console before the routed prefix works.

## License

Apache 2.0. © Abilian SAS.
