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:
@@ -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:
|
||||
|
||||
@@ -36,6 +36,8 @@ from app.application.ports import (
|
||||
WorkspaceInfo,
|
||||
)
|
||||
from app.domain.platform.rules import (
|
||||
RuleViolation,
|
||||
assert_not_protected_port,
|
||||
assert_port_allocatable,
|
||||
build_host,
|
||||
validate_managed_host,
|
||||
@@ -250,6 +252,49 @@ class MockDomainRouterAdapter:
|
||||
if h in self._domains
|
||||
]
|
||||
|
||||
# -- full re-sync (owner console) ----------------------------------------
|
||||
|
||||
def routes_fingerprint(self) -> str:
|
||||
"""Stable digest of the routes currently registered."""
|
||||
material = "\n".join(sorted(f"{h}:{i.port}" for h, i in self._domains.items()))
|
||||
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
async def list_all_routes(self) -> list[PreviewDomainInfo]:
|
||||
return [self._domains[h] for h in sorted(self._domains)]
|
||||
|
||||
async def sync_routes(self, routes: list[tuple[str, int]]) -> ReverseProxyResult:
|
||||
"""Make the mock ledger match ``routes`` exactly.
|
||||
|
||||
Applies the same validation as the Caddy adapter, so a test that passes
|
||||
against the mock describes behaviour the real adapter also guarantees.
|
||||
"""
|
||||
try:
|
||||
desired = {
|
||||
validate_managed_host(host, self.root_domain): int(port)
|
||||
for host, port in routes
|
||||
}
|
||||
except (RuleViolation, ValueError) as exc:
|
||||
return ReverseProxyResult(ok=False, message=str(exc))
|
||||
|
||||
for port in desired.values():
|
||||
try:
|
||||
validate_proxy_target(f"localhost:{port}")
|
||||
assert_not_protected_port(port)
|
||||
except RuleViolation as exc:
|
||||
return ReverseProxyResult(ok=False, message=str(exc))
|
||||
|
||||
if {h: i.port for h, i in self._domains.items()} == desired:
|
||||
return ReverseProxyResult(ok=True, message="مسیرها از قبل همگام بودند.")
|
||||
|
||||
for host in list(self._domains):
|
||||
if host not in desired:
|
||||
self._domains.pop(host, None)
|
||||
for host, port in desired.items():
|
||||
self._domains[host] = PreviewDomainInfo(
|
||||
host=host, port=port, url=f"https://{host}", status="active"
|
||||
)
|
||||
return ReverseProxyResult(ok=True, message=f"{len(desired)} مسیر همگام شد.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox
|
||||
|
||||
@@ -294,6 +294,20 @@ class PreviewDomainRepository:
|
||||
async def count_for_project(self, project_id: str) -> int:
|
||||
return len(await self.list_for_project(project_id))
|
||||
|
||||
async def list_all(self, *, limit: int = 2_000) -> list[dict]:
|
||||
"""Every preview domain on the platform.
|
||||
|
||||
Deliberately **not tenant-scoped**: this is the owner console's view of
|
||||
the whole routing table, and it is reachable only from an
|
||||
``require_platform_owner`` route. Never use it for authorization.
|
||||
"""
|
||||
stmt = (
|
||||
select(PreviewDomainRow)
|
||||
.order_by(PreviewDomainRow.host.asc())
|
||||
.limit(min(max(limit, 1), 5_000))
|
||||
)
|
||||
return [self._row_to_dict(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
@@ -336,4 +350,7 @@ class PreviewDomainRepository:
|
||||
"protected": row.protected,
|
||||
"created_by_user_id": row.created_by_user_id,
|
||||
"created_at": row.created_at,
|
||||
# Derived, not stored: the scheme is always https and the host is the
|
||||
# identity. Kept here so every caller renders the same URL.
|
||||
"url": f"https://{row.host}",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user