169 lines
7 KiB
Markdown
169 lines
7 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
|
|
psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql
|
|
|
|
# 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
|
|
│ ├── schema.sql dump regenerated by dbmate, do not edit by hand
|
|
│ └── seed.sql the two VMs
|
|
├── 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>`.
|
|
|
|
## Production
|
|
|
|
`cargo build --release`, `npm run build`, then point `frontend_dir` at the built
|
|
`frontend/dist`: the backend serves the static files and falls back on `index.html` so
|
|
the vue router keeps working on a page reload. Only one process to deploy.
|
|
|
|
In the container, two things are needed on top of the binary:
|
|
|
|
- `virsh` installed, and `/var/run/libvirt/libvirt-sock` bind-mounted from the host
|
|
- the container user in the host's `libvirt` group (`group_add: ["<gid>"]` in the
|
|
compose file) — without it the socket is visible but every call is denied
|