feat: replace hardcoded service names with ServiceRegistry-driven Caddy and CoreDNS config
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:
2026-05-28 18:27:52 -04:00
parent 63c0dfb9d9
commit 16fb362df7
12 changed files with 1312 additions and 46 deletions
+32 -13
View File
@@ -18,11 +18,13 @@ logger = logging.getLogger(__name__)
class NetworkManager(BaseServiceManager):
"""Manages network services (DNS, DHCP, NTP)"""
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config'):
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config',
service_registry=None):
super().__init__('network', data_dir, config_dir)
self.dns_zones_dir = os.path.join(data_dir, 'dns')
self.dhcp_leases_file = os.path.join(data_dir, 'dhcp', 'leases')
self._service_registry = service_registry
# Ensure directories exist
self.safe_makedirs(self.dns_zones_dir)
self.safe_makedirs(os.path.dirname(self.dhcp_leases_file))
@@ -201,7 +203,7 @@ class NetworkManager(BaseServiceManager):
# domain (e.g. primary_domain='pic.ngo', effective_domain='pic2.pic.ngo'),
# bootstrap service records like 'api', 'calendar' etc. would pollute the
# zone display and shadow the public domain. Remove them.
_stale = {'api', 'webui', 'calendar', 'files', 'mail', 'webmail', 'webdav'}
_stale = {'api', 'webui'} | set(self._get_service_subdomains())
if effective_domain.endswith('.' + primary_domain):
existing = self._load_dns_records(primary_domain)
cleaned = [r for r in existing if r.get('name', '') not in _stale]
@@ -249,6 +251,25 @@ class NetworkManager(BaseServiceManager):
pass
return '10.0.0.1'
_SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
def _get_service_subdomains(self) -> List[str]:
"""Return all service subdomains from the registry, or a hardcoded fallback."""
registry = getattr(self, "_service_registry", None)
if registry is not None:
try:
subs: List[str] = []
for route in registry.get_caddy_routes():
for sub in [route['subdomain']] + list(route.get('extra_subdomains') or []):
if self._SUBDOMAIN_RE.match(sub):
subs.append(sub)
else:
logger.warning('_get_service_subdomains: skipping invalid subdomain %r', sub)
return subs
except Exception as exc:
logger.warning('_get_service_subdomains: registry error: %s', exc)
return ['calendar', 'files', 'mail', 'webmail', 'webdav']
def _build_dns_records(self, cell_name: str, ip_range: str) -> List[Dict]:
"""Build the standard set of DNS A records.
@@ -258,16 +279,14 @@ class NetworkManager(BaseServiceManager):
routes requests to the correct backend by Host header.
"""
wg_ip = self._get_wg_server_ip()
return [
{'name': cell_name, 'type': 'A', 'value': wg_ip},
{'name': 'api', 'type': 'A', 'value': wg_ip},
{'name': 'webui', 'type': 'A', 'value': wg_ip},
{'name': 'calendar', 'type': 'A', 'value': wg_ip},
{'name': 'files', 'type': 'A', 'value': wg_ip},
{'name': 'mail', 'type': 'A', 'value': wg_ip},
{'name': 'webmail', 'type': 'A', 'value': wg_ip},
{'name': 'webdav', 'type': 'A', 'value': wg_ip},
records = [
{'name': cell_name, 'type': 'A', 'value': wg_ip},
{'name': 'api', 'type': 'A', 'value': wg_ip},
{'name': 'webui', 'type': 'A', 'value': wg_ip},
]
for sub in self._get_service_subdomains():
records.append({'name': sub, 'type': 'A', 'value': wg_ip})
return records
def get_dns_records(self, zone: str = 'cell') -> List[Dict]:
"""Get all DNS records across all zones"""
@@ -595,7 +614,7 @@ class NetworkManager(BaseServiceManager):
if not new_name:
return {'restarted': restarted, 'warnings': warnings}
# Exclude service names, wildcard, and apex from cell-hostname detection.
_service_names = {'api', 'webui', 'calendar', 'files', 'mail', 'webmail', 'webdav'}
_service_names = {'api', 'webui'} | set(self._get_service_subdomains())
_reserved = _service_names | {'@', '*'}
changed = False
try: