Entrega read-only de apontamentos DNS (Cloudflare, OpenPanel BIND, público) no Desk e Console /admin/dominio, com spec, scripts de rollback e patches VM112 para painel lateral no passo DNS do onboarding (deploy wizard pendente). Co-authored-by: Cursor <cursoragent@cursor.com>
134 lines
5.6 KiB
Python
134 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Deploy Spec 037 construção — Fase A + stubs D na VM112."""
|
|
from pathlib import Path
|
|
|
|
BASE = Path("/opt/ligbox-spec-hub/repos/ligbox-ops-platform")
|
|
WIZARD = Path("/opt/ligbox-wizard")
|
|
REPO = BASE / "projects/wizard/backend/app"
|
|
|
|
FILES = [
|
|
(REPO / "services/project_identity.py", WIZARD / "backend/app/services/project_identity.py"),
|
|
(REPO / "services/cf_client_accounts.py", WIZARD / "backend/app/services/cf_client_accounts.py"),
|
|
(REPO / "routers/project.py", WIZARD / "backend/app/routers/project.py"),
|
|
]
|
|
|
|
for src, dst in FILES:
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
|
print(f"copied {dst.name}")
|
|
|
|
# main.py — register project router
|
|
main = WIZARD / "backend/app/main.py"
|
|
text = main.read_text(encoding="utf-8")
|
|
if "project" not in text.split("from app.routers import")[1].split("\n")[0]:
|
|
text = text.replace(
|
|
"from app.routers import admin_accounts, admin_domains, assist, corporate, domain_admin, onboarding, portal_auth, telemetry",
|
|
"from app.routers import admin_accounts, admin_domains, assist, corporate, domain_admin, onboarding, portal_auth, project, telemetry",
|
|
)
|
|
if "project.router" not in text:
|
|
text = text.replace(
|
|
"app.include_router(onboarding.router, prefix=\"/api\")",
|
|
"app.include_router(onboarding.router, prefix=\"/api\")\napp.include_router(project.router, prefix=\"/api\")",
|
|
)
|
|
main.write_text(text, encoding="utf-8")
|
|
print("main.py patched")
|
|
|
|
# onboarding.py — phase D endpoints + webhook ligbox_project_email
|
|
onb = WIZARD / "backend/app/routers/onboarding.py"
|
|
ot = onb.read_text(encoding="utf-8")
|
|
|
|
if "cf_client_accounts" not in ot:
|
|
ot = ot.replace(
|
|
"from app.services import dns_byo_vault",
|
|
"from app.services import dns_byo_vault, cf_client_accounts, project_identity",
|
|
)
|
|
|
|
PHASE_D = '''
|
|
|
|
class ProvisionClientAccountRequest(BaseModel):
|
|
domain: str = Field(..., min_length=3, max_length=253)
|
|
display_name: str | None = None
|
|
|
|
|
|
class HandoffManagerRequest(BaseModel):
|
|
domain: str
|
|
manager_email: str = Field(..., min_length=5)
|
|
|
|
|
|
def _project_email_for_domain(domain: str) -> str | None:
|
|
ident = project_identity.get_project_identity(domain.lower().strip())
|
|
return ident.get("ligbox_project_email") if ident else None
|
|
|
|
|
|
@router.post("/dns/cloudflare/provision-client-account")
|
|
def provision_client_cf_account(body: ProvisionClientAccountRequest, request: Request):
|
|
"""Fase D — conta CF dedicada (fallback zona partilhada até Tenant)."""
|
|
get_session_from_request(request)
|
|
domain = normalize_domain(body.domain)
|
|
try:
|
|
validate_primary_domain(domain)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e
|
|
try:
|
|
ligbox_email = _project_email_for_domain(domain)
|
|
result = cf_client_accounts.provision_client_account(
|
|
domain,
|
|
ligbox_project_email=ligbox_email,
|
|
account_name=body.display_name or domain,
|
|
)
|
|
ops_webhook.emit_event(
|
|
"onboard.cf_account.provisioned",
|
|
domain=domain,
|
|
session_id=get_session_from_request(request),
|
|
data={
|
|
"ligbox_project_email": ligbox_email,
|
|
"zone_id": result.get("zone", {}).get("id"),
|
|
"provision_mode": result.get("record", {}).get("provision_mode"),
|
|
},
|
|
)
|
|
return result
|
|
except CloudflareError as e:
|
|
raise HTTPException(502, str(e)) from e
|
|
|
|
|
|
@router.post("/dns/cloudflare/handoff-manager")
|
|
def handoff_cf_manager(body: HandoffManagerRequest, request: Request):
|
|
"""Fase D — convite gestor domínio na conta CF."""
|
|
get_session_from_request(request)
|
|
domain = normalize_domain(body.domain)
|
|
result = cf_client_accounts.handoff_manager(domain, body.manager_email)
|
|
ops_webhook.emit_event(
|
|
"onboard.cf_handoff.requested",
|
|
domain=domain,
|
|
session_id=get_session_from_request(request),
|
|
data=result,
|
|
)
|
|
return result
|
|
|
|
'''
|
|
|
|
if "/dns/cloudflare/provision-client-account" not in ot:
|
|
anchor = "@router.get(\"/session/password-status\")"
|
|
ot = ot.replace(anchor, PHASE_D + anchor)
|
|
|
|
# apply webhook
|
|
if "onboard.dns.applied" not in ot:
|
|
ot = ot.replace(
|
|
' verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)\n return {\n "domain": domain,',
|
|
' verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)\n ops_webhook.emit_event(\n "onboard.dns.applied",\n domain=domain,\n session_id=sid,\n data={\n "dns_mode": dns_mode,\n "ligbox_project_email": _project_email_for_domain(domain),\n "ligbox_account_id": ligbox_acct,\n "zone_id": zone_id,\n "verification_ready": verification.get("ready"),\n },\n )\n return {\n "domain": domain,',
|
|
)
|
|
|
|
# account create webhook extension
|
|
if "onboard.account.created" not in ot:
|
|
ot = ot.replace(
|
|
' activity_log.ok("Processo de onboarding finalizado", source="portal")',
|
|
' ops_webhook.emit_event(\n "onboard.account.created",\n domain=domain,\n session_id=sid,\n data={\n "email": email,\n "dns_mode": dns_mode,\n "ligbox_project_email": _project_email_for_domain(domain),\n "verified": verified,\n },\n )\n activity_log.ok("Processo de onboarding finalizado", source="portal")',
|
|
)
|
|
|
|
onb.write_text(ot, encoding="utf-8")
|
|
print("onboarding.py patched")
|
|
|
|
import ast
|
|
ast.parse(main.read_text())
|
|
ast.parse(onb.read_text())
|
|
print("syntax OK")
|