feat: Phase 5 — remove legacy service blocks, one-shot container cleanup
Unit Tests / test (push) Successful in 11m20s
Unit Tests / test (push) Successful in 11m20s
Email, calendar, files, webmail (rainloop), and the file manager (filegator) are removed from the main docker-compose stack. They install as independent per-service compose projects via ServiceComposer. On startup, _cleanup_legacy_builtin_containers() stops and removes any of the 5 legacy containers still running from the old main stack (guarded by a one-shot sentinel in _meta.legacy_builtins_cleaned so it never runs twice). Per-service installs (com.docker.compose.project != 'pic') are left untouched. Changes: - docker-compose.yml: remove mail, radicale, webdav, rainloop, filegator blocks; fix dhcp + ntp to profiles: ["core","full"] so they start with --profile core - Makefile: replace all --profile full with --profile core (6 occurrences); remove mailserver.env conditional from update: target - api/legacy_cleanup.py: new module with cleanup_legacy_builtin_containers() - api/app.py: import and call cleanup at startup before reapply_on_startup() - tests/test_legacy_cleanup.py: 7 tests covering sentinel, absent containers, per-service project skip, main-stack removal, exception safety Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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."; \
|
||||
;; \
|
||||
*) \
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
+2
-113
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user