Files

184 lines
5.9 KiB
Python

"""Opt-in production-like Compose checks; no fixture secret is written to the repository."""
from __future__ import annotations
import os
import socket
import subprocess
import time
import urllib.error
import urllib.request
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
def _enabled() -> bool:
return os.environ.get("BACKUP_TOOL_COMPOSE_E2E") == "1"
def _free_port() -> int:
try:
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
address = listener.getsockname()
except OSError as error:
raise RuntimeError("could not allocate a Compose test port") from error
if not isinstance(address, tuple) or not isinstance(address[1], int):
raise RuntimeError("could not allocate a Compose test port")
return address[1]
def _compose(environment: dict[str, str], *arguments: str) -> subprocess.CompletedProcess[str]:
command = [
"docker",
"compose",
"-f",
"docker-compose.yml",
"-f",
environment["COMPOSE_FILE"],
*arguments,
]
return subprocess.run(
command,
cwd=ROOT,
env=environment,
check=True,
text=True,
capture_output=True,
timeout=120,
)
def _eventually_get(url: str, expected_status: int) -> str:
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=3) as response:
if response.status == expected_status:
return response.read().decode()
except urllib.error.HTTPError as error:
if error.code == expected_status:
return error.read().decode()
except OSError:
pass
time.sleep(1)
raise AssertionError(f"{url} did not return {expected_status}")
@pytest.mark.skipif(
not _enabled(), reason="set BACKUP_TOOL_COMPOSE_E2E=1 to run Docker Compose E2E"
)
def test_compose_persists_metadata_and_stops_workers_safely(tmp_path: Path) -> None:
source = tmp_path / "source"
source.mkdir(mode=0o755)
fixture_key = tmp_path / "unused-bind-master.key"
fixture_key.write_bytes(b"compose-test-host-fixture-key-material-32-bytes")
fixture_key.chmod(0o600)
port = _free_port()
project = f"backup-tool-e2e-{os.getpid()}"
override = tmp_path / "compose-e2e.yaml"
override.write_text(
"services:\n"
" migrate:\n"
" environment: &e2e-env\n"
" BACKUP_TOOL_MASTER_KEY_FILE: /var/lib/backup-tool/master.key\n"
f" BACKUP_TOOL_PUBLIC_BASE_URL: http://localhost:{port}\n"
" web:\n"
" environment: *e2e-env\n"
" scheduler:\n"
" environment: *e2e-env\n"
" worker:\n"
" environment: *e2e-env\n"
" admin:\n"
" environment: *e2e-env\n"
)
environment = os.environ | {
"BACKUP_TOOL_COMPOSE_E2E": "1",
"BACKUP_TOOL_MASTER_KEY_FILE": str(fixture_key),
"BACKUP_TOOL_PORT": str(port),
"BACKUP_TOOL_SOURCES_DIR": str(source),
"COMPOSE_FILE": str(override),
"COMPOSE_PROJECT_NAME": project,
}
try:
# The service user creates the actual key inside its private named volume;
# the host key exists only to satisfy the unused read-only Compose bind.
_compose(
environment,
"run",
"--rm",
"--no-deps",
"--entrypoint",
"/bin/sh",
"migrate",
"-c",
(
"umask 077; dd if=/dev/urandom of=/var/lib/backup-tool/master.key "
"bs=32 count=1 status=none"
),
)
_compose(environment, "run", "--rm", "migrate")
_compose(environment, "up", "-d")
assert _eventually_get(f"http://127.0.0.1:{port}/readyz", 200)
setup = urllib.request.Request(
f"http://127.0.0.1:{port}/api/v2/setup",
data=b'{"username":"operator","password":"correct horse battery staple"}',
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(setup, timeout=10) as response:
assert response.status == 201
assert "backup_tool_active_executions" in _eventually_get(
f"http://127.0.0.1:{port}/metrics", 200
)
_compose(environment, "stop", "--timeout", "15", "worker")
worker_id = _compose(environment, "ps", "-aq", "worker").stdout.strip()
assert worker_id
stopped = subprocess.run(
["docker", "inspect", "--format", "{{.State.ExitCode}}", worker_id],
check=True,
text=True,
capture_output=True,
timeout=30,
)
assert stopped.stdout.strip() == "0"
_compose(environment, "up", "-d", "worker")
_eventually_get(f"http://127.0.0.1:{port}/readyz", 200)
_compose(environment, "restart", "web", "scheduler", "worker", "proxy")
assert _eventually_get(f"http://127.0.0.1:{port}/readyz", 200)
repeat_setup = urllib.request.Request(
f"http://127.0.0.1:{port}/api/v2/setup",
data=b'{"username":"operator","password":"correct horse battery staple"}',
headers={"Content-Type": "application/json"},
method="POST",
)
with pytest.raises(urllib.error.HTTPError) as repeated:
urllib.request.urlopen(repeat_setup, timeout=10)
assert repeated.value.code == 409
finally:
command = [
"docker",
"compose",
"-f",
"docker-compose.yml",
"-f",
str(override),
"down",
"--volumes",
"--remove-orphans",
]
subprocess.run(
command,
cwd=ROOT,
env=environment,
check=False,
text=True,
capture_output=True,
timeout=120,
)