feat: Phase 3 — ServiceComposer deps + store install via per-service compose
Unit Tests / test (push) Successful in 11m21s
Unit Tests / test (push) Successful in 11m21s
ServiceStoreManager.install() now delegates container lifecycle to ServiceComposer (per-service docker-compose.yml) instead of appending to a shared compose override. This eliminates IP pool allocation, compose override rendering, and the single-stack docker exec approach. Changes: - service_composer.py: add _resolve_requires(), _resolve_dependents(), reapply_active_services() — dependency graph and startup reapply - service_store_manager.py: rewrite install() and remove() to use ServiceComposer; add _fetch_template(); delete _allocate_service_ip(), _render_compose_override(), _write_compose_override(); remove() now guards against removing services that others depend on - managers.py: pass service_composer= to ServiceStoreManager - Tests: 13 new composer dep tests; TestInstall/TestRemove rewritten for the new composer-driven path; test_optional_services_feature.py updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -532,5 +532,109 @@ class TestParsePsJson(unittest.TestCase):
|
||||
self.assertEqual(len(result), 1)
|
||||
|
||||
|
||||
# ── Dependency resolution ─────────────────────────────────────────────────────
|
||||
|
||||
class TestServiceComposerDeps(unittest.TestCase):
|
||||
|
||||
def _composer(self):
|
||||
cm = MagicMock()
|
||||
cm.configs = {}
|
||||
cm.get_installed_services.return_value = {}
|
||||
cm.get_identity.return_value = {}
|
||||
cm.get_effective_domain.return_value = 'test.cell'
|
||||
return ServiceComposer(config_manager=cm, data_dir='/tmp/test')
|
||||
|
||||
def test_resolve_requires_no_requires(self):
|
||||
composer = self._composer()
|
||||
manifest = {'id': 'webmail', 'requires': []}
|
||||
result = composer._resolve_requires(manifest, {})
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_resolve_requires_dep_installed(self):
|
||||
composer = self._composer()
|
||||
manifest = {'id': 'webmail', 'requires': ['email']}
|
||||
installed = {'email': {'manifest': {'id': 'email'}}}
|
||||
result = composer._resolve_requires(manifest, installed)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_resolve_requires_dep_missing(self):
|
||||
composer = self._composer()
|
||||
manifest = {'id': 'webmail', 'requires': ['email']}
|
||||
result = composer._resolve_requires(manifest, {})
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn('email', result)
|
||||
|
||||
def test_resolve_requires_multiple_deps_partial(self):
|
||||
composer = self._composer()
|
||||
manifest = {'id': 'x', 'requires': ['email', 'calendar']}
|
||||
installed = {'email': {'manifest': {'id': 'email'}}}
|
||||
result = composer._resolve_requires(manifest, installed)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn('calendar', result)
|
||||
self.assertNotIn('email', result)
|
||||
|
||||
def test_resolve_requires_no_requires_key(self):
|
||||
composer = self._composer()
|
||||
manifest = {'id': 'files'} # no 'requires' key
|
||||
result = composer._resolve_requires(manifest, {})
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_resolve_dependents_none(self):
|
||||
composer = self._composer()
|
||||
installed = {
|
||||
'email': {'manifest': {'id': 'email', 'requires': []}},
|
||||
}
|
||||
deps = composer._resolve_dependents('email', installed)
|
||||
self.assertEqual(deps, [])
|
||||
|
||||
def test_resolve_dependents_found(self):
|
||||
composer = self._composer()
|
||||
installed = {
|
||||
'email': {'manifest': {'id': 'email', 'requires': []}},
|
||||
'webmail': {'manifest': {'id': 'webmail', 'requires': ['email']}},
|
||||
}
|
||||
deps = composer._resolve_dependents('email', installed)
|
||||
self.assertIn('webmail', deps)
|
||||
|
||||
def test_resolve_dependents_excludes_self(self):
|
||||
composer = self._composer()
|
||||
installed = {
|
||||
'email': {'manifest': {'id': 'email', 'requires': ['email']}}, # weird edge case
|
||||
}
|
||||
deps = composer._resolve_dependents('email', installed)
|
||||
self.assertNotIn('email', deps)
|
||||
|
||||
def test_resolve_dependents_empty_installed(self):
|
||||
composer = self._composer()
|
||||
deps = composer._resolve_dependents('email', {})
|
||||
self.assertEqual(deps, [])
|
||||
|
||||
def test_reapply_active_services_calls_up(self):
|
||||
cm = MagicMock()
|
||||
cm.get_installed_services.return_value = {'email': {'manifest': {'id': 'email'}}}
|
||||
composer = ServiceComposer(config_manager=cm, data_dir='/tmp/test')
|
||||
composer.has_compose_file = MagicMock(return_value=True)
|
||||
composer.up = MagicMock(return_value={'ok': True})
|
||||
composer.reapply_active_services()
|
||||
composer.up.assert_called_once_with('email')
|
||||
|
||||
def test_reapply_active_services_skips_missing_compose(self):
|
||||
cm = MagicMock()
|
||||
cm.get_installed_services.return_value = {'email': {'manifest': {'id': 'email'}}}
|
||||
composer = ServiceComposer(config_manager=cm, data_dir='/tmp/test')
|
||||
composer.has_compose_file = MagicMock(return_value=False)
|
||||
composer.up = MagicMock()
|
||||
composer.reapply_active_services()
|
||||
composer.up.assert_not_called()
|
||||
|
||||
def test_reapply_active_services_empty(self):
|
||||
cm = MagicMock()
|
||||
cm.get_installed_services.return_value = {}
|
||||
composer = ServiceComposer(config_manager=cm, data_dir='/tmp/test')
|
||||
composer.up = MagicMock()
|
||||
composer.reapply_active_services()
|
||||
composer.up.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user