vm-selector/README.md
Antoine Pelletier 453c1241f7
Some checks failed
build / image (push) Failing after 16s
Ajoute l'image, le stack et la CI
2026-07-29 20:36:18 +02:00

242 lines
9.6 KiB
Markdown

# VM selector
A page to start and stop the VMs of the server, one at a time. **Rust** backend
(axum + sqlx + aide) driving **libvirt**, serving a **Vue 3** frontend (TypeScript +
vue-query + shadcn-vue), on **PostgreSQL** with **dbmate** migrations.
The two VMs (`win11` and `arch-hyprland`) share the same hardware and may never run
together: starting one while the other is up asks first, shuts that one down, waits
for it to be really off, and only then boots the wanted one. The state is read from
libvirt on every request, so a VM shut down from inside the guest shows up as
stopped on the page on its own.
There is **no authentication**: do not expose this outside the local network as is.
## Development
The page is developed on a workstation, not on the server: libvirt's connection uri
is the only thing that changes between the two.
```bash
# 1. Start the development database
cd dev-db && docker compose up -d && cd ..
psql -h localhost -U postgres -c 'CREATE DATABASE app_template'
dbmate up # creates the schema and inserts the two VMs
# 2. Configure the app
cp config.example.yml config.yml # then set vm.uri, see below
# 3. Run the backend (port 3000)
cargo run
# 4. Run the frontend (port 5000, proxies /api to the backend)
cd frontend && npm install && npm run dev
```
Open http://localhost:5000. The api documentation is on http://localhost:3000/api/docs.
`config.yml` is gitignored: it is where the secrets go. `config.example.yml` documents
every key, keep it up to date. Every value can also come from the environment
(`APP__VM__URI=...`), which is how the app is configured in production.
### Talking to the hypervisor
`vm.driver` picks the implementation:
| driver | `vm.uri` | when |
|---|---|---|
| `mock` | ignored | develop the page without any hypervisor: nothing is really started, and a shutdown takes 6 s so the handover sequence can be exercised |
| `libvirt` | `qemu+ssh://user@192.168.0.104:2452/system` | from a workstation, against the real server |
| `libvirt` | `qemu:///system` | in production, inside the container, with the socket of the host bind-mounted |
The `qemu+ssh://` transport uses the ssh key of the user running the backend, so a key
accepted by the server is all it takes to drive the real VMs from a laptop.
The driver shells out to `virsh` rather than linking libvirt's C bindings: no native
build dependency, and the uri alone decides local or remote. It needs `virsh` on the
`PATH` (`libvirt-clients` on Debian, `libvirt` on Arch).
## Layout
```
├── src/ backend
│ ├── api/ http layer: routes, extractors, OpenAPI
│ ├── core/ business logic, independent of axum, sqlx and libvirt
│ │ ├── controller/ what the app can do (the one-VM-at-a-time rule lives here)
│ │ ├── models/ domain types
│ │ └── repositories/ traits describing what the core needs from the outside
│ ├── services/
│ │ ├── database/ postgres implementation (sqlx)
│ │ └── hypervisor/ libvirt (virsh) and mock implementations
│ └── utils/ configuration
├── db/
│ ├── migrations/ dbmate migrations (the two VMs are inserted by one)
│ └── schema.sql dump regenerated by dbmate, do not edit by hand
├── dev-db/ postgres + adminer for development
└── frontend/
└── src/
├── components/ shared components (ui/ = shadcn-vue)
├── views/ one component per route
├── router/ route table
├── services/ api/ (one file per domain area) + i18n
├── lib/api.d.ts generated from the backend, never edited by hand
├── utils/types.ts shorthands over the generated schemas
└── locales/ fr.yml / en.yml
```
## Backend
Requests flow through three layers, each one only knowing the next:
```
api (axum handler) -> core/controller -> core/repositories -> services/
status codes business logic traits sqlx, virsh
domain models
```
The point of the repository traits is that the core never depends on sqlx or on
libvirt: swapping the real hypervisor for the mock is a one-line config change, and
neither the controller nor the handlers know about it. Handlers stay thin — extract
the controller, call it, map the error to a status code — and their OpenAPI
documentation sits right next to them (`fn *_docs`).
### The `vms` table
It only holds what libvirt does not know: how to present a domain in the UI
(`display_name`, `icon`, `position`). The live state is never stored. Adding a third
VM is one insert, no redeploy — but the one-at-a-time rule then applies to all three.
### Routes
| route | answers |
|---|---|
| `GET /api/vms` | every VM with its live state |
| `POST /api/vms/{id}/start` | 204, 409 with the name of the VM still holding the hardware, or 503 if libvirt does not know the domain |
| `POST /api/vms/{id}/stop?force=true` | 204; without `force` the guest is asked politely (ACPI) and stays `stopping` until it is really off |
### sqlx and compilation
`query!`/`query_as!` check the sql against a **real database at compile time**, so the
development database must be up and migrated for `cargo build` to work. `DATABASE_URL`
is read from `.env`.
To build without a database (CI, docker image), commit the offline data:
```bash
cargo install sqlx-cli
cargo sqlx prepare # writes .sqlx/, commit it
```
### Migrations
```bash
dbmate n add_something # creates db/migrations/<timestamp>_add_something.sql
dbmate up # applies, and regenerates db/schema.sql
dbmate rollback # undoes the last migration (write your `migrate:down`!)
```
## Frontend
```bash
npm run dev # dev server on :5000, /api proxied to the backend on :3000
npm run build # type-check + build into dist/
npm run type-check
npm run lint
npm run format
npm run openapi # regenerate src/lib/api.d.ts from the running backend
```
Rules of thumb:
- views never call `fetch`: they use a hook from `services/api/`, which returns a
vue-query query or mutation. Caching, loading and error states come for free.
- the VM list is polled (2 s) rather than cached: the hypervisor changes without us.
- texts live in `locales/*.yml` and are used through `$t('key')`, never hardcoded.
- add a shadcn-vue component with `npx shadcn-vue@latest add <name>`.
## Deployment
One image holds everything: the binary, the built frontend, the migrations and a
`dbmate` to apply them. The backend serves the static files and falls back on
`index.html`, so the vue router survives a page reload — a single container to run.
```
push on main
-> Forgejo Actions (.forgejo/workflows/build.yml)
backend + frontend checks
docker build --build-arg GIT_HASH=<sha>
push registry.tibiscuit.ch/vm-selector:<sha> and :latest
POST the Portainer webhook
-> Portainer re-pulls and recreates the stack (docker-compose.yml)
db -> migrate (runs dbmate up, exits) -> app
```
### The image builds without a database
`cargo sqlx prepare` writes `.sqlx/`, and it is **committed**. The macros check the
sql against that data instead of a live server (`SQLX_OFFLINE=true`). Change a query
and you must re-run it, or CI fails:
```bash
cargo sqlx prepare # dev database must be up and migrated
```
### Portainer stack
Deploy `docker-compose.yml` and set the four stack variables it documents at the top
(`IMAGE`, `POSTGRES_PASSWORD`, `LIBVIRT_GID`, `APP_PORT`). Get the group id from the
host:
```bash
getent group libvirt | cut -d: -f3
```
Then create a webhook on the stack, **tick "Re-pull image"**, and put its url in the
`PORTAINER_WEBHOOK` secret of the repository. Without the re-pull, the webhook
recreates the containers from the layer already on disk and nothing changes.
The two things that make libvirt reachable from inside the container:
- `/var/run/libvirt` bind-mounted from the host — the whole directory, since the
socket is `libvirt-sock` or `virtqemud-sock` depending on the host's setup
- the container in the host's `libvirt` group (`group_add`) — without it the socket
is visible and every call is denied
### Domain name
The reverse proxy already in front of the registry handles it: add a proxy host for
`vm.tibiscuit.ch` pointing at `192.168.0.104:${APP_PORT}`, and request a
Let's Encrypt certificate. One manual step, once.
Making it *automatic* for every future service means letting the proxy read the
container labels, which is what Traefik does and Nginx Proxy Manager does not — a
worthwhile change, but a migration of the whole proxy, not something this stack can
do on its own.
### Before exposing any of this
**The registry has no authentication.** `registry.tibiscuit.ch` is public, and
`REGISTRY_STORAGE_DELETE_ENABLED` is on: anyone who finds it can pull every image,
overwrite `vm-selector:latest`, or delete it — and Portainer would then deploy
whatever they pushed. Put an htpasswd in front of it before the domain is indexed:
```yaml
# in the registry stack
environment:
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: registry
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
volumes:
- ./auth:/auth
```
```bash
docker run --rm --entrypoint htpasswd httpd:2 -Bbn <user> <password> > auth/htpasswd
```
Then set `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` as repository secrets: the login
step in the workflow turns itself on as soon as they exist.
**And the app itself has no authentication** — anyone who reaches `vm.tibiscuit.ch`
can power your VMs on and off. Keep it off the public internet, or put access control
in front of it.