feat(harness): port memory/search/evidence/db/decision cmd (Onda 3.2)
5 cmd foglia portati con rename + drift fix: - memory_cmd: portato col modello registry INTATTO (TODO marker per il drop registry decisione spec 5 — task separato, richiede L2 per validare il rewrite su vectordb) - search_cmd: creata search_app sub-app (era funzione standalone in ChironeWp3), registrata come 'tht search find' - evidence_cmd, db_cmd, decision_cmd: port verbatim Drift fix decision_cmd: DECISION_MIN_PHASE.get(type,1) -> load_workflow().decision_min_phase(type). Check grep-per-file: ~15 residui nsp/PSD_SSL_CA nei messaggi utente fixati (nsp <cmd> -> tht <cmd>, nsp.yaml -> workspace yaml, PSD_SSL_CA -> THT_SSL_CA). Suite: 165 passed. tht --help ora mostra 10 sottocomandi.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from tht.cli.config_cmd import CONFIG_OPT
|
||||
from tht.config import ConfigError, load_config
|
||||
from tht.db.connection import can_create_in_schema, make_engine, ping, writable_tables
|
||||
from tht.db.fetch_ca import CaFetchError, describe_pem, fetch_chain_pem, parse_host_port
|
||||
|
||||
db_app = typer.Typer(help="Operazioni sul database target")
|
||||
|
||||
|
||||
def _ping_rest(cfg, schema: str) -> None:
|
||||
"""Health check via REST. Il read-only è garantito strutturalmente dall'API
|
||||
(ammette solo SELECT/WITH): non serve il controllo dei privilegi di scrittura."""
|
||||
from tht.rest.client import RestClient, RestError
|
||||
|
||||
try:
|
||||
info = RestClient(cfg.rest).ping()
|
||||
except RestError as e:
|
||||
typer.secho(f"ERRORE di connessione: {e}", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1)
|
||||
if not info.get("db_connected") or not info.get("schema_accessible"):
|
||||
typer.secho(
|
||||
f"ERRORE: DWH non accessibile via REST (risposta: {info}).",
|
||||
fg=typer.colors.RED, err=True,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
typer.secho(
|
||||
f"OK: connesso via REST a {cfg.rest.base_url} (schema {schema})", fg=typer.colors.GREEN
|
||||
)
|
||||
typer.secho(
|
||||
"OK: accesso read-only garantito dall'API (solo SELECT/WITH).", fg=typer.colors.GREEN
|
||||
)
|
||||
|
||||
|
||||
@db_app.command("ping")
|
||||
def ping_cmd(config: Path = CONFIG_OPT) -> None:
|
||||
"""Testa la connessione e verifica che l'utente sia effettivamente read-only."""
|
||||
try:
|
||||
cfg = load_config(config)
|
||||
except ConfigError as e:
|
||||
typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1)
|
||||
schema = cfg.database.db_schema
|
||||
if cfg.database.transport == "rest":
|
||||
_ping_rest(cfg, schema)
|
||||
return
|
||||
engine = make_engine(cfg.database)
|
||||
try:
|
||||
ping(engine)
|
||||
except OperationalError as e:
|
||||
typer.secho(f"ERRORE di connessione: {e.orig}", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1)
|
||||
typer.secho(f"OK: connesso a {cfg.database.database} (schema {schema})", fg=typer.colors.GREEN)
|
||||
|
||||
writable = writable_tables(engine, schema)
|
||||
can_create = can_create_in_schema(engine, schema)
|
||||
if writable or can_create:
|
||||
typer.secho(
|
||||
f"ERRORE: l'utente '{cfg.database.user}' NON e' read-only.", fg=typer.colors.RED, err=True
|
||||
)
|
||||
if writable:
|
||||
typer.echo(f" Tabelle scrivibili: {', '.join(writable[:10])}", err=True)
|
||||
if can_create:
|
||||
typer.echo(f" L'utente puo' creare oggetti nello schema {schema}.", err=True)
|
||||
typer.echo(" Crea un ruolo read-only con scripts/create_readonly_role.sql.", err=True)
|
||||
raise typer.Exit(code=2)
|
||||
typer.secho("OK: l'utente e' read-only sullo schema target.", fg=typer.colors.GREEN)
|
||||
|
||||
|
||||
@db_app.command("fetch-ca")
|
||||
def fetch_ca_cmd(
|
||||
config: Path = CONFIG_OPT,
|
||||
out: Path = typer.Option(
|
||||
Path("config/ca-chain.pem"), "--out", "-o", help="File PEM di destinazione."
|
||||
),
|
||||
) -> None:
|
||||
"""Scarica la catena CA presentata dal server REST e la salva in un bundle PEM.
|
||||
|
||||
Utile sulle postazioni *workstation* dietro una CA interna: il file va poi puntato
|
||||
con `THT_SSL_CA` nel `.env` (lo consuma `requests`). NON tocca il trust store dell'OS.
|
||||
"""
|
||||
try:
|
||||
cfg = load_config(config)
|
||||
except ConfigError as e:
|
||||
typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1)
|
||||
if cfg.rest is None:
|
||||
typer.secho(
|
||||
"ERRORE: comando pensato per postazioni con accesso REST. "
|
||||
"Configura la sezione `rest` (base_url) nel workspace yaml.",
|
||||
fg=typer.colors.RED, err=True,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Host del DWH REST; se il vector REST è su host diverso, aggiungi anche quello.
|
||||
targets: list[tuple[str, int]] = [parse_host_port(cfg.rest.base_url)]
|
||||
if cfg.vector_rest is not None:
|
||||
vec = parse_host_port(cfg.vector_rest.base_url)
|
||||
if vec not in targets:
|
||||
targets.append(vec)
|
||||
|
||||
pems: list[str] = []
|
||||
seen: set[str] = set()
|
||||
try:
|
||||
for host, port in targets:
|
||||
typer.echo(f"Recupero catena TLS da {host}:{port} ...")
|
||||
for pem in fetch_chain_pem(host, port, cfg.rest.timeout):
|
||||
key = pem.strip()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
pems.append(pem if pem.endswith("\n") else pem + "\n")
|
||||
except CaFetchError as e:
|
||||
typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text("".join(pems))
|
||||
abs_out = out.resolve()
|
||||
|
||||
typer.secho(
|
||||
f"OK: salvati {len(pems)} certificati -> {abs_out}", fg=typer.colors.GREEN
|
||||
)
|
||||
typer.echo("Controlla che corrispondano alla CA interna attesa:")
|
||||
for i, pem in enumerate(pems, 1):
|
||||
desc = describe_pem(pem)
|
||||
if desc:
|
||||
typer.echo(f" [{i}] {desc}")
|
||||
typer.echo(
|
||||
"NOTA: il recupero non verifica la fiducia; la verifica avviene al prossimo "
|
||||
"`tht db ping`, quando THT_SSL_CA punta a questo bundle."
|
||||
)
|
||||
|
||||
# Controlla se la config *effettiva* (THT_SSL_CA nel .env + ssl_ca nel workspace yaml)
|
||||
# gia' punta al bundle appena salvato: in caso contrario, il certificato non e'
|
||||
# ancora collegato e il `db ping` fallirebbe ancora con certificate verify failed.
|
||||
endpoints = [("rest", cfg.rest)]
|
||||
if cfg.vector_rest is not None:
|
||||
endpoints.append(("vector_rest", cfg.vector_rest))
|
||||
not_wired = [name for name, ep in endpoints if not _points_to(ep.ssl_ca, abs_out)]
|
||||
|
||||
if not not_wired:
|
||||
typer.secho(
|
||||
"OK: THT_SSL_CA punta gia' a questo bundle (rest"
|
||||
+ (", vector_rest" if cfg.vector_rest is not None else "")
|
||||
+ "). Lancia `tht db ping`.",
|
||||
fg=typer.colors.GREEN,
|
||||
)
|
||||
return
|
||||
|
||||
typer.secho(
|
||||
"\nATTENZIONE: il certificato non e' ancora collegato "
|
||||
f"(ssl_ca di {', '.join(not_wired)} non punta a questo bundle). Per attivarlo:",
|
||||
fg=typer.colors.YELLOW,
|
||||
)
|
||||
typer.echo(f" 1. nel .env imposta: THT_SSL_CA={abs_out}")
|
||||
typer.echo(
|
||||
" 2. in il workspace yaml, sotto `rest:`"
|
||||
+ (" e `vector_rest:`" if cfg.vector_rest is not None else "")
|
||||
+ ", decommenta/aggiungi:"
|
||||
)
|
||||
typer.echo(" ssl_ca: ${THT_SSL_CA}")
|
||||
typer.echo("Poi rilancia `tht db ping`.")
|
||||
|
||||
|
||||
def _points_to(ssl_ca: str | None, target: Path) -> bool:
|
||||
"""True se `ssl_ca` (path risolto) coincide col bundle appena salvato."""
|
||||
if not ssl_ca:
|
||||
return False
|
||||
try:
|
||||
return Path(ssl_ca).resolve() == target
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
Reference in New Issue
Block a user