""" ServiceRegistry — single source of truth for all PIC services. Merges two layers: 1. Manifest defaults (config_schema.*.default) 2. Admin-saved config from ConfigManager (cell_config.json) All consumers (CaddyManager, backup, peer services endpoint) read from here rather than hardcoding service names or subdomains. """ import logging import re from typing import Dict, List, Optional from urllib.parse import quote as _urlquote logger = logging.getLogger('picell') _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 # ── 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.""" record = self._cm.get_installed_services().get(service_id) if not record: return None manifest = record.get('manifest') if not manifest: return None return {**manifest, 'config': self._merged_config(manifest)} def list_active(self) -> List[Dict]: """Return all installed store services, each with merged config.""" results = [] for _svc_id, record in self._cm.get_installed_services().items(): manifest = record.get('manifest') or {} if manifest.get('id'): results.append({**manifest, 'config': self._merged_config(manifest)}) return results def list_all(self) -> List[Dict]: """Return all installed store services, each with merged config attached as the 'config' key.""" return self.list_active() 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