feat(web): dashboard redesign — Gitea palette, health panels, VM/audit enrichment (stages A–C)

Stage A: swap CSS tokens to the real Gitea dark palette (accent blue #4183c4); add Jinja formatting filters (humanbytes/duration/relative_time/localtime/shorten_path/thousands), a health status-header, and an inline SVG icon sprite; fix two UI bugs — unthemed action pages (htmx swap into #action-slot with a full-page HX-Request fallback) and polling wiping form input (hx-preserve).

Stage B: four read-only panels that degrade to notices (never 500) — host resources (/proc, two-sample CPU%), VMware module health, template inventory (.vmem warn + last backup), and a path-contained file browser; backup card becomes a validated template dropdown; expensive dir-size walk is TTL-cached. Adds operations/inventory.py plus host_resources()/vmware_module_health() in operations/host.py.

Stage C: parse_clone_name() adds a Job column (job_id + started) to the VM table; nav gains a Gitea link-out derived from the OIDC issuer origin (no fabricated run URLs); audit page gets action icons, relative time, and a client-side filter.

Gates: 989 tests pass, coverage 94.12%, ruff + mypy --strict clean. Stage D (service management) intentionally deferred.
This commit is contained in:
2026-07-24 11:46:50 +02:00
parent 907e79818e
commit 9d7bbee35d
33 changed files with 4099 additions and 65 deletions
+477
View File
@@ -0,0 +1,477 @@
# Web UI Redesign — Implementation Plan (Phase 1)
**Status:** Stages A, B, C complete + gates approved (989 tests pass, cov 94.12%, ruff+mypy clean). Stage D (service management, SECURITY-CRITICAL) not started. Uncommitted.
**Owner / executor:** Sonnet (implementation) under Opus supervision (review gate after every stage).
**Scope:** Presentation redesign + new read-only panels + service management. **No** time-series store, **no** light theme, **no** rebuild of Gitea job/log views.
**Target files root:** `src/ci_orchestrator/web/`, `src/ci_orchestrator/operations/`.
This plan is written to be executed by a Sonnet-class model. Each stage is
self-contained, ends with a hard **STOP — supervisor review** gate, and lists
exact files, signatures, tests, and acceptance criteria. Do not start a stage
before the previous stage's gate is approved. Keep the checklist below current
(tick `- [x]` as tasks land); the supervisor or a subagent updates it.
---
## Progress checklist (keep this updated)
Legend: `[ ]` todo · `[~]` in progress · `[x]` done · `[!]` blocked
### Stage A — Restyle foundation + bug fixes
- [x] A1. Swap CSS tokens to real Gitea dark palette (accent → blue `#4183c4`)
- [x] A2. `web/filters.py` — number/format Jinja filters + registration in `app.py`
- [x] A3. Status header partial (`partials/status_header.html`) + health thresholds
- [x] A4. Fix unthemed action pages (htmx swap + full-page `HX-Request` fallback)
- [x] A5. Fix polling-wipes-form-input bug (`hx-preserve` on inputs)
- [x] A6. Inline SVG icon sprite (`partials/icons.html`) + apply to card headers
- [x] A7. Stage A tests green · ruff · mypy --strict · coverage ≥90
- [x] **A-GATE — supervisor review** (Opus: gates green 895 pass / cov 94.15% / ruff+mypy clean; theming fix + action-slot verified)
### Stage B — Read-only panels
- [x] B1. `operations/host.py` — `host_resources()` (/proc, 2-sample CPU%)
- [x] B2. `operations/host.py` — `vmware_module_health()` (lsmod + vmware-host.service)
- [x] B3. `operations/inventory.py` — `list_templates()` (+ `.vmem` health, last backup)
- [x] B4. `operations/inventory.py` — `list_backups()` / `list_artifacts()` (path-contained)
- [x] B5. GET partials + routes: `/resources`, `/vmware`, `/templates`, `/files`
- [x] B6. Backup card → template **dropdown** (validated against `list_templates`)
- [x] B7. Slow-poll / on-demand refresh for expensive panels (dir-size cache)
- [x] B8. Stage B tests green · ruff · mypy --strict · coverage ≥90
- [x] **B-GATE — supervisor review** (Opus: gates green 958 pass / cov 94.10% / ruff+mypy clean; containment + 2-sample CPU + backup-name 400 verified)
### Stage C — VM enrichment + Gitea link-out + audit polish
- [x] C1. `parse_clone_name()` + `repo/job` (job_id + started) column in VM table
- [x] C2. Gitea base URL in config/context; nav `Gitea ↗` + contextual links
- [x] C3. Audit page restyle (relative time, action icons, simple filter)
- [x] C4. Stage C tests green · ruff · mypy --strict · coverage ≥90
- [x] **C-GATE — supervisor review** (Opus: gates green 989 pass / cov 94.12% / ruff+mypy clean; subagent cut off mid-C2 by session limit — Opus finished wiring + fixed 2 left-over test bugs: empty-VM header assert, OIDC keyring stub)
### Stage D — Service management (SECURITY-CRITICAL, last)
- [ ] D1. `operations/services.py` — allow-list, `service_states()`, `control_service()`
- [ ] D2. POST `/services/{service_id}/{action}` — confirm + CSRF + audit + refusals
- [ ] D3. `ci-web` self-restart via detached `systemd-run` (response returns first)
- [ ] D4. Services panel partial (state-aware buttons, active-VM warning on stop)
- [ ] D5. `sudoers.d/ci-web` (exact-command NOPASSWD) + installer `visudo -c` validate
- [ ] D6. Config `[web].managed_services` override + `config.example.toml` + `WEB-UI.md`
- [ ] D7. Security regression tests (unknown id → 400 & systemctl never called; etc.)
- [ ] D8. Stage D tests green · ruff · mypy --strict · coverage ≥90
- [ ] **D-GATE — supervisor review + deploy note**
---
## Locked design decisions
| Decision | Value |
|---|---|
| Theme | **Dark only**, mirroring Gitea `gitea-auto` dark values |
| CSS approach | Hand-written, extend existing tokens. **No framework, no CDN** (LAN offline) |
| Data over time | **None** — all panels point-in-time snapshots |
| Job history / logs | Not rebuilt — link out to Gitea |
| New runtime dep | **None** — host metrics via `/proc`, not psutil |
| Service control | Full start/stop/restart over a **fixed allow-list** |
| Read routes | `web/app.py` · Mutating routes | `web/routes.py` (existing split, keep it) |
### Real Gitea dark palette (source: `gitea.emulab.it` theme `gitea-auto`)
```
--color-primary #4183c4 (accent — replaces green #3fb950)
--color-primary-contrast #ffffff
--color-body #1e1f20 (page bg)
--color-box-body #161718 (card bg)
--color-box-header #1b1c1e (card header bg)
--color-nav-bg #18191b (navbar bg)
--color-secondary-bg #2e3033 (input / subtle panel)
--color-text #d2d4d8
--color-text-light #c0c2c7 (dim)
--color-secondary #3f4248 (border-ish)
--color-success-text #3fb950 --color-error-text #f85149 --color-warning-text #d29922
--color-info-text #2f81f7
```
### Health thresholds (status header + row colors)
- Disk `used_pct` > 85 → **red**, > 70 → **amber**, else green.
- Any runner unit not `active` → **red**.
- Orphan VM present → **amber**.
- VMware `vmmon` or `vmnet` not loaded, or `vmware-host.service` not active → **red**.
---
## Architecture changes (file map)
New files:
- `web/filters.py` — Jinja formatting filters (pure functions, unit-tested).
- `operations/services.py` — managed-service allow-list + status + control.
- `operations/inventory.py` — template discovery + directory listing (path-contained).
- `web/templates/partials/`: `status_header.html`, `resources.html`, `vmware.html`,
`templates.html`, `files.html`, `services.html`, `icons.html`.
Edited files:
- `web/static/app.css` — token swap + new components (gauge, stat tile, service controls).
- `web/app.py` — register filters; new GET routes/partials; status-header context.
- `web/routes.py` — service-control POST; backup route → dropdown validation.
- `operations/host.py` — add `host_resources()`, `vmware_module_health()`.
- `web/templates/base.html` — nav `Gitea ↗`; `#action-slot`.
- `web/templates/partials/{vms,cache,host,audit,confirm,result}.html` — restyle + fixes.
- `config.py` / `config.example.toml` — `[web].gitea_url`, `[web].managed_services`.
- `docs/WEB-UI.md` — sudoers, service panel, new panels.
**Isolation rule (why the split):** `operations/host.py` already carries
disk+runner+backup. Adding resources+modules+templates+files+service-control
would bloat it past what fits in one reviewer's head. Keep host-level probes in
`host.py`, inventory/listing in `inventory.py`, service control in
`services.py`. Each is independently testable and imported by both CLI and web.
---
## Stage A — Restyle foundation + bug fixes
**Goal:** the dashboard *looks* professional and the two known UI bugs die.
No new data sources. This stage alone fixes "look grezzo / colpo d'occhio /
layout piatto" and the unthemed-action-page complaint.
### A1 — CSS tokens
Edit `web/static/app.css` `:root`: replace the palette with the Gitea dark
values above (accent `#4183c4`; `--bg`→`#1e1f20`; `--panel`→`#161718`;
`--panel-2`→`#2e3033`; `--border`→`#3f4248`; `--text`→`#d2d4d8`;
`--text-dim`→`#c0c2c7`; `--accent`→`#4183c4`; `--accent-hover` a lighter blue;
keep `--danger`/`--warn` as-is). Update `.btn-primary` contrast text to
`#ffffff`. No structural HTML change in A1.
### A2 — Jinja filters (`web/filters.py`)
Pure functions + a `register(templates: Jinja2Templates)` helper called from
`app.py` `build_app`. Signatures:
```python
def humanbytes(n: int | float) -> str # 12_345_678 -> "11.8 MB"
def duration(seconds: int | float | None) -> str # 3783 -> "1h 03m"; None -> "-"
def relative_time(value: str | datetime | None) -> str # "2m ago"; None -> "-"
def localtime(value: str | datetime | None) -> str # ISO -> host-local "YYYY-MM-DD HH:MM"
def shorten_path(p: str | Path, keep: int = 2) -> str # ".../Templates/LinuxBuild2404"
def thousands(n: int | float) -> str # 812 -> "812"; 12345 -> "12,345"
```
Apply in templates: `cache.html` (`total_bytes|humanbytes`, `entry_count|thousands`),
`vms.html` (`uptime_sec|duration`), `host.html` (disk GB formatting tidy),
`audit.html` (`ts|relative_time` with `ts|localtime` in `title=`).
### A3 — Status header (`partials/status_header.html`)
A row of stat tiles fed from the existing dashboard context (no new fetch):
VM count (+orphan count), runner `active/total`, disk `used_pct` gauge, cache
size. Apply health-threshold classes. Included at top of `dashboard.html`,
above `.dashboard-grid`. Add a `_health_summary(...)` helper in `app.py` that
derives the tile values/colors from already-fetched context (keep it pure).
### A4 — Fix unthemed action pages
Root cause: mutating `<form method="post">` do full-page navigation and the
routes return bare partials (`confirm/result/lock_conflict.html`) with no
`base.html` wrapper. Fix both paths:
1. Convert the mutating forms to **htmx** (`hx-post`, `hx-target="#action-slot"`,
`hx-swap="innerHTML"`). Confirm→execute stays two-step but renders inline.
2. In the render helpers (`_confirm_partial`, `_result_partial`,
`_lock_conflict_partial`), when `request.headers.get("HX-Request")` is
**absent**, render the partial wrapped in a themed full page (a new
`action_page.html` that `extends base.html` and drops the partial into
`#action-slot`). This keeps no-JS browsers themed too.
Add `<div id="action-slot"></div>` near the top of `base.html` content block.
### A5 — Polling wipes form input
`vms.html` cleanup form (`max_age_hours`) and the backup form live inside cards
that self-replace every 5s via `hx-swap="outerHTML"` → typed values vanish.
Add `hx-preserve="true"` + stable `id` to those inputs, **or** move the forms
into a non-polled sibling container. Prefer `hx-preserve` (smaller diff).
### A6 — Icon sprite (`partials/icons.html`)
One inline SVG `<symbol>` sprite (server, disk, cpu, box/template, gear/service,
file, external-link, check, alert). Reference via `<svg class="icon"><use href="#i-…"></use></svg>`.
No external requests. Add `.icon` sizing to CSS.
### A7 — Verify
```
python -m pytest tests/python -q --cov=ci_orchestrator --cov-fail-under=90
python -m ruff check src tests/python
python -m mypy --strict src
```
New tests: `tests/python/test_web_filters.py` (each filter, incl. `None`/edge),
`test_web_theming.py` (HX-Request present → bare partial; absent → contains
`<link rel="stylesheet"`).
### A-GATE
STOP. Show the supervisor the rendered dashboard (describe or screenshot) and
the green test/lint/type output. Do not proceed to Stage B until approved.
---
## Stage B — Read-only panels
**Goal:** four new information panels. All degrade to an "unavailable" notice on
any error (never 500), matching the existing `_fetch_*` pattern in `app.py`.
### B1 — `host_resources()` (`operations/host.py`)
```python
@dataclass(frozen=True)
class HostResources:
cpu_pct: float # 0..100
ncpu: int
mem_total_gb: float
mem_used_gb: float
uptime_sec: int
load1: float
load5: float
load15: float
def host_resources(*, cpu_sample_ms: int = 150) -> HostResources: ...
```
CPU% **requires two `/proc/stat` samples** `cpu_sample_ms` apart; compute
`1 - idle_delta/total_delta`. Read `/proc/meminfo` (MemTotal, MemAvailable →
used = total-available), `/proc/uptime` (field 0), `/proc/loadavg`,
`os.cpu_count()`. Degrade to zeros on `OSError` (never raise).
### B2 — `vmware_module_health()` (`operations/host.py`)
```python
@dataclass(frozen=True)
class VmwareModuleHealth:
vmmon_loaded: bool
vmnet_loaded: bool
host_service_active: bool
host_service_sub_state: str
```
`vmmon`/`vmnet` via reading `/proc/modules` (dependency-free; no `lsmod`
subprocess needed) or `lsmod` if simpler — prefer `/proc/modules`. Service
state reuses the existing `runner_status(["vmware-host.service"])` machinery.
Ties to the documented "VMs won't start" gotcha.
### B3 — `list_templates()` (`operations/inventory.py`)
```python
@dataclass(frozen=True)
class TemplateInfo:
name: str
vmx: Path | None
guest_os: str | None # parsed from vmx guestOS
snapshot: str | None # best-effort; may be None
transport: str # "ssh" | "winrm" | "unknown" (from guest_os)
dir_bytes: int
has_vmem: bool # True -> snapshot captured running RAM (warn)
last_backup: FileEntry | None
def list_templates(config: Config) -> list[TemplateInfo]: ...
```
Scan `config.paths.templates` subdirs. `guest_os` from the `.vmx` `guestOS`
line; `transport` = ssh if `ubuntu`/`linux` else winrm. `has_vmem` = any
`*.vmem` in the dir. `dir_bytes` via `os.walk` sum (**cached** — see B7).
`last_backup` = newest `Template_<name>_*.7z` under `<root>/backups`.
### B4 — Directory listings (`operations/inventory.py`)
```python
@dataclass(frozen=True)
class FileEntry:
name: str
size_bytes: int
mtime: float
def list_dir(base: Path, *, root: Path, pattern: str = "*") -> list[FileEntry]: ...
def list_backups(config: Config) -> list[FileEntry]: ...
def list_artifacts(config: Config) -> list[FileEntry]: ...
```
**Containment (security):** `list_dir` must resolve `base` and assert it is
`root` or under `root` (`base.resolve().is_relative_to(root.resolve())`); never
follow symlinks that escape `root`; return `[]` if `base` missing. No user input
reaches these in Phase 1 (fixed dirs), but enforce anyway.
### B5 — Routes + partials
In `app.py` add GET routes (behind `require_user`), each rendering its partial:
`/resources`→`resources.html`, `/vmware`→`vmware.html`,
`/templates`→`templates.html`, `/files`→`files.html`. Add matching `_fetch_*`
wrappers (try/except → `None`/`[]`). Include panels in `dashboard.html`.
### B6 — Backup dropdown
Replace the free-text `template` input in the backup form with a `<select>`
populated from `list_templates()` names. In `routes.py` `host_backup`, validate
the submitted `template` is in the discovered set → else 400 (no arbitrary path
to `backup_template`). Add a one-line description above the control ("7z archive
of the template dir under `<root>/backups`").
### B7 — Poll cadence / caching
`resources` may poll 5s (cheap). `vmware` poll 15s. `templates`/`files` poll 30s
**or** render once with a manual "Refresh" button (`hx-get` on click). Template
`dir_bytes` (`os.walk` over multi-GB dirs) must be **cached** with a short TTL
(e.g. module-level `{path: (mtime_ns, bytes, ts)}`, recompute if older than 60s)
so a 30s poll doesn't walk the tree every time.
### B8 — Verify (same commands as A7)
New tests: `test_operations_inventory.py` (tmpdir templates incl. `.vmem`
present/absent, containment reject `../escape` and out-of-root symlink),
`test_host_resources.py` (feed fixture `/proc` strings via a small reader seam;
two-sample CPU math). Coverage ≥90.
### B-GATE
STOP for supervisor review.
---
## Stage C — VM enrichment + Gitea link-out + audit polish
### C1 — Clone-name parsing
```python
@dataclass(frozen=True)
class ParsedClone:
job_id: str | None
started: datetime | None
def parse_clone_name(name: str) -> ParsedClone: ...
```
Format is `Clone_<job_id>_<YYYYmmdd_HHMMSS>_<suffix>` (see
`commands/job.py::_make_clone_name`). Repo is **not** in the name — do not
invent it. VM table gains a `Job` column showing `job_id` (+ `started` relative
time in a tooltip). Put the helper in `operations/vms.py` (shared) and expose
`ParsedClone` on the render-ready `LiveVm` or compute in the view layer.
### C2 — Gitea link-out
Config: add optional `[web].gitea_url`; default = origin of `[web.oidc].issuer`
(`https://gitea.emulab.it`). Expose in template context. `base.html` nav gets
`Gitea ↗` (opens new tab, `rel="noopener"`). If `job_id` looks like a Gitea run
id, link the Job cell to `<gitea_url>` (top-level; do **not** fabricate a
run-URL path unless the exact Actions URL scheme is confirmed with the
supervisor first).
### C3 — Audit page polish
Restyle `audit.html`: `ts|relative_time` (full ts in `title`), an action icon
per row (reuse sprite), result badge (already present), and a simple
client-side text filter over rows (a text input + tiny inline JS, or an
`hx-get` with a query param — prefer no-JS `hx-get?q=` if cheap). "Basta un bel
log": keep it clean, monospace targets, zebra rows optional.
### C4 — Verify + **C-GATE** (STOP).
---
## Stage D — Service management (SECURITY-CRITICAL — do last)
Treat this stage with the same rigor as the existing mutating routes. Read the
`routes.py` module docstring first; mirror its confirm-gate + CSRF + audit shape
exactly.
### D1 — `operations/services.py`
```python
@dataclass(frozen=True)
class ManagedService:
id: str # stable slug, e.g. "act-runner"
unit: str # "act-runner.service"
label: str
allow_stop: bool # ci-web -> False (restart only; stop would lock out the UI)
MANAGED_SERVICES: tuple[ManagedService, ...] = (
ManagedService("act-runner", "act-runner.service", "Runner", True),
ManagedService("act-runner-rust", "act-runner-rust.service", "Runner (Rust)", True),
ManagedService("vmware-host", "vmware-host.service", "VMware host", True),
ManagedService("ci-web", "ci-web.service", "Web UI", False),
)
ALLOWED_ACTIONS = ("start", "stop", "restart")
@dataclass(frozen=True)
class ServiceState:
service: ManagedService
active: bool
sub_state: str
@dataclass(frozen=True)
class ServiceActionResult:
service_id: str
action: str
ok: bool
detail: str
def find_service(service_id: str) -> ManagedService | None: ...
def service_states(services: tuple[ManagedService, ...] = MANAGED_SERVICES) -> list[ServiceState]: ...
def control_service(service: ManagedService, action: str, *,
dry_run: bool = False) -> ServiceActionResult: ...
```
`control_service` rules:
- `action` **must** be in `ALLOWED_ACTIONS` else raise `ValueError` (caller maps to 400).
- `action == "stop"` and `not service.allow_stop` → refuse (400).
- Build an **argument list** `["sudo", "-n", "systemctl", action, service.unit]`
— never a shell string, never a user-supplied unit name. `service` comes only
from `find_service`, which only returns allow-list members.
- `dry_run` returns the would-run command without executing.
- Timeout + `check=False`; non-zero → `ok=False` with stderr tail in `detail`.
### D2 — Route `POST /services/{service_id}/{action}`
In `routes.py`, mirror `vms_remove`: `require_user` + `require_csrf` +
two-stage confirm gate + exactly one `AuditEntry` per stage
(`action="service.<action>"`, `target=unit`). `service_id` → `find_service`;
unknown → 400 **before** any subprocess. `action` not allowed → 400. Register
in `register_routes`.
### D3 — `ci-web` self-restart
Restarting `ci-web.service` from within its own worker kills the response
mid-flight. For `service.id == "ci-web"` and `action == "restart"`, run a
**detached** deferred restart so the HTTP response returns first:
`["sudo","-n","systemd-run","--on-active=2","systemctl","restart","ci-web.service"]`.
Audit as `dry-run`→`ok` with detail "scheduled in 2s". Document that the UI
will briefly drop.
### D4 — Services panel (`partials/services.html`)
Table of `service_states()`: label, unit, active badge, sub_state, and
**state-aware** action buttons (hide "start" when active, hide "stop" when
inactive or `allow_stop=False`). Stop/restart of `vmware-host.service` confirm
step shows **"N active VMs — this interrupts running jobs"** (count from the VM
context). Buttons are htmx → `#action-slot` (same pattern as Stage A4).
### D5 — sudoers + installer
Create `deploy/sudoers.d/ci-web` (or wherever the systemd installer lives — find
it first; referenced by `docs/WEB-UI.md` / the "systemd installer" commit):
```
# Exact-command NOPASSWD — no wildcards, no bare `systemctl`.
Cmnd_Alias CI_WEB_SVC = \
/usr/bin/systemctl start act-runner.service, /usr/bin/systemctl stop act-runner.service, /usr/bin/systemctl restart act-runner.service, \
/usr/bin/systemctl start act-runner-rust.service, /usr/bin/systemctl stop act-runner-rust.service, /usr/bin/systemctl restart act-runner-rust.service, \
/usr/bin/systemctl start vmware-host.service, /usr/bin/systemctl stop vmware-host.service, /usr/bin/systemctl restart vmware-host.service, \
/usr/bin/systemd-run --on-active=2 systemctl restart ci-web.service
ci-runner ALL=(root) NOPASSWD: CI_WEB_SVC
```
Installer must (a) write this to `/etc/sudoers.d/ci-web` mode `0440`, (b) run
`visudo -cf /etc/sudoers.d/ci-web` and abort/rollback on non-zero (a broken
sudoers file can lock out sudo). Verify the real `systemctl`/`systemd-run`
absolute paths on the host (`command -v`) — adjust if not `/usr/bin`.
Note: `ci-web` has **no** `stop` entry (restart-only by design).
### D6 — Config + docs
`config.py`: optional `[web].managed_services` to override the default
allow-list (list of `{id, unit, label, allow_stop}`); default = `MANAGED_SERVICES`.
Update `config.example.toml` and `docs/WEB-UI.md` (new panels, sudoers install,
self-restart caveat, security model).
### D7 — Security regression tests (`test_web_services_security.py`)
- Unknown `service_id` → 400 **and** `control_service`/subprocess never called (assert via mock).
- `action` not in `ALLOWED_ACTIONS` → 400, no subprocess.
- `stop` on `ci-web` (`allow_stop=False`) → refused, no subprocess.
- Missing/invalid CSRF → 403 (reuse existing security test pattern).
- `control_service` builds an arg list (never `shell=True`); `ci-web` restart uses `systemd-run`.
- Confirm-gate: POST without `stage=execute` → dry-run + confirm partial, no real systemctl.
### D8 — Verify + **D-GATE**
Full suite green, coverage ≥90, ruff, mypy --strict. Supervisor review +
**deploy note**: after merge, re-run `pip install .` into the production venv,
install the sudoers file, `sudo systemctl restart ci-web.service`. This stage
changes privilege boundaries — supervisor confirms the sudoers file on the live
host with `visudo -c` before enabling the panel.
---
## Cross-cutting rules (all stages)
- **Never** `subprocess.run(..., shell=True)`; always arg lists. No user input in argv.
- Every route stays behind `require_user`; every mutating POST behind `require_csrf` + confirm gate + audit.
- Read routes must never 500 on a backend/probe hiccup — degrade to an "unavailable" notice.
- `click.echo` only in `commands/*`; `operations/*` and `web/*` return dataclasses / render — no `print`.
- Match existing code style, comment density, and the `operations/` "pure logic, zero click" boundary.
- Coverage gate 90% is non-negotiable; add tests in the same stage as the code.
## Non-goals (explicit)
- No light theme. No `prefers-color-scheme`.
- No metrics-over-time, sparklines, or sampler/store (that would be a separate Phase 2 spec).
- No file **download** (Phase 1 lists only).
- No reimplementation of Gitea job/run/log views — link out only.
- No new mutating VM actions beyond existing remove/cleanup (service control is the only new mutation).
## Open risks / de-risk notes
- **Gitea run-URL scheme** (C2): confirm the exact Actions URL path before linking a Job cell to a specific run; default to a top-level Gitea link.
- **systemd unit names** (D): `act-runner.service` / `act-runner-rust.service` are from `web/app.py::RUNNER_SERVICE_NAMES`; confirm they exist on the live host (`systemctl status`) before wiring buttons.
- **systemctl/systemd-run paths** (D5): confirm absolute paths on host; sudoers matches literal paths.
- **CPU% correctness** (B1): must use two `/proc/stat` samples — single-read % is a classic bug.
+34
View File
@@ -19,6 +19,7 @@ import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
_ENV_KEYS = ("CI_ROOT", "CI_TEMPLATES", "CI_BUILD_VMS", "CI_ARTIFACTS", "CI_KEYS")
@@ -101,6 +102,13 @@ class WebConfig:
``session_secret_cred`` is a **keyring key name**, never the secret
itself — same pattern as :attr:`WebOidcConfig.client_secret_cred`.
``gitea_url`` (Stage C2) is the top-level Gitea instance URL used for
the nav's "Gitea ↗" link-out. When unset, :func:`gitea_base_url` derives
it from ``oidc.issuer``'s origin -- see that function's docstring. There
is deliberately no per-run Actions URL here: the exact Gitea Actions
run-URL path is unconfirmed (open risk), so only the top-level instance
URL is ever exposed.
"""
enabled: bool = False
@@ -110,6 +118,29 @@ class WebConfig:
allowed_users: list[str] = field(default_factory=list)
session_secret_cred: str = "WebSessionSecret"
oidc: WebOidcConfig | None = None
gitea_url: str | None = None
def gitea_base_url(web: WebConfig) -> str | None:
"""Effective Gitea link-out URL for the nav, or ``None`` if nothing usable.
An explicit ``web.gitea_url`` always wins. Otherwise, if ``[web.oidc]``
is configured, the URL is derived from the *origin* (scheme + host) of
``oidc.issuer`` -- e.g. issuer ``https://gitea.emulab.it`` (or
``https://gitea.emulab.it/some/path``) resolves to
``https://gitea.emulab.it``. Never fabricates a path (see
:class:`WebConfig`'s docstring on the Actions-run-URL open risk). With
neither set, or an issuer that fails to parse into scheme+host, returns
``None`` -- the nav simply omits the link.
"""
if web.gitea_url:
return web.gitea_url
if web.oidc is None:
return None
parts = urlsplit(web.oidc.issuer)
if not parts.scheme or not parts.netloc:
return None
return f"{parts.scheme}://{parts.netloc}"
@dataclass(frozen=True)
@@ -199,6 +230,7 @@ def _parse_web_config(web_section: dict[str, Any]) -> WebConfig:
allowed_users = web_section.get("allowed_users")
audit_log = web_section.get("audit_log")
gitea_url = web_section.get("gitea_url")
return WebConfig(
enabled=bool(web_section.get("enabled", False)),
@@ -208,6 +240,7 @@ def _parse_web_config(web_section: dict[str, Any]) -> WebConfig:
allowed_users=[str(u) for u in allowed_users] if isinstance(allowed_users, list) else [],
session_secret_cred=str(web_section.get("session_secret_cred", "WebSessionSecret")),
oidc=_parse_web_oidc(web_section.get("oidc", {}) or {}),
gitea_url=str(gitea_url) if gitea_url else None,
)
@@ -312,5 +345,6 @@ __all__ = [
"Paths",
"WebConfig",
"WebOidcConfig",
"gitea_base_url",
"load_config",
]
+172 -1
View File
@@ -1,6 +1,7 @@
"""Pure host operations shared by the CLI and (future) web UI.
Disk usage, runner (systemd unit) status, and template backup. Zero
Disk usage, runner (systemd unit) status, template backup, and (Stage B of
the web-UI redesign) live host resource/VMware-module health. Zero
``click``, zero ``print`` — everything here returns a dataclass.
Presentation stays in the CLI command wrappers and, later, the web routes.
@@ -12,12 +13,19 @@ every ``systemctl`` invocation is an argument list, read-only.
``commands/template.py::template_backup`` (timestamped ``7z`` archive under
``<config.paths.root>/backups``) but is a fresh, click-free implementation so
this module has no dependency on the CLI layer.
``host_resources``/``vmware_module_health`` (Stage B) read only ``/proc`` —
dependency-free, no ``psutil``, no ``lsmod`` subprocess — via the
:func:`_read_proc` seam so tests can feed fixture strings without touching a
real filesystem.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
@@ -28,6 +36,10 @@ if TYPE_CHECKING: # pragma: no cover
_SYSTEMCTL_TIMEOUT = 10.0
#: The systemd unit the VMware kernel-module stack lives under (see the
#: "VMware modules vs kernel upgrade" gotcha in AGENTS.md / CLAUDE.md memory).
_VMWARE_HOST_SERVICE = "vmware-host.service"
@dataclass(frozen=True)
class DiskStatus:
@@ -58,6 +70,39 @@ class BackupResult:
error: str | None
@dataclass(frozen=True)
class HostResources:
"""Point-in-time host CPU/memory/uptime/load snapshot (Stage B, B1)."""
cpu_pct: float
ncpu: int
mem_total_gb: float
mem_used_gb: float
uptime_sec: int
load1: float
load5: float
load15: float
@dataclass(frozen=True)
class VmwareModuleHealth:
"""``vmmon``/``vmnet`` kernel-module + ``vmware-host.service`` state (Stage B, B2)."""
vmmon_loaded: bool
vmnet_loaded: bool
host_service_active: bool
host_service_sub_state: str
def _read_proc(path: str) -> str:
"""Read a ``/proc`` pseudo-file. The sole I/O seam :func:`host_resources` and
:func:`vmware_module_health` use -- tests monkeypatch this one function to
feed fixture strings instead of touching a real ``/proc``.
"""
with open(path, encoding="utf-8") as fh:
return fh.read()
def disk_status(path: Path) -> DiskStatus:
"""Report usage for ``path``. Degrades to zeros on ``OSError`` — never raises."""
try:
@@ -109,6 +154,128 @@ def runner_status(service_names: list[str]) -> list[RunnerStatus]:
return out
def _parse_stat_cpu_line(text: str) -> tuple[int, int]:
"""Parse the aggregate ``cpu `` line of ``/proc/stat`` text into ``(idle, total)``.
Column order (kernel docs): user nice system idle iowait irq softirq
steal guest guest_nice. ``idle`` is idle+iowait per the B1 spec; ``total``
is the sum of every column present (tolerates a short/old-kernel line
with fewer columns). Returns ``(0, 0)`` if no ``cpu `` line is found —
the caller guards the resulting division by zero.
"""
for line in text.splitlines():
if line.startswith("cpu "):
values = [int(v) for v in line.split()[1:]]
idle = values[3] + (values[4] if len(values) > 4 else 0)
return idle, sum(values)
return 0, 0
def _parse_meminfo(text: str) -> tuple[int, int]:
"""Parse ``/proc/meminfo`` text into ``(MemTotal_kb, MemAvailable_kb)``."""
total_kb = 0
avail_kb = 0
for line in text.splitlines():
if line.startswith("MemTotal:"):
total_kb = int(line.split()[1])
elif line.startswith("MemAvailable:"):
avail_kb = int(line.split()[1])
return total_kb, avail_kb
def host_resources(*, cpu_sample_ms: int = 150) -> HostResources:
"""Snapshot CPU%/memory/uptime/load average from ``/proc``. Never raises.
CPU% needs **two** ``/proc/stat`` samples ``cpu_sample_ms`` apart (a
single read only gives a since-boot cumulative average, not a current
load) — ``cpu_pct = 100 * (1 - idle_delta / total_delta)``. Degrades to
an all-zero snapshot on ``OSError`` (missing ``/proc``, e.g. non-Linux or
a sandboxed test host) — ``ncpu`` still reports the real
:func:`os.cpu_count` since that call never touches ``/proc``.
"""
ncpu = os.cpu_count() or 1
try:
idle1, total1 = _parse_stat_cpu_line(_read_proc("/proc/stat"))
time.sleep(cpu_sample_ms / 1000.0)
idle2, total2 = _parse_stat_cpu_line(_read_proc("/proc/stat"))
idle_delta = idle2 - idle1
total_delta = total2 - total1
cpu_pct = 100.0 * (1.0 - idle_delta / total_delta) if total_delta > 0 else 0.0
cpu_pct = max(0.0, min(100.0, cpu_pct))
total_kb, avail_kb = _parse_meminfo(_read_proc("/proc/meminfo"))
mem_total_gb = total_kb / (1024.0**2)
mem_used_gb = max(0.0, total_kb - avail_kb) / (1024.0**2)
uptime_sec = int(float(_read_proc("/proc/uptime").split()[0]))
load_parts = _read_proc("/proc/loadavg").split()
load1, load5, load15 = float(load_parts[0]), float(load_parts[1]), float(load_parts[2])
except (OSError, ValueError, IndexError):
# OSError: missing/unreadable /proc (non-Linux, a sandboxed test
# host). ValueError/IndexError: defensive -- a malformed line should
# never crash a dashboard poll either, even though it isn't expected
# in practice on a real Linux host.
return HostResources(
cpu_pct=0.0,
ncpu=ncpu,
mem_total_gb=0.0,
mem_used_gb=0.0,
uptime_sec=0,
load1=0.0,
load5=0.0,
load15=0.0,
)
return HostResources(
cpu_pct=cpu_pct,
ncpu=ncpu,
mem_total_gb=mem_total_gb,
mem_used_gb=mem_used_gb,
uptime_sec=uptime_sec,
load1=load1,
load5=load5,
load15=load15,
)
def vmware_module_health() -> VmwareModuleHealth:
"""``vmmon``/``vmnet`` load state + ``vmware-host.service`` state. Never raises.
Module state comes from ``/proc/modules`` (first whitespace-separated
token of each line is the module name) — dependency-free, no ``lsmod``
subprocess. Service state reuses :func:`runner_status` against
``vmware-host.service`` rather than a second systemctl code path. Ties to
the "VMs won't start after boot" gotcha (vmmon/vmnet unbuilt or unloaded).
"""
vmmon_loaded = False
vmnet_loaded = False
try:
loaded_names = {line.split()[0] for line in _read_proc("/proc/modules").splitlines() if line.strip()}
vmmon_loaded = "vmmon" in loaded_names
vmnet_loaded = "vmnet" in loaded_names
except OSError:
pass
host_service_active = False
host_service_sub_state = "unknown"
try:
statuses = runner_status([_VMWARE_HOST_SERVICE])
if statuses:
host_service_active = statuses[0].active
host_service_sub_state = statuses[0].sub_state
except Exception: # defensive: runner_status already degrades, never raise here either
pass
return VmwareModuleHealth(
vmmon_loaded=vmmon_loaded,
vmnet_loaded=vmnet_loaded,
host_service_active=host_service_active,
host_service_sub_state=host_service_sub_state,
)
def _compress_7z(source: Path, archive: Path) -> None:
"""Create a 7z archive of ``source`` at ``archive`` using compression level 1.
@@ -161,8 +328,12 @@ def backup_template(config: Config, template: str, *, dry_run: bool = False) ->
__all__ = [
"BackupResult",
"DiskStatus",
"HostResources",
"RunnerStatus",
"VmwareModuleHealth",
"backup_template",
"disk_status",
"host_resources",
"runner_status",
"vmware_module_health",
]
+290
View File
@@ -0,0 +1,290 @@
"""Pure host-inventory operations shared by the CLI and (future) web UI.
Template discovery (:func:`list_templates`) and read-only directory listings
(:func:`list_backups`/:func:`list_artifacts`, built on :func:`list_dir`) for
the web dashboard's Stage B "Templates" and "Files" panels. Zero ``click``,
zero ``print`` — everything here returns a dataclass.
**Containment (SECURITY):** :func:`list_dir` refuses to list any ``base``
that does not resolve to ``root`` itself or a real descendant of it — this
holds even though Phase 1 never routes user input into ``base``/``root``
here, matching the containment discipline ``web/routes.py``'s
``_is_contained`` already applies to VM-removal paths. Resolving both sides
before comparing means a symlink or ``..`` segment cannot smuggle a listing
outside ``root``.
"""
from __future__ import annotations
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from ci_orchestrator.config import Config
#: TTL (seconds) for the ``dir_bytes`` cache below (B7) — an ``os.walk`` over
#: a multi-GB template dir is too expensive to redo on every 30s dashboard
#: poll.
_DIR_BYTES_TTL_SECONDS = 60.0
#: Module-level cache: ``{resolved-path-str: (mtime_ns, bytes, computed_at)}``.
#: A plain dict is not thread-safe against concurrent writers, but the web
#: dashboard runs as a single-worker uvicorn process (see ``commands/web.py``)
#: so a bare dict is an acceptable simplification here — no lock needed.
_dir_bytes_cache: dict[str, tuple[int, int, float]] = {}
@dataclass(frozen=True)
class FileEntry:
"""A single file's name/size/mtime — used for the backups/artifacts lists."""
name: str
size_bytes: int
mtime: float
@dataclass(frozen=True)
class TemplateInfo:
"""One VM template directory as reported for the dashboard's Templates panel."""
name: str
vmx: Path | None
guest_os: str | None
transport: str
dir_bytes: int
has_vmem: bool
last_backup: FileEntry | None
# ──────────────────────────────────────────────────────────────── templates
def _find_vmx(template_dir: Path) -> Path | None:
"""Return the first ``*.vmx`` file directly under ``template_dir``, if any."""
try:
for entry in template_dir.iterdir():
if entry.is_file() and entry.suffix.lower() == ".vmx":
return entry
except OSError:
return None
return None
def _read_guest_os(vmx: Path) -> str:
"""Best-effort parse of the ``guestOS = "..."`` line from a VMX file.
A small, self-contained copy of ``operations/vms.py``'s private helper of
the same name — each ``operations/*`` module stays independently
importable with no cross-module reach-ins, matching this package's
existing per-module-private-helper convention. Degrades to ``""`` on any
read failure or a missing ``guestOS`` line; never raises.
"""
try:
text = vmx.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
for line in text.splitlines():
if "=" not in line:
continue
key, _, rhs = line.partition("=")
if key.strip().lower() != "guestos":
continue
rhs = rhs.strip()
if rhs.startswith('"') and rhs.endswith('"') and len(rhs) >= 2:
return rhs[1:-1]
return rhs
return ""
def _transport_for(guest_os: str | None) -> str:
"""``"ssh"`` for a Linux-flavoured ``guest_os``, ``"winrm"`` otherwise.
``"unknown"`` when ``guest_os`` is absent — mirrors ``commands/job.py``'s
own ``ubuntu-*`` -> SSH / else -> WinRM transport-selection rule.
"""
if not guest_os:
return "unknown"
lowered = guest_os.lower()
if "ubuntu" in lowered or "linux" in lowered:
return "ssh"
return "winrm"
def _has_vmem(template_dir: Path) -> bool:
"""True iff any ``*.vmem`` sits in ``template_dir`` — a snapshot taken while
the VM was running (see the "Snapshot with VM running" gotcha).
"""
try:
return any(template_dir.glob("*.vmem"))
except OSError:
return False
def _dir_bytes_cached(path: Path) -> int:
"""``os.walk``-summed size of ``path``, cached with a TTL (B7).
A cached entry younger than :data:`_DIR_BYTES_TTL_SECONDS` is reused
verbatim so a 30s dashboard poll never re-walks a multi-GB template
directory on every request. Degrades to ``0`` on any ``OSError`` (missing
dir, permission trouble, a file vanishing mid-walk) rather than raising.
"""
try:
key = str(path.resolve())
except OSError:
return 0
now = time.monotonic()
cached = _dir_bytes_cache.get(key)
if cached is not None:
_mtime_ns, cached_bytes, computed_at = cached
if now - computed_at < _DIR_BYTES_TTL_SECONDS:
return cached_bytes
try:
mtime_ns = path.stat().st_mtime_ns
except OSError:
mtime_ns = 0
total = 0
try:
for dirpath, _dirnames, filenames in os.walk(path):
for filename in filenames:
try:
total += (Path(dirpath) / filename).stat().st_size
except OSError:
continue
except OSError:
total = 0
_dir_bytes_cache[key] = (mtime_ns, total, now)
return total
def _newest_backup(backups_dir: Path, template_name: str) -> FileEntry | None:
"""Newest ``Template_<template_name>_*.7z`` under ``backups_dir``, if any.
Mirrors the archive naming ``operations/host.py::backup_template`` writes
(``Template_{template}_{timestamp}.7z``) so a discovered template always
finds its own backups and nobody else's.
"""
if not backups_dir.is_dir():
return None
try:
matches = list(backups_dir.glob(f"Template_{template_name}_*.7z"))
except OSError:
return None
newest: FileEntry | None = None
for match in matches:
try:
if not match.is_file():
continue
st = match.stat()
except OSError:
continue
if newest is None or st.st_mtime > newest.mtime:
newest = FileEntry(name=match.name, size_bytes=st.st_size, mtime=st.st_mtime)
return newest
def list_templates(config: Config) -> list[TemplateInfo]:
"""Scan ``config.paths.templates``' immediate subdirs into ``TemplateInfo`` rows.
Never raises: a missing/unreadable templates root degrades to ``[]``; a
single bad subdirectory (permission trouble, a race with a concurrent
delete) is skipped rather than aborting the whole scan.
"""
templates_dir = config.paths.templates
if not templates_dir.is_dir():
return []
try:
entries = sorted(templates_dir.iterdir())
except OSError:
return []
backups_dir = config.paths.root / "backups"
out: list[TemplateInfo] = []
for entry in entries:
try:
if not entry.is_dir():
continue
except OSError:
continue
vmx = _find_vmx(entry)
guest_os = (_read_guest_os(vmx) or None) if vmx is not None else None
out.append(
TemplateInfo(
name=entry.name,
vmx=vmx,
guest_os=guest_os,
transport=_transport_for(guest_os),
dir_bytes=_dir_bytes_cached(entry),
has_vmem=_has_vmem(entry),
last_backup=_newest_backup(backups_dir, entry.name),
)
)
return out
# ───────────────────────────────────────────────────────────── directory listings
def list_dir(base: Path, *, root: Path, pattern: str = "*") -> list[FileEntry]:
"""Files directly under ``base`` matching ``pattern``, newest-first.
SECURITY: refuses to list anything unless ``base`` resolves to ``root``
itself or a real descendant of it (see the module docstring) — a
``base`` outside ``root``, including via a symlink, returns ``[]``
exactly like a missing ``base``. Never raises.
"""
try:
resolved_root = root.resolve()
resolved_base = base.resolve()
except OSError:
return []
contained = resolved_base == resolved_root or resolved_root in resolved_base.parents
if not contained or not resolved_base.is_dir():
return []
out: list[FileEntry] = []
try:
for entry in resolved_base.glob(pattern):
try:
if not entry.is_file():
continue
st = entry.stat()
except OSError:
continue
out.append(FileEntry(name=entry.name, size_bytes=st.st_size, mtime=st.st_mtime))
except OSError:
return []
out.sort(key=lambda fe: fe.mtime, reverse=True)
return out
def list_backups(config: Config) -> list[FileEntry]:
"""Backup archives under ``<config.paths.root>/backups`` (see ``backup_template``)."""
return list_dir(config.paths.root / "backups", root=config.paths.root, pattern="*.7z")
def list_artifacts(config: Config) -> list[FileEntry]:
"""Files directly under ``config.paths.artifacts``."""
return list_dir(config.paths.artifacts, root=config.paths.root, pattern="*")
__all__ = [
"FileEntry",
"TemplateInfo",
"list_artifacts",
"list_backups",
"list_dir",
"list_templates",
]
+70
View File
@@ -60,6 +60,21 @@ class RemovalResult:
error: str | None
@dataclass(frozen=True)
class ParsedClone:
"""Best-effort decomposition of a ``Clone_<job_id>_<started>_<suffix>`` name.
``job_id`` and ``started`` degrade to ``None`` independently -- a garbled
timestamp does not blank out an otherwise-recoverable ``job_id`` and vice
versa. There is deliberately no ``repo`` field: the clone name (see
:func:`~ci_orchestrator.commands.job._make_clone_name`) never carries the
repo, so a parser here cannot invent one.
"""
job_id: str | None
started: datetime | None
# ────────────────────────────────────────────────────────────────── helpers
@@ -105,6 +120,59 @@ def _read_guest_os(vmx: Path) -> str:
return ""
# ───────────────────────────────────────────────────────── clone-name parsing
#: Clone names are always ``Clone_<job_id>_<YYYYmmdd>_<HHMMSS>_<suffix>``
#: (see ``commands/job.py::_make_clone_name``). ``job_id`` may itself contain
#: underscores, which is why this can't be a plain ``str.split("_")`` unpack
#: -- the date/time/suffix are anchored from the *right* instead.
_CLONE_PREFIX = "Clone_"
_DATE_LEN = 8 # YYYYmmdd
_TIME_LEN = 6 # HHMMSS
def parse_clone_name(name: str) -> ParsedClone:
"""Invert :func:`~ci_orchestrator.commands.job._make_clone_name`.
Strips the leading ``Clone_`` and the trailing ``_<suffix>``; the two
underscore-joined fields remaining before the suffix are the
``YYYYmmdd``/``HHMMSS`` halves of the timestamp, and everything left
between the prefix and the timestamp is ``job_id`` (re-joined with
``_`` if it contained any itself).
Never raises: a name that doesn't start with ``Clone_``, has too few
``_``-separated parts to hold a date+time+suffix, or has a
non-numeric/wrong-length date or time degrades the affected field(s) to
``None`` rather than raising. An empty ``job_id`` (nothing between the
prefix and the timestamp) is also reported as ``None``, not ``""``.
"""
if not name.startswith(_CLONE_PREFIX):
return ParsedClone(job_id=None, started=None)
parts = name[len(_CLONE_PREFIX) :].split("_")
# Need at least date + time + suffix, even with an empty job_id.
if len(parts) < 3:
return ParsedClone(job_id=None, started=None)
date_str, time_str = parts[-3], parts[-2]
job_id_parts = parts[:-3]
job_id = "_".join(job_id_parts) if job_id_parts else None
started: datetime | None = None
if (
len(date_str) == _DATE_LEN
and date_str.isdigit()
and len(time_str) == _TIME_LEN
and time_str.isdigit()
):
try:
started = datetime.strptime(f"{date_str}_{time_str}", "%Y%m%d_%H%M%S")
except ValueError:
started = None
return ParsedClone(job_id=job_id, started=started)
# ─────────────────────────────────────────────────────────────── live VMs
@@ -327,10 +395,12 @@ def reconcile_ip_leases(
__all__ = [
"LiveVm",
"Orphan",
"ParsedClone",
"RemovalResult",
"cleanup_orphans",
"list_live_vms",
"list_orphans",
"parse_clone_name",
"reconcile_ip_leases",
"remove_vm",
]
+274 -8
View File
@@ -30,6 +30,7 @@ never 500s. Tests override it via ``app.dependency_overrides[get_backend]``.
from __future__ import annotations
import secrets
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
@@ -41,9 +42,27 @@ from starlette.middleware.sessions import SessionMiddleware
from ci_orchestrator.backends import load_backend
from ci_orchestrator.backends.errors import BackendError
from ci_orchestrator.config import gitea_base_url
from ci_orchestrator.operations.cache import CacheStatus, cargo_cache_status
from ci_orchestrator.operations.host import DiskStatus, RunnerStatus, disk_status, runner_status
from ci_orchestrator.operations.host import (
DiskStatus,
HostResources,
RunnerStatus,
VmwareModuleHealth,
disk_status,
host_resources,
runner_status,
vmware_module_health,
)
from ci_orchestrator.operations.inventory import (
FileEntry,
TemplateInfo,
list_artifacts,
list_backups,
list_templates,
)
from ci_orchestrator.operations.vms import LiveVm, list_live_vms
from ci_orchestrator.web import filters
from ci_orchestrator.web.auth import _read_secret, configure_auth, require_user
from ci_orchestrator.web.security import issue_csrf
@@ -57,6 +76,19 @@ _WEB_DIR = Path(__file__).resolve().parent
#: CLAUDE.md's "Phase C"). A module constant per the W4 plan section.
RUNNER_SERVICE_NAMES: list[str] = ["act-runner.service", "act-runner-rust.service"]
#: Disk-usage health thresholds for the status header (A3): above the
#: "critical" percentage -> red; above "elevated" -> amber; else green.
_DISK_PCT_CRITICAL = 85.0
_DISK_PCT_ELEVATED = 70.0
#: CSS class used by ``partials/status_header.html`` for each health level.
#: Health must never be color-only -- the template always pairs this class
#: with a short text label (see :class:`HealthSummary`).
_TILE_OK = "tile-ok"
_TILE_WARN = "tile-warn"
_TILE_BAD = "tile-bad"
_TILE_UNKNOWN = "tile-unknown"
# ─────────────────────────────────────────────────────────────── dependency
@@ -113,6 +145,160 @@ def _fetch_runner_status() -> list[RunnerStatus]:
return []
def _fetch_resources() -> HostResources | None:
"""Host CPU/memory/uptime/load snapshot (B1), or ``None`` if unreadable.
``host_resources`` itself already degrades to an all-zero snapshot on
``OSError`` (never raises) -- this ``except Exception`` is a defensive
second layer so a route can never 500 even on a genuinely unexpected
failure.
"""
try:
return host_resources()
except Exception: # degrade, never 500
return None
def _fetch_vmware_health() -> VmwareModuleHealth | None:
"""``vmmon``/``vmnet``/``vmware-host.service`` health (B2), or ``None`` if unreadable."""
try:
return vmware_module_health()
except Exception: # degrade, never 500
return None
def _fetch_templates(config: Config) -> list[TemplateInfo] | None:
"""Template inventory rows (B3), or ``None`` if the scan itself failed.
Distinct from an empty list (``[]``, a templates dir with nothing in
it) -- ``None`` triggers the "unavailable" notice in ``templates.html``,
``[]`` triggers its plain "no templates found" state.
"""
try:
return list_templates(config)
except Exception: # degrade, never 500
return None
def _fetch_template_names(config: Config) -> list[str]:
"""Template names only, for the B6 backup-form dropdown. Never ``None``."""
rows = _fetch_templates(config)
return [t.name for t in rows] if rows is not None else []
def _fetch_backups(config: Config) -> list[FileEntry] | None:
"""Backup archive listing (B4), or ``None`` if the scan itself failed."""
try:
return list_backups(config)
except Exception: # degrade, never 500
return None
def _fetch_artifacts(config: Config) -> list[FileEntry] | None:
"""Artifact listing (B4), or ``None`` if the scan itself failed."""
try:
return list_artifacts(config)
except Exception: # degrade, never 500
return None
# ────────────────────────────────────────────────────────── status header
@dataclass(frozen=True)
class HealthSummary:
"""Render-ready values for the status-header tiles (A3).
Every field is derived purely from data the dashboard route already
fetched -- :func:`_health_summary` makes no new call of its own. Each
tile carries both a CSS class (``*_class``) and a short text
``*_label`` so health never reads from color alone.
"""
vm_count: int
orphan_count: int
vm_class: str
vm_label: str
runners_active: int
runners_total: int
runner_class: str
runner_label: str
disk_used_pct: float | None
disk_class: str
disk_label: str
cache_bytes: int | None
cache_class: str
cache_label: str
def _health_summary(
vms: list[LiveVm],
*,
backend_available: bool,
cache: CacheStatus | None,
disk: DiskStatus | None,
runners: list[RunnerStatus],
) -> HealthSummary:
"""Derive the A3 status-header tiles from already-fetched dashboard context.
Thresholds (see the A3 brief): disk ``used_pct`` > 85 -> red, > 70 ->
amber, else green; any runner not active -> red; an orphaned VM present
-> amber. A missing data source (backend down, disk/cache unreadable)
renders as a neutral "unknown" tile rather than false-green.
"""
orphan_count = sum(1 for vm in vms if vm.is_orphan)
if not backend_available:
vm_class, vm_label = _TILE_UNKNOWN, "unknown"
elif orphan_count > 0:
vm_class, vm_label = _TILE_WARN, "orphaned"
else:
vm_class, vm_label = _TILE_OK, "ok"
runners_total = len(runners)
runners_active = sum(1 for r in runners if r.active)
if runners_total == 0:
runner_class, runner_label = _TILE_UNKNOWN, "unknown"
elif runners_active < runners_total:
runner_class, runner_label = _TILE_BAD, "down"
else:
runner_class, runner_label = _TILE_OK, "ok"
disk_used_pct = disk.used_pct if disk is not None else None
if disk_used_pct is None:
disk_class, disk_label = _TILE_UNKNOWN, "unknown"
elif disk_used_pct > _DISK_PCT_CRITICAL:
disk_class, disk_label = _TILE_BAD, "critical"
elif disk_used_pct > _DISK_PCT_ELEVATED:
disk_class, disk_label = _TILE_WARN, "elevated"
else:
disk_class, disk_label = _TILE_OK, "ok"
cache_bytes = cache.total_bytes if cache is not None else None
cache_class, cache_label = (
(_TILE_OK, "ok") if cache is not None else (_TILE_UNKNOWN, "unknown")
)
return HealthSummary(
vm_count=len(vms),
orphan_count=orphan_count,
vm_class=vm_class,
vm_label=vm_label,
runners_active=runners_active,
runners_total=runners_total,
runner_class=runner_class,
runner_label=runner_label,
disk_used_pct=disk_used_pct,
disk_class=disk_class,
disk_label=disk_label,
cache_bytes=cache_bytes,
cache_class=cache_class,
cache_label=cache_label,
)
# ────────────────────────────────────────────────────────────────── routes
@@ -124,15 +310,38 @@ def healthz() -> dict[str, str]:
def dashboard(
request: Request, backend: VmBackend | None = Depends(get_backend)
) -> HTMLResponse:
"""Full dashboard: VM live, cache, disk, runner cards."""
"""Full dashboard: VM live, cache, disk, runner, and Stage-B info cards."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
vms = _fetch_live_vms(backend, config)
backend_available = backend is not None
cache = _fetch_cache_status(config)
disk = _fetch_disk_status(config)
runners = _fetch_runner_status()
# One template scan feeds both the full Templates-panel rows and the
# backup-form dropdown's names -- no need for _fetch_template_names to
# call list_templates a second time within the same request.
template_rows = _fetch_templates(config)
template_names = [t.name for t in template_rows] if template_rows is not None else []
context = {
"vms": _fetch_live_vms(backend, config),
"backend_available": backend is not None,
"cache": _fetch_cache_status(config),
"disk": _fetch_disk_status(config),
"runners": _fetch_runner_status(),
"vms": vms,
"backend_available": backend_available,
"cache": cache,
"disk": disk,
"runners": runners,
"resources": _fetch_resources(),
"vmware": _fetch_vmware_health(),
"templates": template_rows,
"template_names": template_names,
"backups": _fetch_backups(config),
"artifacts": _fetch_artifacts(config),
"health": _health_summary(
vms,
backend_available=backend_available,
cache=cache,
disk=disk,
runners=runners,
),
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "dashboard.html", context)
@@ -161,17 +370,51 @@ def cache_partial(request: Request) -> HTMLResponse:
def host_partial(request: Request) -> HTMLResponse:
"""``GET /host`` -- disk + runner status partial."""
"""``GET /host`` -- disk + runner status + template-backup dropdown partial."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {
"disk": _fetch_disk_status(config),
"runners": _fetch_runner_status(),
"template_names": _fetch_template_names(config),
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "partials/host.html", context)
def resources_partial(request: Request) -> HTMLResponse:
"""``GET /resources`` (B5) -- polled every 5s by the dashboard via htmx."""
templates: Jinja2Templates = request.app.state.templates
context = {"resources": _fetch_resources()}
return templates.TemplateResponse(request, "partials/resources.html", context)
def vmware_partial(request: Request) -> HTMLResponse:
"""``GET /vmware`` (B5) -- polled every 15s by the dashboard via htmx."""
templates: Jinja2Templates = request.app.state.templates
context = {"vmware": _fetch_vmware_health()}
return templates.TemplateResponse(request, "partials/vmware.html", context)
def templates_partial(request: Request) -> HTMLResponse:
"""``GET /templates`` (B5) -- polled every 30s by the dashboard via htmx."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {"templates": _fetch_templates(config)}
return templates.TemplateResponse(request, "partials/templates.html", context)
def files_partial(request: Request) -> HTMLResponse:
"""``GET /files`` (B5) -- polled every 30s by the dashboard via htmx."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {
"backups": _fetch_backups(config),
"artifacts": _fetch_artifacts(config),
}
return templates.TemplateResponse(request, "partials/files.html", context)
# ─────────────────────────────────────────────────────────────────── factory
@@ -195,7 +438,13 @@ def create_app(config: Config) -> FastAPI:
app = FastAPI(title="ci-orchestrator web")
app.state.config = config
app.state.templates = Jinja2Templates(directory=str(_WEB_DIR / "templates"))
filters.register(app.state.templates)
app.state.templates.env.filters["vmx_id"] = encode_vmx_id
# Stage C2: a Jinja *global* (not per-route context) so every template
# that extends base.html -- dashboard, audit, the action-page wrapper --
# picks up the nav's "Gitea ↗" link without each route builder having to
# remember to thread it through.
app.state.templates.env.globals["gitea_url"] = gitea_base_url(config.web)
app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static")
app.get("/healthz")(healthz)
@@ -209,6 +458,18 @@ def create_app(config: Config) -> FastAPI:
app.get("/host", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
host_partial
)
app.get("/resources", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
resources_partial
)
app.get("/vmware", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
vmware_partial
)
app.get("/templates", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
templates_partial
)
app.get("/files", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
files_partial
)
register_action_routes(app)
# SessionMiddleware is ALWAYS installed (W6) -- CSRF needs
@@ -241,8 +502,13 @@ def create_app(config: Config) -> FastAPI:
__all__ = [
"RUNNER_SERVICE_NAMES",
"HealthSummary",
"create_app",
"dashboard",
"files_partial",
"get_backend",
"healthz",
"resources_partial",
"templates_partial",
"vmware_partial",
]
+194
View File
@@ -0,0 +1,194 @@
"""Jinja2 display filters for the web dashboard (Stage A / A2).
Pure functions -- no I/O, no ``click``, no framework imports beyond the
``Jinja2Templates`` type used by :func:`register`. Every filter degrades to
a harmless placeholder (``"-"``) on ``None`` input rather than raising, so a
template can apply them unconditionally to fields that may be absent (a
degraded ``operations/`` fetch, an unset timestamp, ...).
"""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
from ci_orchestrator.operations.vms import ParsedClone, parse_clone_name
if TYPE_CHECKING: # pragma: no cover
from fastapi.templating import Jinja2Templates
#: Byte-count thresholds for :func:`humanbytes`, largest first. Base-1024
#: (binary) math, but labelled "KB/MB/GB" rather than "KiB/MiB/GiB" --
#: matches the informal convention most dashboards use.
_BYTE_UNITS: list[tuple[str, int]] = [
("GB", 1024**3),
("MB", 1024**2),
("KB", 1024),
]
_SECONDS_PER_MINUTE = 60
_SECONDS_PER_HOUR = 3600
_SECONDS_PER_DAY = 86400
def humanbytes(n: int | float) -> str:
"""Render a byte count as ``"11.8 MB"``-style text (base-1024, decimal display).
Whole-byte counts under 1 KB print with no decimal (``"500 B"``);
negative input keeps a leading ``"-"`` on the formatted magnitude.
"""
value = float(n)
sign = "-" if value < 0 else ""
value = abs(value)
for label, factor in _BYTE_UNITS:
if value >= factor:
return f"{sign}{value / factor:.1f} {label}"
if value == int(value):
return f"{sign}{int(value)} B"
return f"{sign}{value:.1f} B"
def duration(seconds: int | float | None) -> str:
"""Render a second count as ``"1h 03m"`` / ``"45s"``-style text; ``None`` -> ``"-"``.
Drops the seconds component once hours are shown (``"1h 03m"``, not
``"1h 03m 03s"``) -- deliberately coarse, this is a dashboard uptime
cell, not a stopwatch.
"""
if seconds is None:
return "-"
total = int(seconds)
sign = "-" if total < 0 else ""
total = abs(total)
if total < _SECONDS_PER_MINUTE:
return f"{sign}{total}s"
if total < _SECONDS_PER_HOUR:
minutes, secs = divmod(total, _SECONDS_PER_MINUTE)
return f"{sign}{minutes}m {secs:02d}s"
hours, remainder = divmod(total, _SECONDS_PER_HOUR)
minutes = remainder // _SECONDS_PER_MINUTE
return f"{sign}{hours}h {minutes:02d}m"
def _parse_dt(value: str | datetime | int | float | None) -> datetime | None:
"""Best-effort parse of an ISO-8601 string, epoch number, or pass-through ``datetime``.
The epoch branch (``int``/``float``, e.g. a raw ``os.stat().st_mtime`` as
stored on ``operations.inventory.FileEntry.mtime``) is interpreted as
UTC. Returns ``None`` on ``None`` input or a string that isn't valid
ISO-8601 -- callers turn that into the ``"-"`` placeholder rather than
raising.
"""
if value is None:
return None
if isinstance(value, datetime):
return value
if isinstance(value, (int, float)):
return datetime.fromtimestamp(value, tz=UTC)
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def relative_time(value: str | datetime | int | float | None) -> str:
"""Render a past timestamp as ``"2m ago"``-style relative text; ``None`` -> ``"-"``.
A naive ``datetime``/string is compared against naive "now"; an
aware one against aware "now" in the same offset -- either way this
never raises on a tz-naive/aware mismatch elsewhere in the log.
"""
dt = _parse_dt(value)
if dt is None:
return "-"
now = datetime.now(tz=dt.tzinfo) if dt.tzinfo is not None else datetime.now()
seconds = int((now - dt).total_seconds())
if seconds < 0:
seconds = 0 # a clock-skewed "future" timestamp reads as "just now"
if seconds < _SECONDS_PER_MINUTE:
return "just now"
if seconds < _SECONDS_PER_HOUR:
return f"{seconds // _SECONDS_PER_MINUTE}m ago"
if seconds < _SECONDS_PER_DAY:
return f"{seconds // _SECONDS_PER_HOUR}h ago"
return f"{seconds // _SECONDS_PER_DAY}d ago"
def localtime(value: str | datetime | int | float | None) -> str:
"""Render a timestamp in host-local time as ``"YYYY-MM-DD HH:MM"``; ``None`` -> ``"-"``.
An aware ``datetime`` converts to the host's local zone; a naive one is
treated as already-local (``datetime.astimezone()`` does exactly this
for a naive receiver, so one code path covers both).
"""
dt = _parse_dt(value)
if dt is None:
return "-"
return dt.astimezone().strftime("%Y-%m-%d %H:%M")
def shorten_path(p: str | Path, keep: int = 2) -> str:
"""Keep the last ``keep`` path parts, prefixed with ``".../"``.
A path with ``keep`` or fewer parts is returned unshortened (nothing to
elide). ``keep <= 0`` degrades to a bare ``"..."`` rather than raising
or silently returning the whole path (Python's ``parts[-0:]`` is the
*whole* tuple, not empty, so this must be handled explicitly).
"""
path = Path(p)
parts = path.parts
if keep <= 0:
return "..."
if len(parts) <= keep:
return str(path)
return ".../" + "/".join(parts[-keep:])
def thousands(n: int | float) -> str:
"""Render an integer count with thousands separators: ``12345`` -> ``"12,345"``."""
return f"{int(n):,}"
def clone_job(name: str | None) -> ParsedClone:
"""Parse a VM/clone name into its ``job_id`` + ``started`` (Stage C1).
Thin wrapper around ``operations.vms.parse_clone_name`` -- the ``None``
guard is defensive (``LiveVm.name`` itself is never ``None``) so this
still degrades to an all-``None`` :class:`ParsedClone` rather than
raising if ever applied to an absent value.
"""
if name is None:
return ParsedClone(job_id=None, started=None)
return parse_clone_name(name)
def register(templates: Jinja2Templates) -> None:
"""Register every filter above on ``templates.env`` under its own name."""
templates.env.filters["humanbytes"] = humanbytes
templates.env.filters["duration"] = duration
templates.env.filters["relative_time"] = relative_time
templates.env.filters["localtime"] = localtime
templates.env.filters["shorten_path"] = shorten_path
templates.env.filters["thousands"] = thousands
templates.env.filters["clone_job"] = clone_job
__all__ = [
"clone_job",
"duration",
"humanbytes",
"localtime",
"register",
"relative_time",
"shorten_path",
"thousands",
]
+70 -8
View File
@@ -18,6 +18,17 @@ outside ``config.paths.build_vms`` -> 400, a fresh ``vm-start.lock`` on
execute -> 409) short-circuit *before* any ``operations/`` call and are
never audited -- they are refusals to act, not actions.
**Rendering (A4)**: every confirm/result/lock-conflict partial is rendered
through :func:`_render_action`, which picks the wrapper based on the
``HX-Request`` request header -- present (htmx's own mutating forms, see
``templates/partials/{vms,cache,host}.html``) gets the bare partial back for
an inline ``hx-swap`` into ``#action-slot``; absent (a plain, no-JS form
POST -- the same forms' ``method``/``action`` fallback) gets the identical
partial body wrapped in ``templates/action_page.html`` so a full-page
navigation still lands on a themed page instead of unstyled bare HTML. The
confirm-gate semantics, CSRF, and audit trail are unchanged either way --
only the response wrapper differs.
In-process only: this module imports ``operations/`` directly and never
shells out to the CLI (see ``CLAUDE.md``).
"""
@@ -28,7 +39,7 @@ import base64
import binascii
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
@@ -37,6 +48,7 @@ from starlette import status
from ci_orchestrator.operations.audit import AuditEntry, append_audit, read_audit
from ci_orchestrator.operations.cache import purge_cargo_cache
from ci_orchestrator.operations.host import backup_template
from ci_orchestrator.operations.inventory import list_templates
from ci_orchestrator.operations.vms import RemovalResult, cleanup_orphans, remove_vm
from ci_orchestrator.web.app import get_backend
from ci_orchestrator.web.auth import require_user
@@ -142,6 +154,44 @@ def _audit(
append_audit(config.web.audit_log, entry)
def _is_htmx_request(request: Request) -> bool:
"""True iff this request was issued by htmx (``HX-Request: true``).
Absent entirely on a plain, no-JS full-page form navigation -- every
mutating form keeps its ``method``/``action`` fallback precisely so that
path still POSTs to the same route (see the A4 brief). This header is
the sole signal :func:`_render_action` uses to choose bare-partial vs.
full-page rendering.
"""
return request.headers.get("hx-request", "").lower() == "true"
def _render_action(
request: Request,
partial_template: str,
context: dict[str, Any],
*,
status_code: int = status.HTTP_200_OK,
) -> HTMLResponse:
"""Render an action partial: bare (htmx) or themed-full-page (no-JS) (A4).
Both branches render the exact same ``partial_template``/``context`` --
an htmx request gets it back bare for an inline ``hx-swap``; anything
else gets it wrapped in ``templates/action_page.html`` (which extends
``base.html`` and drops the partial body into ``#action-slot``) so a
plain form POST still lands on a styled page instead of unstyled HTML.
"""
templates: Jinja2Templates = request.app.state.templates
if _is_htmx_request(request):
return templates.TemplateResponse(
request, partial_template, context, status_code=status_code
)
wrapped_context = {**context, "partial_template": partial_template}
return templates.TemplateResponse(
request, "action_page.html", wrapped_context, status_code=status_code
)
def _confirm_partial(
request: Request,
*,
@@ -151,7 +201,6 @@ def _confirm_partial(
action_url: str,
extra_fields: dict[str, str],
) -> HTMLResponse:
templates: Jinja2Templates = request.app.state.templates
context = {
"action_label": action_label,
"target": target,
@@ -160,20 +209,21 @@ def _confirm_partial(
"extra_fields": extra_fields,
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "partials/confirm.html", context)
return _render_action(request, "partials/confirm.html", context)
def _result_partial(request: Request, *, action_label: str, ok: bool, detail: str) -> HTMLResponse:
templates: Jinja2Templates = request.app.state.templates
context = {"action_label": action_label, "ok": ok, "detail": detail}
return templates.TemplateResponse(request, "partials/result.html", context)
return _render_action(request, "partials/result.html", context)
def _lock_conflict_partial(request: Request, *, action_label: str, target: str) -> HTMLResponse:
templates: Jinja2Templates = request.app.state.templates
context = {"action_label": action_label, "target": target}
return templates.TemplateResponse(
request, "partials/lock_conflict.html", context, status_code=status.HTTP_409_CONFLICT
return _render_action(
request,
"partials/lock_conflict.html",
context,
status_code=status.HTTP_409_CONFLICT,
)
@@ -366,6 +416,18 @@ async def host_backup(
template = _form_str(form, "template")
if not template:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="template is required")
# B6: the submitted name must be one of the templates this host actually
# discovers -- rejects an arbitrary path reaching backup_template() via a
# tampered/hand-crafted form post (the dashboard's own <select> only ever
# offers these names). A pure validation refusal, so it is not audited --
# see the module docstring's "refusals to act, not actions" rule.
known_templates = {t.name for t in list_templates(config)}
if template not in known_templates:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="template is not a recognized template name",
)
execute = _form_str(form, _STAGE_FIELD) == _STAGE_EXECUTE
try:
+135 -20
View File
@@ -1,23 +1,27 @@
/* ci-orchestrator web dashboard — dark, Gitea-style theme (W7a).
/* ci-orchestrator web dashboard — dark, Gitea-style theme (W7a, retoned Stage A).
*
* Hand-written, no external framework. Design tokens per the W7 plan
* (plans/web-ui-management-plan.md, section W7a). Everything under this
* directory is served locally (`/static/app.css`) — the host LAN may be
* offline, so no `@import url(...)` to a CDN, no remote fonts.
* Hand-written, no external framework. Design tokens now mirror Gitea's
* real `gitea-auto` dark palette (Stage A / A1) rather than the placeholder
* green-accent values W7a shipped with. Everything under this directory is
* served locally (`/static/app.css`) — the host LAN may be offline, so no
* `@import url(...)` to a CDN, no remote fonts.
*/
:root {
--bg: #16191d;
--panel: #1c2126;
--panel-2: #22272e;
--border: #30363d;
--text: #d1d5da;
--text-dim: #8b949e;
--accent: #3fb950;
--accent-hover: #46c359;
--bg: #1e1f20;
--panel: #161718;
--panel-2: #2e3033;
--nav-bg: #18191b;
--card-header-bg: #1b1c1e;
--border: #3f4248;
--text: #d2d4d8;
--text-dim: #c0c2c7;
--accent: #4183c4;
--accent-hover: #5a9bd4;
--success: #3fb950;
--danger: #f85149;
--warn: #d29922;
--link: #58a6ff;
--link: #2f81f7;
--radius: 6px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -55,7 +59,7 @@ code, .mono {
/* ── navbar ───────────────────────────────────────────────────────────── */
.navbar {
background: var(--panel);
background: var(--nav-bg);
border-bottom: 1px solid var(--border);
}
@@ -94,6 +98,80 @@ code, .mono {
.content { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
/* Feedback region for htmx-swapped confirm/result/lock-conflict partials
* (A4). Empty at rest -- occupies no visible space until an action fills
* it, so it never pushes the grid around on a normal page load. */
#action-slot:empty { display: none; }
#action-slot:not(:empty) { margin-bottom: 1.25rem; }
/* ── status header (A3) ──────────────────────────────────────────────── */
.status-header {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.25rem;
}
.stat-tile {
display: flex;
align-items: flex-start;
gap: 0.75rem;
background: var(--panel);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: var(--radius);
padding: 0.9rem 1rem;
}
.stat-tile > .icon { flex-shrink: 0; margin-top: 0.15rem; color: var(--text-dim); }
.stat-tile-body { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; flex: 1; }
.stat-tile-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-dim);
}
.stat-tile-value { font-size: 1.15rem; font-weight: 600; color: var(--text); }
.stat-tile-sub { font-size: 0.8rem; font-weight: 400; color: var(--warn); }
.stat-tile-status {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* Health must never rely on color alone -- the border accent + icon +
* label all move together, so a color-blind viewer still reads the state
* from the text/icon. */
.tile-ok { border-left-color: var(--success); }
.tile-ok .stat-tile-status { color: var(--success); }
.tile-warn { border-left-color: var(--warn); }
.tile-warn .stat-tile-status { color: var(--warn); }
.tile-bad { border-left-color: var(--danger); }
.tile-bad .stat-tile-status { color: var(--danger); }
.tile-unknown { border-left-color: var(--border); }
.tile-unknown .stat-tile-status { color: var(--text-dim); }
.gauge {
width: 100%;
height: 6px;
background: var(--panel-2);
border-radius: 999px;
overflow: hidden;
margin: 0.15rem 0;
}
.gauge-fill { height: 100%; background: var(--success); }
.tile-warn .gauge-fill { background: var(--warn); }
.tile-bad .gauge-fill { background: var(--danger); }
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
@@ -116,13 +194,18 @@ code, .mono {
}
.card h2 {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
background: var(--card-header-bg);
border-bottom: 1px solid var(--border);
padding-bottom: 0.6rem;
margin-bottom: 0.9rem;
border-radius: var(--radius) var(--radius) 0 0;
margin: -1.25rem -1.25rem 0.9rem;
padding: 0.75rem 1.25rem 0.6rem;
}
.card h3 { font-size: 0.95rem; color: var(--text); }
@@ -155,6 +238,11 @@ tbody tr:hover { background: var(--panel-2); }
tr.row-orphan td { color: var(--warn); }
tr.row-alert td { color: var(--danger); }
/* ── audit filter (C3) ────────────────────────────────────────────────── */
.audit-filter { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.9rem; }
.audit-filter input[type="text"] { min-width: 260px; flex: 1; max-width: 420px; }
/* ── status badges ────────────────────────────────────────────────────── */
.badge {
@@ -168,7 +256,7 @@ tr.row-alert td { color: var(--danger); }
white-space: nowrap;
}
.badge-success { background: rgba(63, 185, 80, 0.15); color: var(--accent); border: 1px solid rgba(63, 185, 80, 0.4); }
.badge-success { background: rgba(63, 185, 80, 0.15); color: var(--success); border: 1px solid rgba(63, 185, 80, 0.4); }
.badge-danger { background: rgba(248, 81, 73, 0.15); color: var(--danger); border: 1px solid rgba(248, 81, 73, 0.4); }
.badge-warn { background: rgba(210, 153, 34, 0.15); color: var(--warn); border: 1px solid rgba(210, 153, 34, 0.4); }
@@ -192,7 +280,7 @@ tr.row-alert td { color: var(--danger); }
.btn:hover { background: var(--border); }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--accent); color: #04170a; border-color: var(--accent); }
.btn-primary { background: var(--accent); color: #ffffff; border-color: var(--accent); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
@@ -204,7 +292,7 @@ form { margin-top: 1rem; display: flex; flex-wrap: wrap; align-items: center; ga
label { font-size: 0.85rem; color: var(--text-dim); display: inline-flex; align-items: center; gap: 0.4rem; }
input[type="text"], input[type="number"] {
input[type="text"], input[type="number"], select {
background: var(--panel-2);
border: 1px solid var(--border);
color: var(--text);
@@ -214,6 +302,12 @@ input[type="text"], input[type="number"] {
font-family: inherit;
}
/* One-line field description above a form control (B6 backup dropdown). */
.field-hint { font-size: 0.8rem; color: var(--text-dim); margin: 0 0 0.5rem; }
/* Muted inline text (e.g. a relative-time suffix next to a file name). */
.text-dim { color: var(--text-dim); }
/* ── notices / confirm / result panels ───────────────────────────────── */
.notice {
@@ -236,6 +330,26 @@ input[type="text"], input[type="number"] {
.result-ok { border-color: rgba(63, 185, 80, 0.4); background: rgba(63, 185, 80, 0.08); }
.result-error { border-color: rgba(248, 81, 73, 0.4); background: rgba(248, 81, 73, 0.08); }
/* ── icon sprite (A6) ─────────────────────────────────────────────────── */
/* Sprite itself (partials/icons.html) is visually hidden; only <use>
* references render. currentColor lets every icon pick up whatever text
* color its container already has -- no per-icon color rules needed. */
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: none;
stroke: currentColor;
flex-shrink: 0;
}
.card h2 .icon,
.stat-tile > .icon {
width: 1.1em;
height: 1.1em;
}
/* ── accessibility ────────────────────────────────────────────────────── */
:focus-visible {
@@ -259,4 +373,5 @@ input[type="text"], input[type="number"] {
.navbar-inner { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
form { flex-direction: column; align-items: flex-start; }
dl { grid-template-columns: 1fr; }
.status-header { grid-template-columns: 1fr; }
}
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{#
Themed full-page wrapper for a mutating-action response (A4). Rendered
only for a plain, no-JS form POST (no ``HX-Request`` header) -- an htmx
request gets ``partial_template`` back bare instead (see
``routes.py::_render_action``). Overrides ``base.html``'s default content
block entirely (no ``{{ super() }}``) so there is exactly one
``#action-slot`` on the page, with the actual confirm/result/lock-conflict
partial dropped inside it.
#}
{% block title %}{{ action_label | default("Action") }} - ci-orchestrator{% endblock %}
{% block content %}
<div id="action-slot">
{% include partial_template %}
</div>
{% endblock %}
+7 -1
View File
@@ -8,12 +8,16 @@
<script src="/static/htmx.min.js" defer></script>
</head>
<body>
{% include "partials/icons.html" %}
{% set logged_in_user = request.session.get("user") %}
<nav class="navbar">
<div class="navbar-inner">
<a class="navbar-brand" href="/">ci-orchestrator</a>
<div class="navbar-links">
<a href="/audit">Audit log</a>
{% if gitea_url %}
<a href="{{ gitea_url }}" target="_blank" rel="noopener">Gitea <svg class="icon" aria-hidden="true"><use href="#i-external"></use></svg></a>
{% endif %}
{% if logged_in_user %}
<span class="navbar-user" aria-label="Logged in as {{ logged_in_user }}">{{ logged_in_user }}</span>
<a class="navbar-logout" href="/logout" aria-label="Log out">Logout</a>
@@ -24,7 +28,9 @@
</div>
</nav>
<main class="content">
{% block content %}{% endblock %}
{% block content %}
<div id="action-slot"></div>
{% endblock %}
</main>
</body>
</html>
@@ -3,9 +3,15 @@
{% block title %}Dashboard - ci-orchestrator{% endblock %}
{% block content %}
{{ super() }}
{% include "partials/status_header.html" %}
<div class="dashboard-grid">
{% include "partials/vms.html" %}
{% include "partials/cache.html" %}
{% include "partials/host.html" %}
{% include "partials/resources.html" %}
{% include "partials/vmware.html" %}
{% include "partials/templates.html" %}
{% include "partials/files.html" %}
</div>
{% endblock %}
@@ -4,8 +4,14 @@
{% block content %}
<section class="card" id="audit-card">
<h2>Audit log</h2>
<h2><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>Audit log</h2>
{% if entries %}
<div class="audit-filter">
<label for="audit-filter-input">Filter</label>
<input type="text" id="audit-filter-input" autocomplete="off"
placeholder="Filter by user, action, target, detail..."
aria-controls="audit-rows">
</div>
<div class="table-wrap">
<table>
<thead>
@@ -18,12 +24,17 @@
<th>Detail</th>
</tr>
</thead>
<tbody>
<tbody id="audit-rows">
{% for e in entries %}
{% set icon_id = "i-server" if e.action.startswith("vm.")
else "i-file" if e.action.startswith("cache.")
else "i-box" if e.action.startswith("host.")
else "i-gear" if e.action.startswith("service.")
else "i-alert" %}
<tr class="{{ 'row-alert' if e.result == 'error' else '' }}">
<td>{{ e.ts }}</td>
<td title="{{ e.ts | localtime }}">{{ e.ts | relative_time }}</td>
<td>{{ e.user }}</td>
<td>{{ e.action }}</td>
<td><svg class="icon" aria-hidden="true"><use href="#{{ icon_id }}"></use></svg> {{ e.action }}</td>
<td><code>{{ e.target }}</code></td>
<td>
{% if e.result == "ok" %}
@@ -44,4 +55,27 @@
<p>No audit entries yet.</p>
{% endif %}
</section>
{% if entries %}
{#
Client-side-only filter (C3): no CSP header is set anywhere in this app
(see web/app.py / security.py), so a plain inline <script> is safe here --
no backend round-trip needed for a hide/show over rows already on the
page. Case-insensitive substring match over each row's full text.
#}
<script>
(function () {
var input = document.getElementById("audit-filter-input");
var rows = document.getElementById("audit-rows");
if (!input || !rows) { return; }
input.addEventListener("input", function () {
var q = input.value.trim().toLowerCase();
var trs = rows.getElementsByTagName("tr");
for (var i = 0; i < trs.length; i++) {
var text = trs[i].textContent.toLowerCase();
trs[i].style.display = (q === "" || text.indexOf(q) !== -1) ? "" : "none";
}
});
})();
</script>
{% endif %}
{% endblock %}
@@ -1,10 +1,10 @@
<section class="card" id="cache-card" hx-get="/cache" hx-trigger="every 5s" hx-swap="outerHTML">
<h2>Cargo cache</h2>
<h2><svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>Cargo cache</h2>
{% if cache %}
<dl>
<dt>Root</dt><dd><code>{{ cache.root }}</code></dd>
<dt>Total size</dt><dd>{{ cache.total_bytes }} bytes</dd>
<dt>Entries</dt><dd>{{ cache.entry_count }}</dd>
<dt>Total size</dt><dd>{{ cache.total_bytes | humanbytes }}</dd>
<dt>Entries</dt><dd>{{ cache.entry_count | thousands }}</dd>
<dt>Oldest entry age</dt>
<dd>{{ cache.oldest_age_days ~ " day(s)" if cache.oldest_age_days is not none else "-" }}</dd>
<dt>Retention</dt><dd>{{ cache.retention_days }} day(s)</dd>
@@ -12,7 +12,8 @@
{% else %}
<p class="notice notice-warn" role="alert">Cargo cache status unavailable.</p>
{% endif %}
<form method="post" action="/cache/purge" id="cache-purge-form" aria-label="Purge cargo cache">
<form method="post" action="/cache/purge" id="cache-purge-form" aria-label="Purge cargo cache"
hx-post="/cache/purge" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" aria-label="Purge cargo cache">Purge cache</button>
</form>
@@ -2,7 +2,7 @@
<h3>Confirm: {{ action_label }}</h3>
<p>Target: <code>{{ target }}</code></p>
<p>{{ summary }}</p>
<form method="post" action="{{ action_url }}">
<form method="post" action="{{ action_url }}" hx-post="{{ action_url }}" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="stage" value="execute">
{% for key, value in extra_fields.items() %}
@@ -0,0 +1,49 @@
<div class="contents" id="files-partial" hx-get="/files" hx-trigger="every 30s" hx-swap="outerHTML">
<section class="card" id="backups-card">
<h2><svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>Template backups</h2>
{% if backups is none %}
<p class="notice notice-warn" role="alert">Backup listing unavailable.</p>
{% elif backups %}
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Size</th><th>Created</th></tr></thead>
<tbody>
{% for entry in backups %}
<tr>
<td><code>{{ entry.name }}</code></td>
<td>{{ entry.size_bytes | humanbytes }}</td>
<td>{{ entry.mtime | relative_time }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No backups found.</p>
{% endif %}
</section>
<section class="card" id="artifacts-card">
<h2><svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>Artifacts</h2>
{% if artifacts is none %}
<p class="notice notice-warn" role="alert">Artifact listing unavailable.</p>
{% elif artifacts %}
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Size</th><th>Created</th></tr></thead>
<tbody>
{% for entry in artifacts %}
<tr>
<td><code>{{ entry.name }}</code></td>
<td>{{ entry.size_bytes | humanbytes }}</td>
<td>{{ entry.mtime | relative_time }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No artifacts found.</p>
{% endif %}
</section>
</div>
@@ -1,6 +1,6 @@
<div class="contents" id="host-partial" hx-get="/host" hx-trigger="every 5s" hx-swap="outerHTML">
<section class="card" id="disk-card">
<h2>Disk</h2>
<h2><svg class="icon" aria-hidden="true"><use href="#i-disk"></use></svg>Disk</h2>
{% if disk %}
<dl>
<dt>Path</dt><dd><code>{{ disk.path }}</code></dd>
@@ -14,7 +14,7 @@
</section>
<section class="card" id="runner-card">
<h2>Runners</h2>
<h2><svg class="icon" aria-hidden="true"><use href="#i-gear"></use></svg>Runners</h2>
{% if runners %}
<div class="table-wrap">
<table>
@@ -48,11 +48,17 @@
</section>
<section class="card" id="backup-card">
<h2>Template backup</h2>
<form method="post" action="/host/backup" id="host-backup-form" aria-label="Backup template">
<h2><svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>Template backup</h2>
<p class="field-hint">7z archive of the template dir under &lt;root&gt;/backups</p>
<form method="post" action="/host/backup" id="host-backup-form" aria-label="Backup template"
hx-post="/host/backup" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<label for="host-backup-template">Template</label>
<input type="text" id="host-backup-template" name="template" required>
<select id="host-backup-template" name="template" required hx-preserve="true">
{% for name in template_names %}
<option value="{{ name }}">{{ name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-danger" aria-label="Backup template">Backup template</button>
</form>
</section>
@@ -0,0 +1,61 @@
{#
Inline SVG icon sprite (A6). One copy per page (included once from
base.html), visually hidden -- every icon elsewhere is a
<svg class="icon"><use href="#i-...">. No external requests: this is the
entire icon set, vendored as markup, not a font/CDN.
#}
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="display:none"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<symbol id="i-server" viewBox="0 0 24 24">
<rect x="2" y="3" width="20" height="7" rx="1.5"></rect>
<rect x="2" y="14" width="20" height="7" rx="1.5"></rect>
<line x1="6" y1="6.5" x2="6.01" y2="6.5"></line>
<line x1="6" y1="17.5" x2="6.01" y2="17.5"></line>
</symbol>
<symbol id="i-disk" viewBox="0 0 24 24">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M3 5v6c0 1.66 4.03 3 9 3s9-1.34 9-3V5"></path>
<path d="M3 11v6c0 1.66 4.03 3 9 3s9-1.34 9-3v-6"></path>
</symbol>
<symbol id="i-cpu" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1.5"></rect>
<rect x="10" y="1.5" width="4" height="3"></rect>
<rect x="10" y="19.5" width="4" height="3"></rect>
<rect x="1.5" y="10" width="3" height="4"></rect>
<rect x="19.5" y="10" width="3" height="4"></rect>
</symbol>
<symbol id="i-box" viewBox="0 0 24 24">
<path d="M21 8l-9-5-9 5 9 5 9-5z"></path>
<path d="M3 8v8l9 5 9-5V8"></path>
<line x1="12" y1="13" x2="12" y2="21"></line>
</symbol>
<symbol id="i-gear" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3"></circle>
<path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"></path>
</symbol>
<symbol id="i-file" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"></path>
<path d="M14 2v6h6"></path>
</symbol>
<symbol id="i-external" viewBox="0 0 24 24">
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path>
<path d="M15 3h6v6"></path>
<path d="M10 14L21 3"></path>
</symbol>
<symbol id="i-check" viewBox="0 0 24 24">
<path d="M20 6L9 17l-5-5"></path>
</symbol>
<symbol id="i-alert" viewBox="0 0 24 24">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</symbol>
</svg>
@@ -0,0 +1,21 @@
<section class="card" id="resources-card" hx-get="/resources" hx-trigger="every 5s" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-cpu"></use></svg>Host resources</h2>
{% if resources %}
<dl>
<dt>CPU</dt>
<dd>
{{ "%.1f"|format(resources.cpu_pct) }}% across {{ resources.ncpu }} core(s)
<div class="gauge" role="img" aria-label="CPU {{ '%.1f'|format(resources.cpu_pct) }} percent used">
<div class="gauge-fill" style="width: {{ resources.cpu_pct }}%"></div>
</div>
</dd>
<dt>Memory</dt>
<dd>{{ "%.1f"|format(resources.mem_used_gb) }} / {{ "%.1f"|format(resources.mem_total_gb) }} GB</dd>
<dt>Uptime</dt><dd>{{ resources.uptime_sec | duration }}</dd>
<dt>Load avg</dt>
<dd>{{ "%.2f"|format(resources.load1) }} / {{ "%.2f"|format(resources.load5) }} / {{ "%.2f"|format(resources.load15) }}</dd>
</dl>
{% else %}
<p class="notice notice-warn" role="alert">Host resource status unavailable.</p>
{% endif %}
</section>
@@ -0,0 +1,69 @@
{#
Status header (A3): a row of stat tiles fed only from the dashboard
context already fetched by app.py's ``dashboard()`` route (via
``_health_summary`` -> ``health``) -- no fetch of its own. Health is never
color-only: each tile pairs its color class with a text label and a
check/alert icon.
#}
<section class="status-header" id="status-header" aria-label="System health summary">
<div class="stat-tile {{ health.vm_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-server"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">VMs</span>
<span class="stat-tile-value">
{{ health.vm_count }}
{% if health.orphan_count %}<span class="stat-tile-sub">({{ health.orphan_count }} orphan)</span>{% endif %}
</span>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.vm_class == 'tile-ok' else 'i-alert' }}"></use></svg>
{{ health.vm_label }}
</span>
</div>
</div>
<div class="stat-tile {{ health.runner_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-gear"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">Runners</span>
<span class="stat-tile-value">{{ health.runners_active }}/{{ health.runners_total }}</span>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.runner_class == 'tile-ok' else 'i-alert' }}"></use></svg>
{{ health.runner_label }}
</span>
</div>
</div>
<div class="stat-tile {{ health.disk_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-disk"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">Disk used</span>
<span class="stat-tile-value">
{{ "%.1f"|format(health.disk_used_pct) ~ "%" if health.disk_used_pct is not none else "-" }}
</span>
<div class="gauge" role="img"
aria-label="Disk {{ '%.1f'|format(health.disk_used_pct) ~ ' percent used' if health.disk_used_pct is not none else 'usage unknown' }}">
<div class="gauge-fill" style="width: {{ health.disk_used_pct if health.disk_used_pct is not none else 0 }}%"></div>
</div>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.disk_class == 'tile-ok' else 'i-alert' }}"></use></svg>
{{ health.disk_label }}
</span>
</div>
</div>
<div class="stat-tile {{ health.cache_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">Cache size</span>
<span class="stat-tile-value">
{{ health.cache_bytes | humanbytes if health.cache_bytes is not none else "-" }}
</span>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.cache_class == 'tile-ok' else 'i-alert' }}"></use></svg>
{{ health.cache_label }}
</span>
</div>
</div>
</section>
@@ -0,0 +1,49 @@
<section class="card" id="templates-card" hx-get="/templates" hx-trigger="every 30s" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>Templates</h2>
{% if templates is none %}
<p class="notice notice-warn" role="alert">Template inventory unavailable.</p>
{% elif templates %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Guest OS</th>
<th>Transport</th>
<th>Size</th>
<th>Snapshot</th>
<th>Last backup</th>
</tr>
</thead>
<tbody>
{% for tmpl in templates %}
<tr class="{{ 'row-alert' if tmpl.has_vmem else '' }}">
<td>{{ tmpl.name }}</td>
<td>{{ tmpl.guest_os or "-" }}</td>
<td>{{ tmpl.transport }}</td>
<td>{{ tmpl.dir_bytes | humanbytes }}</td>
<td>
{% if tmpl.has_vmem %}
<span class="badge badge-warn" title="Snapshot captured running RAM">
<svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>running-RAM snapshot
</span>
{% else %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>clean</span>
{% endif %}
</td>
<td>
{% if tmpl.last_backup %}
{{ tmpl.last_backup.name }} <span class="text-dim">({{ tmpl.last_backup.mtime | relative_time }})</span>
{% else %}
&mdash;
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No templates found under the templates directory.</p>
{% endif %}
</section>
@@ -1,5 +1,5 @@
<section class="card" id="vms-card" hx-get="/vms" hx-trigger="every 5s" hx-swap="outerHTML">
<h2>Live VMs</h2>
<h2><svg class="icon" aria-hidden="true"><use href="#i-server"></use></svg>Live VMs</h2>
{% if not backend_available %}
<p class="notice notice-warn" role="alert">Backend unavailable (vmrun not found on this host).</p>
{% endif %}
@@ -9,22 +9,30 @@
<thead>
<tr>
<th>Name</th>
<th>Job</th>
<th>VMX</th>
<th>IP</th>
<th>Guest OS</th>
<th>Uptime (s)</th>
<th>Uptime</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for vm in vms %}
{% set parsed = vm.name | clone_job %}
<tr class="{{ 'row-orphan' if vm.is_orphan else '' }}">
<td>{{ vm.name }}</td>
<td>
{{ parsed.job_id or "—" }}
{% if parsed.started %}
<br><span class="text-dim" title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</span>
{% endif %}
</td>
<td><code>{{ vm.vmx }}</code></td>
<td>{{ vm.ip or "-" }}</td>
<td>{{ vm.guest_os or "-" }}</td>
<td>{{ vm.uptime_sec if vm.uptime_sec is not none else "-" }}</td>
<td>{{ vm.uptime_sec | duration }}</td>
<td>
{% if vm.is_orphan %}
<span class="badge badge-warn">orphan</span>
@@ -33,7 +41,8 @@
{% endif %}
</td>
<td>
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove">
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove"
hx-post="/vms/{{ vm.vmx | vmx_id }}/remove" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" aria-label="Remove VM {{ vm.name }}">Remove</button>
</form>
@@ -46,10 +55,11 @@
{% else %}
<p>No VMs currently running.</p>
{% endif %}
<form method="post" action="/vms/cleanup" id="vms-cleanup-form" aria-label="Cleanup orphaned VMs">
<form method="post" action="/vms/cleanup" id="vms-cleanup-form" aria-label="Cleanup orphaned VMs"
hx-post="/vms/cleanup" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<label for="vms-cleanup-max-age">Max age (h)</label>
<input type="number" id="vms-cleanup-max-age" name="max_age_hours" value="4" min="0">
<input type="number" id="vms-cleanup-max-age" name="max_age_hours" value="4" min="0" hx-preserve="true">
<button type="submit" class="btn btn-danger" aria-label="Cleanup orphaned VMs older than the given age">Cleanup orphans</button>
</form>
</section>
@@ -0,0 +1,34 @@
<section class="card" id="vmware-card" hx-get="/vmware" hx-trigger="every 15s" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>VMware modules</h2>
{% if vmware %}
<dl>
<dt>vmmon</dt>
<dd>
{% if vmware.vmmon_loaded %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>loaded</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>not loaded</span>
{% endif %}
</dd>
<dt>vmnet</dt>
<dd>
{% if vmware.vmnet_loaded %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>loaded</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>not loaded</span>
{% endif %}
</dd>
<dt>vmware-host.service</dt>
<dd>
{% if vmware.host_service_active %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>active</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>down</span>
{% endif %}
<span class="text-dim">({{ vmware.host_service_sub_state }})</span>
</dd>
</dl>
{% else %}
<p class="notice notice-warn" role="alert">VMware module status unavailable.</p>
{% endif %}
</section>
+70 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from pathlib import Path
from ci_orchestrator.config import WebConfig, WebOidcConfig, load_config
from ci_orchestrator.config import WebConfig, WebOidcConfig, gitea_base_url, load_config
def _paths_section() -> str:
@@ -182,3 +182,72 @@ def test_toml_secret_looking_value_is_never_surfaced_as_a_secret(tmp_path: Path)
# No attribute on WebOidcConfig can ever hold "should-never-be-read".
for value in vars(cfg.web.oidc).values():
assert value != "should-never-be-read"
# ── [web].gitea_url (Stage C2) ───────────────────────────────────────────────
def test_gitea_url_absent_from_toml_defaults_to_none() -> None:
assert WebConfig().gitea_url is None
def test_gitea_url_explicit_toml_value_populates_field(tmp_path: Path) -> None:
extra = '\n[web]\ngitea_url = "https://gitea.example.lan"\n'
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.gitea_url == "https://gitea.example.lan"
def test_gitea_base_url_explicit_override_wins_over_issuer(tmp_path: Path) -> None:
extra = (
"\n[web]\n"
'gitea_url = "https://gitea.explicit.lan"\n'
"\n[web.oidc]\n"
'issuer = "https://gitea.emulab.it"\n'
'client_id = "ci-web"\n'
'redirect_url = "http://192.168.1.50:8800/auth/callback"\n'
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert gitea_base_url(cfg.web) == "https://gitea.explicit.lan"
def test_gitea_base_url_derives_origin_from_oidc_issuer(tmp_path: Path) -> None:
extra = (
"\n[web.oidc]\n"
'issuer = "https://gitea.emulab.it"\n'
'client_id = "ci-web"\n'
'redirect_url = "http://192.168.1.50:8800/auth/callback"\n'
)
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
assert cfg.web.gitea_url is None # not set explicitly
assert gitea_base_url(cfg.web) == "https://gitea.emulab.it"
def test_gitea_base_url_strips_issuer_path_and_query() -> None:
web = WebConfig(
oidc=WebOidcConfig(
issuer="https://gitea.emulab.it/some/path?query=1",
client_id="ci-web",
redirect_url="http://127.0.0.1:8800/auth/callback",
)
)
assert gitea_base_url(web) == "https://gitea.emulab.it"
def test_gitea_base_url_none_when_oidc_unset_and_no_explicit_url() -> None:
assert gitea_base_url(WebConfig()) is None
def test_gitea_base_url_none_when_issuer_unparseable() -> None:
web = WebConfig(
oidc=WebOidcConfig(
issuer="not-a-url",
client_id="ci-web",
redirect_url="http://127.0.0.1:8800/auth/callback",
)
)
assert gitea_base_url(web) is None
+244
View File
@@ -0,0 +1,244 @@
"""Tests for the Stage B (B1/B2) ``operations.host`` additions:
``host_resources`` and ``vmware_module_health``.
Everything reads through the ``_read_proc`` seam -- monkeypatched per test to
fixture strings, never a real ``/proc``. ``time.sleep`` is also patched to a
no-op so the two-sample CPU read never actually waits ``cpu_sample_ms``.
"""
from __future__ import annotations
from typing import Any
import pytest
import ci_orchestrator.operations.host as host_module
from ci_orchestrator.operations.host import (
HostResources,
RunnerStatus,
VmwareModuleHealth,
host_resources,
vmware_module_health,
)
# ── fixture /proc/stat samples ──────────────────────────────────────────────
# Columns: user nice system idle iowait irq softirq steal guest guest_nice.
# Sample 1: idle=8000+0=8000, total=1000+0+1000+8000=10000
# Sample 2: idle=8200+0=8200, total=1100+0+1100+8200=10400
# idle_delta=200, total_delta=400 -> cpu_pct = 100*(1 - 200/400) = 50.0
_STAT_1 = "cpu 1000 0 1000 8000 0 0 0 0 0 0\ncpu0 1000 0 1000 8000 0 0 0 0 0 0\n"
_STAT_2 = "cpu 1100 0 1100 8200 0 0 0 0 0 0\ncpu0 1100 0 1100 8200 0 0 0 0 0 0\n"
_MEMINFO = (
"MemTotal: 16384000 kB\n"
"MemFree: 2000000 kB\n"
"MemAvailable: 4096000 kB\n"
"Buffers: 100000 kB\n"
)
# total_gb = 16384000/1024**2 = 15.625; used = (16384000-4096000)/1024**2 = 11.71875
_UPTIME = "123456.78 987654.32\n"
_LOADAVG = "0.50 0.75 1.00 3/456 7890\n"
def _fake_reader(calls: dict[str, int]) -> Any:
def _read(path: str) -> str:
calls[path] = calls.get(path, 0) + 1
if path == "/proc/stat":
return _STAT_1 if calls[path] == 1 else _STAT_2
if path == "/proc/meminfo":
return _MEMINFO
if path == "/proc/uptime":
return _UPTIME
if path == "/proc/loadavg":
return _LOADAVG
raise AssertionError(f"unexpected proc path: {path}")
return _read
def _install_fake_proc(monkeypatch: pytest.MonkeyPatch) -> dict[str, int]:
calls: dict[str, int] = {}
monkeypatch.setattr(host_module, "_read_proc", _fake_reader(calls))
monkeypatch.setattr(host_module.time, "sleep", lambda _seconds: None)
return calls
# ── host_resources: happy path, two-sample CPU math ─────────────────────────
def test_host_resources_two_sample_cpu_math(monkeypatch: pytest.MonkeyPatch) -> None:
calls = _install_fake_proc(monkeypatch)
monkeypatch.setattr(host_module.os, "cpu_count", lambda: 8)
result = host_resources(cpu_sample_ms=150)
assert result.cpu_pct == pytest.approx(50.0)
assert result.ncpu == 8
assert calls["/proc/stat"] == 2 # exactly two samples, per the B1 spec
def test_host_resources_reads_proc_stat_twice_with_a_sleep_between(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = _install_fake_proc(monkeypatch)
sleeps: list[float] = []
monkeypatch.setattr(host_module.time, "sleep", lambda seconds: sleeps.append(seconds))
host_resources(cpu_sample_ms=200)
assert calls["/proc/stat"] == 2
assert sleeps == [pytest.approx(0.2)]
def test_host_resources_mem_used_is_total_minus_available(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install_fake_proc(monkeypatch)
result = host_resources()
assert result.mem_total_gb == pytest.approx(16384000 / 1024**2)
assert result.mem_used_gb == pytest.approx((16384000 - 4096000) / 1024**2)
def test_host_resources_uptime_and_loadavg(monkeypatch: pytest.MonkeyPatch) -> None:
_install_fake_proc(monkeypatch)
result = host_resources()
assert result.uptime_sec == 123456
assert result.load1 == pytest.approx(0.50)
assert result.load5 == pytest.approx(0.75)
assert result.load15 == pytest.approx(1.00)
# ── host_resources: degrade to zeros on OSError, never raise ────────────────
def test_host_resources_degrades_to_zeros_on_oserror(monkeypatch: pytest.MonkeyPatch) -> None:
def _boom(_path: str) -> str:
raise OSError("no /proc on this platform")
monkeypatch.setattr(host_module, "_read_proc", _boom)
monkeypatch.setattr(host_module.os, "cpu_count", lambda: 4)
result = host_resources()
assert result == HostResources(
cpu_pct=0.0,
ncpu=4,
mem_total_gb=0.0,
mem_used_gb=0.0,
uptime_sec=0,
load1=0.0,
load5=0.0,
load15=0.0,
)
def test_host_resources_degrades_to_zeros_when_second_stat_sample_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The first ``/proc/stat`` read can succeed and the second still fail."""
calls = {"n": 0}
def _flaky(path: str) -> str:
if path != "/proc/stat":
raise AssertionError("should not reach meminfo after stat blows up")
calls["n"] += 1
if calls["n"] == 1:
return _STAT_1
raise OSError("gone")
monkeypatch.setattr(host_module, "_read_proc", _flaky)
monkeypatch.setattr(host_module.time, "sleep", lambda _s: None)
result = host_resources()
assert result.cpu_pct == 0.0
assert result.uptime_sec == 0
# ── vmware_module_health: /proc/modules parsing ─────────────────────────────
def _fake_runner_status(statuses: list[RunnerStatus]) -> Any:
def _query(_names: list[str]) -> list[RunnerStatus]:
return statuses
return _query
def test_vmware_module_health_both_modules_loaded(monkeypatch: pytest.MonkeyPatch) -> None:
modules_text = "vmmon 57344 1 - Live 0x0000000000000000\nvmnet 69632 3 - Live 0x0000000000000000\n"
monkeypatch.setattr(host_module, "_read_proc", lambda _path: modules_text)
monkeypatch.setattr(
host_module,
"runner_status",
_fake_runner_status([RunnerStatus(name="vmware-host.service", active=True, sub_state="running")]),
)
result = vmware_module_health()
assert result == VmwareModuleHealth(
vmmon_loaded=True,
vmnet_loaded=True,
host_service_active=True,
host_service_sub_state="running",
)
def test_vmware_module_health_modules_absent(monkeypatch: pytest.MonkeyPatch) -> None:
modules_text = "usbcore 300000 4 - Live 0x0000000000000000\n"
monkeypatch.setattr(host_module, "_read_proc", lambda _path: modules_text)
monkeypatch.setattr(
host_module,
"runner_status",
_fake_runner_status([RunnerStatus(name="vmware-host.service", active=False, sub_state="dead")]),
)
result = vmware_module_health()
assert result.vmmon_loaded is False
assert result.vmnet_loaded is False
assert result.host_service_active is False
assert result.host_service_sub_state == "dead"
def test_vmware_module_health_degrades_on_proc_modules_oserror(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _boom(_path: str) -> str:
raise OSError("no /proc/modules")
monkeypatch.setattr(host_module, "_read_proc", _boom)
monkeypatch.setattr(
host_module,
"runner_status",
_fake_runner_status([RunnerStatus(name="vmware-host.service", active=True, sub_state="running")]),
)
result = vmware_module_health()
assert result.vmmon_loaded is False
assert result.vmnet_loaded is False
# A /proc/modules failure must not also blank the (independent) service query.
assert result.host_service_active is True
def test_vmware_module_health_degrades_when_runner_status_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(host_module, "_read_proc", lambda _path: "vmmon 1 1 - Live 0x0\n")
def _boom(_names: list[str]) -> list[RunnerStatus]:
raise RuntimeError("systemctl vanished")
monkeypatch.setattr(host_module, "runner_status", _boom)
result = vmware_module_health()
assert result.vmmon_loaded is True
assert result.host_service_active is False
assert result.host_service_sub_state == "unknown"
+503
View File
@@ -0,0 +1,503 @@
"""Tests for the Stage B (B3/B4) ``operations.inventory`` module.
Template discovery (``list_templates``) and containment-checked directory
listings (``list_dir``/``list_backups``/``list_artifacts``). No real ``7z``/
``vmrun`` involved -- everything is plain files under ``tmp_path``.
"""
from __future__ import annotations
import os
import time
from pathlib import Path
import pytest
import ci_orchestrator.operations.inventory as inventory_module
from ci_orchestrator.config import BackendConfig, Config, Paths
from ci_orchestrator.operations.inventory import (
FileEntry,
TemplateInfo,
list_artifacts,
list_backups,
list_dir,
list_templates,
)
def _make_config(root: Path) -> Config:
return Config(
paths=Paths(
root=root,
templates=root / "templates",
build_vms=root / "build-vms",
artifacts=root / "artifacts",
keys=root / "keys",
),
backend=BackendConfig(),
)
@pytest.fixture(autouse=True)
def _clear_dir_bytes_cache() -> None:
"""The B7 ``dir_bytes`` cache is module-level -- start each test clean."""
inventory_module._dir_bytes_cache.clear()
# ── list_templates ───────────────────────────────────────────────────────────
def _write_vmx(path: Path, guest_os: str) -> None:
path.write_text(f'.encoding = "UTF-8"\nguestOS = "{guest_os}"\n', encoding="utf-8")
def test_list_templates_missing_dir_is_empty(tmp_path: Path) -> None:
config = _make_config(tmp_path) # templates dir never created
assert list_templates(config) == []
def test_list_templates_parses_guest_os_and_transport_ssh(tmp_path: Path) -> None:
tmpl_dir = tmp_path / "templates" / "LinuxBuild2404"
tmpl_dir.mkdir(parents=True)
_write_vmx(tmpl_dir / "LinuxBuild2404.vmx", "ubuntu-64")
config = _make_config(tmp_path)
[info] = list_templates(config)
assert info.name == "LinuxBuild2404"
assert info.vmx == tmpl_dir / "LinuxBuild2404.vmx"
assert info.guest_os == "ubuntu-64"
assert info.transport == "ssh"
def test_list_templates_windows_guest_os_is_winrm_transport(tmp_path: Path) -> None:
tmpl_dir = tmp_path / "templates" / "WinBuild2025"
tmpl_dir.mkdir(parents=True)
_write_vmx(tmpl_dir / "WinBuild2025.vmx", "windows9srv-64")
config = _make_config(tmp_path)
[info] = list_templates(config)
assert info.transport == "winrm"
def test_list_templates_no_vmx_is_unknown_transport(tmp_path: Path) -> None:
tmpl_dir = tmp_path / "templates" / "Empty"
tmpl_dir.mkdir(parents=True)
config = _make_config(tmp_path)
[info] = list_templates(config)
assert info.vmx is None
assert info.guest_os is None
assert info.transport == "unknown"
def test_list_templates_has_vmem_true_when_present(tmp_path: Path) -> None:
tmpl_dir = tmp_path / "templates" / "WithVmem"
tmpl_dir.mkdir(parents=True)
_write_vmx(tmpl_dir / "WithVmem.vmx", "ubuntu-64")
(tmpl_dir / "WithVmem.vmem").write_bytes(b"\x00" * 16)
config = _make_config(tmp_path)
[info] = list_templates(config)
assert info.has_vmem is True
def test_list_templates_has_vmem_false_when_absent(tmp_path: Path) -> None:
tmpl_dir = tmp_path / "templates" / "Clean"
tmpl_dir.mkdir(parents=True)
_write_vmx(tmpl_dir / "Clean.vmx", "ubuntu-64")
config = _make_config(tmp_path)
[info] = list_templates(config)
assert info.has_vmem is False
def test_list_templates_dir_bytes_sums_file_sizes(tmp_path: Path) -> None:
tmpl_dir = tmp_path / "templates" / "Sized"
nested = tmpl_dir / "nested"
nested.mkdir(parents=True)
(tmpl_dir / "a.vmdk").write_bytes(b"x" * 100)
(nested / "b.vmdk").write_bytes(b"y" * 50)
config = _make_config(tmp_path)
[info] = list_templates(config)
assert info.dir_bytes == 150
def test_list_templates_multiple_entries_sorted_by_name(tmp_path: Path) -> None:
for name in ("WinBuild2022", "LinuxBuild2404", "WinBuild2025"):
(tmp_path / "templates" / name).mkdir(parents=True)
config = _make_config(tmp_path)
names = [t.name for t in list_templates(config)]
assert names == sorted(names)
def test_list_templates_last_backup_picks_newest_matching_archive(tmp_path: Path) -> None:
tmpl_dir = tmp_path / "templates" / "LinuxBuild2404"
tmpl_dir.mkdir(parents=True)
backups_dir = tmp_path / "backups"
backups_dir.mkdir()
older = backups_dir / "Template_LinuxBuild2404_20260101_000000.7z"
newer = backups_dir / "Template_LinuxBuild2404_20260601_000000.7z"
unrelated = backups_dir / "Template_OtherTemplate_20260701_000000.7z"
older.write_bytes(b"old")
unrelated.write_bytes(b"other")
time.sleep(0.01)
newer.write_bytes(b"new")
config = _make_config(tmp_path)
[info] = list_templates(config)
assert info.last_backup is not None
assert info.last_backup.name == "Template_LinuxBuild2404_20260601_000000.7z"
def test_list_templates_last_backup_none_when_no_backups_dir(tmp_path: Path) -> None:
(tmp_path / "templates" / "LinuxBuild2404").mkdir(parents=True)
config = _make_config(tmp_path)
[info] = list_templates(config)
assert info.last_backup is None
def test_list_templates_skips_regular_files_directly_under_templates(tmp_path: Path) -> None:
templates_dir = tmp_path / "templates"
templates_dir.mkdir(parents=True)
(templates_dir / "README.txt").write_text("not a template", encoding="utf-8")
(templates_dir / "Real").mkdir()
config = _make_config(tmp_path)
names = [t.name for t in list_templates(config)]
assert names == ["Real"]
def test_list_templates_dir_bytes_is_cached(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
tmpl_dir = tmp_path / "templates" / "Cached"
tmpl_dir.mkdir(parents=True)
(tmpl_dir / "a.vmdk").write_bytes(b"x" * 10)
config = _make_config(tmp_path)
walk_calls: list[Path] = []
real_walk = os.walk
def _counting_walk(path: object, *a: object, **kw: object) -> object:
walk_calls.append(Path(str(path)))
return real_walk(path, *a, **kw) # type: ignore[arg-type]
monkeypatch.setattr(inventory_module.os, "walk", _counting_walk)
first = list_templates(config)[0].dir_bytes
second = list_templates(config)[0].dir_bytes
assert first == second == 10
assert len(walk_calls) == 1, "second call must hit the TTL cache, not os.walk again"
# ── list_dir / containment ───────────────────────────────────────────────────
def test_list_dir_accepts_root_itself(tmp_path: Path) -> None:
(tmp_path / "f1.txt").write_bytes(b"a")
entries = list_dir(tmp_path, root=tmp_path)
assert [e.name for e in entries] == ["f1.txt"]
def test_list_dir_accepts_real_subdir(tmp_path: Path) -> None:
sub = tmp_path / "backups"
sub.mkdir()
(sub / "f1.7z").write_bytes(b"a")
entries = list_dir(sub, root=tmp_path, pattern="*.7z")
assert [e.name for e in entries] == ["f1.7z"]
def test_list_dir_rejects_base_outside_root(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(outside / "secret.txt").write_bytes(b"nope")
assert list_dir(outside, root=root) == []
def test_list_dir_rejects_dotdot_escape(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(outside / "secret.txt").write_bytes(b"nope")
escaping = root / ".." / "outside"
assert list_dir(escaping, root=root) == []
def test_list_dir_rejects_symlink_escaping_root(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(outside / "secret.txt").write_bytes(b"nope")
link = root / "escape_link"
link.symlink_to(outside, target_is_directory=True)
assert list_dir(link, root=root) == []
def test_list_dir_missing_base_is_empty(tmp_path: Path) -> None:
assert list_dir(tmp_path / "does-not-exist", root=tmp_path) == []
def test_list_dir_sorted_newest_first(tmp_path: Path) -> None:
older = tmp_path / "a.txt"
newer = tmp_path / "b.txt"
older.write_bytes(b"old")
time.sleep(0.01)
newer.write_bytes(b"new")
entries = list_dir(tmp_path, root=tmp_path)
assert [e.name for e in entries] == ["b.txt", "a.txt"]
def test_list_dir_pattern_filters_extension(tmp_path: Path) -> None:
(tmp_path / "keep.7z").write_bytes(b"x")
(tmp_path / "skip.txt").write_bytes(b"y")
entries = list_dir(tmp_path, root=tmp_path, pattern="*.7z")
assert [e.name for e in entries] == ["keep.7z"]
def test_list_dir_entry_shape(tmp_path: Path) -> None:
f = tmp_path / "x.7z"
f.write_bytes(b"12345")
[entry] = list_dir(tmp_path, root=tmp_path)
assert isinstance(entry, FileEntry)
assert entry.size_bytes == 5
assert entry.mtime == pytest.approx(f.stat().st_mtime)
# ── list_backups / list_artifacts ────────────────────────────────────────────
def test_list_backups_reads_root_backups_dir(tmp_path: Path) -> None:
backups = tmp_path / "backups"
backups.mkdir()
(backups / "Template_X_20260101_000000.7z").write_bytes(b"x")
(backups / "notes.txt").write_bytes(b"ignored")
config = _make_config(tmp_path)
entries = list_backups(config)
assert [e.name for e in entries] == ["Template_X_20260101_000000.7z"]
def test_list_backups_missing_dir_is_empty(tmp_path: Path) -> None:
config = _make_config(tmp_path)
assert list_backups(config) == []
def test_list_artifacts_reads_configured_artifacts_dir(tmp_path: Path) -> None:
config = _make_config(tmp_path)
config.paths.artifacts.mkdir(parents=True)
(config.paths.artifacts / "build.log").write_bytes(b"log")
entries = list_artifacts(config)
assert [e.name for e in entries] == ["build.log"]
def test_list_artifacts_outside_root_is_rejected(tmp_path: Path) -> None:
"""``config.paths.artifacts`` pointed outside ``root`` must still 0-list.
``outside_artifacts`` sits beside ``root`` (both under ``tmp_path``) --
a sibling directory, not a descendant -- so containment must reject it.
"""
root = tmp_path / "root"
root.mkdir()
outside_artifacts = tmp_path / "artifacts-outside"
outside_artifacts.mkdir()
(outside_artifacts / "build.log").write_bytes(b"log")
config = Config(
paths=Paths(
root=root,
templates=root / "templates",
build_vms=root / "build-vms",
artifacts=outside_artifacts,
keys=root / "keys",
),
backend=BackendConfig(),
)
assert list_artifacts(config) == []
# ── defensive OSError degrade paths ("never raise") ─────────────────────────
class _BoomIterdir:
"""Duck-typed stand-in for a ``Path`` whose ``iterdir()`` blows up."""
def iterdir(self) -> list[Path]:
raise OSError("denied")
class _BoomGlob:
"""Duck-typed stand-in for a ``Path`` whose ``glob()`` blows up."""
def glob(self, _pattern: str) -> list[Path]:
raise OSError("denied")
class _BoomReadText:
"""Duck-typed stand-in for a ``Path`` whose ``read_text()`` blows up."""
def read_text(self, encoding: str = "utf-8", errors: str = "strict") -> str:
raise OSError("denied")
class _BoomResolve:
"""Duck-typed stand-in for a ``Path`` whose ``resolve()`` blows up."""
def resolve(self) -> Path:
raise OSError("denied")
def test_find_vmx_oserror_returns_none() -> None:
assert inventory_module._find_vmx(_BoomIterdir()) is None # type: ignore[arg-type]
def test_has_vmem_oserror_returns_false() -> None:
assert inventory_module._has_vmem(_BoomGlob()) is False # type: ignore[arg-type]
def test_read_guest_os_oserror_returns_empty_string() -> None:
assert inventory_module._read_guest_os(_BoomReadText()) == "" # type: ignore[arg-type]
def test_dir_bytes_cached_resolve_oserror_returns_zero() -> None:
assert inventory_module._dir_bytes_cached(_BoomResolve()) == 0 # type: ignore[arg-type]
def test_newest_backup_glob_oserror_returns_none(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
backups_dir = tmp_path / "backups"
backups_dir.mkdir()
monkeypatch.setattr(Path, "glob", lambda self, pattern: (_ for _ in ()).throw(OSError("denied")))
assert inventory_module._newest_backup(backups_dir, "X") is None
def test_dir_bytes_cached_walk_oserror_degrades_to_zero(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
tmpl_dir = tmp_path / "T"
tmpl_dir.mkdir()
(tmpl_dir / "a.txt").write_bytes(b"x" * 5)
def _boom_walk(_path: Path, *_a: object, **_kw: object) -> object:
raise OSError("walk exploded")
monkeypatch.setattr(inventory_module.os, "walk", _boom_walk)
assert inventory_module._dir_bytes_cached(tmpl_dir) == 0
def test_dir_bytes_cache_recomputes_after_ttl_expiry(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""B7: a cache entry older than the TTL must be recomputed, not reused forever."""
tmpl_dir = tmp_path / "templates" / "Stale"
tmpl_dir.mkdir(parents=True)
(tmpl_dir / "a.vmdk").write_bytes(b"x" * 10)
config = _make_config(tmp_path)
clock = {"t": 0.0}
monkeypatch.setattr(inventory_module.time, "monotonic", lambda: clock["t"])
walk_calls: list[Path] = []
real_walk = os.walk
def _counting_walk(path: object, *a: object, **kw: object) -> object:
walk_calls.append(Path(str(path)))
return real_walk(path, *a, **kw) # type: ignore[arg-type]
monkeypatch.setattr(inventory_module.os, "walk", _counting_walk)
first = list_templates(config)[0].dir_bytes
clock["t"] += inventory_module._DIR_BYTES_TTL_SECONDS + 1.0
second = list_templates(config)[0].dir_bytes
assert first == second == 10
assert len(walk_calls) == 2, "an expired cache entry must trigger a fresh os.walk"
def test_list_templates_iterdir_oserror_returns_empty(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
templates_dir = tmp_path / "templates"
templates_dir.mkdir()
config = _make_config(tmp_path)
original_iterdir = Path.iterdir
def _boom_iterdir(self: Path) -> object:
if self == templates_dir:
raise OSError("denied")
return original_iterdir(self)
monkeypatch.setattr(Path, "iterdir", _boom_iterdir)
assert list_templates(config) == []
def test_list_dir_resolve_oserror_returns_empty(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(Path, "resolve", lambda self: (_ for _ in ()).throw(OSError("denied")))
assert list_dir(tmp_path, root=tmp_path) == []
def test_list_dir_skips_subdirectories(tmp_path: Path) -> None:
(tmp_path / "subdir").mkdir()
(tmp_path / "file.txt").write_bytes(b"x")
entries = list_dir(tmp_path, root=tmp_path)
assert [e.name for e in entries] == ["file.txt"]
def test_template_info_is_frozen_dataclass() -> None:
info = TemplateInfo(
name="X",
vmx=None,
guest_os=None,
transport="unknown",
dir_bytes=0,
has_vmem=False,
last_backup=None,
)
with pytest.raises(AttributeError):
info.name = "Y" # type: ignore[misc]
+59 -1
View File
@@ -9,7 +9,7 @@ coverage for ``list_live_vms`` and ``remove_vm``.
from __future__ import annotations
import time
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
@@ -18,13 +18,16 @@ import pytest
import ci_orchestrator.operations.vms as vms_module
from ci_orchestrator.backends.errors import BackendOperationFailed
from ci_orchestrator.backends.protocol import VmHandle
from ci_orchestrator.commands.job import _make_clone_name
from ci_orchestrator.config import BackendConfig, Config, Paths
from ci_orchestrator.operations.vms import (
Orphan,
ParsedClone,
RemovalResult,
cleanup_orphans,
list_live_vms,
list_orphans,
parse_clone_name,
remove_vm,
)
@@ -506,3 +509,58 @@ def test_reconcile_ip_leases_unreadable_base_is_noop(tmp_path: Path) -> None:
leases = json.loads((tmp_path / "ip-pool.json").read_text())
assert leases["10.0.0.1"] == "clone-dead"
# ── parse_clone_name (Stage C1) ───────────────────────────────────────────
def test_parse_clone_name_round_trips_real_make_clone_name() -> None:
"""A real ``_make_clone_name`` output must invert cleanly."""
before = datetime.now(tz=UTC).replace(microsecond=0)
name = _make_clone_name("build-42")
after = datetime.now(tz=UTC).replace(microsecond=0)
parsed = parse_clone_name(name)
assert parsed.job_id == "build-42"
assert parsed.started is not None
# started is a naive datetime built from UTC wall-clock digits -- compare
# against naive bounds taken from an aware UTC "now" either side of the call.
assert before.replace(tzinfo=None) <= parsed.started <= after.replace(tzinfo=None) + timedelta(seconds=1)
def test_parse_clone_name_job_id_containing_underscore() -> None:
parsed = parse_clone_name("Clone_my_job_42_20250101_123456_abc123")
assert parsed.job_id == "my_job_42"
assert parsed.started == datetime(2025, 1, 1, 12, 34, 56)
def test_parse_clone_name_non_clone_prefix_degrades_both_to_none() -> None:
assert parse_clone_name("SomeOtherVMName") == ParsedClone(job_id=None, started=None)
def test_parse_clone_name_too_few_parts_degrades_both_to_none() -> None:
# No room for date + time + suffix at all.
assert parse_clone_name("Clone_short") == ParsedClone(job_id=None, started=None)
def test_parse_clone_name_garbage_timestamp_degrades_started_only() -> None:
parsed = parse_clone_name("Clone_myjob_notadate_notatime_abc123")
assert parsed.job_id == "myjob"
assert parsed.started is None
def test_parse_clone_name_empty_job_id_degrades_to_none() -> None:
# No text between "Clone_" and the timestamp.
parsed = parse_clone_name("Clone_20250101_123456_abc123")
assert parsed.job_id is None
assert parsed.started == datetime(2025, 1, 1, 12, 34, 56)
def test_parse_clone_name_wrong_length_date_degrades_started_only() -> None:
parsed = parse_clone_name("Clone_job1_2025010_123456_abc123")
assert parsed.started is None
+10 -5
View File
@@ -759,7 +759,11 @@ def test_host_backup_missing_template_field_is_400(tmp_path: Path) -> None:
assert resp.status_code == 400
def test_host_backup_preview_missing_template_dir_is_error_partial(tmp_path: Path) -> None:
def test_host_backup_unknown_template_name_is_400_not_audited(tmp_path: Path) -> None:
"""B6: a template name absent from ``list_templates()`` is rejected before
``backup_template`` is ever called -- a pure validation refusal, so (like
the VM-remove path-containment check) it is never audited.
"""
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
@@ -767,10 +771,8 @@ def test_host_backup_preview_missing_template_dir_is_error_partial(tmp_path: Pat
resp = client.post("/host/backup", data={"csrf": csrf, "template": "NoSuchTemplate"})
assert resp.status_code == 200
assert "error" in resp.text.lower()
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "error"
assert resp.status_code == 400
assert not config.web.audit_log.exists()
def test_host_backup_preview_then_execute_full_cycle(
@@ -812,6 +814,9 @@ def test_host_backup_execute_exception_never_500s(
raise RuntimeError("disk exploded")
monkeypatch.setattr(routes_module, "backup_template", _boom)
# Must be a name list_templates() actually discovers, or the B6
# known-template check 400s before backup_template is ever reached.
(tmp_path / "templates" / "X").mkdir(parents=True)
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
+139
View File
@@ -20,6 +20,9 @@ import ci_orchestrator.web.app as app_module
from ci_orchestrator.backends.errors import BackendNotAvailable
from ci_orchestrator.backends.protocol import VmHandle
from ci_orchestrator.config import BackendConfig, CargoCacheConfig, Config, Paths
from ci_orchestrator.operations.cache import CacheStatus
from ci_orchestrator.operations.host import DiskStatus, RunnerStatus
from ci_orchestrator.operations.vms import LiveVm
from ci_orchestrator.web.app import create_app, get_backend
_DiskUsage = namedtuple("_DiskUsage", ["total", "used", "free"])
@@ -268,3 +271,139 @@ def test_static_mount_serves_app_css(tmp_path: Path) -> None:
resp = client.get("/static/app.css")
assert resp.status_code == 200
# ── _health_summary (A3) ─────────────────────────────────────────────────
_ACTIVE_RUNNER = RunnerStatus(name="act-runner.service", active=True, sub_state="running")
_DEAD_RUNNER = RunnerStatus(name="act-runner-rust.service", active=False, sub_state="dead")
_LIVE_VM = LiveVm(
name="job1", vmx="/x/job1.vmx", ip="10.0.0.5", guest_os="ubuntu-64",
uptime_sec=120, is_orphan=False,
)
_ORPHAN_VM = LiveVm(
name="job2", vmx="/x/job2.vmx", ip=None, guest_os="ubuntu-64",
uptime_sec=99999, is_orphan=True,
)
_CACHE = CacheStatus(
root=Path("/cache"), total_bytes=1024, entry_count=1, oldest_age_days=1, retention_days=30
)
def test_health_summary_backend_unavailable_is_vm_unknown() -> None:
health = app_module._health_summary(
[], backend_available=False, cache=None, disk=None, runners=[]
)
assert health.vm_class == "tile-unknown"
assert health.vm_label == "unknown"
def test_health_summary_orphan_present_is_vm_warn() -> None:
health = app_module._health_summary(
[_LIVE_VM, _ORPHAN_VM], backend_available=True, cache=None, disk=None, runners=[]
)
assert health.vm_count == 2
assert health.orphan_count == 1
assert health.vm_class == "tile-warn"
assert health.vm_label == "orphaned"
def test_health_summary_no_orphans_is_vm_ok() -> None:
health = app_module._health_summary(
[_LIVE_VM], backend_available=True, cache=None, disk=None, runners=[]
)
assert health.vm_class == "tile-ok"
assert health.vm_label == "ok"
def test_health_summary_no_runners_is_unknown() -> None:
health = app_module._health_summary(
[], backend_available=True, cache=None, disk=None, runners=[]
)
assert health.runners_total == 0
assert health.runner_class == "tile-unknown"
def test_health_summary_all_runners_active_is_ok() -> None:
health = app_module._health_summary(
[], backend_available=True, cache=None, disk=None, runners=[_ACTIVE_RUNNER]
)
assert health.runners_active == 1
assert health.runners_total == 1
assert health.runner_class == "tile-ok"
def test_health_summary_a_down_runner_is_bad() -> None:
health = app_module._health_summary(
[], backend_available=True, cache=None, disk=None,
runners=[_ACTIVE_RUNNER, _DEAD_RUNNER],
)
assert health.runners_active == 1
assert health.runners_total == 2
assert health.runner_class == "tile-bad"
assert health.runner_label == "down"
def test_health_summary_disk_none_is_unknown() -> None:
health = app_module._health_summary(
[], backend_available=True, cache=None, disk=None, runners=[]
)
assert health.disk_used_pct is None
assert health.disk_class == "tile-unknown"
def test_health_summary_disk_above_critical_is_bad() -> None:
disk = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=5.0, used_pct=90.0)
health = app_module._health_summary(
[], backend_available=True, cache=None, disk=disk, runners=[]
)
assert health.disk_class == "tile-bad"
assert health.disk_label == "critical"
def test_health_summary_disk_above_elevated_is_warn() -> None:
disk = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=25.0, used_pct=75.0)
health = app_module._health_summary(
[], backend_available=True, cache=None, disk=disk, runners=[]
)
assert health.disk_class == "tile-warn"
assert health.disk_label == "elevated"
def test_health_summary_disk_healthy_is_ok() -> None:
disk = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=60.0, used_pct=40.0)
health = app_module._health_summary(
[], backend_available=True, cache=None, disk=disk, runners=[]
)
assert health.disk_class == "tile-ok"
assert health.disk_label == "ok"
def test_health_summary_cache_none_is_unknown() -> None:
health = app_module._health_summary(
[], backend_available=True, cache=None, disk=None, runners=[]
)
assert health.cache_bytes is None
assert health.cache_class == "tile-unknown"
def test_health_summary_cache_present_is_ok() -> None:
health = app_module._health_summary(
[], backend_available=True, cache=_CACHE, disk=None, runners=[]
)
assert health.cache_bytes == 1024
assert health.cache_class == "tile-ok"
def test_dashboard_includes_status_header_tiles(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_quiet_host(monkeypatch)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/")
assert resp.status_code == 200
assert 'id="status-header"' in resp.text
+242
View File
@@ -0,0 +1,242 @@
"""Tests for the Stage A (A2) Jinja display filters.
Pure-function tests -- no app/template rendering needed, just exact-string
assertions on each filter's chosen output format (the brief leaves the
precise text up to the implementation, as long as it's exercised and
degrades sanely on ``None``/edge inputs).
"""
from __future__ import annotations
import re
from datetime import UTC, datetime, timedelta
from pathlib import Path
from ci_orchestrator.operations.vms import ParsedClone
from ci_orchestrator.web.filters import (
clone_job,
duration,
humanbytes,
localtime,
relative_time,
shorten_path,
thousands,
)
# ── humanbytes ───────────────────────────────────────────────────────────
def test_humanbytes_example_from_brief() -> None:
assert humanbytes(12345678) == "11.8 MB"
def test_humanbytes_zero() -> None:
assert humanbytes(0) == "0 B"
def test_humanbytes_small_whole_byte_count_has_no_decimal() -> None:
assert humanbytes(500) == "500 B"
def test_humanbytes_kilobytes() -> None:
assert humanbytes(1536) == "1.5 KB"
def test_humanbytes_gigabytes() -> None:
assert humanbytes(1024**3 * 2) == "2.0 GB"
def test_humanbytes_negative_keeps_sign() -> None:
assert humanbytes(-2048) == "-2.0 KB"
def test_humanbytes_accepts_float() -> None:
assert humanbytes(2048.0) == "2.0 KB"
def test_humanbytes_fractional_byte_count_has_decimal() -> None:
assert humanbytes(500.5) == "500.5 B"
# ── duration ─────────────────────────────────────────────────────────────
def test_duration_none_is_dash() -> None:
assert duration(None) == "-"
def test_duration_seconds_only() -> None:
assert duration(45) == "45s"
def test_duration_hours_and_minutes_drops_seconds() -> None:
assert duration(3783) == "1h 03m"
def test_duration_zero() -> None:
assert duration(0) == "0s"
def test_duration_minutes_and_seconds() -> None:
assert duration(125) == "2m 05s"
def test_duration_exactly_one_hour() -> None:
assert duration(3600) == "1h 00m"
def test_duration_negative_keeps_sign() -> None:
assert duration(-45) == "-45s"
def test_duration_accepts_float() -> None:
assert duration(45.9) == "45s"
# ── relative_time ────────────────────────────────────────────────────────
def test_relative_time_none_is_dash() -> None:
assert relative_time(None) == "-"
def test_relative_time_two_minutes_ago() -> None:
ts = (datetime.now(tz=UTC) - timedelta(minutes=2)).isoformat()
assert relative_time(ts) == "2m ago"
def test_relative_time_just_now() -> None:
ts = datetime.now(tz=UTC).isoformat()
assert relative_time(ts) == "just now"
def test_relative_time_hours_ago() -> None:
ts = (datetime.now(tz=UTC) - timedelta(hours=3)).isoformat()
assert relative_time(ts) == "3h ago"
def test_relative_time_days_ago() -> None:
ts = (datetime.now(tz=UTC) - timedelta(days=5)).isoformat()
assert relative_time(ts) == "5d ago"
def test_relative_time_accepts_datetime_object() -> None:
dt = datetime.now(tz=UTC) - timedelta(minutes=10)
assert relative_time(dt) == "10m ago"
def test_relative_time_malformed_string_is_dash() -> None:
assert relative_time("not-a-timestamp") == "-"
def test_relative_time_future_clamps_to_just_now() -> None:
ts = (datetime.now(tz=UTC) + timedelta(minutes=5)).isoformat()
assert relative_time(ts) == "just now"
def test_relative_time_accepts_epoch_float() -> None:
# e.g. a raw os.stat().st_mtime, as stored on inventory.FileEntry.mtime.
epoch = (datetime.now(tz=UTC) - timedelta(minutes=7)).timestamp()
assert relative_time(epoch) == "7m ago"
# ── localtime ────────────────────────────────────────────────────────────
def test_localtime_none_is_dash() -> None:
assert localtime(None) == "-"
_LOCALTIME_SHAPE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$")
def test_localtime_formats_aware_datetime() -> None:
dt = datetime(2026, 7, 23, 10, 0, 0, tzinfo=UTC)
# Exact clock value depends on the host's local zone -- only the shape
# (and that it doesn't raise) is asserted here.
assert _LOCALTIME_SHAPE.match(localtime(dt))
def test_localtime_formats_iso_string() -> None:
assert _LOCALTIME_SHAPE.match(localtime("2026-07-23T10:00:00+00:00"))
def test_localtime_naive_datetime_is_treated_as_already_local() -> None:
dt = datetime(2026, 7, 23, 10, 0, 0) # naive on purpose
assert localtime(dt) == "2026-07-23 10:00"
def test_localtime_malformed_string_is_dash() -> None:
assert localtime("garbage") == "-"
def test_localtime_accepts_epoch_float() -> None:
dt = datetime(2026, 7, 23, 10, 0, 0, tzinfo=UTC)
assert _LOCALTIME_SHAPE.match(localtime(dt.timestamp()))
# ── shorten_path ─────────────────────────────────────────────────────────
def test_shorten_path_default_keep_two() -> None:
assert shorten_path("/var/lib/ci/config.toml") == ".../ci/config.toml"
def test_shorten_path_shorter_than_keep_is_unshortened() -> None:
assert shorten_path("/a") == "/a"
def test_shorten_path_accepts_path_object() -> None:
assert shorten_path(Path("/var/lib/ci/config.toml")) == ".../ci/config.toml"
def test_shorten_path_custom_keep() -> None:
assert shorten_path("/var/lib/ci/logs/web-audit.jsonl", keep=3) == ".../ci/logs/web-audit.jsonl"
def test_shorten_path_keep_zero_is_ellipsis() -> None:
assert shorten_path("/var/lib/ci", keep=0) == "..."
def test_shorten_path_keep_negative_is_ellipsis() -> None:
assert shorten_path("/var/lib/ci", keep=-1) == "..."
# ── thousands ────────────────────────────────────────────────────────────
def test_thousands_small_number_unchanged() -> None:
assert thousands(812) == "812"
def test_thousands_example_from_brief() -> None:
assert thousands(12345) == "12,345"
def test_thousands_zero() -> None:
assert thousands(0) == "0"
def test_thousands_negative() -> None:
assert thousands(-1234) == "-1,234"
def test_thousands_accepts_float() -> None:
assert thousands(12345.0) == "12,345"
# ── clone_job (Stage C1) ─────────────────────────────────────────────────
def test_clone_job_delegates_to_parse_clone_name() -> None:
parsed = clone_job("Clone_build-42_20250101_123456_abc123")
assert parsed == ParsedClone(job_id="build-42", started=datetime(2025, 1, 1, 12, 34, 56))
def test_clone_job_none_input_degrades_to_all_none() -> None:
assert clone_job(None) == ParsedClone(job_id=None, started=None)
def test_clone_job_non_clone_name_degrades_to_all_none() -> None:
assert clone_job("SomeOtherName") == ParsedClone(job_id=None, started=None)
+302
View File
@@ -0,0 +1,302 @@
"""Tests for the Stage B read-only web routes: ``/resources``, ``/vmware``,
``/templates``, ``/files``, plus the B6 backup-form template dropdown.
In-process only, same conventions as ``test_web_app.py``: no real ``/proc``,
``vmrun``, or ``systemctl`` -- every ``operations/`` call is monkeypatched at
the name ``web/app.py`` imported it under.
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
import ci_orchestrator.web.app as app_module
from ci_orchestrator.config import BackendConfig, Config, Paths
from ci_orchestrator.operations.host import HostResources, VmwareModuleHealth
from ci_orchestrator.operations.inventory import FileEntry, TemplateInfo
from ci_orchestrator.web.app import create_app
def _quiet_systemctl(monkeypatch: pytest.MonkeyPatch) -> None:
"""No real ``systemctl`` subprocess from ``operations.host.runner_status``."""
monkeypatch.setattr(shutil, "which", lambda _name: None)
def _make_config(root: Path) -> Config:
return Config(
paths=Paths(
root=root,
templates=root / "templates",
build_vms=root / "build-vms",
artifacts=root / "artifacts",
keys=root / "keys",
),
backend=BackendConfig(),
)
_RESOURCES = HostResources(
cpu_pct=42.5, ncpu=8, mem_total_gb=16.0, mem_used_gb=8.0,
uptime_sec=3661, load1=0.5, load5=0.4, load15=0.3,
)
_VMWARE_OK = VmwareModuleHealth(
vmmon_loaded=True, vmnet_loaded=True, host_service_active=True, host_service_sub_state="running"
)
_TEMPLATE_ROW = TemplateInfo(
name="LinuxBuild2404", vmx=None, guest_os="ubuntu-64", transport="ssh",
dir_bytes=1024 * 1024, has_vmem=False, last_backup=None,
)
_FILE_ENTRY = FileEntry(name="Template_X_20260101_000000.7z", size_bytes=2048, mtime=1_700_000_000.0)
# ── /resources ───────────────────────────────────────────────────────────────
def test_resources_route_renders_snapshot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(app_module, "host_resources", lambda: _RESOURCES)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/resources")
assert resp.status_code == 200
assert "Host resources" in resp.text
assert "42.5" in resp.text
def test_resources_route_degrades_on_probe_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom() -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(app_module, "host_resources", _boom)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/resources")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
# ── /vmware ───────────────────────────────────────────────────────────────────
def test_vmware_route_renders_health(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(app_module, "vmware_module_health", lambda: _VMWARE_OK)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/vmware")
assert resp.status_code == 200
assert "VMware modules" in resp.text
assert "running" in resp.text
def test_vmware_route_degrades_on_probe_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom() -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(app_module, "vmware_module_health", _boom)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/vmware")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
def test_vmware_route_shows_not_loaded_badge_when_module_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
unhealthy = VmwareModuleHealth(
vmmon_loaded=False, vmnet_loaded=True, host_service_active=False, host_service_sub_state="dead"
)
monkeypatch.setattr(app_module, "vmware_module_health", lambda: unhealthy)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/vmware")
assert resp.status_code == 200
assert "not loaded" in resp.text
assert "down" in resp.text
# ── /templates ────────────────────────────────────────────────────────────────
def test_templates_route_renders_rows(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(app_module, "list_templates", lambda _config: [_TEMPLATE_ROW])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/templates")
assert resp.status_code == 200
assert "LinuxBuild2404" in resp.text
assert "ssh" in resp.text
def test_templates_route_empty_is_not_an_error_notice(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(app_module, "list_templates", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/templates")
assert resp.status_code == 200
assert "unavailable" not in resp.text.lower()
assert "No templates found" in resp.text
def test_templates_route_degrades_on_probe_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_config: Config) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(app_module, "list_templates", _boom)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/templates")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
def test_templates_route_shows_running_ram_snapshot_warning(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
warm = TemplateInfo(
name="Warm", vmx=None, guest_os="ubuntu-64", transport="ssh",
dir_bytes=0, has_vmem=True, last_backup=None,
)
monkeypatch.setattr(app_module, "list_templates", lambda _config: [warm])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/templates")
assert resp.status_code == 200
assert "running-RAM snapshot" in resp.text
# ── /files ────────────────────────────────────────────────────────────────────
def test_files_route_renders_backups_and_artifacts(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(app_module, "list_backups", lambda _config: [_FILE_ENTRY])
monkeypatch.setattr(app_module, "list_artifacts", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/files")
assert resp.status_code == 200
assert "Template_X_20260101_000000.7z" in resp.text
assert "No artifacts found" in resp.text
def test_files_route_degrades_on_backups_probe_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_config: Config) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(app_module, "list_backups", _boom)
monkeypatch.setattr(app_module, "list_artifacts", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/files")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
def test_files_route_degrades_on_artifacts_probe_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_config: Config) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(app_module, "list_backups", lambda _config: [])
monkeypatch.setattr(app_module, "list_artifacts", _boom)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/files")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
# ── dashboard includes all Stage B cards ─────────────────────────────────────
def test_dashboard_renders_all_stage_b_cards(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_quiet_systemctl(monkeypatch)
monkeypatch.setattr(app_module, "host_resources", lambda: _RESOURCES)
monkeypatch.setattr(app_module, "vmware_module_health", lambda: _VMWARE_OK)
monkeypatch.setattr(app_module, "list_templates", lambda _config: [_TEMPLATE_ROW])
monkeypatch.setattr(app_module, "list_backups", lambda _config: [])
monkeypatch.setattr(app_module, "list_artifacts", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/")
assert resp.status_code == 200
for needle in ("Host resources", "VMware modules", "Templates", "Template backups", "Artifacts"):
assert needle in resp.text
# ── B6: backup dropdown ──────────────────────────────────────────────────────
def test_host_partial_backup_form_offers_discovered_template_names(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_quiet_systemctl(monkeypatch)
monkeypatch.setattr(app_module, "list_templates", lambda _config: [_TEMPLATE_ROW])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
assert resp.status_code == 200
assert '<select id="host-backup-template" name="template"' in resp.text
assert '<option value="LinuxBuild2404">LinuxBuild2404</option>' in resp.text
assert "7z archive of the template dir under" in resp.text
def test_host_partial_backup_form_empty_dropdown_when_no_templates(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_quiet_systemctl(monkeypatch)
monkeypatch.setattr(app_module, "list_templates", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
assert resp.status_code == 200
assert "<option" not in resp.text
+266
View File
@@ -0,0 +1,266 @@
"""Tests for Stage C: VM-table Job column, Gitea nav link-out, audit polish.
In-process only, same conventions as ``test_web_app.py`` / ``test_web_stage_b.py``:
no real ``vmrun``/disk/systemctl call, everything driven through
``dependency_overrides`` and a fake backend.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
from ci_orchestrator.backends.protocol import VmHandle
from ci_orchestrator.config import (
BackendConfig,
CargoCacheConfig,
Config,
Paths,
WebConfig,
WebOidcConfig,
)
from ci_orchestrator.operations.audit import AuditEntry, append_audit
from ci_orchestrator.web.app import create_app, get_backend
def _make_config(
root: Path,
*,
gitea_url: str | None = None,
oidc: WebOidcConfig | None = None,
) -> Config:
return Config(
paths=Paths(
root=root,
templates=root / "templates",
build_vms=root / "build-vms",
artifacts=root / "artifacts",
keys=root / "keys",
),
backend=BackendConfig(),
cargo_cache=CargoCacheConfig(
enabled=True, root=root / "cache" / "cargo", retention_days=30
),
# audit_log must stay under tmp_path -- the real default
# (/var/lib/ci/logs/web-audit.jsonl) is not writable in a test sandbox.
web=WebConfig(audit_log=root / "web-audit.jsonl", gitea_url=gitea_url, oidc=oidc),
)
class _FakeBackend:
"""Stub ``VmBackend`` -- proves no real ``vmrun`` is ever invoked."""
def __init__(self, handles: list[VmHandle], ip: str | None = "192.168.79.201") -> None:
self._handles = handles
self._ip = ip
def list_running(self) -> list[VmHandle]:
return self._handles
def get_ip(self, handle: VmHandle, timeout: float = 0.0) -> str | None:
return self._ip
def _make_clone_vmx(build_vms: Path, clone_name: str) -> Path:
clone_dir = build_vms / clone_name
clone_dir.mkdir(parents=True)
vmx = clone_dir / f"{clone_name}.vmx"
vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8")
return vmx
# ── VM table Job column (C1) ──────────────────────────────────────────────
def test_vms_table_renders_job_column_header(tmp_path: Path) -> None:
# The Job column header lives inside the VM table, which only renders when
# at least one VM is running (an empty backend shows the "no VMs" notice
# and no table at all), so seed one clone before asserting on the header.
build_vms = tmp_path / "build-vms"
build_vms.mkdir(parents=True)
vmx = _make_clone_vmx(build_vms, "Clone_build-7_20250101_120000_deadbe")
config = _make_config(tmp_path)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([VmHandle(identifier=str(vmx))])
client = TestClient(app)
resp = client.get("/")
assert resp.status_code == 200
assert "<th>Job</th>" in resp.text
def test_vms_table_renders_parsed_job_id_and_started(tmp_path: Path) -> None:
build_vms = tmp_path / "build-vms"
build_vms.mkdir(parents=True)
vmx = _make_clone_vmx(build_vms, "Clone_build-42_20250101_123456_abc123")
config = _make_config(tmp_path)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([VmHandle(identifier=str(vmx))])
client = TestClient(app)
resp = client.get("/vms")
assert resp.status_code == 200
assert "build-42" in resp.text
def test_vms_table_unparseable_name_shows_placeholder_not_error(tmp_path: Path) -> None:
build_vms = tmp_path / "build-vms"
build_vms.mkdir(parents=True)
vmx = _make_clone_vmx(build_vms, "SomeHandCreatedVM")
config = _make_config(tmp_path)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([VmHandle(identifier=str(vmx))])
client = TestClient(app)
resp = client.get("/vms")
assert resp.status_code == 200
assert "—" in resp.text # em-dash placeholder for a missing job_id
# ── Gitea nav link-out (C2) ───────────────────────────────────────────────
def test_dashboard_nav_has_gitea_link_when_configured(tmp_path: Path) -> None:
config = _make_config(tmp_path, gitea_url="https://gitea.example.lan")
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/")
assert resp.status_code == 200
assert 'href="https://gitea.example.lan"' in resp.text
assert 'target="_blank"' in resp.text
assert 'rel="noopener"' in resp.text
def test_dashboard_nav_derives_gitea_link_from_oidc_issuer(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Configuring OIDC makes create_app() read the session-secret keyring key
# (absent in the test sandbox); stub the keyring lookup so the app builds
# and we can assert the nav derives its Gitea link from the issuer origin.
monkeypatch.setattr(
"ci_orchestrator.credentials.KeyringCredentialStore.get",
lambda self, name: SimpleNamespace(password="test-session-secret"),
)
oidc = WebOidcConfig(
issuer="https://gitea.emulab.it",
client_id="ci-web",
redirect_url="http://127.0.0.1:8800/auth/callback",
)
config = _make_config(tmp_path, oidc=oidc)
app = create_app(config)
# base.html's nav reads the ``gitea_url`` Jinja global; assert create_app
# installed the issuer-derived origin there. Rendering that global into the
# <a href> is covered end-to-end by test_dashboard_nav_has_gitea_link_when_configured;
# reaching "/" under OIDC would require a full authenticated session this
# wiring check does not need.
assert app.state.templates.env.globals["gitea_url"] == "https://gitea.emulab.it"
def test_dashboard_nav_omits_gitea_link_when_unconfigured(tmp_path: Path) -> None:
config = _make_config(tmp_path) # default WebConfig(): no gitea_url, no oidc
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/")
assert resp.status_code == 200
assert "Gitea" not in resp.text
def test_audit_page_nav_also_gets_gitea_link(tmp_path: Path) -> None:
"""The Jinja global must reach every page that extends base.html, not just '/'."""
config = _make_config(tmp_path, gitea_url="https://gitea.example.lan")
app = create_app(config)
client = TestClient(app)
resp = client.get("/audit")
assert resp.status_code == 200
assert 'href="https://gitea.example.lan"' in resp.text
# ── Audit page polish (C3) ─────────────────────────────────────────────────
def test_audit_page_renders_filter_input(tmp_path: Path) -> None:
config = _make_config(tmp_path)
append_audit(
config.web.audit_log,
AuditEntry(
ts="2026-07-23T10:00:00+00:00",
user="anonymous",
action="vm.remove",
target="/some/vmx",
result="ok",
detail="removed",
),
)
app = create_app(config)
client = TestClient(app)
resp = client.get("/audit")
assert resp.status_code == 200
assert 'id="audit-filter-input"' in resp.text
assert 'aria-controls="audit-rows"' in resp.text
assert '<label for="audit-filter-input">' in resp.text
def test_audit_page_no_filter_input_when_log_empty(tmp_path: Path) -> None:
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
resp = client.get("/audit")
assert resp.status_code == 200
assert "No audit entries yet." in resp.text
assert 'id="audit-filter-input"' not in resp.text
@pytest.mark.parametrize(
("action", "icon_id"),
[
("vm.remove", "i-server"),
("cache.purge", "i-file"),
("host.backup", "i-box"),
("service.restart", "i-gear"),
("something.else", "i-alert"),
],
)
def test_audit_row_icon_matches_action_prefix(
tmp_path: Path, action: str, icon_id: str
) -> None:
config = _make_config(tmp_path)
append_audit(
config.web.audit_log,
AuditEntry(
ts="2026-07-23T10:00:00+00:00",
user="anonymous",
action=action,
target="target",
result="ok",
detail="detail",
),
)
app = create_app(config)
client = TestClient(app)
resp = client.get("/audit")
assert resp.status_code == 200
assert f'href="#{icon_id}"' in resp.text
+163
View File
@@ -0,0 +1,163 @@
"""Tests for the A4 theming fix.
Root cause being fixed: mutating ``<form method="post">`` do a full-page
navigation, and the POST routes used to return a bare partial with no
``base.html`` wrapper -- an unstyled page. Now the same route renders
inline (bare partial) for an htmx request (``HX-Request: true``) and a
themed full page (``templates/action_page.html``, extending ``base.html``)
for a plain no-JS navigation.
Config/CSRF setup mirrors ``test_web_actions.py``'s ``_make_config`` (a bare,
non-OIDC config is enough -- these routes only need a CSRF-bearing session,
not real authentication).
"""
from __future__ import annotations
import re
from pathlib import Path
from fastapi.testclient import TestClient
from ci_orchestrator.config import BackendConfig, CargoCacheConfig, Config, Paths, WebConfig
from ci_orchestrator.web.app import create_app, get_backend
def _make_config(root: Path) -> Config:
return Config(
paths=Paths(
root=root,
templates=root / "templates",
build_vms=root / "build-vms",
artifacts=root / "artifacts",
keys=root / "keys",
),
backend=BackendConfig(),
cargo_cache=CargoCacheConfig(
enabled=True, root=root / "cache" / "cargo", retention_days=30
),
# audit_log MUST live under tmp_path -- the WebConfig default points
# at the real host's /var/lib/ci/logs, which these tests must never
# touch (see the module docstring: routes exercised here audit).
web=WebConfig(audit_log=root / "web-audit.jsonl"),
)
def _csrf_from(text: str) -> str:
match = re.search(r'name="csrf" value="([^"]+)"', text)
assert match is not None, f"no csrf field found in: {text[:300]!r}"
return match.group(1)
# ── HX-Request present -> bare partial, no theme wrapper ───────────────────
def test_hx_request_preview_gets_bare_partial(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
resp = client.post(
"/cache/purge", data={"csrf": csrf}, headers={"HX-Request": "true"}
)
assert resp.status_code == 200
assert "<html" not in resp.text.lower()
assert '<link rel="stylesheet"' not in resp.text
assert "Confirm" in resp.text
def test_hx_request_execute_gets_bare_partial(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
resp = client.post(
"/cache/purge",
data={"csrf": csrf, "stage": "execute"},
headers={"HX-Request": "true"},
)
assert resp.status_code == 200
assert "<html" not in resp.text.lower()
assert '<link rel="stylesheet"' not in resp.text
# ── HX-Request absent -> full themed page ───────────────────────────────────
def test_no_hx_header_preview_gets_full_themed_page(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
resp = client.post("/cache/purge", data={"csrf": csrf})
assert resp.status_code == 200
assert '<link rel="stylesheet" href="/static/app.css">' in resp.text
assert 'id="action-slot"' in resp.text
assert "Confirm" in resp.text
def test_no_hx_header_execute_gets_full_themed_page(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
resp = client.post("/cache/purge", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 200
assert '<link rel="stylesheet"' in resp.text
assert 'id="action-slot"' in resp.text
# ── wrapper never changes status code, CSRF, or audit behavior ────────────
def test_lock_conflict_status_code_preserved_in_full_page(tmp_path: Path) -> None:
config = _make_config(tmp_path)
config.paths.build_vms.mkdir(parents=True, exist_ok=True)
lock_path = config.paths.build_vms.parent / "vm-start.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_path.write_text("job in progress", encoding="utf-8")
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: None
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
resp = client.post("/vms/cleanup", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 409
assert '<link rel="stylesheet"' in resp.text
assert 'id="action-slot"' in resp.text
def test_lock_conflict_status_code_preserved_bare_for_htmx(tmp_path: Path) -> None:
config = _make_config(tmp_path)
config.paths.build_vms.mkdir(parents=True, exist_ok=True)
lock_path = config.paths.build_vms.parent / "vm-start.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_path.write_text("job in progress", encoding="utf-8")
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: None
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
resp = client.post(
"/vms/cleanup",
data={"csrf": csrf, "stage": "execute"},
headers={"HX-Request": "true"},
)
assert resp.status_code == 409
assert "<html" not in resp.text.lower()
def test_missing_csrf_still_403s_regardless_of_hx_header(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
client.get("/cache") # establish a session
resp = client.post("/cache/purge", data={}, headers={"HX-Request": "true"})
assert resp.status_code == 403