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:
+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