feat(v2): complete v2 reimplementation
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user