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
+69 -111
View File
@@ -299,64 +299,63 @@ def _make_ssm(tmp_dir, installed=None, identity=None):
}
caddy = MagicMock()
container = MagicMock()
composer = MagicMock()
composer._resolve_requires.return_value = None
composer._resolve_dependents.return_value = []
composer.install.return_value = {'ok': True}
composer.remove.return_value = {'ok': True}
mgr = ServiceStoreManager(
config_manager=cm,
caddy_manager=caddy,
container_manager=container,
data_dir=tmp_dir,
config_dir=tmp_dir,
service_composer=composer,
)
mgr.compose_override = os.path.join(tmp_dir, 'docker-compose.services.yml')
return mgr
class TestInstallHappyPath(unittest.TestCase):
def test_install_fetches_manifest_renders_compose_calls_docker_up(self):
"""install() happy path: fetches manifest, writes compose, calls docker compose up."""
"""install() happy path: fetches manifest, calls service_composer.install, stores record."""
with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp)
manifest = _ssm_manifest('email')
mgr._fetch_manifest = MagicMock(return_value=manifest)
mgr._write_compose_override = MagicMock()
mgr._fetch_template = MagicMock(return_value='version: "3"\nservices: {}\n')
with patch('firewall_manager.apply_service_rules'), \
patch('service_store_manager.subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
result = mgr.install('email')
result = mgr.install('email')
self.assertTrue(result['ok'])
mgr._fetch_manifest.assert_called_once_with('email')
mgr.config_manager.set_installed_service.assert_called_once()
# docker compose up must have been called
self.assertTrue(mock_run.called)
docker_cmd = mock_run.call_args[0][0]
self.assertIn('up', docker_cmd)
self.assertIn('-d', docker_cmd)
# service_composer.install must have been called
mgr.service_composer.install.assert_called_once()
def test_install_persists_install_record_before_docker_up(self):
"""Install record must be written to config before docker compose up is attempted."""
def test_install_persists_install_record_after_composer_install(self):
"""Install record must be written after service_composer.install succeeds."""
call_order = []
with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp)
manifest = _ssm_manifest('calendar')
mgr._fetch_manifest = MagicMock(return_value=manifest)
mgr._write_compose_override = MagicMock()
mgr._fetch_template = MagicMock(return_value='version: "3"\nservices: {}\n')
mgr.config_manager.set_installed_service.side_effect = \
lambda *a, **kw: call_order.append('set_installed')
with patch('firewall_manager.apply_service_rules'), \
patch('service_store_manager.subprocess.run') as mock_run:
def _docker(*a, **kw):
call_order.append('docker_up')
return MagicMock(returncode=0, stderr='')
mock_run.side_effect = _docker
mgr.install('calendar')
def _composer_install(*a, **kw):
call_order.append('composer_install')
return {'ok': True}
mgr.service_composer.install.side_effect = _composer_install
mgr.install('calendar')
self.assertIn('composer_install', call_order)
self.assertIn('set_installed', call_order)
self.assertLess(
call_order.index('composer_install'),
call_order.index('set_installed'),
call_order.index('docker_up'),
'install record must be written before docker compose up',
'composer.install must be called before install record is persisted',
)
@@ -367,8 +366,7 @@ class TestInstallAlreadyInstalled(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
installed = {'email': {'id': 'email'}}
mgr = _make_ssm(tmp, installed=installed)
with patch('firewall_manager.apply_service_rules'):
result = mgr.install('email')
result = mgr.install('email')
self.assertTrue(result['ok'])
self.assertTrue(result.get('already_installed'))
@@ -378,8 +376,7 @@ class TestInstallAlreadyInstalled(unittest.TestCase):
installed = {'email': {'id': 'email'}}
mgr = _make_ssm(tmp, installed=installed)
mgr._fetch_manifest = MagicMock()
with patch('firewall_manager.apply_service_rules'):
mgr.install('email')
mgr.install('email')
mgr._fetch_manifest.assert_not_called()
def test_install_already_installed_does_not_write_config(self):
@@ -387,8 +384,7 @@ class TestInstallAlreadyInstalled(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
installed = {'calendar': {'id': 'calendar'}}
mgr = _make_ssm(tmp, installed=installed)
with patch('firewall_manager.apply_service_rules'):
mgr.install('calendar')
mgr.install('calendar')
mgr.config_manager.set_installed_service.assert_not_called()
@@ -427,47 +423,6 @@ class TestInstallManifestFetchFails(unittest.TestCase):
self.assertFalse(result['ok'])
mgr.config_manager.set_installed_service.assert_not_called()
class TestInstallComposeUpFails(unittest.TestCase):
"""
The current implementation writes the install record BEFORE docker compose up.
When compose up fails the install record is already written — that is the
existing (accepted) behaviour documented in the implementation.
These tests verify the error is surfaced correctly rather than silently swallowed,
and that the install record IS present (not rolled back) after a compose failure.
"""
def test_install_compose_failure_is_logged_not_raised(self):
"""A non-zero exit from docker compose up must not raise — it is logged."""
with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp)
manifest = _ssm_manifest('email')
mgr._fetch_manifest = MagicMock(return_value=manifest)
mgr._write_compose_override = MagicMock()
with patch('firewall_manager.apply_service_rules'), \
patch('service_store_manager.subprocess.run') as mock_run:
mock_run.return_value = MagicMock(
returncode=1, stderr='image pull failed'
)
# Must not raise
result = mgr.install('email')
# ok is still True because the record was persisted (compose is best-effort)
self.assertTrue(result['ok'])
def test_install_record_written_even_when_compose_fails(self):
"""Install record must exist after compose failure (compose is best-effort)."""
with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp)
manifest = _ssm_manifest('email')
mgr._fetch_manifest = MagicMock(return_value=manifest)
mgr._write_compose_override = MagicMock()
with patch('firewall_manager.apply_service_rules'), \
patch('service_store_manager.subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=1, stderr='pull failed')
mgr.install('email')
mgr.config_manager.set_installed_service.assert_called_once()
def test_install_invalid_manifest_does_not_write_record(self):
"""Manifest validation failure must prevent any install record from being written."""
with tempfile.TemporaryDirectory() as tmp:
@@ -484,6 +439,36 @@ class TestInstallComposeUpFails(unittest.TestCase):
mgr.config_manager.set_installed_service.assert_not_called()
class TestInstallComposeUpFails(unittest.TestCase):
"""
In the new architecture, a compose failure from service_composer.install returns
ok=False immediately — the install record is NOT written when compose fails.
"""
def test_install_compose_failure_returns_error(self):
"""A failure from service_composer.install must return ok=False."""
with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp)
manifest = _ssm_manifest('email')
mgr._fetch_manifest = MagicMock(return_value=manifest)
mgr._fetch_template = MagicMock(return_value='version: "3"\nservices: {}\n')
mgr.service_composer.install.return_value = {'ok': False, 'stderr': 'image pull failed'}
result = mgr.install('email')
self.assertFalse(result['ok'])
self.assertIn('error', result)
def test_install_record_not_written_when_compose_fails(self):
"""Install record must NOT be written when service_composer.install fails."""
with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp)
manifest = _ssm_manifest('email')
mgr._fetch_manifest = MagicMock(return_value=manifest)
mgr._fetch_template = MagicMock(return_value='version: "3"\nservices: {}\n')
mgr.service_composer.install.return_value = {'ok': False, 'stderr': 'pull failed'}
mgr.install('email')
mgr.config_manager.set_installed_service.assert_not_called()
# ---------------------------------------------------------------------------
# 6. ServiceStoreManager.uninstall() (remove())
# ---------------------------------------------------------------------------
@@ -492,62 +477,39 @@ class TestUninstallHappyPath(unittest.TestCase):
def _make_mgr_with_email(self, tmp):
record = {
'container_name': 'cell-email',
'service_ip': '172.20.0.20',
'id': 'email',
'manifest': {
'image': 'git.pic.ngo/roof/email:1.0',
'volumes': [],
},
'iptables_rules': [],
}
installed = {'email': record}
mgr = _make_ssm(tmp, installed=installed)
mgr.config_manager.remove_installed_service = MagicMock()
mgr.config_manager.get_installed_services.side_effect = [
installed, # first call: existence check
{}, # second call: after removal, compose rewrite
]
mgr._write_compose_override = MagicMock()
return mgr
def test_uninstall_happy_path_returns_ok_true(self):
with tempfile.TemporaryDirectory() as tmp:
mgr = self._make_mgr_with_email(tmp)
with patch('firewall_manager.clear_service_rules'), \
patch('service_store_manager.subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
result = mgr.remove('email')
result = mgr.remove('email')
self.assertTrue(result['ok'])
def test_uninstall_removes_install_record(self):
with tempfile.TemporaryDirectory() as tmp:
mgr = self._make_mgr_with_email(tmp)
with patch('firewall_manager.clear_service_rules'), \
patch('service_store_manager.subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
mgr.remove('email')
mgr.remove('email')
mgr.config_manager.remove_installed_service.assert_called_once_with('email')
def test_uninstall_calls_docker_compose_stop_and_rm(self):
def test_uninstall_calls_service_composer_remove(self):
"""New architecture: composer.remove() is called instead of subprocess directly."""
with tempfile.TemporaryDirectory() as tmp:
mgr = self._make_mgr_with_email(tmp)
with patch('firewall_manager.clear_service_rules'), \
patch('service_store_manager.subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
mgr.remove('email')
calls_str = [str(c) for c in mock_run.call_args_list]
has_stop = any('stop' in c for c in calls_str)
has_rm = any('rm' in c for c in calls_str)
self.assertTrue(has_stop, 'docker compose stop should have been called')
self.assertTrue(has_rm, 'docker rm should have been called')
mgr.remove('email')
mgr.service_composer.remove.assert_called_once_with('email', purge_data=False)
def test_uninstall_regenerates_caddyfile(self):
with tempfile.TemporaryDirectory() as tmp:
mgr = self._make_mgr_with_email(tmp)
with patch('firewall_manager.clear_service_rules'), \
patch('service_store_manager.subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
mgr.remove('email')
mgr.remove('email')
mgr.caddy_manager.regenerate_with_installed.assert_called()
@@ -556,26 +518,22 @@ class TestUninstallNotInstalled(unittest.TestCase):
def test_uninstall_service_not_installed_returns_error(self):
with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp, installed={})
with patch('firewall_manager.clear_service_rules'):
result = mgr.remove('email')
result = mgr.remove('email')
self.assertFalse(result['ok'])
self.assertIn('error', result)
self.assertIn('not installed', result['error'].lower())
def test_uninstall_nonexistent_service_does_not_call_docker(self):
def test_uninstall_nonexistent_service_does_not_call_composer(self):
with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp, installed={})
with patch('firewall_manager.clear_service_rules'), \
patch('service_store_manager.subprocess.run') as mock_run:
mgr.remove('email')
mock_run.assert_not_called()
mgr.remove('email')
mgr.service_composer.remove.assert_not_called()
def test_uninstall_nonexistent_service_does_not_remove_config(self):
with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp, installed={})
mgr.config_manager.remove_installed_service = MagicMock()
with patch('firewall_manager.clear_service_rules'):
mgr.remove('email')
mgr.remove('email')
mgr.config_manager.remove_installed_service.assert_not_called()