feat: replace hardcoded service names with ServiceRegistry-driven Caddy and CoreDNS config
Unit Tests / test (push) Failing after 11s
Unit Tests / test (push) Failing after 11s
Previously, CaddyManager and NetworkManager contained hardcoded lists of service names (calendar, files, mail, webdav, etc.), meaning every new service required a code change to appear in Caddy routes and DNS records. Now both managers accept a service_registry parameter and derive their service lists dynamically from the registry at runtime. - CaddyManager: new _build_registry_service_routes() and _http01_service_pairs() methods pull routes from the registry - NetworkManager: new _get_service_subdomains() method returns registry subdomains with a hardcoded fallback when no registry is wired in; _build_dns_records, stale-record detection, and service name sets all use the registry - managers.py: service_registry constructed before network_manager so it can be injected into both CaddyManager and NetworkManager - service_registry.py: validation chokepoint in get_caddy_routes() rejects invalid subdomain/backend values and reserved service names - service_store_manager.py: _validate_manifest now validates top-level subdomain, backend, extra_subdomains, and extra_backends fields - tests: 24 new tests covering registry-driven routing and DNS subdomain generation (test_caddy_registry_integration.py) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+97
-13
@@ -52,11 +52,13 @@ class CaddyManager(BaseServiceManager):
|
||||
def __init__(self, config_manager=None,
|
||||
data_dir: str = '/app/data',
|
||||
config_dir: str = '/app/config',
|
||||
service_bus=None):
|
||||
service_bus=None,
|
||||
service_registry=None):
|
||||
super().__init__('caddy', data_dir, config_dir)
|
||||
self.config_manager = config_manager
|
||||
self.container_name = 'cell-caddy'
|
||||
self.caddyfile_path = LIVE_CADDYFILE
|
||||
self._service_registry = service_registry
|
||||
# Consecutive health-check failure counter (reset on success or when
|
||||
# the caller restarts the container).
|
||||
self._health_failures = 0
|
||||
@@ -187,6 +189,69 @@ class CaddyManager(BaseServiceManager):
|
||||
f" }}"
|
||||
)
|
||||
|
||||
def _build_registry_service_routes(self, domain: str) -> str:
|
||||
"""Build named-matcher + handle blocks from the service registry.
|
||||
|
||||
Falls back to the hardcoded ``_build_core_service_routes`` when no
|
||||
registry is wired or the registry returns nothing, so the method is
|
||||
always safe to call even in tests that don't supply a registry.
|
||||
"""
|
||||
routes: List[Dict] = []
|
||||
if self._service_registry is not None:
|
||||
try:
|
||||
routes = self._service_registry.get_caddy_routes()
|
||||
except Exception as exc:
|
||||
logger.warning('_build_registry_service_routes: registry error: %s', exc)
|
||||
|
||||
if not routes:
|
||||
return self._build_core_service_routes(domain)
|
||||
|
||||
# Pre-seed with reserved names so no registry entry can squat them.
|
||||
seen_matchers: set = {'api', 'webui'}
|
||||
|
||||
blocks: List[str] = []
|
||||
for route in routes:
|
||||
primary_sub = route['subdomain']
|
||||
backend = route['backend']
|
||||
extra_subs: List[str] = route.get('extra_subdomains') or []
|
||||
extra_backends: Dict[str, str] = route.get('extra_backends') or {}
|
||||
|
||||
if primary_sub in seen_matchers:
|
||||
logger.warning('Caddy: skipping duplicate/reserved matcher %r', primary_sub)
|
||||
continue
|
||||
seen_matchers.add(primary_sub)
|
||||
|
||||
# Subdomains that share the primary backend go in one matcher block.
|
||||
shared = [primary_sub] + [s for s in extra_subs if s not in extra_backends]
|
||||
host_list = ' '.join(f'{s}.{domain}' for s in shared)
|
||||
blocks.append(
|
||||
f' @{primary_sub} host {host_list}\n'
|
||||
f' handle @{primary_sub} {{\n'
|
||||
f' reverse_proxy {backend}\n'
|
||||
f' }}'
|
||||
)
|
||||
# Extra subdomains with their own backends each get their own block.
|
||||
for sub, sub_backend in extra_backends.items():
|
||||
if sub in seen_matchers:
|
||||
logger.warning('Caddy: skipping duplicate/reserved matcher %r', sub)
|
||||
continue
|
||||
seen_matchers.add(sub)
|
||||
blocks.append(
|
||||
f' @{sub} host {sub}.{domain}\n'
|
||||
f' handle @{sub} {{\n'
|
||||
f' reverse_proxy {sub_backend}\n'
|
||||
f' }}'
|
||||
)
|
||||
|
||||
# The api subdomain is always infrastructure — not delegated to the registry.
|
||||
blocks.append(
|
||||
f' @api host api.{domain}\n'
|
||||
f' handle @api {{\n'
|
||||
f' reverse_proxy cell-api:3000\n'
|
||||
f' }}'
|
||||
)
|
||||
return '\n'.join(blocks)
|
||||
|
||||
@staticmethod
|
||||
def _indent_routes(routes: str, spaces: int = 4) -> str:
|
||||
"""Indent a multi-line route block by ``spaces`` columns."""
|
||||
@@ -230,7 +295,7 @@ class CaddyManager(BaseServiceManager):
|
||||
service_routes: str, core_routes: str) -> str:
|
||||
"""pic_ngo mode: wildcard DNS-01 via the pic_ngo plugin."""
|
||||
domain = f"{cell_name}.pic.ngo"
|
||||
body = [self._build_core_service_routes(domain)]
|
||||
body = [self._build_registry_service_routes(domain)]
|
||||
if service_routes:
|
||||
body.append(self._indent_routes(service_routes))
|
||||
body.append(core_routes)
|
||||
@@ -253,7 +318,7 @@ class CaddyManager(BaseServiceManager):
|
||||
def _caddyfile_cloudflare(self, custom_domain: str,
|
||||
service_routes: str, core_routes: str) -> str:
|
||||
"""cloudflare mode: wildcard DNS-01 via the cloudflare plugin."""
|
||||
body = [self._build_core_service_routes(custom_domain)]
|
||||
body = [self._build_registry_service_routes(custom_domain)]
|
||||
if service_routes:
|
||||
body.append(self._indent_routes(service_routes))
|
||||
body.append(core_routes)
|
||||
@@ -273,7 +338,7 @@ class CaddyManager(BaseServiceManager):
|
||||
service_routes: str, core_routes: str) -> str:
|
||||
"""duckdns mode: DNS-01 via the duckdns plugin."""
|
||||
domain = f"{cell_name}.duckdns.org"
|
||||
body = [self._build_core_service_routes(domain)]
|
||||
body = [self._build_registry_service_routes(domain)]
|
||||
if service_routes:
|
||||
body.append(self._indent_routes(service_routes))
|
||||
body.append(core_routes)
|
||||
@@ -299,15 +364,8 @@ class CaddyManager(BaseServiceManager):
|
||||
out.append(core_routes)
|
||||
out.append("}")
|
||||
|
||||
# One block per core service subdomain.
|
||||
_core_services = [
|
||||
('calendar', 'cell-radicale:5232'),
|
||||
('mail', 'cell-rainloop:8888'),
|
||||
('webmail', 'cell-rainloop:8888'),
|
||||
('files', 'cell-filegator:8080'),
|
||||
('webdav', 'cell-webdav:80'),
|
||||
('api', 'cell-api:3000'),
|
||||
]
|
||||
# Build (subdomain, backend) pairs from registry when available.
|
||||
_core_services = self._http01_service_pairs()
|
||||
for subdomain, backend in _core_services:
|
||||
out.append("")
|
||||
out.append(f"{subdomain}.{host} {{")
|
||||
@@ -330,6 +388,32 @@ class CaddyManager(BaseServiceManager):
|
||||
out.append("}")
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
def _http01_service_pairs(self) -> List[tuple]:
|
||||
"""Return (subdomain, backend) pairs for http01 per-host blocks."""
|
||||
pairs: List[tuple] = []
|
||||
if self._service_registry is not None:
|
||||
try:
|
||||
for route in self._service_registry.get_caddy_routes():
|
||||
pairs.append((route['subdomain'], route['backend']))
|
||||
extra_subs: List[str] = route.get('extra_subdomains') or []
|
||||
extra_backends: Dict[str, str] = route.get('extra_backends') or {}
|
||||
for sub in extra_subs:
|
||||
backend = extra_backends.get(sub, route['backend'])
|
||||
pairs.append((sub, backend))
|
||||
except Exception as exc:
|
||||
logger.warning('_http01_service_pairs: registry error: %s', exc)
|
||||
pairs = []
|
||||
if not pairs:
|
||||
pairs = [
|
||||
('calendar', 'cell-radicale:5232'),
|
||||
('mail', 'cell-rainloop:8888'),
|
||||
('webmail', 'cell-rainloop:8888'),
|
||||
('files', 'cell-filegator:8080'),
|
||||
('webdav', 'cell-webdav:80'),
|
||||
]
|
||||
pairs.append(('api', 'cell-api:3000'))
|
||||
return pairs
|
||||
|
||||
# ── filesystem + admin-API operations ─────────────────────────────────
|
||||
|
||||
def write_caddyfile(self, caddyfile_content: str) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user