CLAUDE.md and docs/ARCHITECTURE.md were still missing the operations/ and web/ packages added by the web-UI plan (W0-W7). Also records that ci-web.service is now live on this host (LAN + Gitea OIDC) in docs/WEB-UI.md and closes plans/web-ui-management-plan.md with a status header.
460 lines
24 KiB
Markdown
460 lines
24 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`).
|
||
|
||
**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.
|