ligbox-ops-platform/projects/ops-desk/api/app/vm112_domains.py
Ligbox Spec Hub edffd8b3c0 feat(desk): Serviços IaaS perf, Escopo OPS cards e blocklist UI (Spec 017/018)
Cache VM112/VM122 para lista e detalhe domínio, modal purge com loading animado,
cards Escopo OPS clicáveis (camada + Spec + navegação), blocklist visível na UI,
e documentação nas specs e anais de referência 20260625.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 18:53:57 +00:00

471 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Proxy VM112 domínios orquestrados + limpeza Desk (Spec 017)."""
from __future__ import annotations
import os
import sqlite3
import time
from datetime import datetime, timezone
from threading import Lock
from typing import Any
import httpx
from app import auth
VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090")
VM112_ADMIN_API_KEY = os.getenv("VM112_ADMIN_API_KEY", "ibytera-corp-api-key-change-later")
# Purge Carbonio/CF/Traefik pode demorar vários minutos — evitar httpx "timed out" prematuro.
VM112_PURGE_HTTP_TIMEOUT = float(os.getenv("VM112_PURGE_HTTP_TIMEOUT", "300"))
VM112_LIST_TIMEOUT = float(os.getenv("VM112_LIST_TIMEOUT", "30"))
VM112_DOMAINS_CACHE_TTL = float(os.getenv("VM112_DOMAINS_CACHE_TTL", "60"))
VM112_DOMAIN_DETAIL_TTL = float(os.getenv("VM112_DOMAIN_DETAIL_TTL", "45"))
_DOMAINS_CACHE: dict[str, Any] | None = None
_DOMAINS_CACHE_AT = 0.0
_DOMAINS_CACHE_LOCK = Lock()
_DOMAIN_DETAIL_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
PURGE_BLOCKLIST = frozenset({"ligbox.com.br", "itecnologys.com"})
# Exige código de autorização gerado em Infra (root) — além de senha Root no purge.
PURGE_EXTRA_AUTH_DOMAINS = frozenset({"myvexx.com"})
VM112_PURGE_STEP_LABELS = (
"Contas Carbonio (zmprov da)",
"Domínio Carbonio (zmprov dd)",
"Portal users Self-Service",
"Pasta ligbox-sites",
"Zona Cloudflare Ibytera",
"Traefik / SNI CT114",
"Logs de sessão wizard",
)
# Origem da solicitação de expurgo (user = JWT / sessão; source = canal)
PURGE_SOURCE_LABELS: dict[str, str] = {
"desk.ui.purge": "Desk Purge domínio (UI)",
"desk.api.purge": "Desk Purge domínio (API sync)",
"desk.api.purge-jobs": "Desk Purge domínio (job async)",
"desk.api.purge-stream": "Desk Purge domínio (SSE)",
"cursor.agent": "Cursor Agent (comando chat)",
"domain-console.sandbox": "Domain Console sandbox",
"desk.agentic": "Desk rotina agentic",
}
def purge_source_label(source: str) -> str:
return PURGE_SOURCE_LABELS.get(source, source or "desconhecido")
def _ts() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _timeline_entry(label: str, status: str, detail: str = "") -> dict[str, str]:
return {"at": _ts(), "label": label, "status": status, "detail": detail}
def _vm112_headers() -> dict[str, str]:
return {"X-Api-Key": VM112_ADMIN_API_KEY}
def verify_root_password(conn: sqlite3.Connection, password: str) -> bool:
row = conn.execute(
"SELECT password_hash FROM desk_users WHERE username = 'root' AND active = 1"
).fetchone()
if not row or not row["password_hash"]:
return False
return auth.verify_password(password, row["password_hash"])
def requires_purge_extra_auth(domain: str) -> bool:
return domain.lower().strip() in PURGE_EXTRA_AUTH_DOMAINS
def delete_carbonio_account(email: str) -> dict[str, Any]:
"""Remove uma conta Carbonio (zmprov da) — Spec 022."""
email = email.lower().strip()
if "@" not in email:
raise ValueError("e-mail inválido")
domain = email.split("@", 1)[1]
if domain in PURGE_BLOCKLIST:
raise ValueError(f"Domínio protegido: {domain}")
with httpx.Client(timeout=120.0) as client:
r = client.post(
f"{VM112_API}/api/admin/accounts/{email}/delete",
headers=_vm112_headers(),
)
if r.status_code == 404:
return {"ok": True, "email": email, "message": "Conta já não existia no Carbonio", "skipped": True}
r.raise_for_status()
data = r.json()
return {
"ok": True,
"email": email,
"message": data.get("message") or f"Conta {email} removida",
"detail": data,
}
def list_domains(query: str = "", *, force_refresh: bool = False) -> dict[str, Any]:
"""Lista domínios VM112 — cache TTL curto (Serviços IaaS poll frequente)."""
global _DOMAINS_CACHE, _DOMAINS_CACHE_AT
if not query and not force_refresh:
with _DOMAINS_CACHE_LOCK:
if _DOMAINS_CACHE is not None and time.time() - _DOMAINS_CACHE_AT < VM112_DOMAINS_CACHE_TTL:
return {**_DOMAINS_CACHE, "cached": True, "cache_age_sec": int(time.time() - _DOMAINS_CACHE_AT)}
with httpx.Client(timeout=VM112_LIST_TIMEOUT) as client:
r = client.get(
f"{VM112_API}/api/admin/domains",
params={"q": query} if query else None,
headers=_vm112_headers(),
)
r.raise_for_status()
data = r.json()
if not query:
with _DOMAINS_CACHE_LOCK:
_DOMAINS_CACHE = data
_DOMAINS_CACHE_AT = time.time()
return data
def get_domain(domain: str) -> dict[str, Any]:
domain = domain.lower().strip()
now = time.time()
cached = _DOMAIN_DETAIL_CACHE.get(domain)
if cached and now - cached[0] < VM112_DOMAIN_DETAIL_TTL:
data = dict(cached[1])
else:
with httpx.Client(timeout=VM112_LIST_TIMEOUT) as client:
r = client.get(
f"{VM112_API}/api/admin/domains/{domain}",
headers=_vm112_headers(),
)
r.raise_for_status()
data = r.json()
if isinstance(data, dict):
_DOMAIN_DETAIL_CACHE[domain] = (now, data)
if isinstance(data, dict):
data["purge_extra_auth_required"] = requires_purge_extra_auth(domain)
data["purge_blocked"] = domain in PURGE_BLOCKLIST
return data
def domain_exists_on_vm112(domain: str) -> bool:
"""True se o domínio ainda consta na lista orquestrada VM112."""
domain = domain.lower().strip()
try:
data = list_domains()
items = data.get("domains") if isinstance(data, dict) else data
if not isinstance(items, list):
return True
for item in items:
name = item.get("domain") if isinstance(item, dict) else item
if str(name or "").lower().strip() == domain:
return True
return False
except Exception:
# VM112 indisponível — não assumir removido durante poll
return True
def start_purge_vm112(domain: str) -> dict[str, Any]:
"""Inicia purge assíncrono na VM112 (Spec 017 Fase 3)."""
domain = domain.lower().strip()
with httpx.Client(timeout=VM112_PURGE_HTTP_TIMEOUT) as client:
r = client.post(
f"{VM112_API}/api/admin/domains/{domain}/purge",
headers=_vm112_headers(),
)
r.raise_for_status()
return r.json()
def poll_purge_vm112_job(job_id: str) -> dict[str, Any]:
with httpx.Client(timeout=VM112_PURGE_HTTP_TIMEOUT) as client:
r = client.get(
f"{VM112_API}/api/admin/domains/purge-jobs/{job_id}",
headers=_vm112_headers(),
)
r.raise_for_status()
return r.json()
def vm112_job_steps_timeline(job: dict[str, Any]) -> list[dict[str, str]]:
"""Passos individuais VM112 durante execução (Fase 3)."""
out: list[dict[str, str]] = []
for step in job.get("steps") or []:
if not isinstance(step, dict):
continue
st = str(step.get("status") or "pending")
if st == "pending":
continue
label = str(step.get("label") or "Passo VM112")
if st == "done":
status = "ok"
elif st == "error":
status = "fail"
else:
status = "running"
detail = str(step.get("detail") or "")
at = step.get("finished_at") or step.get("started_at") or _ts()
out.append({"at": at, "label": label, "status": status, "detail": detail})
return out
def purge_vm112_with_poll(domain: str, poll_interval: float = 1.5, timeout: float = 600.0):
"""Generator: (event_type, payload) — passos em tempo real + resultado final."""
import time
started = start_purge_vm112(domain)
job_id = started.get("job_id")
if not job_id:
yield ("final", started)
return
t0 = time.monotonic()
deadline = t0 + timeout
seen = 0
poll_errors = 0
while time.monotonic() < deadline:
try:
job = poll_purge_vm112_job(job_id)
poll_errors = 0
except Exception as exc:
poll_errors += 1
err = str(exc) or "erro poll VM112"
yield (
"heartbeat",
{
"elapsed": int(time.monotonic() - t0),
"job_id": job_id,
"warn": err,
"poll_errors": poll_errors,
},
)
if poll_errors >= 5 and not domain_exists_on_vm112(domain):
yield (
"final",
{
"ok": True,
"job_id": job_id,
"recovered": True,
"steps": [],
"result": {"message": "Domínio ausente na VM112 após falhas de poll"},
},
)
return
time.sleep(poll_interval)
continue
steps = vm112_job_steps_timeline(job)
if len(steps) > seen:
for step in steps[seen:]:
yield ("step", step)
seen = len(steps)
status = job.get("status")
if status == "completed":
yield (
"final",
{
"ok": True,
"job_id": job_id,
"steps": steps,
"result": job.get("result") or {},
},
)
return
if status == "failed":
yield (
"final",
{
"ok": False,
"job_id": job_id,
"steps": steps,
"error": job.get("error") or "Purge VM112 falhou",
"result": job.get("result") or {},
},
)
return
yield ("heartbeat", {"elapsed": int(time.monotonic() - t0), "job_id": job_id})
time.sleep(poll_interval)
if not domain_exists_on_vm112(domain):
yield (
"final",
{
"ok": True,
"job_id": job_id,
"recovered": True,
"steps": [],
"result": {"message": "Domínio removido na VM112 (timeout poll — recuperado)"},
},
)
return
yield (
"final",
{
"ok": False,
"error": "Timeout purge VM112 — domínio ainda presente; use Recuperar ou repita",
"job_id": job_id,
},
)
def purge_vm112(domain: str) -> dict[str, Any]:
domain = domain.lower().strip()
for kind, payload in purge_vm112_with_poll(domain):
if kind == "final":
return payload
return {"ok": False, "error": "Purge VM112 sem resposta"}
def vm112_purge_timeline(vm112_result: dict[str, Any]) -> list[dict[str, str]]:
"""Converte resposta VM112 em linhas de timeline."""
raw_steps = vm112_result.get("steps")
if isinstance(raw_steps, list) and raw_steps:
out: list[dict[str, str]] = []
for step in raw_steps:
if not isinstance(step, dict):
continue
label = str(step.get("label") or step.get("name") or "Passo VM112")
ok = step.get("ok", step.get("success", True))
status = "ok" if ok else "fail"
detail = str(step.get("message") or step.get("detail") or "")
at = step.get("at") or _ts()
out.append({"at": at, "label": label, "status": status, "detail": detail})
return out
if vm112_result.get("ok") is False:
return [
_timeline_entry(
"Purge VM112",
"fail",
str(vm112_result.get("message") or vm112_result.get("error") or "falhou"),
)
]
return [_timeline_entry("Purge VM112", "ok", "Orquestração VM112 concluída")]
def purge_desk_records(conn: sqlite3.Connection, domain: str) -> dict[str, int]:
counts, _ = purge_desk_timeline(
conn, domain, by_user="system", purge_source="desk.api.purge", record_event=False
)
return counts
def purge_domain_console_scenario(conn: sqlite3.Connection, domain: str) -> int:
domain = domain.lower().strip()
return conn.execute(
"DELETE FROM domain_console_scenarios WHERE domain = ?", (domain,)
).rowcount
def record_domain_purged_event(
conn: sqlite3.Connection,
domain: str,
*,
by_user: str,
purge_source: str,
desk_removed: dict[str, int] | None = None,
vm112_ok: bool | None = None,
vm112_error: str | None = None,
job_id: str | None = None,
) -> int:
"""Marca expurgo em eventos — histórico preservado (não apaga webhook_events)."""
import json
now = datetime.now(timezone.utc).isoformat()
payload = json.dumps(
{
"domain": domain.lower().strip(),
"event": "domain.purged",
"purged_at": now,
"by_user": by_user,
"purge_source": purge_source,
"purge_tool": purge_source_label(purge_source),
"message": (
f"Domínio expurgado em {now} pelo utilizador {by_user} "
f"via {purge_source_label(purge_source)}"
),
"desk_removed": desk_removed or {},
"vm112_ok": vm112_ok,
"vm112_error": vm112_error,
"job_id": job_id,
"accounts_hint": [f"admin@{domain.lower().strip()}", f"mail.{domain.lower().strip()}"],
},
ensure_ascii=False,
)
cur = conn.execute(
"INSERT INTO webhook_events (event_type, source, payload, created_at) VALUES (?,?,?,?)",
("domain.purged", "desk.purge", payload, now),
)
conn.commit()
return int(cur.lastrowid)
def purge_desk_timeline(
conn: sqlite3.Connection,
domain: str,
*,
by_user: str = "system",
purge_source: str = "desk.api.purge",
record_event: bool = True,
vm112_ok: bool | None = None,
vm112_error: str | None = None,
job_id: str | None = None,
) -> tuple[dict[str, int], list[dict[str, str]]]:
"""Purge Desk — mantém webhook_events; regista domain.purged no fim."""
domain = domain.lower().strip()
like = f"%{domain}%"
timeline: list[dict[str, str]] = []
counts: dict[str, int] = {}
desk_steps = (
("Desk — domain_console_scenarios", "domain_console_scenarios", None, None),
("Desk — tickets", "tickets", "DELETE FROM tickets WHERE subject LIKE ? OR payload LIKE ?", (like, like)),
("Desk — audit_domains", "audit_domains", "DELETE FROM audit_domains WHERE domain = ?", (domain,)),
("Desk — assist_sessions", "assist_sessions", "DELETE FROM assist_sessions WHERE domain = ?", (domain,)),
("Desk — audit_checks", "audit_checks", "DELETE FROM audit_checks WHERE domain = ?", (domain,)),
)
for label, key, sql, params in desk_steps:
if key == "domain_console_scenarios":
n = purge_domain_console_scenario(conn, domain)
else:
n = conn.execute(sql, params).rowcount
counts[key] = n
timeline.append(_timeline_entry(label, "ok", f"{n} registo(s) removido(s)"))
event_id = 0
if record_event:
event_id = record_domain_purged_event(
conn,
domain,
by_user=by_user,
purge_source=purge_source,
desk_removed=counts,
vm112_ok=vm112_ok,
vm112_error=vm112_error,
job_id=job_id,
)
counts["webhook_events_purged_marker"] = 1
timeline.append(
_timeline_entry(
"Desk — evento domain.purged",
"ok",
f"event_id={event_id} · histórico webhook preservado",
)
)
conn.commit()
return counts, timeline
def build_purge_timeline(vm112_result: dict[str, Any], desk_counts: dict[str, int], desk_timeline: list[dict[str, str]]) -> list[dict[str, str]]:
timeline = [_timeline_entry("Validação Root + confirmação", "ok")]
timeline.extend(vm112_purge_timeline(vm112_result))
timeline.extend(desk_timeline)
total_desk = sum(desk_counts.values())
timeline.append(_timeline_entry("Purge concluído", "ok", f"Desk: {total_desk} registo(s)"))
return timeline