From 60cee5418e15074ff84f85d4b35821107cc81d21 Mon Sep 17 00:00:00 2001 From: Simone Date: Thu, 23 Jul 2026 18:36:15 +0200 Subject: [PATCH] feat(web): dark Gitea UI, systemd installer, WEB-UI docs (W7) Restyles every template with a hand-written dark Gitea-style theme (web/static/app.css, ~270 lines: badges, buttons, cards, focus rings, prefers-reduced-motion) and completes the htmx polling wiring for all four dashboard cards. Adds the repeatable setup-web-linux.sh installer (idempotent: pip install '[web]' non-editable, render ci-web.service, template [web]/[web.oidc] into config.toml, prompt for keyring secrets, print Gitea OIDC registration steps, enable/restart the unit) plus deploy/systemd/ci-web.service and docs/WEB-UI.md. --- deploy/systemd/ci-web.service | 38 +++ docs/WEB-UI.md | 218 +++++++++++++++ setup-web-linux.sh | 263 ++++++++++++++++++ src/ci_orchestrator/web/static/app.css | 263 +++++++++++++++++- src/ci_orchestrator/web/static/htmx.min.js | 1 + src/ci_orchestrator/web/templates/base.html | 15 +- .../web/templates/dashboard.html | 10 +- .../web/templates/partials/audit.html | 58 ++-- .../web/templates/partials/cache.html | 10 +- .../web/templates/partials/confirm.html | 4 +- .../web/templates/partials/host.html | 103 ++++--- .../web/templates/partials/lock_conflict.html | 4 +- .../web/templates/partials/result.html | 11 +- .../web/templates/partials/vms.html | 83 +++--- 14 files changed, 953 insertions(+), 128 deletions(-) create mode 100644 deploy/systemd/ci-web.service create mode 100644 docs/WEB-UI.md create mode 100755 setup-web-linux.sh create mode 100644 src/ci_orchestrator/web/static/htmx.min.js diff --git a/deploy/systemd/ci-web.service b/deploy/systemd/ci-web.service new file mode 100644 index 0000000..f9676d7 --- /dev/null +++ b/deploy/systemd/ci-web.service @@ -0,0 +1,38 @@ +[Unit] +Description=Local CI/CD — web dashboard (ci web serve) +Documentation=file:///opt/ci/local-ci-cd-system/docs/WEB-UI.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=ci-runner +Group=ci-runner +WorkingDirectory=/opt/ci/local-ci-cd-system +# CI_CONFIG (and any other CI_* override) lives here, one KEY=VALUE per +# line, no quoting. The "-" prefix means the unit still starts if the file +# is absent (config.toml is then auto-discovered at /var/lib/ci/config.toml +# — see config.py's resolution order). Rendered/edited by setup-web-linux.sh +# — never put secrets in this file, only the config path. +EnvironmentFile=-/etc/ci/web.env +# The bind address below is filled in by setup-web-linux.sh at install time +# (a LAN IP, or left as 127.0.0.1 for loopback-only) — never edit this file +# by hand on the target host, re-run the installer instead. +ExecStart=/opt/ci/venv/bin/python -m ci_orchestrator web serve --host __CI_WEB_BIND_HOST__ --port 8800 +Restart=on-failure +RestartSec=10 +TimeoutStartSec=30s + +# Hardening. ProtectHome=read-only (NOT "yes"/true): the OIDC/session +# secrets are read from ci-runner's headless keyring backend +# (keyrings.alt.file.PlaintextKeyring, ~/.local/share/python_keyring/ — +# see docs/WEB-UI.md's keyring-headless note); "yes" would hide /home +# entirely and every login would fail with a keyring lookup error. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=read-only +PrivateTmp=true +ReadWritePaths=/var/lib/ci + +[Install] +WantedBy=multi-user.target diff --git a/docs/WEB-UI.md b/docs/WEB-UI.md new file mode 100644 index 0000000..f419687 --- /dev/null +++ b/docs/WEB-UI.md @@ -0,0 +1,218 @@ +# Web UI — Management Dashboard + +Server-rendered dashboard (`ci web serve`) for the local CI/CD system: live +VMs, cargo cache, disk, and act_runner status, plus confirm-gated destructive +actions (VM remove/cleanup, cache purge, template backup) with an audit +trail. In-process only — it imports `operations/` and `backends.load_backend` +directly and never shells out to the CLI. + +See `plans/web-ui-management-plan.md` (steps W0–W7) for the design history +and `config.example.toml`'s `[web]` / `[web.oidc]` section for every +config key referenced below. + +--- + +## 1. Prerequisites + +- The Linux host already bootstrapped by `setup-host-linux.sh` (`ci-runner` + service user, `/opt/ci/venv`, `/var/lib/ci/config.toml`). +- A reachable Gitea instance to authenticate against (OIDC/OAuth2) — only + required if you intend to bind the dashboard on the LAN; a loopback-only + install can skip Gitea entirely (see §5, "open mode"). +- Outbound network from the host to Gitea's `/.well-known/openid-configuration` + endpoint, if OIDC is enabled. + +Nothing here needs internet access — `ci_orchestrator[web]`'s dependencies +(FastAPI, Jinja2, Authlib, uvicorn, ...) install from the same package index +already used for the core install, and every frontend asset +(`web/static/app.css`, `web/static/htmx.min.js`) is vendored in the repo, no +CDN. + +--- + +## 2. Running the installer + +```bash +cd /opt/ci/local-ci-cd-system +sudo bash setup-web-linux.sh --host 127.0.0.1 # loopback-only, no OIDC needed +# or, once OIDC is registered (see §3): +sudo bash setup-web-linux.sh --host 192.168.1.50 # LAN +``` + +`setup-web-linux.sh` is idempotent — every mutating step checks its own +state first and skips (with a message) when already satisfied, so re-running +it (e.g. after `git pull` + a code change) is always safe: + +1. `pip install '[web]'` into `/opt/ci/venv`, **non-editable** — per + `CLAUDE.md`, the production venv can't resolve editable installs when + `act_runner`/systemd launches Python from a transient working directory. +2. Renders `deploy/systemd/ci-web.service` into `/etc/systemd/system/`, + substituting the bind IP you passed via `--host`. +3. Appends a **commented** `[web]` / `[web.oidc]` template to + `/var/lib/ci/config.toml` if no such section exists yet — it never + overwrites an existing one, and never writes a secret value. +4. Prompts (interactively, one at a time) to store `WebSessionSecret` and + `WebOidcClientSecret` in the keyring, if they aren't already set. The + secret is only ever typed at the terminal prompt — the installer never + accepts it as a command-line argument and never echoes or logs it. +5. Prints the manual Gitea OIDC application registration steps (see §3). +6. `systemctl daemon-reload` then enables + starts (or restarts, if already + running) `ci-web.service`. + +Run `setup-web-linux.sh --help` for the full flag list (`--skip-pip`, +`--skip-secrets`, `--config-path`). + +After step 3/4, edit `/var/lib/ci/config.toml`: uncomment the appended +`[web]` / `[web.oidc]` block, fill in `issuer`, `client_id`, +`allowed_users`, then restart the service (`systemctl restart ci-web.service`). + +--- + +## 3. Gitea OAuth2 application registration + +The dashboard authenticates against Gitea via OIDC (Authlib + Gitea's +`/.well-known/openid-configuration` discovery document). Register the +application **before** enabling `[web.oidc]`: + +1. In Gitea: **Settings → Applications → "Manage OAuth2 Applications" → + Create a new OAuth2 Application**. +2. **Application Name**: anything recognizable, e.g. `ci-web`. +3. **Redirect URI**: `http://:8800/auth/callback` — this **must + match `[web.oidc].redirect_url` exactly** (scheme, host, port, and path). + A mismatch is the #1 cause of a login redirect loop (see §6). +4. Gitea shows a **Client ID** and **Client Secret** exactly once: + - Client ID → `[web.oidc].client_id` in `config.toml` (not a secret, plain text is fine). + - Client Secret → **never put it in `config.toml`**. Store it in the + keyring instead: + ```bash + sudo -u ci-runner /opt/ci/venv/bin/python -m ci_orchestrator creds set \ + --target WebOidcClientSecret --user web-oidc-client + ``` + `config.toml` only ever holds the keyring *key name* + (`client_secret_cred = "WebOidcClientSecret"`), never the value. +5. Add the Gitea username(s) allowed to log in to `[web].allowed_users`. + **The allowlist is fail-closed**: an empty list denies everyone, it + never means "allow all". + +--- + +## 4. Operating + +```bash +# Start / stop / restart +sudo systemctl start ci-web.service +sudo systemctl stop ci-web.service +sudo systemctl restart ci-web.service # after a config.toml edit or a code upgrade + +# Status +systemctl status ci-web.service + +# Logs (uvicorn access log + app stdout/stderr go to the journal) +journalctl -u ci-web.service -f +journalctl -u ci-web.service -n 100 --no-pager + +# Health check (unauthenticated, always 200) +curl -s http://127.0.0.1:8800/healthz +``` + +Once code changes: `sudo bash setup-web-linux.sh --host ` +reinstalls the `[web]` extra and restarts the service in one step (steps +2–6 no-op if nothing else changed). + +--- + +## 5. Firewall / security + +- **Bind only a specific LAN IP, never `0.0.0.0`.** Neither `ci web serve` + nor `setup-web-linux.sh` will ever write `0.0.0.0` — the installer prompts + for (or accepts via `--host`) one explicit address, defaulting to + `127.0.0.1`. +- **Open mode (no `[web.oidc]` configured) is localhost-only, enforced at + startup.** `ci web serve` refuses to start if you bind a non-loopback + address while `[web.oidc]` is absent — an unauthenticated dashboard that + can destroy VMs and purge caches must never be reachable from the LAN. + Configure `[web.oidc]` first, or bind `127.0.0.1`. +- Every mutating route (VM remove/cleanup, cache purge, template backup) is + behind a synchronizer-token CSRF check and a two-step confirm gate (a + preview/dry-run, then an explicit "Confirm" that actually executes) — + there is no one-click destroy. +- Every mutating action appends exactly one entry to the audit log + (`[web].audit_log`, default `/var/lib/ci/logs/web-audit.jsonl`), viewable + at `/audit` in the dashboard. +- If you run a host firewall (ufw/nftables/iptables), only open the chosen + port (default `8800`) to the specific LAN/VLAN that needs dashboard + access, not the whole network. + +--- + +## 6. Keyring / headless note + +`ci-web.service` runs as `ci-runner` under systemd — there is no desktop +session and no D-Bus, so the GNOME Secret Service backend (`secret-tool`) +does not work here. This host uses **`keyrings.alt.file.PlaintextKeyring`** +instead (already the credential backend used for guest/Gitea credentials — +see `plans/archived/2026-05-23/B3-closeout.md`): + +- Config: `/home/ci-runner/.local/share/python_keyring/keyringrc.cfg` + (`[backend] default-keyring=keyrings.alt.file.PlaintextKeyring`) +- Store: `/home/ci-runner/.local/share/python_keyring/keyring_pass.cfg` — + plain text, readable only by `ci-runner` (file permissions), no D-Bus + session required. Verified to work both from `sudo -u ci-runner ...` and + from inside a plain systemd service. +- This is why `ci-web.service`'s hardening uses `ProtectHome=read-only` + and **not** `ProtectHome=yes`/`true`: the latter hides `/home` entirely + from the unit's mount namespace, which would break every OIDC/session + secret lookup and make login fail even though the keyring itself is + fine. `read-only` keeps `/home/ci-runner` readable (the app never writes + to the keyring at request time — secrets are set once, out-of-band, by + `ci creds set`) while still blocking writes. +- `WebSessionSecret` / `WebOidcClientSecret` are keyring key *names* in + `config.toml` (`session_secret_cred`, `client_secret_cred`) — the actual + values live only in the keyring, set via `ci creds set --target + --user