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.
This commit is contained in:
2026-07-23 18:36:15 +02:00
parent 43925e6746
commit 60cee5418e
14 changed files with 953 additions and 128 deletions
+218
View File
@@ -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 '<repo>[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://<LAN-IP>: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 <same-as-before>`
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 <name>
--user <label>` (interactive prompt, `hide_input` + confirmation; never
a CLI argument, never logged).
---
## 7. Troubleshooting
**Login loop (bounces back to `/login` after authenticating with Gitea)**
: The registered Redirect URI in Gitea does not exactly match
`[web.oidc].redirect_url` in `config.toml` — check scheme (`http` vs
`https`), host/IP, port, and the `/auth/callback` path character-for-character.
**403 after a successful Gitea login**
: Your Gitea username is not in `[web].allowed_users` (or the list is
empty — the allowlist is fail-closed by design). Add it and restart the
service.
**"keyring key '...' not found -- run `ci creds set ...`" in the journal /
on `/login`**
: The session secret or the OIDC client secret has not been stored yet
(or was stored for a different Unix user than `ci-runner`, or under a
different `_cred` name than the one in `config.toml`). Re-run:
```bash
sudo -u ci-runner /opt/ci/venv/bin/python -m ci_orchestrator creds set \
--target WebSessionSecret --user web-session
```
substituting the exact key name printed in the error. See §6 for why this
must run as `ci-runner`, not as your own login.
**Dashboard is blank / cards show "unavailable"**
: The VM backend (`vmrun`) or `shutil.disk_usage`/`systemctl` could not be
reached from the host — the dashboard degrades a card to an "unavailable"
notice instead of a 500. Check `vmware-host.service` is up
(`systemctl is-active vmware-host.service`) and that `/healthz` still
returns `200`.
**`ci web serve` refuses to start with "refusing to bind ... without
authentication"**
: You passed a non-loopback `--host` (or `[web].bind_host` in
`config.toml`) while `[web.oidc]` is unset. This is the guard described
in §5 — configure `[web.oidc]` (see §3) or bind `127.0.0.1`.
**Confirm/Cleanup/Purge form returns 403**
: The CSRF token expired (the session cookie was cleared, e.g. by
`/logout`, or the browser tab was left open across a service restart
that rotated the session secret in open mode). Reload the page and retry
— the confirm form always mints a fresh token.