rebuild(stage-14): deliver migration and recovery tooling

This commit is contained in:
leefer
2026-07-30 08:29:37 +08:00
parent 9eb7b548f3
commit fa7a8dde06
17 changed files with 1397 additions and 3 deletions
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import argparse
import json
import shutil
import sqlite3
import tempfile
import zipfile
from collections.abc import Sequence
from contextlib import closing
from pathlib import Path
from tools.backup import sha256
def _safe_members(archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
members = archive.infolist()
for member in members:
path = Path(member.filename)
if path.is_absolute() or ".." in path.parts:
raise ValueError("backup contains an unsafe path")
return members
def restore_backup(
archive_path: Path,
database: Path,
*,
private_skills: Path | None = None,
environment_file: Path | None = None,
encryption_key_file: Path | None = None,
overwrite: bool = False,
) -> dict:
archive_path = archive_path.resolve()
database = database.resolve()
destinations = [database]
destinations.extend(path.resolve() for path in (environment_file, encryption_key_file) if path)
if private_skills:
destinations.append(private_skills.resolve())
if not overwrite and any(path.exists() for path in destinations):
raise FileExistsError(
"restore destination exists; explicit overwrite confirmation is required"
)
with tempfile.TemporaryDirectory(prefix="xiaobai-restore-") as raw:
staging = Path(raw)
with zipfile.ZipFile(archive_path) as archive:
_safe_members(archive)
archive.extractall(staging)
manifest = json.loads((staging / "manifest.json").read_text(encoding="utf-8"))
if manifest.get("format") != 1 or not isinstance(manifest.get("files"), dict):
raise ValueError("backup manifest is invalid")
for name, metadata in manifest["files"].items():
path = staging / name
if not path.is_file() or sha256(path) != metadata.get("sha256"):
raise ValueError(f"backup checksum mismatch: {name}")
restored_db = staging / "database.sqlite3"
with closing(
sqlite3.connect(f"file:{restored_db.as_posix()}?mode=ro", uri=True)
) as connection:
if connection.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise ValueError("restored database failed integrity check")
database.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(restored_db, database)
for destination, name in (
(environment_file, "secrets/environment"),
(encryption_key_file, "secrets/app-encryption.key"),
):
source = staging / name
if destination and source.is_file():
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
skills_source = staging / "private-mentor-skills"
if private_skills and skills_source.is_dir():
private_skills.mkdir(parents=True, exist_ok=True)
shutil.copytree(skills_source, private_skills, dirs_exist_ok=True)
return manifest
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Validate and restore a Xiaobai backup")
parser.add_argument("--archive", type=Path, required=True)
parser.add_argument("--database", type=Path, required=True)
parser.add_argument("--private-skills", type=Path)
parser.add_argument("--environment-file", type=Path)
parser.add_argument("--encryption-key-file", type=Path)
parser.add_argument("--confirm-restore", action="store_true")
parser.add_argument("--confirm-overwrite", action="store_true")
return parser
def main(arguments: Sequence[str] | None = None) -> int:
parsed = build_parser().parse_args(arguments)
if not parsed.confirm_restore:
raise SystemExit("restore requires --confirm-restore")
manifest = restore_backup(
parsed.archive,
parsed.database,
private_skills=parsed.private_skills,
environment_file=parsed.environment_file,
encryption_key_file=parsed.encryption_key_file,
overwrite=parsed.confirm_overwrite,
)
print(
json.dumps(
{"restored": str(parsed.database.resolve()), "files": len(manifest["files"])},
ensure_ascii=False,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())