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:
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user