253 lines
10 KiB
Markdown
253 lines
10 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>`.
|
|
|
|
### After touching the dependencies
|
|
|
|
The image builds with the npm of `node:22-slim` (npm 10). npm 12 writes lockfiles
|
|
that omit `"optional": true` on the platform-specific packages (`@esbuild/*`,
|
|
`@rollup/rollup-*`); npm 10 then reads them as required and `npm ci` dies with
|
|
`EBADPLATFORM` on a package meant for another architecture — in the image only,
|
|
never locally. So regenerate the lockfile with the older resolver:
|
|
|
|
```bash
|
|
npx npm@10 install --package-lock-only
|
|
npx npm@10 ci # reproduces exactly what the image does
|
|
```
|
|
|
|
## 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 stack variables it documents at the top
|
|
(`IMAGE`, `POSTGRES_PASSWORD`, `APP_PORT`).
|
|
|
|
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.
|
|
|
|
What makes libvirt reachable from inside the container: `/var/run/libvirt` is
|
|
bind-mounted from the host — the whole directory, since the socket is `libvirt-sock`
|
|
or `virtqemud-sock` depending on the host's setup — and the container runs as root.
|
|
|
|
Running as root is not laziness about the group: with a custom uid, libvirt resolves
|
|
the caller to a user record before opening the socket and refused to do so here even
|
|
with `/etc/passwd` correct and the `libvirt` group added through `group_add`
|
|
(`Failed to find user record for uid '10001'`). What root actually adds is small —
|
|
anything that reaches this socket already controls every VM on the host.
|
|
|
|
### 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.
|