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:
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 <root>/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 %}
|
||||
—
|
||||
{% 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user