Sorting by started_at let a manually re-run old workflow jump above a genuinely newer run: the re-run's started_at bumps to now, but its run number never changes, so the Run column stopped reading strictly descending. run_id is assigned once at creation and never changes across attempts, so (run_id, attempt) descending is both correct and simpler -- it also handles a queued run (no started_at yet) for free, no datetime.max special case needed.
469 lines
23 KiB
Markdown
469 lines
23 KiB
Markdown
# Web UI — Management Dashboard
|
||
|
||
Server-rendered dashboard (`ci web serve`) for the local CI/CD system: recent
|
||
CI executions with their live VMs (§9), 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.
|
||
|
||
---
|
||
|
||
## 0. Current deployment on this host
|
||
|
||
Live since 2026-07-23: `ci-web.service` is enabled + running, bound to the
|
||
LAN (`192.168.10.11:8800`), authenticating against `https://gitea.emulab.it`
|
||
via OIDC. Only the allowlisted Gitea user(s) in `/var/lib/ci/config.toml`'s
|
||
`[web].allowed_users` can log in. Manage it exactly as described in §4 below
|
||
(`systemctl status/restart ci-web.service`, `journalctl -u ci-web.service -f`).
|
||
`WebSessionSecret` and `WebOidcClientSecret` are already stored in the
|
||
`ci-runner` keyring — no action needed unless they're rotated (§3/§6).
|
||
|
||
---
|
||
|
||
## 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".
|
||
6. For the Executions card (§9), add an API read scope to
|
||
`[web.oidc].scopes` — `read:repository`, plus `read:admin` if the account
|
||
is a Gitea admin and you want runs from every repo on the instance. New
|
||
scopes take effect after one fresh login (Gitea shows the consent screen
|
||
again); existing sessions keep working with the old scopes until then.
|
||
|
||
---
|
||
|
||
## 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). Use the address the service is
|
||
# bound to -- uvicorn listens on `[web].bind_host` only, so on this host
|
||
# (192.168.10.11) a loopback request gets connection-refused, not a 200.
|
||
curl -s http://192.168.10.11: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,
|
||
template delete, backup delete) 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.
|
||
- **Template delete has no backup-required safety rail.** It removes the
|
||
entire template directory — the golden image VM clones are made from, not
|
||
a single backup archive — behind the same confirm gate as everything else,
|
||
but nothing stops you from deleting a template with zero backups. Use the
|
||
adjacent Backup button first if you want a way back.
|
||
- 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. Service management (Stage D)
|
||
|
||
The dashboard's **Services** panel lets an authenticated web user start, stop,
|
||
or restart a fixed allow-list of systemd units on the host — the same
|
||
confirm-gated, CSRF-protected, audited mutating-action shape as VM
|
||
remove/cleanup, cache purge, and template backup (§5), applied to `sudo
|
||
systemctl`.
|
||
|
||
**Allow-list (default, `operations/services.py::MANAGED_SERVICES`):**
|
||
|
||
| Slug | Unit | Stop allowed? |
|
||
| --- | --- | --- |
|
||
| `act-runner` | `act-runner.service` | yes |
|
||
| `act-runner-rust` | `act-runner-rust.service` | yes |
|
||
| `vmware-host` | `vmware-host.service` | yes |
|
||
| `ci-web` | `ci-web.service` | **no** — restart-only |
|
||
|
||
Override it with `[web].managed_services` in `config.toml` (see
|
||
`config.example.toml`'s commented example) — a list of `{id, unit, label,
|
||
allow_stop}` tables. A malformed row is skipped, never crashes startup.
|
||
|
||
### Security model
|
||
|
||
- **Allow-list by slug, not by unit name.** The URL is
|
||
`POST /services/{service_id}/{action}` where `service_id` is one of the
|
||
slugs above (`find_service()`), never a raw systemd unit name — there is no
|
||
code path that builds a controllable unit from unvalidated request input.
|
||
- **Exact-command sudoers, no wildcards.** `deploy/sudoers.d/ci-web` grants
|
||
`ci-runner` passwordless `sudo` for exactly nine literal command lines (one
|
||
per unit × action, plus the `ci-web` restart special case) — no bare
|
||
`systemctl`, no `*`, no `ALL` in the command position. Even if the web app
|
||
itself were compromised, `ci-runner`'s sudo grant cannot be used to run any
|
||
command beyond those nine lines.
|
||
- **Arg list, never a shell.** `operations/services.py::control_service`
|
||
builds `["sudo", "-n", "systemctl", action, unit]` (or the `systemd-run`
|
||
variant below) as a Python list handed straight to `subprocess.run` —
|
||
`shell=True` is never used anywhere in this path.
|
||
- **`ci-web` is restart-only.** `allow_stop=False` — a `stop` request against
|
||
it is refused with a 400 *before* any subprocess or audit entry, both at
|
||
the route (`web/routes.py::services_control`) and defensively again inside
|
||
`control_service` itself. Rationale: stopping the service that renders this
|
||
very panel would lock the operator out with no way to start it again from
|
||
the UI.
|
||
- **`ci-web` restart is deferred.** Restarting `ci-web.service` from inside
|
||
its own worker would kill the HTTP response before it could be sent, so
|
||
that one case runs `sudo -n systemd-run --on-active=2 systemctl restart
|
||
ci-web.service` instead of a direct `systemctl restart` — the
|
||
`systemd-run` call itself returns almost immediately (it only schedules
|
||
the restart ~2s out), so the confirming request completes normally and the
|
||
dashboard briefly drops a couple of seconds later.
|
||
- **`vmware-host` stop/restart interrupts running jobs.** The confirm step
|
||
shows the current count of active VMs ("N active VM(s) — this interrupts
|
||
running jobs") when stopping or restarting `vmware-host.service`, computed
|
||
from the same VM backend the Executions card uses — best-effort, degrades
|
||
to no warning if the backend is unavailable, never blocks the action.
|
||
- **Every action is audited exactly once per stage** (`service.start` /
|
||
`service.stop` / `service.restart`, `target` = the unit name), same as
|
||
every other mutating route — visible at `/audit`.
|
||
|
||
### Sudoers install
|
||
|
||
`setup-web-linux.sh` installs `deploy/sudoers.d/ci-web` to
|
||
`/etc/sudoers.d/ci-web` (mode `0440`, owner `root:root`) as step 6/7,
|
||
validating it with `visudo -cf` and **removing it again if validation
|
||
fails** — a broken `sudoers.d` fragment can lock out `sudo` entirely, so this
|
||
step never leaves an unvalidated file in place. Before installing on a new
|
||
host, confirm `systemctl`/`systemd-run` are actually at `/usr/bin` on that
|
||
host (`command -v systemctl systemd-run`) — edit both
|
||
`deploy/sudoers.d/ci-web` and the installed copy if not.
|
||
|
||
To install just the sudoers grant (e.g. after editing
|
||
`deploy/sudoers.d/ci-web`) without touching anything else:
|
||
|
||
```bash
|
||
sudo bash setup-web-linux.sh --skip-pip --skip-secrets
|
||
```
|
||
|
||
### Deploy note
|
||
|
||
After merging a Stage D change: re-run `pip install .` into the production
|
||
venv (per `CLAUDE.md`), re-run `setup-web-linux.sh` (installs/updates the
|
||
sudoers grant and restarts `ci-web.service`), then confirm the sudoers file
|
||
on the live host with `sudo visudo -c` before relying on the panel.
|
||
|
||
---
|
||
|
||
## 8. Dashboard layout — one card per subject, no tile row
|
||
|
||
The dashboard opens with four compact info cards, each the **only** place its
|
||
subject is reported:
|
||
|
||
| Card | Route | Refresh | Contents |
|
||
|---|---|---|---|
|
||
| **Host** | `GET /host` | 5s | `System` (hostname · kernel), `CPU` (model · cores), CPU / RAM / disk with gauges; uptime in the header bar, load average under the CPU gauge |
|
||
| **VMware modules** | `GET /vmware` | 15s | `vmmon`, `vmnet`, `vmware-host.service` |
|
||
| **Runners** | `GET /runners` | 5s | each `act-runner*` unit with its sub-state |
|
||
| **Cargo cache** | `GET /cache` | 5s | root, total size, entries, oldest entry, retention |
|
||
|
||
Then the wide tables: Executions (§9), Templates, Artifacts, Services,
|
||
Maintenance.
|
||
|
||
All four info cards render the same `<dl>` grammar at the page's plain body
|
||
size — one `<dt>`/`<dd>` pair per fact. VMware and Runners used to be a
|
||
bordered `<table>` at a smaller font (`0.9rem` body text, uppercase `0.75rem`
|
||
column headers), which read as a visibly different style sitting next to
|
||
Host and Cargo cache's plain label/value lists. They carry the same two data
|
||
points as before per row (a status badge, a sub-state) — just as `<dt>{{
|
||
unit }}</dt><dd>{{ badge }} {{ sub_state }}</dd>` instead of a table row —
|
||
so no reading was dropped, and no card in this row uses `<table>` any more.
|
||
|
||
There is **no status-header tile row**. It used to sit above these cards
|
||
showing VMs / Runners / CPU / RAM / Disk / Cache, which meant runner state was
|
||
displayed in three places at once (tile, Runners card, Services card) and the
|
||
core count, RAM total, cache size and entry count in two. The readouts that
|
||
only the tiles had — CPU%, RAM used, disk used/free — moved into the Host card,
|
||
where the rest of the host data already was. The VM count went away with the
|
||
tiles; running VMs are listed in the Executions card, and the orphan sweep is
|
||
in Maintenance.
|
||
|
||
The Host card carries the most data of the four, so it is packed to match
|
||
their height: the near-static facts pair up (`System`, `CPU`), uptime rides the
|
||
header bar (spelled out — it has no `<dt>` to label it), and the load average
|
||
is the CPU gauge's sub-line. Five `<dt>` rows total. The CPU model is trimmed
|
||
by the `cpu_short` filter (`Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz` →
|
||
`Intel Core i9-13900K`); the untrimmed name is the `title` of that row.
|
||
|
||
**Health does not depend on color.** Each gauge states its value as text
|
||
directly above the bar (`42%`, `20.0 / 62 GB`, `40% · 60 GB free`), and the
|
||
level word — `ok` / `elevated` / `critical` / `unknown` — is part of the
|
||
gauge's `aria-label`, so a screen reader announces it. There is no visible
|
||
level strip: the words repeated what the numbers already said on every row.
|
||
Thresholds behind the fill colour and the announced word: CPU and RAM `> 75%`
|
||
amber, `> 90%` red; disk `> 70%` amber, `> 85%` red; a source that could not
|
||
be read is a neutral `unknown`, never a false green — and when the disk probe
|
||
fails outright, `unknown` is printed as the row's value, since there is no
|
||
gauge left to carry it.
|
||
|
||
---
|
||
|
||
## 9. Executions card (recent Gitea Actions runs)
|
||
|
||
The dashboard's top wide table is **Executions**: the most recent workflow
|
||
runs, the live ones included, with the build VM(s) that run spawned (with
|
||
their `Remove` button). It replaced the old read-only "Live VMs" table — a VM
|
||
alone could never answer "did the last build pass?".
|
||
|
||
Every fact is its own column — Run, Repo, Workflow, Branch, Commit, Trigger,
|
||
Actor, Status, Duration, Started, VM, IP / Uptime, Actions. No cell stacks two
|
||
facts on separate lines with `<br>`; a run's repo, the commit hash, who
|
||
triggered it, and when it started each get their own column instead. The
|
||
table is wide as a result — 13 columns — and scrolls horizontally inside the
|
||
card's own `.table-wrap` rather than the page, same as the other wide tables
|
||
(Templates, Services).
|
||
|
||
Runs come from Gitea's Actions API: `/api/v1/admin/actions/runs` (every repo
|
||
on the instance, admin token) with `/api/v1/user/actions/runs` as the
|
||
non-admin fallback. Results are cached for 10s, so the card's 5s refresh costs
|
||
Gitea at most one call per 10s window; the VM columns are re-read on every
|
||
refresh.
|
||
|
||
The Status pill carries a check/✕ icon for the two outcomes that are a real
|
||
verdict (`success`/`failure`, via `run_badge_icon`); `queued`/`running`/
|
||
`cancelled`/`skipped` stay plain text since they are not a pass/fail on the
|
||
run. Every pill in the column — outcome pills and the VM-only `orphan`/
|
||
`running` pills on unmatched-VM rows — is forced to the same width
|
||
(`#executions-card td:nth-child(8)` — Status is the 8th column, see above —
|
||
sized to fit `cancelled`, the longest word) with left-aligned content, so the
|
||
column reads as a clean list instead of ragged pill widths.
|
||
|
||
Rows are ordered **newest first by run id, then attempt** — not by
|
||
`started` timestamp. A queued run (no `started` yet) sorts above everything
|
||
for free, since it already holds the highest id of anything currently
|
||
listed. Sorting by time instead would let a manually re-run old workflow (its
|
||
`started_at` bumped to now, its id unchanged) jump above a genuinely newer
|
||
run; sorting by id/attempt keeps the Run column reading strictly descending.
|
||
The row-count dropdown at
|
||
the right end of the card's header bar picks how many to list — 10 / 25 / 50 /
|
||
100, default from `[web].executions_limit` (10). The choice rides on the card's own polling URL
|
||
(`/executions?limit=N`), so it survives every 5s refresh with no client-side
|
||
state; a value set in the config outside those four is offered as an extra
|
||
option rather than dropped.
|
||
|
||
**Where the API token comes from.** Normally nowhere extra: the dashboard
|
||
reuses the access token from your own OIDC login, so what the card shows is
|
||
exactly what your Gitea account can see. That token is kept **server-side
|
||
only** (an in-process map keyed by username) and dropped on logout — it is
|
||
never written into the session cookie, which is signed but *not* encrypted and
|
||
travels over plain HTTP on the LAN. An expired token is refreshed once
|
||
automatically; a refusal that refresh cannot fix shows a "log in again" link
|
||
in the card.
|
||
|
||
Optional fallback: store a Gitea PAT in the keyring and name it in
|
||
`[web].gitea_token_cred`. It is used when no logged-in token is available —
|
||
after a service restart (which empties the in-process map while session cookies
|
||
stay valid) — **and** when Gitea refuses the login token (`401`, or `403` for
|
||
insufficient scope), which is what an OIDC token limited to `openid profile`
|
||
gets. Set it exactly like the other secrets:
|
||
|
||
```bash
|
||
sudo -u ci-runner /opt/ci/venv/bin/python -m ci_orchestrator creds set \
|
||
--target GiteaApiToken --user gitea-api
|
||
```
|
||
|
||
**Degraded states** never take the card down: no token, an expired token, or
|
||
an unreachable Gitea each render a warning notice *and still list every
|
||
running VM* below it, so the dashboard is never less useful than the VM table
|
||
it replaced. The notice includes whatever the server said (Gitea names the
|
||
missing scope in its own body), which is the fastest way to tell a token
|
||
problem from something in front of Gitea.
|
||
|
||
**Gotcha — Cloudflare in front of Gitea.** `gitea.emulab.it` is proxied by
|
||
Cloudflare, which rejects urllib's default `Python-urllib/3.x` user agent with
|
||
`403` + `error code: 1010` *before the request reaches Gitea* — indistinguishable
|
||
from a token refusal if you only look at the status code. `operations/executions.py`
|
||
therefore always sends an explicit `User-Agent`; any ordinary agent string
|
||
passes. Do not remove that header.
|
||
|
||
**Orphaned VMs are not handled in this card.** A powered-on VM whose run is no
|
||
longer in the window (or that no run ever claimed) keeps a row of its own
|
||
flagged `no run`, with its `Remove` button — but the orphan *sweep* lives in
|
||
the **Maintenance** card ("Cleanup orphaned VMs"). That is the place to act on
|
||
orphans; the Executions card only avoids hiding them.
|
||
|
||
---
|
||
|
||
## 10. 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.
|