ligbox-ops-platform/projects/wizard/backend/app/services/dns_account_registry.py
Ligbox Spec Hub c1881f58e6 chore: sync Console SSO, DNS viewer, specs e infra docs pendentes
Inclui console handoff Desk↔Console (Spec 019), melhorias DNS Viewer (037),
OpenPanel/Nextcloud/VM116 deploy notes, contracts stack e sidebar actualizado.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 20:10:17 +00:00

190 lines
6.4 KiB
Python

"""
Spec 037 — Resolve qual conta Cloudflare Ligbox gere um domínio.
Deploy: /opt/ligbox-wizard/backend/app/services/dns_account_registry.py
Config: /opt/ligbox-wizard/dns-accounts.yaml
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from app.config import _PROJECT_ROOT, _parse_token_line
from app.services.cloudflare import CloudflareDNS, CloudflareError
_DEFAULT_CONFIG = _PROJECT_ROOT / "dns-accounts.yaml"
@dataclass(frozen=True)
class LigboxCfAccount:
id: str
label: str
admin_email: str
cloudflare_account_id: str
token_file: str
def load_token(self) -> str:
path = _PROJECT_ROOT / self.token_file
if not path.is_file():
return ""
for line in path.read_text(encoding="utf-8").splitlines():
token = _parse_token_line(line)
if token:
return token
return ""
def client(self) -> CloudflareDNS:
return CloudflareDNS(token=self.load_token())
@dataclass
class ZoneMatch:
domain: str
account: LigboxCfAccount
zone: dict[str, Any]
zone_id: str
@property
def dns_mode(self) -> str:
return f"ligbox_cf_{self.account.id}"
def load_accounts(config_path: Path | None = None) -> tuple[list[LigboxCfAccount], list[str]]:
path = config_path or _DEFAULT_CONFIG
if not path.is_file():
return [], []
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
order = list(data.get("probe_order") or [])
accounts: list[LigboxCfAccount] = []
for raw in data.get("accounts") or []:
accounts.append(
LigboxCfAccount(
id=str(raw["id"]),
label=str(raw.get("label") or raw["id"]),
admin_email=str(raw.get("admin_email") or ""),
cloudflare_account_id=str(raw.get("cloudflare_account_id") or ""),
token_file=str(raw.get("token_file") or ""),
)
)
by_id = {a.id: a for a in accounts}
ordered = [by_id[i] for i in order if i in by_id]
# append any not listed in probe_order
seen = {a.id for a in ordered}
for a in accounts:
if a.id not in seen:
ordered.append(a)
return ordered, order
def resolve_ligbox_zone(
domain: str,
config_path: Path | None = None,
) -> ZoneMatch | None:
"""Procura zona nas contas Ligbox por ordem do catálogo."""
domain = domain.lower().strip().rstrip(".")
accounts, _ = load_accounts(config_path)
errors: list[str] = []
for account in accounts:
token = account.load_token()
if not token:
errors.append(f"{account.id}: token ausente")
continue
try:
cf = account.client()
zone = cf.get_zone_by_name(domain)
if zone and zone.get("id"):
return ZoneMatch(
domain=domain,
account=account,
zone=zone,
zone_id=str(zone["id"]),
)
except CloudflareError as e:
errors.append(f"{account.id}: {e}")
return None
def _load_config_raw(config_path: Path | None = None) -> dict[str, Any]:
path = config_path or _DEFAULT_CONFIG
if not path.is_file():
return {}
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
def pick_provision_account(config_path: Path | None = None) -> LigboxCfAccount | None:
"""Conta Ligbox onde o wizard cria zona nova do cliente."""
data = _load_config_raw(config_path)
default_id = str(data.get("default_provision_account") or "ibytera")
accounts, _ = load_accounts(config_path)
by_id = {a.id: a for a in accounts}
preferred = by_id.get(default_id)
if preferred and preferred.load_token():
return preferred
for account in accounts:
if account.load_token():
return account
return None
def provision_ligbox_zone(
domain: str,
account: LigboxCfAccount | None = None,
config_path: Path | None = None,
) -> dict[str, Any]:
"""Garante zona do cliente na conta CF Ligbox indicada (cria se necessário)."""
domain = domain.lower().strip().rstrip(".")
account = account or pick_provision_account(config_path)
if not account:
raise CloudflareError("Nenhuma conta Ligbox com token configurado para provisionar zona.")
cf = account.client()
existing = cf.get_zone_by_name(domain)
if existing:
return {"zone": existing, "created": False, "account": account}
acc_id = account.cloudflare_account_id or None
if acc_id and acc_id.startswith("PREENCHER"):
acc_id = None
zone = cf.create_zone(domain, account_id=acc_id)
return {"zone": zone, "created": True, "account": account}
def resolve_payload(domain: str, config_path: Path | None = None) -> dict[str, Any]:
match = resolve_ligbox_zone(domain, config_path)
provision_acct = pick_provision_account(config_path)
base = {
"domain": domain.lower().strip().rstrip("."),
"paths_available": ["provision_ligbox", "byo", "external"],
}
if match:
return {
**base,
"matched": True,
"account_id": match.account.id,
"account_label": f"{match.account.label} ({match.account.admin_email})",
"zone_id": match.zone_id,
"zone_status": match.zone.get("status"),
"dns_mode": match.dns_mode,
"can_provision_ligbox": True,
"provision_account_id": match.account.id,
"paths_available": ["apply_ligbox", "provision_ligbox", "byo", "external"],
"message": (
f"Domínio encontrado na conta Cloudflare {match.account.label}. "
"Pode aplicar apontamentos Ligbox automaticamente."
),
}
prov_id = provision_acct.id if provision_acct else None
return {
**base,
"matched": False,
"dns_mode": f"ligbox_cf_{prov_id}" if prov_id else None,
"can_provision_ligbox": provision_acct is not None,
"provision_account_id": prov_id,
"message": (
"Domínio novo — o wizard pode criar a zona na Cloudflare gerenciada pela Ligbox "
f"({provision_acct.label}) ou usar BYO Cloudflare / registrador."
if provision_acct
else "Configure tokens Ligbox para provisionar zona, ou use BYO/registrador."
),
}