feat: Phase 2 — remove builtins layer, ServiceRegistry is installed-only
Unit Tests / test (push) Successful in 11m31s
Unit Tests / test (push) Successful in 11m31s
Builtins (email/calendar/files) are no longer baked into the API image. ServiceRegistry now only knows about installed store services. When nothing is installed, Caddy and DNS get no service routes — no hardcoded fallback. Changes: - service_registry.py: remove _BUILTINS_DIR, _builtin_ids, _builtin_manifest, _load_manifest; get() and list_all() now delegate entirely to installed services - caddy_manager.py: remove _build_core_service_routes(); remove hardcoded fallback pairs from _http01_service_pairs(); empty registry → api block only - network_manager.py: _get_service_subdomains() returns [] when no registry - api/services/builtins/: deleted (email, calendar, files manifests) - Tests updated throughout: removed builtin-dependent assertions, added installed-service fixtures, updated fallback expectations to api-only Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+3
-40
@@ -163,38 +163,12 @@ class CaddyManager(BaseServiceManager):
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _build_core_service_routes(domain: str) -> str:
|
||||
"""Return 4-space-indented named-matcher + handle blocks for core services."""
|
||||
return (
|
||||
f" @calendar host calendar.{domain}\n"
|
||||
f" handle @calendar {{\n"
|
||||
f" reverse_proxy cell-radicale:5232\n"
|
||||
f" }}\n"
|
||||
f" @mail host mail.{domain} webmail.{domain}\n"
|
||||
f" handle @mail {{\n"
|
||||
f" reverse_proxy cell-rainloop:8888\n"
|
||||
f" }}\n"
|
||||
f" @files host files.{domain}\n"
|
||||
f" handle @files {{\n"
|
||||
f" reverse_proxy cell-filegator:8080\n"
|
||||
f" }}\n"
|
||||
f" @webdav host webdav.{domain}\n"
|
||||
f" handle @webdav {{\n"
|
||||
f" reverse_proxy cell-webdav:80\n"
|
||||
f" }}\n"
|
||||
f" @api host api.{domain}\n"
|
||||
f" handle @api {{\n"
|
||||
f" reverse_proxy cell-api:3000\n"
|
||||
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.
|
||||
When no registry is wired or the registry returns nothing, only the
|
||||
api block is emitted (api is always infrastructure, not delegated to
|
||||
the registry).
|
||||
"""
|
||||
routes: List[Dict] = []
|
||||
if self._service_registry is not None:
|
||||
@@ -203,9 +177,6 @@ class CaddyManager(BaseServiceManager):
|
||||
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'}
|
||||
|
||||
@@ -403,14 +374,6 @@ class CaddyManager(BaseServiceManager):
|
||||
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
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ class NetworkManager(BaseServiceManager):
|
||||
return subs
|
||||
except Exception as exc:
|
||||
logger.warning('_get_service_subdomains: registry error: %s', exc)
|
||||
return ['calendar', 'files', 'mail', 'webmail', 'webdav']
|
||||
return []
|
||||
|
||||
def _build_dns_records(self, cell_name: str, ip_range: str) -> List[Dict]:
|
||||
"""Build the standard set of DNS A records.
|
||||
|
||||
+8
-63
@@ -1,28 +1,21 @@
|
||||
"""
|
||||
ServiceRegistry — single source of truth for all PIC services.
|
||||
|
||||
Merges three layers:
|
||||
Merges two 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'})
|
||||
@@ -33,29 +26,6 @@ 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}
|
||||
@@ -83,22 +53,16 @@ class ServiceRegistry:
|
||||
|
||||
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')
|
||||
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 only installed store services, each with merged config.
|
||||
|
||||
Unlike list_all(), builtins are excluded. Use this wherever the
|
||||
intent is "what has the admin chosen to run?" rather than "everything
|
||||
the registry knows about."
|
||||
"""
|
||||
"""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 {}
|
||||
@@ -107,27 +71,8 @@ class ServiceRegistry:
|
||||
return results
|
||||
|
||||
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
|
||||
"""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]:
|
||||
"""
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"schema_version": 3,
|
||||
"id": "calendar",
|
||||
"name": "Calendar & Contacts",
|
||||
"description": "Radicale CalDAV / CardDAV server",
|
||||
"version": "1.0.0",
|
||||
"author": "pic",
|
||||
"kind": "builtin",
|
||||
"min_pic_version": "1.0",
|
||||
|
||||
"capabilities": {
|
||||
"has_subdomain": true,
|
||||
"has_accounts": true,
|
||||
"has_admin_config": true,
|
||||
"has_storage": true,
|
||||
"has_egress": true,
|
||||
"has_api_hooks": false
|
||||
},
|
||||
|
||||
"subdomain": "calendar",
|
||||
"extra_subdomains": [],
|
||||
"backend": "cell-radicale:5232",
|
||||
|
||||
"containers": ["cell-radicale"],
|
||||
|
||||
"config_schema": {
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"label": "CalDAV port (internal)",
|
||||
"default": 5232,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
}
|
||||
},
|
||||
|
||||
"peer_config_template": {
|
||||
"caldav_url": "https://calendar.{domain}/{peer.username}/",
|
||||
"carddav_url": "https://calendar.{domain}/{peer.username}/",
|
||||
"username": "{peer.username}",
|
||||
"password": "{peer.service_credentials.calendar.password}"
|
||||
},
|
||||
|
||||
"accounts": {
|
||||
"manager": "calendar_manager",
|
||||
"credentials": ["password"]
|
||||
},
|
||||
|
||||
"compose": null,
|
||||
|
||||
"backup": {
|
||||
"volumes": [
|
||||
{"container": "cell-radicale", "path": "/data", "name": "radicale_data"}
|
||||
],
|
||||
"config_paths": [
|
||||
"config/radicale"
|
||||
]
|
||||
},
|
||||
|
||||
"egress": {
|
||||
"default": "default",
|
||||
"allowed": ["default", "wireguard_ext", "openvpn", "tor"]
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"primary_path": "data/radicale",
|
||||
"quota_mb": null
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
{
|
||||
"schema_version": 3,
|
||||
"id": "email",
|
||||
"name": "Email",
|
||||
"description": "Postfix (SMTP) + Dovecot (IMAP) email server with Rainloop webmail",
|
||||
"version": "1.0.0",
|
||||
"author": "pic",
|
||||
"kind": "builtin",
|
||||
"min_pic_version": "1.0",
|
||||
|
||||
"capabilities": {
|
||||
"has_subdomain": true,
|
||||
"has_accounts": true,
|
||||
"has_admin_config": true,
|
||||
"has_storage": true,
|
||||
"has_egress": true,
|
||||
"has_api_hooks": false
|
||||
},
|
||||
|
||||
"subdomain": "mail",
|
||||
"extra_subdomains": ["webmail"],
|
||||
"backend": "cell-rainloop:8888",
|
||||
|
||||
"containers": ["cell-mail", "cell-rainloop"],
|
||||
|
||||
"config_schema": {
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"label": "Mail domain",
|
||||
"required": true
|
||||
},
|
||||
"smtp_port": {
|
||||
"type": "integer",
|
||||
"label": "SMTP port",
|
||||
"default": 25,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
},
|
||||
"submission_port": {
|
||||
"type": "integer",
|
||||
"label": "Submission port",
|
||||
"default": 587,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
},
|
||||
"imap_port": {
|
||||
"type": "integer",
|
||||
"label": "IMAP port",
|
||||
"default": 993,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
},
|
||||
"webmail_port": {
|
||||
"type": "integer",
|
||||
"label": "Webmail port (internal)",
|
||||
"default": 8888,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
}
|
||||
},
|
||||
|
||||
"peer_config_template": {
|
||||
"imap_server": "{domain}",
|
||||
"imap_port": "{config.imap_port}",
|
||||
"smtp_server": "{domain}",
|
||||
"smtp_port": "{config.submission_port}",
|
||||
"webmail_url": "https://mail.{domain}/",
|
||||
"username": "{peer.username}@{domain}",
|
||||
"password": "{peer.service_credentials.email.password}"
|
||||
},
|
||||
|
||||
"accounts": {
|
||||
"manager": "email_manager",
|
||||
"credentials": ["password"]
|
||||
},
|
||||
|
||||
"compose": null,
|
||||
|
||||
"backup": {
|
||||
"volumes": [
|
||||
{"container": "cell-mail", "path": "/var/mail", "name": "maildata"},
|
||||
{"container": "cell-mail", "path": "/var/mail-state", "name": "mailstate"},
|
||||
{"container": "cell-rainloop", "path": "/rainloop/data", "name": "rainloop"}
|
||||
],
|
||||
"config_paths": [
|
||||
"config/mail"
|
||||
]
|
||||
},
|
||||
|
||||
"egress": {
|
||||
"default": "default",
|
||||
"allowed": ["default", "wireguard_ext", "openvpn", "tor"]
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"primary_path": "data/maildata",
|
||||
"quota_mb": null
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"schema_version": 3,
|
||||
"id": "files",
|
||||
"name": "File Storage",
|
||||
"description": "FileGator browser UI + WebDAV network drive",
|
||||
"version": "1.0.0",
|
||||
"author": "pic",
|
||||
"kind": "builtin",
|
||||
"min_pic_version": "1.0",
|
||||
|
||||
"capabilities": {
|
||||
"has_subdomain": true,
|
||||
"has_accounts": true,
|
||||
"has_admin_config": true,
|
||||
"has_storage": true,
|
||||
"has_egress": true,
|
||||
"has_api_hooks": false
|
||||
},
|
||||
|
||||
"subdomain": "files",
|
||||
"extra_subdomains": ["webdav"],
|
||||
"backend": "cell-filegator:8080",
|
||||
"extra_backends": {
|
||||
"webdav": "cell-webdav:80"
|
||||
},
|
||||
|
||||
"containers": ["cell-filegator", "cell-webdav"],
|
||||
|
||||
"config_schema": {
|
||||
"manager_port": {
|
||||
"type": "integer",
|
||||
"label": "FileGator port (internal)",
|
||||
"default": 8082,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"label": "WebDAV port (internal)",
|
||||
"default": 8080,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
}
|
||||
},
|
||||
|
||||
"peer_config_template": {
|
||||
"files_url": "https://files.{domain}/",
|
||||
"webdav_url": "https://webdav.{domain}/",
|
||||
"username": "{peer.username}",
|
||||
"password": "{peer.service_credentials.files.password}"
|
||||
},
|
||||
|
||||
"accounts": {
|
||||
"manager": "file_manager",
|
||||
"credentials": ["password"]
|
||||
},
|
||||
|
||||
"compose": null,
|
||||
|
||||
"backup": {
|
||||
"volumes": [
|
||||
{"container": "cell-filegator", "path": "/var/www/filegator/private", "name": "filegator"},
|
||||
{"container": "cell-webdav", "path": "/var/lib/dav", "name": "files"}
|
||||
],
|
||||
"config_paths": [
|
||||
"config/webdav"
|
||||
]
|
||||
},
|
||||
|
||||
"egress": {
|
||||
"default": "default",
|
||||
"allowed": ["default", "wireguard_ext", "openvpn", "tor"]
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"primary_path": "data/files",
|
||||
"quota_mb": null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user