feat: Phase 5 — remove legacy service blocks, one-shot container cleanup
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:
2026-05-29 15:57:45 -04:00
parent a10fe11136
commit a69ca1e402
5 changed files with 166 additions and 123 deletions
+6 -10
View File
@@ -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."; \
;; \
*) \
+6
View File
@@ -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()
+53
View File
@@ -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
View File
@@ -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
+99
View File
@@ -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()