184 lines
5.9 KiB
Python
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,
|
|
)
|