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
+1
View File
@@ -93,6 +93,7 @@ service_store_manager = ServiceStoreManager(
container_manager=container_manager, container_manager=container_manager,
data_dir=DATA_DIR, data_dir=DATA_DIR,
config_dir=CONFIG_DIR, config_dir=CONFIG_DIR,
service_composer=service_composer,
) )
# Service logger configuration # Service logger configuration
+33
View File
@@ -277,6 +277,39 @@ class ServiceComposer:
pass pass
return result return result
# ── Dependency resolution ─────────────────────────────────────────────
def _resolve_requires(self, manifest: Dict, installed_services: Dict) -> Optional[str]:
"""Return an error string if any required services are missing, else None."""
requires = manifest.get('requires') or []
missing = [r for r in requires if r not in installed_services]
if missing:
return f"Required services not installed: {', '.join(sorted(missing))}"
return None
def _resolve_dependents(self, service_id: str, installed_services: Dict) -> List[str]:
"""Return list of installed service IDs that declare service_id in their requires."""
dependents = []
for svc_id, record in installed_services.items():
if svc_id == service_id:
continue
m = (record.get('manifest') or {})
if service_id in (m.get('requires') or []):
dependents.append(svc_id)
return dependents
def reapply_active_services(self) -> None:
"""Call up() for every installed service that has a compose file. Called at startup."""
installed = self.cm.get_installed_services()
for svc_id in installed:
if not self.has_compose_file(svc_id):
logger.warning('reapply_active_services: no compose file for %s, skipping', svc_id)
continue
result = self.up(svc_id)
if not result.get('ok'):
logger.warning('reapply_active_services: up failed for %s: %s',
svc_id, result.get('error') or result.get('stderr', ''))
# ── Builtin-service lifecycle (main compose stack) ───────────────────── # ── Builtin-service lifecycle (main compose stack) ─────────────────────
@staticmethod @staticmethod
+74 -260
View File
@@ -14,17 +14,14 @@ import logging
import os import os
import re import re
import threading import threading
import subprocess
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import json import json
import requests import requests
import yaml
from base_service_manager import BaseServiceManager from base_service_manager import BaseServiceManager
from ip_utils import CONTAINER_OFFSETS
from manifest_validator import validate_manifest, validate_provision_hook from manifest_validator import validate_manifest, validate_provision_hook
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -33,15 +30,15 @@ logger = logging.getLogger(__name__)
# Constants # Constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SERVICE_POOL_START = 20
SERVICE_POOL_END = 254
INDEX_URL_DEFAULT = ( INDEX_URL_DEFAULT = (
'https://git.pic.ngo/roof/pic-services/raw/branch/main/index.json' 'https://git.pic.ngo/roof/pic-services/raw/branch/main/index.json'
) )
MANIFEST_URL_TPL = ( MANIFEST_URL_TPL = (
'https://git.pic.ngo/roof/pic-services/raw/branch/main/services/{id}/manifest.json' 'https://git.pic.ngo/roof/pic-services/raw/branch/main/services/{id}/manifest.json'
) )
TEMPLATE_URL_TPL = (
'https://git.pic.ngo/roof/pic-services/raw/branch/main/services/{id}/compose-template.yml'
)
IMAGE_ALLOWLIST_RE = re.compile( IMAGE_ALLOWLIST_RE = re.compile(
r'^git\.pic\.ngo/roof/[a-z0-9._/-]+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$' r'^git\.pic\.ngo/roof/[a-z0-9._/-]+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$'
@@ -77,11 +74,13 @@ class ServiceStoreManager(BaseServiceManager):
"""Manages service store: install, remove, and list available/installed services.""" """Manages service store: install, remove, and list available/installed services."""
def __init__(self, config_manager, caddy_manager, container_manager, def __init__(self, config_manager, caddy_manager, container_manager,
data_dir: str = '', config_dir: str = ''): data_dir: str = '', config_dir: str = '',
service_composer=None):
super().__init__('service_store', data_dir, config_dir) super().__init__('service_store', data_dir, config_dir)
self.config_manager = config_manager self.config_manager = config_manager
self.caddy_manager = caddy_manager self.caddy_manager = caddy_manager
self.container_manager = container_manager self.container_manager = container_manager
self.service_composer = service_composer
self.compose_override = os.environ.get( self.compose_override = os.environ.get(
'COMPOSE_SERVICES_PATH', '/app/docker-compose.services.yml' 'COMPOSE_SERVICES_PATH', '/app/docker-compose.services.yml'
) )
@@ -239,125 +238,6 @@ class ServiceStoreManager(BaseServiceManager):
return (len(errors) == 0, errors) return (len(errors) == 0, errors)
# ── IP allocation ─────────────────────────────────────────────────────
def _allocate_service_ip(self, service_id: str) -> str:
"""Allocate the next free IP from the service pool."""
identity = self.config_manager.get_identity()
ip_range = identity.get('ip_range', '172.20.0.0/16')
import ipaddress
network = ipaddress.IPv4Network(ip_range, strict=False)
base = int(network.network_address)
# IPs already assigned to named containers
reserved_offsets = set(CONTAINER_OFFSETS.values())
# IPs already assigned to installed services
service_ips: Dict[str, str] = identity.get('service_ips', {})
taken_ips = set(service_ips.values())
for offset in range(SERVICE_POOL_START, SERVICE_POOL_END + 1):
if offset in reserved_offsets:
continue
candidate = str(ipaddress.IPv4Address(base + offset))
if candidate not in taken_ips:
return candidate
raise RuntimeError('Service IP pool exhausted (offsets 20-254 all taken)')
# ── Compose override ──────────────────────────────────────────────────
def _render_compose_override(self, installed_records: dict) -> str:
"""Generate docker-compose YAML override for all installed services."""
services: Dict[str, Any] = {}
for svc_id, record in installed_records.items():
manifest = record.get('manifest', {})
container_name = record.get('container_name', svc_id)
image = manifest.get('image', record.get('image', ''))
service_ip = record.get('service_ip', '')
# Volumes
volumes = []
for vol in manifest.get('volumes', []):
vol_name = vol.get('name', '')
mount = vol.get('mount', '')
if vol_name and mount:
volumes.append(f'{vol_name}:{mount}')
# Environment
environment: Dict[str, str] = {}
for env_entry in manifest.get('env', []):
k = env_entry.get('key', '')
v = str(env_entry.get('value', ''))
if k:
environment[k] = v
svc_def: Dict[str, Any] = {
'image': image,
'container_name': container_name,
'restart': 'unless-stopped',
'logging': {
'driver': 'json-file',
'options': {
'max-size': '10m',
'max-file': '5',
},
},
'networks': {
'cell-network': {
'ipv4_address': service_ip,
}
},
}
if volumes:
svc_def['volumes'] = volumes
if environment:
svc_def['environment'] = environment
services[container_name] = svc_def
# Collect named volumes
named_volumes: Dict[str, Any] = {}
for svc_id, record in installed_records.items():
manifest = record.get('manifest', {})
for vol in manifest.get('volumes', []):
vol_name = vol.get('name', '')
if vol_name:
named_volumes[vol_name] = None # Docker default driver
doc: Dict[str, Any] = {
'version': '3.8',
'services': services,
'networks': {
'cell-network': {
'external': True,
}
},
}
if named_volumes:
doc['volumes'] = named_volumes
return yaml.dump(doc, default_flow_style=False, allow_unicode=True)
def _write_compose_override(self, content: str) -> None:
"""Atomic write of the compose override file."""
tmp_path = self.compose_override + '.tmp'
try:
os.makedirs(os.path.dirname(os.path.abspath(self.compose_override)),
exist_ok=True)
except (PermissionError, OSError):
pass
with open(tmp_path, 'w') as f:
f.write(content)
f.flush()
try:
os.fsync(f.fileno())
except OSError:
pass
os.replace(tmp_path, self.compose_override)
# ── Index / manifest fetching ───────────────────────────────────────── # ── Index / manifest fetching ─────────────────────────────────────────
def fetch_index(self) -> list: def fetch_index(self) -> list:
@@ -394,14 +274,22 @@ class ServiceStoreManager(BaseServiceManager):
) )
return json.loads(content) return json.loads(content)
def _fetch_template(self, service_id: str, manifest: dict) -> str:
"""Fetch the compose template for a service."""
_SIZE_LIMIT = 256 * 1024
url = TEMPLATE_URL_TPL.format(id=service_id)
resp = requests.get(url, timeout=10, stream=True)
resp.raise_for_status()
content = resp.raw.read(_SIZE_LIMIT + 1, decode_content=True)
if len(content) > _SIZE_LIMIT:
raise ValueError(f'Compose template for {service_id} exceeds 256 KB limit')
return content.decode('utf-8')
# ── Core operations ─────────────────────────────────────────────────── # ── Core operations ───────────────────────────────────────────────────
def install(self, service_id: str) -> dict: def install(self, service_id: str) -> dict:
"""Install a service from the store.""" """Install a service from the store."""
from firewall_manager import apply_service_rules
with self._lock: with self._lock:
# Already installed?
installed = self.config_manager.get_installed_services() installed = self.config_manager.get_installed_services()
if service_id in installed: if service_id in installed:
return {'ok': True, 'already_installed': True} return {'ok': True, 'already_installed': True}
@@ -416,154 +304,80 @@ class ServiceStoreManager(BaseServiceManager):
if not ok: if not ok:
return {'ok': False, 'errors': errs} return {'ok': False, 'errors': errs}
# Allocate IP ok2, errs2 = validate_manifest(manifest)
try: if not ok2:
ip = self._allocate_service_ip(service_id) return {'ok': False, 'errors': errs2}
except RuntimeError as e:
return {'ok': False, 'error': str(e)}
# Build install record # Dependency check
if self.service_composer is not None:
err = self.service_composer._resolve_requires(manifest, installed)
if err:
return {'ok': False, 'error': err}
# Fetch compose template
try:
template_content = self._fetch_template(service_id, manifest)
except Exception as e:
return {'ok': False, 'error': f'Failed to fetch compose template: {e}'}
# Write compose file and start containers (validation inside write_compose)
if self.service_composer is not None:
try:
result = self.service_composer.install(service_id, manifest, template_content)
except ValueError as e:
return {'ok': False, 'error': str(e)}
except Exception as e:
return {'ok': False, 'error': f'Failed to start service: {e}'}
if not result.get('ok'):
return {'ok': False, 'error': result.get('error') or result.get('stderr', 'docker up failed')}
# Persist minimal install record
record = { record = {
'id': service_id, 'id': service_id,
'name': manifest.get('name', service_id),
'container_name': manifest['container_name'],
'image': manifest.get('image', ''),
'service_ip': ip,
'caddy_route': manifest.get('caddy_route'),
'iptables_rules': manifest.get('iptables_rules', []),
'manifest': manifest, 'manifest': manifest,
'installed_at': datetime.utcnow().isoformat(), 'installed_at': datetime.utcnow().isoformat(),
} }
# Persist to config
self.config_manager.set_installed_service(service_id, record) self.config_manager.set_installed_service(service_id, record)
identity = self.config_manager.get_identity()
service_ips = dict(identity.get('service_ips', {}))
service_ips[service_id] = ip
self.config_manager.set_identity_field('service_ips', service_ips)
# Write compose override # Regenerate Caddy (registry now drives routes, no caddy_routes list needed)
all_installed = self.config_manager.get_installed_services()
try: try:
content = self._render_compose_override(all_installed) self.caddy_manager.regenerate_with_installed([])
self._write_compose_override(content)
except Exception as e: except Exception as e:
logger.error(f'Failed to write compose override: {e}') logger.warning('install: caddy regenerate failed for %s (non-fatal): %s', service_id, e)
# Apply iptables rules (best-effort) return {'ok': True}
try:
apply_service_rules(service_id, ip, manifest.get('iptables_rules', []))
except Exception as e:
logger.warning(f'apply_service_rules for {service_id} failed (non-fatal): {e}')
# Regenerate Caddyfile
try:
caddy_routes = [
r.get('caddy_route')
for r in all_installed.values()
if r.get('caddy_route')
]
self.caddy_manager.regenerate_with_installed(caddy_routes)
except Exception as e:
logger.warning(f'caddy regenerate for {service_id} failed (non-fatal): {e}')
# Start the container via docker compose
base_compose = os.environ.get('COMPOSE_FILE', '/app/docker-compose.yml')
try:
result = subprocess.run(
['docker', 'compose',
'-f', base_compose,
'-f', self.compose_override,
'up', '-d', manifest['container_name']],
capture_output=True, text=True, timeout=120,
)
if result.returncode != 0:
logger.warning(
f'docker compose up for {service_id} failed: {result.stderr.strip()}'
)
except Exception as e:
logger.warning(f'docker compose up for {service_id} failed (non-fatal): {e}')
return {
'ok': True,
'service_ip': ip,
'container_name': manifest['container_name'],
}
def remove(self, service_id: str, purge_data: bool = False) -> dict: def remove(self, service_id: str, purge_data: bool = False) -> dict:
"""Remove an installed service.""" """Remove an installed service."""
from firewall_manager import clear_service_rules
with self._lock: with self._lock:
installed = self.config_manager.get_installed_services() installed = self.config_manager.get_installed_services()
record = installed.get(service_id) if service_id not in installed:
if not record:
return {'ok': False, 'error': f'Service {service_id} is not installed'} return {'ok': False, 'error': f'Service {service_id} is not installed'}
container_name = record.get('container_name', service_id) # Prevent removing a service that others depend on
manifest = record.get('manifest', {}) if self.service_composer is not None:
base_compose = os.environ.get('COMPOSE_FILE', '/app/docker-compose.yml') dependents = self.service_composer._resolve_dependents(service_id, installed)
if dependents:
return {
'ok': False,
'error': f'Cannot remove {service_id}: required by {", ".join(sorted(dependents))}',
}
# Stop and remove container # Stop and remove containers (best-effort)
try: if self.service_composer is not None:
subprocess.run( try:
['docker', 'compose', self.service_composer.remove(service_id, purge_data=purge_data)
'-f', base_compose, except Exception as e:
'-f', self.compose_override, logger.warning('remove: composer.remove failed for %s (non-fatal): %s', service_id, e)
'stop', container_name],
capture_output=True, text=True, timeout=60,
)
except Exception as e:
logger.warning(f'docker compose stop for {service_id} failed (non-fatal): {e}')
try: # Remove from config
subprocess.run(
['docker', 'rm', '-f', container_name],
capture_output=True, text=True, timeout=30,
)
except Exception as e:
logger.warning(f'docker rm for {service_id} failed (non-fatal): {e}')
# Clear iptables rules
try:
clear_service_rules(service_id)
except Exception as e:
logger.warning(f'clear_service_rules for {service_id} failed (non-fatal): {e}')
# Remove from config, regenerate compose + caddy
self.config_manager.remove_installed_service(service_id) self.config_manager.remove_installed_service(service_id)
remaining = self.config_manager.get_installed_services()
# Regenerate Caddy
try: try:
content = self._render_compose_override(remaining) self.caddy_manager.regenerate_with_installed([])
self._write_compose_override(content)
except Exception as e: except Exception as e:
logger.error(f'Failed to write compose override after remove: {e}') logger.warning('remove: caddy regenerate failed for %s (non-fatal): %s', service_id, e)
try:
caddy_routes = [
r.get('caddy_route')
for r in remaining.values()
if r.get('caddy_route')
]
self.caddy_manager.regenerate_with_installed(caddy_routes)
except Exception as e:
logger.warning(f'caddy regenerate after remove failed (non-fatal): {e}')
# Purge named volumes if requested
if purge_data:
for vol in manifest.get('volumes', []):
vol_name = vol.get('name', '')
if vol_name:
try:
subprocess.run(
['docker', 'volume', 'rm', vol_name],
capture_output=True, text=True, timeout=30,
)
except Exception as e:
logger.warning(
f'docker volume rm {vol_name} failed (non-fatal): {e}'
)
return {'ok': True} return {'ok': True}
@@ -581,13 +395,6 @@ class ServiceStoreManager(BaseServiceManager):
if not installed: if not installed:
return return
# Regenerate compose override in case it was deleted
try:
content = self._render_compose_override(installed)
self._write_compose_override(content)
except Exception as e:
logger.warning(f'reapply_on_startup: compose override write failed: {e}')
# Re-apply iptables rules # Re-apply iptables rules
for svc_id, record in installed.items(): for svc_id, record in installed.items():
ip = record.get('service_ip', '') ip = record.get('service_ip', '')
@@ -607,3 +414,10 @@ class ServiceStoreManager(BaseServiceManager):
self.caddy_manager.regenerate_with_installed(caddy_routes) self.caddy_manager.regenerate_with_installed(caddy_routes)
except Exception as e: except Exception as e:
logger.warning(f'reapply_on_startup: caddy regenerate failed: {e}') logger.warning(f'reapply_on_startup: caddy regenerate failed: {e}')
# Bring up per-service compose stacks
if self.service_composer is not None:
try:
self.service_composer.reapply_active_services()
except Exception as e:
logger.warning('reapply_on_startup: reapply_active_services failed: %s', e)
+69 -111
View File
@@ -299,64 +299,63 @@ def _make_ssm(tmp_dir, installed=None, identity=None):
} }
caddy = MagicMock() caddy = MagicMock()
container = 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( mgr = ServiceStoreManager(
config_manager=cm, config_manager=cm,
caddy_manager=caddy, caddy_manager=caddy,
container_manager=container, container_manager=container,
data_dir=tmp_dir, data_dir=tmp_dir,
config_dir=tmp_dir, config_dir=tmp_dir,
service_composer=composer,
) )
mgr.compose_override = os.path.join(tmp_dir, 'docker-compose.services.yml')
return mgr return mgr
class TestInstallHappyPath(unittest.TestCase): class TestInstallHappyPath(unittest.TestCase):
def test_install_fetches_manifest_renders_compose_calls_docker_up(self): 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: with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp) mgr = _make_ssm(tmp)
manifest = _ssm_manifest('email') manifest = _ssm_manifest('email')
mgr._fetch_manifest = MagicMock(return_value=manifest) 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'), \ result = mgr.install('email')
patch('service_store_manager.subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
result = mgr.install('email')
self.assertTrue(result['ok']) self.assertTrue(result['ok'])
mgr._fetch_manifest.assert_called_once_with('email') mgr._fetch_manifest.assert_called_once_with('email')
mgr.config_manager.set_installed_service.assert_called_once() mgr.config_manager.set_installed_service.assert_called_once()
# docker compose up must have been called # service_composer.install must have been called
self.assertTrue(mock_run.called) mgr.service_composer.install.assert_called_once()
docker_cmd = mock_run.call_args[0][0]
self.assertIn('up', docker_cmd)
self.assertIn('-d', docker_cmd)
def test_install_persists_install_record_before_docker_up(self): def test_install_persists_install_record_after_composer_install(self):
"""Install record must be written to config before docker compose up is attempted.""" """Install record must be written after service_composer.install succeeds."""
call_order = [] call_order = []
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp) mgr = _make_ssm(tmp)
manifest = _ssm_manifest('calendar') manifest = _ssm_manifest('calendar')
mgr._fetch_manifest = MagicMock(return_value=manifest) 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 = \ mgr.config_manager.set_installed_service.side_effect = \
lambda *a, **kw: call_order.append('set_installed') lambda *a, **kw: call_order.append('set_installed')
with patch('firewall_manager.apply_service_rules'), \ def _composer_install(*a, **kw):
patch('service_store_manager.subprocess.run') as mock_run: call_order.append('composer_install')
def _docker(*a, **kw): return {'ok': True}
call_order.append('docker_up') mgr.service_composer.install.side_effect = _composer_install
return MagicMock(returncode=0, stderr='') mgr.install('calendar')
mock_run.side_effect = _docker
mgr.install('calendar')
self.assertIn('composer_install', call_order)
self.assertIn('set_installed', call_order)
self.assertLess( self.assertLess(
call_order.index('composer_install'),
call_order.index('set_installed'), call_order.index('set_installed'),
call_order.index('docker_up'), 'composer.install must be called before install record is persisted',
'install record must be written before docker compose up',
) )
@@ -367,8 +366,7 @@ class TestInstallAlreadyInstalled(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
installed = {'email': {'id': 'email'}} installed = {'email': {'id': 'email'}}
mgr = _make_ssm(tmp, installed=installed) 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['ok'])
self.assertTrue(result.get('already_installed')) self.assertTrue(result.get('already_installed'))
@@ -378,8 +376,7 @@ class TestInstallAlreadyInstalled(unittest.TestCase):
installed = {'email': {'id': 'email'}} installed = {'email': {'id': 'email'}}
mgr = _make_ssm(tmp, installed=installed) mgr = _make_ssm(tmp, installed=installed)
mgr._fetch_manifest = MagicMock() mgr._fetch_manifest = MagicMock()
with patch('firewall_manager.apply_service_rules'): mgr.install('email')
mgr.install('email')
mgr._fetch_manifest.assert_not_called() mgr._fetch_manifest.assert_not_called()
def test_install_already_installed_does_not_write_config(self): def test_install_already_installed_does_not_write_config(self):
@@ -387,8 +384,7 @@ class TestInstallAlreadyInstalled(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
installed = {'calendar': {'id': 'calendar'}} installed = {'calendar': {'id': 'calendar'}}
mgr = _make_ssm(tmp, installed=installed) 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() mgr.config_manager.set_installed_service.assert_not_called()
@@ -427,47 +423,6 @@ class TestInstallManifestFetchFails(unittest.TestCase):
self.assertFalse(result['ok']) self.assertFalse(result['ok'])
mgr.config_manager.set_installed_service.assert_not_called() 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): def test_install_invalid_manifest_does_not_write_record(self):
"""Manifest validation failure must prevent any install record from being written.""" """Manifest validation failure must prevent any install record from being written."""
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -484,6 +439,36 @@ class TestInstallComposeUpFails(unittest.TestCase):
mgr.config_manager.set_installed_service.assert_not_called() 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()) # 6. ServiceStoreManager.uninstall() (remove())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -492,62 +477,39 @@ class TestUninstallHappyPath(unittest.TestCase):
def _make_mgr_with_email(self, tmp): def _make_mgr_with_email(self, tmp):
record = { record = {
'container_name': 'cell-email', 'id': 'email',
'service_ip': '172.20.0.20',
'manifest': { 'manifest': {
'image': 'git.pic.ngo/roof/email:1.0', 'image': 'git.pic.ngo/roof/email:1.0',
'volumes': [],
}, },
'iptables_rules': [],
} }
installed = {'email': record} installed = {'email': record}
mgr = _make_ssm(tmp, installed=installed) mgr = _make_ssm(tmp, installed=installed)
mgr.config_manager.remove_installed_service = MagicMock() 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 return mgr
def test_uninstall_happy_path_returns_ok_true(self): def test_uninstall_happy_path_returns_ok_true(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
mgr = self._make_mgr_with_email(tmp) mgr = self._make_mgr_with_email(tmp)
with patch('firewall_manager.clear_service_rules'), \ result = mgr.remove('email')
patch('service_store_manager.subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
result = mgr.remove('email')
self.assertTrue(result['ok']) self.assertTrue(result['ok'])
def test_uninstall_removes_install_record(self): def test_uninstall_removes_install_record(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
mgr = self._make_mgr_with_email(tmp) mgr = self._make_mgr_with_email(tmp)
with patch('firewall_manager.clear_service_rules'), \ mgr.remove('email')
patch('service_store_manager.subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
mgr.remove('email')
mgr.config_manager.remove_installed_service.assert_called_once_with('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: with tempfile.TemporaryDirectory() as tmp:
mgr = self._make_mgr_with_email(tmp) mgr = self._make_mgr_with_email(tmp)
with patch('firewall_manager.clear_service_rules'), \ mgr.remove('email')
patch('service_store_manager.subprocess.run') as mock_run: mgr.service_composer.remove.assert_called_once_with('email', purge_data=False)
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')
def test_uninstall_regenerates_caddyfile(self): def test_uninstall_regenerates_caddyfile(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
mgr = self._make_mgr_with_email(tmp) mgr = self._make_mgr_with_email(tmp)
with patch('firewall_manager.clear_service_rules'), \ mgr.remove('email')
patch('service_store_manager.subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
mgr.remove('email')
mgr.caddy_manager.regenerate_with_installed.assert_called() 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): def test_uninstall_service_not_installed_returns_error(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp, installed={}) 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.assertFalse(result['ok'])
self.assertIn('error', result) self.assertIn('error', result)
self.assertIn('not installed', result['error'].lower()) 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: with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp, installed={}) mgr = _make_ssm(tmp, installed={})
with patch('firewall_manager.clear_service_rules'), \ mgr.remove('email')
patch('service_store_manager.subprocess.run') as mock_run: mgr.service_composer.remove.assert_not_called()
mgr.remove('email')
mock_run.assert_not_called()
def test_uninstall_nonexistent_service_does_not_remove_config(self): def test_uninstall_nonexistent_service_does_not_remove_config(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
mgr = _make_ssm(tmp, installed={}) mgr = _make_ssm(tmp, installed={})
mgr.config_manager.remove_installed_service = MagicMock() 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() mgr.config_manager.remove_installed_service.assert_not_called()
+104
View File
@@ -532,5 +532,109 @@ class TestParsePsJson(unittest.TestCase):
self.assertEqual(len(result), 1) 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+161 -396
View File
@@ -18,7 +18,6 @@ import yaml
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
from service_store_manager import ServiceStoreManager from service_store_manager import ServiceStoreManager
from ip_utils import CONTAINER_OFFSETS
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -466,218 +465,6 @@ class TestValidateManifestSubdomain(unittest.TestCase):
self.assertTrue(ok) 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 # 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): 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): def test_install_already_installed_returns_ok_already_installed(self):
installed = {'myapp': {'id': 'myapp'}} cm = MagicMock()
mgr = _make_manager(installed=installed) cm.get_installed_services.return_value = {'myapp': {'id': 'myapp'}}
with patch('firewall_manager.apply_service_rules'): ssm, _, _, _ = _make_ssm(config_manager=cm)
result = mgr.install('myapp') result = ssm.install('myapp')
self.assertTrue(result['ok']) self.assertTrue(result['ok'])
self.assertTrue(result.get('already_installed')) 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): def test_install_fetch_failure_returns_error(self):
mgr = _make_manager() ssm, _, _, _ = _make_ssm()
mgr._fetch_manifest = MagicMock(side_effect=Exception('connection refused')) ssm._fetch_manifest = MagicMock(side_effect=Exception('connection refused'))
result = mgr.install('nonexistent') result = ssm.install('myapp')
self.assertFalse(result['ok']) self.assertFalse(result['ok'])
self.assertIn('error', result) self.assertIn('error', result)
self.assertIn('fetch', result['error'].lower()) 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): class TestRemove(unittest.TestCase):
def _mgr_with_installed(self, tmp_dir, service_id='myapp'): def _make_mgr_with_installed(self, service_id='myapp'):
record = { record = {
'container_name': 'cell-myapp', 'id': service_id,
'service_ip': '172.20.0.20', 'manifest': {'id': service_id, 'image': 'git.pic.ngo/roof/myapp:1.0'},
'manifest': {'image': 'git.pic.ngo/roof/myapp:1.0', 'volumes': []},
'iptables_rules': [],
} }
installed = {service_id: record} installed = {service_id: record}
mgr = _make_manager(tmp_dir=tmp_dir, installed=installed) cm = MagicMock()
# After remove, config_manager.get_installed_services returns empty cm.get_installed_services.return_value = installed
mgr.config_manager.remove_installed_service = MagicMock() caddy = MagicMock()
mgr.config_manager.get_installed_services.side_effect = [ composer = MagicMock()
installed, # first call (inside remove, initial check) composer._resolve_dependents.return_value = []
{}, # second call (after removal, for compose rewrite) composer.remove.return_value = {'ok': True}
] ssm = ServiceStoreManager(
mgr._write_compose_override = MagicMock() config_manager=cm,
return mgr caddy_manager=caddy,
container_manager=MagicMock(),
service_composer=composer,
)
return ssm, cm, caddy, composer
def test_remove_not_installed_returns_error(self): def test_remove_not_installed_returns_error(self):
mgr = _make_manager() cm = MagicMock()
with patch('firewall_manager.clear_service_rules'): cm.get_installed_services.return_value = {}
result = mgr.remove('nosuchapp') ssm = ServiceStoreManager(
config_manager=cm,
caddy_manager=MagicMock(),
container_manager=MagicMock(),
)
result = ssm.remove('nosuchapp')
self.assertFalse(result['ok']) self.assertFalse(result['ok'])
self.assertIn('error', result) self.assertIn('error', result)
self.assertIn('not installed', result['error']) self.assertIn('not installed', result['error'])
def test_remove_installed_returns_ok_true(self, tmp_dir='/tmp/pic-ssm-rm-test'): def test_remove_with_dependents_returns_error(self):
import tempfile, shutil ssm, _, _, composer = self._make_mgr_with_installed()
tmp = tempfile.mkdtemp() composer._resolve_dependents.return_value = ['webmail']
try: result = ssm.remove('myapp')
mgr = self._mgr_with_installed(tmp) self.assertFalse(result['ok'])
with patch('firewall_manager.clear_service_rules'), \ self.assertIn('error', result)
patch('service_store_manager.subprocess.run') as mock_run: self.assertIn('webmail', result['error'])
mock_run.return_value = MagicMock(returncode=0, stderr='')
result = mgr.remove('myapp') def test_remove_calls_composer_remove(self):
self.assertTrue(result['ok']) ssm, _, _, composer = self._make_mgr_with_installed()
finally: ssm.remove('myapp')
shutil.rmtree(tmp, ignore_errors=True) composer.remove.assert_called_once_with('myapp', purge_data=False)
def test_remove_calls_remove_installed_service(self): def test_remove_calls_remove_installed_service(self):
import tempfile, shutil ssm, cm, _, _ = self._make_mgr_with_installed()
tmp = tempfile.mkdtemp() ssm.remove('myapp')
try: cm.remove_installed_service.assert_called_once_with('myapp')
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)
def test_remove_calls_caddy_regenerate(self): def test_remove_calls_caddy_regenerate(self):
import tempfile, shutil ssm, _, caddy, _ = self._make_mgr_with_installed()
tmp = tempfile.mkdtemp() ssm.remove('myapp')
try: caddy.regenerate_with_installed.assert_called()
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)
def test_remove_purge_data_calls_docker_volume_rm(self): def test_remove_returns_ok_true(self):
import tempfile, shutil ssm, _, _, _ = self._make_mgr_with_installed()
tmp = tempfile.mkdtemp() result = ssm.remove('myapp')
try: self.assertTrue(result['ok'])
record = {
'container_name': 'cell-myapp', def test_remove_purge_data_passed_to_composer(self):
'service_ip': '172.20.0.20', ssm, _, _, composer = self._make_mgr_with_installed()
'manifest': { ssm.remove('myapp', purge_data=True)
'image': 'git.pic.ngo/roof/myapp:1.0', composer.remove.assert_called_once_with('myapp', purge_data=True)
'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)
if __name__ == '__main__': if __name__ == '__main__':