Files

157 lines
7.1 KiB
Python
Raw Permalink Normal View History

2026-09-21 17:30:02 +08:00
"""Export a read-only MySQL snapshot and a row-count/hash manifest.
Pause application writes before exporting. Connection settings come from
database.engine.url (DB_* or DATABASE_URL in the process environment).
This script never initializes, resets, or deletes database tables.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
ROOT = Path(__file__).resolve().parents[1]
MYSQLDUMP_FALLBACK = Path(r"C:\tools\mysql\mysql-8.0.27-winx64\bin\mysqldump.exe")
class MigrationError(Exception):
pass
def option_value(value: object) -> str:
"""Quote MySQL option-file values without exposing them in argv."""
value = str(value)
if "\x00" in value:
raise MigrationError("数据库连接参数不能包含 NUL 字符。")
return '"' + (value.replace("\\", "\\\\").replace('"', '\\"')
.replace("\n", "\\n").replace("\r", "\\r")
.replace("\t", "\\t")) + '"'
def source_counts(engine) -> dict[str, int]:
from sqlalchemy import inspect, text
database = engine.url.database
with engine.connect() as connection:
inspector = inspect(connection)
names = sorted(inspector.get_table_names(schema=database)
+ inspector.get_view_names(schema=database))
storage = connection.execute(text(
"SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = :database AND TABLE_TYPE = 'BASE TABLE'"
), {"database": database}).all()
if any(str(row[1]).upper() != "INNODB" for row in storage):
raise MigrationError("源库存在非 InnoDB 表,不能保证事务一致性;请先检查存储引擎。")
quote = engine.dialect.identifier_preparer.quote_identifier
return {name: int(connection.execute(text(
f"SELECT COUNT(*) FROM {quote(database)}.{quote(name)}"
)).scalar_one()) for name in names}
def export_database(args) -> tuple[Path, Path, dict[str, int]]:
executable = args.mysqldump or shutil.which("mysqldump")
if not executable and MYSQLDUMP_FALLBACK.is_file():
executable = str(MYSQLDUMP_FALLBACK)
if not executable or not Path(executable).is_file():
raise MigrationError("找不到 mysqldump,请使用 --mysqldump 指定其完整路径。")
sys.path.insert(0, str(ROOT))
from database import engine
url = engine.url
if url.get_backend_name() != "mysql" or not url.database or not url.username:
raise MigrationError("database.engine.url 必须是包含数据库名和用户名的 MySQL 连接。")
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
output = (args.output or ROOT / "artifacts" / "migration" / f"wolin-{stamp}.sql").resolve()
manifest_path = output.with_suffix(".manifest.json")
if output.exists() or manifest_path.exists():
raise MigrationError("导出文件或 manifest 已存在;请指定新的 --output,程序不会覆盖它们。")
output.parent.mkdir(parents=True, exist_ok=True)
try:
before = source_counts(engine)
if not before:
raise MigrationError("源数据库没有表,已取消导出以避免误迁移空库。")
with tempfile.TemporaryDirectory(prefix="wolin-mysql-export-") as temp_dir:
config_path = Path(temp_dir) / "client.cnf"
config = "[client]\n" + "\n".join(
f"{key}={option_value(value)}" for key, value in {
"host": url.host or "localhost", "port": url.port or 3306,
"user": url.username, "password": url.password or "",
"protocol": "TCP", "default-character-set": "utf8mb4",
}.items()
) + "\n"
descriptor = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as config_file:
config_file.write(config)
command = [str(executable), f"--defaults-file={config_path}",
"--single-transaction", "--no-tablespaces", "--set-gtid-purged=OFF",
"--column-statistics=0", "--skip-add-drop-table", "--skip-add-locks",
"--hex-blob", "--default-character-set=utf8mb4", "--", url.database]
# An incomplete SQL file deliberately has no manifest and cannot be restored.
with output.open("xb") as sql_file:
result = subprocess.run(command, stdout=sql_file, stderr=subprocess.PIPE,
check=False)
sql_file.flush()
os.fsync(sql_file.fileno())
if result.returncode:
raise MigrationError(
f"mysqldump 失败(退出码 {result.returncode});请检查连接和导出权限。"
f"未生成 manifest,部分文件不能用于迁移:{output}"
)
after = source_counts(engine)
if before != after:
raise MigrationError(
"导出期间表结构或行数发生变化。请暂停应用写入后重新导出;本次不生成 manifest。"
)
digest = hashlib.sha256()
with output.open("rb") as sql_file:
for block in iter(lambda: sql_file.read(1024 * 1024), b""):
digest.update(block)
manifest = {
"format_version": 1,
"created_at_utc": datetime.now(timezone.utc).isoformat(),
"database": url.database,
"sql_file": output.name,
"sha256": digest.hexdigest(),
"tables": before,
"consistency": "single-transaction; row counts checked before and after dump",
}
with manifest_path.open("x", encoding="utf-8", newline="\n") as manifest_file:
json.dump(manifest, manifest_file, ensure_ascii=False, indent=2)
manifest_file.write("\n")
return output, manifest_path, before
finally:
engine.dispose()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, help="新的 SQL 文件路径;默认 artifacts/migration/wolin-时间戳.sql")
parser.add_argument("--mysqldump", help="mysqldump 可执行文件的完整路径")
args = parser.parse_args()
try:
output, manifest, counts = export_database(args)
except MigrationError as error:
print(f"导出失败:{error}", file=sys.stderr)
return 1
except Exception as error:
# Driver/subprocess exception text may contain connection or record data.
print(f"导出失败({type(error).__name__}):请检查数据库连接、权限及目标目录。"
"未完成的 SQL 文件没有有效 manifest,不能用于导入。", file=sys.stderr)
return 1
print(f"SQL:{output}\nManifest:{manifest}")
print(f"已导出 {len(counts)} 个表/视图,共 {sum(counts.values())} 行;未修改源数据库。")
return 0
if __name__ == "__main__":
raise SystemExit(main())