155 lines
7.4 KiB
Python
155 lines
7.4 KiB
Python
"""Restore a verified export only into an empty compose.cloud.yaml db service.
|
|||
|
|
|
||
|
|
The SQL's adjacent .manifest.json is mandatory. --env-file must explicitly
|
||
|
|
declare COMPOSE_PROJECT_NAME. Existing tables/views are never overwritten;
|
||
|
|
failed imports are left intact for inspection, without automatic DROP/reset.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import re
|
||
|
|
import shutil
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
TARGET_DATABASE = "student_management_system"
|
||
|
|
MYSQL_COMMAND = (
|
||
|
|
'[ "$MYSQL_DATABASE" = "student_management_system" ] && '
|
||
|
|
'[ "$MYSQL_USER" = "wolin" ] || exit 64; '
|
||
|
|
'MYSQL_PWD="$MYSQL_PASSWORD" exec mysql --protocol=TCP --host=127.0.0.1 '
|
||
|
|
'--user="$MYSQL_USER" --database="$MYSQL_DATABASE" '
|
||
|
|
'--default-character-set=utf8mb4 --batch --raw --skip-column-names --binary-mode'
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class MigrationError(Exception):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def compose_command(env_file: Path) -> tuple[list[str], dict[str, str]]:
|
||
|
|
docker = shutil.which("docker")
|
||
|
|
if not docker:
|
||
|
|
raise MigrationError("找不到 docker 命令。")
|
||
|
|
if not env_file.is_file():
|
||
|
|
raise MigrationError("--env-file 指定的文件不存在。")
|
||
|
|
entries = {}
|
||
|
|
for line in env_file.read_text(encoding="utf-8-sig").splitlines():
|
||
|
|
match = re.match(r"\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$", line)
|
||
|
|
if match:
|
||
|
|
entries[match[1]] = match[2].strip()
|
||
|
|
project = entries.get("COMPOSE_PROJECT_NAME", "")
|
||
|
|
if project.startswith(('"', "'")) and project[-1:] == project[:1]:
|
||
|
|
project = project[1:-1]
|
||
|
|
else:
|
||
|
|
project = project.split(" #", 1)[0].strip()
|
||
|
|
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", project):
|
||
|
|
raise MigrationError("env 文件必须明确设置合法的 COMPOSE_PROJECT_NAME,禁止使用空值或变量插值。")
|
||
|
|
# --env-file normally loses to shell variables. Make this chosen file
|
||
|
|
# authoritative so an inherited project/password cannot select another stack.
|
||
|
|
environment = os.environ.copy()
|
||
|
|
for name in entries:
|
||
|
|
environment.pop(name, None)
|
||
|
|
environment["COMPOSE_PROJECT_NAME"] = project
|
||
|
|
command = [docker, "compose", "--env-file", str(env_file),
|
||
|
|
"-f", str(ROOT / "compose.cloud.yaml"), "exec", "-T", "db",
|
||
|
|
"sh", "-c", MYSQL_COMMAND]
|
||
|
|
return command, environment
|
||
|
|
|
||
|
|
|
||
|
|
def run_mysql(command: list[str], environment: dict[str, str], *, sql: str | None = None,
|
||
|
|
source=None, importing: bool = False) -> str:
|
||
|
|
arguments = {"input": sql.encode("utf-8")} if sql is not None else {"stdin": source}
|
||
|
|
result = subprocess.run(command, env=environment, cwd=ROOT, stdout=subprocess.PIPE,
|
||
|
|
stderr=subprocess.PIPE, check=False, **arguments)
|
||
|
|
if result.returncode:
|
||
|
|
detail = ("导入可能已部分完成,未执行任何清理或覆盖;请保留目标库并检查。"
|
||
|
|
if importing else "请检查指定 Compose 项目、db 健康状态与账号权限。")
|
||
|
|
raise MigrationError(f"目标数据库操作失败(退出码 {result.returncode})。{detail}")
|
||
|
|
return result.stdout.decode("utf-8").strip()
|
||
|
|
|
||
|
|
|
||
|
|
def load_manifest(sql_path: Path) -> dict:
|
||
|
|
manifest_path = sql_path.with_suffix(".manifest.json")
|
||
|
|
if not sql_path.is_file() or not manifest_path.is_file():
|
||
|
|
raise MigrationError("SQL 文件及同名 .manifest.json 必须同时存在。")
|
||
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||
|
|
if not isinstance(manifest, dict) or manifest.get("format_version") != 1:
|
||
|
|
raise MigrationError("不支持的 manifest 格式。")
|
||
|
|
if manifest.get("sql_file") != sql_path.name:
|
||
|
|
raise MigrationError("manifest 中的 SQL 文件名不匹配。")
|
||
|
|
if not re.fullmatch(r"[0-9a-f]{64}", str(manifest.get("sha256", ""))):
|
||
|
|
raise MigrationError("manifest 的 SHA-256 无效。")
|
||
|
|
tables = manifest.get("tables")
|
||
|
|
if (not isinstance(tables, dict) or not tables
|
||
|
|
or any(not isinstance(name, str) or not name or "\x00" in name
|
||
|
|
or "\n" in name or "\r" in name
|
||
|
|
or type(count) is not int or count < 0 for name, count in tables.items())):
|
||
|
|
raise MigrationError("manifest 表清单或行数无效。")
|
||
|
|
return manifest
|
||
|
|
|
||
|
|
|
||
|
|
def restore(sql_path: Path, env_file: Path) -> tuple[int, int]:
|
||
|
|
manifest = load_manifest(sql_path)
|
||
|
|
with sql_path.open("rb") as sql_file:
|
||
|
|
digest = hashlib.sha256()
|
||
|
|
for block in iter(lambda: sql_file.read(1024 * 1024), b""):
|
||
|
|
digest.update(block)
|
||
|
|
if digest.hexdigest() != manifest["sha256"]:
|
||
|
|
raise MigrationError("SQL 文件的 SHA-256 与 manifest 不一致,已拒绝导入。")
|
||
|
|
command, environment = compose_command(env_file)
|
||
|
|
table_query = ("SELECT TABLE_NAME FROM information_schema.TABLES "
|
||
|
|
"WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME;")
|
||
|
|
existing = run_mysql(command, environment, sql=table_query)
|
||
|
|
if existing:
|
||
|
|
raise MigrationError("目标数据库已有表或视图,已拒绝导入;不会覆盖、删除或重置现有数据。")
|
||
|
|
print(f"已通过 SHA-256 与空库检查,正在导入 {TARGET_DATABASE}。")
|
||
|
|
sql_file.seek(0)
|
||
|
|
run_mysql(command, environment, source=sql_file, importing=True)
|
||
|
|
|
||
|
|
actual_tables = run_mysql(command, environment, sql=table_query).splitlines()
|
||
|
|
expected = manifest["tables"]
|
||
|
|
names = sorted(expected)
|
||
|
|
if sorted(actual_tables) != names:
|
||
|
|
raise MigrationError("导入后的表/视图清单与 manifest 不一致;保留目标数据,请检查后再使用。")
|
||
|
|
quote = lambda name: "`" + name.replace("`", "``") + "`"
|
||
|
|
count_query = " UNION ALL ".join(
|
||
|
|
f"SELECT {index}, COUNT(*) FROM {quote(name)}" for index, name in enumerate(names)
|
||
|
|
) + ";"
|
||
|
|
counts = run_mysql(command, environment, sql=count_query).splitlines()
|
||
|
|
try:
|
||
|
|
actual_counts = {int(parts[0]): int(parts[1])
|
||
|
|
for parts in (line.split("\t") for line in counts)}
|
||
|
|
except (ValueError, IndexError):
|
||
|
|
raise MigrationError("无法解析导入后的行数;保留目标数据,请检查。") from None
|
||
|
|
if (set(actual_counts) != set(range(len(names)))
|
||
|
|
or any(actual_counts[index] != expected[name] for index, name in enumerate(names))):
|
||
|
|
raise MigrationError("导入后的逐表行数与 manifest 不一致;保留目标数据,请检查后再使用。")
|
||
|
|
return len(names), sum(expected.values())
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument("sql", type=Path, help="export_database.py 生成的 SQL 文件")
|
||
|
|
parser.add_argument("--env-file", required=True, type=Path, help="目标 Compose 项目的云端/预览环境文件")
|
||
|
|
args = parser.parse_args()
|
||
|
|
try:
|
||
|
|
tables, rows = restore(args.sql.resolve(), args.env_file.resolve())
|
||
|
|
except MigrationError as error:
|
||
|
|
print(f"迁移失败:{error}", file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
except Exception as error:
|
||
|
|
print(f"迁移失败({type(error).__name__}):请检查文件、Docker 和数据库状态。"
|
||
|
|
"不会自动删除或重置任何数据库。", file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
print(f"迁移完成:{tables} 个表/视图、{rows} 行,逐表行数与 SHA-256 校验通过。")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|