Ajoute l'image, le stack et la CI
Some checks failed
build / image (push) Failing after 16s

This commit is contained in:
Antoine Pelletier 2026-07-29 20:36:18 +02:00
parent 26d00abb80
commit 453c1241f7
14 changed files with 472 additions and 36 deletions

14
.dockerignore Normal file
View file

@ -0,0 +1,14 @@
# Everything the image does not need. Keeping the context small matters: the
# build sends it to the daemon before doing anything.
.git
.github
.forgejo
target
frontend/node_modules
frontend/dist
dev-db
/config.yml
/config.yaml
*.log
README.md
rename.sh

2
.env Normal file
View file

@ -0,0 +1,2 @@
# This file is used by dbmate, and by the sqlx macros at compile time.
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app_template?sslmode=disable

View file

@ -0,0 +1,54 @@
# One job on purpose. The checks (eslint, vue-tsc, cargo test) live in the
# Dockerfile stages, so this workflow needs a single job image — one that has
# docker, git and node. Splitting the checks into their own jobs would mean
# `container: rust:...`, and a JavaScript action like `actions/checkout` needs
# node inside the job container, which the rust image does not have.
#
# Deliberately no third-party actions beyond `checkout`: a self-hosted Forgejo
# runner resolves `uses:` against its own mirror, and `docker/*` is usually not
# there. Plain docker commands work everywhere.
name: build
on:
push:
branches: [main]
workflow_dispatch:
env:
IMAGE: registry.tibiscuit.ch/vm-selector
jobs:
image:
runs-on: ubuntu-latest
env:
# Secrets are read into the environment here, not interpolated into the
# shell commands below: `${{ secrets.X }}` inside a `run:` ends up in the
# process arguments, and the `secrets` context is not usable in `if:`.
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
steps:
- uses: actions/checkout@v4
# Turns itself on as soon as the registry has authentication and the two
# secrets exist; skipped while the registry is open.
- name: Log in to the registry
if: env.REGISTRY_USERNAME != ''
run: echo "$REGISTRY_PASSWORD" | docker login registry.tibiscuit.ch -u "$REGISTRY_USERNAME" --password-stdin
- name: Build and push
run: |
set -eu
docker build \
--build-arg GIT_HASH="${{ github.sha }}" \
--tag "$IMAGE:${{ github.sha }}" \
--tag "$IMAGE:latest" \
.
docker push "$IMAGE:${{ github.sha }}"
docker push "$IMAGE:latest"
# Portainer re-pulls the image and recreates the stack. Enable
# "Re-pull image" on the webhook, otherwise :latest stays the old layer.
- name: Redeploy on Portainer
if: env.PORTAINER_WEBHOOK != ''
run: curl -fsS -X POST "$PORTAINER_WEBHOOK"

32
.gitignore vendored
View file

@ -1,3 +1,31 @@
# Build output
/target /target
config.yml /frontend/dist
config.yaml
# Local configuration: this is where the secrets go, config.example.yml documents it.
# Anchored to the root on purpose: an unanchored `config.yml` would also match
# docker/config.yml, which is baked into the image and must stay committed.
/config.yml
/config.yaml
# Development database volume (dev-db/docker-compose.yml)
/dev-db/db/
# Dependencies
node_modules/
# Logs
*.log
# Editor and OS
.idea/
.vscode/*
!.vscode/extensions.json
.DS_Store
*.swp
*~
# NOT ignored, and must stay committed:
# .sqlx/ sqlx offline data, so the image builds without a database
# frontend/package-lock.json reproducible frontend builds in CI
# db/schema.sql regenerated by dbmate, reviewed like any other change

View file

@ -0,0 +1,44 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, \"domain\", display_name, icon, \"position\"\n FROM vms\n ORDER BY \"position\", id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "domain",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "display_name",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "icon",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "position",
"type_info": "Int4"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "19ecb5253ac44df6b974947efb48914080ad65aef67fbd4b7c5a354a972a9be8"
}

View file

@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, \"domain\", display_name, icon, \"position\"\n FROM vms\n WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "domain",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "display_name",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "icon",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "position",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "5dd485ffb73dba5cc76cfb6eead8c40731f6d441ca25bd8f1688c9f1eece7547"
}

73
Dockerfile Normal file
View file

@ -0,0 +1,73 @@
# Frontend: built first, it is the part that changes most often
FROM node:22-slim AS frontend
WORKDIR /build
# Dependencies in their own layer, so a source-only change does not reinstall them
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
# The checks run here rather than in a separate CI job: the build then fails on
# the runner for the same reason it would fail locally, and there is only one
# environment to keep working.
RUN npx eslint . --max-warnings=0
RUN npm run build # type-check + vite build
# Backend: compiled against the sqlx offline data, so no database is needed here
FROM rust:1.97-slim AS backend
WORKDIR /build
ENV SQLX_OFFLINE=true
# Traceability: `build.rs` falls back on this when there is no git repository
ARG GIT_HASH=unknown
ENV GIT_HASH=${GIT_HASH}
# Warm the dependency layer with a dummy main, so editing src/ does not rebuild
# every crate we depend on
COPY Cargo.toml Cargo.lock build.rs ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs \
&& cargo build --release \
&& rm -rf src
COPY .sqlx/ .sqlx/
COPY src/ src/
# cargo skips a rebuild when only mtime changed: force it for our own crate
RUN touch src/main.rs \
&& cargo build --release --locked \
&& cargo test --release --locked
# Runtime
FROM debian:trixie-slim
LABEL org.opencontainers.image.title="vm-selector"
# virsh is how the app talks to libvirt. openssh-client is only needed if you
# point vm.uri at a qemu+ssh:// uri instead of the mounted socket; curl serves
# the healthcheck.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvirt-clients openssh-client curl \
&& rm -rf /var/lib/apt/lists/*
# The migrations travel with the image, so the stack needs nothing from the
# repository: the `migrate` service runs this same image with another entrypoint.
COPY --from=ghcr.io/amacneil/dbmate:2 /usr/local/bin/dbmate /usr/local/bin/dbmate
COPY db/migrations/ /app/db/migrations/
# Unprivileged, but it must land in the host's libvirt group to reach the
# socket: `group_add` in the compose file does that at run time.
RUN useradd --system --create-home --uid 10001 app
WORKDIR /app
COPY --from=backend /build/target/release/app-template /usr/local/bin/vm-selector
COPY --from=frontend /build/dist/ /app/frontend/
COPY docker/config.yml /etc/app-template/config.yml
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://localhost:3000/api/version || exit 1
# No shell: signals reach the binary directly, so the graceful shutdown works
ENTRYPOINT ["/usr/local/bin/vm-selector"]

View file

@ -21,8 +21,7 @@ is the only thing that changes between the two.
# 1. Start the development database # 1. Start the development database
cd dev-db && docker compose up -d && cd .. cd dev-db && docker compose up -d && cd ..
psql -h localhost -U postgres -c 'CREATE DATABASE app_template' psql -h localhost -U postgres -c 'CREATE DATABASE app_template'
dbmate up dbmate up # creates the schema and inserts the two VMs
psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql
# 2. Configure the app # 2. Configure the app
cp config.example.yml config.yml # then set vm.uri, see below cp config.example.yml config.yml # then set vm.uri, see below
@ -71,9 +70,8 @@ build dependency, and the uri alone decides local or remote. It needs `virsh` on
│ │ └── hypervisor/ libvirt (virsh) and mock implementations │ │ └── hypervisor/ libvirt (virsh) and mock implementations
│ └── utils/ configuration │ └── utils/ configuration
├── db/ ├── db/
│ ├── migrations/ dbmate migrations │ ├── migrations/ dbmate migrations (the two VMs are inserted by one)
│ ├── schema.sql dump regenerated by dbmate, do not edit by hand │ └── schema.sql dump regenerated by dbmate, do not edit by hand
│ └── seed.sql the two VMs
├── dev-db/ postgres + adminer for development ├── dev-db/ postgres + adminer for development
└── frontend/ └── frontend/
└── src/ └── src/
@ -156,14 +154,89 @@ Rules of thumb:
- texts live in `locales/*.yml` and are used through `$t('key')`, never hardcoded. - 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>`. - add a shadcn-vue component with `npx shadcn-vue@latest add <name>`.
## Production ## Deployment
`cargo build --release`, `npm run build`, then point `frontend_dir` at the built One image holds everything: the binary, the built frontend, the migrations and a
`frontend/dist`: the backend serves the static files and falls back on `index.html` so `dbmate` to apply them. The backend serves the static files and falls back on
the vue router keeps working on a page reload. Only one process to deploy. `index.html`, so the vue router survives a page reload — a single container to run.
In the container, two things are needed on top of the binary: ```
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
```
- `virsh` installed, and `/var/run/libvirt/libvirt-sock` bind-mounted from the host ### The image builds without a database
- 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 `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.

View file

@ -2,16 +2,24 @@ use std::process::Command;
/// Exposes the current commit and the crate name to the code through `env!()` /// Exposes the current commit and the crate name to the code through `env!()`
fn main() { fn main() {
let git_hash = Command::new("git") // The docker build has no git repository: CI passes the commit in instead
.args(["rev-parse", "HEAD"]) // (`--build-arg GIT_HASH=...`), so the running image stays traceable.
.output() let git_hash = std::env::var("GIT_HASH")
.ok() .ok()
.filter(|output| output.status.success()) .filter(|hash| !hash.is_empty() && hash != "unknown")
.and_then(|output| String::from_utf8(output.stdout).ok()) .or_else(|| {
Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
})
.map(|hash| hash.trim().chars().take(8).collect::<String>()) .map(|hash| hash.trim().chars().take(8).collect::<String>())
.unwrap_or_else(|| "unknown".to_owned()); .unwrap_or_else(|| "unknown".to_owned());
println!("cargo:rustc-env=GIT_HASH={git_hash}"); println!("cargo:rustc-env=GIT_HASH={git_hash}");
println!("cargo:rerun-if-env-changed=GIT_HASH");
println!( println!(
"cargo:rustc-env=CRATE_NAME={}", "cargo:rustc-env=CRATE_NAME={}",
env!("CARGO_PKG_NAME").replace("-", "_") env!("CARGO_PKG_NAME").replace("-", "_")

View file

@ -0,0 +1,13 @@
-- migrate:up
-- The VMs are configuration, not demo data: without these rows the page has
-- nothing to show. They belong to the schema so that a fresh deployment is
-- usable straight after `dbmate up`, with no manual seeding step.
--
-- `domain` must match the libvirt domain name exactly (`virsh list --all`).
INSERT INTO vms ("domain", "display_name", "icon", "position") VALUES
('win11', 'Windows 11', 'windows', 1),
('arch-hyprland', 'Arch Linux (Hyprland)', 'linux', 2)
ON CONFLICT ("domain") DO NOTHING;
-- migrate:down
DELETE FROM vms WHERE "domain" IN ('win11', 'arch-hyprland');

View file

@ -104,4 +104,5 @@ ALTER TABLE ONLY public.vms
-- --
INSERT INTO public.schema_migrations (version) VALUES INSERT INTO public.schema_migrations (version) VALUES
('20260729120000'); ('20260729120000'),
('20260729180000');

View file

@ -1,15 +0,0 @@
-- Development data. Apply with:
-- psql "$DATABASE_URL" -f db/seed.sql
--
-- `domain` must match the libvirt domain name exactly (`virsh list --all`).
BEGIN;
INSERT INTO public.vms (id, "domain", "display_name", "icon", "position") VALUES
(1, 'win11', 'Windows 11', 'windows', 1),
(2, 'arch-hyprland', 'Arch Linux (Hyprland)', 'linux', 2)
ON CONFLICT ("domain") DO NOTHING;
-- Keep the sequence in sync with the explicit ids inserted above
SELECT setval('public.vms_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.vms));
COMMIT;

72
docker-compose.yml Normal file
View file

@ -0,0 +1,72 @@
# Stack for Portainer. Everything it needs lives in the image, so it can be
# deployed by pasting this file — no checkout of the repository on the server.
#
# Environment variables to set in the Portainer stack:
# IMAGE registry.tibiscuit.ch/vm-selector:latest
# POSTGRES_PASSWORD anything, it never leaves the internal network
# LIBVIRT_GID the host's libvirt group id, from:
# getent group libvirt | cut -d: -f3
# APP_PORT host port for the reverse proxy to point at (default 3010)
services:
db:
image: postgres:18.3-alpine
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in the stack}
POSTGRES_DB: vmselector
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d vmselector']
interval: 5s
timeout: 5s
retries: 10
networks: [internal]
# Runs once per deployment, before the app: applies any new migration, then exits
migrate:
image: ${IMAGE:?set IMAGE in the stack}
restart: 'no'
entrypoint:
['dbmate', '--migrations-dir', '/app/db/migrations', '--no-dump-schema', 'up']
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/vmselector?sslmode=disable
depends_on:
db:
condition: service_healthy
networks: [internal]
app:
image: ${IMAGE:?set IMAGE in the stack}
restart: unless-stopped
ports:
- '${APP_PORT:-3010}:3000'
environment:
APP__POSTGRES__HOST: db
APP__POSTGRES__PASSWORD: ${POSTGRES_PASSWORD}
APP__POSTGRES__NAME: vmselector
APP__VM__DRIVER: libvirt
# The socket bind-mounted below, not ssh: the app runs on the same host
APP__VM__URI: qemu:///system
volumes:
# The whole directory, not a single socket: depending on whether the host
# runs the monolithic libvirtd or the split daemons, the app needs
# libvirt-sock or virtqemud-sock.
- /var/run/libvirt:/var/run/libvirt
# Reaching that socket requires being in the host's libvirt group. Without
# this the socket is visible and every call is denied.
group_add:
- '${LIBVIRT_GID:?see the header of this file}'
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
networks: [internal]
volumes:
db_data:
networks:
internal:

23
docker/config.yml Normal file
View file

@ -0,0 +1,23 @@
# Baked into the image at /etc/app-template/config.yml: the defaults for a
# containerised run. Every value is overridden by the environment
# (APP__POSTGRES__PASSWORD=..., APP__VM__URI=...), which is how the compose file
# passes the secrets — never edit this file to put one in.
server:
address: 0.0.0.0
port: 3000
postgres:
host: db
port: 5432
user: postgres
password: postgres
name: vmselector
vm:
driver: libvirt
# The host's libvirt socket, bind-mounted by the compose file
uri: qemu:///system
timeout_seconds: 20
# Where the Dockerfile puts the built frontend
frontend_dir: /app/frontend