mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 12:11:45 +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
@@ -79,6 +79,36 @@ the questions again.
|
||||
| `docsgpt down` | Stop the stack; data and settings stay |
|
||||
| `docsgpt uninstall [--purge]` | Remove the containers; `--purge` also deletes the settings and all data |
|
||||
|
||||
### Backups
|
||||
|
||||
`docsgpt backup` writes one archive holding a dump of the database and a tar of
|
||||
each data volume (`indexes`, `inputs`, `vectors`):
|
||||
|
||||
```bash
|
||||
docsgpt backup # into <stack>/backups
|
||||
docsgpt backup --out /mnt/backups # somewhere else, e.g. a mounted disk
|
||||
```
|
||||
|
||||
The archive does **not** include `.env`, because that file holds the install's
|
||||
secrets. `docsgpt backup --with-settings` puts it in, for when the archive
|
||||
itself is stored somewhere private. Keep `.env` safe separately otherwise: the
|
||||
database password in it is what an existing Postgres volume expects.
|
||||
|
||||
Restoring replaces the data in an install:
|
||||
|
||||
```bash
|
||||
docsgpt restore ~/.docsgpt/server/backups/docsgpt-20260916-120000.tar.gz
|
||||
```
|
||||
|
||||
It asks first, then stops the stack, puts the volumes and the database back, and
|
||||
starts DocsGPT again. `--yes` skips the question for scripts. A backup taken
|
||||
with a newer DocsGPT is refused, since its data may not fit this version's
|
||||
schema; upgrade first, or pass `--force` if you know the two match.
|
||||
|
||||
The Postgres data directory itself is not archived: the dump is the database
|
||||
backup, and copying a directory Postgres is writing to would capture a torn
|
||||
copy. Caddy's certificates are not archived either, as it obtains them again.
|
||||
|
||||
More `docsgpt up` options: `--port`, `--docling` (the image with the docling
|
||||
parser engine and OCR), `--image-tag develop` (follow the `main` branch) and
|
||||
`--adopt` (manage a stack you started from the standalone Compose file in
|
||||
|
||||
@@ -11,6 +11,16 @@ The notable changes in each release. Every release on GitHub also carries
|
||||
[auto-generated notes](https://github.com/arc53/DocsGPT/releases) listing every merged pull
|
||||
request, and [Upgrading](/upgrading) covers the steps an existing deployment has to take.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Back up and restore an install
|
||||
|
||||
`docsgpt backup` writes a dump of the database and a tar of each data volume into one archive, and
|
||||
`docsgpt restore <archive>` puts them back. The settings file is left out unless
|
||||
`--with-settings` asks for it, since it holds the install's secrets, and a backup from a newer
|
||||
DocsGPT is refused unless you pass `--force`. See
|
||||
[Backups](/Deploying/Docker-Deploying#backups).
|
||||
|
||||
## 0.21.0
|
||||
|
||||
### Install with one command
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""`docsgpt backup` and `docsgpt restore`, against a fake Docker."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.deploy import backup as backup_module
|
||||
from docsgpt.deploy.docker import DeployError
|
||||
|
||||
from .test_commands import FakeDocker, FakePrompter, _context, _run
|
||||
|
||||
|
||||
def _installed(tmp_path, *extra):
|
||||
assert _run(["up", "--yes", "--dir", str(tmp_path), *extra], _context()) == 0
|
||||
|
||||
|
||||
def _archive_names(path):
|
||||
with tarfile.open(path, "r:gz") as archive:
|
||||
return sorted(member.name for member in archive.getmembers() if member.isfile())
|
||||
|
||||
|
||||
def _manifest(path):
|
||||
with tarfile.open(path, "r:gz") as archive:
|
||||
return json.loads(archive.extractfile(backup_module.MANIFEST).read())
|
||||
|
||||
|
||||
def _copy_with_version(archive, version, dest):
|
||||
"""The same archive, with another DocsGPT version written into its manifest."""
|
||||
with tarfile.open(archive, "r:gz") as source, tarfile.open(dest, "w:gz") as out:
|
||||
for member in source.getmembers():
|
||||
body = source.extractfile(member).read() if member.isfile() else None
|
||||
if member.name == backup_module.MANIFEST:
|
||||
manifest = json.loads(body.decode())
|
||||
manifest["version"] = version
|
||||
body = json.dumps(manifest).encode()
|
||||
member.size = len(body)
|
||||
out.addfile(member, io.BytesIO(body) if body is not None else None)
|
||||
return dest
|
||||
|
||||
|
||||
class TestBackup:
|
||||
def test_writes_a_dump_a_tar_per_volume_and_a_manifest(self, tmp_path, capsys):
|
||||
_installed(tmp_path)
|
||||
docker = FakeDocker(volumes={"docsgpt_postgres_data"})
|
||||
out = tmp_path / "backups"
|
||||
assert _run(["backup", "--dir", str(tmp_path), "--out", str(out)], _context(docker)) == 0
|
||||
|
||||
archives = list(out.glob("docsgpt-*.tar.gz"))
|
||||
assert len(archives) == 1, archives
|
||||
names = _archive_names(archives[0])
|
||||
assert backup_module.DUMP in names
|
||||
for volume in ("indexes", "inputs", "vectors"):
|
||||
assert f"volumes/{volume}.tar" in names
|
||||
manifest = _manifest(archives[0])
|
||||
assert manifest["version"] == "0.21.0"
|
||||
assert manifest["image_tag"] == "0.21.0"
|
||||
assert manifest["volumes"] == ["indexes", "inputs", "vectors"]
|
||||
assert manifest["settings_included"] is False
|
||||
assert "Backup written to" in capsys.readouterr().out
|
||||
assert [op for op in docker.volume_ops if op[0] == "export"], docker.volume_ops
|
||||
|
||||
def test_the_settings_file_is_left_out_unless_asked_for(self, tmp_path):
|
||||
_installed(tmp_path)
|
||||
out = tmp_path / "backups"
|
||||
assert _run(["backup", "--dir", str(tmp_path), "--out", str(out)], _context()) == 0
|
||||
assert backup_module.SETTINGS not in _archive_names(next(out.glob("*.tar.gz")))
|
||||
|
||||
with_settings = tmp_path / "with-settings"
|
||||
argv = ["backup", "--dir", str(tmp_path), "--out", str(with_settings), "--with-settings"]
|
||||
assert _run(argv, _context()) == 0
|
||||
assert backup_module.SETTINGS in _archive_names(next(with_settings.glob("*.tar.gz")))
|
||||
|
||||
def test_the_database_is_dumped_from_the_running_container(self, tmp_path):
|
||||
_installed(tmp_path)
|
||||
docker = FakeDocker()
|
||||
assert _run(["backup", "--dir", str(tmp_path), "--out", str(tmp_path / "b")], _context(docker)) == 0
|
||||
dumps = [args for _, args in docker.calls if "pg_dump" in " ".join(args)]
|
||||
assert dumps, docker.calls
|
||||
assert dumps[0][:3] == ["exec", "-T", "postgres"]
|
||||
|
||||
def test_without_an_install(self, tmp_path, capsys):
|
||||
assert _run(["backup", "--dir", str(tmp_path)], _context()) == 1
|
||||
assert "docsgpt up" in capsys.readouterr().err
|
||||
|
||||
|
||||
class TestRestore:
|
||||
def _backup(self, tmp_path, *extra):
|
||||
_installed(tmp_path)
|
||||
out = tmp_path / "backups"
|
||||
assert _run(["backup", "--dir", str(tmp_path), "--out", str(out), *extra], _context()) == 0
|
||||
return next(out.glob("*.tar.gz"))
|
||||
|
||||
def test_restores_the_volumes_and_the_database(self, tmp_path):
|
||||
archive = self._backup(tmp_path)
|
||||
docker = FakeDocker(volumes={"docsgpt_postgres_data"})
|
||||
argv = ["restore", str(archive), "--dir", str(tmp_path), "--yes"]
|
||||
assert _run(argv, _context(docker)) == 0
|
||||
joined = [" ".join(args) for _, args in docker.calls]
|
||||
assert any(call.startswith("--profile https down") for call in joined), joined
|
||||
assert any("psql" in call for call in joined), joined
|
||||
assert any(call.startswith("up -d") for call in joined), joined
|
||||
|
||||
def test_asks_before_replacing_data(self, tmp_path):
|
||||
archive = self._backup(tmp_path)
|
||||
docker = FakeDocker(volumes={"docsgpt_postgres_data"})
|
||||
prompter = FakePrompter([False])
|
||||
assert _run(["restore", str(archive), "--dir", str(tmp_path)], _context(docker, prompter, interactive=True)) == 1
|
||||
assert docker.calls == []
|
||||
|
||||
def test_refuses_an_archive_that_is_not_a_docsgpt_backup(self, tmp_path):
|
||||
stray = tmp_path / "stray.tar.gz"
|
||||
with tarfile.open(stray, "w:gz") as archive:
|
||||
note = tmp_path / "note.txt"
|
||||
note.write_text("not a backup")
|
||||
archive.add(note, arcname="note.txt")
|
||||
with pytest.raises(DeployError, match="not a DocsGPT backup"):
|
||||
_run(["restore", str(stray), "--dir", str(tmp_path), "--yes"], _context())
|
||||
|
||||
def test_a_missing_archive(self, tmp_path):
|
||||
with pytest.raises(DeployError, match="does not exist"):
|
||||
_run(["restore", str(tmp_path / "nope.tar.gz"), "--dir", str(tmp_path), "--yes"], _context())
|
||||
|
||||
def test_a_newer_backup_is_refused_without_force(self, tmp_path):
|
||||
"""Restoring a 0.22 backup into 0.21 would hand an older schema newer data."""
|
||||
newer = _copy_with_version(self._backup(tmp_path), "0.22.0", tmp_path / "newer.tar.gz")
|
||||
with pytest.raises(DeployError, match="newer"):
|
||||
_run(["restore", str(newer), "--dir", str(tmp_path), "--yes"], _context(FakeDocker()))
|
||||
argv = ["restore", str(newer), "--dir", str(tmp_path), "--yes", "--force"]
|
||||
assert _run(argv, _context(FakeDocker())) == 0
|
||||
@@ -1,8 +1,10 @@
|
||||
"""`docsgpt up` and the commands that manage the stack, against a fake Docker."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -19,17 +21,38 @@ class FakeDocker:
|
||||
self.volumes = set(volumes)
|
||||
self.dirs = {Path(d) for d in project_dirs}
|
||||
self.calls = []
|
||||
self.volume_ops = []
|
||||
self.preflights = 0
|
||||
|
||||
def preflight(self, interactive=False):
|
||||
self.preflights += 1
|
||||
|
||||
def compose(self, directory, *args, capture=False, check=True):
|
||||
def compose(self, directory, *args, capture=False, check=True, stdout=None, stdin=None):
|
||||
self.calls.append((Path(directory), list(args)))
|
||||
if "down" in args and "-v" in args:
|
||||
self.volumes.clear()
|
||||
if stdout is not None:
|
||||
stdout.write("-- fake pg_dump\n")
|
||||
if stdin is not None:
|
||||
self.restored_sql = stdin.read()
|
||||
return subprocess.CompletedProcess(["docker", "compose", *args], 0, stdout="", stderr="")
|
||||
|
||||
def export_volume(self, volume, dest, image):
|
||||
"""Write a small tar, as the real one does with `docker run ... tar cf -`."""
|
||||
self.volume_ops.append(("export", volume, image))
|
||||
dest = Path(dest)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tarfile.open(dest, "w") as tar:
|
||||
body = f"{volume} contents".encode()
|
||||
info = tarfile.TarInfo(f"{volume}.marker")
|
||||
info.size = len(body)
|
||||
tar.addfile(info, io.BytesIO(body))
|
||||
|
||||
def import_volume(self, volume, source, image):
|
||||
self.volume_ops.append(("import", volume, image))
|
||||
self.volumes.add(volume)
|
||||
assert Path(source).is_file(), source
|
||||
|
||||
def volume_exists(self, name):
|
||||
return name in self.volumes
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ class FakeRunner:
|
||||
def __init__(self, answers=None):
|
||||
self.answers = list((answers or {}).items())
|
||||
self.calls = []
|
||||
self.streams = []
|
||||
|
||||
def __call__(self, args, *, cwd=None, capture=False, check=True):
|
||||
def __call__(self, args, *, cwd=None, capture=False, check=True, stdout=None, stdin=None):
|
||||
self.calls.append((list(args), cwd))
|
||||
self.streams.append((stdout, stdin))
|
||||
for prefix, answer in self.answers:
|
||||
if list(args[: len(prefix)]) == list(prefix):
|
||||
if callable(answer):
|
||||
@@ -105,6 +107,32 @@ class TestQueries:
|
||||
assert "label=com.docker.compose.project=docsgpt" in args
|
||||
|
||||
|
||||
class TestVolumes:
|
||||
def test_export_writes_the_volume_through_the_stack_image(self, tmp_path):
|
||||
runner = FakeRunner()
|
||||
dest = tmp_path / "volumes" / "inputs.tar"
|
||||
_docker(runner).export_volume("docsgpt_inputs", dest, "arc53/docsgpt:0.21.0")
|
||||
args, _ = runner.calls[0]
|
||||
assert args[:4] == ["docker", "run", "--rm", "-v"]
|
||||
assert args[4] == "docsgpt_inputs:/data:ro"
|
||||
assert args[5] == "arc53/docsgpt:0.21.0"
|
||||
assert args[6:] == ["tar", "cf", "-", "-C", "/data", "."]
|
||||
assert dest.is_file(), "the tar is written to the destination"
|
||||
assert runner.streams[0][0] is not None, "stdout goes to the file, not through this process"
|
||||
|
||||
def test_import_replaces_the_volume_contents(self, tmp_path):
|
||||
source = tmp_path / "inputs.tar"
|
||||
source.write_bytes(b"tar")
|
||||
runner = FakeRunner()
|
||||
_docker(runner).import_volume("docsgpt_inputs", source, "arc53/docsgpt:0.21.0")
|
||||
args, _ = runner.calls[0]
|
||||
assert "docsgpt_inputs:/data" in args
|
||||
assert args[-2] == "-c"
|
||||
assert "tar xf - -C /data" in args[-1]
|
||||
assert "find /data -mindepth 1 -delete" in args[-1], "old contents go first"
|
||||
assert runner.streams[0][1] is not None, "the tar is fed in on stdin"
|
||||
|
||||
|
||||
class TestWaitHealthy:
|
||||
def test_succeeds_once_the_api_answers(self):
|
||||
attempts = {"n": 0}
|
||||
|
||||
Reference in new issue
Block a user