Files
DocsGPT/tests/deploy/test_docker.py
T
Alex 447ae72fe2 fix: harden docsgpt backup and docsgpt restore
From review of #2799:

- The archive is created 0600 rather than at the process umask: it holds the
  install's data, and --with-settings puts .env and its secrets in it.
- restore validates everything the manifest declares before the stack is
  stopped, so a damaged archive fails while DocsGPT is still running rather
  than after `compose down` has taken it away.
- Only the volumes a backup is made of are restored. A hand-made manifest can
  no longer point import_volume at postgres_data, whose contents it empties.
- psql runs with ON_ERROR_STOP=on, so a restore that fails halfway cannot
  start DocsGPT again and call it a success.
- import_volume unpacks into the container's own filesystem first and clears
  the live volume only once the tar has come out whole, so a corrupt one
  leaves the volume as it was.
- The backend and the worker stop while the archive is made and start again
  even if the dump fails, so the dump and the volume tars describe the same
  moment instead of drifting apart as ingestion writes.
2026-09-16 21:55:33 +01:00

204 lines
8.1 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 -> (code, stdout[, stderr]) and records every call."""
def __init__(self, answers=None):
self.answers = list((answers or {}).items())
self.calls = []
self.streams = []
def __call__(self, args, *, cwd=None, capture=False, check=True, stdout=None, stdin=None):
self.calls.append((list(args), cwd))
self.streams.append((stdout, stdin))
for prefix, answer in self.answers:
if list(args[: len(prefix)]) == list(prefix):
if callable(answer):
answer = answer()
code, out, err = (*answer, "")[:3]
if check and code != 0:
raise DeployError(f"{' '.join(args)} failed")
return subprocess.CompletedProcess(args, code, stdout=out, stderr=err)
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_no_permission_on_the_socket_says_how_to_get_it(self):
"""A user outside the docker group is not told that Docker is down."""
denied = "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock"
runner = FakeRunner({("docker", "info"): (1, "", denied)})
with pytest.raises(DeployError, match="docker group"):
_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 TestVolumes:
def test_export_writes_the_volume_through_the_stack_image(self, tmp_path):
runner = FakeRunner()
dest = tmp_path / "volumes" / "inputs.tar"
_docker(runner).export_volume("docsgpt_inputs", dest, "arc53/docsgpt:0.21.0")
args, _ = runner.calls[0]
assert args[:4] == ["docker", "run", "--rm", "-v"]
assert args[4] == "docsgpt_inputs:/data:ro"
assert args[5] == "arc53/docsgpt:0.21.0"
assert args[6:] == ["tar", "cf", "-", "-C", "/data", "."]
assert dest.is_file(), "the tar is written to the destination"
assert runner.streams[0][0] is not None, "stdout goes to the file, not through this process"
def test_import_replaces_the_volume_contents(self, tmp_path):
source = tmp_path / "inputs.tar"
source.write_bytes(b"tar")
runner = FakeRunner()
_docker(runner).import_volume("docsgpt_inputs", source, "arc53/docsgpt:0.21.0")
args, _ = runner.calls[0]
assert "docsgpt_inputs:/data" in args
assert args[-2] == "-c"
assert "tar xf - -C /data" in args[-1]
command = args[-1]
assert command.index("tar xf - -C /stage") < command.index("find /data -mindepth 1 -delete"), (
"the incoming tar is unpacked outside the volume first, so a corrupt one leaves it alone"
)
assert runner.streams[0][1] is not None, "the tar is fed in on stdin"
class TestWaitHealthy:
def test_succeeds_once_the_api_answers(self):
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_no_request_starts_at_or_after_the_deadline(self):
"""A short timeout must not be overrun by a sleep and one more five-second request."""
now = {"t": 0.0}
starts = []
def opener(url, timeout):
starts.append((now["t"], timeout))
now["t"] += 1
raise OSError("connection refused")
def sleep(seconds):
now["t"] += seconds
assert not docker_module.wait_healthy("http://127.0.0.1:7091/api/health", 3, opener=opener,
sleep=sleep, clock=lambda: now["t"])
assert starts, "the first attempt always runs"
for started, timeout in starts[1:]:
assert started < 3
assert started + timeout <= 3
assert now["t"] <= 3
def test_a_zero_timeout_still_tries_once(self):
calls = []
def opener(url, timeout):
calls.append(timeout)
return _Response(200)
assert docker_module.wait_healthy("http://127.0.0.1:7091/api/health", 0, opener=opener,
sleep=lambda s: None, clock=lambda: 0.0)
assert len(calls) == 1 and calls[0] > 0
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