Files
mi-fitness-health/deploy/directory-migration/test_migrate.py
T
weiyuqing 6e2a53ad20 整理 NAS 部署目录并归档迁移工具
更新 Compose 和部署更新脚本以使用新的状态、凭据及日历目录布局;新增目录迁移、诊断和历史清理工具及本机演练测试,并记录 NAS 迁移与清理结果。
2026-10-17 22:32:58 +08:00

188 lines
9.0 KiB
Python

"""本机合成文件演练;不连接 NAS、不读取真实数据。"""
import copy
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import subprocess
HERE = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location('directory_migration', HERE / 'migrate.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class SimulatedMigration(module.Migration):
def __init__(self, root):
super().__init__(root)
self.items = []
self.other = []
self.fail_verification = False
self.events = []
for name, image in zip(module.NAMES, ['summary8', 'summary8', 'audit3']):
kind = name.split('-')[-2]
mounts = ([('state', '/state', True)] if kind != 'web' else [('calendar', '/calendar', True)])
if kind == 'backend':
mounts.append(('secrets/keyring_master', '/run/secrets/keyring_master', False))
self.items.append({'Name': '/' + name, 'Image': image,
'State': {'Running': True, 'Health': {'Status': 'healthy'}},
'Config': {'Image': 'local/mi-fitness-nas:20261017-' + image,
'Env': ['EXAMPLE=synthetic'], 'Labels': {
'com.docker.compose.project.working_dir': str(root),
'com.docker.compose.project.config_files': ','.join(str(root / n) for n in module.EXPECTED)}},
'HostConfig': {'PortBindings': {}, 'ReadonlyRootfs': True, 'RestartPolicy': {'Name': 'unless-stopped'}},
'Mounts': [{'Source': str(root / p), 'Destination': d, 'RW': rw, 'Type': 'bind'} for p, d, rw in mounts]})
def run(self, *args):
if args[:3] == ('docker', 'ps', '-aq'):
return b'one\n'
if args[:2] == ('docker', 'inspect'):
return json.dumps(self.items + self.other).encode()
if args[0] == 'python3':
return b''
raise AssertionError('unexpected command')
def inspect(self):
return copy.deepcopy(self.items)
def compose(self, *args):
self.events.append(args[0])
if args[0] == 'stop':
for item in self.items:
item['State']['Running'] = False
if args[0] == 'up':
migrated = './shared/state:' in (self.root / 'compose.yml').read_text(encoding='utf-8')
for item in self.items:
item['State']['Running'] = True
for mount in item['Mounts']:
mount['Source'] = str(self.root / ({'/state': 'shared/state' if migrated else 'state',
'/calendar': 'web/calendar' if migrated else 'calendar',
'/run/secrets/keyring_master': 'backend/secrets/keyring_master' if migrated else 'secrets/keyring_master'}[mount['Destination']]))
return b''
def verify(self):
if self.fail_verification:
raise RuntimeError('synthetic readiness failure')
super().verify()
class MigrationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name).resolve() / 'mi-fitness-health'
self.root.mkdir()
for name in module.EXPECTED:
data = (HERE.parent / name).read_bytes().replace(b'\r\n', b'\n')
data = data.replace(b'./shared/state:', b'./state:').replace(b'./shared/state/data:', b'./state/data:')
data = data.replace(b'./backend/secrets/', b'./secrets/').replace(b'./web/calendar:', b'./calendar:')
(self.root / name).write_bytes(data)
for folder in ['state', 'secrets', 'calendar']:
(self.root / folder).mkdir()
(self.root / folder / 'synthetic.txt').write_text('synthetic-only', encoding='utf-8')
for name in ['.mi-fitness-health-stage-20261016-212720', '.mi-fitness-health-password-change',
*['.mi-fitness-health-ui%d-update' % n for n in range(4, 8)]]:
path = self.root.parent / name
path.mkdir()
(path / 'example.txt').write_text('archive-example', encoding='utf-8')
(self.root / 'compose.override.pre-synthetic.yml').write_text('old-config', encoding='utf-8')
self.migration = SimulatedMigration(self.root)
def test_success_keeps_contents_permissions_and_shared_mounts(self):
original = module.inventory(self.root / 'state')
self.migration.execute()
self.assertEqual(module.inventory(self.root / 'shared/state'), original)
self.assertFalse((self.root / 'state').exists())
self.assertTrue((self.root / 'archive/ui7/example.txt').exists())
self.assertTrue((self.root / 'archive/rollback-configs/compose.override.pre-synthetic.yml').exists())
self.assertEqual(json.loads((self.migration.journal / 'result.json').read_text())['status'], 'complete')
before = (self.root / 'compose.yml').read_text(encoding='utf-8')
self.assertIn('./shared/state/data:/health:ro', before)
def test_failed_readiness_restores_all_original_paths_and_configs(self):
self.migration.fail_verification = True
before = (self.root / 'compose.yml').read_bytes()
with self.assertRaises(RuntimeError):
self.migration.execute()
self.assertEqual((self.root / 'compose.yml').read_bytes(), before)
self.assertTrue((self.root / 'state/synthetic.txt').exists())
self.assertFalse((self.root / 'shared/state').exists())
self.assertTrue((self.root.parent / '.mi-fitness-health-ui7-update/example.txt').exists())
self.assertTrue(all(item['State']['Running'] for item in self.migration.items))
def test_other_container_reference_stops_before_mutation(self):
self.migration.other = [{'Name': '/other-production', 'Mounts': [
{'Type': 'bind', 'Source': str(self.root / 'state/data')}]}]
with self.assertRaises(RuntimeError):
self.migration.execute()
self.assertNotIn('stop', self.migration.events)
self.assertTrue((self.root / 'state').exists())
def test_changed_config_stops_before_mutation(self):
with (self.root / 'compose.yml').open('a', encoding='utf-8') as stream:
stream.write('# changed\n')
with self.assertRaises(RuntimeError):
self.migration.execute()
self.assertNotIn('stop', self.migration.events)
def test_web_probe_waits_for_http_after_container_reports_running(self):
self.migration.preflight()
# 挂载保持未迁移时,同样模拟预期路径转换以独立验证网页启动等待。
self.migration.before = []
attempts = []
original_run = self.migration.run
def delayed_probe(*args):
if args[0] == 'python3':
attempts.append(1)
if len(attempts) < 3:
raise subprocess.CalledProcessError(1, args, stderr=b'ConnectionRefusedError')
return b''
return original_run(*args)
self.migration.run = delayed_probe
with patch.object(module.time, 'sleep'):
self.migration.verify()
self.assertEqual(len(attempts), 3)
def test_retry_preserves_previous_rollback_record(self):
self.migration.fail_verification = True
with self.assertRaises(RuntimeError):
self.migration.execute()
previous = self.migration.journal
retry = SimulatedMigration(self.root)
retry.execute()
self.assertNotEqual(retry.journal, previous)
self.assertEqual(json.loads((previous/'result.json').read_text())['status'], 'rolled_back')
self.assertEqual(json.loads((retry.journal/'result.json').read_text())['status'], 'complete')
def test_environment_order_does_not_count_as_value_change(self):
for item in self.migration.items:
item['Config']['Env'] = ['EXAMPLE=synthetic', 'SECOND=unchanged']
original_compose = self.migration.compose
def recreate_with_reordered_environment(*args):
result = original_compose(*args)
if args[0] == 'up':
for item in self.migration.items:
item['Config']['Env'].reverse()
return result
self.migration.compose = recreate_with_reordered_environment
self.migration.execute()
self.assertEqual(json.loads((self.migration.journal/'result.json').read_text())['status'], 'complete')
def test_real_environment_value_change_still_rolls_back(self):
original_compose = self.migration.compose
def recreate_with_changed_environment(*args):
result = original_compose(*args)
if args[0] == 'up':
self.migration.items[0]['Config']['Env'] = ['EXAMPLE=changed']
return result
self.migration.compose = recreate_with_changed_environment
with self.assertRaisesRegex(RuntimeError, '环境变量'):
self.migration.execute()
self.assertTrue((self.root/'state/synthetic.txt').exists())
if __name__ == '__main__':
unittest.main()