Files
DocsGPT/tests/deploy/test_docker.py
T
Alex aa0e4ea280 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.
2026-09-15 22:57:00 +01:00

134 lines
5.0 KiB
Python

"""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