Spaces:
Running
Running
Download deploy/backup_restore.py from Leon4gr45/brain: direct link, hf CLI and curl.
- Browser
- Download file 18.5 kB
-
https://huggingface.co/spaces/Leon4gr45/brain/resolve/main/deploy/backup_restore.py
- Command line
-
hf download hf://spaces/Leon4gr45/brain/deploy/backup_restore.py
-
curl -L -o backup_restore.py https://huggingface.co/spaces/Leon4gr45/brain/resolve/main/deploy/backup_restore.py
18.5 kB
| #!/usr/bin/env python3 | |
| """Offline-consistent BrainAPI profile backup, verification, and restore.""" | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import json | |
| import os | |
| import shlex | |
| import stat | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import BinaryIO | |
| ROOT = Path(__file__).resolve().parents[1] | |
| DEPLOY = ROOT / "deploy" | |
| ARCHIVER_IMAGE = "alpine:3.21.3" | |
| APP_SERVICES = ("nginx", "brainapi", "brainapi-mcp", "brainapi-worker") | |
| PROFILE_VOLUMES = { | |
| "light": ("redis-data", "brainapi-plugins"), | |
| "heavy": ( | |
| "redis-data", | |
| "milvus-data", | |
| "etcd-data", | |
| "minio-data", | |
| "brainapi-plugins", | |
| ), | |
| } | |
| PROFILE_ALL_VOLUMES = { | |
| "light": ("redis-data", "postgres-data", "brainapi-plugins"), | |
| "heavy": ( | |
| "redis-data", | |
| "neo4j-data", | |
| "neo4j-logs", | |
| "neo4j-plugins", | |
| "etcd-data", | |
| "minio-data", | |
| "milvus-data", | |
| "mongo-data", | |
| "mongo-config", | |
| "brainapi-plugins", | |
| ), | |
| } | |
| RESTORE_ORDER = { | |
| "light": ("redis-data", "postgres", "brainapi-plugins", "stack"), | |
| "heavy": ( | |
| "redis-data", | |
| "etcd-data", | |
| "minio-data", | |
| "milvus-data", | |
| "neo4j", | |
| "mongo", | |
| "brainapi-plugins", | |
| "stack", | |
| ), | |
| } | |
| def _load_env(path: Path) -> dict[str, str]: | |
| values = dict(os.environ) | |
| for raw in path.read_text(encoding="utf-8").splitlines(): | |
| line = raw.strip() | |
| if not line or line.startswith("#") or "=" not in line: | |
| continue | |
| key, value = line.split("=", 1) | |
| values.setdefault(key.strip(), value.strip().strip("'\"")) | |
| return values | |
| class Stack: | |
| def __init__( | |
| self, | |
| profile: str, | |
| env_file: Path, | |
| project: str, | |
| dry_run: bool, | |
| compose_files: list[Path] | None = None, | |
| ): | |
| self.profile = profile | |
| self.env_file = env_file.resolve() | |
| self.project = project | |
| self.dry_run = dry_run | |
| self.compose_files = [DEPLOY / f"docker-compose.{profile}.yaml"] | |
| self.compose_files.extend(path.resolve() for path in (compose_files or [])) | |
| self.env = _load_env(self.env_file) | |
| self.env["BRAINAPI_ENV_FILE"] = str(self.env_file) | |
| self.base = [ | |
| "docker", | |
| "compose", | |
| "--project-name", | |
| project, | |
| "--env-file", | |
| str(self.env_file), | |
| ] | |
| for compose_file in self.compose_files: | |
| self.base.extend(["-f", str(compose_file)]) | |
| def run( | |
| self, | |
| args: list[str], | |
| *, | |
| stdout: BinaryIO | int | None = None, | |
| stdin: BinaryIO | int | None = None, | |
| capture: bool = False, | |
| check: bool = True, | |
| ) -> subprocess.CompletedProcess: | |
| command = [*self.base, *args] | |
| if self.dry_run: | |
| print("+", shlex.join(command)) | |
| return subprocess.CompletedProcess(command, 0, stdout="" if capture else None) | |
| return subprocess.run( | |
| command, | |
| env=self.env, | |
| stdin=stdin, | |
| stdout=subprocess.PIPE if capture else stdout, | |
| stderr=subprocess.PIPE if capture else None, | |
| text=capture, | |
| check=check, | |
| ) | |
| def docker( | |
| self, | |
| args: list[str], | |
| *, | |
| stdout: BinaryIO | int | None = None, | |
| stdin: BinaryIO | int | None = None, | |
| capture: bool = False, | |
| check: bool = True, | |
| ) -> subprocess.CompletedProcess: | |
| command = ["docker", *args] | |
| if self.dry_run: | |
| print("+", shlex.join(command)) | |
| return subprocess.CompletedProcess(command, 0, stdout="" if capture else None) | |
| return subprocess.run( | |
| command, | |
| env=self.env, | |
| stdin=stdin, | |
| stdout=subprocess.PIPE if capture else stdout, | |
| stderr=subprocess.PIPE if capture else None, | |
| text=capture, | |
| check=check, | |
| ) | |
| def volume_name(self, key: str) -> str: | |
| return f"{self.project}_{key}" | |
| def _sha256(path: Path) -> str: | |
| digest = hashlib.sha256() | |
| with path.open("rb") as stream: | |
| for chunk in iter(lambda: stream.read(1024 * 1024), b""): | |
| digest.update(chunk) | |
| return digest.hexdigest() | |
| def _write_stream(stack: Stack, args: list[str], destination: Path) -> None: | |
| if stack.dry_run: | |
| stack.run(args) | |
| destination.touch() | |
| return | |
| with destination.open("wb") as output: | |
| stack.run(args, stdout=output) | |
| def _dump_postgres_brain_databases(stack: Stack, destination: Path) -> None: | |
| script = r''' | |
| set -eu | |
| tmp="$(mktemp -d)" | |
| trap 'rm -rf "$tmp"' EXIT | |
| psql -U "$POSTGRES_USER" -d postgres -Atc \ | |
| "SELECT datname FROM pg_database WHERE datistemplate = false AND left(datname, 6) = 'brain_' ORDER BY datname" | | |
| while IFS= read -r database; do | |
| test -n "$database" || continue | |
| pg_dump -U "$POSTGRES_USER" -d "$database" --format=custom --file="$tmp/$database.dump" | |
| done | |
| tar -C "$tmp" -czf - . | |
| '''.strip() | |
| _write_stream( | |
| stack, | |
| ["exec", "-T", "postgres", "sh", "-c", script], | |
| destination, | |
| ) | |
| def _restore_postgres_brain_databases( | |
| stack: Stack, archive: Path, pg_user: str | |
| ) -> None: | |
| with tempfile.TemporaryDirectory(prefix="brainapi-postgres-restore-") as tmp: | |
| tmp_path = Path(tmp) | |
| if not stack.dry_run: | |
| subprocess.run( | |
| ["tar", "-C", str(tmp_path), "-xzf", str(archive)], check=True | |
| ) | |
| dumps = sorted(tmp_path.glob("brain_*.dump")) | |
| if stack.dry_run: | |
| dumps = [tmp_path / "brain_example.dump"] | |
| for dump in dumps: | |
| database = dump.stem | |
| if not database.replace("_", "").isalnum() or not database.startswith( | |
| "brain_" | |
| ): | |
| raise RuntimeError(f"Invalid PostgreSQL brain dump name: {dump.name}") | |
| stack.run( | |
| ["exec", "-T", "postgres", "createdb", "-U", pg_user, database], | |
| check=False, | |
| ) | |
| if stack.dry_run: | |
| stack.run( | |
| [ | |
| "exec", | |
| "-T", | |
| "postgres", | |
| "pg_restore", | |
| "-U", | |
| pg_user, | |
| "-d", | |
| database, | |
| "--clean", | |
| "--if-exists", | |
| ] | |
| ) | |
| else: | |
| with dump.open("rb") as source: | |
| stack.run( | |
| [ | |
| "exec", | |
| "-T", | |
| "postgres", | |
| "pg_restore", | |
| "-U", | |
| pg_user, | |
| "-d", | |
| database, | |
| "--clean", | |
| "--if-exists", | |
| ], | |
| stdin=source, | |
| ) | |
| def _archive_volume(stack: Stack, key: str, destination: Path) -> None: | |
| if stack.dry_run: | |
| stack.docker(["run", "--rm", "-v", f"{stack.volume_name(key)}:/source:ro", ARCHIVER_IMAGE, "tar", "-C", "/source", "-czf", "-", "."]) | |
| destination.touch() | |
| return | |
| with destination.open("wb") as output: | |
| stack.docker( | |
| [ | |
| "run", | |
| "--rm", | |
| "-v", | |
| f"{stack.volume_name(key)}:/source:ro", | |
| ARCHIVER_IMAGE, | |
| "tar", | |
| "-C", | |
| "/source", | |
| "-czf", | |
| "-", | |
| ".", | |
| ], | |
| stdout=output, | |
| ) | |
| def _restore_volume(stack: Stack, key: str, archive: Path) -> None: | |
| if stack.dry_run: | |
| stack.docker(["run", "--rm", "-i", "-v", f"{stack.volume_name(key)}:/target", ARCHIVER_IMAGE, "tar", "-C", "/target", "-xzf", "-"]) | |
| return | |
| with archive.open("rb") as source: | |
| stack.docker( | |
| [ | |
| "run", | |
| "--rm", | |
| "-i", | |
| "-v", | |
| f"{stack.volume_name(key)}:/target", | |
| ARCHIVER_IMAGE, | |
| "tar", | |
| "-C", | |
| "/target", | |
| "-xzf", | |
| "-", | |
| ], | |
| stdin=source, | |
| ) | |
| def _running_services(stack: Stack) -> list[str]: | |
| result = stack.run( | |
| ["ps", "--services", "--filter", "status=running"], capture=True | |
| ) | |
| return [line for line in (result.stdout or "").splitlines() if line] | |
| def _current_image(stack: Stack) -> str: | |
| result = stack.run(["config", "--format", "json"], capture=True) | |
| if stack.dry_run: | |
| return stack.env.get("BRAINAPI_IMAGE", "dry-run") | |
| config = json.loads(result.stdout) | |
| return str(config["services"]["brainapi"]["image"]) | |
| def _manifest(backup_dir: Path) -> dict: | |
| return json.loads((backup_dir / "manifest.json").read_text(encoding="utf-8")) | |
| def verify_backup(backup_dir: Path, expected_profile: str | None = None) -> dict: | |
| manifest = _manifest(backup_dir) | |
| if manifest.get("format_version") != 1: | |
| raise RuntimeError("Unsupported backup manifest format") | |
| if expected_profile and manifest.get("profile") != expected_profile: | |
| raise RuntimeError("Backup profile does not match the requested profile") | |
| for name, expected in manifest.get("checksums", {}).items(): | |
| path = backup_dir / name | |
| if not path.is_file(): | |
| raise RuntimeError(f"Missing backup artifact: {name}") | |
| actual = _sha256(path) | |
| if actual != expected: | |
| raise RuntimeError(f"Checksum mismatch: {name}") | |
| return manifest | |
| def backup(stack: Stack, backup_root: Path) -> Path: | |
| timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") | |
| backup_root = backup_root.expanduser().resolve() | |
| backup_root.mkdir(parents=True, mode=0o700, exist_ok=True) | |
| backup_root.chmod(stat.S_IRWXU) | |
| destination = backup_root / f"{stack.profile}-{timestamp}" | |
| destination.mkdir(parents=True, mode=0o700, exist_ok=False) | |
| destination.chmod(stat.S_IRWXU) | |
| running = _running_services(stack) | |
| try: | |
| stack.run(["stop", *APP_SERVICES]) | |
| if stack.profile == "light": | |
| pg_user = stack.env.get("POSTGRES_USERNAME", "brainapi") | |
| pg_db = stack.env.get("POSTGRES_SYSTEM_DATABASE", "brainapi") | |
| _write_stream( | |
| stack, | |
| ["exec", "-T", "postgres", "pg_dump", "-U", pg_user, "-d", pg_db, "--format=custom"], | |
| destination / "postgres.dump", | |
| ) | |
| _dump_postgres_brain_databases( | |
| stack, destination / "postgres-brains.tar.gz" | |
| ) | |
| stack.run(["exec", "-T", "redis", "sh", "-c", 'redis-cli -a "$REDIS_PASSWORD" SAVE']) | |
| stack.run(["stop", "redis", "postgres"]) | |
| else: | |
| mongo_user = stack.env.get("MONGO_USERNAME", "brainapi") | |
| _write_stream( | |
| stack, | |
| ["exec", "-T", "mongo", "sh", "-c", f'mongodump --archive --gzip --username {shlex.quote(mongo_user)} --password "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin'], | |
| destination / "mongo.archive.gz", | |
| ) | |
| stack.run(["exec", "-T", "redis", "sh", "-c", 'redis-cli -a "$REDIS_PASSWORD" SAVE']) | |
| stack.run(["stop", "neo4j", "mongo", "milvus", "etcd", "minio", "redis"]) | |
| neo_dir = destination / "neo4j" | |
| neo_dir.mkdir(mode=0o700) | |
| stack.run( | |
| ["run", "--rm", "--no-deps", "-v", f"{neo_dir}:/backup", "neo4j", "neo4j-admin", "database", "dump", "system", "neo4j", "--to-path=/backup", "--overwrite-destination=true"] | |
| ) | |
| if not stack.dry_run: | |
| subprocess.run(["tar", "-C", str(neo_dir), "-czf", str(destination / "neo4j.dumps.tar.gz"), "."], check=True) | |
| for child in neo_dir.iterdir(): | |
| child.unlink() | |
| neo_dir.rmdir() | |
| for volume in PROFILE_VOLUMES[stack.profile]: | |
| _archive_volume(stack, volume, destination / f"{volume}.tar.gz") | |
| artifacts = sorted(path for path in destination.iterdir() if path.is_file()) | |
| manifest = { | |
| "format_version": 1, | |
| "profile": stack.profile, | |
| "brainapi_image": _current_image(stack), | |
| "brainapi_version": _current_image(stack).rsplit(":", 1)[-1], | |
| "timestamp": timestamp, | |
| "services": ( | |
| ["postgres", "redis", "brainapi-plugins"] | |
| if stack.profile == "light" | |
| else ["neo4j", "mongo", "redis", "milvus", "etcd", "minio", "brainapi-plugins"] | |
| ), | |
| "restore_order": list(RESTORE_ORDER[stack.profile]), | |
| "checksums": {path.name: _sha256(path) for path in artifacts}, | |
| } | |
| (destination / "manifest.json").write_text( | |
| json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" | |
| ) | |
| verify_backup(destination, stack.profile) | |
| return destination | |
| finally: | |
| if running: | |
| stack.run(["start", *running], check=False) | |
| def _assert_empty_stopped(stack: Stack) -> None: | |
| running = _running_services(stack) | |
| if running: | |
| raise RuntimeError("Restore target must be stopped; running: " + ", ".join(running)) | |
| for key in PROFILE_ALL_VOLUMES[stack.profile]: | |
| volume = stack.volume_name(key) | |
| exists = stack.docker(["volume", "inspect", volume], check=False).returncode == 0 | |
| if not exists or stack.dry_run: | |
| continue | |
| result = stack.docker( | |
| ["run", "--rm", "-v", f"{volume}:/source:ro", ARCHIVER_IMAGE, "sh", "-c", "test -z \"$(find /source -mindepth 1 -maxdepth 1 -print -quit)\""], | |
| check=False, | |
| ) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"Restore target volume is not empty: {volume}") | |
| def restore(stack: Stack, backup_dir: Path) -> None: | |
| manifest = verify_backup(backup_dir, stack.profile) | |
| if manifest["brainapi_image"] != _current_image(stack): | |
| raise RuntimeError("Backup image does not match BRAINAPI_IMAGE") | |
| _assert_empty_stopped(stack) | |
| stack.run(["up", "--no-start"]) | |
| for key in PROFILE_VOLUMES[stack.profile]: | |
| _restore_volume(stack, key, backup_dir / f"{key}.tar.gz") | |
| if stack.profile == "light": | |
| stack.run(["up", "-d", "--wait", "postgres"]) | |
| pg_user = stack.env.get("POSTGRES_USERNAME", "brainapi") | |
| pg_db = stack.env.get("POSTGRES_SYSTEM_DATABASE", "brainapi") | |
| if stack.dry_run: | |
| stack.run(["exec", "-T", "postgres", "pg_restore", "-U", pg_user, "-d", pg_db, "--clean", "--if-exists"]) | |
| else: | |
| with (backup_dir / "postgres.dump").open("rb") as source: | |
| stack.run(["exec", "-T", "postgres", "pg_restore", "-U", pg_user, "-d", pg_db, "--clean", "--if-exists"], stdin=source) | |
| _restore_postgres_brain_databases( | |
| stack, backup_dir / "postgres-brains.tar.gz", pg_user | |
| ) | |
| else: | |
| with tempfile.TemporaryDirectory(prefix="brainapi-neo4j-restore-") as tmp: | |
| tmp_path = Path(tmp) | |
| if not stack.dry_run: | |
| subprocess.run(["tar", "-C", str(tmp_path), "-xzf", str(backup_dir / "neo4j.dumps.tar.gz")], check=True) | |
| stack.run(["run", "--rm", "--no-deps", "-v", f"{tmp_path}:/backup:ro", "neo4j", "neo4j-admin", "database", "load", "system", "neo4j", "--from-path=/backup", "--overwrite-destination=true"]) | |
| stack.run(["up", "-d", "--wait", "mongo"]) | |
| mongo_user = stack.env.get("MONGO_USERNAME", "brainapi") | |
| if stack.dry_run: | |
| stack.run(["exec", "-T", "mongo", "sh", "-c", f'mongorestore --archive --gzip --drop --username {shlex.quote(mongo_user)} --password "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin']) | |
| else: | |
| with (backup_dir / "mongo.archive.gz").open("rb") as source: | |
| stack.run(["exec", "-T", "mongo", "sh", "-c", f'mongorestore --archive --gzip --drop --username {shlex.quote(mongo_user)} --password "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin'], stdin=source) | |
| stack.run(["up", "-d", "--wait"]) | |
| def _parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("command", choices=("backup", "verify", "restore")) | |
| parser.add_argument("--profile", choices=("light", "heavy"), required=True) | |
| parser.add_argument("--env-file", type=Path, required=True) | |
| parser.add_argument("--project-name", default="brainapi") | |
| parser.add_argument("--backup-dir", type=Path, default=Path(os.getenv("BACKUP_DIR", "/srv/brainapi/backups"))) | |
| parser.add_argument("--archive", type=Path, help="Backup directory for verify/restore") | |
| parser.add_argument( | |
| "--compose-file", | |
| action="append", | |
| default=[], | |
| type=Path, | |
| help="Additional Compose override file; may be repeated.", | |
| ) | |
| parser.add_argument("--dry-run", action="store_true") | |
| return parser | |
| def main() -> int: | |
| args = _parser().parse_args() | |
| try: | |
| if args.command == "verify": | |
| if not args.archive: | |
| raise RuntimeError("--archive is required for verify") | |
| verify_backup(args.archive.resolve(), args.profile) | |
| print(f"Verified {args.archive}") | |
| return 0 | |
| stack = Stack( | |
| args.profile, | |
| args.env_file, | |
| args.project_name, | |
| args.dry_run, | |
| args.compose_file, | |
| ) | |
| if args.command == "backup": | |
| destination = backup(stack, args.backup_dir) | |
| print(destination) | |
| else: | |
| if not args.archive: | |
| raise RuntimeError("--archive is required for restore") | |
| restore(stack, args.archive.resolve()) | |
| print(f"Restored {args.archive}") | |
| return 0 | |
| except (KeyError, OSError, RuntimeError, subprocess.CalledProcessError) as exc: | |
| print(f"backup_restore: {exc}", file=sys.stderr) | |
| return 1 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |