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:
@@ -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