Files
vps-manager/scripts/make_migration_bundle.py
T
gouki cc8c91ddd9 feat(migration): 换宿主平台迁移能力(打包/恢复脚本 + runbook)
make_migration_bundle:sqlite backup API 在线快照(无需停服)打包 data/(两库+escrow+backups)与 .env,含逐文件 sha256 清单与版本/commit 元信息。restore_migration_bundle:sha256 逐文件校验(篡改即拒绝)、目标机原状态留 pre-restore 备份、兼容旧版 Python 的 tar 解包路径校验,并打印 serve/agent/CORS/源机下线等手工步骤。docs/migration-runbook.md:迁移面清单、可选预处理(agent 去 IP 化)、七步迁移、十项验收、回滚与灾难恢复。本地隔离演练验证:密码解密一致、2FA 出码可用、escrow+RESTORE_KEY 可解回 MASTER_KEY、篡改包被拦截。
2026-09-05 23:20:45 +00:00

139 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""迁移打包:把 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.dbsqlite backup API 在线一致性快照(无需停服)
- data/master_key.escrowMASTER_KEY 托管档(若已建立)
- data/backups/:历史备份(含 escrow 副本)
- .envMASTER_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] 缺少 .envMASTER_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())