项目代码提交
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user