mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 19:23:48 +08:00
整理 NAS 部署目录并归档迁移工具
更新 Compose 和部署更新脚本以使用新的状态、凭据及日历目录布局;新增目录迁移、诊断和历史清理工具及本机演练测试,并记录 NAS 迁移与清理结果。
This commit is contained in:
1 parent
1af63ff83a
commit
6e2a53ad20
14 files changed
+838
-8
No files matched your search
@@ -0,0 +1,187 @@
|
||||
"""本机合成文件演练;不连接 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()
|
||||
Reference in new issue
Block a user