Files
pic/api/service_registry.py
T
roof 16fb362df7
Unit Tests / test (push) Failing after 11s
feat: replace hardcoded service names with ServiceRegistry-driven Caddy and CoreDNS config
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>
2026-05-28 18:27:52 -04:00

219 lines
8.6 KiB
Python

"""
ServiceRegistry — single source of truth for all PIC services.
Merges three layers:
1. Manifest defaults (config_schema.*.default)
2. Admin-saved config from ConfigManager (cell_config.json)
3. Runtime state from installed store records
All consumers (CaddyManager, backup, peer services endpoint) read from here
rather than hardcoding service names or subdomains.
"""
import json
import logging
import os
import re
from typing import Dict, List, Optional
from urllib.parse import quote as _urlquote
logger = logging.getLogger('picell')
# Built-ins are baked into the container image at build time.
# Do not bind-mount this path read-write in docker-compose.
_BUILTINS_DIR = os.path.join(os.path.dirname(__file__), 'services', 'builtins')
_SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
_BACKEND_RE = re.compile(r'^[A-Za-z0-9._-]+:\d{1,5}$')
_RESERVED_SUBS = frozenset({'api', 'webui', 'admin', 'www', 'ns1', 'ns2', 'git', 'registry', 'install'})
class ServiceRegistry:
def __init__(self, config_manager):
self._cm = config_manager
# ── Manifest loading ──────────────────────────────────────────────────
def _load_manifest(self, path: str) -> Optional[Dict]:
try:
with open(path) as f:
return json.load(f)
except Exception as e:
logger.warning('ServiceRegistry: failed to load manifest %s: %s', path, e)
return None
def _builtin_ids(self) -> List[str]:
if not os.path.isdir(_BUILTINS_DIR):
return []
return sorted(
d for d in os.listdir(_BUILTINS_DIR)
if os.path.isfile(os.path.join(_BUILTINS_DIR, d, 'manifest.json'))
)
def _builtin_manifest(self, service_id: str) -> Optional[Dict]:
return self._load_manifest(
os.path.join(_BUILTINS_DIR, service_id, 'manifest.json')
)
# ── Config merging ────────────────────────────────────────────────────
_TYPE_COERCIONS = {'integer': int, 'string': str, 'boolean': bool}
def _merged_config(self, manifest: Dict) -> Dict:
"""Return manifest defaults overridden by admin-saved values, type-coerced."""
svc_id = manifest.get('id', '')
saved = self._cm.configs.get(svc_id, {})
schema = manifest.get('config_schema') or {}
merged = {k: v['default'] for k, v in schema.items() if 'default' in v}
for k, spec in schema.items():
if k not in saved:
continue
raw = saved[k]
coerce = self._TYPE_COERCIONS.get(spec.get('type', ''))
if coerce is not None:
try:
raw = coerce(raw)
except (TypeError, ValueError):
raw = merged.get(k, raw)
merged[k] = raw
return merged
# ── Public API ────────────────────────────────────────────────────────
def get(self, service_id: str) -> Optional[Dict]:
"""Return manifest + merged config for one service, or None if unknown."""
manifest = self._builtin_manifest(service_id)
if manifest is None:
record = self._cm.get_installed_services().get(service_id)
if record:
manifest = record.get('manifest')
if not manifest:
return None
return {**manifest, 'config': self._merged_config(manifest)}
def list_all(self) -> List[Dict]:
"""
Return all services — builtins first, then installed store services —
each with merged config attached as the 'config' key.
"""
results: List[Dict] = []
seen: set = set()
for svc_id in self._builtin_ids():
manifest = self._builtin_manifest(svc_id)
if manifest:
results.append({**manifest, 'config': self._merged_config(manifest)})
seen.add(svc_id)
for svc_id, record in self._cm.get_installed_services().items():
if svc_id in seen:
continue
manifest = record.get('manifest') or {}
if manifest.get('id'):
results.append({**manifest, 'config': self._merged_config(manifest)})
return results
def get_caddy_routes(self) -> List[Dict]:
"""
Return routing info for all services that have a subdomain.
Used by CaddyManager to build service blocks without hardcoding.
Values are validated here as a chokepoint so Caddyfile/DNS builders
can safely interpolate them regardless of how manifests reached disk.
"""
routes = []
for svc in self.list_all():
caps = svc.get('capabilities') or {}
if not caps.get('has_subdomain'):
continue
sub = svc.get('subdomain', '')
bknd = svc.get('backend', '')
if not sub or not bknd:
continue
svc_id = svc.get('id', '?')
if not _SUBDOMAIN_RE.match(sub) or sub in _RESERVED_SUBS:
logger.warning('ServiceRegistry: skipping %s — invalid/reserved subdomain %r', svc_id, sub)
continue
if not _BACKEND_RE.match(bknd):
logger.warning('ServiceRegistry: skipping %s — invalid backend %r', svc_id, bknd)
continue
extra_subs = [
s for s in (svc.get('extra_subdomains') or [])
if isinstance(s, str) and _SUBDOMAIN_RE.match(s) and s not in _RESERVED_SUBS
]
extra_backends = {
k: v for k, v in (svc.get('extra_backends') or {}).items()
if (isinstance(k, str) and _SUBDOMAIN_RE.match(k) and k not in _RESERVED_SUBS
and isinstance(v, str) and _BACKEND_RE.match(v))
}
routes.append({
'service_id': svc_id,
'subdomain': sub,
'backend': bknd,
'extra_subdomains': extra_subs,
'extra_backends': extra_backends,
})
return routes
def get_backup_plan(self) -> List[Dict]:
"""
Return backup declarations for all services that have storage.
Used by the backup system instead of hardcoded file lists.
Each entry:
service_id — service identifier
volumes — list of {container, path, name} for docker-exec streaming
config_paths — host-relative paths copied directly (config files)
"""
plan = []
for svc in self.list_all():
caps = svc.get('capabilities') or {}
if not caps.get('has_storage'):
continue
backup = svc.get('backup') or {}
volumes = backup.get('volumes') or []
config_paths = backup.get('config_paths') or []
if not volumes and not config_paths:
continue
plan.append({
'service_id': svc['id'],
'volumes': volumes,
'config_paths': config_paths,
})
return plan
def get_peer_service_info(self, service_id: str, peer_username: str,
domain: str, credentials: Dict) -> Optional[Dict]:
"""
Fill peer_config_template for one service+peer combination.
credentials: dict of {field_name: value} for that peer+service.
Returns None if service unknown or has no peer template.
"""
svc = self.get(service_id)
if not svc:
return None
template = svc.get('peer_config_template')
if not template:
return None
# URL-safe peer username (safe='') — prevents path traversal in CalDAV/WebDAV URLs
safe_username = _urlquote(peer_username, safe='')
result = {}
for key, raw in template.items():
val = raw
val = val.replace('{domain}', domain)
val = val.replace('{peer.username}', safe_username)
for field, cred_val in credentials.items():
val = val.replace(
'{peer.service_credentials.' + service_id + '.' + field + '}',
str(cred_val) if cred_val is not None else '',
)
cfg = svc.get('config') or {}
for cfg_key, cfg_val in cfg.items():
val = val.replace('{config.' + cfg_key + '}', str(cfg_val) if cfg_val is not None else '')
result[key] = val
return result