Files
DocsGPT/docsgpt/deploy/commands.py
T
Alex 501baf8aae fix: say why backup and restore do not apply to a native install
Both accepted a native install and then drove `docker compose` in a directory
with no compose file, so the user got "no configuration file provided" rather
than an explanation. They now refuse with what to do instead, and restore
refuses before it reads the archive or stops anything.
2026-09-16 23:01:30 +01:00

768 lines
34 KiB
Python

"""``docsgpt up`` and the commands that manage the stack it starts."""
from __future__ import annotations
import getpass
import json
import os
import re
import secrets
import shutil
import subprocess
import sys
import tempfile
import webbrowser
from collections.abc import Callable, Mapping
from dataclasses import dataclass
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, native, stack
from docsgpt.deploy.docker import DeployError, Docker, lan_ip, wait_healthy
PROJECT = "docsgpt"
DATABASE_VOLUME = f"{PROJECT}_postgres_data"
_MOVING_TAGS = ("latest", "develop")
_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Commands that stop or remove services name every profile, so Caddy goes too
# even when COMPOSE_PROFILES no longer enables it.
_EVERY_PROFILE = ("--profile", "https")
EXPOSURE_CHOICES = [
("local", "Only this computer"),
("network", "Other machines on the network (plain HTTP, access token)"),
("domain", "A domain name with HTTPS (Caddy certificate, access token)"),
]
class Prompter:
"""Questions on the terminal. The installer hands its terminal to ``docsgpt up``."""
def choose(self, question: str, options: list[tuple[str, str]], default: str) -> str:
keys = [key for key, _ in options]
print(question)
for number, (key, label) in enumerate(options, 1):
print(f" {number}) {label}{' (default)' if key == default else ''}")
while True:
answer = input(f"Choose 1-{len(options)} [{keys.index(default) + 1}]: ").strip()
if not answer:
return default
if answer.isdigit() and 1 <= int(answer) <= len(options):
return keys[int(answer) - 1]
if answer in keys:
return answer
print("Enter one of the numbers above.")
def text(self, question: str, default: Optional[str] = None, secret: bool = False) -> str:
ask = getpass.getpass if secret else input
while True:
answer = ask(f"{question}{f' [{default}]' if default else ''}: ").strip()
if answer:
return answer
if default:
return default
def confirm(self, question: str, default: bool = False) -> bool:
answer = input(f"{question} [{'Y/n' if default else 'y/N'}]: ").strip().lower()
return default if not answer else answer in ("y", "yes")
def detect_installer() -> str:
"""How this ``docsgpt`` was installed: ``uv`` (uv tool), ``pipx`` or ``pip``."""
prefix = Path(sys.prefix)
if (prefix / "uv-receipt.toml").is_file():
return "uv"
if "pipx" in prefix.parts:
return "pipx"
return "pip"
def _run_command(args: list[str], env: Optional[Mapping[str, str]] = None) -> int:
"""Run ``args``; ``env`` adds to this process's environment rather than replacing it."""
try:
return subprocess.call(args, env={**os.environ, **env} if env else None)
except FileNotFoundError as exc:
raise DeployError(f"{args[0]} is not on PATH") from exc
def _exec_command(argv: list[str]) -> int:
"""Replace this process with ``argv`` (the upgraded ``docsgpt``); Windows runs it and waits."""
executable = shutil.which(argv[0]) or argv[0]
if sys.platform == "win32":
return subprocess.call([executable, *argv[1:]])
os.execv(executable, [executable, *argv[1:]])
return 0 # not reached
@dataclass
class Context:
"""What the commands talk to; tests replace the parts that touch Docker, the terminal or the network."""
docker: Any
prompter: Any
interactive: bool
version: str
lan_ip: Callable[[], str] = lan_ip
wait: Callable[[str, float], bool] = wait_healthy
open_browser: Callable[[str], Any] = webbrowser.open
installer: Callable[[], str] = detect_installer
run: Callable[..., int] = _run_command
exec_up: Callable[[list[str]], int] = _exec_command
services: Any = None
@classmethod
def default(cls, args) -> "Context":
from docsgpt.version import __version__
interactive = sys.stdin.isatty() and not getattr(args, "yes", False)
return cls(docker=Docker(), prompter=Prompter(), interactive=interactive, version=__version__)
def service_manager(self):
"""The launchd or systemd wrapper, made on first use so Docker installs never touch it."""
if self.services is None:
self.services = native.services_for_platform()
return self.services
def _record(directory: Path) -> dict:
"""What ``install.json`` says about this install (empty when there is none)."""
path = directory / stack.RECORD_FILE
if not path.is_file():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except ValueError:
return {}
def _mode(directory: Path) -> str:
"""``native`` or ``docker``, from the install record."""
return "native" if _record(directory).get("mode") == "native" else "docker"
def _installed(directory: Path) -> Optional[dict[str, str]]:
"""The stack's settings, or None (with a message) when there is no install in ``directory``."""
installed = (directory / stack.COMPOSE_FILE).is_file() or _mode(directory) == "native"
if not installed:
print(f"No DocsGPT install in {directory}. Run `docsgpt up` first, or pass --dir.", file=sys.stderr)
return None
return envfile.read(directory / ".env")
def _current_provider(env: Mapping[str, str]) -> str:
name = env.get("LLM_PROVIDER", "docsgpt")
if name == "openai" and env.get("OPENAI_BASE_URL"):
return "openai-compatible"
return name if name in stack.PROVIDERS else "docsgpt"
def _check_other_stacks(args, context: Context, directory: Path) -> bool:
"""True when containers of the project were started from another folder and ``up`` takes them over."""
others = {path for path in context.docker.project_dirs(PROJECT) if path.resolve() != directory.resolve()}
if not others:
return False
if args.adopt:
return True
where = ", ".join(sorted(str(path) for path in others))
question = (
f"Docker already runs a DocsGPT stack started from {where}; it uses the same data volumes. "
f"Manage it from {directory} instead?"
)
if context.interactive and context.prompter.confirm(question, default=False):
return True
raise DeployError(
f"Docker already runs a DocsGPT stack started from {where}. Stop it with `docker compose down` "
f"in that folder, or run again with --adopt to manage it from {directory}."
)
def _choose_provider(args, context: Context, existing: Mapping[str, str], ask: bool):
name = args.provider
if name is None and ask:
name = context.prompter.choose("Which model provider?", list(stack.PROVIDERS.items()), _current_provider(existing))
if name is None:
return None
api_key = args.api_key or os.environ.get("DOCSGPT_API_KEY")
model, base_url = args.model, args.base_url
if context.interactive:
if name == "openai-compatible":
base_url = base_url or context.prompter.text(
"Server base URL (Ollama on this machine: http://host.docker.internal:11434/v1)"
)
model = model or context.prompter.text("Model name")
elif name != "docsgpt" and not api_key:
api_key = context.prompter.text(f"{stack.PROVIDERS[name]} API key", secret=True)
try:
return stack.provider_settings(name, api_key=api_key, model=model, base_url=base_url)
except ValueError as exc:
raise DeployError(str(exc)) from exc
def _redis_urls(base: str) -> dict[str, str]:
"""Celery's broker and result backend, and the cache, on three consecutive databases of one Redis.
They start at database 0, or at the one the URL names: ``redis://host:6379/5`` puts them on 5, 6
and 7, which is how one Redis is shared with something that already uses the first databases.
"""
trimmed = base.rstrip("/")
first = 0
head, _, tail = trimmed.rpartition("/")
if tail.isdigit():
trimmed, first = head, int(tail)
return {
"CELERY_BROKER_URL": f"{trimmed}/{first}",
"CELERY_RESULT_BACKEND": f"{trimmed}/{first + 1}",
"CACHE_REDIS_URL": f"{trimmed}/{first + 2}",
}
def _docsgpt_launcher() -> list[str]:
"""How to start DocsGPT from another process: the command on PATH, or this interpreter and the module.
A unit has to name something that can be executed, and ``sys.argv[0]`` often cannot be: under
``python -m docsgpt``, or pytest, it is a module file. Falling back to the running interpreter
works wherever the package is importable, which it must be to have got here.
"""
found = shutil.which("docsgpt")
if found:
# Absolute: PATH can hold relative entries, and a unit file needs a program it can exec.
return [str(Path(found).resolve())]
candidate = Path(sys.argv[0]).resolve()
if candidate.is_file() and os.access(candidate, os.X_OK):
return [str(candidate)]
if sys.executable:
return [sys.executable, "-m", "docsgpt"]
raise DeployError(
"could not work out how to start docsgpt for the services. "
"Install it with `uv tool install docsgpt` (or `pip install docsgpt`) and run this again."
)
def _native_port(env: Mapping[str, str]) -> str:
"""The port a native install listens on."""
return str(env.get("DOCSGPT_PORT") or stack.DEFAULT_PORT)
def _native_address(env: Mapping[str, str]) -> str:
"""Where a native install answers: its units bind 127.0.0.1, whatever DOCSGPT_BIND says."""
return f"http://localhost:{_native_port(env)}"
def _native_up(args, context: Context, directory: Path) -> int:
"""Run the API and the worker as services on this machine, against an existing Postgres and Redis."""
services = context.service_manager()
env_path = directory / ".env"
existing = envfile.read(env_path)
record = _record(directory)
configured = bool(record)
postgres = args.postgres_uri or existing.get("POSTGRES_URI")
redis = args.redis_url or ""
ask = context.interactive and (not configured or args.reconfigure)
if not postgres and ask:
postgres = context.prompter.text("PostgreSQL URL (postgresql://user:password@host:5432/docsgpt)")
if not redis and ask:
redis = context.prompter.text("Redis URL", default="redis://localhost:6379")
if not postgres:
raise DeployError(
"native mode needs a database: pass --postgres-uri postgresql://user:password@host:5432/docsgpt "
"(Redis defaults to redis://localhost:6379)."
)
# An install keeps the port it was given: a later `up` with no --port must not move it back to the default.
port = int(args.port or existing.get("DOCSGPT_PORT") or stack.DEFAULT_PORT)
updates: dict[str, Optional[str]] = {
"POSTGRES_URI": postgres,
"API_URL": f"http://127.0.0.1:{port}",
"DOCSGPT_PORT": str(port),
}
if redis or "CELERY_BROKER_URL" not in existing:
updates.update(_redis_urls(redis or "redis://localhost:6379"))
for key in ("INTERNAL_KEY", "JWT_SECRET_KEY"):
if not existing.get(key):
updates[key] = secrets.token_hex(32)
if "VITE_API_STREAMING" not in existing:
updates["VITE_API_STREAMING"] = "true"
provider = _choose_provider(args, context, existing, ask)
if provider:
updates.update(provider)
elif "LLM_PROVIDER" not in existing:
updates.update(stack.provider_settings("docsgpt"))
directory.mkdir(parents=True, exist_ok=True)
(directory / "logs").mkdir(exist_ok=True)
envfile.update(env_path, updates)
launcher = _docsgpt_launcher()
print("Applying database migrations ...")
# The child reads the stack's settings, not a .env in whatever directory this was run from.
stack_env = {"DOCSGPT_HOME": str(directory), "DOCSGPT_ENV_FILE": str(env_path)}
if context.run([*launcher, "migrate"], stack_env) != 0:
raise DeployError("`docsgpt migrate` failed; check the database URL and that the server is reachable.")
# The record goes in before the services: if one fails to start, this is still a native install
# that status, down and uninstall can see and clean up, rather than orphaned units.
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
record = record or {"installed_at": now}
record.update(version=context.version, mode="native", updated_at=now)
(directory / stack.RECORD_FILE).write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
for unit in native.units_for(directory, launcher, port, directory):
services.install(unit)
for name in native.SERVICES:
print(f"Starting {name} ...")
services.start(name)
health = f"http://127.0.0.1:{port}/api/health"
print("Waiting for DocsGPT to answer ...")
if not context.wait(health, args.timeout):
print(
f"DocsGPT did not answer at {health} within {args.timeout} seconds. "
"See what happened with `docsgpt logs`.",
file=sys.stderr,
)
return 1
print(f"\nDocsGPT is running at http://localhost:{port}")
print(f"Services: {', '.join(native.SERVICES)} under {services.name}")
print(f"Settings: {env_path}")
print("Manage it with: docsgpt status | logs | down | uninstall")
return 0
def up(args, context: Optional[Context] = None) -> int:
"""Install or update the stack in its directory and start it."""
context = context or Context.default(args)
directory = stack.stack_dir(args.dir)
if getattr(args, "native", False) or _mode(directory) == "native":
if _mode(directory) != "native" and (directory / stack.COMPOSE_FILE).is_file():
raise DeployError(
f"{directory} holds a Docker install. Native services would run beside its containers "
"on the same port, and down, status and uninstall would stop seeing them. Stop it "
"first with `docsgpt down` (and `docsgpt uninstall` to remove it), or pass --dir to "
"put the native install somewhere else."
)
return _native_up(args, context, directory)
env_path = directory / ".env"
record_path = directory / stack.RECORD_FILE
existing = envfile.read(env_path)
configured = record_path.is_file()
context.docker.preflight(context.interactive)
# Compose keeps containers whose configuration did not change, and with them the other
# folder's working-directory label; recreating them all makes the takeover complete.
recreate = ["--force-recreate"] if _check_other_stacks(args, context, directory) else []
ask = context.interactive and (not configured or args.reconfigure)
expose, domain = args.expose, args.domain
if ask and expose is None and domain is None:
expose = context.prompter.choose("Who should reach DocsGPT?", EXPOSURE_CHOICES, stack.exposure(existing))
if expose == "domain" and not domain:
if not context.interactive:
raise DeployError("--expose domain needs --domain")
domain = context.prompter.text("Domain name (its DNS must point at this machine)", existing.get("DOCSGPT_DOMAIN"))
provider = _choose_provider(args, context, existing, ask)
image_tag = args.image_tag or context.version
try:
updates = stack.plan(
existing,
image_tag=image_tag,
fresh_database=not context.docker.volume_exists(DATABASE_VOLUME),
expose=expose,
domain=domain,
port=args.port,
provider=provider,
docling=args.docling,
)
except ValueError as exc:
raise DeployError(str(exc)) from exc
directory.mkdir(parents=True, exist_ok=True)
shutil.copyfile(stack.compose_source(), directory / stack.COMPOSE_FILE)
envfile.update(env_path, updates)
env = envfile.read(env_path)
if stack.exposure(existing) == "domain" and stack.exposure(env) != "domain":
# With the https profile off, `up --remove-orphans` would leave Caddy running on ports 80 and 443.
context.docker.compose(directory, *_EVERY_PROFILE, "rm", "--stop", "--force", "caddy", check=False)
if stack.exposure(env) == "network":
print(
"DocsGPT will listen on every interface over plain HTTP: its access token travels as "
"readable text. Use `docsgpt up --domain <name>` for HTTPS outside a trusted network.",
file=sys.stderr,
)
up_args = ["up", "-d", "--remove-orphans", *recreate]
if image_tag in _MOVING_TAGS:
up_args += ["--pull", "always"]
print(f"Starting DocsGPT {image_tag} from {directory} ...")
context.docker.compose(directory, *up_args)
health = stack.health_url(env)
print("Waiting for DocsGPT to answer (the first start also sets up the database) ...")
if not context.wait(health, args.timeout):
print(
f"DocsGPT did not answer at {health} within {args.timeout} seconds. "
"See what happened with `docsgpt logs backend`.",
file=sys.stderr,
)
return 1
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
record = json.loads(record_path.read_text(encoding="utf-8")) if configured else {"installed_at": now}
record.update(version=context.version, image_tag=image_tag, updated_at=now)
record_path.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
address = stack.url(env, context.lan_ip())
mode = stack.exposure(env)
print(f"\nDocsGPT is running at {address}")
if env.get("AUTH_TYPE") == "simple_jwt" and env.get("JWT_SECRET_KEY"):
print("Access token (the page asks for it; `docsgpt token` prints it again):")
print(f" {stack.simple_jwt_token(env['JWT_SECRET_KEY'])}")
if mode == "domain":
print("Caddy gets the certificate when it starts: DNS must point at this machine and ports 80 and 443 be open.")
print(f"Settings: {env_path} (change the model provider or access with `docsgpt up --reconfigure`)")
print("Manage it with: docsgpt status | logs | upgrade | down | uninstall")
if context.interactive and not configured and not args.no_open and mode == "local":
context.open_browser(address)
return 0
def down(args, context: Optional[Context] = None) -> int:
"""Stop the stack; data and settings stay."""
context = context or Context.default(args)
directory = stack.stack_dir(args.dir)
if _installed(directory) is None:
return 1
if _mode(directory) == "native":
services = context.service_manager()
# The worker goes first: it talks to the API, not the other way round.
for name in reversed(native.SERVICES):
services.stop(name)
return 0
context.docker.compose(directory, *_EVERY_PROFILE, "down")
return 0
def status(args, context: Optional[Context] = None) -> int:
"""Version, address, containers and whether the API answers (exit 1 when it does not)."""
context = context or Context.default(args)
directory = stack.stack_dir(args.dir)
env = _installed(directory)
if env is None:
return 1
if _mode(directory) == "native":
services = context.service_manager()
# Against a LAN bind, stack.url and stack.health_url would advertise and poll an address
# nothing listens on, and status would call a healthy install dead.
port = _native_port(env)
print(f"DocsGPT {_record(directory).get('version', 'unknown')} in {directory} (native, {services.name})")
print(f"Address: {_native_address(env)}")
for name in native.SERVICES:
print(f" {name}: {'running' if services.is_running(name) else 'stopped'}")
healthy = context.wait(f"http://127.0.0.1:{port}/api/health", 0)
print("API: answering" if healthy else "API: not answering (see `docsgpt logs`)")
return 0 if healthy else 1
tag = env.get("DOCSGPT_IMAGE_TAG", "unknown") + env.get("DOCSGPT_IMAGE_VARIANT", "")
print(f"DocsGPT {tag} in {directory}")
print(f"Address: {stack.url(env, context.lan_ip())} ({stack.exposure(env)})")
context.docker.compose(directory, "ps", check=False)
healthy = context.wait(stack.health_url(env), 0)
print("API: answering" if healthy else "API: not answering (see `docsgpt logs backend`)")
return 0 if healthy else 1
def logs(args, context: Optional[Context] = None) -> int:
"""``docker compose logs`` for the stack."""
context = context or Context.default(args)
directory = stack.stack_dir(args.dir)
if _installed(directory) is None:
return 1
if _mode(directory) == "native":
logs_dir = directory / "logs"
wanted = args.services or [name.removeprefix("docsgpt-") for name in native.SERVICES]
for service in wanted:
path = logs_dir / f"{service}.log"
print(f"=== {path}")
if path.is_file():
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
print("\n".join(lines[-args.tail:] if args.tail else lines))
else:
print("(nothing logged yet)")
if args.follow:
print("Following is not supported in native mode; use `tail -f` on the files above.", file=sys.stderr)
return 0
options = (["--follow"] if args.follow else []) + (["--tail", str(args.tail)] if args.tail else [])
return context.docker.compose(directory, "logs", *options, *args.services, check=False).returncode
def token(args, context: Optional[Context] = None) -> int:
"""Print the access token of a ``simple_jwt`` install."""
directory = stack.stack_dir(args.dir)
env = _installed(directory)
if env is None:
return 1
if env.get("AUTH_TYPE") != "simple_jwt" or not env.get("JWT_SECRET_KEY"):
print(f"This install has no access token (AUTH_TYPE={env.get('AUTH_TYPE') or 'none'}).", file=sys.stderr)
return 1
print(stack.simple_jwt_token(env["JWT_SECRET_KEY"]))
return 0
def open_ui(args, context: Optional[Context] = None) -> int:
"""Open DocsGPT in the browser."""
context = context or Context.default(args)
directory = stack.stack_dir(args.dir)
env = _installed(directory)
if env is None:
return 1
# A native install answers on loopback only, so stack.url would hand the browser a LAN address
# or a domain that nothing behind this command is serving.
native_install = _mode(directory) == "native"
address = _native_address(env) if native_install else stack.url(env, context.lan_ip())
print(address)
context.open_browser(address)
return 0
def env(args, context: Optional[Context] = None) -> int:
"""Show where the settings are, or get and set them."""
env_path = stack.stack_dir(args.dir) / ".env"
if args.env_action is None:
print(env_path)
return 0
if args.env_action == "get":
values = envfile.read(env_path)
if args.key not in values:
print(f"{args.key} is not set in {env_path}", file=sys.stderr)
return 1
print(values[args.key])
return 0
updates = {}
for pair in args.pairs:
key, separator, value = pair.partition("=")
if not separator or not _KEY.match(key):
raise DeployError(f"expected KEY=VALUE, got {pair!r}")
updates[key] = value
try:
envfile.update(env_path, updates)
except ValueError as exc:
raise DeployError(str(exc)) from exc
print(f"Saved to {env_path}. Run `docsgpt up` to apply.")
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
if _mode(directory) == "native":
raise DeployError(
f"{directory} is a native install: its database and its files are not in Docker volumes, so there is nothing here to archive. Back up the PostgreSQL that POSTGRES_URI points at with pg_dump, and copy the indexes, inputs and vectors folders from the data home."
)
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)
try:
print("Pausing the backend and the worker so the database and the files match ...")
context.docker.compose(directory, "stop", "backend", "worker")
_write_backup(args, context, directory, env, target, image, taken_at)
finally:
print("Starting the backend and the worker again ...")
context.docker.compose(directory, "up", "-d", "backend", "worker")
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 _write_backup(args, context: Context, directory: Path, env, target: Path, image: str, taken_at) -> None:
"""Dump the database and the data volumes into ``target``, with the writers stopped."""
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)
def _restore_data(context: Context, directory: Path, archive: Path, volumes: list, image: str) -> None:
"""Put the archive's volumes and database back, with the stack stopped."""
with tempfile.TemporaryDirectory() as workspace:
work = Path(workspace)
backup_format.extract(archive, work)
# Read every volume tar through before replacing any of them: a damaged third tar must not
# be discovered with the first two already swapped in.
tars = {}
for name in 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")
backup_format.check_volume_tar(name, tar_path)
tars[name] = tar_path
for name, tar_path in tars.items():
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", "--set", "ON_ERROR_STOP=on", "-U", "docsgpt", "-d", "docsgpt",
stdin=handle,
)
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)
directory = stack.stack_dir(args.dir)
if _mode(directory) == "native":
raise DeployError(
f"{directory} is a native install: its database and its files are not in Docker volumes, so there is nothing here to archive. Back up the PostgreSQL that POSTGRES_URI points at with pg_dump, and copy the indexes, inputs and vectors folders from the data home. `docsgpt restore` puts back what `docsgpt backup` wrote for a Docker install."
)
archive = Path(args.archive).expanduser()
manifest = backup_format.read_manifest(archive)
backup_format.check_version(manifest, context.version, args.force)
# Everything the archive declares is checked here, while DocsGPT is still up.
volumes = backup_format.validate(archive, manifest)
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)
try:
print("Stopping the stack ...")
context.docker.compose(directory, *_EVERY_PROFILE, "down")
_restore_data(context, directory, archive, volumes, image)
except BaseException:
# The stack is down by now. A corrupt payload inside an otherwise well-formed archive, or a
# statement psql refuses, must not leave DocsGPT stopped: start it again, then report.
print("The restore failed. Starting DocsGPT again ...", file=sys.stderr)
context.docker.compose(directory, "up", "-d", "--remove-orphans", check=False)
raise
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")
def upgrade(args, context: Optional[Context] = None) -> int:
"""Upgrade the package, then run the new version's ``docsgpt up``."""
context = context or Context.default(args)
spec = f"docsgpt=={args.version}" if args.version else "docsgpt"
installer = context.installer()
if installer == "uv":
if context.run(["uv", "tool", "install", "--force", spec]) != 0:
raise DeployError(f"uv could not install {spec}")
# The same launcher the service units get: a bare name is not always on PATH to exec.
return context.exec_up([*_docsgpt_launcher(), "up", "--dir", str(stack.stack_dir(args.dir))])
command = f"pipx install --force {spec}" if installer == "pipx" else f"pip install -U {spec}"
print(f"Upgrade the package with `{command}`, then run `docsgpt up` to move the stack to it.", file=sys.stderr)
return 1
def uninstall(args, context: Optional[Context] = None) -> int:
"""Remove the containers and the stack files; ``--purge`` also deletes settings and data."""
context = context or Context.default(args)
directory = stack.stack_dir(args.dir)
if _installed(directory) is None:
return 1
if args.purge:
what = "containers, settings and data (documents, conversations, the database)"
else:
what = "containers (the settings in .env and the data volumes are kept)"
if not args.yes:
if not context.interactive:
raise DeployError("uninstall needs --yes when there is no terminal to confirm on")
if not context.prompter.confirm(f"Remove the DocsGPT {what} in {directory}?", default=False):
print("Nothing removed.")
return 1
if _mode(directory) == "native":
services = context.service_manager()
for name in reversed(native.SERVICES):
services.remove(name)
print(f"Removed the {', '.join(native.SERVICES)} services.")
if args.purge:
shutil.rmtree(directory)
print(f"Removed {directory}. The database and Redis it used are untouched.")
else:
(directory / stack.RECORD_FILE).unlink(missing_ok=True)
print(f"Settings stay in {directory / '.env'}; the database and Redis are untouched.")
print(f"To remove the docsgpt command too: {_uninstall_hint(context.installer())}")
return 0
context.docker.compose(directory, *_EVERY_PROFILE, "down", "--remove-orphans", *(["-v"] if args.purge else []))
if args.purge:
shutil.rmtree(directory)
print(f"Removed DocsGPT and its data from {directory}.")
else:
for name in (stack.COMPOSE_FILE, stack.RECORD_FILE):
(directory / name).unlink(missing_ok=True)
print(f"Removed the containers. Settings stay in {directory / '.env'} and data in the Docker volumes.")
print(f"To remove the docsgpt command too: {_uninstall_hint(context.installer())}")
return 0