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:
Alex committed 2026-09-16 21:33:33 +01:00
1 parent 4abd2c0c9f
commit fe68fec69e
9 files changed
+489 -7

No files matched your search

+12
View File
@@ -230,6 +230,18 @@ def _add_deploy_commands(commands) -> None:
uninstall.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
uninstall.add_argument("--purge", action="store_true", help="also delete the settings and all data")
backup = stack_command("backup", "backup", "write a backup of the database and the uploaded data")
backup.add_argument("--out", help="directory for the archive (default: <stack>/backups)")
backup.add_argument("--with-settings", action="store_true",
help="include .env in the archive; it holds this install's secrets")
restore = stack_command("restore", "restore", "restore a backup over this install")
restore.add_argument("archive", help="the .tar.gz written by `docsgpt backup`")
restore.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
restore.add_argument("--force", action="store_true", help="restore a backup taken with a newer DocsGPT")
restore.add_argument("--timeout", type=int, default=300,
help="seconds to wait for the API afterwards (default: 300)")
env = stack_command("env", "env", "show, get or set the stack's settings")
env_actions = env.add_subparsers(dest="env_action", metavar="<action>")
get = env_actions.add_parser("get", help="print one setting")
+107
View File
@@ -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."
)
+115
View File
@@ -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")
+31 -5
View File
@@ -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