No description
Find a file
Antoine Pelletier fb47979787
All checks were successful
build / image (push) Successful in 1m15s
fix: image uid
2026-07-30 00:14:55 +02:00
.forgejo/workflows Ajoute l'image, le stack et la CI 2026-07-29 20:36:18 +02:00
.sqlx Ajoute l'image, le stack et la CI 2026-07-29 20:36:18 +02:00
db Ajoute l'image, le stack et la CI 2026-07-29 20:36:18 +02:00
dev-db vm-selector: deployement 2026-07-29 20:32:47 +02:00
docker Ajoute l'image, le stack et la CI 2026-07-29 20:36:18 +02:00
frontend fix: change lockfile for npm10 2026-07-29 21:11:52 +02:00
src vm-selector: deployement 2026-07-29 20:32:47 +02:00
.dockerignore Ajoute l'image, le stack et la CI 2026-07-29 20:36:18 +02:00
.env Ajoute l'image, le stack et la CI 2026-07-29 20:36:18 +02:00
.gitignore Ajoute l'image, le stack et la CI 2026-07-29 20:36:18 +02:00
build.rs Ajoute l'image, le stack et la CI 2026-07-29 20:36:18 +02:00
Cargo.lock vm-selector: deployement 2026-07-29 20:32:47 +02:00
Cargo.toml vm-selector: deployement 2026-07-29 20:32:47 +02:00
config.example.yml vm-selector: deployement 2026-07-29 20:32:47 +02:00
docker-compose.yml fix: image uid 2026-07-30 00:14:55 +02:00
Dockerfile fix: image uid 2026-07-30 00:14:55 +02:00
README.md fix: image uid 2026-07-30 00:14:55 +02:00
rename.sh vm-selector: deployement 2026-07-29 20:32:47 +02:00

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.

# 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:

cargo install sqlx-cli
cargo sqlx prepare      # writes .sqlx/, commit it

Migrations

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

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:

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:

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:

# in the registry stack
environment:
  REGISTRY_AUTH: htpasswd
  REGISTRY_AUTH_HTPASSWD_REALM: registry
  REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
volumes:
  - ./auth:/auth
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.