ligbox-ops-platform/deploy/vm112-wizard/perf-domains-list-20260625/domain_orchestration.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

598 lines
22 KiB
Python

"""Listagem e purge de domínios orquestrados na VM112 (Spec 017 + 026)."""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any, Callable
from app.config import settings
from app.services import activity_log, carbonio, carbonio_cache, domain_registry, purge_jobs
from app.services.domain_site_layout import PLATFORM_DOMAINS, SITES_ROOT
from app.services.infrastructure import _mail_host
from app.services.traefik_purge import (
_collect_purge_hosts,
purge_traefik_routers,
purge_traefik_sni,
rollback_traefik_backup,
validate_traefik_after_purge,
)
from app.services.purge_snapshot import build_audit_snapshot
from app.services.purge_openpanel import purge_openpanel
from app.services.purge_pmg import purge_pmg
_LOG_DIR = Path("/var/log/ibytera-mail-portal/sessions")
_WIZARD_PORTAL_USERS_DIR = Path("/var/lib/ligbox-wizard/portal_users")
_TENANT_BRANDING = Path("/opt/ligbox-wizard/backend/app/services/tenant_branding.py")
_DEPLOY_SCRIPTS = Path("/opt/ligbox-deploy/scripts/admin-login-check")
_TRAEFIK_CERT_EXPORT = Path("/opt/zextras/ssl/letsencrypt/traefik-export")
_NGINX_INCLUDES = Path("/opt/zextras/conf/nginx/includes")
_PURGE_BLOCKLIST = PLATFORM_DOMAINS | frozenset({"itecnologys.com"})
def _domain_slug(domain: str) -> str:
return domain.lower().strip().replace(".", "-")
def _list_carbonio_domains() -> list[str]:
return carbonio.list_all_domains()
def _list_site_domains() -> list[str]:
if not SITES_ROOT.is_dir():
return []
return [
p.name.lower()
for p in SITES_ROOT.iterdir()
if p.is_dir() and (p / "domain.json").is_file()
]
def _portal_users_for_domain(domain: str, users_dir: Path | None = None) -> list[dict[str, Any]]:
domain = domain.lower().strip()
base = users_dir or Path(settings.portal_users_dir)
if not base.is_dir():
return []
found: list[dict[str, Any]] = []
for f in base.glob("*.json"):
try:
data = json.loads(f.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
email = (data.get("planned_corporate_email") or "").lower().strip()
if email.endswith(f"@{domain}"):
found.append({
"login_id": data.get("login_id") or f.stem,
"planned_corporate_email": email,
"path": str(f),
})
return found
def _build_orchestrated_domains(query: str = "") -> list[dict[str, Any]]:
carbonio_set = set(_list_carbonio_domains())
names = sorted(carbonio_set | set(_list_site_domains()))
users_by_domain: dict[str, list[dict[str, Any]]] = {}
users_dir = Path(settings.portal_users_dir)
if users_dir.is_dir():
for f in users_dir.glob("*.json"):
try:
data = json.loads(f.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
email = (data.get("planned_corporate_email") or "").lower().strip()
if "@" not in email:
continue
dom = email.split("@", 1)[1]
users_by_domain.setdefault(dom, []).append({
"login_id": data.get("login_id") or f.stem,
"planned_corporate_email": email,
"path": str(f),
})
if _WIZARD_PORTAL_USERS_DIR.is_dir():
for f in _WIZARD_PORTAL_USERS_DIR.glob("*.json"):
try:
data = json.loads(f.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
email = (data.get("planned_corporate_email") or "").lower().strip()
if "@" not in email:
continue
dom = email.split("@", 1)[1]
entry = {
"login_id": data.get("login_id") or f.stem,
"planned_corporate_email": email,
"path": str(f),
}
if entry not in users_by_domain.get(dom, []):
users_by_domain.setdefault(dom, []).append(entry)
q = (query or "").strip().lower()
items: list[dict[str, Any]] = []
for domain in names:
if domain in PLATFORM_DOMAINS:
continue
rec = domain_registry.get_domain_record(domain) or {}
admin_email = rec.get("portal_admin_email") or ""
exists = domain in carbonio_set
item = {
"domain": domain,
"mail_host": _mail_host(domain),
"portal_admin_email": admin_email,
"carbonio_exists": exists,
"site_folder_exists": (SITES_ROOT / domain / "domain.json").is_file(),
"account_count": None,
"accounts_preview": [],
"portal_users": users_by_domain.get(domain, []),
"updated_at": rec.get("updated_at"),
}
if q:
blob = " ".join([
domain,
admin_email,
" ".join(u.get("login_id", "") for u in item["portal_users"]),
]).lower()
if q not in blob:
continue
items.append(item)
return items
def list_orchestrated_domains(query: str = "") -> list[dict[str, Any]]:
q = (query or "").strip().lower()
if not q:
cached = carbonio_cache.get(carbonio_cache.CACHE_KEY_ORCH_LIST)
if cached is not None:
return cached
items = _build_orchestrated_domains(q)
if not q:
carbonio_cache.set(
carbonio_cache.CACHE_KEY_ORCH_LIST,
items,
carbonio_cache.TTL_ORCH_LIST,
)
return items
def list_orchestrated_domains_meta(query: str = "") -> dict[str, Any]:
items = list_orchestrated_domains(query)
age = carbonio_cache.entry_age_sec(carbonio_cache.CACHE_KEY_ALL_DOMAINS)
return {
"domains": items,
"cached": age is not None,
"cache_age_sec": age,
}
def get_domain_detail(domain: str) -> dict[str, Any]:
domain = domain.lower().strip()
detail_key = f"domain_detail:{domain}"
cached = carbonio_cache.get(detail_key)
if cached is not None:
return dict(cached)
matches = [i for i in list_orchestrated_domains() if i["domain"] == domain]
if not matches:
raise ValueError(f"Domínio {domain} não encontrado na orquestração")
base = dict(matches[0])
from app.services.infrastructure import get_status
rec = domain_registry.get_domain_record(domain) or {}
aliases = list(rec.get("mail_aliases") or [])
base["domain_record"] = rec
carbonio_exists = bool(base.get("carbonio_exists"))
def fetch_accounts() -> list[str]:
if not carbonio_exists:
return []
try:
return carbonio.list_accounts(domain)
except carbonio.CarbonioError:
return []
def fetch_infra() -> dict[str, Any]:
return get_status(domain, aliases)
def fetch_cf() -> dict[str, Any]:
try:
from app.services.cloudflare import CloudflareDNS
return CloudflareDNS().get_zone_by_name(domain)
except Exception as e:
return {"error": str(e)}
with ThreadPoolExecutor(max_workers=3) as pool:
fut_acc = pool.submit(fetch_accounts)
fut_inf = pool.submit(fetch_infra)
fut_cf = pool.submit(fetch_cf)
base["accounts"] = fut_acc.result()
base["infra_status"] = fut_inf.result()
base["cloudflare_zone"] = fut_cf.result()
carbonio_cache.set(detail_key, base, carbonio_cache.TTL_DOMAIN_DETAIL)
return base
def _delete_carbonio_accounts(domain: str) -> list[str]:
logs: list[str] = []
accounts: list[str] = []
try:
accounts = carbonio.list_accounts(domain)
except carbonio.CarbonioError:
pass
for email in accounts:
code, _out, _err = carbonio._zmprov_run("da", email, log_cmd=True)
logs.append(f"da {email}: rc={code}")
return logs
def _delete_carbonio_domain_only(domain: str) -> str | None:
if carbonio.domain_exists(domain, use_cache=False):
code, _out, _err = carbonio._zmprov_run("dd", domain, log_cmd=True)
carbonio.invalidate_domain_list_cache()
return f"dd {domain}: rc={code}"
return None
def _purge_portal_users(domain: str) -> list[str]:
removed: list[str] = []
for users_dir in (Path(settings.portal_users_dir), _WIZARD_PORTAL_USERS_DIR):
for u in _portal_users_for_domain(domain, users_dir):
try:
Path(u["path"]).unlink(missing_ok=True)
label = f"{u['login_id']}@{users_dir.name}"
if label not in removed:
removed.append(label)
except OSError as e:
removed.append(f"ERR:{u['login_id']}:{e}")
return removed
def _purge_site_folder(domain: str) -> bool:
path = SITES_ROOT / domain
if path.is_dir():
shutil.rmtree(path)
return True
return False
def _purge_cloudflare_zone(domain: str) -> str:
try:
from app.services.cloudflare import CloudflareDNS, CloudflareError
cf = CloudflareDNS()
zone = cf.get_zone_by_name(domain)
if not zone:
return "no_zone"
zid = zone.get("id")
with cf._client() as c:
r = c.delete(f"https://api.cloudflare.com/client/v4/zones/{zid}")
data = r.json()
if data.get("success"):
return f"deleted:{zid}"
raise CloudflareError(str(data.get("errors")))
except Exception as e:
return f"error:{e}"
def _purge_tenant_branding(domain: str) -> str:
if not _TENANT_BRANDING.is_file():
return "skip"
text = _TENANT_BRANDING.read_text(encoding="utf-8")
needle = f'"{domain}"'
if needle not in text:
return "absent"
new_text = re.sub(rf'^\s*"{re.escape(domain)}".*\n', "", text, flags=re.MULTILINE)
_TENANT_BRANDING.write_text(new_text, encoding="utf-8")
return "removed"
def _purge_deploy_script_refs(domain: str) -> str:
mail_host = _mail_host(domain)
changed = []
for name in ("apply-admin-nginx-overrides.py", "sync-traefik-admin-certs.sh"):
path = _DEPLOY_SCRIPTS / name
if not path.is_file():
continue
text = path.read_text(encoding="utf-8")
if mail_host not in text:
continue
new_text = re.sub(rf'^\s*"{re.escape(mail_host)}".*\n', "", text, flags=re.MULTILINE)
new_text = new_text.replace(f" {mail_host}", "")
new_text = new_text.replace(f"'{mail_host}'", "")
path.write_text(new_text, encoding="utf-8")
changed.append(name)
return ",".join(changed) if changed else "none"
def _purge_traefik_export_certs(domain: str) -> str:
slug = _domain_slug(domain)
pattern = f"mail-{slug}*"
removed = 0
if _TRAEFIK_CERT_EXPORT.is_dir():
for f in _TRAEFIK_CERT_EXPORT.glob(pattern):
f.unlink(missing_ok=True)
removed += 1
return str(removed)
def _remove_nginx_server_block(text: str, server_name: str) -> tuple[str, bool]:
pattern = re.compile(
rf"server\s*\{{\s*\n\s*server_name\s+{re.escape(server_name)}\b",
re.MULTILINE,
)
match = pattern.search(text)
if not match:
return text, False
start = match.start()
brace = 0
i = text.find("{", start)
while i < len(text):
if text[i] == "{":
brace += 1
elif text[i] == "}":
brace -= 1
if brace == 0:
end = i + 1
while end < len(text) and text[end] in "\r\n":
end += 1
return text[:start] + text[end:], True
i += 1
return text, False
def _purge_nginx_vhosts(domain: str, hosts: list[str]) -> str:
if not _NGINX_INCLUDES.is_dir():
return "skip"
targets = [
_NGINX_INCLUDES / "nginx.conf.web.https",
_NGINX_INCLUDES / "nginx.conf.web.admin",
_NGINX_INCLUDES / "nginx.conf.web.http",
_NGINX_INCLUDES / "nginx.conf.mail.imap",
_NGINX_INCLUDES / "nginx.conf.mail.imaps",
_NGINX_INCLUDES / "nginx.conf.mail.pop3",
_NGINX_INCLUDES / "nginx.conf.mail.pop3s",
_NGINX_INCLUDES / "nginx.conf.map.key",
_NGINX_INCLUDES / "nginx.conf.map.crt",
]
edited = 0
for fp in targets:
if not fp.is_file():
continue
text = fp.read_text(encoding="utf-8")
orig = text
for host in hosts:
while True:
text, removed = _remove_nginx_server_block(text, host)
if not removed:
break
for host in hosts:
text = re.sub(rf"^{re.escape(host)} .*\n", "", text, flags=re.MULTILINE)
if text != orig:
fp.with_suffix(fp.suffix + f".bak-purge-{_domain_slug(domain)}").write_text(orig, encoding="utf-8")
fp.write_text(text, encoding="utf-8")
edited += 1
if edited == 0:
return "none"
proc = subprocess.run(
["/opt/zextras/common/sbin/nginx", "-t", "-c", "/opt/zextras/conf/nginx.conf"],
capture_output=True,
text=True,
timeout=60,
)
if proc.returncode != 0:
return f"nginx_test_fail:{(proc.stderr or proc.stdout)[:120]}"
subprocess.run(
["su", "-", "zextras", "-c", "/opt/zextras/common/sbin/nginx -s reload"],
capture_output=True,
timeout=60,
)
return f"updated:{edited}"
def _purge_session_logs(domain: str) -> int:
count = 0
if not _LOG_DIR.is_dir():
return 0
for f in _LOG_DIR.glob("*.jsonl"):
try:
if domain in f.read_text(encoding="utf-8", errors="ignore").lower():
f.unlink()
count += 1
except OSError:
pass
return count
def _execute_purge(
domain: str,
report: Callable[[str, str, str | None], None] | None = None,
*,
job_id: str | None = None,
requested_by: str | None = None,
) -> dict[str, Any]:
def _step(step_id: str, status: str, detail: str | None = None) -> None:
if report:
report(step_id, status, detail)
if status == "running":
activity_log.info(f"PURGE [{step_id}] {detail or ''}", source="admin")
elif status == "done":
activity_log.ok(f"PURGE [{step_id}] {detail or 'OK'}", source="admin")
elif status == "error":
activity_log.error(f"PURGE [{step_id}] {detail or 'erro'}", source="admin")
rec = domain_registry.get_domain_record(domain) or {}
aliases = list(rec.get("mail_aliases") or [])
hosts = _collect_purge_hosts(domain, aliases)
result: dict[str, Any] = {"domain": domain}
traefik_backup: str | None = None
_step("audit_snapshot", "running")
snapshot = build_audit_snapshot(domain, job_id=job_id, requested_by=requested_by)
result["audit_snapshot"] = snapshot
ac = snapshot.get("carbonio", {}).get("account_count", 0)
_step("audit_snapshot", "done", f"{ac} conta(s), inventário gravado")
_step("carbonio_accounts", "running")
result["carbonio_accounts"] = _delete_carbonio_accounts(domain)
_step("carbonio_accounts", "done", "; ".join(result["carbonio_accounts"]) or "nenhuma conta")
_step("carbonio_domain", "running")
dd = _delete_carbonio_domain_only(domain)
result["carbonio_domain"] = dd or "domínio já ausente"
_step("carbonio_domain", "done", result["carbonio_domain"])
_step("portal_users", "running")
result["portal_users_removed"] = _purge_portal_users(domain)
_step("portal_users", "done", ", ".join(result["portal_users_removed"]) or "nenhum")
_step("site_folder", "running")
result["site_folder_removed"] = _purge_site_folder(domain)
_step("site_folder", "done", "removido" if result["site_folder_removed"] else "já ausente")
_step("cloudflare", "running")
result["cloudflare"] = _purge_cloudflare_zone(domain)
_step("cloudflare", "done", result["cloudflare"])
_step("session_logs", "running")
result["session_logs_removed"] = _purge_session_logs(domain)
_step("session_logs", "done", str(result["session_logs_removed"]))
_step("tenant_branding", "running")
result["tenant_branding"] = _purge_tenant_branding(domain)
_step("tenant_branding", "done", result["tenant_branding"])
_step("deploy_scripts", "running")
result["deploy_scripts"] = _purge_deploy_script_refs(domain)
_step("deploy_scripts", "done", result["deploy_scripts"])
_step("traefik_export_certs", "running")
result["traefik_export_certs"] = _purge_traefik_export_certs(domain)
_step("traefik_export_certs", "done", result["traefik_export_certs"])
_step("nginx_vhosts", "running")
result["nginx_vhosts"] = _purge_nginx_vhosts(domain, hosts)
nginx_ok = not str(result["nginx_vhosts"]).startswith("nginx_test_fail")
_step("nginx_vhosts", "done" if nginx_ok else "error", result["nginx_vhosts"])
try:
_step("traefik_sni", "running")
result["traefik_sni"] = purge_traefik_sni(hosts)
sni_ok = not str(result["traefik_sni"]).startswith("sni_fail")
_step("traefik_sni", "done" if sni_ok else "error", result["traefik_sni"])
_step("traefik_routers", "running")
tr = purge_traefik_routers(domain, hosts)
result["traefik_routers"] = tr.get("detail") or ("traefik_ok" if tr.get("ok") else "traefik_fail")
traefik_backup = tr.get("backup")
result["traefik_backup"] = traefik_backup
tr_ok = bool(tr.get("ok"))
_step("traefik_routers", "done" if tr_ok else "error", result["traefik_routers"])
_step("traefik_validate", "running")
validation = validate_traefik_after_purge(domain)
result["traefik_validate"] = validation
val_detail = json.dumps(validation.get("checks") or {}, ensure_ascii=False)[:400]
val_ok = bool(validation.get("ok"))
if not val_ok and traefik_backup:
rb = rollback_traefik_backup(traefik_backup)
result["traefik_rollback"] = rb
validation = validate_traefik_after_purge(domain)
result["traefik_validate_after_rollback"] = validation
_step("traefik_validate", "done" if val_ok else "error", val_detail)
if not sni_ok or not tr_ok or not val_ok:
raise RuntimeError(
f"Traefik purge incompleto (sni={result['traefik_sni']}, "
f"routers={result['traefik_routers']}, validate={val_detail})"
)
except Exception as e:
result["traefik_error"] = str(e)
if report:
_step("traefik_validate", "error", str(e))
raise
_step("openpanel_dns", "running")
op = purge_openpanel(domain)
result["openpanel"] = op
_step("openpanel_dns", "done", op.get("dns", "ok"))
_step("openpanel_user", "running")
_step("openpanel_user", "done", op.get("user", "ok"))
_step("pmg_domain", "running")
pmg = purge_pmg(domain)
result["pmg"] = pmg
_step("pmg_domain", "done", pmg.get("domains", "ok"))
_step("pmg_transport", "running")
_step("pmg_transport", "done", pmg.get("transport", "ok"))
result["carbonio"] = result.get("carbonio_accounts", []) + (
[result["carbonio_domain"]] if result.get("carbonio_domain") else []
)
return result
def run_purge_job(job_id: str) -> None:
job = purge_jobs.get_job(job_id)
if not job:
return
domain = job["domain"]
report = purge_jobs.make_reporter(job_id)
try:
if domain in _PURGE_BLOCKLIST:
raise ValueError(f"Domínio {domain} está na blocklist de purge")
activity_log.info(f"PURGE job {job_id} iniciado: {domain}", source="admin")
result = _execute_purge(domain, report=report, job_id=job_id)
purge_jobs.complete_job(job_id, result)
carbonio.invalidate_domain_list_cache()
carbonio_cache.invalidate_domain(domain)
activity_log.ok(f"PURGE job {job_id} concluído: {domain}", source="admin")
except Exception as e:
purge_jobs.fail_job(job_id, str(e))
activity_log.error(f"PURGE job {job_id} falhou: {e}", source="admin")
def assert_purge_allowed(domain: str) -> None:
domain = domain.lower().strip()
if domain in _PURGE_BLOCKLIST:
raise ValueError(f"Domínio {domain} está na blocklist de purge")
def purge_domain(domain: str) -> dict[str, Any]:
domain = domain.lower().strip()
assert_purge_allowed(domain)
activity_log.info(f"PURGE iniciado: {domain}", source="admin")
job = purge_jobs.create_job(domain)
job_id = job["job_id"]
report = purge_jobs.make_reporter(job_id)
try:
result = _execute_purge(domain, report=report)
purge_jobs.complete_job(job_id, result)
carbonio.invalidate_domain_list_cache()
carbonio_cache.invalidate_domain(domain)
activity_log.ok(f"PURGE concluído: {domain}", source="admin")
job_data = purge_jobs.get_job(job_id) or {}
steps = purge_jobs.steps_for_desk(job_data.get("steps") or [])
return {"domain": domain, "steps": steps, **result}
except Exception as e:
purge_jobs.fail_job(job_id, str(e))
raise
def delete_carbonio_account(email: str) -> dict:
"""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]
assert_purge_allowed(domain)
if not carbonio.account_exists(email):
return {"ok": True, "email": email, "message": "Conta já não existia", "skipped": True}
code, out, err = carbonio._zmprov_run("da", email, log_cmd=True)
if code != 0 and not carbonio._is_missing_account(err, out):
raise carbonio.CarbonioError(err or out or f"zmprov da falhou para {email}")
carbonio.invalidate_domain_cache(domain)
return {"ok": True, "email": email, "message": f"Conta {email} removida do Carbonio", "rc": code}