feat(delivery): owner-only domains + idempotent route re-sync (v2 phase A3-4)

Domains are infrastructure, and in v2 infrastructure is the platform owner's
alone (D9). A tenant gets one automatic demo host and nothing else.

- admin_domains.py (owner-only): list every route with its owning app, attach an
  extra host, detach with the host typed back as confirmation (audited), re-sync
  the router from the database, and read the suggested MCP gateway route.
- domain/platform/rules.py: validate_extra_host. Two shapes only — a single
  label under the managed root, or a full custom FQDN when ALLOW_CUSTOM_DOMAINS
  is on. A wildcard is refused in both (it would match another tenant's
  subdomain), and so are 30 protected platform labels (git, mcp, panel, api...).
  Enforced in the router *and* again in the adapter.
- Both router adapters gained routes_fingerprint / list_all_routes /
  sync_routes. Re-sync writes and reloads only when the rendered state differs:
  a reload drops every in-flight connection on the host, including live demos.
  Adapter-level extras, not additions to the frozen DomainRouterPort.
- The member-facing preview-domain routes are owner-only now. They are kept
  because the demo host is still created through them at app-creation time.

Tests: 473 API (+58); ruff and mypy clean.

Report: docs/plan/reports/agent-3-phase-4.md
This commit is contained in:
mjalmoudy94
2026-10-14 00:57:08 +03:30
parent a1b8a28dda
commit 82cea1e690
8 changed files with 1059 additions and 4 deletions
@@ -38,6 +38,7 @@ from tenant code: tenant commands only ever run inside a sandbox adapter.
from __future__ import annotations
import asyncio
import hashlib
import re
from pathlib import Path
@@ -207,6 +208,80 @@ class CaddyDomainRouterAdapter:
for e in self._parse(self._read_snippet())
]
# -- full re-sync (owner console) ----------------------------------------
def routes_fingerprint(self) -> str:
"""A stable digest of the routes currently in the snippet.
The owner console's "re-sync" is a convenience button, and pressing it
must not reload Caddy when nothing changed: a reload drops every
in-flight connection on the host, including tenants' live demos.
"""
entries = sorted(self._parse(self._read_snippet()), key=lambda e: e.host)
material = "\n".join(f"{e.host}:{e.port}" for e in entries)
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
async def list_all_routes(self) -> list[PreviewDomainInfo]:
"""Every managed route, with no tenant filter (owner console only)."""
return [
PreviewDomainInfo(host=e.host, port=e.port, url=f"https://{e.host}")
for e in sorted(self._parse(self._read_snippet()), key=lambda e: e.host)
]
async def sync_routes(self, routes: list[tuple[str, int]]) -> ReverseProxyResult:
"""Make the snippet match ``routes`` exactly, in one write and one reload.
Idempotent by construction: the desired state is rendered, compared
against what is on disk, and written only when it differs. This is what
makes "re-sync from the database" a safe thing to expose as a button —
it converges on the truth rather than accumulating edits.
"""
try:
entries = [
_Entry(host=validate_managed_host(host, self.root_domain), port=int(port))
for host, port in routes
]
except (RuleViolation, ValueError) as exc:
return ReverseProxyResult(ok=False, message=str(exc))
for entry in entries:
try:
validate_proxy_target(f"localhost:{entry.port}")
assert_port_allocatable(entry.port, start=self.port_start, end=self.port_end)
assert_not_protected_port(entry.port)
except RuleViolation as exc:
return ReverseProxyResult(ok=False, message=str(exc))
if not self._import_is_wired():
return ReverseProxyResult(
ok=False,
message=(
"فایل Caddyfile خط import مربوط به AGY را ندارد. "
f"این خط را اضافه کنید: import {self.snippet_dir}/*.caddy"
),
)
previous = self._read_snippet()
updated = self._render(entries)
if updated == previous:
# Nothing to do. A reload here would drop every live connection for
# no reason at all.
return ReverseProxyResult(
ok=True, message="مسیرها از قبل همگام بودند.", url=""
)
self._write_snippet(updated)
error = await self._validate()
if error is None:
error = await self._reload()
if error is not None:
self._write_snippet(previous)
await self._reload()
return ReverseProxyResult(ok=False, message=error)
return ReverseProxyResult(
ok=True, message=f"{len(entries)} مسیر همگام شد.", url=""
)
# -- file handling -------------------------------------------------------
def _read_snippet(self) -> str: