brain / deploy /backup_restore.py
Leon4gr45's picture
Deploy complete BrainAPI source 81ad5ce1c9c1
eb60b6b verified
Raw History Blame Contribute Delete
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())