pipeline-app/deploy/dbackup.py
yumoqing dd40df6337 feat(deploy): 测试→生产增量部署机制——dmig迁移引擎+ddiff差异对账+dbackup增量备份+四步规范
- METHODOLOGY.md: 备份/操作/验证/回退四大步操作规范
- dmig.py: 迁移引擎(台账表pipeline_deploy_ledger+幂等+破坏性守卫+down回退)
- ddiff.py: 环境schema/参数/模块版本差异对账
- dbackup.py: mysqldump增量表备份+恢复命令生成
- migrations/m0001-m0004: 首批迁移(今日发现的生产库缺口)
2026-09-01 16:00:35 +08:00

173 lines
6.8 KiB
Python
Executable File
Raw Permalink 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.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dbackup.py — 增量表备份与恢复命令生成(四步流程第 1 步/第 4 步配套)。
用法(在应用根目录执行):
./py3/bin/python deploy/dbackup.py backup --tables sd_projects,pipeline_tasks
./py3/bin/python deploy/dbackup.py backup --batch # 备份待执行迁移批次声明的所有表
./py3/bin/python deploy/dbackup.py verify <备份文件> # 校验备份文件完整性
./py3/bin/python deploy/dbackup.py restore --file <备份文件> --confirm # 生成并执行恢复(危险)
备份位置:<APP_ROOT>/backups/<库名>_<表名>_<YYYYmmdd_HHMMSS>.sql
恢复 = DROP TABLE + 导入,执行前打印 [NEEDS_APPROVAL],必须 --confirm 才真执行。
"""
import json
import os
import subprocess
import sys
from datetime import datetime
APP_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BACKUP_DIR = os.path.join(APP_ROOT, "backups")
MIG_DIR = os.path.join(APP_ROOT, "deploy", "migrations")
def get_db_conf():
sys.path.insert(0, APP_ROOT)
try:
from appPublic.jsonConfig import getConfig
from appPublic.aes import aes_decode_b64
cfg = getConfig(APP_ROOT, {"workdir": APP_ROOT})
kw = cfg.databases["pipeline"].kwargs
pwd = aes_decode_b64(cfg.password_key, kw.password)
return str(kw.host), str(kw.port), str(kw.user), pwd, str(kw.db)
except Exception as e:
print("[FATAL] 读配置失败:", e)
sys.exit(1)
def table_exists(conf, name):
host, port, user, pwd, db = conf
r = subprocess.run(["mysql", "-h", host, "-P", port, "-u", user, "-p" + pwd,
db, "-N", "-e", "SHOW TABLES LIKE '%s'" % name],
capture_output=True, text=True)
return bool((r.stdout or "").strip())
def batch_tables():
"""收集所有待执行迁移批次声明的 backup_tables去重"""
# 读台账判断哪些批次已执行(简化:直接扫所有迁移文件的 backup_tables 并集)
tables = []
if os.path.isdir(MIG_DIR):
for f in sorted(os.listdir(MIG_DIR)):
if not f.endswith(".json"):
continue
try:
d = json.load(open(os.path.join(MIG_DIR, f)))
for t in d.get("backup_tables") or []:
if t not in tables:
tables.append(t)
except Exception:
pass
return tables
def backup(conf, tables):
host, port, user, pwd, db = conf
os.makedirs(BACKUP_DIR, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
done = []
for t in tables:
if not table_exists(conf, t):
print(" 跳过(表不存在): %s" % t)
continue
out_file = os.path.join(BACKUP_DIR, "%s_%s_%s.sql" % (db, t, ts))
r = subprocess.run(
["mysqldump", "-h", host, "-P", port, "-u", user, "-p" + pwd,
db, t, "--single-transaction", "--skip-lock-tables",
"--result-file", out_file],
capture_output=True, text=True)
err = (r.stderr or "").replace(
"mysqldump: [Warning] Using a password on the command line interface can be insecure.\n", "")
if r.returncode != 0 or not os.path.isfile(out_file) or os.path.getsize(out_file) == 0:
print("[FAIL] %s: %s" % (t, err[:200]))
sys.exit(1)
size = os.path.getsize(out_file)
print(" OK %s (%d bytes)" % (out_file, size))
done.append(out_file)
# 生成对应的恢复命令文件
if done:
restore_file = os.path.join(BACKUP_DIR, "RESTORE_%s.sh" % ts)
with open(restore_file, "w") as f:
f.write("#!/bin/bash\n# 恢复命令(危险!先人工核对)\n")
for bf in done:
f.write('mysql -h%s -P%s -u%s -p"$PASS" %s < %s\n' % (host, port, user, db, bf))
os.chmod(restore_file, 0o700)
print(" 恢复命令: %s" % restore_file)
return done
def verify(conf, path):
if not os.path.isfile(path):
print("[FAIL] 文件不存在: %s" % path)
sys.exit(1)
size = os.path.getsize(path)
head = open(path, encoding="utf-8", errors="replace").read(4000)
has_create = "CREATE TABLE" in head
# 尾部完整性标记
tail = subprocess.run(["tail", "-c", "200", path], capture_output=True, text=True).stdout
has_dump_info = "Dump completed" in tail
print("文件: %s (%d bytes)" % (path, size))
print(" 含 CREATE TABLE: %s" % ("" if has_create else "否[异常]"))
print(" 含 Dump completed: %s" % ("" if has_dump_info else "否[可能被截断]"))
if not (has_create and has_dump_info and size > 0):
sys.exit(1)
print(" ✅ 备份文件完整")
def restore(conf, path):
"""恢复 = 从备份文件重建表。打印命令,--confirm 才执行。"""
host, port, user, pwd, db = conf
if not os.path.isfile(path):
print("[FAIL] 文件不存在: %s" % path)
sys.exit(1)
print("[NEEDS_APPROVAL] 恢复操作将 DROP 并重建备份中的表!")
print(" 备份文件: %s" % path)
print(" 目标库: %s@%s" % (db, host))
if "--confirm" not in sys.argv:
print(" (预演模式,未执行。确认后追加 --confirm")
print(" 手工命令: mysql -h%s -P%s -u%s -p<密码> %s < %s" % (host, port, user, db, path))
return
r = subprocess.run(["mysql", "-h", host, "-P", port, "-u", user, "-p" + pwd, db],
stdin=open(path), capture_output=True, text=True)
err = (r.stderr or "").replace(
"mysql: [Warning] Using a password on the command line interface can be insecure.\n", "")
if r.returncode != 0:
print("[FAIL] 恢复失败: %s" % err[:300])
sys.exit(1)
print(" ✅ 恢复完成")
def main():
if len(sys.argv) < 2 or sys.argv[1] not in ("backup", "verify", "restore"):
print(__doc__)
sys.exit(1)
conf = get_db_conf()
cmd = sys.argv[1]
if cmd == "backup":
tables = []
if "--tables" in sys.argv:
tables = [t.strip() for t in sys.argv[sys.argv.index("--tables") + 1].split(",") if t.strip()]
elif "--batch" in sys.argv:
tables = batch_tables()
if not tables:
print("无表可备份(--tables a,b,c 或 --batch")
sys.exit(1)
print("备份 %d 张表: %s" % (len(tables), ",".join(tables)))
backup(conf, tables)
elif cmd == "verify":
if len(sys.argv) < 3:
print("verify 需要备份文件路径")
sys.exit(1)
verify(conf, sys.argv[2])
elif cmd == "restore":
if "--file" not in sys.argv:
print("restore 需要 --file <备份文件>")
sys.exit(1)
restore(conf, sys.argv[sys.argv.index("--file") + 1])
if __name__ == "__main__":
main()