mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 14:17:08 +00:00
feat: docsgpt backup and docsgpt restore
`docsgpt backup` writes one archive holding a pg_dump of the database, a tar of each data volume and a manifest of what it came from; `docsgpt restore` puts it back over an install. The settings file is left out unless --with-settings asks for it, since it holds the install's secrets, and a backup taken with a newer DocsGPT is refused without --force. The volume tars go through the image the install already runs, so a backup pulls nothing extra, and compose calls can now redirect stdout and stdin so the dump never passes through this process.
This commit is contained in:
1 parent
4abd2c0c9f
commit
fe68fec69e
9 files changed
+489
-7
No files matched your search
@@ -0,0 +1,107 @@
|
||||
"""The archive ``docsgpt backup`` writes and ``docsgpt restore`` reads.
|
||||
|
||||
One gzipped tar holds a SQL dump of the database, a tar per data volume, and a
|
||||
manifest saying which version and image the backup came from. The settings file
|
||||
is left out unless it is asked for: it holds the secrets.
|
||||
|
||||
``postgres_data`` is not tarred, because the dump is the database backup and a
|
||||
copy of a running data directory would be a torn one. Caddy's volumes are left
|
||||
out too: they hold certificates it obtains again on the next start.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from docsgpt.deploy.docker import DeployError
|
||||
|
||||
FORMAT = 1
|
||||
MANIFEST = "manifest.json"
|
||||
DUMP = "database.sql"
|
||||
SETTINGS = "settings.env"
|
||||
VOLUME_DIR = "volumes"
|
||||
DATA_VOLUMES = ("indexes", "inputs", "vectors")
|
||||
|
||||
|
||||
def archive_name(when: datetime) -> str:
|
||||
"""The file name for a backup taken at ``when``."""
|
||||
return f"docsgpt-{when.strftime('%Y%m%d-%H%M%S')}.tar.gz"
|
||||
|
||||
|
||||
def volume_member(name: str) -> str:
|
||||
"""Where a volume's tar sits inside the archive."""
|
||||
return f"{VOLUME_DIR}/{name}.tar"
|
||||
|
||||
|
||||
def write_archive(
|
||||
path: Path,
|
||||
*,
|
||||
dump: Path,
|
||||
volume_tars: Mapping[str, Path],
|
||||
manifest: Mapping[str, object],
|
||||
settings: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Write the backup archive; the manifest is added last so a truncated file has none."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tarfile.open(path, "w:gz") as archive:
|
||||
archive.add(dump, arcname=DUMP)
|
||||
for name, tar_path in sorted(volume_tars.items()):
|
||||
archive.add(tar_path, arcname=volume_member(name))
|
||||
if settings is not None:
|
||||
archive.add(settings, arcname=SETTINGS)
|
||||
body = json.dumps(dict(manifest), indent=2).encode("utf-8")
|
||||
info = tarfile.TarInfo(MANIFEST)
|
||||
info.size = len(body)
|
||||
info.mtime = int(time.time())
|
||||
info.mode = 0o600
|
||||
archive.addfile(info, io.BytesIO(body))
|
||||
|
||||
|
||||
def read_manifest(path: Path) -> dict:
|
||||
"""The archive's manifest, or a DeployError naming what is wrong with the file."""
|
||||
if not path.is_file():
|
||||
raise DeployError(f"{path} does not exist")
|
||||
try:
|
||||
with tarfile.open(path, "r:gz") as archive:
|
||||
member = archive.extractfile(MANIFEST)
|
||||
if member is None:
|
||||
raise KeyError(MANIFEST)
|
||||
manifest = json.loads(member.read().decode("utf-8"))
|
||||
except (tarfile.TarError, KeyError, ValueError, OSError) as exc:
|
||||
raise DeployError(f"{path} is not a DocsGPT backup: {exc}") from exc
|
||||
if not isinstance(manifest, dict) or "volumes" not in manifest:
|
||||
raise DeployError(f"{path} is not a DocsGPT backup: its manifest is missing what to restore")
|
||||
return manifest
|
||||
|
||||
|
||||
def extract(path: Path, destination: Path) -> None:
|
||||
"""Unpack the archive into ``destination`` (data filter: no paths outside it, no devices)."""
|
||||
with tarfile.open(path, "r:gz") as archive:
|
||||
archive.extractall(destination, filter="data")
|
||||
|
||||
|
||||
def _parts(version: str) -> tuple[int, ...]:
|
||||
numbers = []
|
||||
for chunk in str(version).split("."):
|
||||
digits = "".join(character for character in chunk if character.isdigit())
|
||||
numbers.append(int(digits) if digits else 0)
|
||||
return tuple(numbers)
|
||||
|
||||
|
||||
def check_version(manifest: Mapping[str, object], current: str, force: bool) -> None:
|
||||
"""Refuse a backup from a newer DocsGPT: its data may not fit this version's schema."""
|
||||
taken_with = str(manifest.get("version") or "")
|
||||
if force or not taken_with:
|
||||
return
|
||||
if _parts(taken_with) > _parts(current):
|
||||
raise DeployError(
|
||||
f"this backup is from DocsGPT {taken_with}, newer than the installed {current}. "
|
||||
"Upgrade first with `docsgpt upgrade`, or pass --force to restore it anyway."
|
||||
)
|
||||
@@ -9,6 +9,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import webbrowser
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
@@ -16,6 +17,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from docsgpt.deploy import backup as backup_format
|
||||
from docsgpt.deploy import envfile, stack
|
||||
from docsgpt.deploy.docker import DeployError, Docker, lan_ip, wait_healthy
|
||||
|
||||
@@ -350,6 +352,119 @@ def env(args, context: Optional[Context] = None) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _stack_image(env: Mapping[str, str]) -> str:
|
||||
"""The image this install runs; the volume tars go through it, so nothing extra is pulled."""
|
||||
tag = env.get("DOCSGPT_IMAGE_TAG") or "latest"
|
||||
return f"arc53/docsgpt:{tag}{env.get('DOCSGPT_IMAGE_VARIANT', '')}"
|
||||
|
||||
|
||||
def backup(args, context: Optional[Context] = None) -> int:
|
||||
"""Write a dump of the database and a tar of each data volume into one archive."""
|
||||
context = context or Context.default(args)
|
||||
directory = stack.stack_dir(args.dir)
|
||||
env = _installed(directory)
|
||||
if env is None:
|
||||
return 1
|
||||
|
||||
out_dir = Path(args.out).expanduser() if args.out else directory / "backups"
|
||||
taken_at = datetime.now(timezone.utc)
|
||||
target = out_dir / backup_format.archive_name(taken_at)
|
||||
image = _stack_image(env)
|
||||
|
||||
with tempfile.TemporaryDirectory() as workspace:
|
||||
work = Path(workspace)
|
||||
dump = work / backup_format.DUMP
|
||||
print("Dumping the database ...")
|
||||
with dump.open("w", encoding="utf-8") as handle:
|
||||
context.docker.compose(
|
||||
directory, "exec", "-T", "postgres",
|
||||
"pg_dump", "--clean", "--if-exists", "-U", "docsgpt", "-d", "docsgpt",
|
||||
stdout=handle,
|
||||
)
|
||||
volume_tars = {}
|
||||
for name in backup_format.DATA_VOLUMES:
|
||||
print(f"Archiving the {name} volume ...")
|
||||
tar_path = work / f"{name}.tar"
|
||||
context.docker.export_volume(f"{PROJECT}_{name}", tar_path, image)
|
||||
volume_tars[name] = tar_path
|
||||
manifest = {
|
||||
"format": backup_format.FORMAT,
|
||||
"created_at": taken_at.isoformat(timespec="seconds"),
|
||||
"version": context.version,
|
||||
"image_tag": env.get("DOCSGPT_IMAGE_TAG", ""),
|
||||
"volumes": list(backup_format.DATA_VOLUMES),
|
||||
"settings_included": bool(args.with_settings),
|
||||
}
|
||||
settings = directory / ".env" if args.with_settings else None
|
||||
backup_format.write_archive(target, dump=dump, volume_tars=volume_tars, manifest=manifest, settings=settings)
|
||||
|
||||
size = target.stat().st_size / 1_000_000
|
||||
print(f"\nBackup written to {target} ({size:.1f} MB)")
|
||||
if args.with_settings:
|
||||
print("It contains .env, so it holds this install's secrets: keep it somewhere private.")
|
||||
else:
|
||||
print("Settings are not in it; `docsgpt backup --with-settings` includes .env, secrets and all.")
|
||||
print(f"Restore it with: docsgpt restore {target}")
|
||||
return 0
|
||||
|
||||
|
||||
def restore(args, context: Optional[Context] = None) -> int:
|
||||
"""Put a backup's database and data volumes back over this install."""
|
||||
context = context or Context.default(args)
|
||||
archive = Path(args.archive).expanduser()
|
||||
manifest = backup_format.read_manifest(archive)
|
||||
backup_format.check_version(manifest, context.version, args.force)
|
||||
|
||||
directory = stack.stack_dir(args.dir)
|
||||
env = _installed(directory)
|
||||
if env is None:
|
||||
return 1
|
||||
|
||||
taken_at = manifest.get("created_at", "an unknown time")
|
||||
if not args.yes:
|
||||
if not context.interactive:
|
||||
raise DeployError("restore needs --yes when there is no terminal to confirm on")
|
||||
question = f"Replace the data in {directory} with the backup from {taken_at}? This cannot be undone."
|
||||
if not context.prompter.confirm(question, default=False):
|
||||
print("Nothing restored.")
|
||||
return 1
|
||||
|
||||
image = _stack_image(env)
|
||||
print("Stopping the stack ...")
|
||||
context.docker.compose(directory, *_EVERY_PROFILE, "down")
|
||||
|
||||
with tempfile.TemporaryDirectory() as workspace:
|
||||
work = Path(workspace)
|
||||
backup_format.extract(archive, work)
|
||||
for name in manifest.get("volumes", []):
|
||||
tar_path = work / backup_format.volume_member(name)
|
||||
if not tar_path.is_file():
|
||||
raise DeployError(f"{archive} is missing the {name} volume it says it contains")
|
||||
print(f"Restoring the {name} volume ...")
|
||||
context.docker.import_volume(f"{PROJECT}_{name}", tar_path, image)
|
||||
|
||||
dump = work / backup_format.DUMP
|
||||
if not dump.is_file():
|
||||
raise DeployError(f"{archive} is missing its database dump")
|
||||
print("Starting the database ...")
|
||||
context.docker.compose(directory, "up", "-d", "--wait", "postgres")
|
||||
print("Restoring the database ...")
|
||||
with dump.open("r", encoding="utf-8") as handle:
|
||||
context.docker.compose(
|
||||
directory, "exec", "-T", "postgres",
|
||||
"psql", "--quiet", "-U", "docsgpt", "-d", "docsgpt",
|
||||
stdin=handle,
|
||||
)
|
||||
|
||||
print("Starting DocsGPT ...")
|
||||
context.docker.compose(directory, "up", "-d", "--remove-orphans")
|
||||
if not context.wait(stack.health_url(env), args.timeout):
|
||||
print("DocsGPT did not answer after the restore. See `docsgpt logs backend`.", file=sys.stderr)
|
||||
return 1
|
||||
print(f"\nRestored the backup from {taken_at}. DocsGPT is running at {stack.url(env, context.lan_ip())}")
|
||||
return 0
|
||||
|
||||
|
||||
def _uninstall_hint(installer: str) -> str:
|
||||
return {"uv": "uv tool uninstall docsgpt", "pipx": "pipx uninstall docsgpt"}.get(installer, "pip uninstall docsgpt")
|
||||
|
||||
|
||||
@@ -22,10 +22,16 @@ class DeployError(Exception):
|
||||
"""A problem the user can act on; the command prints it without a traceback."""
|
||||
|
||||
|
||||
def run(args: Sequence[str], *, cwd: Optional[Path] = None, capture: bool = False, check: bool = True):
|
||||
"""Run a command, streaming its output unless ``capture``; with ``check`` a failure raises DeployError."""
|
||||
def run(args: Sequence[str], *, cwd: Optional[Path] = None, capture: bool = False, check: bool = True,
|
||||
stdout=None, stdin=None):
|
||||
"""Run a command, streaming its output unless ``capture`` or a file is given for ``stdout``.
|
||||
|
||||
``stdout`` and ``stdin`` take open files, so a dump goes straight to disk and
|
||||
back in again without passing through this process.
|
||||
"""
|
||||
streams = {"capture_output": True} if capture else {"stdout": stdout, "stdin": stdin}
|
||||
try:
|
||||
result = subprocess.run(list(args), cwd=cwd, text=True, capture_output=capture, check=False)
|
||||
result = subprocess.run(list(args), cwd=cwd, text=True, check=False, **streams)
|
||||
except FileNotFoundError as exc:
|
||||
raise DeployError(f"{args[0]} is not installed or not on PATH") from exc
|
||||
if check and result.returncode != 0:
|
||||
@@ -94,9 +100,29 @@ class Docker:
|
||||
raise DeployError("Docker is not running. Start it with `sudo systemctl start docker` and run this again.")
|
||||
raise DeployError("Docker is not running. Start Docker Desktop and run this again.")
|
||||
|
||||
def compose(self, directory: Path, *args: str, capture: bool = False, check: bool = True):
|
||||
def compose(self, directory: Path, *args: str, capture: bool = False, check: bool = True,
|
||||
stdout=None, stdin=None):
|
||||
"""``docker compose <args>`` in ``directory``, which holds the Compose file and its ``.env``."""
|
||||
return self._run(["docker", "compose", *args], cwd=directory, capture=capture, check=check)
|
||||
return self._run(["docker", "compose", *args], cwd=directory, capture=capture, check=check,
|
||||
stdout=stdout, stdin=stdin)
|
||||
|
||||
def export_volume(self, volume: str, dest: Path, image: str) -> None:
|
||||
"""Write ``volume`` to ``dest`` as a tar, through an image the install already has."""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with dest.open("wb") as handle:
|
||||
self._run(
|
||||
["docker", "run", "--rm", "-v", f"{volume}:/data:ro", image, "tar", "cf", "-", "-C", "/data", "."],
|
||||
stdout=handle,
|
||||
)
|
||||
|
||||
def import_volume(self, volume: str, source: Path, image: str) -> None:
|
||||
"""Replace ``volume``'s contents with the tar at ``source``; the volume is created when missing."""
|
||||
with source.open("rb") as handle:
|
||||
self._run(
|
||||
["docker", "run", "--rm", "-i", "-v", f"{volume}:/data", image,
|
||||
"sh", "-c", "find /data -mindepth 1 -delete && tar xf - -C /data"],
|
||||
stdin=handle,
|
||||
)
|
||||
|
||||
def volume_exists(self, name: str) -> bool:
|
||||
return self._run(["docker", "volume", "inspect", name], capture=True, check=False).returncode == 0
|
||||
|
||||
Reference in new issue
Block a user