diff --git a/plans/web-ui-management-plan.md b/plans/web-ui-management-plan.md new file mode 100644 index 0000000..75550ef --- /dev/null +++ b/plans/web-ui-management-plan.md @@ -0,0 +1,452 @@ +# 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): + +- [ ] **W0** — Scaffolding: extra `[web]` in `pyproject.toml`, dir package `operations/` + `web/`, skeleton test. Nessuna logica. +- [ ] **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.** +- [ ] **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.