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

View File
Whitespace-only changes.
+286
View File
@@ -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)
+133
View File
@@ -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
+72
View File
@@ -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"})
+196
View File
@@ -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