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:
|
start:
|
||||||
@echo "Starting Personal Internet Cell..."
|
@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'"
|
@echo "Services started. Check status with 'make status'"
|
||||||
|
|
||||||
stop:
|
stop:
|
||||||
@echo "Stopping Personal Internet Cell..."
|
@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."
|
@echo "Services stopped."
|
||||||
|
|
||||||
restart:
|
restart:
|
||||||
@@ -140,17 +140,13 @@ update:
|
|||||||
@git stash --include-untracked --quiet 2>/dev/null || true
|
@git stash --include-untracked --quiet 2>/dev/null || true
|
||||||
git pull
|
git pull
|
||||||
@git stash pop --quiet 2>/dev/null || true
|
@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..."
|
@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."
|
@echo "Update complete. Run 'make status' to verify."
|
||||||
|
|
||||||
reinstall:
|
reinstall:
|
||||||
@echo "Reinstalling Personal Internet Cell from scratch..."
|
@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/
|
@sudo rm -rf config/ data/
|
||||||
@$(MAKE) setup
|
@$(MAKE) setup
|
||||||
@$(MAKE) start
|
@$(MAKE) start
|
||||||
@@ -179,14 +175,14 @@ uninstall:
|
|||||||
case "$$ans" in \
|
case "$$ans" in \
|
||||||
y|Y) \
|
y|Y) \
|
||||||
echo "Stopping containers and removing images..."; \
|
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/..."; \
|
echo "Deleting config/ and data/..."; \
|
||||||
sudo rm -rf config/ data/; \
|
sudo rm -rf config/ data/; \
|
||||||
echo "Uninstall complete. Git repo and scripts remain."; \
|
echo "Uninstall complete. Git repo and scripts remain."; \
|
||||||
;; \
|
;; \
|
||||||
n|N|"") \
|
n|N|"") \
|
||||||
echo "Stopping and removing containers (keeping images and data)..."; \
|
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."; \
|
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 wireguard_manager import _resolve_peer_dns
|
||||||
from port_registry import PORT_FIELDS, detect_conflicts
|
from port_registry import PORT_FIELDS, detect_conflicts
|
||||||
import auth_routes
|
import auth_routes
|
||||||
|
from legacy_cleanup import cleanup_legacy_builtin_containers
|
||||||
|
|
||||||
# Context variable for request info
|
# Context variable for request info
|
||||||
request_context = contextvars.ContextVar('request_context', default={})
|
request_context = contextvars.ContextVar('request_context', default={})
|
||||||
@@ -374,6 +375,11 @@ def _apply_startup_enforcement():
|
|||||||
sync_summary = cell_link_manager.replay_pending_pushes()
|
sync_summary = cell_link_manager.replay_pending_pushes()
|
||||||
if sync_summary.get('attempted'):
|
if sync_summary.get('attempted'):
|
||||||
logger.info(f"Startup permission sync: {sync_summary}")
|
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
|
# Service store: re-apply firewall/caddy rules for installed services
|
||||||
try:
|
try:
|
||||||
service_store_manager.reapply_on_startup()
|
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:
|
dhcp:
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
container_name: cell-dhcp
|
container_name: cell-dhcp
|
||||||
profiles: ["full"]
|
profiles: ["core", "full"]
|
||||||
ports:
|
ports:
|
||||||
- "${DHCP_PORT:-67}:67/udp"
|
- "${DHCP_PORT:-67}:67/udp"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -74,7 +74,7 @@ services:
|
|||||||
ntp:
|
ntp:
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
container_name: cell-ntp
|
container_name: cell-ntp
|
||||||
profiles: ["full"]
|
profiles: ["core", "full"]
|
||||||
ports:
|
ports:
|
||||||
- "${NTP_PORT:-123}:123/udp"
|
- "${NTP_PORT:-123}:123/udp"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -92,79 +92,6 @@ services:
|
|||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "5"
|
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 VPN
|
||||||
wireguard:
|
wireguard:
|
||||||
image: linuxserver/wireguard:latest
|
image: linuxserver/wireguard:latest
|
||||||
@@ -251,44 +178,6 @@ services:
|
|||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "5"
|
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:
|
networks:
|
||||||
cell-network:
|
cell-network:
|
||||||
driver: bridge
|
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