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:
@@ -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 blocks — it 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__':
|
||||
|
||||
Reference in New Issue
Block a user