diff --git a/Makefile b/Makefile index 11f3cc6..1c587ee 100644 --- a/Makefile +++ b/Makefile @@ -102,12 +102,12 @@ init-peers: start: @echo "Starting Personal Internet Cell..." - PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full up -d --build + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core up -d --build @echo "Services started. Check status with 'make status'" stop: @echo "Stopping Personal Internet Cell..." - PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core down @echo "Services stopped." restart: @@ -140,17 +140,13 @@ update: @git stash --include-untracked --quiet 2>/dev/null || true git pull @git stash pop --quiet 2>/dev/null || true - @if [ ! -f config/mail/mailserver.env ]; then \ - echo "Config missing — running setup first..."; \ - $(MAKE) setup; \ - fi @echo "Rebuilding and restarting services..." - PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full up -d --build + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core up -d --build @echo "Update complete. Run 'make status' to verify." reinstall: @echo "Reinstalling Personal Internet Cell from scratch..." - PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down -v 2>/dev/null || true + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core down -v 2>/dev/null || true @sudo rm -rf config/ data/ @$(MAKE) setup @$(MAKE) start @@ -179,14 +175,14 @@ uninstall: case "$$ans" in \ y|Y) \ echo "Stopping containers and removing images..."; \ - PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down -v --rmi all 2>/dev/null || true; \ + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core down -v --rmi all 2>/dev/null || true; \ echo "Deleting config/ and data/..."; \ sudo rm -rf config/ data/; \ echo "Uninstall complete. Git repo and scripts remain."; \ ;; \ n|N|"") \ echo "Stopping and removing containers (keeping images and data)..."; \ - PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down 2>/dev/null || true; \ + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core down 2>/dev/null || true; \ echo "Done. Images, config/ and data/ are untouched. Run 'make start' to bring it back up."; \ ;; \ *) \ diff --git a/api/app.py b/api/app.py index 1883116..bb6ebdd 100644 --- a/api/app.py +++ b/api/app.py @@ -54,6 +54,7 @@ from cell_manager import CellManager from wireguard_manager import _resolve_peer_dns from port_registry import PORT_FIELDS, detect_conflicts import auth_routes +from legacy_cleanup import cleanup_legacy_builtin_containers # Context variable for request info request_context = contextvars.ContextVar('request_context', default={}) @@ -374,6 +375,11 @@ def _apply_startup_enforcement(): sync_summary = cell_link_manager.replay_pending_pushes() if sync_summary.get('attempted'): logger.info(f"Startup permission sync: {sync_summary}") + # Remove legacy builtin containers from old main stack (one-shot, idempotent) + try: + cleanup_legacy_builtin_containers(config_manager) + except Exception as _cle: + logger.warning(f'legacy cleanup failed (non-fatal): {_cle}') # Service store: re-apply firewall/caddy rules for installed services try: service_store_manager.reapply_on_startup() diff --git a/api/legacy_cleanup.py b/api/legacy_cleanup.py new file mode 100644 index 0000000..acd86c8 --- /dev/null +++ b/api/legacy_cleanup.py @@ -0,0 +1,53 @@ +"""One-shot cleanup of legacy builtin containers from the old main compose stack.""" +import logging +import subprocess + +logger = logging.getLogger('picell') + +_LEGACY_BUILTIN_CONTAINERS = [ + 'cell-mail', 'cell-rainloop', 'cell-radicale', 'cell-webdav', 'cell-filegator', +] + + +def cleanup_legacy_builtin_containers(config_manager) -> None: + """Remove legacy containers whose compose project is 'pic' (main stack). + + Idempotent — guarded by _meta.legacy_builtins_cleaned in cell_config.json. + Containers from per-service installs (project != 'pic') are left untouched. + """ + try: + already_done = config_manager.configs.get('_meta', {}).get('legacy_builtins_cleaned', False) + if already_done: + return + except Exception: + return + + removed = [] + for cname in _LEGACY_BUILTIN_CONTAINERS: + try: + inspect = subprocess.run( + ['docker', 'inspect', cname, + '--format', '{{index .Config.Labels "com.docker.compose.project"}}'], + capture_output=True, text=True, timeout=10, + ) + if inspect.returncode != 0: + continue + project = inspect.stdout.strip() + if project != 'pic': + continue + subprocess.run(['docker', 'stop', cname], capture_output=True, timeout=30) + subprocess.run(['docker', 'rm', cname], capture_output=True, timeout=30) + removed.append(cname) + except Exception as exc: + logger.warning('cleanup_legacy_builtin_containers: %s: %s', cname, exc) + + try: + meta = dict(config_manager.configs.get('_meta', {})) + meta['legacy_builtins_cleaned'] = True + config_manager.configs['_meta'] = meta + config_manager._save_all_configs() + except Exception as exc: + logger.warning('cleanup_legacy_builtin_containers: failed to set sentinel: %s', exc) + + if removed: + logger.info('Removed legacy builtin containers: %s', ', '.join(removed)) diff --git a/docker-compose.yml b/docker-compose.yml index da9d422..cb44c68 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,7 +51,7 @@ services: dhcp: image: alpine:latest container_name: cell-dhcp - profiles: ["full"] + profiles: ["core", "full"] ports: - "${DHCP_PORT:-67}:67/udp" volumes: @@ -74,7 +74,7 @@ services: ntp: image: alpine:latest container_name: cell-ntp - profiles: ["full"] + profiles: ["core", "full"] ports: - "${NTP_PORT:-123}:123/udp" volumes: @@ -92,79 +92,6 @@ services: max-size: "10m" max-file: "5" - # Email Server - Postfix + Dovecot - mail: - image: mailserver/docker-mailserver:latest - container_name: cell-mail - profiles: ["full"] - hostname: mail - domainname: cell.local - env_file: ./config/mail/mailserver.env - ports: - - "${MAIL_SMTP_PORT:-25}:25" - - "${MAIL_SUBMISSION_PORT:-587}:587" - - "${MAIL_IMAP_PORT:-993}:993" - volumes: - - ./data/maildata:/var/mail - - ./data/mailstate:/var/mail-state - - ./data/maillogs:/var/log/mail - - ./config/mail/config:/tmp/docker-mailserver/ - - ./config/mail/ssl:/etc/letsencrypt - restart: unless-stopped - networks: - cell-network: - ipv4_address: ${MAIL_IP:-172.20.0.6} - cap_add: - - NET_ADMIN - logging: - driver: json-file - options: - max-size: "10m" - max-file: "5" - - # Calendar & Contacts - Radicale - radicale: - image: tomsquest/docker-radicale:latest - container_name: cell-radicale - profiles: ["full"] - ports: - - "127.0.0.1:${RADICALE_PORT:-5232}:5232" - volumes: - - ./config/radicale:/etc/radicale - - ./data/radicale:/data - restart: unless-stopped - networks: - cell-network: - ipv4_address: ${RADICALE_IP:-172.20.0.7} - logging: - driver: json-file - options: - max-size: "10m" - max-file: "5" - - # File Storage - WebDAV - webdav: - image: bytemark/webdav:latest - container_name: cell-webdav - profiles: ["full"] - ports: - - "127.0.0.1:${WEBDAV_PORT:-8080}:80" - environment: - - AUTH_TYPE=Basic - - USERNAME=${WEBDAV_USER:-admin} - - PASSWORD=${WEBDAV_PASS} - volumes: - - ./data/files:/var/lib/dav - restart: unless-stopped - networks: - cell-network: - ipv4_address: ${WEBDAV_IP:-172.20.0.8} - logging: - driver: json-file - options: - max-size: "10m" - max-file: "5" - # WireGuard VPN wireguard: image: linuxserver/wireguard:latest @@ -251,44 +178,6 @@ services: max-size: "10m" max-file: "5" - # Webmail - RainLoop - rainloop: - image: hardware/rainloop - container_name: cell-rainloop - profiles: ["full"] - restart: unless-stopped - networks: - cell-network: - ipv4_address: ${RAINLOOP_IP:-172.20.0.12} - ports: - - "127.0.0.1:${RAINLOOP_PORT:-8888}:8888" - volumes: - - ./data/rainloop:/rainloop/data - logging: - driver: json-file - options: - max-size: "10m" - max-file: "5" - - # File Manager - FileGator - filegator: - image: filegator/filegator - container_name: cell-filegator - profiles: ["full"] - restart: unless-stopped - networks: - cell-network: - ipv4_address: ${FILEGATOR_IP:-172.20.0.13} - ports: - - "127.0.0.1:${FILEGATOR_PORT:-8082}:8080" - volumes: - - ./data/filegator:/var/www/filegator/private - logging: - driver: json-file - options: - max-size: "10m" - max-file: "5" - networks: cell-network: driver: bridge diff --git a/tests/test_legacy_cleanup.py b/tests/test_legacy_cleanup.py new file mode 100644 index 0000000..27ed84f --- /dev/null +++ b/tests/test_legacy_cleanup.py @@ -0,0 +1,99 @@ +"""Tests for cleanup_legacy_builtin_containers in legacy_cleanup.py.""" +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent / 'api')) + +from legacy_cleanup import cleanup_legacy_builtin_containers + + +def _make_cm(already_cleaned=False): + cm = MagicMock() + cm.configs = {'_meta': {'legacy_builtins_cleaned': already_cleaned}} if already_cleaned else {} + return cm + + +class TestCleanupLegacyBuiltinContainers(unittest.TestCase): + + def test_sentinel_true_skips_all_docker_calls(self): + cm = _make_cm(already_cleaned=True) + with patch('legacy_cleanup.subprocess.run') as mock_run: + cleanup_legacy_builtin_containers(cm) + mock_run.assert_not_called() + + def test_container_not_found_skipped(self): + """docker inspect returns non-zero -> container absent -> no stop/rm.""" + cm = _make_cm() + inspect_result = MagicMock(returncode=1, stdout='', stderr='') + with patch('legacy_cleanup.subprocess.run', return_value=inspect_result) as mock_run: + cleanup_legacy_builtin_containers(cm) + # Only inspect calls, no stop/rm + for c in mock_run.call_args_list: + self.assertNotIn('stop', c.args[0]) + self.assertNotIn('rm', c.args[0]) + + def test_container_from_per_service_project_not_removed(self): + """Project label 'pic-email' -> skip (per-service install).""" + cm = _make_cm() + inspect_result = MagicMock(returncode=0, stdout='pic-email\n') + with patch('legacy_cleanup.subprocess.run', return_value=inspect_result) as mock_run: + cleanup_legacy_builtin_containers(cm) + for c in mock_run.call_args_list: + self.assertNotIn('stop', c.args[0]) + + def test_container_from_main_stack_removed(self): + """Project label 'pic' -> stop + rm called.""" + cm = _make_cm() + def side_effect(cmd, **kwargs): + r = MagicMock(returncode=0) + if 'inspect' in cmd: + r.stdout = 'pic\n' + else: + r.stdout = '' + return r + with patch('legacy_cleanup.subprocess.run', side_effect=side_effect) as mock_run: + cleanup_legacy_builtin_containers(cm) + cmds = [c.args[0] for c in mock_run.call_args_list] + stop_cmds = [c for c in cmds if 'stop' in c] + self.assertGreater(len(stop_cmds), 0) + + def test_sentinel_set_after_run(self): + """_meta.legacy_builtins_cleaned is set to True after cleanup.""" + cm = _make_cm() + inspect_result = MagicMock(returncode=1, stdout='') + with patch('legacy_cleanup.subprocess.run', return_value=inspect_result): + cleanup_legacy_builtin_containers(cm) + self.assertTrue(cm.configs.get('_meta', {}).get('legacy_builtins_cleaned', False)) + cm._save_all_configs.assert_called() + + def test_exception_in_inspect_does_not_crash(self): + """If docker inspect throws, the function continues and sets sentinel.""" + cm = _make_cm() + with patch('legacy_cleanup.subprocess.run', side_effect=OSError('docker not found')): + cleanup_legacy_builtin_containers(cm) # must not raise + self.assertTrue(cm.configs.get('_meta', {}).get('legacy_builtins_cleaned', False)) + + def test_mixed_containers_only_pic_project_removed(self): + """Some containers are per-service installs, only 'pic'-labelled ones removed.""" + cm = _make_cm() + call_count = [0] + def side_effect(cmd, **kwargs): + r = MagicMock(returncode=0) + if 'inspect' in cmd: + call_count[0] += 1 + # First container is per-service, rest are main stack + r.stdout = 'pic-email\n' if call_count[0] == 1 else 'pic\n' + else: + r.stdout = '' + return r + with patch('legacy_cleanup.subprocess.run', side_effect=side_effect) as mock_run: + cleanup_legacy_builtin_containers(cm) + stop_cmds = [c.args[0] for c in mock_run.call_args_list if 'stop' in c.args[0]] + # 4 of 5 containers should be stopped (first was per-service) + self.assertEqual(len(stop_cmds), 4) + + +if __name__ == '__main__': + unittest.main()