From 453c1241f7b52be72048619230315fb63a7fe76f Mon Sep 17 00:00:00 2001 From: Antoine Pelletier Date: Wed, 29 Jul 2026 20:36:18 +0200 Subject: [PATCH] Ajoute l'image, le stack et la CI --- .dockerignore | 14 +++ .env | 2 + .forgejo/workflows/build.yml | 54 ++++++++++ .gitignore | 32 +++++- ...8914080ad65aef67fbd4b7c5a354a972a9be8.json | 44 +++++++++ ...8c40731f6d441ca25bd8f1688c9f1eece7547.json | 46 +++++++++ Dockerfile | 73 ++++++++++++++ README.md | 99 ++++++++++++++++--- build.rs | 18 +++- db/migrations/20260729180000_add_vms.sql | 13 +++ db/schema.sql | 3 +- db/seed.sql | 15 --- docker-compose.yml | 72 ++++++++++++++ docker/config.yml | 23 +++++ 14 files changed, 472 insertions(+), 36 deletions(-) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .forgejo/workflows/build.yml create mode 100644 .sqlx/query-19ecb5253ac44df6b974947efb48914080ad65aef67fbd4b7c5a354a972a9be8.json create mode 100644 .sqlx/query-5dd485ffb73dba5cc76cfb6eead8c40731f6d441ca25bd8f1688c9f1eece7547.json create mode 100644 Dockerfile create mode 100644 db/migrations/20260729180000_add_vms.sql delete mode 100644 db/seed.sql create mode 100644 docker-compose.yml create mode 100644 docker/config.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f58c8d0 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env b/.env new file mode 100644 index 0000000..f9a79d5 --- /dev/null +++ b/.env @@ -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 diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..c8ae060 --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -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" diff --git a/.gitignore b/.gitignore index 2fd919e..7695d2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,31 @@ +# Build output /target -config.yml -config.yaml +/frontend/dist + +# 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 diff --git a/.sqlx/query-19ecb5253ac44df6b974947efb48914080ad65aef67fbd4b7c5a354a972a9be8.json b/.sqlx/query-19ecb5253ac44df6b974947efb48914080ad65aef67fbd4b7c5a354a972a9be8.json new file mode 100644 index 0000000..a725f5f --- /dev/null +++ b/.sqlx/query-19ecb5253ac44df6b974947efb48914080ad65aef67fbd4b7c5a354a972a9be8.json @@ -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" +} diff --git a/.sqlx/query-5dd485ffb73dba5cc76cfb6eead8c40731f6d441ca25bd8f1688c9f1eece7547.json b/.sqlx/query-5dd485ffb73dba5cc76cfb6eead8c40731f6d441ca25bd8f1688c9f1eece7547.json new file mode 100644 index 0000000..19ebd6b --- /dev/null +++ b/.sqlx/query-5dd485ffb73dba5cc76cfb6eead8c40731f6d441ca25bd8f1688c9f1eece7547.json @@ -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" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a4d0733 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 93616f6..2164241 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,7 @@ 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 -psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql +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 @@ -71,9 +70,8 @@ build dependency, and the uri alone decides local or remote. It needs `virsh` on │ │ └── 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 +│ ├── 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/ @@ -156,14 +154,89 @@ Rules of thumb: - texts live in `locales/*.yml` and are used through `$t('key')`, never hardcoded. - add a shadcn-vue component with `npx shadcn-vue@latest add `. -## Production +## Deployment -`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. +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. -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= + push registry.tibiscuit.ch/vm-selector: 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 container user in the host's `libvirt` group (`group_add: [""]` in the - compose file) — without it the socket is visible but every call is denied +### 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 > 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. diff --git a/build.rs b/build.rs index 7db8f38..a297534 100644 --- a/build.rs +++ b/build.rs @@ -2,16 +2,24 @@ use std::process::Command; /// Exposes the current commit and the crate name to the code through `env!()` fn main() { - let git_hash = Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() + // The docker build has no git repository: CI passes the commit in instead + // (`--build-arg GIT_HASH=...`), so the running image stays traceable. + let git_hash = std::env::var("GIT_HASH") .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) + .filter(|hash| !hash.is_empty() && hash != "unknown") + .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::()) .unwrap_or_else(|| "unknown".to_owned()); println!("cargo:rustc-env=GIT_HASH={git_hash}"); + println!("cargo:rerun-if-env-changed=GIT_HASH"); println!( "cargo:rustc-env=CRATE_NAME={}", env!("CARGO_PKG_NAME").replace("-", "_") diff --git a/db/migrations/20260729180000_add_vms.sql b/db/migrations/20260729180000_add_vms.sql new file mode 100644 index 0000000..b6b6790 --- /dev/null +++ b/db/migrations/20260729180000_add_vms.sql @@ -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'); diff --git a/db/schema.sql b/db/schema.sql index 505185a..aaaed52 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -104,4 +104,5 @@ ALTER TABLE ONLY public.vms -- INSERT INTO public.schema_migrations (version) VALUES - ('20260729120000'); + ('20260729120000'), + ('20260729180000'); diff --git a/db/seed.sql b/db/seed.sql deleted file mode 100644 index 589c387..0000000 --- a/db/seed.sql +++ /dev/null @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4e729ab --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/config.yml b/docker/config.yml new file mode 100644 index 0000000..3520a2b --- /dev/null +++ b/docker/config.yml @@ -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