Files
local-ci-cd-system/plans/web-ui-management-plan.md
T
Simone f0d856b644 fix(ip-pool): reconcile stale leases against live clones (§2.8, W1)
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.
2026-07-23 17:15:23 +02:00

23 KiB
Raw Blame History

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. (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:
    [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)):

@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:

  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.

# 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.

@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.

  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 <ip> --port <n> → 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 <LAN> --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://<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:

  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.