A job killed mid-flight never released its IP lease; with only 4 pool addresses a single leak exhausted the pool ("IP pool exhausted after 60s"). IpSlotPool.reconcile() releases leases whose owner clone-dir is gone; vm cleanup runs it only against the real build_vms base. Closes TODO §2.8.
23 KiB
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 %, nienteclick.echoinoperations/, niente CDN, nientesubprocess) e i comandi di verifica da lanciare dopo ogni passo.
Summary — checklist master
Stato passi (spunta man mano):
- W0 — Scaffolding: extra
[web]inpyproject.toml, dir packageoperations/+web/, skeleton test. Nessuna logica. - W1 — Layer
operations/vms.py: estrai la logica dacommands/vm.pyin funzioni tipate che ritornano dataclass. Refactor dei comandi CLI per chiamarle. Comportamento CLI invariato. (Incluso: seamVmBackend.list_running()+ fold del fix §2.8 IP-pool leak viaIpSlotPool.reconcile/operations.vms.reconcile_ip_leases.) - W2 — Layer
operations/cache.py+operations/host.py: stato cache cargo, purge, disco, runner, backup template. Refactorretention.py/monitor.py. - W3 —
operations/audit.py: append-only JSONL + lettura. Config[web]inconfig.py. - W4 — App web core:
web/app.pyfactory +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.
- Aggiungi extra opzionale in
pyproject.toml:Il core ([project.optional-dependencies] web = [ "fastapi>=0.115", "uvicorn[standard]>=0.30", "jinja2>=3.1", "authlib>=1.3", "itsdangerous>=2.2", "httpx>=0.27", ]dependencies) non cambia: la CLI resta dep-light. - Crea le dir package con
__init__.pyvuoti. - Test scaffold: importa
ci_orchestrator.operationseci_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)):
@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):
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:
- Sposta il corpo di
commands/vm.py::cleanup_orphans(attuale righe ~218–298) inoperations/vms.py. Rimuovi tutti iclick.echodalla funzione; sostituiscili accumulandoRemovalResult. - Estrai
_list_orphans/_find_vmxinoperations/vms.pycomelist_orphans/ helper. list_live_vms: parse divmrun list(VM in esecuzione) + lettura campoguestOSdai VMX + IP via backend/guestinfo. Filtra le righe"Error:"(gotcha notogetGuestIPAddress).- Refactor
commands/vm.py: i comandi click chiamanooperations/e fannoclick.echosui 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_orphansinoperations/vms.pyed è 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 incleanup_orphans(o inlist_orphans) il rilascio dei lease il cui clone-dir non esiste più inCloneBaseDir, 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.
# 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) emonitor.py(_service_status, disk) spostandola/adattandola; togli iclick.echo. runner_statusinterrogasystemctl is-activeperact-runner.serviceeact-runner-rust.service(i due runner reali sull'host). Non usareshell=True; usasubprocess.run([...], capture_output=True)con lista di argomenti (unico caso ammesso: interrogazione read-only di systemctl, già presente inmonitor.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 runcieco su backup/template/ISO) è ORTOGONALE, non risolverlo qui.operations/cache.pywrappa 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.
@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
@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.
create_app(config: Config) -> FastAPI: monta static, configura Jinja2, registra le route read-only.- 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)
commands/web.py:ci web serve --host <ip> --port <n>→uvicorn.run(create_app(cfg), ...). Default host/port daWebConfig. Bind 127.0.0.1 in questo passo.- 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.
- Registra Authlib OAuth client verso Gitea (
issuerdaWebOidcConfig, discovery/.well-known/openid-configuration). - Route:
GET /login→ redirect authorize GiteaGET /auth/callback→ scambia code→token, leggepreferred_username, verifica allowlist (allowed_users); se non in lista → 403; altrimenti crea sessione firmata (itsdangerous, secret da keyringsession_secret_cred).GET /logout→ invalida sessione.
- Dependency
require_user(): protegge tutte le route tranne/login,/auth/callback,/healthz,/static/*. Non autenticato → redirect/login. - 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_vmPOST /vms/cleanup→operations.vms.cleanup_orphansPOST /cache/purge→operations.cache.purge_cargo_cachePOST /host/backup→operations.host.backup_templateGET /audit→operations.audit.read_audit(tabella)
Safety (obbligatorio su ogni azione distruttiva):
- Confirm gate 2-step: primo POST ritorna un partial di conferma con token CSRF; il secondo POST (confermato) esegue. Niente destroy one-click.
- Dry-run preview: cleanup/purge di default mostrano "would remove N / free X" prima dell'esecuzione reale (checkbox "esegui davvero").
- CSRF:
web/security.pygenera token firmato per-sessione; ogni POST lo valida. - Audit: ogni azione (anche dry-run) →
append_auditconuserdalla sessione. - 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.jsservito 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):
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).- Rendi
deploy/systemd/ci-web.servicein/etc/systemd/system/(User=ci-runner,ExecStart=/opt/ci/venv/bin/python -m ci_orchestrator web serve --host <LAN> --port 8800,Restart=on-failure,EnvironmentFile=perCI_CONFIG). - Scrivi/aggiorna
[web]+[web.oidc]in/var/lib/ci/config.toml(se assenti). - Salva i secret in keyring:
ci creds set WebOidcClientSecret+WebSessionSecret(prompt, non da argv). - Stampa le istruzioni di registrazione OIDC su Gitea (passo manuale, vedi
docs/WEB-UI.md). 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://<LAN>: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:
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://<LAN>: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:
- Leggi la sezione Wn e i file esistenti citati (
Readprima di editare). - Scrivi i test in
tests/python/test_*.py(mock del backend/OIDC — niente VM reali, niente rete reale). - Implementa fino al verde.
- Lancia i 3 comandi di verifica (pytest+cov, ruff, mypy). Se rosso, correggi prima di procedere.
- Aggiorna la checklist master (spunta Wn) e committa con messaggio conventional-commits.
- 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; default127.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 getGuestIPAddresspuò ritornare exit 0 con"Error: The VMware Tools are not running..."su stdout → filtra righe che iniziano con"Error:"inlist_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-runnersotto systemd; verifica che il backend keyring sia accessibile in quel contesto (documenta il backend usato indocs/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.pyha ancora defaultF:\CI\Logs; sull'host Linux i log stanno sotto/var/lib/ci/logs. Usa i path daConfig, non hardcoded.