"""迁移打包:把 vps-manager 的唯一状态打成单一可校验迁移包 在源机器(如 cc1)运行: .venv/bin/python scripts/make_migration_bundle.py [输出tar.gz路径] 打包内容(唯一状态;代码/venv/systemd units/tailscale serve 均可由 setup.sh 与一条 serve 命令重建,故不在包内): - data/assets.db、data/metrics.db:sqlite backup API 在线一致性快照(无需停服) - data/master_key.escrow:MASTER_KEY 托管档(若已建立) - data/backups/:历史备份(含 escrow 副本) - .env:MASTER_KEY / API_KEY / AGENT_KEY / 通知渠道 / S3 配置 - manifest.json + manifest.sha256:版本、commit、主机、逐文件校验和 安全说明:包内含 MASTER_KEY 与全部密文,等同最高机密。传输仅用 scp 或加密 介质,恢复完成并验证后即刻删除;切勿上传公开对象存储或聊天工具。 恢复步骤见 scripts/restore_migration_bundle.py 与 docs/migration-runbook.md。 """ import hashlib import json import shutil import socket import sqlite3 import subprocess import sys import tarfile import tempfile from datetime import datetime, timezone from pathlib import Path BASE = Path(__file__).resolve().parent.parent def sha256_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def snap_db(src: Path, dst: Path) -> None: """sqlite 在线一致性快照:backup API 保证事务边界完整,源库无需停服""" con = sqlite3.connect(str(src)) bkp = sqlite3.connect(str(dst)) with bkp: con.backup(bkp) bkp.close() con.close() def read_git_commit() -> str: try: out = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], cwd=BASE, capture_output=True, text=True, timeout=5, ) return out.stdout.strip() or "unknown" except Exception: return "unknown" def main() -> int: out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path( f"/tmp/vps-manager-bundle-{datetime.now():%Y%m%d_%H%M%S}.tar.gz" ) env_src = BASE / ".env" if not env_src.exists(): print("[abort] 缺少 .env(MASTER_KEY 不在包内则迁移无意义)", file=sys.stderr) return 2 work = Path(tempfile.mkdtemp(prefix="vps-bundle-")) root = work / "bundle" data_dst = root / "data" data_dst.mkdir(parents=True) data_src = BASE / "data" copied: list[str] = [] for db in ("assets.db", "metrics.db"): src = data_src / db if src.exists(): snap_db(src, data_dst / db) copied.append(f"data/{db}") else: print(f"[skip] {db} 不存在") escrow = data_src / "master_key.escrow" if escrow.exists(): shutil.copy2(escrow, data_dst / "master_key.escrow") (data_dst / "master_key.escrow").chmod(0o600) copied.append("data/master_key.escrow") else: print("[warn] 未建立 MASTER_KEY 托管(master_key.escrow 不存在),恢复链不完整") backups_src = data_src / "backups" if backups_src.is_dir(): shutil.copytree(backups_src, data_dst / "backups") for p in sorted((data_dst / "backups").rglob("*")): if p.is_file(): copied.append(str(p.relative_to(root))) shutil.copy2(env_src, root / ".env") (root / ".env").chmod(0o600) copied.append(".env") manifest = { "app": "vps-manager", "version": (BASE / "VERSION").read_text(encoding="utf-8").strip() if (BASE / "VERSION").exists() else "dev", "commit": read_git_commit(), "source_host": socket.gethostname(), "created_at": datetime.now(timezone.utc).isoformat(), "files": {name: {"sha256": sha256_file(root / name), "size": (root / name).stat().st_size} for name in copied}, } (root / "manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" ) (root / "manifest.sha256").write_text( "".join(f"{v['sha256']} {name}\n" for name, v in manifest["files"].items()), encoding="utf-8", ) out.parent.mkdir(parents=True, exist_ok=True) with tarfile.open(out, "w:gz") as tar: tar.add(root, arcname="bundle") shutil.rmtree(work, ignore_errors=True) print(f"[ok] 迁移包:{out}") print(f" 大小:{out.stat().st_size / 1024:.1f} KB") print(f" 包校验和:{sha256_file(out)}") print(f" 源主机:{manifest['source_host']} · 版本 {manifest['version']} ({manifest['commit']})") print(f" 内含 {len(copied)} 个状态文件(两库快照 + escrow + backups + .env)") print(" 恢复:新机器跑 setup.sh 后停服,执行") print(f" .venv/bin/python scripts/restore_migration_bundle.py {out}") print(" ⚠ 包等同最高机密:仅 scp/加密介质传输,验证后删除") return 0 if __name__ == "__main__": sys.exit(main())