Files
local-ci-cd-system/plans/web-ui-management-plan.md
T
Simone af7dee60e5 feat(web): Executions card replaces Live VMs
The dashboard could only show what was powered on this instant, so it could
not answer "did the last build pass?" — and no local execution history exists
to answer it from (`report job` reads a PowerShell-era JSONL path that nothing
writes on this host, and the recent artifact dirs are empty).

Recent runs now come from Gitea's Actions API (`operations/executions.py`):
`/api/v1/admin/actions/runs` for every repo on the instance, falling back to
`/api/v1/user/actions/runs` when the token is not admin-scoped. One list call
per refresh, cached 10s, so the card's 5s htmx poll costs Gitea at most one
call per window. Live VMs are grouped under the run that spawned them — the
job id is `{run_id}-{run_attempt}[-suffix]` and the clone name carries it — so
a matrix run collects several VMs on one row. Every failure mode (no token,
401, unreachable Gitea, malformed payload) degrades to a notice plus the plain
VM rows, each keeping its Remove button, so the card is never less useful than
the table it replaces. Orphans stay where they were: the sweep in Maintenance,
the count in the status-header tile.

Auth reuses the access token from the operator's own OIDC login, which
`auth.py` previously discarded. SECURITY: that token is kept server-side only
(`app.state.gitea_tokens`, dropped on logout) and never written into the
session — `SessionMiddleware` signs the session cookie but does not encrypt
it, and this service is deployed over plain HTTP on the LAN, so a bearer token
in there would be readable in the browser and on the wire. An expired token is
refreshed once; `[web] gitea_token_cred` names an optional keyring PAT for the
cases a login token cannot cover (service restart, non-admin operator).

`GET /vms` and `partials/vms.html` are gone; `POST /vms/{id}/remove` and
`POST /vms/cleanup` are untouched.

Design: docs/superpowers/specs/2026-07-25-executions-card-design.md
2026-07-25 14:55:27 +02:00

25 KiB
Raw Blame History

Interfaccia web di gestione VM/host — Piano di applicazione

Stato: PIANO CHIUSO (2026-07-23). Tutti i passi W0–W7 completati, mergeati in main e pushati. ci-web.service è live in produzione su questo host: http://192.168.10.11:8800, auth Gitea OIDC attiva (https://gitea.emulab.it), allowlist in /var/lib/ci/config.toml. Dettagli operativi in docs/WEB-UI.md §0. Gate finale: 611 test, coverage 93.98 %, ruff/mypy --strict puliti.

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 lasciato invariato — refactor opzionale; disk_status/runner_status verranno consumati dalla web app in W4).

  • 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 (fail-closed) + secret solo da keyring. Tutte le route dati protette (/healthz,/static,/login,/auth/callback aperte). Hardening: ci web serve rifiuta bind non-loopback quando [web.oidc] assente (open mode solo su localhost).

  • W6 — Azioni mutanti + safety: remove VM, cleanup orfane, purge cache, backup. Confirm gate 2-step (preview dry-run → execute) + CSRF sincronizzatore + audit (preview+execute) + path-containment (clone dir strettamente sotto build_vms) + 409 job-race guard. SessionMiddleware sempre attivo (secret effimero in open mode) per CSRF. HTMX partials.

  • W7 — UI dark Gitea-style (asset vendorizzati: htmx 2.0.3 locale, app.css scritto a mano) + installer ripetibile idempotente (setup-web-linux.sh, deploy/systemd/ci-web.service, docs/WEB-UI.md) + validazione finale (611 test, coverage 93.98%, mypy/ruff puliti). Smoke manuale sull'host: TODO operatore (installa + login Gitea + 4 card + dry-run cleanup + /audit).

  • Stage F — Card Executions al posto di "Live VMs": run recenti da Gitea Actions (operations/executions.py, GET /executions), quelli live inclusi, con le VM raggruppate sotto il run che le ha generate (job id {run_id}-{run_attempt} ↔ nome clone). Token API = access token del login OIDC tenuto solo lato server (app.state.gitea_tokens, mai nel cookie di sessione: firmato ma non cifrato, HTTP in chiaro sulla LAN), refresh una volta su 401, PAT keyring opzionale ([web] gitea_token_cred) come fallback. Cache TTL 10 s sull'API, VM rilette a ogni poll. Ogni stato degradato mostra un notice e continua a elencare le VM accese. Spec: docs/superpowers/specs/2026-07-25-executions-card-design.md.

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.