Files
local-ci-cd-system/docs/WEB-UI.md
T
Simone 60cee5418e 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.
2026-07-23 18:36:15 +02:00

219 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.