mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 18:15:33 +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
@@ -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")
|
||||
|
||||
|
||||
Reference in new issue
Block a user