From 6e2a53ad20ecff29d8c8b48c2f105cf0cdebe0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AD=8F=E9=81=87=E5=8D=BF?= <358614401@qq.com> Date: Sat, 17 Oct 2026 22:32:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E7=90=86=20NAS=20=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=B9=B6=E5=BD=92=E6=A1=A3=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新 Compose 和部署更新脚本以使用新的状态、凭据及日历目录布局;新增目录迁移、诊断和历史清理工具及本机演练测试,并记录 NAS 迁移与清理结果。 --- README-NAS.md | 4 +- deploy/compose.override.yml | 2 +- deploy/compose.yml | 8 +- deploy/directory-migration/Invoke-Cleanup.ps1 | 17 + .../directory-migration/Invoke-Diagnose.ps1 | 13 + .../directory-migration/Invoke-Migration.ps1 | 26 ++ deploy/directory-migration/cleanup_history.py | 94 ++++++ deploy/directory-migration/diagnose.py | 47 +++ deploy/directory-migration/migrate.py | 296 ++++++++++++++++++ .../directory-migration/test_local_rebuild.py | 90 ++++++ deploy/directory-migration/test_migrate.py | 187 +++++++++++ deploy/query-summary/compose.override.yml | 2 +- deploy/query-summary/update-health.sh | 2 +- docs/nas/directory-migration.md | 58 ++++ 14 files changed, 838 insertions(+), 8 deletions(-) create mode 100644 deploy/directory-migration/Invoke-Cleanup.ps1 create mode 100644 deploy/directory-migration/Invoke-Diagnose.ps1 create mode 100644 deploy/directory-migration/Invoke-Migration.ps1 create mode 100644 deploy/directory-migration/cleanup_history.py create mode 100644 deploy/directory-migration/diagnose.py create mode 100644 deploy/directory-migration/migrate.py create mode 100644 deploy/directory-migration/test_local_rebuild.py create mode 100644 deploy/directory-migration/test_migrate.py create mode 100644 docs/nas/directory-migration.md diff --git a/README-NAS.md b/README-NAS.md index 87a1b41..639cacf 100644 --- a/README-NAS.md +++ b/README-NAS.md @@ -6,6 +6,8 @@ 2026-10-17 15:25:backend/web已更新为20261017-summary8,worker继续使用20261017-audit3。包含小米中国区云同步、SQLite长期保存与原始归档、备份恢复、密码鉴权、八类健康查询、紧凑参考线、纵轴下限和工作日/非工作日标记。ChatGPT隧道仍未启用。 +2026-10-17 17:02:NAS 项目目录整理完成。网页日历位于 `web/calendar`,后端凭据位于 `backend/secrets`,backend/worker 共用 `shared/state`;历史安装和回退文件集中在 `archive`。镜像、密码和端口不变,迁移及实际就绪核对通过。详见 [目录迁移记录](docs/nas/directory-migration.md)。旧离线包不适用于新目录,后续应从当前部署源重新生成发布包。 + 后端汇总与分页优化已通过本机合成数据、浏览器、容器及独立审查,并在用户授权后部署NAS。部署检查返回0,配置哈希匹配,worker状态ok;未在NAS执行合成或性能测试。八类查询的统计、最多120个图表桶与每页10条明细由后端返回,CSV按流传输。 ## 目录 @@ -22,7 +24,7 @@ 沿用pyproject.toml中的Python依赖,建议uv创建独立开发环境。已有环境可执行 `python -m pytest tests/test_web.py -q` 和 `python qa/test_workday_calendar.py`;前端使用 `node --test qa/*.test.cjs`。测试只使用临时或合成数据。 -此仓库不包含.env、密钥环、数据库、真实健康记录、镜像包和第三方依赖缓存;不向Git提交这些内容。上游历史保持,远程upstream只作来源,未创建或推送任何网络仓库。 +此仓库不包含.env、密钥环、数据库、真实健康记录、镜像包和第三方依赖缓存;不向Git提交这些内容。上游历史保持,远程upstream只作来源;origin 为用户指定的 GitHub 仓库,首次已上传 nas-health 分支,后续提交与推送分别按用户授权执行。 汇总优化的合成数据、旧查询对照和独立Edge验收入口为 `qa/serve_query_fixture.py`、`qa/benchmark_server_queries.py`、`qa/verify_server_queries_browser.cjs`。全部数据库、浏览器profile、截图、日志和离线包仅放忽略的 `artifacts/query-summary/`。实际测量、限制与复现步骤见 [后端查询验证报告](docs/nas/backend-query-validation.md)。 diff --git a/deploy/compose.override.yml b/deploy/compose.override.yml index e84866e..754f9f7 100644 --- a/deploy/compose.override.yml +++ b/deploy/compose.override.yml @@ -9,7 +9,7 @@ services: MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1,192.168.31.201 MI_FITNESS_WEB_PASSWORD: ${MI_FITNESS_WEB_PASSWORD:?尚未初始化网页访问密码} volumes: - - ./calendar:/calendar + - ./web/calendar:/calendar ports: !override - '127.0.0.1:18322:8322' - '192.168.31.201:18322:8322' diff --git a/deploy/compose.yml b/deploy/compose.yml index c5322bd..2236f5f 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -8,8 +8,8 @@ services: MI_FITNESS_ADMIN_KEY: ${MI_FITNESS_ADMIN_KEY:?需在本地生成管理员密钥} MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1,backend volumes: - - ./state:/state - - ./secrets/keyring_master:/run/secrets/keyring_master:ro + - ./shared/state:/state + - ./backend/secrets/keyring_master:/run/secrets/keyring_master:ro healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8321/', timeout=3).close()"] interval: 15s @@ -49,7 +49,7 @@ services: image: local/mi-fitness-nas:20261016 command: ["worker"] volumes: - - ./state:/state + - ./shared/state:/state depends_on: backend: condition: service_healthy @@ -67,7 +67,7 @@ services: command: ["mcp"] profiles: [chatgpt] volumes: - - ./state/data:/health:ro + - ./shared/state/data:/health:ro user: '1000:1001' read_only: true tmpfs: diff --git a/deploy/directory-migration/Invoke-Cleanup.ps1 b/deploy/directory-migration/Invoke-Cleanup.ps1 new file mode 100644 index 0000000..eadfd18 --- /dev/null +++ b/deploy/directory-migration/Invoke-Cleanup.ps1 @@ -0,0 +1,17 @@ +$ErrorActionPreference = 'Stop' +$script = Join-Path $PSScriptRoot 'cleanup_history.py' +$remote = '/tmp/mi-fitness-cleanup-history-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.py' +& scp $script "fn:$remote" +if ($LASTEXITCODE -ne 0) { throw '清理工具上传失败' } +$expected = (Get-FileHash -LiteralPath $script -Algorithm SHA256).Hash.ToLowerInvariant() +$actual = (& ssh fn "sha256sum '$remote'") -split '\s+' +if ($LASTEXITCODE -ne 0 -or $actual[0] -ne $expected) { throw '传输校验失败' } +Write-Host '仅删除已列出的过时归档。保留运行数据、最近成功迁移记录和当前配置备份,不重启容器。' +& ssh -t fn "sudo python3 '$remote'" +$code = $LASTEXITCODE +$directory = Join-Path $PSScriptRoot '../../artifacts/directory-migration' +New-Item -ItemType Directory -Path $directory -Force | Out-Null +@{exit_code=$code;completed_at=(Get-Date -Format o)} | ConvertTo-Json | + Set-Content -LiteralPath (Join-Path $directory 'cleanup-terminal-result.local.json') -Encoding utf8NoBOM +if ($code -ne 0) { throw '清理未完成,请保留输出。' } +Write-Host '清理完成。' diff --git a/deploy/directory-migration/Invoke-Diagnose.ps1 b/deploy/directory-migration/Invoke-Diagnose.ps1 new file mode 100644 index 0000000..0d0c415 --- /dev/null +++ b/deploy/directory-migration/Invoke-Diagnose.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = 'Stop' +$remote = '/tmp/mi-fitness-migration-diagnose-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.py' +& scp (Join-Path $PSScriptRoot 'diagnose.py') "fn:$remote" +if ($LASTEXITCODE -ne 0) { throw '诊断脚本上传失败' } +Write-Host '此次仅收集容器状态、迁移时事件和候选配置检查,不迁移、不重启。' +$output = & ssh -t fn "sudo python3 '$remote'" +$code = $LASTEXITCODE +$output | ForEach-Object { Write-Host $_ } +$directory = Join-Path $PSScriptRoot '../../artifacts/directory-migration' +New-Item -ItemType Directory -Force -Path $directory | Out-Null +$output | Set-Content -LiteralPath (Join-Path $directory 'diagnose.local.txt') -Encoding utf8NoBOM +if ($code -ne 0) { throw '诊断未完成,请保留输出。' } +Write-Host '诊断结果已保存到本机,可以回到聊天回复“已完成”。' diff --git a/deploy/directory-migration/Invoke-Migration.ps1 b/deploy/directory-migration/Invoke-Migration.ps1 new file mode 100644 index 0000000..40728a1 --- /dev/null +++ b/deploy/directory-migration/Invoke-Migration.ps1 @@ -0,0 +1,26 @@ +param([switch]$Run) +$ErrorActionPreference = 'Stop' +$migration = Join-Path $PSScriptRoot 'migrate.py' +$check = Join-Path $PSScriptRoot '../query-summary/check-ready.py' +if (-not $Run) { throw '仅在用户已批准目录迁移后使用 -Run。' } +$remote = '/tmp/mi-fitness-directory-migration-' + (Get-Date -Format 'yyyyMMdd-HHmmss') +& ssh fn "umask 077; mkdir '$remote'" +if ($LASTEXITCODE -ne 0) { throw '创建传输目录失败' } +foreach ($file in @($migration, $check)) { + & scp $file "fn:$remote/" + if ($LASTEXITCODE -ne 0) { throw '迁移工具上传失败' } + $name = Split-Path $file -Leaf + $expected = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash.ToLowerInvariant() + $actual = (& ssh fn "sha256sum '$remote/$name'") -split '\s+' + if ($LASTEXITCODE -ne 0 -or $actual[0] -ne $expected) { throw '传输校验未通过' } +} +Write-Host '仅整理健康项目目录并重建三个健康容器;不变更镜像、密码或端口。' +Write-Host '如出现 sudo 提示,请仅在此窗口输入 NAS 密码。' +& ssh -t fn "sudo python3 '$remote/migrate.py'" +$result = $LASTEXITCODE +$receipt = Join-Path $PSScriptRoot '../../artifacts/directory-migration' +New-Item -ItemType Directory -Path $receipt -Force | Out-Null +@{ exit_code=$result; completed_at=(Get-Date -Format o); remote_tools=$remote } | + ConvertTo-Json | Set-Content -LiteralPath (Join-Path $receipt 'terminal-result.local.json') -Encoding utf8NoBOM +if ($result -ne 0) { throw '迁移未完成,请保留终端输出。' } +Write-Host '项目目录迁移完成,网站地址和密码保持不变。' diff --git a/deploy/directory-migration/cleanup_history.py b/deploy/directory-migration/cleanup_history.py new file mode 100644 index 0000000..5ed704d --- /dev/null +++ b/deploy/directory-migration/cleanup_history.py @@ -0,0 +1,94 @@ +"""只清理明确列出的旧归档,不操作容器、镜像或运行数据。""" +import hashlib +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +from datetime import datetime, timezone + +ROOT = Path('/vol2/1000/Soft/mi-fitness-health') +KEEP = 'directory-migration-retry-1792227666562967051' +OBSOLETE = ['initial-install', 'password-change', 'rollback-configs', 'ui4', 'ui5', 'ui6', 'ui7', + 'directory-migration', 'directory-migration-retry-1792227359874267909'] +EXPECTED = {'compose.yml': 'b5c965c653948e97e02e22774bd15253dbde74d5e25616fa0115f047a36556dd', + 'compose.override.yml': 'd77485b3d3177def55c200478ba9e4645689f8476f46c13a47487be22edcd1c1'} + + +def candidates(root): + root = Path(root) + archive = root/'archive' + if root.is_symlink() or root.resolve() != root or archive.is_symlink() or archive.resolve().parent != root: + raise RuntimeError('项目或归档路径异常') + paths = [] + for name in OBSOLETE: + path = archive/name + if path.is_symlink() or path.resolve().parent != archive or not path.is_dir(): + raise RuntimeError('历史目录缺失或路径异常,未清理任何文件') + for child in path.rglob('*'): + if not (stat.S_ISREG(child.lstat().st_mode) or stat.S_ISDIR(child.lstat().st_mode)): + raise RuntimeError('历史目录包含链接或特殊文件,未清理任何文件') + paths.append(path) + return paths + + +def main(): + if os.geteuid() != 0: + raise RuntimeError('需要通过 sudo 执行') + paths = candidates(ROOT) + keep = ROOT/'archive'/KEEP + if keep.is_symlink() or json.loads((keep/'result.json').read_text(encoding='utf-8'))['status'] != 'complete': + raise RuntimeError('成功迁移记录异常,停止') + for name, expected in EXPECTED.items(): + path = ROOT/name + if path.is_symlink() or hashlib.sha256(path.read_bytes()).hexdigest() != expected: + raise RuntimeError('当前配置已变化,停止') + ids = subprocess.check_output(['docker', 'ps', '-aq']).decode().split() + items = json.loads(subprocess.check_output(['docker','inspect',*ids],stderr=subprocess.PIPE)) if ids else [] + for item in items: + for mount in item.get('Mounts', []): + if mount.get('Type') != 'bind': + continue + source = Path(mount['Source']).resolve() + if any(source == p or source.is_relative_to(p) or p.is_relative_to(source) for p in paths): + raise RuntimeError('容器引用历史归档,停止') + services = {'/mi-fitness-health-'+x+'-1' for x in ['web','backend','worker']} + current = [i for i in items if i['Name'] in services] + if len(current)!=3 or not all(i['State']['Running'] for i in current): + raise RuntimeError('健康容器状态异常,停止') + snapshots = [(i['Id'], i['State']['StartedAt']) for i in current] + record = {'at': datetime.now(timezone.utc).isoformat(), 'directories':[], 'bytes_removed':0, 'files_removed':0} + for path in paths: + files = [p for p in path.rglob('*') if p.is_file()] + size = sum(p.stat().st_size for p in files) + record['directories'].append({'name':path.name,'files':len(files),'bytes':size}) + record['bytes_removed'] += size + record['files_removed'] += len(files) + # 保留当前可用配置,而非仅留下引用旧路径的迁移前配置。 + for name in EXPECTED: + shutil.copy2(ROOT/name, keep/('current-'+name)) + (keep/'cleanup-plan.json').write_text(json.dumps(record,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') + for path in paths: + if path.is_symlink() or path.resolve().parent != ROOT/'archive': + raise RuntimeError('删除前路径核对失败,停止') + shutil.rmtree(path) + print('已清理:'+path.name,flush=True) + after = json.loads(subprocess.check_output(['docker','inspect',*[i['Id'] for i in current]],stderr=subprocess.PIPE)) + if snapshots != [(i['Id'],i['State']['StartedAt']) for i in after] or not all(i['State']['Running'] for i in after): + raise RuntimeError('容器状态发生变化,需核对') + for name, expected in EXPECTED.items(): + if hashlib.sha256((ROOT/name).read_bytes()).hexdigest()!=expected: + raise RuntimeError('当前配置发生变化,需核对') + record['status']='complete' + record['containers_unchanged']=True + (keep/'cleanup-result.json').write_text(json.dumps(record,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') + print('历史文件清理完成,释放约 %.1f MiB;三个容器未重启。' % (record['bytes_removed']/1024**2),flush=True) + + +if __name__=='__main__': + try: + main() + except Exception as error: + print(str(error) if type(error) is RuntimeError else type(error).__name__,flush=True) + raise SystemExit(1) diff --git a/deploy/directory-migration/diagnose.py b/deploy/directory-migration/diagnose.py new file mode 100644 index 0000000..cccd93b --- /dev/null +++ b/deploy/directory-migration/diagnose.py @@ -0,0 +1,47 @@ +"""只读诊断迁移失败;不停止容器,不读取健康数据或输出环境凭据。""" +import json +import subprocess +import tempfile +from pathlib import Path + +ROOT = Path('/vol2/1000/Soft/mi-fitness-health') +NAMES = ['mi-fitness-health-' + x + '-1' for x in ['backend', 'web', 'worker']] + + +def run(*args): + return subprocess.check_output(args, stderr=subprocess.PIPE, timeout=90) + + +items = json.loads(run('docker', 'inspect', *NAMES)) +for item in items: + print(json.dumps({'container': item['Name'], 'status': item['State']['Status'], + 'health': item['State'].get('Health', {}).get('Status'), + 'mounts': [{'source': m.get('Source'), 'destination': m['Destination']} + for m in item['Mounts'] if m.get('Type') == 'bind']}, ensure_ascii=False)) +events = run('docker', 'events', '--since', '2026-10-17T16:40:00+08:00', + '--until', '2026-10-17T16:45:00+08:00', '--filter', 'type=container', + '--filter', 'label=com.docker.compose.project=mi-fitness-health', '--format', '{{json .}}') +for line in events.splitlines(): + event = json.loads(line) + action = event.get('Action', '') + if action in {'create', 'start', 'stop', 'die', 'destroy', 'kill'} or action.startswith('health_status'): + attrs = event.get('Actor', {}).get('Attributes', {}) + print(json.dumps({'event': action, 'time': event.get('time'), + 'name': attrs.get('name'), 'exit_code': attrs.get('exitCode')}, ensure_ascii=False)) +with tempfile.TemporaryDirectory(prefix='health-migration-readonly-') as directory: + base = (ROOT/'compose.yml').read_text(encoding='utf-8') + over = (ROOT/'compose.override.yml').read_text(encoding='utf-8') + base = base.replace('./state:', './shared/state:').replace('./state/data:', './shared/state/data:').replace('./secrets/', './backend/secrets/') + over = over.replace('./calendar:', './web/calendar:') + paths = [] + for name, text in [('compose.yml', base), ('compose.override.yml', over)]: + path = Path(directory)/name + path.write_text(text, encoding='utf-8') + paths.extend(['-f', str(path)]) + try: + run('docker', 'compose', '--project-directory', str(ROOT), '--env-file', str(ROOT/'.env'), + '-p', 'mi-fitness-health', *paths, 'config', '--quiet') + print('候选挂载配置静态解析通过(未应用)。') + except subprocess.CalledProcessError: + print('候选挂载配置静态解析失败。') +print('只读诊断完成。') diff --git a/deploy/directory-migration/migrate.py b/deploy/directory-migration/migrate.py new file mode 100644 index 0000000..ce85513 --- /dev/null +++ b/deploy/directory-migration/migrate.py @@ -0,0 +1,296 @@ +"""已授权的 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) diff --git a/deploy/directory-migration/test_local_rebuild.py b/deploy/directory-migration/test_local_rebuild.py new file mode 100644 index 0000000..e0be93e --- /dev/null +++ b/deploy/directory-migration/test_local_rebuild.py @@ -0,0 +1,90 @@ +"""显式运行的本机真实容器演练,全部使用合成配置,不连接 NAS。""" +import ast +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import time + +HERE = Path(__file__).resolve().parent +DOCKER = 'C:/Users/35861/AppData/Local/Programs/DockerDesktop/resources/bin/docker.exe' +PROJECT = 'health-directory-local-rebuild' + + +def main(): + if sys.argv[1:] != ['--run']: + raise SystemExit('仅在本机运行时显式指定 --run') + with tempfile.TemporaryDirectory(prefix='health-directory-local-') as directory: + root = Path(directory) + for name in ['state', 'secrets', 'calendar']: + (root/name).mkdir() + (root/'secrets/keyring_master').write_text('synthetic-local-master-value-1234567890123456789', encoding='utf-8') + (root/'.env').write_text('MI_FITNESS_ADMIN_KEY=synthetic-local-admin\nMI_FITNESS_WEB_PASSWORD=synthetic-local-password\n', encoding='utf-8') + base = (HERE.parent/'compose.yml').read_text(encoding='utf-8').replace('name: mi-fitness-health', 'name: '+PROJECT) + over = (HERE.parent/'compose.override.yml').read_text(encoding='utf-8') + base = base.replace('./shared/state:', './state:').replace('./shared/state/data:', './state/data:').replace('./backend/secrets/', './secrets/') + over = over.replace('./web/calendar:', './calendar:') + over = over.replace(" - '127.0.0.1:18322:8322'\n - '192.168.31.201:18322:8322'", " - '127.0.0.1:0:8322'") + over += '\nnetworks:\n default:\n internal: true\n' + def save(): + (root/'compose.yml').write_text(base, encoding='utf-8') + (root/'compose.override.yml').write_text(over, encoding='utf-8') + save() + command = [DOCKER, '--context', 'desktop-linux', 'compose', '--project-directory', str(root), + '-p', PROJECT, '-f', str(root/'compose.yml'), '-f', str(root/'compose.override.yml')] + def compose(*args): + return subprocess.check_output([*command, *args], stderr=subprocess.STDOUT) + def inspect(): + ids = compose('ps', '-aq').decode().split() + return json.loads(subprocess.check_output([DOCKER, '--context', 'desktop-linux', 'inspect', *ids])) + def ready(): + deadline = time.monotonic()+90 + while time.monotonic() < deadline: + items = inspect() + if len(items)==3 and all(i['State']['Running'] and i['State'].get('Health',{}).get('Status','healthy')=='healthy' for i in items): + return + time.sleep(1) + raise RuntimeError('本机容器就绪超时') + module = ast.parse((HERE.parent/'query-summary/check-ready.py').read_text(encoding='utf-8')) + probe = next(ast.literal_eval(n.value) for n in module.body if isinstance(n, ast.Assign) + and any(isinstance(t, ast.Name) and t.id=='probe' for t in n.targets)) + try: + compose('up','-d','--no-build','--pull','never','backend','web','worker') + ready() + compose('stop','-t','30','worker','web','backend') + for source, target in [('state','shared/state'),('secrets','backend/secrets'),('calendar','web/calendar')]: + destination=root/target + destination.parent.mkdir(exist_ok=True) + (root/source).rename(destination) + base=base.replace('./state:', './shared/state:').replace('./state/data:', './shared/state/data:').replace('./secrets/', './backend/secrets/') + over=over.replace('./calendar:', './web/calendar:') + save() + compose('config','--quiet') + compose('up','-d','--no-build','--pull','never','backend','web','worker') + ready() + print('容器已标记就绪,立即执行实际网页探针:', flush=True) + result=subprocess.run([DOCKER,'--context','desktop-linux','exec','-i',PROJECT+'-web-1','python','-'], + input=probe.encode('utf-8'),capture_output=True) + print(result.stdout.decode('utf-8',errors='replace'), flush=True) + print(result.stderr.decode('utf-8',errors='replace'), flush=True) + if result.returncode: + print('首次探针失败,五秒后重复同一只读探针以区分启动时序。', flush=True) + time.sleep(5) + retry=subprocess.run([DOCKER,'--context','desktop-linux','exec','-i',PROJECT+'-web-1','python','-'], + input=probe.encode('utf-8'),capture_output=True) + print(retry.stdout.decode('utf-8',errors='replace'),flush=True) + print(retry.stderr.decode('utf-8',errors='replace'),flush=True) + if retry.returncode: + raise RuntimeError('本机网页探针持续失败') + print('LOCAL_REAL_REBUILD_AND_WEB_PROBE_OK',flush=True) + except subprocess.CalledProcessError as error: + # 此处仅含本机合成配置,无真实凭据。 + print(error.output.decode('utf-8',errors='replace'),flush=True) + raise + finally: + compose('down') + + +if __name__ == '__main__': + main() diff --git a/deploy/directory-migration/test_migrate.py b/deploy/directory-migration/test_migrate.py new file mode 100644 index 0000000..1a63026 --- /dev/null +++ b/deploy/directory-migration/test_migrate.py @@ -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() diff --git a/deploy/query-summary/compose.override.yml b/deploy/query-summary/compose.override.yml index e84866e..754f9f7 100644 --- a/deploy/query-summary/compose.override.yml +++ b/deploy/query-summary/compose.override.yml @@ -9,7 +9,7 @@ services: MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1,192.168.31.201 MI_FITNESS_WEB_PASSWORD: ${MI_FITNESS_WEB_PASSWORD:?尚未初始化网页访问密码} volumes: - - ./calendar:/calendar + - ./web/calendar:/calendar ports: !override - '127.0.0.1:18322:8322' - '192.168.31.201:18322:8322' diff --git a/deploy/query-summary/update-health.sh b/deploy/query-summary/update-health.sh index c4d4b33..eee7ab4 100644 --- a/deploy/query-summary/update-health.sh +++ b/deploy/query-summary/update-health.sh @@ -11,7 +11,7 @@ import hashlib,sys from pathlib import Path assert hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest()==Path(sys.argv[2]).read_text().strip(), '现有覆盖配置已变化,停止避免覆盖' PY -[ -d "$target/calendar" ] && [ ! -L "$target/calendar" ] || { echo '日历挂载目录缺失或异常,停止。'; exit 1; } +[ -d "$target/web/calendar" ] && [ ! -L "$target/web/calendar" ] || { echo '日历挂载目录缺失或异常,停止。'; exit 1; } cd "$target" compose() { docker compose --project-name mi-fitness-health --env-file .env -f compose.yml -f compose.override.yml "$@"; } snapshot() { docker ps -aq --filter label=com.docker.compose.project=mi-fitness-health | sort | while read -r id; do docker inspect --format '{{index .Config.Labels "com.docker.compose.service"}} {{.Id}} {{.Image}}' "$id"; done | grep -v -E '^(backend|web) ' | sort; } diff --git a/docs/nas/directory-migration.md b/docs/nas/directory-migration.md new file mode 100644 index 0000000..69dd343 --- /dev/null +++ b/docs/nas/directory-migration.md @@ -0,0 +1,58 @@ +# 项目目录整理 + +用户批准将三个容器的专属文件与共享状态分别存放,历史安装文件归档至项目目录。 + +```text +mi-fitness-health/ +├── compose.yml +├── compose.override.yml +├── .env +├── web/calendar/ +├── backend/secrets/ +├── worker/ +├── shared/state/ +└── archive/ + ├── initial-install/ + ├── ui4/ + ├── ui5/ + ├── ui6/ + ├── ui7/ + ├── password-change/ + ├── rollback-configs/ + └── directory-migration/ +``` + +`worker` 目前没有独立的持久化文件,使用 `shared/state`。backend 与 worker 的容器内路径仍是 `/state`;web 的缓存路径仍是 `/calendar`。凭据文件在容器内仍为只读。尚未启用的 readonly 服务挂载路径一并更新,但不启动该服务。 + +迁移工具:`deploy/directory-migration/migrate.py`。执行前验证旧配置 SHA256、三个容器的版本、其他容器引用和同文件系统。停止本项目三个服务后,对各文件树内容、属主和权限做快照,原地 rename,再核对内容与权限,修改三处宿主挂载路径。镜像、环境变量、端口、重启策略不得变化。失败时停止本项目容器,恢复原路径和配置,再启动原服务。 + +原 Compose 文件和迁移结果保存在 NAS 的 `archive/directory-migration/`;历史回退配置在 `archive/rollback-configs/` 中。历史脚本和配置仅归档留存,其中的旧路径不再适用,不应直接执行。 + +本机合成演练入口:`deploy/directory-migration/test_migrate.py`,覆盖成功迁移、就绪失败回退、其他容器引用阻止及配置变化阻止。NAS 只进行实际迁移与部署就绪核对,不运行合成测试或健康查询测试。 + +已获用户明确授权后,使用 PowerShell 7 执行 `deploy/directory-migration/Invoke-Migration.ps1 -Run`。sudo 密码仅在本机交互窗口输入,不写入脚本或聊天。 + +迁移已于 2026-10-17 17:02(北京时间)完成,终端返回 0。NAS 实际记录为 `archive/directory-migration-retry-1792227666562967051/result.json`,共核对并移动 21 个文件/目录树,三个健康容器及实际网页探针通过,外部旧临时目录已全部移入项目。核对包含文件哈希、权限和属主;未复制或拆分健康数据库。 + +`verification.json` 确认三个容器镜像与环境变量值均未改变,其中 backend/web 仅环境变量排列顺序变化,worker 顺序亦未改变。端口、挂载和重启策略核对通过,未操作其他项目容器。原安装包、维护脚本和回退配置全部保留,无数据删除。 + +实际生效配置 SHA256: + +- `compose.yml`:`b5c965c653948e97e02e22774bd15253dbde74d5e25616fa0115f047a36556dd` +- `compose.override.yml`:`d77485b3d3177def55c200478ba9e4645689f8476f46c13a47487be22edcd1c1` + +迁移工具只接受原始旧目录与配置,不可对已完成迁移的项目重复执行。后续更新需从新的本地部署源生成发布包;现有旧离线发布包和 archive 内旧脚本不适用于新目录。 + +首次迁移的文件校验和容器重建通过,但验收未通过,已自动回退;原配置 SHA256 与迁移前一致。NAS 事件表明 web/worker 启动约一秒后即触发回退,原脚本未保存子进程 stderr,因此无法还原该次具体异常。检查发现运行状态检查没有等待 web HTTP 服务,已增加最多 90 秒的真实网页探针重试。失败阶段和脱敏 stderr 保存在每次迁移记录目录的 `errors.log`;历史回退记录保留,重试另建记录目录。 + +本机 Windows 与 Linux 的六项针对性用例通过。独立合成项目的三个真实容器已完成停止、移动、重建,以及静态资源、鉴权、代理、OpenAPI 和日历探针验证;演练容器已移除,未访问真实健康数据。 + +第二次尝试在 `verify-services` 的镜像/环境变量合并检查处失败并回退。发现原检查直接比较 `Config.Env` 列表,会把仅顺序不同误判为变量值改变。已将镜像和环境检查分开,环境按唯一名称比较完整值;重复名称仍拒绝。本机 Windows/Linux 八项针对性用例通过,真实值变化仍会回退。`verification.json` 只记录容器名称、是否同镜像、变量值是否一致、顺序是否改变和变化的变量名,不记录变量值。 + +## 已授权的历史归档清理 + +2026-10-17 18:55(北京时间)完成。用户要求清理无用历史文件后,删除 initial-install、password-change、rollback-configs、ui4/5/6/7 及两次已回退迁移记录,共 9 个目录、74 个文件、678263713 字节(约 647 MiB)。删除前确认没有容器绑定这些归档路径;删除后确认三个健康容器的 ID、启动时间与运行状态未变,Compose 配置校验值未变,运行数据目录全部存在。 + +`archive` 当前仅保留 `directory-migration-retry-1792227666562967051/`,包含成功迁移记录、原配置、当前生效 Compose 配置备份和清理记录 `cleanup-result.json`。前文目录树及迁移过程中的“全部保留”描述为清理前状态。健康数据库、凭据、日历及其运行目录未删除;未清理 Docker 镜像或操作其他项目。 + +清理工具位于 `deploy/directory-migration/cleanup_history.py`,只接受该次固定清单;清理完成后不应重复执行。