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