feat: Phase 2 — remove builtins layer, ServiceRegistry is installed-only
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:
2026-05-29 08:53:44 -04:00
parent 18b50d08c1
commit 0bfe95320b
12 changed files with 419 additions and 766 deletions
+55 -144
View File
@@ -89,10 +89,9 @@ class TestServiceRegistryListActive(unittest.TestCase):
return ServiceRegistry(cm)
def test_list_active_zero_installed_returns_empty(self):
"""With no installed records and no builtins on disk, list_active() is empty."""
"""With no installed records, list_active() is empty."""
reg = self._make_registry(installed={})
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
result = reg.list_active()
result = reg.list_active()
self.assertEqual(result, [])
def test_list_active_one_installed_returns_only_that_service(self):
@@ -102,8 +101,7 @@ class TestServiceRegistryListActive(unittest.TestCase):
'email': {'manifest': email_manifest},
}
reg = self._make_registry(installed=installed)
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
result = reg.list_active()
result = reg.list_active()
ids = [s['id'] for s in result]
self.assertIn('email', ids)
self.assertNotIn('calendar', ids)
@@ -117,8 +115,7 @@ class TestServiceRegistryListActive(unittest.TestCase):
'files': {'manifest': _store_manifest('files', 'files', 'cell-filegator:8080')},
}
reg = self._make_registry(installed=installed)
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
result = reg.list_active()
result = reg.list_active()
ids = {s['id'] for s in result}
self.assertEqual(ids, {'email', 'calendar', 'files'})
@@ -128,8 +125,7 @@ class TestServiceRegistryListActive(unittest.TestCase):
'calendar': {'manifest': _store_manifest('calendar', 'calendar', 'cell-radicale:5232')},
}
reg = self._make_registry(installed=installed)
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
result = reg.list_active()
result = reg.list_active()
for svc in result:
self.assertIn('config', svc, f'{svc["id"]} is missing the config key')
@@ -139,8 +135,7 @@ class TestServiceRegistryListActive(unittest.TestCase):
'broken': {}, # no 'manifest' key at all
}
reg = self._make_registry(installed=installed)
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
result = reg.list_active()
result = reg.list_active()
self.assertEqual(result, [])
@@ -255,13 +250,12 @@ class TestServiceRegistryGetNotInstalled(unittest.TestCase):
unless the service is in get_installed_services().
"""
def test_get_returns_none_when_not_in_builtins_and_not_installed(self):
def test_get_returns_none_when_not_installed(self):
cm = MagicMock()
cm.configs = {}
cm.get_installed_services.return_value = {}
reg = ServiceRegistry(cm)
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
result = reg.get('email')
result = reg.get('email')
self.assertIsNone(result)
def test_get_returns_none_for_calendar_when_not_installed(self):
@@ -269,16 +263,14 @@ class TestServiceRegistryGetNotInstalled(unittest.TestCase):
cm.configs = {}
cm.get_installed_services.return_value = {}
reg = ServiceRegistry(cm)
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
self.assertIsNone(reg.get('calendar'))
self.assertIsNone(reg.get('calendar'))
def test_get_returns_none_for_files_when_not_installed(self):
cm = MagicMock()
cm.configs = {}
cm.get_installed_services.return_value = {}
reg = ServiceRegistry(cm)
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
self.assertIsNone(reg.get('files'))
self.assertIsNone(reg.get('files'))
def test_get_returns_service_when_installed(self):
"""Once email is in installed records it must be returned by get()."""
@@ -289,8 +281,7 @@ class TestServiceRegistryGetNotInstalled(unittest.TestCase):
'email': {'manifest': email_manifest},
}
reg = ServiceRegistry(cm)
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
result = reg.get('email')
result = reg.get('email')
self.assertIsNotNone(result)
self.assertEqual(result['id'], 'email')
@@ -595,17 +586,11 @@ class TestUninstallNotInstalled(unittest.TestCase):
class TestCaddyManagerEmptyActiveRegistry(unittest.TestCase):
"""
When the registry returns no active routes (empty list_active()), the
registry-driven path produces no service matcher blocksit falls back
to the hardcoded _build_core_service_routes.
registry-driven path produces only the @api block — no service matcher
blocks for calendar/mail/files/webdav.
The important test for this feature is that a registry returning [] from
get_caddy_routes produces no service blocks in a NEW install where
email/calendar/files have NOT been installed yet.
The existing fallback behaviour (empty → hardcoded) is already tested in
test_caddy_registry_integration.py:TestBuildRegistryServiceRoutes. These
new tests verify what happens when we pass a registry that explicitly
signals zero active services (e.g. all three were just uninstalled).
Phase 2: builtins removed, so there is no hardcoded fallback. An empty
registry means no service routes at all (except the always-present api block).
"""
def _mgr_with_empty_registry(self):
@@ -616,21 +601,17 @@ class TestCaddyManagerEmptyActiveRegistry(unittest.TestCase):
return CaddyManager(config_manager=cm, service_registry=reg)
def test_empty_active_list_produces_no_service_matcher_blocks(self):
"""Zero active services → no @calendar, @mail, @files, @webdav matchers
when we override the fallback behaviour by returning hardcoded routes
only because registry is empty.
"""Zero active services → no @calendar, @mail, @files, @webdav matchers.
NOTE: the current implementation falls back to _build_core_service_routes
when the registry returns []. This test documents that existing behaviour.
When list_active() is wired in and builtins are removed, this test will
need updating to assert no service matchers appear. For now it pins the
fallback contract.
Phase 2: builtins are gone so an empty registry produces only the @api block.
"""
mgr = self._mgr_with_empty_registry()
result = mgr._build_registry_service_routes('mycell.pic.ngo')
# Current contract: empty registry → hardcoded fallback is used
expected = CaddyManager._build_core_service_routes('mycell.pic.ngo')
self.assertEqual(result, expected)
self.assertIn('@api host api.mycell.pic.ngo', result)
self.assertNotIn('@calendar', result)
self.assertNotIn('@mail', result)
self.assertNotIn('@files', result)
self.assertNotIn('@webdav', result)
def test_empty_registry_no_store_service_blocks_injected(self):
"""An empty active list must not inject any store-service-specific matchers."""
@@ -912,145 +893,75 @@ class TestMigrateLegacyContainers(unittest.TestCase):
# ---------------------------------------------------------------------------
# 10. Existing tests that will break when builtins are removed from disk
# (documented with the exact assertion that breaks)
# 10. Phase 2 completion: verify builtins layer is fully removed
# ---------------------------------------------------------------------------
class TestDocumentedBreakagePoints(unittest.TestCase):
class TestPhase2CompletionChecks(unittest.TestCase):
"""
These tests do NOT fail right now because the builtin manifests are still
on disk. They are here to document which assertions in the existing test
suite will start failing the moment
api/services/builtins/{email,calendar,files}/manifest.json are deleted.
Confirms that Phase 2 (builtins removal) is complete.
Each test runs the existing assertion in isolation so you can confirm it
fails after deletion by running this class with -v.
These tests verify the post-migration state: no builtins directory,
no hardcoded fallbacks, and registry-only routing for all services.
"""
def _load_builtin(self, service_id):
from service_registry import _BUILTINS_DIR
path = os.path.join(_BUILTINS_DIR, service_id, 'manifest.json')
if not os.path.exists(path):
self.skipTest(f'builtin manifest for {service_id!r} already removed')
with open(path) as f:
return json.load(f)
def test_builtins_dir_does_not_exist(self):
"""api/services/builtins/ must not exist after Phase 2."""
import api.service_registry as sr_module
self.assertFalse(hasattr(sr_module, '_BUILTINS_DIR'),
'service_registry must not export _BUILTINS_DIR after Phase 2')
# --- test_service_registry.py::TestBuiltinManifests ---
def test_BREAKAGE_email_manifest_exists_on_disk(self):
"""
test_service_registry.py::TestBuiltinManifests::test_email_manifest_valid
BREAKS because _load('email') calls os.path.exists on the builtin path
and asserts True.
"""
self._load_builtin('email') # will raise AssertionError once file is deleted
def test_BREAKAGE_calendar_manifest_exists_on_disk(self):
"""test_calendar_manifest_valid breaks for the same reason."""
self._load_builtin('calendar')
def test_BREAKAGE_files_manifest_exists_on_disk(self):
"""test_files_manifest_valid breaks for the same reason."""
self._load_builtin('files')
# --- test_service_registry.py::TestServiceRegistryListAll ---
def test_BREAKAGE_list_all_returns_three_builtins(self):
"""
test_service_registry.py::TestServiceRegistryListAll::test_lists_three_builtins
asserts: assertIn('email', ids), assertIn('calendar', ids), assertIn('files', ids)
All three will fail when builtins are removed unless install records exist.
"""
def test_list_all_empty_without_installed_services(self):
"""list_all() returns [] when nothing is installed."""
cm = MagicMock()
cm.configs = {}
cm.get_installed_services.return_value = {}
reg = ServiceRegistry(cm)
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
services = reg.list_all()
services = reg.list_all()
ids = [s['id'] for s in services]
# Demonstrates the breakage: these assertions will fail
self.assertNotIn('email', ids,
'After builtin removal email must NOT appear in list_all without an install record')
self.assertNotIn('email', ids)
self.assertNotIn('calendar', ids)
self.assertNotIn('files', ids)
self.assertEqual(ids, [])
# --- test_service_registry.py::TestServiceRegistryGetCaddyRoutes ---
def test_BREAKAGE_get_caddy_routes_empty_without_builtins(self):
"""
TestServiceRegistryGetCaddyRoutes::test_all_builtins_appear_in_routes
assertIn('email', route_ids) etc will all fail when builtins removed.
"""
def test_get_caddy_routes_empty_without_installed_services(self):
"""get_caddy_routes() returns [] when nothing is installed."""
cm = MagicMock()
cm.configs = {}
cm.get_installed_services.return_value = {}
reg = ServiceRegistry(cm)
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
routes = reg.get_caddy_routes()
route_ids = [r['service_id'] for r in routes]
self.assertEqual(route_ids, [],
'With no builtins on disk and nothing installed, routes must be empty')
routes = reg.get_caddy_routes()
self.assertEqual(routes, [])
# --- test_service_registry.py::TestServiceRegistryGetBackupPlan ---
def test_BREAKAGE_backup_plan_empty_without_builtins(self):
"""
TestServiceRegistryGetBackupPlan::test_all_builtins_in_backup_plan
will fail for all three service IDs.
"""
def test_backup_plan_empty_without_installed_services(self):
"""get_backup_plan() returns [] when nothing is installed."""
cm = MagicMock()
cm.configs = {}
cm.get_installed_services.return_value = {}
reg = ServiceRegistry(cm)
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
plan = reg.get_backup_plan()
self.assertEqual(plan, [],
'Backup plan must be empty when builtins removed and nothing installed')
plan = reg.get_backup_plan()
self.assertEqual(plan, [])
# --- test_service_registry.py::TestServiceRegistryConfigMerge ---
def test_BREAKAGE_config_merge_returns_none_without_builtin(self):
"""
TestServiceRegistryConfigMerge::test_defaults_used_when_no_saved_config
calls reg.get('calendar') and asserts result['config']['port'] == 5232.
Once the calendar manifest is gone from disk, get('calendar') returns None
and None['config'] raises TypeError.
"""
def test_get_returns_none_for_uninstalled_service(self):
"""get('calendar') returns None when calendar is not installed."""
cm = MagicMock()
cm.configs = {'calendar': {}}
cm.get_installed_services.return_value = {}
reg = ServiceRegistry(cm)
with patch('service_registry._BUILTINS_DIR', '/nonexistent/builtins'):
result = reg.get('calendar')
self.assertIsNone(result,
'get(calendar) must be None when builtin is removed and no install record exists')
result = reg.get('calendar')
self.assertIsNone(result)
# --- test_caddy_registry_integration.py — fallback to hardcoded ---
def test_BREAKAGE_caddy_with_empty_registry_falls_back_to_hardcoded(self):
"""
TestCaddyfileWithRegistry::test_pic_ngo_fallback_when_registry_empty
currently tests that an empty registry list falls back to hardcoded routes
which include calendar/mail/files.
When list_active() is wired in and builtins are gone, returning [] should
mean NO service routes at all — the fallback to hardcoded must also be removed.
The existing test assertion 'assertIn @calendar...' will then be WRONG.
This test documents the collision: currently the fallback is correct
behaviour; after the migration it becomes a bug.
"""
def test_caddy_empty_registry_produces_only_api_block(self):
"""Empty registry → no service matcher blocks (no hardcoded fallback)."""
reg = MagicMock()
reg.get_caddy_routes.return_value = []
cm = MagicMock()
cm.get_identity.return_value = {}
mgr = CaddyManager(config_manager=cm, service_registry=reg)
result = mgr._build_registry_service_routes('alpha.pic.ngo')
# Documents current (pre-migration) contract:
# Empty registry → hardcoded fallback → calendar still appears
self.assertIn('@calendar host calendar.alpha.pic.ngo', result,
'Pre-migration: empty registry falls back to hardcoded; '
'this assertion must be INVERTED after the migration is complete')
self.assertIn('@api host api.alpha.pic.ngo', result)
self.assertNotIn('@calendar', result)
self.assertNotIn('@mail', result)
self.assertNotIn('@files', result)
if __name__ == '__main__':