157 lines
7.1 KiB
Python
157 lines
7.1 KiB
Python
"""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())
|