# Interfaccia web di gestione VM/host — Piano di applicazione > Piano operativo per una interfaccia web di **gestione** (non solo > monitoraggio) del sistema CI/CD. Generato il **2026-07-23**. > Eseguire i passi **in ordine**: ogni passo è autonomo, testabile e con > rollback banale (nessun file esistente viene rimosso; il codice nuovo vive > in package/dir nuovi finché W7 non chiude la validazione). > > **Destinatario**: questo piano è scritto per essere eseguito da un modello > più piccolo. Leggi prima la sezione **"Istruzioni per l'implementatore"** > in fondo: contiene i vincoli non negoziabili (TDD, `mypy --strict`, > coverage ≥ 90 %, niente `click.echo` in `operations/`, niente CDN, niente > `subprocess`) e i comandi di verifica da lanciare dopo ogni passo. --- ## Summary — checklist master Stato passi (spunta man mano): - [x] **W0** — Scaffolding: extra `[web]` in `pyproject.toml`, dir package `operations/` + `web/`, skeleton test. Nessuna logica. - [x] **W1** — Layer `operations/vms.py`: estrai la logica da `commands/vm.py` in funzioni tipate che ritornano dataclass. Refactor dei comandi CLI per chiamarle. **Comportamento CLI invariato.** (Incluso: seam `VmBackend.list_running()` + fold del fix §2.8 IP-pool leak via `IpSlotPool.reconcile` / `operations.vms.reconcile_ip_leases`.) - [ ] **W2** — Layer `operations/cache.py` + `operations/host.py`: stato cache cargo, purge, disco, runner, backup template. Refactor `retention.py` / `monitor.py`. - [ ] **W3** — `operations/audit.py`: append-only JSONL + lettura. Config `[web]` in `config.py`. - [ ] **W4** — App web core: `web/app.py` factory + `commands/web.py` (`ci web serve`). Dashboard **read-only** (VM, cache, disco, runner). Ancora **senza auth**, bind localhost. - [ ] **W5** — Auth: Gitea OIDC (Authlib) + sessione firmata + allowlist utenti. Tutte le route protette. - [ ] **W6** — Azioni mutanti + safety: remove VM, cleanup orfane, purge cache, backup. Confirm gate + CSRF + audit + dry-run. HTMX partials. - [ ] **W7** — UI dark Gitea-style (asset vendorizzati) + installer ripetibile (`setup-web-linux.sh`, unit systemd, `docs/WEB-UI.md`) + validazione finale (test, coverage, mypy, ruff, smoke manuale). **Definition of done globale**: `ci web serve` gira come `ci-web.service`, login via Gitea, dashboard dark mostra stato live, le 4 azioni (kill VM / cleanup orfane / purge cache / backup) funzionano con conferma + audit, suite test verde con coverage ≥ 90 % e `mypy --strict` pulito. --- ## Contesto e decisioni approvate Decisioni cablate in fase di brainstorming (2026-07-23): | Tema | Scelta | |------|--------| | Scopo | **Gestione VM/host** (non solo monitoraggio) | | Auth | **Riusa Gitea come OIDC provider** (OAuth2) | | Azioni esposte | VM live lista + kill/remove · cleanup orfane bulk · stato+purge cache cargo · backup template + salute host (disk/runner) | | Stack | **Server-rendered HTML + HTMX** (FastAPI + Jinja2 + Authlib) | | Deploy | **Bare systemd** (`ci-web.service`), **niente container** — la web app deve chiamare `vmrun`/`load_backend` in-process sull'host | | UI | **Dark, stile Gitea**, molto curata, asset vendorizzati (no CDN) | | Installazione | **Ripetibile/idempotente** via `setup-web-linux.sh` + unit template + doc | **Vincolo architetturale chiave**: la web app chiama la logica **in-process** importando il layer `operations/` — **mai** via `subprocess` sulla CLI. Riusa `backends.load_backend(config)` come `commands/job.py`, così la strada ESXi (Fase D) resta aperta. --- ## Architettura ``` src/ci_orchestrator/ operations/ # NUOVO — logica pura, ritorna dataclass, ZERO click.echo __init__.py vms.py # list_live_vms / remove_vm / list_orphans / cleanup_orphans cache.py # cargo_cache_status / purge_cargo_cache host.py # disk_status / runner_status / backup_template audit.py # append_audit / read_audit web/ # NUOVO __init__.py app.py # create_app(config) -> FastAPI auth.py # OIDC Gitea (Authlib), sessione, allowlist, require_user routes.py # endpoint HTMX security.py # CSRF token firmato per-sessione templates/ # Jinja2: base.html, dashboard.html, partials/*.html static/ app.css # design tokens dark Gitea (~200 righe, scritto a mano) htmx.min.js # vendorizzato (no CDN) commands/ web.py # NUOVO — `ci web serve --host --port` vm.py / retention.py / monitor.py # REFACTOR — chiamano operations/, mantengono click.echo deploy/systemd/ ci-web.service # NUOVO — template unit setup-web-linux.sh # NUOVO — installer idempotente docs/WEB-UI.md # NUOVO — install + OIDC + operate + troubleshoot ``` **Il seam è `operations/`**: sia i comandi CLI sia la web app lo importano. La logica esistente (es. `commands/vm.py::cleanup_orphans`) si sposta lì e si **splitta** in una parte "dati" (`list_orphans() -> list[Orphan]`) e una parte "azione" (`cleanup_orphans() -> list[RemovalResult]`). I `click.echo` risalgono nei wrapper dei comandi. La CLI non cambia comportamento; guadagna un `--json` più pulito. --- ## W0 — Scaffolding **File**: `pyproject.toml`, `src/ci_orchestrator/operations/__init__.py`, `src/ci_orchestrator/web/__init__.py`, `tests/python/test_web_scaffold.py`. 1. Aggiungi extra opzionale in `pyproject.toml`: ```toml [project.optional-dependencies] web = [ "fastapi>=0.115", "uvicorn[standard]>=0.30", "jinja2>=3.1", "authlib>=1.3", "itsdangerous>=2.2", "httpx>=0.27", ] ``` Il core (`dependencies`) **non** cambia: la CLI resta dep-light. 2. Crea le dir package con `__init__.py` vuoti. 3. Test scaffold: importa `ci_orchestrator.operations` e `ci_orchestrator.web`, verifica che l'import non fallisca. **Acceptance**: `pip install -e '.[web,dev]'` OK; `pytest tests/python/test_web_scaffold.py` verde; `mypy --strict src` pulito; suite esistente invariata. --- ## W1 — `operations/vms.py` + refactor `commands/vm.py` **File**: `src/ci_orchestrator/operations/vms.py`, `src/ci_orchestrator/commands/vm.py`, `tests/python/test_operations_vms.py`. Dataclass (tutte `@dataclass(frozen=True)`): ```python @dataclass(frozen=True) class LiveVm: name: str vmx: str ip: str | None guest_os: str uptime_sec: int | None is_orphan: bool @dataclass(frozen=True) class Orphan: path: Path vmx: Path | None age_hours: int @dataclass(frozen=True) class RemovalResult: target: str stopped: bool deleted: bool dir_removed: bool error: str | None ``` Funzioni (firme esatte, type hints obbligatori): ```python def list_live_vms(backend: VmBackend, config: Config) -> list[LiveVm]: ... def list_orphans(clone_base: Path, max_age_hours: int) -> list[Orphan]: ... def remove_vm(backend: VmBackend, vmx: Path, *, dry_run: bool = False) -> RemovalResult: ... def cleanup_orphans( clone_base: Path, max_age_hours: int, backend: VmBackend | None, *, dry_run: bool = False, lock_file: Path | None = None, lock_max_age_minutes: int = 30, ) -> list[RemovalResult]: ... ``` Passi: 1. Sposta il corpo di `commands/vm.py::cleanup_orphans` (attuale righe ~218–298) in `operations/vms.py`. **Rimuovi tutti i `click.echo`** dalla funzione; sostituiscili accumulando `RemovalResult`. 2. Estrai `_list_orphans` / `_find_vmx` in `operations/vms.py` come `list_orphans` / helper. 3. `list_live_vms`: parse di `vmrun list` (VM in esecuzione) + lettura campo `guestOS` dai VMX + IP via backend/guestinfo. Filtra le righe `"Error:"` (gotcha noto `getGuestIPAddress`). 4. Refactor `commands/vm.py`: i comandi click chiamano `operations/` e fanno `click.echo` sui risultati. **Output testuale CLI identico a prima** (confronta con i test esistenti). **Acceptance**: `pytest tests/python/test_operations_vms.py tests/python/test_commands_vm*.py` verde; nessun `click` importato in `operations/vms.py`; `mypy --strict` pulito; coverage del modulo ≥ 90 %. > **Nota — piega qui il fix di TODO §2.8 (IP-pool leak).** Questo passo sposta > `cleanup_orphans` in `operations/vms.py` ed è la stessa funzione che l'azione > web "cleanup orfane" (W6) invocherà. TODO §2.8 è un bug reale aperto: un job > killato mid-flight leaka il lease IP in modo permanente e con soli 4 IP > (`192.168.79.201–204`) un singolo leak esaurisce il pool (`IP pool exhausted > after 60s`). Mentre refattori, aggiungi in `cleanup_orphans` (o in > `list_orphans`) il rilascio dei lease il cui clone-dir non esiste più in > `CloneBaseDir`, con test dedicato. Chiude §2.8 senza un secondo giro sullo > stesso codice. **Non** è un requisito d'accettazione di W1 (la UI funziona > comunque): è un'opportunità di coalescing, non un blocco. --- ## W2 — `operations/cache.py` + `operations/host.py` **File**: `operations/cache.py`, `operations/host.py`, refactor `commands/retention.py` + `commands/monitor.py`, `tests/python/test_operations_cache.py`, `tests/python/test_operations_host.py`. ```python # cache.py @dataclass(frozen=True) class CacheStatus: root: Path total_bytes: int entry_count: int oldest_age_days: int | None retention_days: int @dataclass(frozen=True) class PurgeResult: removed_entries: int freed_bytes: int dry_run: bool def cargo_cache_status(config: Config) -> CacheStatus: ... def purge_cargo_cache(config: Config, *, retention_days: int | None = None, dry_run: bool = False) -> PurgeResult: ... # host.py @dataclass(frozen=True) class DiskStatus: path: Path total_gb: float free_gb: float used_pct: float @dataclass(frozen=True) class RunnerStatus: name: str active: bool sub_state: str @dataclass(frozen=True) class BackupResult: template: str archive_path: Path | None ok: bool error: str | None def disk_status(path: Path) -> DiskStatus: ... def runner_status(service_names: list[str]) -> list[RunnerStatus]: ... def backup_template(config: Config, template: str, *, dry_run: bool = False) -> BackupResult: ... ``` - Riusa la logica esistente di `retention.py` (`_purge_cargo_cache`) e `monitor.py` (`_service_status`, disk) spostandola/adattandola; togli i `click.echo`. - `runner_status` interroga `systemctl is-active` per `act-runner.service` e `act-runner-rust.service` (i due runner reali sull'host). **Non** usare `shell=True`; usa `subprocess.run([...], capture_output=True)` con lista di argomenti (unico caso ammesso: interrogazione read-only di systemctl, già presente in `monitor.py`). **Acceptance**: test verdi per entrambi i moduli; comandi `retention run` / `monitor disk` / `monitor runner` invariati nell'output; coverage ≥ 90 %. > **Nota — TODO §2.9 (`retention run` cieco su backup/template/ISO) è > ORTOGONALE, non risolverlo qui.** `operations/cache.py` wrappa solo lo stato > e la purge della cache **cargo**; la UI (W6) espone "purge cache cargo", non > la policy di retention del disco. §2.9 è un buco separato: il timer di > retention agisce su artifact/log/lease (52 KB) mentre il disco si riempiva > per backup/template/ISO (fuori dal suo raggio). Non allargare lo scopo di W2 > per coprirlo — segnalo e basta. Se vuoi che la UI mostri lo stato disco > reale, `operations/host.py::disk_status` (già in questo passo) lo copre; > l'estensione della *policy* automatica resta un task TODO a sé. --- ## W3 — `operations/audit.py` + config `[web]` **File**: `operations/audit.py`, `src/ci_orchestrator/config.py`, `tests/python/test_operations_audit.py`, `tests/python/test_config_web.py`, `config.example.toml`. ```python @dataclass(frozen=True) class AuditEntry: ts: str # ISO 8601 UTC user: str action: str # "vm.remove" | "vm.cleanup" | "cache.purge" | "host.backup" target: str result: str # "ok" | "error" | "dry-run" detail: str def append_audit(log_path: Path, entry: AuditEntry) -> None: ... # append JSONL, mai troncare def read_audit(log_path: Path, *, last: int = 100) -> list[AuditEntry]: ... ``` Config: aggiungi a `config.py` ```python @dataclass(frozen=True) class WebConfig: enabled: bool = False bind_host: str = "127.0.0.1" bind_port: int = 8800 audit_log: Path = Path("/var/lib/ci/logs/web-audit.jsonl") allowed_users: list[str] = field(default_factory=list) # username Gitea in allowlist session_secret_cred: str = "WebSessionSecret" # nome chiave keyring oidc: WebOidcConfig | None = None @dataclass(frozen=True) class WebOidcConfig: issuer: str # es. https://gitea.lan client_id: str redirect_url: str # es. http://192.168.x.x:8800/auth/callback client_secret_cred: str = "WebOidcClientSecret" # nome chiave keyring, NO secret in TOML scopes: list[str] = field(default_factory=lambda: ["openid", "profile"]) ``` Aggiungi il parsing `[web]` / `[web.oidc]` in `load_config` e la sezione annotata in `config.example.toml`. **Nessun segreto in chiaro nel TOML** — solo il *nome* della chiave keyring. **Acceptance**: config carica `[web]`/`[web.oidc]`; assenti → `web=disabled`; audit round-trip (append→read) testato; coverage ≥ 90 %. --- ## W4 — App web core (read-only, senza auth) **File**: `web/app.py`, `commands/web.py`, `web/templates/base.html`, `web/templates/dashboard.html`, `tests/python/test_web_app.py`. 1. `create_app(config: Config) -> FastAPI`: monta static, configura Jinja2, registra le route read-only. 2. Route read-only (rendono HTML/partials): - `GET /` → dashboard (card: VM live, cache, disco, runner) - `GET /vms` → tabella VM (partial, `hx-trigger="every 5s"`) - `GET /cache` → stato cache (partial) - `GET /host` → disco + runner (partial) - `GET /healthz` → `{"status":"ok"}` (per systemd/monitor) 3. `commands/web.py`: `ci web serve --host --port ` → `uvicorn.run(create_app(cfg), ...)`. Default host/port da `WebConfig`. Bind **127.0.0.1** in questo passo. 4. Test con `fastapi.testclient.TestClient`: `GET /healthz` = 200; `GET /` contiene le card; le route dati rendono con backend **mockato** (nessuna VM reale nei test). **Acceptance**: `TestClient` verde; `ci web serve --help` OK; nessuna chiamata reale a `vmrun` nei test (backend mock); coverage ≥ 90 %. --- ## W5 — Auth Gitea OIDC + allowlist **File**: `web/auth.py`, aggiornamenti `web/app.py` + `web/routes.py`, `tests/python/test_web_auth.py`. 1. Registra Authlib OAuth client verso Gitea (`issuer` da `WebOidcConfig`, discovery `/.well-known/openid-configuration`). 2. Route: - `GET /login` → redirect authorize Gitea - `GET /auth/callback` → scambia code→token, legge `preferred_username`, verifica **allowlist** (`allowed_users`); se non in lista → **403**; altrimenti crea sessione firmata (`itsdangerous`, secret da keyring `session_secret_cred`). - `GET /logout` → invalida sessione. 3. Dependency `require_user()`: protegge **tutte** le route tranne `/login`, `/auth/callback`, `/healthz`, `/static/*`. Non autenticato → redirect `/login`. 4. Client secret OIDC + session secret **da keyring** (`credentials.KeyringCredentialStore`), mai da TOML. **Test**: mocka il provider OIDC (no rete reale). Casi: utente in allowlist → 200; fuori allowlist → 403; no sessione → redirect `/login`. **Acceptance**: tutte le route mutate/read protette; test auth verdi; nessun secret in chiaro nel repo o nei log; coverage ≥ 90 %. --- ## W6 — Azioni mutanti + safety **File**: `web/routes.py`, `web/security.py`, `web/templates/partials/*.html`, `tests/python/test_web_actions.py`. Route mutanti (tutte `POST`, tutte dietro `require_user` + CSRF): - `POST /vms/{vmx_id}/remove` → `operations.vms.remove_vm` - `POST /vms/cleanup` → `operations.vms.cleanup_orphans` - `POST /cache/purge` → `operations.cache.purge_cargo_cache` - `POST /host/backup` → `operations.host.backup_template` - `GET /audit` → `operations.audit.read_audit` (tabella) Safety (obbligatorio su ogni azione distruttiva): 1. **Confirm gate 2-step**: primo POST ritorna un partial di conferma con token CSRF; il secondo POST (confermato) esegue. Niente destroy one-click. 2. **Dry-run preview**: cleanup/purge di default mostrano "would remove N / free X" prima dell'esecuzione reale (checkbox "esegui davvero"). 3. **CSRF**: `web/security.py` genera token firmato per-sessione; ogni POST lo valida. 4. **Audit**: ogni azione (anche dry-run) → `append_audit` con `user` dalla sessione. 5. **Lock**: cleanup/remove rispettano `vm-start.lock` (stessa semantica del job) per non correre con un job attivo. **Test**: POST senza CSRF → 403; dry-run non chiama `backend.delete`; conferma reale chiama le funzioni `operations/` (mockate) e scrive audit; utente non-auth → redirect. **Acceptance**: 4 azioni funzionanti con conferma+audit; CSRF applicato; test verdi; coverage ≥ 90 %. --- ## W7 — UI dark Gitea + installer ripetibile + validazione ### W7a — UI dark, stile Gitea **File**: `web/static/app.css`, `web/static/htmx.min.js` (vendorizzato), `web/templates/*` completi. Design tokens (CSS variables in `:root`), tema dark tipo Gitea `gitea-dark`: ``` --bg:#16191d; --panel:#1c2126; --panel-2:#22272e; --border:#30363d; --text:#d1d5da; --text-dim:#8b949e; --accent:#3fb950; --accent-hover:#46c359; --danger:#f85149; --warn:#d29922; --link:#58a6ff; --radius:6px; --font: system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; --mono: ui-monospace,SFMono-Regular,Menlo,monospace; ``` Requisiti UI: - Navbar dark: logo/nome a sinistra, username Gitea + logout a destra. - Griglia di card (segments) con bordo sottile; righe tabella con hover-lit. - Badge stato: verde=success, rosso=failed, ambra=running/orphan. - Bottoni: verde primario, **rosso danger** per destroy. - Accessibilità: focus ring visibili, `aria-*` sui bottoni azione, `prefers-reduced-motion`. - **Asset vendorizzati**: `htmx.min.js` servito da `/static` (no CDN — l'host LAN può essere offline). CSS scritto a mano (~200 righe), **niente** Fomantic/framework CSS esterni. ### W7b — Installer ripetibile **File**: `setup-web-linux.sh` (root), `deploy/systemd/ci-web.service`, `docs/WEB-UI.md`. `setup-web-linux.sh` — **idempotente** (safe re-run, controlla prima di creare): 1. `sudo -u ci-runner /opt/ci/venv/bin/pip install '/opt/ci/local-ci-cd-system[web]'` (non-editable, come da CLAUDE.md per la venv di produzione). 2. Rendi `deploy/systemd/ci-web.service` in `/etc/systemd/system/` (User=`ci-runner`, `ExecStart=/opt/ci/venv/bin/python -m ci_orchestrator web serve --host --port 8800`, `Restart=on-failure`, `EnvironmentFile=` per `CI_CONFIG`). 3. Scrivi/aggiorna `[web]` + `[web.oidc]` in `/var/lib/ci/config.toml` (se assenti). 4. Salva i secret in keyring: `ci creds set WebOidcClientSecret` + `WebSessionSecret` (prompt, non da argv). 5. Stampa le **istruzioni di registrazione OIDC su Gitea** (passo manuale, vedi `docs/WEB-UI.md`). 6. `systemctl daemon-reload && systemctl enable --now ci-web.service`. Unit template `deploy/systemd/ci-web.service` (segui lo stile degli unit esistenti in `deploy/systemd/`). `docs/WEB-UI.md`: prerequisiti · registrazione app OIDC su Gitea (Settings → Applications → OAuth2, redirect `http://:8800/auth/callback`) · esecuzione installer · gestione (start/stop/log via `journalctl -u ci-web`) · **nota firewall: bind solo su IP LAN, mai `0.0.0.0` pubblico** · troubleshooting (login loop, allowlist, keyring). ### W7c — Validazione finale Esegui e allega output: ```bash 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 ``` Smoke manuale sull'host: installa, apri `http://:8800`, login Gitea, verifica le 4 card, esegui un **dry-run** di cleanup, controlla `/audit` e `web-audit.jsonl`. **Acceptance globale (Definition of done)**: vedi Summary. Tutti i comandi di verifica verdi; smoke manuale OK. --- ## Istruzioni per l'implementatore (modello più piccolo) **Vincoli non negoziabili** — valgono per OGNI passo: | Regola | Dettaglio | |--------|-----------| | TDD | Scrivi il test **prima** dell'implementazione. Rosso → verde → refactor. Un test per comportamento. | | Coverage | Gate **≥ 90 %**: `pytest --cov=ci_orchestrator --cov-fail-under=90`. Non abbassare la soglia. | | Tipi | `mypy --strict src` deve essere pulito. Type hints su **tutte** le funzioni pubbliche. Evita `Any` (solo in fixture/mock pytest). | | Lint | `ruff check src tests/python` pulito. | | `operations/` puro | **Zero `click`, zero `print`, zero I/O di presentazione** in `operations/`. Ritorna dataclass; la presentazione sta nei wrapper CLI o nei template web. | | Niente subprocess shell | **Mai** `subprocess.run(..., shell=True)`. `paramiko`/`pypsrp`/`keyring` per transport/cred. `systemctl` read-only via lista di argomenti (unico caso). | | Niente CDN | Tutti gli asset frontend vendorizzati sotto `web/static/`. L'host LAN può essere offline. | | Secret | Client secret OIDC e session secret **solo in keyring**, mai in TOML/log/repo. | | In-process | La web app importa `operations/` e `backends.load_backend(config)`. **Mai** shellare la CLI. | | Separazione backend | Non modificare rami WinRM e SSH nello stesso commit; qui non tocchi i transport, ma se capita, rispetta la regola. | **Ricetta per ogni passo Wn**: 1. Leggi la sezione Wn e i file esistenti citati (`Read` prima di editare). 2. Scrivi i test in `tests/python/test_*.py` (mock del backend/OIDC — **niente VM reali, niente rete reale**). 3. Implementa fino al verde. 4. Lancia i 3 comandi di verifica (pytest+cov, ruff, mypy). Se rosso, correggi prima di procedere. 5. Aggiorna la checklist master (spunta Wn) e committa con messaggio conventional-commits. 6. **Non** passare a Wn+1 finché Wn non è verde. **Do / Don't**: - ✅ Riusa `Config`, `load_backend`, `KeyringCredentialStore`, i pattern dei comandi esistenti. - ✅ Mantieni l'output testuale CLI **identico** dopo il refactor (i test CLI esistenti sono il contratto). - ❌ Non aggiungere Prometheus/Grafana/OTel/DB (fuori scope — vedi master plan archiviato). - ❌ Non aggiungere dipendenze al core; solo all'extra `[web]`. - ❌ Non bindare su `0.0.0.0`; default `127.0.0.1`, LAN esplicito solo via config/installer. - ❌ Non fare azioni distruttive senza confirm gate + audit. **Ordine di build consigliato**: W0 → W1 → W2 → W3 (backend/seam completo e testato) → W4 → W5 → W6 (web) → W7 (UI+deploy). I passi W1–W3 non toccano la web app: se ti blocchi sul web, il refactor `operations/` è comunque un miglioramento a sé stante. --- ## Rischi e gotcha - **`vmrun getGuestIPAddress`** può ritornare exit 0 con `"Error: The VMware Tools are not running..."` su stdout → filtra righe che iniziano con `"Error:"` in `list_live_vms`. - **OIDC redirect URL** deve combaciare esatto tra config Gitea e `WebOidcConfig.redirect_url` (host+porta+path), altrimenti login loop. - **Keyring headless**: la venv gira come `ci-runner` sotto systemd; verifica che il backend keyring sia accessibile in quel contesto (documenta il backend usato in `docs/WEB-UI.md`). - **Race con job attivo**: cleanup/remove dalla web app devono rispettare `vm-start.lock`. - **Coverage web**: usa `TestClient` + mock; non serve un browser reale nei test automatici (lo smoke manuale copre il resto). - **Path Windows residui**: `report.py` ha ancora default `F:\CI\Logs`; sull'host Linux i log stanno sotto `/var/lib/ci/logs`. Usa i path da `Config`, non hardcoded.