This commit is contained in:
parent
26d00abb80
commit
453c1241f7
14 changed files with 472 additions and 36 deletions
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
2
.env
Normal 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
|
||||||
54
.forgejo/workflows/build.yml
Normal file
54
.forgejo/workflows/build.yml
Normal 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
32
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
44
.sqlx/query-19ecb5253ac44df6b974947efb48914080ad65aef67fbd4b7c5a354a972a9be8.json
generated
Normal file
44
.sqlx/query-19ecb5253ac44df6b974947efb48914080ad65aef67fbd4b7c5a354a972a9be8.json
generated
Normal 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"
|
||||||
|
}
|
||||||
46
.sqlx/query-5dd485ffb73dba5cc76cfb6eead8c40731f6d441ca25bd8f1688c9f1eece7547.json
generated
Normal file
46
.sqlx/query-5dd485ffb73dba5cc76cfb6eead8c40731f6d441ca25bd8f1688c9f1eece7547.json
generated
Normal 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
73
Dockerfile
Normal 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"]
|
||||||
99
README.md
99
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
10
build.rs
10
build.rs
|
|
@ -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
|
||||||
|
// (`--build-arg GIT_HASH=...`), so the running image stays traceable.
|
||||||
|
let git_hash = std::env::var("GIT_HASH")
|
||||||
|
.ok()
|
||||||
|
.filter(|hash| !hash.is_empty() && hash != "unknown")
|
||||||
|
.or_else(|| {
|
||||||
|
Command::new("git")
|
||||||
.args(["rev-parse", "HEAD"])
|
.args(["rev-parse", "HEAD"])
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|output| output.status.success())
|
.filter(|output| output.status.success())
|
||||||
.and_then(|output| String::from_utf8(output.stdout).ok())
|
.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("-", "_")
|
||||||
|
|
|
||||||
13
db/migrations/20260729180000_add_vms.sql
Normal file
13
db/migrations/20260729180000_add_vms.sql
Normal 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');
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
15
db/seed.sql
15
db/seed.sql
|
|
@ -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
72
docker-compose.yml
Normal 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
23
docker/config.yml
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue