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
462 lines
25 KiB
Markdown
462 lines
25 KiB
Markdown
# 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):
|
||
|
||
- [x] **W0** — Scaffolding: extra `[web]` in `pyproject.toml`, dir package `operations/` + `web/`, skeleton test. Nessuna logica.
|
||
- [x] **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`.)
|
||
- [x] **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).
|
||
- [x] **W3** — `operations/audit.py`: append-only JSONL + lettura. Config `[web]` in `config.py`.
|
||
- [x] **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.
|
||
- [x] **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).
|
||
- [x] **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.
|
||
- [x] **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`).
|
||
|
||
- [x] **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`:
|
||
```toml
|
||
[project.optional-dependencies]
|
||
web = [
|
||
"fastapi>=0.115",
|
||
"uvicorn[standard]>=0.30",
|
||
"jinja2>=3.1",
|
||
"authlib>=1.3",
|
||
"itsdangerous>=2.2",
|
||
"httpx>=0.27",
|
||
]
|
||
```
|
||
Il core (`dependencies`) **non** cambia: la CLI resta dep-light.
|
||
2. Crea le dir package con `__init__.py` vuoti.
|
||
3. Test scaffold: importa `ci_orchestrator.operations` e `ci_orchestrator.web`,
|
||
verifica che l'import non fallisca.
|
||
|
||
**Acceptance**: `pip install -e '.[web,dev]'` OK; `pytest tests/python/test_web_scaffold.py` verde; `mypy --strict src` pulito; suite esistente invariata.
|
||
|
||
---
|
||
|
||
## W1 — `operations/vms.py` + refactor `commands/vm.py`
|
||
|
||
**File**: `src/ci_orchestrator/operations/vms.py`, `src/ci_orchestrator/commands/vm.py`, `tests/python/test_operations_vms.py`.
|
||
|
||
Dataclass (tutte `@dataclass(frozen=True)`):
|
||
```python
|
||
@dataclass(frozen=True)
|
||
class LiveVm:
|
||
name: str
|
||
vmx: str
|
||
ip: str | None
|
||
guest_os: str
|
||
uptime_sec: int | None
|
||
is_orphan: bool
|
||
|
||
@dataclass(frozen=True)
|
||
class Orphan:
|
||
path: Path
|
||
vmx: Path | None
|
||
age_hours: int
|
||
|
||
@dataclass(frozen=True)
|
||
class RemovalResult:
|
||
target: str
|
||
stopped: bool
|
||
deleted: bool
|
||
dir_removed: bool
|
||
error: str | None
|
||
```
|
||
|
||
Funzioni (firme esatte, type hints obbligatori):
|
||
```python
|
||
def list_live_vms(backend: VmBackend, config: Config) -> list[LiveVm]: ...
|
||
def list_orphans(clone_base: Path, max_age_hours: int) -> list[Orphan]: ...
|
||
def remove_vm(backend: VmBackend, vmx: Path, *, dry_run: bool = False) -> RemovalResult: ...
|
||
def cleanup_orphans(
|
||
clone_base: Path,
|
||
max_age_hours: int,
|
||
backend: VmBackend | None,
|
||
*,
|
||
dry_run: bool = False,
|
||
lock_file: Path | None = None,
|
||
lock_max_age_minutes: int = 30,
|
||
) -> list[RemovalResult]: ...
|
||
```
|
||
|
||
Passi:
|
||
1. Sposta il corpo di `commands/vm.py::cleanup_orphans` (attuale righe ~218–298) in `operations/vms.py`. **Rimuovi tutti i `click.echo`** dalla funzione; sostituiscili accumulando `RemovalResult`.
|
||
2. Estrai `_list_orphans` / `_find_vmx` in `operations/vms.py` come `list_orphans` / helper.
|
||
3. `list_live_vms`: parse di `vmrun list` (VM in esecuzione) + lettura campo `guestOS` dai VMX + IP via backend/guestinfo. Filtra le righe `"Error:"` (gotcha noto `getGuestIPAddress`).
|
||
4. Refactor `commands/vm.py`: i comandi click chiamano `operations/` e fanno `click.echo` sui risultati. **Output testuale CLI identico a prima** (confronta con i test esistenti).
|
||
|
||
**Acceptance**: `pytest tests/python/test_operations_vms.py tests/python/test_commands_vm*.py` verde; nessun `click` importato in `operations/vms.py`; `mypy --strict` pulito; coverage del modulo ≥ 90 %.
|
||
|
||
> **Nota — piega qui il fix di TODO §2.8 (IP-pool leak).** Questo passo sposta
|
||
> `cleanup_orphans` in `operations/vms.py` ed è la stessa funzione che l'azione
|
||
> web "cleanup orfane" (W6) invocherà. TODO §2.8 è un bug reale aperto: un job
|
||
> killato mid-flight leaka il lease IP in modo permanente e con soli 4 IP
|
||
> (`192.168.79.201–204`) un singolo leak esaurisce il pool (`IP pool exhausted
|
||
> after 60s`). Mentre refattori, aggiungi in `cleanup_orphans` (o in
|
||
> `list_orphans`) il rilascio dei lease il cui clone-dir non esiste più in
|
||
> `CloneBaseDir`, con test dedicato. Chiude §2.8 senza un secondo giro sullo
|
||
> stesso codice. **Non** è un requisito d'accettazione di W1 (la UI funziona
|
||
> comunque): è un'opportunità di coalescing, non un blocco.
|
||
|
||
---
|
||
|
||
## W2 — `operations/cache.py` + `operations/host.py`
|
||
|
||
**File**: `operations/cache.py`, `operations/host.py`, refactor `commands/retention.py` + `commands/monitor.py`, `tests/python/test_operations_cache.py`, `tests/python/test_operations_host.py`.
|
||
|
||
```python
|
||
# cache.py
|
||
@dataclass(frozen=True)
|
||
class CacheStatus:
|
||
root: Path
|
||
total_bytes: int
|
||
entry_count: int
|
||
oldest_age_days: int | None
|
||
retention_days: int
|
||
|
||
@dataclass(frozen=True)
|
||
class PurgeResult:
|
||
removed_entries: int
|
||
freed_bytes: int
|
||
dry_run: bool
|
||
|
||
def cargo_cache_status(config: Config) -> CacheStatus: ...
|
||
def purge_cargo_cache(config: Config, *, retention_days: int | None = None, dry_run: bool = False) -> PurgeResult: ...
|
||
|
||
# host.py
|
||
@dataclass(frozen=True)
|
||
class DiskStatus:
|
||
path: Path
|
||
total_gb: float
|
||
free_gb: float
|
||
used_pct: float
|
||
|
||
@dataclass(frozen=True)
|
||
class RunnerStatus:
|
||
name: str
|
||
active: bool
|
||
sub_state: str
|
||
|
||
@dataclass(frozen=True)
|
||
class BackupResult:
|
||
template: str
|
||
archive_path: Path | None
|
||
ok: bool
|
||
error: str | None
|
||
|
||
def disk_status(path: Path) -> DiskStatus: ...
|
||
def runner_status(service_names: list[str]) -> list[RunnerStatus]: ...
|
||
def backup_template(config: Config, template: str, *, dry_run: bool = False) -> BackupResult: ...
|
||
```
|
||
|
||
- Riusa la logica esistente di `retention.py` (`_purge_cargo_cache`) e `monitor.py` (`_service_status`, disk) spostandola/adattandola; togli i `click.echo`.
|
||
- `runner_status` interroga `systemctl is-active` per `act-runner.service` e `act-runner-rust.service` (i due runner reali sull'host). **Non** usare `shell=True`; usa `subprocess.run([...], capture_output=True)` con lista di argomenti (unico caso ammesso: interrogazione read-only di systemctl, già presente in `monitor.py`).
|
||
|
||
**Acceptance**: test verdi per entrambi i moduli; comandi `retention run` / `monitor disk` / `monitor runner` invariati nell'output; coverage ≥ 90 %.
|
||
|
||
> **Nota — TODO §2.9 (`retention run` cieco su backup/template/ISO) è
|
||
> ORTOGONALE, non risolverlo qui.** `operations/cache.py` wrappa solo lo stato
|
||
> e la purge della cache **cargo**; la UI (W6) espone "purge cache cargo", non
|
||
> la policy di retention del disco. §2.9 è un buco separato: il timer di
|
||
> retention agisce su artifact/log/lease (52 KB) mentre il disco si riempiva
|
||
> per backup/template/ISO (fuori dal suo raggio). Non allargare lo scopo di W2
|
||
> per coprirlo — segnalo e basta. Se vuoi che la UI mostri lo stato disco
|
||
> reale, `operations/host.py::disk_status` (già in questo passo) lo copre;
|
||
> l'estensione della *policy* automatica resta un task TODO a sé.
|
||
|
||
---
|
||
|
||
## W3 — `operations/audit.py` + config `[web]`
|
||
|
||
**File**: `operations/audit.py`, `src/ci_orchestrator/config.py`, `tests/python/test_operations_audit.py`, `tests/python/test_config_web.py`, `config.example.toml`.
|
||
|
||
```python
|
||
@dataclass(frozen=True)
|
||
class AuditEntry:
|
||
ts: str # ISO 8601 UTC
|
||
user: str
|
||
action: str # "vm.remove" | "vm.cleanup" | "cache.purge" | "host.backup"
|
||
target: str
|
||
result: str # "ok" | "error" | "dry-run"
|
||
detail: str
|
||
|
||
def append_audit(log_path: Path, entry: AuditEntry) -> None: ... # append JSONL, mai troncare
|
||
def read_audit(log_path: Path, *, last: int = 100) -> list[AuditEntry]: ...
|
||
```
|
||
|
||
Config: aggiungi a `config.py`
|
||
```python
|
||
@dataclass(frozen=True)
|
||
class WebConfig:
|
||
enabled: bool = False
|
||
bind_host: str = "127.0.0.1"
|
||
bind_port: int = 8800
|
||
audit_log: Path = Path("/var/lib/ci/logs/web-audit.jsonl")
|
||
allowed_users: list[str] = field(default_factory=list) # username Gitea in allowlist
|
||
session_secret_cred: str = "WebSessionSecret" # nome chiave keyring
|
||
oidc: WebOidcConfig | None = None
|
||
|
||
@dataclass(frozen=True)
|
||
class WebOidcConfig:
|
||
issuer: str # es. https://gitea.lan
|
||
client_id: str
|
||
redirect_url: str # es. http://192.168.x.x:8800/auth/callback
|
||
client_secret_cred: str = "WebOidcClientSecret" # nome chiave keyring, NO secret in TOML
|
||
scopes: list[str] = field(default_factory=lambda: ["openid", "profile"])
|
||
```
|
||
Aggiungi il parsing `[web]` / `[web.oidc]` in `load_config` e la sezione annotata in `config.example.toml`. **Nessun segreto in chiaro nel TOML** — solo il *nome* della chiave keyring.
|
||
|
||
**Acceptance**: config carica `[web]`/`[web.oidc]`; assenti → `web=disabled`; audit round-trip (append→read) testato; coverage ≥ 90 %.
|
||
|
||
---
|
||
|
||
## W4 — App web core (read-only, senza auth)
|
||
|
||
**File**: `web/app.py`, `commands/web.py`, `web/templates/base.html`, `web/templates/dashboard.html`, `tests/python/test_web_app.py`.
|
||
|
||
1. `create_app(config: Config) -> FastAPI`: monta static, configura Jinja2, registra le route read-only.
|
||
2. Route read-only (rendono HTML/partials):
|
||
- `GET /` → dashboard (card: VM live, cache, disco, runner)
|
||
- `GET /vms` → tabella VM (partial, `hx-trigger="every 5s"`)
|
||
- `GET /cache` → stato cache (partial)
|
||
- `GET /host` → disco + runner (partial)
|
||
- `GET /healthz` → `{"status":"ok"}` (per systemd/monitor)
|
||
3. `commands/web.py`: `ci web serve --host <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:
|
||
```bash
|
||
python -m pytest tests/python -q --cov=ci_orchestrator --cov-fail-under=90
|
||
python -m ruff check src tests/python
|
||
python -m mypy --strict src
|
||
```
|
||
Smoke manuale sull'host: installa, apri `http://<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.
|