Specs stay at repo root (cross-VM). Move deploy and code into logical projects with README per domain, updated manifest.yaml, and symlinks at legacy paths for VM122 backward compatibility.
297 lines
10 KiB
Python
297 lines
10 KiB
Python
"""Proxy VM112 domínios orquestrados + limpeza Desk (Spec 017)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
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_BLOCKLIST = frozenset({"ligbox.com.br", "itecnologys.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",
|
|
)
|
|
|
|
|
|
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 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 = "") -> dict[str, Any]:
|
|
with httpx.Client(timeout=60.0) 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()
|
|
return r.json()
|
|
|
|
|
|
def get_domain(domain: str) -> dict[str, Any]:
|
|
domain = domain.lower().strip()
|
|
with httpx.Client(timeout=180.0) as client:
|
|
r = client.get(
|
|
f"{VM112_API}/api/admin/domains/{domain}",
|
|
headers=_vm112_headers(),
|
|
)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
|
|
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=60.0) 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=60.0) 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
|
|
while time.monotonic() < deadline:
|
|
job = poll_purge_vm112_job(job_id)
|
|
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)
|
|
|
|
yield ("final", {"ok": False, "error": "Timeout purge VM112", "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]:
|
|
domain = domain.lower().strip()
|
|
like = f"%{domain}%"
|
|
counts = {}
|
|
counts["webhook_events"] = conn.execute(
|
|
"DELETE FROM webhook_events WHERE payload LIKE ?", (like,)
|
|
).rowcount
|
|
counts["tickets"] = conn.execute(
|
|
"DELETE FROM tickets WHERE subject LIKE ? OR payload LIKE ?", (like, like)
|
|
).rowcount
|
|
counts["audit_domains"] = conn.execute(
|
|
"DELETE FROM audit_domains WHERE domain = ?", (domain,)
|
|
).rowcount
|
|
counts["assist_sessions"] = conn.execute(
|
|
"DELETE FROM assist_sessions WHERE domain = ?", (domain,)
|
|
).rowcount
|
|
counts["audit_checks"] = conn.execute(
|
|
"DELETE FROM audit_checks WHERE domain = ?", (domain,)
|
|
).rowcount
|
|
conn.commit()
|
|
return counts
|
|
|
|
|
|
def purge_desk_timeline(conn: sqlite3.Connection, domain: str) -> tuple[dict[str, int], list[dict[str, str]]]:
|
|
"""Purge Desk com uma linha de timeline por tabela."""
|
|
domain = domain.lower().strip()
|
|
like = f"%{domain}%"
|
|
timeline: list[dict[str, str]] = []
|
|
counts: dict[str, int] = {}
|
|
|
|
desk_steps = (
|
|
("Desk — webhook_events", "webhook_events", "DELETE FROM webhook_events WHERE payload LIKE ?", (like,)),
|
|
("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:
|
|
n = conn.execute(sql, params).rowcount
|
|
counts[key] = n
|
|
timeline.append(_timeline_entry(label, "ok", f"{n} registo(s) removido(s)"))
|
|
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
|