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

297 lines
16 KiB
Python

"""已授权的 NAS 目录整理;只移动本项目文件,失败时恢复原路径。"""
import hashlib
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import time
ROOT = Path('/vol2/1000/Soft/mi-fitness-health')
SERVICES = ['backend', 'web', 'worker']
NAMES = ['mi-fitness-health-' + name + '-1' for name in SERVICES]
EXPECTED = {
'compose.yml': '111fcd84b1740e377db958c459c922dda4191317e121267bdf3146a75e864ac0',
'compose.override.yml': '054b4541a3a2e50d46473061822c637c4694fa210414d05b2b6aa43deabea0d3',
}
def digest(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def inventory(path):
"""仅在内存中核对内容和权限,不输出凭据或健康文件名。"""
result = {}
for item in [path, *sorted(path.rglob('*'))] if path.is_dir() else [path]:
info = item.lstat()
if not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)):
raise RuntimeError('待迁移目录含链接或特殊文件,停止')
result[str(item.relative_to(path))] = (
stat.S_IFMT(info.st_mode), stat.S_IMODE(info.st_mode),
info.st_uid, info.st_gid, digest(item) if item.is_file() else None,
)
return result
def environment_values(item):
result = {}
for entry in item['Config'].get('Env', []):
key, separator, value = entry.partition('=')
if not separator or key in result:
raise RuntimeError('环境变量含缺失值或重复名称,停止核对')
result[key] = value
return result
class Migration:
def __init__(self, root=ROOT):
self.root = Path(root)
self.moves = []
self.snapshots = {}
self.original = {}
self.stopped = False
self.journal = self.root / 'archive' / 'directory-migration'
self.stage = 'preflight'
def run(self, *args):
# Docker inspect/config 的完整结果不得输出到终端(可能包含环境凭据)。
try:
return subprocess.check_output(list(args), stderr=subprocess.PIPE)
except subprocess.CalledProcessError as error:
if self.journal.is_dir():
detail = (error.stderr or b'').decode('utf-8', errors='replace')[-16000:]
for item in getattr(self, 'before', []):
for entry in item['Config'].get('Env', []):
key, _, value = entry.partition('=')
if len(value) >= 4 and any(word in key.upper() for word in ['PASSWORD', 'TOKEN', 'SECRET', 'KEY']):
detail = detail.replace(value, '[REDACTED]')
path = self.journal / 'errors.log'
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
with os.fdopen(descriptor, 'a', encoding='utf-8') as stream:
stream.write(json.dumps({'stage': self.stage, 'exit_code': error.returncode,
'stderr': detail}, ensure_ascii=False)+'\n')
raise
def compose(self, *args):
return self.run('docker', 'compose', '--project-directory', str(self.root),
'-p', 'mi-fitness-health', '-f', str(self.root / 'compose.yml'),
'-f', str(self.root / 'compose.override.yml'), *args)
def inspect(self):
return json.loads(self.run('docker', 'inspect', *NAMES))
def mapping(self):
pairs = [('state', 'shared/state'), ('secrets', 'backend/secrets'),
('calendar', 'web/calendar')]
for name, destination in [
('.mi-fitness-health-stage-20261016-212720', 'initial-install'),
('.mi-fitness-health-password-change', 'password-change'),
*[('.mi-fitness-health-ui%d-update' % n, 'ui%d' % n) for n in range(4, 8)],
]:
pairs.append((self.root.parent / name, 'archive/' + destination))
for item in sorted(self.root.iterdir()):
if item.name.startswith(('compose.override.pre-', '.env.pre-')):
pairs.append((item, 'archive/rollback-configs/' + item.name))
return [(self.root / source, self.root / target) for source, target in pairs]
def preflight(self):
if self.root.is_symlink() or self.root.resolve() != self.root:
raise RuntimeError('项目路径含符号链接,停止')
for name, expected in EXPECTED.items():
path = self.root / name
if path.is_symlink() or digest(path) != expected:
raise RuntimeError('当前配置已变化,需重新核对')
self.original[name] = path.read_bytes()
self.pairs = self.mapping()
if self.journal.exists():
result = self.journal / 'result.json'
if self.journal.is_symlink() or result.is_symlink() or not result.is_file() or json.loads(result.read_text(encoding='utf-8')).get('status') != 'rolled_back':
raise RuntimeError('已有未回退的迁移记录,拒绝重复运行')
self.journal = self.root / 'archive' / ('directory-migration-retry-' + str(time.time_ns()))
for name in ['shared', 'backend', 'web', 'worker']:
path = self.root / name
if path.exists() and (path.is_symlink() or not path.is_dir() or any(path.iterdir())):
raise RuntimeError('容器专属目标目录不为空,停止')
for source, target in self.pairs:
if not source.exists() or source.is_symlink() or target.exists():
raise RuntimeError('迁移源缺失或目标已存在,停止')
if source.resolve().parent not in (self.root, self.root.parent):
raise RuntimeError('迁移源超出授权目录')
if not target.resolve().is_relative_to(self.root):
raise RuntimeError('迁移目标超出项目目录')
if source.stat().st_dev != self.root.stat().st_dev:
raise RuntimeError('跨文件系统移动需另行处理,停止')
self.before = self.inspect()
expected_images = ['local/mi-fitness-nas:20261017-summary8',
'local/mi-fitness-nas:20261017-summary8',
'local/mi-fitness-nas:20261017-audit3']
for item, name, image in zip(self.before, NAMES, expected_images):
if item['Name'] != '/' + name or item['Config']['Image'] != image:
raise RuntimeError('运行容器或镜像与已确认版本不符')
if not item['State']['Running']:
raise RuntimeError('原容器未运行,需先核对')
labels = item['Config']['Labels']
if Path(labels['com.docker.compose.project.working_dir']) != self.root:
raise RuntimeError('容器项目目录不符')
files = labels['com.docker.compose.project.config_files'].split(',')
if set(files) != {str(self.root / n) for n in EXPECTED}:
raise RuntimeError('存在额外 Compose 配置,停止')
# 其他项目若引用待移动路径,不能继续。
ids = self.run('docker', 'ps', '-aq').decode().split()
containers = json.loads(self.run('docker', 'inspect', *ids)) if ids else []
for item in containers:
if item['Name'] in ['/' + n for n in NAMES]:
continue
for mount in item.get('Mounts', []):
if mount.get('Type') != 'bind':
continue
path = Path(mount.get('Source', '/'))
for source, _ in self.pairs:
if path == source or path.is_relative_to(source) or source.is_relative_to(path):
raise RuntimeError('其他容器引用待迁移路径,停止')
self.compose('config', '--quiet')
print('配置、容器引用和迁移路径预检通过。', flush=True)
def write_configs(self):
for name, content in self.original.items():
text = content.decode('utf-8')
if name == 'compose.yml':
if text.count('./state:') != 2 or text.count('./state/data:') != 1 or text.count('./secrets/') != 1:
raise RuntimeError('基础配置挂载数量不符')
text = text.replace('./state:', './shared/state:').replace('./state/data:', './shared/state/data:')
text = text.replace('./secrets/', './backend/secrets/')
else:
if text.count('./calendar:') != 1:
raise RuntimeError('日历挂载数量不符')
text = text.replace('./calendar:', './web/calendar:')
(self.root / name).write_bytes(text.encode('utf-8'))
def ready(self):
deadline = time.monotonic() + 180
while time.monotonic() < deadline:
items = self.inspect()
if all(item['State'].get('Running') and
item['State'].get('Health', {}).get('Status', 'healthy') == 'healthy'
for item in items):
return items
time.sleep(3)
raise RuntimeError('容器未按时就绪')
def verify(self):
after = self.ready()
replacements = {str(self.root / 'state'): str(self.root / 'shared/state'),
str(self.root / 'secrets/keyring_master'): str(self.root / 'backend/secrets/keyring_master'),
str(self.root / 'calendar'): str(self.root / 'web/calendar')}
def mounts(item, translated=False):
return sorted((replacements.get(m['Source'], m['Source']) if translated else m['Source'],
m['Destination'], m['RW']) for m in item['Mounts'])
comparisons = []
for old, new in zip(self.before, after):
old_env, new_env = environment_values(old), environment_values(new)
changed = sorted(key for key in old_env.keys() | new_env.keys()
if key not in old_env or key not in new_env or old_env[key] != new_env[key])
comparisons.append({'container': old['Name'], 'same_image': old['Image'] == new['Image'],
'same_environment_values': not changed, 'changed_environment_names': changed,
'environment_order_changed': old['Config'].get('Env') != new['Config'].get('Env')})
if self.journal.is_dir():
(self.journal/'verification.json').write_text(json.dumps(comparisons, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
for old, new, comparison in zip(self.before, after, comparisons):
if not comparison['same_image']:
raise RuntimeError('镜像标识发生变化:' + old['Name'])
if not comparison['same_environment_values']:
raise RuntimeError('环境变量值发生变化:' + old['Name'] + ' ' + ','.join(comparison['changed_environment_names']))
if comparison['environment_order_changed']:
print(old['Name'] + ' 环境变量值逐项一致,仅排列顺序变化。', flush=True)
for key in ['PortBindings', 'ReadonlyRootfs', 'RestartPolicy']:
if old['HostConfig'][key] != new['HostConfig'][key]:
raise RuntimeError('端口或运行策略发生额外变化')
if mounts(old, True) != mounts(new):
raise RuntimeError('挂载与迁移计划不符')
# 复用现有静态资源、鉴权和代理检查,不查询真实健康数据。
self.stage = 'web-readiness'
deadline = time.monotonic() + 90
while True:
try:
self.run('python3', str(Path(__file__).with_name('check-ready.py')))
break
except subprocess.CalledProcessError:
if time.monotonic() >= deadline:
raise
print('网页仍在启动,等待实际 HTTP 鉴权和代理就绪。', flush=True)
time.sleep(3)
print('三个容器已就绪;镜像、密码环境、端口及迁移挂载核对通过。', flush=True)
def execute(self):
self.preflight()
self.journal.mkdir(parents=True, mode=0o700)
for name, content in self.original.items():
path = self.journal / name
path.write_bytes(content)
shutil.copystat(self.root / name, path)
# 两端目录仅在项目内创建,不改原有运行文件权限。
for name in ['shared', 'backend', 'web', 'worker']:
(self.root / name).mkdir(mode=0o700, exist_ok=True)
shutil.copystat(self.root, self.root / name)
if hasattr(os, 'chown'):
info = self.root.stat()
os.chown(self.root / name, info.st_uid, info.st_gid)
try:
self.stopped = True
self.stage = 'stop-original-services'
self.compose('stop', '-t', '60', 'worker', 'web', 'backend')
if any(item['State']['Running'] for item in self.inspect()):
raise RuntimeError('容器未完全停止')
# 停止写入后再计算哈希;文件、权限、属主逐项核对。
self.stage = 'move-and-verify-files'
for source, target in self.pairs:
self.snapshots[str(source)] = inventory(source)
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
source.rename(target)
self.moves.append((source, target))
if inventory(target) != self.snapshots[str(source)]:
raise RuntimeError('移动后文件或权限校验失败')
print('全部文件移动完成,内容校验及权限核对通过。', flush=True)
self.write_configs()
self.stage = 'validate-migrated-config'
self.compose('config', '--quiet')
self.stage = 'recreate-services'
self.compose('up', '-d', '--no-build', '--pull', 'never', *SERVICES)
self.stage = 'verify-services'
self.verify()
receipt = {'status': 'complete', 'file_trees_verified': len(self.moves),
'services': SERVICES, 'paths': [[str(a), str(b)] for a, b in self.moves]}
(self.journal / 'result.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
print('项目目录迁移完成。', flush=True)
except BaseException:
failed_stage = self.stage
print('迁移未通过(阶段:' + failed_stage + '),正在恢复原路径和配置。', flush=True)
self.stage = 'rollback'
if self.stopped:
self.compose('stop', '-t', '60', 'worker', 'web', 'backend')
for source, target in reversed(self.moves):
if source.exists():
raise RuntimeError('回退源路径被占用,已停止,需人工核对')
target.rename(source)
for name, content in self.original.items():
(self.root / name).write_bytes(content)
self.compose('up', '-d', '--no-build', '--pull', 'never', *SERVICES)
self.ready()
(self.journal / 'result.json').write_text(json.dumps({'status': 'rolled_back', 'failed_stage': failed_stage})+'\n', encoding='utf-8')
print('已恢复原目录、配置并启动三个健康容器。', flush=True)
raise
if __name__ == '__main__':
if os.geteuid() != 0:
raise SystemExit('需要通过 sudo 在 NAS 本机执行')
try:
Migration().execute()
except BaseException as error:
# 不打印可能带容器环境变量的子进程输出。
detail = str(error) if type(error) is RuntimeError else type(error).__name__
print('迁移中止:' + detail, flush=True)
raise SystemExit(1)