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

317 lines
12 KiB
Python
Executable File
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.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dmig.py — 数据库增量迁移引擎(测试→生产增量部署规范配套工具)。
用法(在应用根目录执行,用应用 venv
./py3/bin/python deploy/dmig.py status # 台账 + 待执行批次
./py3/bin/python deploy/dmig.py plan [batch ...] # 预演:打印将执行的语句(不落库)
./py3/bin/python deploy/dmig.py apply [batch ...] # 执行(默认全部待执行;先自动备份涉及表)
./py3/bin/python deploy/dmig.py rollback <batch> # 回退:执行该批次 down + 台账置 rolled_back
迁移文件deploy/migrations/mNNNN_名称.json字典序 = 执行序),格式见 METHODOLOGY.md。
设计原则:
- 幂等:每步执行前查存在性(表/列/索引/参数),已有则跳过;重跑安全。
- 台账pipeline_deploy_ledger 记录每步 applied/skipped/rolled_back。
- 破坏性守卫up 里的 DROP TABLE / TRUNCATE / DELETE 打印 [NEEDS_APPROVAL] 并中止,
需人工执行(--force 也无效,这是有意设计)。
"""
import json
import os
import re
import subprocess
import sys
from datetime import datetime
APP_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MIG_DIR = os.path.join(APP_ROOT, "deploy", "migrations")
LEDGER_TABLE = "pipeline_deploy_ledger"
DESTRUCTIVE = re.compile(r"\b(DROP\s+TABLE|TRUNCATE|DELETE\s+FROM)\b", re.I)
LEDGER_DDL = """CREATE TABLE IF NOT EXISTS %s (
id VARCHAR(32) NOT NULL,
batch VARCHAR(16) NOT NULL,
step INT NOT NULL,
status VARCHAR(16) NOT NULL,
backup_file VARCHAR(255),
detail VARCHAR(4000),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_ledger_batch_step (batch, step)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci""" % LEDGER_TABLE
def die(msg):
print("[FATAL]", msg)
sys.exit(1)
def get_db_conf():
"""从应用配置读 pipeline 库连接(复用应用 venv 的 appPublic 解密)。"""
sys.path.insert(0, APP_ROOT)
conf_path = os.path.join(APP_ROOT, "conf", "config.json")
if not os.path.isfile(conf_path):
die("找不到 %s(必须在应用根目录的 venv 里跑)" % conf_path)
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:
die("读配置失败: %s" % e)
def mysql(conf, sql, database=None):
host, port, user, pwd, db = conf
cmd = ["mysql", "-h", host, "-P", port, "-u", user, "-p" + pwd,
database or db, "-N", "-e", sql]
r = subprocess.run(cmd, 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:
return None, err.strip()[:300]
return r.stdout, None
def table_exists(conf, name):
out, _ = mysql(conf, "SHOW TABLES LIKE '%s'" % name)
return bool((out or "").strip())
def column_exists(conf, table, col):
_, _, _, _, db = conf
out, _ = mysql(conf, "SELECT 1 FROM information_schema.columns WHERE table_schema='%s' "
"AND table_name='%s' AND column_name='%s'" % (db, table, col))
return bool((out or "").strip())
def index_exists(conf, table, idx):
_, _, _, _, db = conf
out, _ = mysql(conf, "SELECT 1 FROM information_schema.statistics WHERE table_schema='%s' "
"AND table_name='%s' AND index_name='%s'" % (db, table, idx))
return bool((out or "").strip())
def ensure_ledger(conf):
out, err = mysql(conf, LEDGER_DDL)
if err:
die("建台账表失败: %s" % err)
def ledger_state(conf):
out, err = mysql(conf, "SELECT batch, MAX(step) FROM %s WHERE status='applied' "
"GROUP BY batch" % LEDGER_TABLE)
applied = {}
for ln in (out or "").splitlines():
p = ln.split("\t")
if len(p) == 2:
applied[p[0]] = int(p[1])
return applied
def load_migrations():
migs = {}
if not os.path.isdir(MIG_DIR):
return migs
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)))
d["_file"] = f
migs[d["id"]] = d
except Exception as e:
die("迁移文件解析失败 %s: %s" % (f, e))
return migs
def render_step(conf, step, direction):
"""把一个 op 渲染成 (sql 列表, skip_reason|None)。幂等判断在这里。"""
op = step.get("op", "")
if op == "sql":
sql = step["sql"].strip().rstrip(";")
m = re.match(r"CREATE\s+TABLE\s+(IF\s+NOT\s+EXISTS\s+)?`?(\w+)`?", sql, re.I)
if m:
if table_exists(conf, m.group(2)):
return [], "表已存在: %s" % m.group(2)
m = re.match(r"ALTER\s+TABLE\s+`?(\w+)`?\s+ADD\s+(COLUMN\s+)?`?(\w+)`?", sql, re.I)
if m:
if column_exists(conf, m.group(1), m.group(3)):
return [], "列已存在: %s.%s" % (m.group(1), m.group(3))
m = re.match(r"CREATE\s+(UNIQUE\s+)?INDEX\s+`?(\w+)`?\s+ON\s+`?(\w+)`?", sql, re.I)
if m:
if index_exists(conf, m.group(3), m.group(2)):
return [], "索引已存在: %s.%s" % (m.group(3), m.group(2))
return [sql], None
if op == "create_table":
name = step["name"]
if table_exists(conf, name):
return [], "表已存在: %s" % name
return [step["sql"].strip().rstrip(";")], None
if op == "params_set":
out, _ = mysql(conf, "SELECT id FROM params WHERE params_name='%s'" % step["key"])
val = str(step["value"]).replace("'", "''")
if (out or "").strip():
return ["UPDATE params SET params_value='%s' WHERE params_name='%s'"
% (val, step["key"])], None
import uuid
return ["INSERT INTO params (id, params_name, params_value) "
"VALUES ('%s', '%s', '%s')" % (uuid.uuid4().hex, step["key"], val)], None
if op == "params_del":
return ["DELETE FROM params WHERE params_name='%s'" % step["key"]], None
die("未知 op: %s" % op)
def backup_tables(conf, tables):
if not tables:
return ""
r = subprocess.run(
[sys.executable, os.path.join(APP_ROOT, "deploy", "dbackup.py"),
"backup", "--tables", ",".join(tables)],
capture_output=True, text=True, cwd=APP_ROOT)
if r.returncode != 0:
die("备份失败(中止迁移): %s" % (r.stderr or r.stdout)[-300:])
return (r.stdout or "").strip().splitlines()[-1] if r.stdout.strip() else ""
def cmd_status(conf):
ensure_ledger(conf)
applied = ledger_state(conf)
migs = load_migrations()
print("=== 迁移台账 ===")
for mid in sorted(migs):
m = migs[mid]
st = "applied(步骤%d)" % applied[mid] if mid in applied else "待执行"
print(" %s %-10s %s" % (mid, st, m.get("title", "")[:50]))
print("\n待执行批次数:", len([m for m in migs if m not in applied]))
def cmd_plan(conf, batches):
ensure_ledger(conf)
applied = ledger_state(conf)
migs = load_migrations()
targets = [b for b in (batches or sorted(migs)) if b not in applied]
if not targets:
print("无待执行批次")
return
for mid in targets:
m = migs[mid]
print("\n── %s %s" % (mid, m.get("title", "")))
if m.get("backup_tables"):
print(" [自动备份表]: %s" % ",".join(m["backup_tables"]))
for i, step in enumerate(m.get("up", []), 1):
sqls, skip = render_step(conf, step, "up")
if skip:
print(" 步骤%d 跳过(%s)" % (i, skip))
else:
for s in sqls:
mark = "[NEEDS_APPROVAL] " if DESTRUCTIVE.search(s) else ""
print(" 步骤%d %s%s" % (i, mark, s[:150]))
def cmd_apply(conf, batches):
ensure_ledger(conf)
applied = ledger_state(conf)
migs = load_migrations()
targets = [b for b in (batches or sorted(migs)) if b not in applied]
if not targets:
print("无待执行批次")
return
_, _, _, _, db = conf
for mid in targets:
m = migs[mid]
print("\n══ 应用 %s: %s" % (mid, m.get("title", "")))
# 破坏性守卫(先扫全部语句再动手)
for step in m.get("up", []):
sqls, skip = render_step(conf, step, "up")
if skip:
continue
for s in sqls:
if DESTRUCTIVE.search(s):
die("up 含破坏性语句,拒绝自动执行(人工复核后手工跑):\n %s" % s[:200])
# 备份
bk = backup_tables(conf, m.get("backup_tables") or [])
# 逐步执行 + 记账
for i, step in enumerate(m.get("up", []), 1):
sqls, skip = render_step(conf, step, "up")
if skip:
mysql(conf, "INSERT INTO %s (id, batch, step, status, detail) "
"VALUES ('%s', '%s', %d, 'skipped', '%s') "
"ON DUPLICATE KEY UPDATE status='skipped'"
% (LEDGER_TABLE, _new_id(), mid, i, skip[:200]))
print(" 步骤%d 跳过(%s)" % (i, skip))
continue
for s in sqls:
out, err = mysql(conf, s)
if err:
mysql(conf, "INSERT INTO %s (id, batch, step, status, detail) "
"VALUES ('%s', '%s', %d, 'failed', '%s')"
% (LEDGER_TABLE, _new_id(), mid, i, err[:200]))
die("步骤%d 失败: %s\n(已完成步骤可用 `dmig.py rollback %s` 回退)"
% (i, err, mid))
print(" 步骤%d OK: %s" % (i, s[:90]))
mysql(conf, "INSERT INTO %s (id, batch, step, status, backup_file, detail) "
"VALUES ('%s', '%s', %d, 'applied', '%s', '%s')"
% (LEDGER_TABLE, _new_id(), mid, i, bk[:200], str(step)[:200]))
print("%s 完成" % mid)
print("\n全部完成。验证: ./py3/bin/python deploy/dmig.py status && ddiff.py --checklist")
def cmd_rollback(conf, batch):
ensure_ledger(conf)
applied = ledger_state(conf)
migs = load_migrations()
if batch not in migs:
die("迁移 %s 不存在" % batch)
if batch not in applied:
print("台账中 %s 未执行过,无需回退" % batch)
return
m = migs[batch]
downs = m.get("down", [])
if not downs:
die("%s 无 down 定义(不可自动回退,用 dbackup.py restore 恢复备份)" % batch)
print("══ 回退 %sdown %d 步,倒序)" % (batch, len(downs)))
for i, step in enumerate(reversed(downs), 1):
sqls, skip = render_step(conf, step, "down")
if skip:
print(" 回退%d 跳过(%s)" % (i, skip))
continue
for s in sqls:
out, err = mysql(conf, s)
if err:
die("回退%d 失败: %s(剩余步骤请人工处理)" % (i, err))
print(" 回退%d OK: %s" % (i, s[:90]))
mysql(conf, "UPDATE %s SET status='rolled_back' WHERE batch='%s' AND status='applied'"
% (LEDGER_TABLE, batch))
print("%s 已回退(台账已标记)" % batch)
def _new_id():
import uuid
return uuid.uuid4().hex
def main():
if len(sys.argv) < 2 or sys.argv[1] not in ("status", "plan", "apply", "rollback"):
print(__doc__)
sys.exit(1)
conf = get_db_conf()
cmd = sys.argv[1]
args = sys.argv[2:]
if cmd == "status":
cmd_status(conf)
elif cmd == "plan":
cmd_plan(conf, args)
elif cmd == "apply":
cmd_apply(conf, args)
elif cmd == "rollback":
if not args:
die("rollback 需要批次号: dmig.py rollback m0001")
cmd_rollback(conf, args[0])
if __name__ == "__main__":
main()