mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 18:13:03 +00:00
feat: docsgpt up runs and manages DocsGPT on Docker
`docsgpt up` copies the standalone Compose file shipped with this package version into the stack directory (~/.docsgpt/server by default), writes its .env and starts the stack on the images of the same version. A first run asks who should reach DocsGPT (this computer, the network with a token, or a domain with HTTPS) and which model provider to use; flags answer the same questions for scripts. Re-running keeps secrets and settings and moves the image tag, and the database password is only generated for a new database. Also: down, status, logs, token, open, env, upgrade (uv tool installs upgrade themselves and run `up` again) and uninstall (keeps settings and data unless --purge). The commands import no Flask, Celery or settings. The wheel carries deployment/docker-compose-standalone.yaml as docsgpt/deploy/docker-compose.yaml; the sdist includes the source file.
This commit is contained in:
1 parent
ac06527a8d
commit
aa0e4ea280
13 files changed
+1567
-3
No files matched your search
@@ -65,6 +65,8 @@ jobs:
|
||||
names = set(zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist())
|
||||
for required in (
|
||||
"docsgpt/cli.py",
|
||||
"docsgpt/deploy/commands.py",
|
||||
"docsgpt/deploy/docker-compose.yaml",
|
||||
"docsgpt/alembic.ini",
|
||||
"docsgpt/alembic/env.py",
|
||||
"docsgpt/alembic/script.py.mako",
|
||||
|
||||
+84
-2
@@ -1,4 +1,5 @@
|
||||
"""The ``docsgpt`` command: run the API, the worker and the maintenance scripts.
|
||||
"""The ``docsgpt`` command: run the API, the worker and the maintenance scripts,
|
||||
or run and manage DocsGPT on Docker (``docsgpt up``).
|
||||
|
||||
Every subcommand imports what it needs when it runs, so ``docsgpt --help``
|
||||
stays instant and does not touch the database.
|
||||
@@ -153,6 +154,17 @@ def _migrate(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _deploy(name: str):
|
||||
"""A subcommand handler that imports ``docsgpt.deploy.commands`` only when it runs."""
|
||||
|
||||
def handler(args: argparse.Namespace, context=None) -> int:
|
||||
from docsgpt.deploy import commands
|
||||
|
||||
return getattr(commands, name)(args, context)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
# Maintenance scripts keep their own argument parsers; the command hands
|
||||
# everything after the script name to them untouched (argparse would try to
|
||||
# interpret the options itself).
|
||||
@@ -169,11 +181,70 @@ def _run_script(module: str, argv: list[str]) -> int:
|
||||
return int(importlib.import_module(f"docsgpt.scripts.{module}").main(argv) or 0)
|
||||
|
||||
|
||||
def _add_deploy_commands(commands) -> None:
|
||||
"""``docsgpt up`` and the commands that manage the Docker stack it runs."""
|
||||
from docsgpt.deploy.stack import EXPOSURES, PROVIDERS
|
||||
|
||||
def stack_command(name: str, handler: str, help_text: str) -> argparse.ArgumentParser:
|
||||
parser = commands.add_parser(name, help=help_text)
|
||||
parser.add_argument("--dir", help="stack directory (default: DOCSGPT_HOME, else ~/.docsgpt/server)")
|
||||
parser.set_defaults(func=_deploy(handler), deploy=True)
|
||||
return parser
|
||||
|
||||
up = stack_command("up", "up", "install or update DocsGPT on Docker and start it")
|
||||
up.add_argument("--expose", choices=EXPOSURES, help="who can reach it: local (default), network or domain")
|
||||
up.add_argument("--domain", help="public domain served over HTTPS by Caddy (implies --expose domain)")
|
||||
up.add_argument("--port", type=int, help="host port for the UI and API (default: 7091)")
|
||||
up.add_argument("--provider", choices=list(PROVIDERS), help="model provider (default: the DocsGPT public API)")
|
||||
up.add_argument("--api-key", help="the provider's API key (or set DOCSGPT_API_KEY)")
|
||||
up.add_argument("--model", help="model name (required for openai-compatible)")
|
||||
up.add_argument("--base-url", help="base URL of an OpenAI-compatible server")
|
||||
docling = up.add_mutually_exclusive_group()
|
||||
docling.add_argument("--docling", dest="docling", action="store_const", const=True,
|
||||
help="run the image with the docling parser engine and OCR (several GB larger)")
|
||||
docling.add_argument("--no-docling", dest="docling", action="store_const", const=False,
|
||||
help="go back to the default image")
|
||||
up.set_defaults(docling=None)
|
||||
up.add_argument("--image-tag", help="image tag to run instead of this package's version, e.g. develop")
|
||||
up.add_argument("-y", "--yes", action="store_true", help="ask nothing: use the flags, then the defaults")
|
||||
up.add_argument("--reconfigure", action="store_true", help="ask the setup questions again")
|
||||
up.add_argument("--adopt", action="store_true", help="take over a DocsGPT stack started from another folder")
|
||||
up.add_argument("--no-open", action="store_true", help="do not open the browser after the first install")
|
||||
up.add_argument("--timeout", type=int, default=300, help="seconds to wait for the API to answer (default: 300)")
|
||||
|
||||
stack_command("down", "down", "stop the Docker stack (data and settings stay)")
|
||||
stack_command("status", "status", "show the stack's version, address, containers and health")
|
||||
|
||||
logs = stack_command("logs", "logs", "show the stack's logs")
|
||||
logs.add_argument("-f", "--follow", action="store_true", help="keep printing new lines")
|
||||
logs.add_argument("--tail", type=int, help="only the last N lines of each service")
|
||||
logs.add_argument("services", nargs="*", help="services to show, e.g. backend worker")
|
||||
|
||||
stack_command("token", "token", "print the access token (installs reachable beyond this computer)")
|
||||
stack_command("open", "open_ui", "open DocsGPT in the browser")
|
||||
|
||||
upgrade = stack_command("upgrade", "upgrade", "upgrade the package and restart the stack on the new version")
|
||||
upgrade.add_argument("--version", help="version to install (default: the latest release)")
|
||||
|
||||
uninstall = stack_command("uninstall", "uninstall", "remove the Docker stack")
|
||||
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")
|
||||
|
||||
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")
|
||||
get.add_argument("key")
|
||||
set_ = env_actions.add_parser("set", help="set settings (KEY=VALUE ...); run `docsgpt up` to apply")
|
||||
set_.add_argument("pairs", nargs="+", metavar="KEY=VALUE")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="docsgpt", description="DocsGPT: private AI for agents, assistants and search.")
|
||||
parser.add_argument("--version", action="version", version=f"docsgpt {__version__}")
|
||||
commands = parser.add_subparsers(dest="command", metavar="<command>")
|
||||
|
||||
_add_deploy_commands(commands)
|
||||
|
||||
api = commands.add_parser("api", help="serve the HTTP API")
|
||||
api.add_argument("--host", default=DEFAULT_HOST, help="interface to listen on (default: localhost; 0.0.0.0 for all)")
|
||||
api.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
@@ -212,7 +283,18 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return 2
|
||||
return args.func(args)
|
||||
if not getattr(args, "deploy", False):
|
||||
return args.func(args)
|
||||
from docsgpt.deploy.docker import DeployError
|
||||
|
||||
try:
|
||||
return args.func(args)
|
||||
except DeployError as exc:
|
||||
print(f"docsgpt: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
print(file=sys.stderr)
|
||||
return 130
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Run DocsGPT on Docker from the installed package: ``docsgpt up`` and the commands that manage it.
|
||||
|
||||
Nothing here imports the Flask app, Celery or the settings module, so these
|
||||
commands start instantly and work before any configuration exists.
|
||||
"""
|
||||
@@ -0,0 +1,381 @@
|
||||
"""``docsgpt up`` and the commands that manage the stack it starts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
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 envfile, 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_]*$")
|
||||
|
||||
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]) -> int:
|
||||
try:
|
||||
return subprocess.call(args)
|
||||
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[[list[str]], int] = _run_command
|
||||
exec_up: Callable[[list[str]], int] = _exec_command
|
||||
|
||||
@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 _installed(directory: Path) -> Optional[dict[str, str]]:
|
||||
"""The stack's settings, or None (with a message) when there is no install in ``directory``."""
|
||||
if not (directory / stack.COMPOSE_FILE).is_file():
|
||||
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) -> None:
|
||||
others = {path for path in context.docker.project_dirs(PROJECT) if path.resolve() != directory.resolve()}
|
||||
if not others or args.adopt:
|
||||
return
|
||||
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
|
||||
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 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)
|
||||
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)
|
||||
_check_other_stacks(args, context, directory)
|
||||
|
||||
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)
|
||||
|
||||
up_args = ["up", "-d", "--remove-orphans"]
|
||||
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 == "network":
|
||||
print("Traffic is plain HTTP. Outside a trusted network, use a domain with HTTPS: docsgpt up --domain <name>")
|
||||
elif 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
|
||||
context.docker.compose(directory, "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
|
||||
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
|
||||
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
|
||||
address = 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 _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}")
|
||||
return context.exec_up(["docsgpt", "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
|
||||
context.docker.compose(directory, "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
|
||||
@@ -0,0 +1,143 @@
|
||||
"""The docker CLI calls behind ``docsgpt up``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
MIN_COMPOSE = (2, 24, 0)
|
||||
DAEMON_START_SECONDS = 120
|
||||
|
||||
|
||||
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."""
|
||||
try:
|
||||
result = subprocess.run(list(args), cwd=cwd, text=True, capture_output=capture, check=False)
|
||||
except FileNotFoundError as exc:
|
||||
raise DeployError(f"{args[0]} is not installed or not on PATH") from exc
|
||||
if check and result.returncode != 0:
|
||||
detail = (result.stderr or "").strip() if capture else ""
|
||||
message = f"`{' '.join(args)}` failed with exit code {result.returncode}"
|
||||
raise DeployError(f"{message}: {detail}" if detail else message)
|
||||
return result
|
||||
|
||||
|
||||
def _parse_version(text: str) -> Optional[tuple[int, ...]]:
|
||||
match = re.search(r"(\d+)\.(\d+)\.(\d+)", text or "")
|
||||
return tuple(int(part) for part in match.groups()) if match else None
|
||||
|
||||
|
||||
class Docker:
|
||||
"""Docker and Docker Compose, through their command-line tools."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runner: Callable[..., subprocess.CompletedProcess] = run,
|
||||
which: Callable[[str], Optional[str]] = shutil.which,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
platform: str = sys.platform,
|
||||
) -> None:
|
||||
self._run = runner
|
||||
self._which = which
|
||||
self._sleep = sleep
|
||||
self._platform = platform
|
||||
|
||||
def preflight(self, interactive: bool = False) -> None:
|
||||
"""Make sure Docker is installed and running and Compose is new enough, starting Docker Desktop on macOS."""
|
||||
if not self._which("docker"):
|
||||
raise DeployError("Docker is not installed. Get it from https://docs.docker.com/get-docker/ and run this again.")
|
||||
if not self.daemon_running():
|
||||
self._start_daemon()
|
||||
result = self._run(["docker", "compose", "version", "--short"], capture=True, check=False)
|
||||
version = _parse_version(result.stdout) if result.returncode == 0 else None
|
||||
if version is None:
|
||||
raise DeployError(
|
||||
"Docker Compose v2 is not available (`docker compose version` failed). "
|
||||
"Install the Compose plugin: https://docs.docker.com/compose/install/"
|
||||
)
|
||||
if version < MIN_COMPOSE:
|
||||
found = ".".join(str(part) for part in version)
|
||||
raise DeployError(f"Docker Compose {found} is too old; DocsGPT needs 2.24 or newer.")
|
||||
|
||||
def daemon_running(self) -> bool:
|
||||
return self._run(["docker", "info"], capture=True, check=False).returncode == 0
|
||||
|
||||
def _start_daemon(self) -> None:
|
||||
if self._platform == "darwin":
|
||||
print("Docker is not running; starting Docker Desktop ...", file=sys.stderr)
|
||||
self._run(["open", "-a", "Docker"], check=False)
|
||||
for _ in range(DAEMON_START_SECONDS // 2):
|
||||
self._sleep(2)
|
||||
if self.daemon_running():
|
||||
return
|
||||
raise DeployError("Docker Desktop did not start within two minutes. Start it and run this again.")
|
||||
if self._platform.startswith("linux"):
|
||||
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):
|
||||
"""``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)
|
||||
|
||||
def volume_exists(self, name: str) -> bool:
|
||||
return self._run(["docker", "volume", "inspect", name], capture=True, check=False).returncode == 0
|
||||
|
||||
def project_dirs(self, project: str) -> set[Path]:
|
||||
"""The folders containers of Compose project ``project`` were started from."""
|
||||
result = self._run(
|
||||
[
|
||||
"docker", "ps", "-a",
|
||||
"--filter", f"label=com.docker.compose.project={project}",
|
||||
"--format", '{{.Label "com.docker.compose.project.working_dir"}}',
|
||||
],
|
||||
capture=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return set()
|
||||
return {Path(line.strip()) for line in result.stdout.splitlines() if line.strip()}
|
||||
|
||||
|
||||
def wait_healthy(
|
||||
url: str,
|
||||
timeout: float,
|
||||
*,
|
||||
opener: Callable = urllib.request.urlopen,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> bool:
|
||||
"""Poll ``url`` until it answers 2xx (True) or ``timeout`` seconds pass (False); tries at least once."""
|
||||
deadline = clock() + timeout
|
||||
while True:
|
||||
try:
|
||||
with opener(url, timeout=5) as response:
|
||||
if 200 <= response.status < 300:
|
||||
return True
|
||||
except (OSError, http.client.HTTPException):
|
||||
pass
|
||||
if clock() >= deadline:
|
||||
return False
|
||||
sleep(2)
|
||||
|
||||
|
||||
def lan_ip() -> str:
|
||||
"""This machine's address on its network, or ``localhost``. No packet is sent."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
|
||||
try:
|
||||
probe.connect(("192.0.2.1", 80))
|
||||
return probe.getsockname()[0]
|
||||
except OSError:
|
||||
return "localhost"
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Read and update a ``.env`` file in place, keeping the lines the user wrote."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_ASSIGNMENT = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$")
|
||||
# Values written without quotes: nothing Compose would interpolate, strip or treat as a comment.
|
||||
_PLAIN = re.compile(r"^[A-Za-z0-9_./:@+,=-]*$")
|
||||
|
||||
|
||||
def _parse_value(raw: str) -> str:
|
||||
"""The value of one assignment, with Compose's quoting rules."""
|
||||
value = raw.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] == "'":
|
||||
return value[1:-1]
|
||||
if len(value) >= 2 and value[0] == value[-1] == '"':
|
||||
return re.sub(r'\\(["\\])', r"\1", value[1:-1])
|
||||
return value.split(" #", 1)[0].rstrip()
|
||||
|
||||
|
||||
def _format_value(value: str) -> str:
|
||||
"""``value`` quoted so it reads back unchanged."""
|
||||
if "\n" in value or "\r" in value:
|
||||
raise ValueError("a .env value cannot contain a newline")
|
||||
if _PLAIN.match(value):
|
||||
return value
|
||||
if "'" not in value:
|
||||
return f"'{value}'"
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def read(path: Path) -> dict[str, str]:
|
||||
"""The assignments in ``path`` (empty when it does not exist); a repeated key keeps its last value."""
|
||||
path = Path(path)
|
||||
if not path.is_file():
|
||||
return {}
|
||||
values: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
match = _ASSIGNMENT.match(line)
|
||||
if match:
|
||||
values[match.group(1)] = _parse_value(match.group(2))
|
||||
return values
|
||||
|
||||
|
||||
def update(path: Path, values: Mapping[str, Optional[str]]) -> None:
|
||||
"""Set each key in place (``None`` removes it), append new keys, and leave every other line alone.
|
||||
|
||||
A new file is created readable by its owner only: it holds secrets.
|
||||
"""
|
||||
formatted = {key: None if value is None else _format_value(value) for key, value in values.items()}
|
||||
path = Path(path)
|
||||
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
|
||||
written: set[str] = set()
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
match = _ASSIGNMENT.match(line)
|
||||
key = match.group(1) if match else None
|
||||
if key not in formatted:
|
||||
out.append(line)
|
||||
continue
|
||||
if key not in written and formatted[key] is not None:
|
||||
out.append(f"{key}={formatted[key]}")
|
||||
written.add(key)
|
||||
out.extend(f"{key}={value}" for key, value in formatted.items() if key not in written and value is not None)
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(out) + "\n" if out else "")
|
||||
@@ -0,0 +1,184 @@
|
||||
"""The stack ``docsgpt up`` runs: where it lives and what its ``.env`` holds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from docsgpt.core import paths
|
||||
|
||||
COMPOSE_FILE = "docker-compose.yaml"
|
||||
RECORD_FILE = "install.json"
|
||||
DEFAULT_PORT = 7091
|
||||
EXPOSURES = ("local", "network", "domain")
|
||||
|
||||
PROVIDERS = {
|
||||
"docsgpt": "DocsGPT public API (free, no key)",
|
||||
"openai": "OpenAI",
|
||||
"anthropic": "Anthropic",
|
||||
"google": "Google Gemini",
|
||||
"openrouter": "OpenRouter",
|
||||
"groq": "Groq",
|
||||
"openai-compatible": "OpenAI-compatible server (Ollama, vLLM, LM Studio, ...)",
|
||||
}
|
||||
|
||||
_LOCAL_BINDS = ("", "127.0.0.1", "localhost", "::1")
|
||||
_ALL_INTERFACES = ("0.0.0.0", "::")
|
||||
|
||||
|
||||
def compose_source() -> Path:
|
||||
"""The Compose file for this version: shipped in the wheel, or ``deployment/`` in a checkout."""
|
||||
packaged = paths.package_dir() / "deploy" / COMPOSE_FILE
|
||||
if packaged.is_file():
|
||||
return packaged
|
||||
root = paths.checkout_root()
|
||||
if root is not None:
|
||||
in_checkout = root / "deployment" / "docker-compose-standalone.yaml"
|
||||
if in_checkout.is_file():
|
||||
return in_checkout
|
||||
raise FileNotFoundError("the Compose file is missing from this docsgpt installation; reinstall the package")
|
||||
|
||||
|
||||
def stack_dir(explicit: Optional[str]) -> Path:
|
||||
"""Where the stack lives: ``--dir``, else ``DOCSGPT_HOME``, else the default home (never a checkout)."""
|
||||
if explicit:
|
||||
return Path(explicit).expanduser().resolve()
|
||||
configured = os.environ.get(paths.HOME_ENV)
|
||||
if configured:
|
||||
return Path(configured).expanduser().resolve()
|
||||
return paths.default_home()
|
||||
|
||||
|
||||
def _profiles(env: Mapping[str, str]) -> set[str]:
|
||||
return {name.strip() for name in env.get("COMPOSE_PROFILES", "").split(",") if name.strip()}
|
||||
|
||||
|
||||
def exposure(env: Mapping[str, str]) -> str:
|
||||
"""Who can reach the stack, read back from its ``.env``: local, network or domain."""
|
||||
if "https" in _profiles(env) and env.get("DOCSGPT_DOMAIN"):
|
||||
return "domain"
|
||||
if env.get("DOCSGPT_BIND", "") not in _LOCAL_BINDS:
|
||||
return "network"
|
||||
return "local"
|
||||
|
||||
|
||||
def provider_settings(
|
||||
name: str,
|
||||
api_key: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> dict[str, Optional[str]]:
|
||||
"""The model settings for a provider choice; keys another provider used are cleared."""
|
||||
if name not in PROVIDERS:
|
||||
raise ValueError(f"unknown provider {name!r}; choose one of: {', '.join(PROVIDERS)}")
|
||||
if name == "docsgpt":
|
||||
return {"LLM_PROVIDER": "docsgpt", "API_KEY": None, "LLM_NAME": None, "OPENAI_BASE_URL": None}
|
||||
if name == "openai-compatible":
|
||||
if not base_url:
|
||||
raise ValueError("an OpenAI-compatible server needs a base URL, e.g. http://host.docker.internal:11434/v1")
|
||||
if not model:
|
||||
raise ValueError("an OpenAI-compatible server needs a model name")
|
||||
return {
|
||||
"LLM_PROVIDER": "openai",
|
||||
"API_KEY": api_key or "not-needed",
|
||||
"LLM_NAME": model,
|
||||
"OPENAI_BASE_URL": base_url,
|
||||
}
|
||||
if not api_key:
|
||||
raise ValueError(f"{PROVIDERS[name]} needs an API key")
|
||||
# Without LLM_NAME the model catalog picks the provider's first model.
|
||||
return {"LLM_PROVIDER": name, "API_KEY": api_key, "LLM_NAME": model or None, "OPENAI_BASE_URL": None}
|
||||
|
||||
|
||||
def plan(
|
||||
existing: Mapping[str, str],
|
||||
*,
|
||||
image_tag: str,
|
||||
fresh_database: bool,
|
||||
expose: Optional[str] = None,
|
||||
domain: Optional[str] = None,
|
||||
port: Optional[int] = None,
|
||||
provider: Optional[Mapping[str, Optional[str]]] = None,
|
||||
docling: Optional[bool] = None,
|
||||
secret: Optional[Callable[[], str]] = None,
|
||||
) -> dict[str, Optional[str]]:
|
||||
"""The ``.env`` changes for an ``up``: only keys that change, ``None`` for a key to remove.
|
||||
|
||||
Settings the user did not ask to change are left alone, secrets are generated
|
||||
once, and the database password is only set for a database that does not exist
|
||||
yet (Postgres reads it when the volume is created).
|
||||
"""
|
||||
secret = secret or (lambda: secrets.token_hex(32))
|
||||
wanted: dict[str, Optional[str]] = {"DOCSGPT_IMAGE_TAG": image_tag}
|
||||
|
||||
if domain and expose is None:
|
||||
expose = "domain"
|
||||
if expose is None and "DOCSGPT_BIND" not in existing and "COMPOSE_PROFILES" not in existing:
|
||||
expose = "local"
|
||||
if expose == "local":
|
||||
wanted.update(DOCSGPT_BIND="127.0.0.1", COMPOSE_PROFILES=None, DOCSGPT_DOMAIN=None)
|
||||
elif expose == "network":
|
||||
wanted.update(DOCSGPT_BIND="0.0.0.0", COMPOSE_PROFILES=None, DOCSGPT_DOMAIN=None)
|
||||
elif expose == "domain":
|
||||
if not domain:
|
||||
raise ValueError("exposing DocsGPT on a domain needs the domain name")
|
||||
wanted.update(DOCSGPT_BIND="127.0.0.1", COMPOSE_PROFILES="https", DOCSGPT_DOMAIN=domain)
|
||||
elif expose is not None:
|
||||
raise ValueError(f"unknown exposure {expose!r}; choose one of: {', '.join(EXPOSURES)}")
|
||||
if expose in ("network", "domain") and not existing.get("AUTH_TYPE"):
|
||||
wanted["AUTH_TYPE"] = "simple_jwt"
|
||||
|
||||
for key in ("INTERNAL_KEY", "JWT_SECRET_KEY"):
|
||||
if not existing.get(key):
|
||||
wanted[key] = secret()
|
||||
if not existing.get("POSTGRES_PASSWORD") and fresh_database:
|
||||
wanted["POSTGRES_PASSWORD"] = secret()
|
||||
if "VITE_API_STREAMING" not in existing:
|
||||
wanted["VITE_API_STREAMING"] = "true"
|
||||
if port is not None:
|
||||
wanted["DOCSGPT_PORT"] = str(port)
|
||||
if docling is not None:
|
||||
wanted["DOCSGPT_IMAGE_VARIANT"] = "-docling" if docling else None
|
||||
if provider is None and "LLM_PROVIDER" not in existing:
|
||||
provider = provider_settings("docsgpt")
|
||||
if provider:
|
||||
wanted.update(provider)
|
||||
|
||||
return {
|
||||
key: value
|
||||
for key, value in wanted.items()
|
||||
if (value is None and key in existing) or (value is not None and existing.get(key) != value)
|
||||
}
|
||||
|
||||
|
||||
def _port(env: Mapping[str, str]) -> str:
|
||||
return env.get("DOCSGPT_PORT") or str(DEFAULT_PORT)
|
||||
|
||||
|
||||
def url(env: Mapping[str, str], lan_ip: str) -> str:
|
||||
"""The address to open DocsGPT at."""
|
||||
mode = exposure(env)
|
||||
if mode == "domain":
|
||||
return f"https://{env['DOCSGPT_DOMAIN']}"
|
||||
if mode == "network":
|
||||
bind = env.get("DOCSGPT_BIND", "")
|
||||
host = lan_ip if bind in _ALL_INTERFACES else bind
|
||||
return f"http://{host}:{_port(env)}"
|
||||
return f"http://localhost:{_port(env)}"
|
||||
|
||||
|
||||
def health_url(env: Mapping[str, str]) -> str:
|
||||
"""The API health check, reached from this machine whatever the exposure."""
|
||||
bind = env.get("DOCSGPT_BIND", "")
|
||||
host = "127.0.0.1" if bind in _LOCAL_BINDS or bind in _ALL_INTERFACES else bind
|
||||
return f"http://{host}:{_port(env)}/api/health"
|
||||
|
||||
|
||||
def simple_jwt_token(secret_key: str) -> str:
|
||||
"""The token the API accepts under ``AUTH_TYPE=simple_jwt`` (it signs the same payload at start)."""
|
||||
from jose import jwt
|
||||
|
||||
return jwt.encode({"sub": "local"}, secret_key, algorithm="HS256")
|
||||
+7
-1
@@ -194,8 +194,14 @@ exclude = [
|
||||
"docsgpt/vectors/",
|
||||
]
|
||||
|
||||
# `docsgpt up` runs the standalone Compose file of its own version. The file
|
||||
# stays in deployment/ (the release asset and the curl instructions use it
|
||||
# there); the wheel carries a copy inside the package.
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"deployment/docker-compose-standalone.yaml" = "docsgpt/deploy/docker-compose.yaml"
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = ["/docsgpt"]
|
||||
include = ["/docsgpt", "/deployment/docker-compose-standalone.yaml"]
|
||||
artifacts = ["docsgpt/static/**"]
|
||||
exclude = [
|
||||
"docsgpt/Dockerfile",
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,286 @@
|
||||
"""`docsgpt up` and the commands that manage the stack, against a fake Docker."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt import cli
|
||||
from docsgpt.deploy import commands, envfile, stack
|
||||
from docsgpt.deploy.docker import DeployError
|
||||
|
||||
|
||||
class FakeDocker:
|
||||
def __init__(self, volumes=(), project_dirs=()):
|
||||
self.volumes = set(volumes)
|
||||
self.dirs = {Path(d) for d in project_dirs}
|
||||
self.calls = []
|
||||
self.preflights = 0
|
||||
|
||||
def preflight(self, interactive=False):
|
||||
self.preflights += 1
|
||||
|
||||
def compose(self, directory, *args, capture=False, check=True):
|
||||
self.calls.append((Path(directory), list(args)))
|
||||
if args and args[0] == "down" and "-v" in args:
|
||||
self.volumes.clear()
|
||||
return subprocess.CompletedProcess(["docker", "compose", *args], 0, stdout="", stderr="")
|
||||
|
||||
def volume_exists(self, name):
|
||||
return name in self.volumes
|
||||
|
||||
def project_dirs(self, project):
|
||||
return set(self.dirs)
|
||||
|
||||
|
||||
class FakePrompter:
|
||||
def __init__(self, answers=()):
|
||||
self.answers = list(answers)
|
||||
self.questions = []
|
||||
|
||||
def _next(self, question):
|
||||
self.questions.append(question)
|
||||
if not self.answers:
|
||||
raise AssertionError(f"unexpected question: {question}")
|
||||
return self.answers.pop(0)
|
||||
|
||||
def choose(self, question, options, default):
|
||||
return self._next(question)
|
||||
|
||||
def text(self, question, default=None, secret=False):
|
||||
return self._next(question)
|
||||
|
||||
def confirm(self, question, default=False):
|
||||
return self._next(question)
|
||||
|
||||
|
||||
def _context(docker=None, prompter=None, interactive=False, healthy=True, **overrides):
|
||||
context = commands.Context(
|
||||
docker=docker or FakeDocker(),
|
||||
prompter=prompter or FakePrompter(),
|
||||
interactive=interactive,
|
||||
version="0.21.0",
|
||||
lan_ip=lambda: "192.168.1.10",
|
||||
wait=lambda url, timeout: healthy,
|
||||
open_browser=lambda url: None,
|
||||
)
|
||||
for key, value in overrides.items():
|
||||
setattr(context, key, value)
|
||||
return context
|
||||
|
||||
|
||||
def _run(argv, context):
|
||||
args = cli.build_parser().parse_args(argv)
|
||||
return args.func(args, context)
|
||||
|
||||
|
||||
class TestUpFirstInstall:
|
||||
def test_yes_installs_locally_with_the_public_api(self, tmp_path, capsys):
|
||||
docker = FakeDocker()
|
||||
assert _run(["up", "--yes", "--dir", str(tmp_path)], _context(docker)) == 0
|
||||
|
||||
assert (tmp_path / "docker-compose.yaml").read_text() == stack.compose_source().read_text()
|
||||
env = envfile.read(tmp_path / ".env")
|
||||
assert env["DOCSGPT_IMAGE_TAG"] == "0.21.0"
|
||||
assert env["DOCSGPT_BIND"] == "127.0.0.1"
|
||||
assert env["LLM_PROVIDER"] == "docsgpt"
|
||||
assert env["INTERNAL_KEY"] and env["JWT_SECRET_KEY"] and env["POSTGRES_PASSWORD"]
|
||||
assert docker.preflights == 1
|
||||
assert (tmp_path, ["up", "-d", "--remove-orphans"]) in docker.calls
|
||||
record = json.loads((tmp_path / "install.json").read_text())
|
||||
assert record["version"] == "0.21.0"
|
||||
assert "http://localhost:7091" in capsys.readouterr().out
|
||||
|
||||
def test_interactive_asks_who_reaches_it_and_which_model(self, tmp_path, capsys):
|
||||
prompter = FakePrompter(["network", "anthropic", "sk-ant"])
|
||||
assert _run(["up", "--dir", str(tmp_path)], _context(prompter=prompter, interactive=True)) == 0
|
||||
env = envfile.read(tmp_path / ".env")
|
||||
assert env["DOCSGPT_BIND"] == "0.0.0.0"
|
||||
assert env["AUTH_TYPE"] == "simple_jwt"
|
||||
assert env["LLM_PROVIDER"] == "anthropic"
|
||||
assert env["API_KEY"] == "sk-ant"
|
||||
out = capsys.readouterr().out
|
||||
assert "http://192.168.1.10:7091" in out
|
||||
assert stack.simple_jwt_token(env["JWT_SECRET_KEY"]) in out
|
||||
|
||||
def test_flags_answer_the_questions(self, tmp_path):
|
||||
argv = ["up", "--dir", str(tmp_path), "--domain", "docs.example.com", "--provider", "openai", "--api-key", "sk"]
|
||||
assert _run(argv, _context(interactive=True)) == 0
|
||||
env = envfile.read(tmp_path / ".env")
|
||||
assert env["COMPOSE_PROFILES"] == "https"
|
||||
assert env["DOCSGPT_DOMAIN"] == "docs.example.com"
|
||||
assert env["LLM_PROVIDER"] == "openai"
|
||||
|
||||
def test_a_missing_api_key_is_an_error_without_a_terminal(self, tmp_path):
|
||||
with pytest.raises(DeployError, match="API key"):
|
||||
_run(["up", "--yes", "--dir", str(tmp_path), "--provider", "openai"], _context())
|
||||
|
||||
def test_the_api_key_can_come_from_the_environment(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DOCSGPT_API_KEY", "sk-env")
|
||||
assert _run(["up", "--yes", "--dir", str(tmp_path), "--provider", "openai"], _context()) == 0
|
||||
assert envfile.read(tmp_path / ".env")["API_KEY"] == "sk-env"
|
||||
|
||||
def test_an_existing_database_keeps_its_password(self, tmp_path):
|
||||
docker = FakeDocker(volumes={"docsgpt_postgres_data"})
|
||||
assert _run(["up", "--yes", "--dir", str(tmp_path)], _context(docker)) == 0
|
||||
assert "POSTGRES_PASSWORD" not in envfile.read(tmp_path / ".env")
|
||||
|
||||
def test_image_tag_and_docling(self, tmp_path):
|
||||
argv = ["up", "--yes", "--dir", str(tmp_path), "--image-tag", "develop", "--docling"]
|
||||
docker = FakeDocker()
|
||||
assert _run(argv, _context(docker)) == 0
|
||||
env = envfile.read(tmp_path / ".env")
|
||||
assert env["DOCSGPT_IMAGE_TAG"] == "develop"
|
||||
assert env["DOCSGPT_IMAGE_VARIANT"] == "-docling"
|
||||
# A moving tag is pulled every time, not only when missing.
|
||||
assert (tmp_path, ["up", "-d", "--remove-orphans", "--pull", "always"]) in docker.calls
|
||||
|
||||
def test_an_unhealthy_start_points_at_the_logs(self, tmp_path, capsys):
|
||||
assert _run(["up", "--yes", "--dir", str(tmp_path)], _context(healthy=False)) == 1
|
||||
assert "docsgpt logs" in capsys.readouterr().err
|
||||
assert not (tmp_path / "install.json").exists()
|
||||
|
||||
|
||||
class TestUpAgain:
|
||||
def test_keeps_the_settings_and_moves_the_version(self, tmp_path):
|
||||
assert _run(["up", "--yes", "--dir", str(tmp_path)], _context()) == 0
|
||||
before = envfile.read(tmp_path / ".env")
|
||||
envfile.update(tmp_path / ".env", {"CUSTOM": "mine"})
|
||||
|
||||
context = _context(interactive=True, docker=FakeDocker(volumes={"docsgpt_postgres_data"}))
|
||||
context.version = "0.22.0"
|
||||
assert _run(["up", "--dir", str(tmp_path)], context) == 0
|
||||
after = envfile.read(tmp_path / ".env")
|
||||
assert after["DOCSGPT_IMAGE_TAG"] == "0.22.0"
|
||||
assert after["CUSTOM"] == "mine"
|
||||
for key in ("INTERNAL_KEY", "JWT_SECRET_KEY", "POSTGRES_PASSWORD"):
|
||||
assert after[key] == before[key]
|
||||
assert context.prompter.questions == [], "a configured install is not asked again"
|
||||
|
||||
def test_another_projects_containers_need_consent(self, tmp_path):
|
||||
docker = FakeDocker(project_dirs={"/srv/old-docsgpt"})
|
||||
with pytest.raises(DeployError, match="--adopt"):
|
||||
_run(["up", "--yes", "--dir", str(tmp_path)], _context(docker))
|
||||
assert docker.calls == []
|
||||
assert _run(["up", "--yes", "--adopt", "--dir", str(tmp_path)], _context(docker)) == 0
|
||||
|
||||
def test_consent_can_be_given_at_the_prompt(self, tmp_path):
|
||||
docker = FakeDocker(project_dirs={"/srv/old-docsgpt"})
|
||||
prompter = FakePrompter([True, "local", "docsgpt"])
|
||||
assert _run(["up", "--dir", str(tmp_path)], _context(docker, prompter, interactive=True)) == 0
|
||||
|
||||
|
||||
class TestManage:
|
||||
@staticmethod
|
||||
def _installed(tmp_path, *extra):
|
||||
assert _run(["up", "--yes", "--dir", str(tmp_path), *extra], _context()) == 0
|
||||
|
||||
def test_down(self, tmp_path):
|
||||
self._installed(tmp_path)
|
||||
docker = FakeDocker()
|
||||
assert _run(["down", "--dir", str(tmp_path)], _context(docker)) == 0
|
||||
assert docker.calls == [(tmp_path, ["down"])]
|
||||
|
||||
def test_commands_on_a_missing_install(self, tmp_path, capsys):
|
||||
assert _run(["status", "--dir", str(tmp_path)], _context()) == 1
|
||||
assert "docsgpt up" in capsys.readouterr().err
|
||||
|
||||
def test_status(self, tmp_path, capsys):
|
||||
self._installed(tmp_path)
|
||||
docker = FakeDocker()
|
||||
assert _run(["status", "--dir", str(tmp_path)], _context(docker)) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "0.21.0" in out and "http://localhost:7091" in out
|
||||
assert (tmp_path, ["ps"]) in docker.calls
|
||||
|
||||
def test_logs_pass_through(self, tmp_path):
|
||||
self._installed(tmp_path)
|
||||
docker = FakeDocker()
|
||||
assert _run(["logs", "--dir", str(tmp_path), "-f", "--tail", "50", "backend"], _context(docker)) == 0
|
||||
assert docker.calls == [(tmp_path, ["logs", "--follow", "--tail", "50", "backend"])]
|
||||
|
||||
def test_token(self, tmp_path, capsys):
|
||||
self._installed(tmp_path, "--expose", "network")
|
||||
capsys.readouterr()
|
||||
assert _run(["token", "--dir", str(tmp_path)], _context()) == 0
|
||||
secret = envfile.read(tmp_path / ".env")["JWT_SECRET_KEY"]
|
||||
assert capsys.readouterr().out.strip() == stack.simple_jwt_token(secret)
|
||||
|
||||
def test_no_token_without_simple_jwt(self, tmp_path, capsys):
|
||||
self._installed(tmp_path)
|
||||
assert _run(["token", "--dir", str(tmp_path)], _context()) == 1
|
||||
assert "AUTH_TYPE" in capsys.readouterr().err
|
||||
|
||||
def test_env_get_and_set(self, tmp_path, capsys):
|
||||
self._installed(tmp_path)
|
||||
capsys.readouterr()
|
||||
assert _run(["env", "--dir", str(tmp_path), "set", "LLM_NAME=gpt-5.5", "OCR_ENABLED=true"], _context()) == 0
|
||||
assert "docsgpt up" in capsys.readouterr().out
|
||||
assert _run(["env", "--dir", str(tmp_path), "get", "LLM_NAME"], _context()) == 0
|
||||
assert capsys.readouterr().out.strip() == "gpt-5.5"
|
||||
assert _run(["env", "--dir", str(tmp_path), "get", "NOPE"], _context()) == 1
|
||||
with pytest.raises(DeployError, match="KEY=VALUE"):
|
||||
_run(["env", "--dir", str(tmp_path), "set", "oops"], _context())
|
||||
|
||||
def test_uninstall_keeps_data_and_settings(self, tmp_path, capsys):
|
||||
self._installed(tmp_path)
|
||||
docker = FakeDocker()
|
||||
assert _run(["uninstall", "--yes", "--dir", str(tmp_path)], _context(docker, installer=lambda: "uv")) == 0
|
||||
assert docker.calls == [(tmp_path, ["down", "--remove-orphans"])]
|
||||
assert (tmp_path / ".env").is_file(), "the database password lives there"
|
||||
assert not (tmp_path / "docker-compose.yaml").exists()
|
||||
assert not (tmp_path / "install.json").exists()
|
||||
assert "uv tool uninstall docsgpt" in capsys.readouterr().out
|
||||
|
||||
def test_uninstall_purge_removes_everything(self, tmp_path):
|
||||
directory = tmp_path / "stack"
|
||||
self._installed(directory)
|
||||
docker = FakeDocker()
|
||||
assert _run(["uninstall", "--yes", "--purge", "--dir", str(directory)], _context(docker)) == 0
|
||||
assert docker.calls == [(directory, ["down", "--remove-orphans", "-v"])]
|
||||
assert not directory.exists()
|
||||
|
||||
def test_uninstall_asks_first(self, tmp_path):
|
||||
self._installed(tmp_path)
|
||||
docker = FakeDocker()
|
||||
prompter = FakePrompter([False])
|
||||
assert _run(["uninstall", "--dir", str(tmp_path)], _context(docker, prompter, interactive=True)) == 1
|
||||
assert docker.calls == []
|
||||
|
||||
|
||||
class TestUpgrade:
|
||||
def test_a_uv_tool_install_upgrades_and_runs_up_again(self, tmp_path):
|
||||
self_calls = []
|
||||
context = _context(
|
||||
installer=lambda: "uv",
|
||||
run=lambda args: self_calls.append(args) or 0,
|
||||
exec_up=lambda argv: self_calls.append(["exec", *argv]) or 0,
|
||||
)
|
||||
assert _run(["upgrade", "--dir", str(tmp_path), "--version", "0.22.0"], context) == 0
|
||||
assert self_calls[0] == ["uv", "tool", "install", "--force", "docsgpt==0.22.0"]
|
||||
assert self_calls[1] == ["exec", "docsgpt", "up", "--dir", str(tmp_path)]
|
||||
|
||||
def test_latest_when_no_version_is_given(self, tmp_path):
|
||||
self_calls = []
|
||||
context = _context(installer=lambda: "uv", run=lambda args: self_calls.append(args) or 0,
|
||||
exec_up=lambda argv: 0)
|
||||
assert _run(["upgrade", "--dir", str(tmp_path)], context) == 0
|
||||
assert self_calls[0] == ["uv", "tool", "install", "--force", "docsgpt"]
|
||||
|
||||
def test_a_pip_install_is_told_what_to_run(self, tmp_path, capsys):
|
||||
context = _context(installer=lambda: "pip", run=lambda args: pytest.fail("must not run"))
|
||||
assert _run(["upgrade", "--dir", str(tmp_path)], context) == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "pip install -U docsgpt" in err and "docsgpt up" in err
|
||||
|
||||
|
||||
class TestImports:
|
||||
def test_the_deploy_commands_do_not_boot_the_app(self):
|
||||
code = (
|
||||
"import sys, docsgpt.cli, docsgpt.deploy.commands; "
|
||||
"loaded = {m for m in sys.modules if m in ('docsgpt.app', 'docsgpt.core.settings', 'celery', 'flask')}; "
|
||||
"assert not loaded, loaded"
|
||||
)
|
||||
subprocess.run([sys.executable, "-c", code], cwd=Path(__file__).resolve().parents[2], check=True)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""The docker CLI calls behind `docsgpt up`, against a fake runner."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.deploy import docker as docker_module
|
||||
from docsgpt.deploy.docker import DeployError, Docker
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
"""Answers docker commands from a table of (argv prefix -> result) and records every call."""
|
||||
|
||||
def __init__(self, answers=None):
|
||||
self.answers = list((answers or {}).items())
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, args, *, cwd=None, capture=False, check=True):
|
||||
self.calls.append((list(args), cwd))
|
||||
for prefix, answer in self.answers:
|
||||
if list(args[: len(prefix)]) == list(prefix):
|
||||
if callable(answer):
|
||||
answer = answer()
|
||||
code, out = answer
|
||||
if check and code != 0:
|
||||
raise DeployError(f"{' '.join(args)} failed")
|
||||
return subprocess.CompletedProcess(args, code, stdout=out, stderr="")
|
||||
return subprocess.CompletedProcess(args, 0, stdout="", stderr="")
|
||||
|
||||
|
||||
def _docker(runner, platform="linux", which=lambda name: "/usr/bin/docker"):
|
||||
return Docker(runner=runner, which=which, sleep=lambda seconds: None, platform=platform)
|
||||
|
||||
|
||||
class TestPreflight:
|
||||
def test_docker_missing(self):
|
||||
with pytest.raises(DeployError, match="Docker is not installed"):
|
||||
_docker(FakeRunner(), which=lambda name: None).preflight()
|
||||
|
||||
def test_daemon_down_on_linux_says_how_to_start_it(self):
|
||||
runner = FakeRunner({("docker", "info"): (1, "")})
|
||||
with pytest.raises(DeployError, match="systemctl start docker"):
|
||||
_docker(runner).preflight()
|
||||
|
||||
def test_daemon_down_on_macos_starts_docker_desktop(self):
|
||||
state = {"started": False}
|
||||
|
||||
def info():
|
||||
return (0, "") if state["started"] else (1, "")
|
||||
|
||||
def start():
|
||||
state["started"] = True
|
||||
return (0, "")
|
||||
|
||||
runner = FakeRunner(
|
||||
{
|
||||
("docker", "info"): info,
|
||||
("open", "-a", "Docker"): start,
|
||||
("docker", "compose", "version"): (0, "v2.39.1-desktop.1\n"),
|
||||
}
|
||||
)
|
||||
_docker(runner, platform="darwin").preflight()
|
||||
assert (["open", "-a", "Docker"], None) in runner.calls
|
||||
|
||||
@pytest.mark.parametrize("version", ["v2.23.0", "2.20.2"])
|
||||
def test_compose_too_old(self, version):
|
||||
runner = FakeRunner({("docker", "compose", "version"): (0, version + "\n")})
|
||||
with pytest.raises(DeployError, match="2.24"):
|
||||
_docker(runner).preflight()
|
||||
|
||||
def test_compose_missing(self):
|
||||
runner = FakeRunner({("docker", "compose", "version"): (1, "")})
|
||||
with pytest.raises(DeployError, match="Docker Compose"):
|
||||
_docker(runner).preflight()
|
||||
|
||||
@pytest.mark.parametrize("version", ["v2.24.0", "2.39.1-desktop.1", "5.5.1"])
|
||||
def test_compose_new_enough(self, version):
|
||||
runner = FakeRunner({("docker", "compose", "version"): (0, version + "\n")})
|
||||
_docker(runner).preflight()
|
||||
|
||||
|
||||
class TestQueries:
|
||||
def test_compose_runs_in_the_stack_directory(self, tmp_path):
|
||||
runner = FakeRunner()
|
||||
_docker(runner).compose(tmp_path, "up", "-d")
|
||||
assert runner.calls == [(["docker", "compose", "up", "-d"], tmp_path)]
|
||||
|
||||
def test_volume_exists(self):
|
||||
runner = FakeRunner({("docker", "volume", "inspect", "docsgpt_postgres_data"): (0, "[]")})
|
||||
assert _docker(runner).volume_exists("docsgpt_postgres_data")
|
||||
assert not _docker(FakeRunner({("docker", "volume"): (1, "")})).volume_exists("docsgpt_postgres_data")
|
||||
|
||||
def test_project_directories(self):
|
||||
runner = FakeRunner({("docker", "ps"): (0, "/srv/old\n/srv/old\n\n/home/me/.docsgpt/server\n")})
|
||||
assert _docker(runner).project_dirs("docsgpt") == {Path("/srv/old"), Path("/home/me/.docsgpt/server")}
|
||||
args = runner.calls[0][0]
|
||||
assert "label=com.docker.compose.project=docsgpt" in args
|
||||
|
||||
|
||||
class TestWaitHealthy:
|
||||
def test_succeeds_once_the_api_answers(self, monkeypatch):
|
||||
attempts = {"n": 0}
|
||||
|
||||
def opener(url, timeout):
|
||||
attempts["n"] += 1
|
||||
if attempts["n"] < 3:
|
||||
raise OSError("connection refused")
|
||||
return _Response(200)
|
||||
|
||||
clock = iter(range(0, 1000, 2))
|
||||
assert docker_module.wait_healthy("http://127.0.0.1:7091/api/health", 60, opener=opener,
|
||||
sleep=lambda s: None, clock=lambda: next(clock))
|
||||
assert attempts["n"] == 3
|
||||
|
||||
def test_gives_up_after_the_timeout(self):
|
||||
def opener(url, timeout):
|
||||
raise OSError("connection refused")
|
||||
|
||||
clock = iter(range(0, 1000, 10))
|
||||
assert not docker_module.wait_healthy("http://127.0.0.1:7091/api/health", 30, opener=opener,
|
||||
sleep=lambda s: None, clock=lambda: next(clock))
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, status):
|
||||
self.status = status
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Reading and updating the stack's .env without losing what the user wrote."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.deploy import envfile
|
||||
|
||||
|
||||
class TestRead:
|
||||
def test_comments_blanks_quotes_and_export(self, tmp_path):
|
||||
path = tmp_path / ".env"
|
||||
path.write_text(
|
||||
"# settings\n"
|
||||
"\n"
|
||||
"LLM_PROVIDER=openai\n"
|
||||
"export API_KEY='sk-123'\n"
|
||||
'LLM_NAME="gpt 5"\n'
|
||||
"EMPTY=\n"
|
||||
"not a line\n"
|
||||
)
|
||||
assert envfile.read(path) == {
|
||||
"LLM_PROVIDER": "openai",
|
||||
"API_KEY": "sk-123",
|
||||
"LLM_NAME": "gpt 5",
|
||||
"EMPTY": "",
|
||||
}
|
||||
|
||||
def test_a_missing_file_is_empty(self, tmp_path):
|
||||
assert envfile.read(tmp_path / ".env") == {}
|
||||
|
||||
def test_the_last_duplicate_wins(self, tmp_path):
|
||||
path = tmp_path / ".env"
|
||||
path.write_text("A=1\nA=2\n")
|
||||
assert envfile.read(path) == {"A": "2"}
|
||||
|
||||
|
||||
class TestUpdate:
|
||||
def test_changes_values_in_place_and_keeps_everything_else(self, tmp_path):
|
||||
path = tmp_path / ".env"
|
||||
path.write_text("# my settings\nLLM_PROVIDER=openai\n\nCUSTOM=keep me\nDOCSGPT_IMAGE_TAG=0.19.0\n")
|
||||
envfile.update(path, {"DOCSGPT_IMAGE_TAG": "0.21.0"})
|
||||
assert path.read_text() == "# my settings\nLLM_PROVIDER=openai\n\nCUSTOM=keep me\nDOCSGPT_IMAGE_TAG=0.21.0\n"
|
||||
|
||||
def test_appends_new_keys_and_removes_none(self, tmp_path):
|
||||
path = tmp_path / ".env"
|
||||
path.write_text("A=1\nB=2")
|
||||
envfile.update(path, {"B": None, "C": "3"})
|
||||
assert path.read_text() == "A=1\nC=3\n"
|
||||
|
||||
def test_duplicates_collapse_to_the_first_line(self, tmp_path):
|
||||
path = tmp_path / ".env"
|
||||
path.write_text("A=1\nX=y\nA=2\n")
|
||||
envfile.update(path, {"A": "3"})
|
||||
assert path.read_text() == "A=3\nX=y\n"
|
||||
|
||||
@pytest.mark.parametrize("value", ["plain", "with space", "hash # inside", "it's", 'say "hi"', "back\\slash", ""])
|
||||
def test_values_round_trip(self, tmp_path, value):
|
||||
path = tmp_path / ".env"
|
||||
envfile.update(path, {"VALUE": value})
|
||||
assert envfile.read(path)["VALUE"] == value
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions")
|
||||
def test_a_new_file_is_private(self, tmp_path):
|
||||
path = tmp_path / "stack" / ".env"
|
||||
envfile.update(path, {"JWT_SECRET_KEY": "s"})
|
||||
assert oct(os.stat(path).st_mode & 0o777) == oct(0o600)
|
||||
|
||||
def test_rejects_a_newline_in_a_value(self, tmp_path):
|
||||
with pytest.raises(ValueError, match="newline"):
|
||||
envfile.update(tmp_path / ".env", {"A": "1\n2"})
|
||||
@@ -0,0 +1,196 @@
|
||||
"""What `docsgpt up` writes to the stack's .env, and where the stack lives."""
|
||||
|
||||
from itertools import count
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.core import paths
|
||||
from docsgpt.deploy import stack
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _secrets():
|
||||
numbers = count(1)
|
||||
return lambda: f"secret{next(numbers)}"
|
||||
|
||||
|
||||
def _first_install(**overrides):
|
||||
options = {"image_tag": "0.21.0", "fresh_database": True, "secret": _secrets()}
|
||||
options.update(overrides)
|
||||
return stack.plan({}, **options)
|
||||
|
||||
|
||||
class TestFirstInstall:
|
||||
def test_defaults_are_local_with_the_public_api(self):
|
||||
updates = _first_install()
|
||||
assert updates["DOCSGPT_IMAGE_TAG"] == "0.21.0"
|
||||
assert updates["DOCSGPT_BIND"] == "127.0.0.1"
|
||||
assert updates["LLM_PROVIDER"] == "docsgpt"
|
||||
assert updates["VITE_API_STREAMING"] == "true"
|
||||
assert "AUTH_TYPE" not in updates
|
||||
|
||||
def test_secrets_are_generated_once_each(self):
|
||||
updates = _first_install()
|
||||
generated = {updates["INTERNAL_KEY"], updates["JWT_SECRET_KEY"], updates["POSTGRES_PASSWORD"]}
|
||||
assert len(generated) == 3
|
||||
|
||||
def test_an_existing_database_keeps_its_password(self):
|
||||
"""Postgres reads the password only when its volume is created."""
|
||||
updates = _first_install(fresh_database=False)
|
||||
assert "POSTGRES_PASSWORD" not in updates
|
||||
|
||||
|
||||
class TestRerun:
|
||||
def test_secrets_and_settings_are_left_alone(self):
|
||||
existing = {
|
||||
"DOCSGPT_IMAGE_TAG": "0.20.0",
|
||||
"INTERNAL_KEY": "k",
|
||||
"JWT_SECRET_KEY": "j",
|
||||
"POSTGRES_PASSWORD": "p",
|
||||
"VITE_API_STREAMING": "true",
|
||||
"LLM_PROVIDER": "anthropic",
|
||||
"API_KEY": "sk",
|
||||
"DOCSGPT_BIND": "0.0.0.0",
|
||||
"AUTH_TYPE": "simple_jwt",
|
||||
}
|
||||
updates = stack.plan(existing, image_tag="0.21.0", fresh_database=False, secret=_secrets())
|
||||
assert updates == {"DOCSGPT_IMAGE_TAG": "0.21.0"}
|
||||
|
||||
def test_a_missing_password_is_not_invented_for_an_existing_database(self):
|
||||
updates = stack.plan({"INTERNAL_KEY": "k", "JWT_SECRET_KEY": "j"}, image_tag="x", fresh_database=False)
|
||||
assert "POSTGRES_PASSWORD" not in updates
|
||||
|
||||
|
||||
class TestExposure:
|
||||
def test_network_publishes_everywhere_and_turns_on_auth(self):
|
||||
updates = _first_install(expose="network")
|
||||
assert updates["DOCSGPT_BIND"] == "0.0.0.0"
|
||||
assert updates["AUTH_TYPE"] == "simple_jwt"
|
||||
assert updates.get("COMPOSE_PROFILES") is None
|
||||
|
||||
def test_domain_adds_caddy_and_auth_and_keeps_the_port_local(self):
|
||||
updates = _first_install(expose="domain", domain="docs.example.com")
|
||||
assert updates["COMPOSE_PROFILES"] == "https"
|
||||
assert updates["DOCSGPT_DOMAIN"] == "docs.example.com"
|
||||
assert updates["DOCSGPT_BIND"] == "127.0.0.1"
|
||||
assert updates["AUTH_TYPE"] == "simple_jwt"
|
||||
|
||||
def test_domain_needs_a_domain(self):
|
||||
with pytest.raises(ValueError, match="domain"):
|
||||
_first_install(expose="domain")
|
||||
|
||||
def test_an_existing_auth_mode_is_not_downgraded(self):
|
||||
updates = stack.plan({"AUTH_TYPE": "oidc"}, image_tag="x", expose="network", fresh_database=False)
|
||||
assert "AUTH_TYPE" not in updates
|
||||
|
||||
def test_back_to_local_removes_the_proxy(self):
|
||||
existing = {"COMPOSE_PROFILES": "https", "DOCSGPT_DOMAIN": "docs.example.com", "DOCSGPT_BIND": "127.0.0.1"}
|
||||
updates = stack.plan(existing, image_tag="x", expose="local", fresh_database=False)
|
||||
assert updates["COMPOSE_PROFILES"] is None
|
||||
assert updates["DOCSGPT_DOMAIN"] is None
|
||||
|
||||
def test_port_and_docling(self):
|
||||
updates = _first_install(port=8080, docling=True)
|
||||
assert updates["DOCSGPT_PORT"] == "8080"
|
||||
assert updates["DOCSGPT_IMAGE_VARIANT"] == "-docling"
|
||||
assert stack.plan({"DOCSGPT_IMAGE_VARIANT": "-docling"}, image_tag="x", docling=False, fresh_database=False)[
|
||||
"DOCSGPT_IMAGE_VARIANT"
|
||||
] is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env, mode",
|
||||
[
|
||||
({}, "local"),
|
||||
({"DOCSGPT_BIND": "0.0.0.0"}, "network"),
|
||||
({"COMPOSE_PROFILES": "https", "DOCSGPT_DOMAIN": "d.example.com"}, "domain"),
|
||||
],
|
||||
)
|
||||
def test_the_mode_is_read_back_from_the_env(self, env, mode):
|
||||
assert stack.exposure(env) == mode
|
||||
|
||||
|
||||
class TestProviders:
|
||||
def test_switching_provider_drops_the_old_keys(self):
|
||||
existing = {"LLM_PROVIDER": "openai", "API_KEY": "sk", "LLM_NAME": "m", "OPENAI_BASE_URL": "http://x/v1"}
|
||||
provider = stack.provider_settings("anthropic", api_key="ak")
|
||||
updates = stack.plan(existing, image_tag="x", provider=provider, fresh_database=False)
|
||||
assert updates["LLM_PROVIDER"] == "anthropic"
|
||||
assert updates["API_KEY"] == "ak"
|
||||
assert updates["LLM_NAME"] is None
|
||||
assert updates["OPENAI_BASE_URL"] is None
|
||||
|
||||
def test_the_public_api_needs_no_key(self):
|
||||
assert stack.provider_settings("docsgpt") == {
|
||||
"LLM_PROVIDER": "docsgpt",
|
||||
"API_KEY": None,
|
||||
"LLM_NAME": None,
|
||||
"OPENAI_BASE_URL": None,
|
||||
}
|
||||
|
||||
def test_a_hosted_provider_needs_a_key(self):
|
||||
with pytest.raises(ValueError, match="API key"):
|
||||
stack.provider_settings("openai")
|
||||
|
||||
def test_an_openai_compatible_server_needs_a_url_and_a_model(self):
|
||||
with pytest.raises(ValueError, match="base URL"):
|
||||
stack.provider_settings("openai-compatible", model="llama3")
|
||||
settings = stack.provider_settings("openai-compatible", base_url="http://host.docker.internal:11434/v1", model="llama3")
|
||||
assert settings == {
|
||||
"LLM_PROVIDER": "openai",
|
||||
"API_KEY": "not-needed",
|
||||
"LLM_NAME": "llama3",
|
||||
"OPENAI_BASE_URL": "http://host.docker.internal:11434/v1",
|
||||
}
|
||||
|
||||
def test_an_unknown_provider(self):
|
||||
with pytest.raises(ValueError, match="unknown provider"):
|
||||
stack.provider_settings("nope")
|
||||
|
||||
|
||||
class TestUrls:
|
||||
def test_local(self):
|
||||
assert stack.url({"DOCSGPT_PORT": "8080"}, lan_ip="10.0.0.5") == "http://localhost:8080"
|
||||
|
||||
def test_network_uses_the_machine_address(self):
|
||||
assert stack.url({"DOCSGPT_BIND": "0.0.0.0"}, lan_ip="10.0.0.5") == "http://10.0.0.5:7091"
|
||||
|
||||
def test_domain(self):
|
||||
env = {"COMPOSE_PROFILES": "https", "DOCSGPT_DOMAIN": "docs.example.com"}
|
||||
assert stack.url(env, lan_ip="10.0.0.5") == "https://docs.example.com"
|
||||
|
||||
def test_health_is_always_checked_on_this_machine(self):
|
||||
assert stack.health_url({"DOCSGPT_BIND": "0.0.0.0", "DOCSGPT_PORT": "9000"}) == "http://127.0.0.1:9000/api/health"
|
||||
|
||||
|
||||
class TestToken:
|
||||
def test_matches_what_the_api_prints(self):
|
||||
"""docsgpt/app.py signs {"sub": "local"} with JWT_SECRET_KEY for AUTH_TYPE=simple_jwt."""
|
||||
from jose import jwt
|
||||
|
||||
token = stack.simple_jwt_token("s3cret")
|
||||
assert token == jwt.encode({"sub": "local"}, "s3cret", algorithm="HS256")
|
||||
assert jwt.decode(token, "s3cret", algorithms=["HS256"]) == {"sub": "local"}
|
||||
|
||||
|
||||
class TestLocations:
|
||||
def test_the_stack_dir_is_never_the_checkout(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv(paths.HOME_ENV, raising=False)
|
||||
monkeypatch.setattr(paths, "default_home", lambda: tmp_path / "home")
|
||||
assert stack.stack_dir(None) == tmp_path / "home"
|
||||
|
||||
def test_docsgpt_home_and_then_an_explicit_dir_win(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(paths.HOME_ENV, str(tmp_path / "env-home"))
|
||||
assert stack.stack_dir(None) == (tmp_path / "env-home").resolve()
|
||||
assert stack.stack_dir(str(tmp_path / "flag")) == (tmp_path / "flag").resolve()
|
||||
|
||||
def test_a_checkout_uses_the_deployment_compose_file(self):
|
||||
assert stack.compose_source() == REPO_ROOT / "deployment" / "docker-compose-standalone.yaml"
|
||||
|
||||
def test_the_packaged_compose_file_wins(self, monkeypatch, tmp_path):
|
||||
packaged = tmp_path / "docsgpt" / "deploy" / "docker-compose.yaml"
|
||||
packaged.parent.mkdir(parents=True)
|
||||
packaged.write_text("name: docsgpt\n")
|
||||
monkeypatch.setattr(paths, "package_dir", lambda: tmp_path / "docsgpt")
|
||||
assert stack.compose_source() == packaged
|
||||
Reference in new issue
Block a user