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