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
+161 -396
View File
@@ -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__':