docs(plans): add web UI management plan (W0–W7)
Server-rendered management UI (FastAPI + HTMX + Jinja2), Gitea OIDC auth, bare-systemd deploy, in-process operations/ seam reused by both CLI and web (no subprocess, load_backend kept for the ESXi path). Includes the implementer recipe (TDD, mypy --strict, coverage >=90, no CDN, secrets in keyring only). Added two implementer notes tying the refactor to open TODO items: - W1: fold the TODO §2.8 IP-pool leak fix into operations/vms.cleanup_orphans while moving it (same function the web "cleanup orphans" action calls). - W2: TODO §2.9 (retention blind to backups/templates/ISO) is orthogonal — operations/cache.py wraps only cargo-cache purge; do not widen W2 scope. report.py's residual F:\CI\Logs default is already flagged under Rischi.
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
# Interfaccia web di gestione VM/host — Piano di applicazione
|
||||
|
||||
> Piano operativo per una interfaccia web di **gestione** (non solo
|
||||
> monitoraggio) del sistema CI/CD. Generato il **2026-07-23**.
|
||||
> Eseguire i passi **in ordine**: ogni passo è autonomo, testabile e con
|
||||
> rollback banale (nessun file esistente viene rimosso; il codice nuovo vive
|
||||
> in package/dir nuovi finché W7 non chiude la validazione).
|
||||
>
|
||||
> **Destinatario**: questo piano è scritto per essere eseguito da un modello
|
||||
> più piccolo. Leggi prima la sezione **"Istruzioni per l'implementatore"**
|
||||
> in fondo: contiene i vincoli non negoziabili (TDD, `mypy --strict`,
|
||||
> coverage ≥ 90 %, niente `click.echo` in `operations/`, niente CDN, niente
|
||||
> `subprocess`) e i comandi di verifica da lanciare dopo ogni passo.
|
||||
|
||||
---
|
||||
|
||||
## Summary — checklist master
|
||||
|
||||
Stato passi (spunta man mano):
|
||||
|
||||
- [ ] **W0** — Scaffolding: extra `[web]` in `pyproject.toml`, dir package `operations/` + `web/`, skeleton test. Nessuna logica.
|
||||
- [ ] **W1** — Layer `operations/vms.py`: estrai la logica da `commands/vm.py` in funzioni tipate che ritornano dataclass. Refactor dei comandi CLI per chiamarle. **Comportamento CLI invariato.**
|
||||
- [ ] **W2** — Layer `operations/cache.py` + `operations/host.py`: stato cache cargo, purge, disco, runner, backup template. Refactor `retention.py` / `monitor.py`.
|
||||
- [ ] **W3** — `operations/audit.py`: append-only JSONL + lettura. Config `[web]` in `config.py`.
|
||||
- [ ] **W4** — App web core: `web/app.py` factory + `commands/web.py` (`ci web serve`). Dashboard **read-only** (VM, cache, disco, runner). Ancora **senza auth**, bind localhost.
|
||||
- [ ] **W5** — Auth: Gitea OIDC (Authlib) + sessione firmata + allowlist utenti. Tutte le route protette.
|
||||
- [ ] **W6** — Azioni mutanti + safety: remove VM, cleanup orfane, purge cache, backup. Confirm gate + CSRF + audit + dry-run. HTMX partials.
|
||||
- [ ] **W7** — UI dark Gitea-style (asset vendorizzati) + installer ripetibile (`setup-web-linux.sh`, unit systemd, `docs/WEB-UI.md`) + validazione finale (test, coverage, mypy, ruff, smoke manuale).
|
||||
|
||||
**Definition of done globale**: `ci web serve` gira come `ci-web.service`, login via Gitea, dashboard dark mostra stato live, le 4 azioni (kill VM / cleanup orfane / purge cache / backup) funzionano con conferma + audit, suite test verde con coverage ≥ 90 % e `mypy --strict` pulito.
|
||||
|
||||
---
|
||||
|
||||
## Contesto e decisioni approvate
|
||||
|
||||
Decisioni cablate in fase di brainstorming (2026-07-23):
|
||||
|
||||
| Tema | Scelta |
|
||||
|------|--------|
|
||||
| Scopo | **Gestione VM/host** (non solo monitoraggio) |
|
||||
| Auth | **Riusa Gitea come OIDC provider** (OAuth2) |
|
||||
| Azioni esposte | VM live lista + kill/remove · cleanup orfane bulk · stato+purge cache cargo · backup template + salute host (disk/runner) |
|
||||
| Stack | **Server-rendered HTML + HTMX** (FastAPI + Jinja2 + Authlib) |
|
||||
| Deploy | **Bare systemd** (`ci-web.service`), **niente container** — la web app deve chiamare `vmrun`/`load_backend` in-process sull'host |
|
||||
| UI | **Dark, stile Gitea**, molto curata, asset vendorizzati (no CDN) |
|
||||
| Installazione | **Ripetibile/idempotente** via `setup-web-linux.sh` + unit template + doc |
|
||||
|
||||
**Vincolo architetturale chiave**: la web app chiama la logica **in-process**
|
||||
importando il layer `operations/` — **mai** via `subprocess` sulla CLI.
|
||||
Riusa `backends.load_backend(config)` come `commands/job.py`, così la strada
|
||||
ESXi (Fase D) resta aperta.
|
||||
|
||||
---
|
||||
|
||||
## Architettura
|
||||
|
||||
```
|
||||
src/ci_orchestrator/
|
||||
operations/ # NUOVO — logica pura, ritorna dataclass, ZERO click.echo
|
||||
__init__.py
|
||||
vms.py # list_live_vms / remove_vm / list_orphans / cleanup_orphans
|
||||
cache.py # cargo_cache_status / purge_cargo_cache
|
||||
host.py # disk_status / runner_status / backup_template
|
||||
audit.py # append_audit / read_audit
|
||||
web/ # NUOVO
|
||||
__init__.py
|
||||
app.py # create_app(config) -> FastAPI
|
||||
auth.py # OIDC Gitea (Authlib), sessione, allowlist, require_user
|
||||
routes.py # endpoint HTMX
|
||||
security.py # CSRF token firmato per-sessione
|
||||
templates/ # Jinja2: base.html, dashboard.html, partials/*.html
|
||||
static/
|
||||
app.css # design tokens dark Gitea (~200 righe, scritto a mano)
|
||||
htmx.min.js # vendorizzato (no CDN)
|
||||
commands/
|
||||
web.py # NUOVO — `ci web serve --host --port`
|
||||
vm.py / retention.py / monitor.py # REFACTOR — chiamano operations/, mantengono click.echo
|
||||
deploy/systemd/
|
||||
ci-web.service # NUOVO — template unit
|
||||
setup-web-linux.sh # NUOVO — installer idempotente
|
||||
docs/WEB-UI.md # NUOVO — install + OIDC + operate + troubleshoot
|
||||
```
|
||||
|
||||
**Il seam è `operations/`**: sia i comandi CLI sia la web app lo importano.
|
||||
La logica esistente (es. `commands/vm.py::cleanup_orphans`) si sposta lì e si
|
||||
**splitta** in una parte "dati" (`list_orphans() -> list[Orphan]`) e una parte
|
||||
"azione" (`cleanup_orphans() -> list[RemovalResult]`). I `click.echo` risalgono
|
||||
nei wrapper dei comandi. La CLI non cambia comportamento; guadagna un `--json`
|
||||
più pulito.
|
||||
|
||||
---
|
||||
|
||||
## W0 — Scaffolding
|
||||
|
||||
**File**: `pyproject.toml`, `src/ci_orchestrator/operations/__init__.py`,
|
||||
`src/ci_orchestrator/web/__init__.py`, `tests/python/test_web_scaffold.py`.
|
||||
|
||||
1. Aggiungi extra opzionale in `pyproject.toml`:
|
||||
```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.
|
||||
Reference in New Issue
Block a user