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
25 KiB
Interfaccia web di gestione VM/host — Piano di applicazione
Stato: PIANO CHIUSO (2026-07-23). Tutti i passi W0–W7 completati, mergeati in
maine 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 indocs/WEB-UI.md§0. Gate finale: 611 test, coverage 93.98 %,ruff/mypy --strictpuliti.
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.pylasciato invariato — refactor opzionale;disk_status/runner_statusverranno consumati dalla web app in W4). -
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 (fail-closed) + secret solo da keyring. Tutte le route dati protette (
/healthz,/static,/login,/auth/callbackaperte). Hardening:ci web serverifiuta 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.
SessionMiddlewaresempre attivo (secret effimero in open mode) per CSRF. HTMX partials. -
W7 — UI dark Gitea-style (asset vendorizzati: htmx 2.0.3 locale,
app.cssscritto 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.
- 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.