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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+161
-396
@@ -18,7 +18,6 @@ import yaml
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
|
||||
|
||||
from service_store_manager import ServiceStoreManager
|
||||
from ip_utils import CONTAINER_OFFSETS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -466,218 +465,6 @@ class TestValidateManifestSubdomain(unittest.TestCase):
|
||||
self.assertTrue(ok)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _allocate_service_ip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAllocateServiceIp(unittest.TestCase):
|
||||
|
||||
def test_first_allocation_skips_reserved_offsets_and_returns_first_free(self):
|
||||
"""The first free offset after SERVICE_POOL_START(20) must not be in CONTAINER_OFFSETS."""
|
||||
reserved_offsets = set(CONTAINER_OFFSETS.values())
|
||||
# Find expected first offset (>= 20, not reserved)
|
||||
expected_offset = None
|
||||
for off in range(20, 255):
|
||||
if off not in reserved_offsets:
|
||||
expected_offset = off
|
||||
break
|
||||
expected_ip = f'172.20.0.{expected_offset}'
|
||||
|
||||
mgr = _make_manager()
|
||||
ip = mgr._allocate_service_ip('svc-alpha')
|
||||
self.assertEqual(ip, expected_ip)
|
||||
|
||||
def test_first_allocation_returns_172_20_0_20_for_clean_pool(self):
|
||||
"""Offset 20 is not in CONTAINER_OFFSETS, so it should be the first allocated IP."""
|
||||
self.assertNotIn(20, CONTAINER_OFFSETS.values(),
|
||||
"If offset 20 is now reserved, update this test")
|
||||
mgr = _make_manager()
|
||||
ip = mgr._allocate_service_ip('svc1')
|
||||
self.assertEqual(ip, '172.20.0.20')
|
||||
|
||||
def test_reserved_container_offsets_are_skipped(self):
|
||||
"""No allocated IP should land on a CONTAINER_OFFSETS offset."""
|
||||
reserved_offsets = set(CONTAINER_OFFSETS.values())
|
||||
mgr = _make_manager()
|
||||
ip = mgr._allocate_service_ip('svc2')
|
||||
import ipaddress
|
||||
allocated_offset = int(ipaddress.IPv4Address(ip)) - int(ipaddress.IPv4Address('172.20.0.0'))
|
||||
self.assertNotIn(allocated_offset, reserved_offsets)
|
||||
|
||||
def test_already_taken_ips_are_skipped(self):
|
||||
"""Already-assigned service IPs in service_ips are not reallocated."""
|
||||
identity = {
|
||||
'ip_range': '172.20.0.0/16',
|
||||
'service_ips': {'svc-existing': '172.20.0.20'},
|
||||
}
|
||||
mgr = _make_manager(identity=identity)
|
||||
ip = mgr._allocate_service_ip('svc-new')
|
||||
# 172.20.0.20 is taken, so must get the next available one
|
||||
self.assertNotEqual(ip, '172.20.0.20')
|
||||
# Should be 172.20.0.21 (offset 21 is vip_calendar in CONTAINER_OFFSETS — skip it)
|
||||
# Find what the next free one should be
|
||||
reserved_offsets = set(CONTAINER_OFFSETS.values())
|
||||
expected_offset = None
|
||||
for off in range(20, 255):
|
||||
if off not in reserved_offsets and f'172.20.0.{off}' != '172.20.0.20':
|
||||
expected_offset = off
|
||||
break
|
||||
self.assertEqual(ip, f'172.20.0.{expected_offset}')
|
||||
|
||||
def test_multiple_taken_ips_skipped_sequentially(self):
|
||||
"""Allocator advances past multiple taken IPs correctly."""
|
||||
reserved_offsets = set(CONTAINER_OFFSETS.values())
|
||||
# Pre-fill the first few non-reserved offsets
|
||||
free_offsets = [off for off in range(20, 255) if off not in reserved_offsets]
|
||||
# Take the first 3
|
||||
service_ips = {f'svc{i}': f'172.20.0.{off}' for i, off in enumerate(free_offsets[:3])}
|
||||
identity = {'ip_range': '172.20.0.0/16', 'service_ips': service_ips}
|
||||
mgr = _make_manager(identity=identity)
|
||||
ip = mgr._allocate_service_ip('svc-fourth')
|
||||
self.assertEqual(ip, f'172.20.0.{free_offsets[3]}')
|
||||
|
||||
def test_exhausted_pool_raises_runtime_error(self):
|
||||
"""Fill all 20-254 non-reserved offsets and expect RuntimeError."""
|
||||
reserved_offsets = set(CONTAINER_OFFSETS.values())
|
||||
service_ips = {}
|
||||
idx = 0
|
||||
for off in range(20, 255):
|
||||
if off not in reserved_offsets:
|
||||
service_ips[f'svc{idx}'] = f'172.20.0.{off}'
|
||||
idx += 1
|
||||
identity = {'ip_range': '172.20.0.0/16', 'service_ips': service_ips}
|
||||
mgr = _make_manager(identity=identity)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mgr._allocate_service_ip('overflow')
|
||||
self.assertIn('exhausted', str(ctx.exception).lower())
|
||||
|
||||
def test_uses_ip_range_from_identity(self):
|
||||
"""Allocation respects a different ip_range like 10.10.0.0/16."""
|
||||
identity = {'ip_range': '10.10.0.0/16', 'service_ips': {}}
|
||||
mgr = _make_manager(identity=identity)
|
||||
ip = mgr._allocate_service_ip('svc')
|
||||
self.assertTrue(ip.startswith('10.10.'), f'Expected 10.10.x.x, got {ip}')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _render_compose_override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRenderComposeOverride(unittest.TestCase):
|
||||
|
||||
def test_empty_records_produces_valid_yaml_with_empty_services(self):
|
||||
mgr = _make_manager()
|
||||
output = mgr._render_compose_override({})
|
||||
doc = yaml.safe_load(output)
|
||||
self.assertIn('services', doc)
|
||||
self.assertEqual(doc['services'], {})
|
||||
self.assertIn('networks', doc)
|
||||
self.assertIn('cell-network', doc['networks'])
|
||||
|
||||
def test_empty_records_has_no_volumes_key(self):
|
||||
mgr = _make_manager()
|
||||
output = mgr._render_compose_override({})
|
||||
doc = yaml.safe_load(output)
|
||||
self.assertNotIn('volumes', doc)
|
||||
|
||||
def test_single_service_renders_correct_definition(self):
|
||||
mgr = _make_manager()
|
||||
records = {
|
||||
'myapp': {
|
||||
'container_name': 'cell-myapp',
|
||||
'service_ip': '172.20.0.20',
|
||||
'manifest': {
|
||||
'image': 'git.pic.ngo/roof/myapp:1.0',
|
||||
},
|
||||
}
|
||||
}
|
||||
output = mgr._render_compose_override(records)
|
||||
doc = yaml.safe_load(output)
|
||||
svc = doc['services']['cell-myapp']
|
||||
self.assertEqual(svc['image'], 'git.pic.ngo/roof/myapp:1.0')
|
||||
self.assertEqual(svc['container_name'], 'cell-myapp')
|
||||
self.assertEqual(svc['networks']['cell-network']['ipv4_address'], '172.20.0.20')
|
||||
self.assertEqual(svc['restart'], 'unless-stopped')
|
||||
|
||||
def test_named_volumes_declared_at_top_level(self):
|
||||
mgr = _make_manager()
|
||||
records = {
|
||||
'myapp': {
|
||||
'container_name': 'cell-myapp',
|
||||
'service_ip': '172.20.0.20',
|
||||
'manifest': {
|
||||
'image': 'git.pic.ngo/roof/myapp:1.0',
|
||||
'volumes': [
|
||||
{'name': 'myapp-data', 'mount': '/data'},
|
||||
{'name': 'myapp-config', 'mount': '/config'},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
output = mgr._render_compose_override(records)
|
||||
doc = yaml.safe_load(output)
|
||||
self.assertIn('volumes', doc)
|
||||
self.assertIn('myapp-data', doc['volumes'])
|
||||
self.assertIn('myapp-config', doc['volumes'])
|
||||
|
||||
def test_named_volumes_appear_in_service_volumes_list(self):
|
||||
mgr = _make_manager()
|
||||
records = {
|
||||
'myapp': {
|
||||
'container_name': 'cell-myapp',
|
||||
'service_ip': '172.20.0.20',
|
||||
'manifest': {
|
||||
'image': 'git.pic.ngo/roof/myapp:1.0',
|
||||
'volumes': [{'name': 'myapp-data', 'mount': '/data'}],
|
||||
},
|
||||
}
|
||||
}
|
||||
output = mgr._render_compose_override(records)
|
||||
doc = yaml.safe_load(output)
|
||||
svc_volumes = doc['services']['cell-myapp']['volumes']
|
||||
self.assertIn('myapp-data:/data', svc_volumes)
|
||||
|
||||
def test_environment_rendered_in_service(self):
|
||||
mgr = _make_manager()
|
||||
records = {
|
||||
'myapp': {
|
||||
'container_name': 'cell-myapp',
|
||||
'service_ip': '172.20.0.20',
|
||||
'manifest': {
|
||||
'image': 'git.pic.ngo/roof/myapp:1.0',
|
||||
'env': [
|
||||
{'key': 'FOO', 'value': 'bar'},
|
||||
{'key': 'PORT', 'value': '8080'},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
output = mgr._render_compose_override(records)
|
||||
doc = yaml.safe_load(output)
|
||||
env = doc['services']['cell-myapp']['environment']
|
||||
self.assertEqual(env['FOO'], 'bar')
|
||||
self.assertEqual(env['PORT'], '8080')
|
||||
|
||||
def test_no_volumes_key_in_service_when_manifest_has_no_volumes(self):
|
||||
mgr = _make_manager()
|
||||
records = {
|
||||
'myapp': {
|
||||
'container_name': 'cell-myapp',
|
||||
'service_ip': '172.20.0.20',
|
||||
'manifest': {'image': 'git.pic.ngo/roof/myapp:1.0'},
|
||||
}
|
||||
}
|
||||
output = mgr._render_compose_override(records)
|
||||
doc = yaml.safe_load(output)
|
||||
self.assertNotIn('volumes', doc['services']['cell-myapp'])
|
||||
|
||||
def test_network_declared_as_external(self):
|
||||
mgr = _make_manager()
|
||||
output = mgr._render_compose_override({})
|
||||
doc = yaml.safe_load(output)
|
||||
self.assertTrue(doc['networks']['cell-network']['external'])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_status
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -774,222 +561,200 @@ class TestListServices(unittest.TestCase):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# install
|
||||
# install (new architecture: ServiceComposer-driven)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_ssm(config_manager=None, manifest=None, template='version: "3"\nservices: {}\n'):
|
||||
"""Build a ServiceStoreManager with a mock service_composer."""
|
||||
cm = config_manager or MagicMock()
|
||||
if config_manager is None:
|
||||
cm.get_installed_services.return_value = {}
|
||||
caddy = MagicMock()
|
||||
composer = MagicMock()
|
||||
composer._resolve_requires.return_value = None # no missing deps
|
||||
composer._resolve_dependents.return_value = []
|
||||
composer.install.return_value = {'ok': True}
|
||||
ssm = ServiceStoreManager(
|
||||
config_manager=cm,
|
||||
caddy_manager=caddy,
|
||||
container_manager=MagicMock(),
|
||||
service_composer=composer,
|
||||
)
|
||||
if manifest is not None:
|
||||
ssm._fetch_manifest = MagicMock(return_value=manifest)
|
||||
ssm._fetch_template = MagicMock(return_value=template)
|
||||
return ssm, cm, caddy, composer
|
||||
|
||||
|
||||
class TestInstall(unittest.TestCase):
|
||||
|
||||
def _mock_fetch(self, mgr, manifest):
|
||||
mgr._fetch_manifest = MagicMock(return_value=manifest)
|
||||
|
||||
def _mock_write_compose(self, mgr):
|
||||
mgr._write_compose_override = MagicMock()
|
||||
|
||||
def test_install_already_installed_returns_ok_already_installed(self):
|
||||
installed = {'myapp': {'id': 'myapp'}}
|
||||
mgr = _make_manager(installed=installed)
|
||||
with patch('firewall_manager.apply_service_rules'):
|
||||
result = mgr.install('myapp')
|
||||
cm = MagicMock()
|
||||
cm.get_installed_services.return_value = {'myapp': {'id': 'myapp'}}
|
||||
ssm, _, _, _ = _make_ssm(config_manager=cm)
|
||||
result = ssm.install('myapp')
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertTrue(result.get('already_installed'))
|
||||
|
||||
def test_install_invalid_manifest_returns_errors(self):
|
||||
mgr = _make_manager()
|
||||
bad_manifest = {'id': 'myapp', 'image': 'bad-registry.io/img:latest'}
|
||||
self._mock_fetch(mgr, bad_manifest)
|
||||
self._mock_write_compose(mgr)
|
||||
with patch('firewall_manager.apply_service_rules'):
|
||||
result = mgr.install('myapp')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('errors', result)
|
||||
|
||||
def test_install_valid_manifest_returns_ok_true(self):
|
||||
mgr = _make_manager()
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
self._mock_fetch(mgr, manifest)
|
||||
self._mock_write_compose(mgr)
|
||||
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('myapp')
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertFalse(result.get('already_installed', False))
|
||||
|
||||
def test_install_returns_service_ip(self):
|
||||
mgr = _make_manager()
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
self._mock_fetch(mgr, manifest)
|
||||
self._mock_write_compose(mgr)
|
||||
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('myapp')
|
||||
self.assertIn('service_ip', result)
|
||||
self.assertTrue(result['service_ip'].startswith('172.20.'))
|
||||
|
||||
def test_install_returns_container_name(self):
|
||||
mgr = _make_manager()
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
self._mock_fetch(mgr, manifest)
|
||||
self._mock_write_compose(mgr)
|
||||
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('myapp')
|
||||
self.assertEqual(result['container_name'], 'cell-myapp')
|
||||
|
||||
def test_install_calls_set_installed_service(self):
|
||||
mgr = _make_manager()
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
self._mock_fetch(mgr, manifest)
|
||||
self._mock_write_compose(mgr)
|
||||
with patch('firewall_manager.apply_service_rules'), \
|
||||
patch('service_store_manager.subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr='')
|
||||
mgr.install('myapp')
|
||||
mgr.config_manager.set_installed_service.assert_called_once()
|
||||
args = mgr.config_manager.set_installed_service.call_args[0]
|
||||
self.assertEqual(args[0], 'myapp')
|
||||
|
||||
def test_install_calls_caddy_regenerate_when_service_has_caddy_route(self):
|
||||
mgr = _make_manager()
|
||||
manifest = _valid_manifest(
|
||||
id='myapp',
|
||||
container_name='cell-myapp',
|
||||
caddy_route={'subdomain': 'myapp', 'upstream': 'cell-myapp:8080'},
|
||||
)
|
||||
self._mock_fetch(mgr, manifest)
|
||||
self._mock_write_compose(mgr)
|
||||
with patch('firewall_manager.apply_service_rules'), \
|
||||
patch('service_store_manager.subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr='')
|
||||
mgr.install('myapp')
|
||||
mgr.caddy_manager.regenerate_with_installed.assert_called()
|
||||
|
||||
def test_install_saves_service_ip_in_identity(self):
|
||||
mgr = _make_manager()
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
self._mock_fetch(mgr, manifest)
|
||||
self._mock_write_compose(mgr)
|
||||
with patch('firewall_manager.apply_service_rules'), \
|
||||
patch('service_store_manager.subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr='')
|
||||
mgr.install('myapp')
|
||||
mgr.config_manager.set_identity_field.assert_called()
|
||||
call_args = mgr.config_manager.set_identity_field.call_args[0]
|
||||
self.assertEqual(call_args[0], 'service_ips')
|
||||
self.assertIn('myapp', call_args[1])
|
||||
|
||||
def test_install_fetch_failure_returns_error(self):
|
||||
mgr = _make_manager()
|
||||
mgr._fetch_manifest = MagicMock(side_effect=Exception('connection refused'))
|
||||
result = mgr.install('nonexistent')
|
||||
ssm, _, _, _ = _make_ssm()
|
||||
ssm._fetch_manifest = MagicMock(side_effect=Exception('connection refused'))
|
||||
result = ssm.install('myapp')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('fetch', result['error'].lower())
|
||||
|
||||
def test_install_invalid_manifest_returns_errors(self):
|
||||
bad_manifest = {'id': 'myapp', 'image': 'bad-registry.io/img:latest'}
|
||||
ssm, _, _, _ = _make_ssm(manifest=bad_manifest)
|
||||
result = ssm.install('myapp')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('errors', result)
|
||||
|
||||
def test_install_missing_dep_returns_error(self):
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
ssm, _, _, composer = _make_ssm(manifest=manifest)
|
||||
composer._resolve_requires.return_value = 'Required services not installed: email'
|
||||
result = ssm.install('myapp')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('email', result['error'])
|
||||
|
||||
def test_install_template_fetch_failure_returns_error(self):
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
ssm, _, _, _ = _make_ssm(manifest=manifest)
|
||||
ssm._fetch_template = MagicMock(side_effect=Exception('404 Not Found'))
|
||||
result = ssm.install('myapp')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('compose template', result['error'].lower())
|
||||
|
||||
def test_install_composer_install_failure_returns_error(self):
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
ssm, _, _, composer = _make_ssm(manifest=manifest)
|
||||
composer.install.return_value = {'ok': False, 'stderr': 'docker error'}
|
||||
result = ssm.install('myapp')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('error', result)
|
||||
|
||||
def test_install_calls_set_installed_service(self):
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
ssm, cm, _, _ = _make_ssm(manifest=manifest)
|
||||
ssm.install('myapp')
|
||||
cm.set_installed_service.assert_called_once()
|
||||
args = cm.set_installed_service.call_args[0]
|
||||
self.assertEqual(args[0], 'myapp')
|
||||
|
||||
def test_install_record_contains_manifest(self):
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
ssm, cm, _, _ = _make_ssm(manifest=manifest)
|
||||
ssm.install('myapp')
|
||||
record = cm.set_installed_service.call_args[0][1]
|
||||
self.assertIn('manifest', record)
|
||||
|
||||
def test_install_calls_caddy_regenerate(self):
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
ssm, _, caddy, _ = _make_ssm(manifest=manifest)
|
||||
ssm.install('myapp')
|
||||
caddy.regenerate_with_installed.assert_called()
|
||||
|
||||
def test_install_returns_ok_true(self):
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
ssm, _, _, _ = _make_ssm(manifest=manifest)
|
||||
result = ssm.install('myapp')
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertFalse(result.get('already_installed', False))
|
||||
|
||||
def test_install_without_composer_stores_record(self):
|
||||
"""When service_composer=None, skip compose but still store the install record."""
|
||||
manifest = _valid_manifest(id='myapp', container_name='cell-myapp')
|
||||
cm = MagicMock()
|
||||
cm.get_installed_services.return_value = {}
|
||||
caddy = MagicMock()
|
||||
ssm = ServiceStoreManager(
|
||||
config_manager=cm,
|
||||
caddy_manager=caddy,
|
||||
container_manager=MagicMock(),
|
||||
service_composer=None,
|
||||
)
|
||||
ssm._fetch_manifest = MagicMock(return_value=manifest)
|
||||
ssm._fetch_template = MagicMock(return_value='version: "3"\nservices: {}\n')
|
||||
result = ssm.install('myapp')
|
||||
self.assertTrue(result['ok'])
|
||||
cm.set_installed_service.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remove
|
||||
# remove (new architecture: ServiceComposer-driven)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRemove(unittest.TestCase):
|
||||
|
||||
def _mgr_with_installed(self, tmp_dir, service_id='myapp'):
|
||||
def _make_mgr_with_installed(self, service_id='myapp'):
|
||||
record = {
|
||||
'container_name': 'cell-myapp',
|
||||
'service_ip': '172.20.0.20',
|
||||
'manifest': {'image': 'git.pic.ngo/roof/myapp:1.0', 'volumes': []},
|
||||
'iptables_rules': [],
|
||||
'id': service_id,
|
||||
'manifest': {'id': service_id, 'image': 'git.pic.ngo/roof/myapp:1.0'},
|
||||
}
|
||||
installed = {service_id: record}
|
||||
mgr = _make_manager(tmp_dir=tmp_dir, installed=installed)
|
||||
# After remove, config_manager.get_installed_services returns empty
|
||||
mgr.config_manager.remove_installed_service = MagicMock()
|
||||
mgr.config_manager.get_installed_services.side_effect = [
|
||||
installed, # first call (inside remove, initial check)
|
||||
{}, # second call (after removal, for compose rewrite)
|
||||
]
|
||||
mgr._write_compose_override = MagicMock()
|
||||
return mgr
|
||||
cm = MagicMock()
|
||||
cm.get_installed_services.return_value = installed
|
||||
caddy = MagicMock()
|
||||
composer = MagicMock()
|
||||
composer._resolve_dependents.return_value = []
|
||||
composer.remove.return_value = {'ok': True}
|
||||
ssm = ServiceStoreManager(
|
||||
config_manager=cm,
|
||||
caddy_manager=caddy,
|
||||
container_manager=MagicMock(),
|
||||
service_composer=composer,
|
||||
)
|
||||
return ssm, cm, caddy, composer
|
||||
|
||||
def test_remove_not_installed_returns_error(self):
|
||||
mgr = _make_manager()
|
||||
with patch('firewall_manager.clear_service_rules'):
|
||||
result = mgr.remove('nosuchapp')
|
||||
cm = MagicMock()
|
||||
cm.get_installed_services.return_value = {}
|
||||
ssm = ServiceStoreManager(
|
||||
config_manager=cm,
|
||||
caddy_manager=MagicMock(),
|
||||
container_manager=MagicMock(),
|
||||
)
|
||||
result = ssm.remove('nosuchapp')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('not installed', result['error'])
|
||||
|
||||
def test_remove_installed_returns_ok_true(self, tmp_dir='/tmp/pic-ssm-rm-test'):
|
||||
import tempfile, shutil
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
mgr = self._mgr_with_installed(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('myapp')
|
||||
self.assertTrue(result['ok'])
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
def test_remove_with_dependents_returns_error(self):
|
||||
ssm, _, _, composer = self._make_mgr_with_installed()
|
||||
composer._resolve_dependents.return_value = ['webmail']
|
||||
result = ssm.remove('myapp')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('webmail', result['error'])
|
||||
|
||||
def test_remove_calls_composer_remove(self):
|
||||
ssm, _, _, composer = self._make_mgr_with_installed()
|
||||
ssm.remove('myapp')
|
||||
composer.remove.assert_called_once_with('myapp', purge_data=False)
|
||||
|
||||
def test_remove_calls_remove_installed_service(self):
|
||||
import tempfile, shutil
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
mgr = self._mgr_with_installed(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('myapp')
|
||||
mgr.config_manager.remove_installed_service.assert_called_once_with('myapp')
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
ssm, cm, _, _ = self._make_mgr_with_installed()
|
||||
ssm.remove('myapp')
|
||||
cm.remove_installed_service.assert_called_once_with('myapp')
|
||||
|
||||
def test_remove_calls_caddy_regenerate(self):
|
||||
import tempfile, shutil
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
mgr = self._mgr_with_installed(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('myapp')
|
||||
mgr.caddy_manager.regenerate_with_installed.assert_called()
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
ssm, _, caddy, _ = self._make_mgr_with_installed()
|
||||
ssm.remove('myapp')
|
||||
caddy.regenerate_with_installed.assert_called()
|
||||
|
||||
def test_remove_purge_data_calls_docker_volume_rm(self):
|
||||
import tempfile, shutil
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
record = {
|
||||
'container_name': 'cell-myapp',
|
||||
'service_ip': '172.20.0.20',
|
||||
'manifest': {
|
||||
'image': 'git.pic.ngo/roof/myapp:1.0',
|
||||
'volumes': [{'name': 'myapp-data', 'mount': '/data'}],
|
||||
},
|
||||
}
|
||||
mgr = _make_manager(tmp_dir=tmp, installed={'myapp': record})
|
||||
mgr.config_manager.remove_installed_service = MagicMock()
|
||||
mgr.config_manager.get_installed_services.side_effect = [
|
||||
{'myapp': record}, {}
|
||||
]
|
||||
mgr._write_compose_override = MagicMock()
|
||||
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('myapp', purge_data=True)
|
||||
# Check that docker volume rm was called with the volume name
|
||||
calls = [str(c) for c in mock_run.call_args_list]
|
||||
self.assertTrue(
|
||||
any('myapp-data' in c for c in calls),
|
||||
f'Expected docker volume rm myapp-data in calls: {calls}',
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
def test_remove_returns_ok_true(self):
|
||||
ssm, _, _, _ = self._make_mgr_with_installed()
|
||||
result = ssm.remove('myapp')
|
||||
self.assertTrue(result['ok'])
|
||||
|
||||
def test_remove_purge_data_passed_to_composer(self):
|
||||
ssm, _, _, composer = self._make_mgr_with_installed()
|
||||
ssm.remove('myapp', purge_data=True)
|
||||
composer.remove.assert_called_once_with('myapp', purge_data=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user