feat: Phase 3 — ServiceComposer deps + store install via per-service compose
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:
2026-05-29 09:33:02 -04:00
parent 0bfe95320b
commit 87c321c1c9
6 changed files with 442 additions and 767 deletions
+104
View File
@@ -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()