Smail/test/deployment/migrate_runtime.py
smail d7837de561
Some checks are pending
Security Checks / security (push) Waiting to run
保存模拟部署迁移与最新构建校验阶段成果
2026-09-08 18:33:21 +08:00

124 lines
5.6 KiB
Python

"""停服后迁移隔离模拟部署,保留源文件和完整备份,不清空业务数据库。"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import sqlite3
import subprocess
from datetime import datetime
ROOT = Path(__file__).resolve().parents[2]
TARGET = ROOT / "yunx-test"
CONFIG_NAMES = {f"{name}.json" for name in ("config", "smtp", "email", "ssl", "plugins")}
def safe_tree(directory):
for current, directories, files in os.walk(directory, followlinks=False):
for name in directories + files:
item = Path(current) / name
if item.is_symlink() or item.is_junction():
raise RuntimeError("迁移集合包含链接,已停止")
if directory.is_symlink() or directory.is_junction():
raise RuntimeError("迁移目录不能是链接")
def digest(filename):
return hashlib.file_digest(filename.open("rb"), "sha256").hexdigest()
def copy_verified(source, target):
if target.exists():
if target.is_file() and digest(source) == digest(target):
return
raise RuntimeError(f"目标冲突,拒绝覆盖:{target.relative_to(ROOT)}")
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
if digest(source) != digest(target):
raise RuntimeError("文件复制摘要不一致")
def copy_tree(source, target):
safe_tree(source)
for item in source.rglob("*"):
if item.is_file():
copy_verified(item, target / item.relative_to(source))
def sqlite_snapshot(source, target):
with sqlite3.connect(source.as_uri() + "?mode=ro", uri=True) as src:
if src.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise RuntimeError("源 SQLite 完整性检查未通过")
with sqlite3.connect(target) as dst:
src.backup(dst)
if dst.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise RuntimeError("目标 SQLite 完整性检查未通过")
tables = [row[0] for row in src.execute("SELECT name FROM sqlite_master WHERE type='table'")]
for name in tables:
quoted = '"' + name.replace('"', '""') + '"'
if src.execute(f"SELECT count(*) FROM {quoted}").fetchone() != dst.execute(f"SELECT count(*) FROM {quoted}").fetchone():
raise RuntimeError("SQLite 表记录数量不一致")
return len(tables)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
safe_tree(TARGET)
for name in ("config", "data", "ssl"):
safe_tree(ROOT / name)
if {item.name for item in (ROOT / "config").iterdir()} != CONFIG_NAMES:
raise RuntimeError("源配置目录不止五个指定配置")
if any((ROOT / "data" / "transaction").iterdir()):
raise RuntimeError("源目录存在未恢复配置事务,不能迁移")
if any((TARGET / name).exists() for name in ("config", "ssl", "data")):
raise RuntimeError("目标已有运行数据,拒绝重复迁移")
if not args.apply:
print("迁移集合检查通过。先停止前后端,再使用 --apply。")
return
# 再次核对端口,避免复制期间仍有配置写入。
result = subprocess.run([
"powershell.exe", "-NoProfile", "-Command",
"if (Get-NetTCPConnection -State Listen -LocalPort 5201 -ErrorAction SilentlyContinue) { exit 1 }"
], capture_output=True)
if result.returncode:
raise RuntimeError("5201 后端仍在运行,拒绝迁移")
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup = ROOT / "test-log" / f"deployment-migration-{stamp}"
backup.mkdir(parents=True, exist_ok=False)
for name in ("config", "data", "ssl"):
copy_tree(ROOT / name, backup / name)
for name in ("config", "ssl", "data", "log"):
(TARGET / name).mkdir(parents=True, exist_ok=True)
copy_tree(backup / "config", TARGET / "config")
copy_tree(backup / "data" / "ssl", TARGET / "ssl")
copy_tree(backup / "ssl", TARGET / "ssl")
copy_tree(backup / "data" / "dkim", TARGET / "ssl" / "dkim")
for name in ("credential.key", "external-email-encryption.key", "external-email-fingerprint.key"):
copy_verified(backup / "data" / name, TARGET / "ssl" / name)
copy_verified(backup / "data" / "config-audit-v2.jsonl", TARGET / "log" / "config-audit-v2.jsonl")
databases = {}
for source in (ROOT / "data").glob("*.db"):
databases[source.name] = {"tables": sqlite_snapshot(source, TARGET / "data" / source.name), "integrity": "ok"}
env = {**os.environ, "SMAIL_ROOT": str(TARGET)}
result = subprocess.run([
str(ROOT / "out" / "backend" / "smail-config.exe"), "--root", str(TARGET),
"--deployment-history", str(ROOT), "--apply"
], env=env, capture_output=True)
(backup / "import.stdout.log").write_bytes(result.stdout)
(backup / "import.stderr.log").write_bytes(result.stderr)
if result.returncode:
raise RuntimeError("配置历史导入失败;保留原目录、目标和备份供排查,不启动服务")
report = {"source": str(ROOT), "target": str(TARGET), "backup": str(backup), "databases": databases,
"files": {str(item.relative_to(TARGET)): digest(item) for item in TARGET.rglob("*") if item.is_file()}}
(backup / "receipt.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"迁移完成;一致性验收记录:{backup.relative_to(ROOT)}/receipt.json")
if __name__ == "__main__":
main()