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:
Alex committed 2026-09-15 22:57:00 +01:00
1 parent ac06527a8d
commit aa0e4ea280
13 files changed
+1567 -3

No files matched your search

+84 -2
View File
@@ -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__":
+5
View File
@@ -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.
"""
+381
View File
@@ -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
+143
View File
@@ -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"
+74
View File
@@ -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 "")
+184
View File
@@ -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")