US08-05: Automate Container Deployment Acceptance (#100)
This commit was merged in pull request #100.
This commit is contained in:
260
tests/e2e/_container_harness.py
Normal file
260
tests/e2e/_container_harness.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""The composed stack, as a fixture: build, provision, drive, destroy (US08-03/US08-05).
|
||||
|
||||
Extracted from ``tests/e2e/test_compose_stack.py`` when the container acceptance gate
|
||||
needed the same stack under a different image, project, and library. One
|
||||
implementation, because two would drift on exactly the details that make a container
|
||||
test worth anything — the mount, the volume, the ports, and the teardown.
|
||||
|
||||
Nothing here fakes anything below the process boundary: Docker builds the image,
|
||||
Compose starts the real containers, and every helper talks to them over HTTP or the
|
||||
Docker CLI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
COMPOSE_FILE = REPO / "docker-compose.yml"
|
||||
CONTAINER_LIBRARY = "/library"
|
||||
READY_TIMEOUT_SECONDS = 180
|
||||
JOB_TIMEOUT_SECONDS = 300
|
||||
UP_TIMEOUT_SECONDS = 30 * 60
|
||||
|
||||
|
||||
def compose_available() -> bool:
|
||||
try:
|
||||
return (
|
||||
subprocess.run(
|
||||
["docker", "compose", "version"], capture_output=True, timeout=60
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def mount_base() -> Path:
|
||||
"""A directory a Docker VM shares with the host.
|
||||
|
||||
Not pytest's ``tmp_path``: on macOS that is ``/var/folders/...``, which a Docker VM
|
||||
(Colima, Docker Desktop) does not share, so the bind mount would arrive empty and
|
||||
every assertion would be about nothing. ``$HOME`` is shared by every default
|
||||
configuration.
|
||||
"""
|
||||
base = Path(
|
||||
os.environ.get("PHOTO_PIPELINE_TEST_MOUNT_BASE", Path.home() / ".cache" / "photo-pipeline")
|
||||
)
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
def temporary_library(albums: dict[str, int], *, prefix: str = "library-") -> Path:
|
||||
"""A fixture library on a shareable host path, with the exclusion sentinel in it."""
|
||||
root = Path(tempfile.mkdtemp(prefix=prefix, dir=mount_base()))
|
||||
for position, (album, count) in enumerate(albums.items()):
|
||||
(root / album).mkdir(parents=True, exist_ok=True)
|
||||
for index in range(count):
|
||||
# Distinct per album *and* index: two solid images of the same colour are
|
||||
# byte-identical, which would make them a duplicate cluster by accident.
|
||||
colour = (17 + 7 * index, 31 + 29 * position, 160 - 3 * index)
|
||||
Image.new("RGB", (64, 48), colour).save(root / album / f"{album}_{index}.jpg")
|
||||
# Never discovered, counted, analyzed, or uploaded — asserted from outside it.
|
||||
(root / "_IGNORE").mkdir(exist_ok=True)
|
||||
Image.new("RGB", (32, 32), (0, 0, 0)).save(root / "_IGNORE" / "sentinel.jpg")
|
||||
return root
|
||||
|
||||
|
||||
def remove_library(root: Path) -> None:
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
def write_env_file(path: Path, secret: str, extra: dict[str, str] | None = None) -> Path:
|
||||
"""Configuration and secrets come from the environment, so a test writes its own
|
||||
file rather than borrowing the operator's ``.env``."""
|
||||
lines = [
|
||||
f"PHOTO_PIPELINE_ACCESS_SECRET={secret}",
|
||||
"PHOTO_PIPELINE_LOG_FORMAT=text",
|
||||
# The deterministic vision seam, in the data volume so both roles and the test
|
||||
# can read it (concept §18).
|
||||
"PHOTO_PIPELINE_FAKE_VISION_LOG=/data/vision.log",
|
||||
*(f"{key}={value}" for key, value in (extra or {}).items()),
|
||||
]
|
||||
path.write_text("\n".join(lines) + "\n")
|
||||
return path
|
||||
|
||||
|
||||
class Stack:
|
||||
"""The composition under test, plus the environment it was started with."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library: Path,
|
||||
env_file: Path,
|
||||
*,
|
||||
project: str,
|
||||
image: str,
|
||||
secret: str,
|
||||
) -> None:
|
||||
self.library = library
|
||||
self.project = project
|
||||
self.secret = secret
|
||||
self.port = free_port()
|
||||
self.base = f"http://127.0.0.1:{self.port}"
|
||||
# Compose reads the repository's own .env for substitution; the process
|
||||
# environment wins over it, so the test's values are the ones that apply.
|
||||
self.env = {
|
||||
**os.environ,
|
||||
"PHOTO_PIPELINE_IMAGE": image,
|
||||
"PHOTO_PIPELINE_ENV_FILE": str(env_file),
|
||||
"PHOTO_PIPELINE_LIBRARY_HOST_PATH": str(library),
|
||||
"PHOTO_PIPELINE_LIBRARY_ROOTS": CONTAINER_LIBRARY,
|
||||
"PHOTO_PIPELINE_PORT": str(self.port),
|
||||
"PHOTO_PIPELINE_UID": str(os.getuid()),
|
||||
"PHOTO_PIPELINE_GID": str(os.getgid()),
|
||||
}
|
||||
|
||||
# ── the compose lifecycle ────────────────────────────────────────────────
|
||||
|
||||
def compose(self, *args: str, check: bool = True, timeout: int = 300):
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "-p", self.project, "-f", str(COMPOSE_FILE), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=self.env,
|
||||
cwd=REPO,
|
||||
timeout=timeout,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"docker compose {' '.join(args)} failed:\n{result.stdout}\n{result.stderr}\n"
|
||||
f"{self.compose('logs', '--tail', '80', check=False).stdout}"
|
||||
)
|
||||
return result
|
||||
|
||||
def up(self, *extra: str) -> None:
|
||||
self.compose("up", "--detach", *extra, timeout=UP_TIMEOUT_SECONDS)
|
||||
|
||||
def down(self, *, volumes: bool = True) -> None:
|
||||
self.compose(
|
||||
"down",
|
||||
*(("--volumes",) if volumes else ()),
|
||||
"--remove-orphans",
|
||||
check=False,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
def use_image(self, image: str) -> None:
|
||||
"""Point the composition at another tag — the upgrade path (US08-05)."""
|
||||
self.env["PHOTO_PIPELINE_IMAGE"] = image
|
||||
|
||||
def logs(self, *services: str) -> str:
|
||||
result = self.compose("logs", *services, check=False)
|
||||
return result.stdout + result.stderr
|
||||
|
||||
def wait_until_ready(self) -> None:
|
||||
deadline = time.monotonic() + READY_TIMEOUT_SECONDS
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if httpx.get(f"{self.base}/api/v1/health/ready", timeout=5).status_code == 200:
|
||||
return
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
raise AssertionError(f"the stack never became ready:\n{self.logs()}")
|
||||
|
||||
# ── talking to it ────────────────────────────────────────────────────────
|
||||
|
||||
def client(self) -> httpx.Client:
|
||||
"""A browser that has loaded the app: session cookie in the jar, token in a
|
||||
header. The session belongs to the API process, so it is re-bootstrapped after
|
||||
every restart."""
|
||||
client = httpx.Client(base_url=f"{self.base}/api/v1", timeout=60)
|
||||
bootstrap = client.get("/session", headers={"X-Access-Secret": self.secret})
|
||||
assert bootstrap.status_code == 200, bootstrap.text
|
||||
client.headers["X-CSRF-Token"] = bootstrap.json()["csrf_token"]
|
||||
return client
|
||||
|
||||
|
||||
def await_job(client: httpx.Client, job_id: str, states=("succeeded",)) -> dict:
|
||||
deadline = time.monotonic() + JOB_TIMEOUT_SECONDS
|
||||
snapshot: dict = {}
|
||||
while time.monotonic() < deadline:
|
||||
response = client.get(f"/jobs/{job_id}")
|
||||
if response.status_code == 200:
|
||||
snapshot = response.json()
|
||||
if snapshot["state"] in states:
|
||||
return snapshot
|
||||
time.sleep(0.5)
|
||||
raise AssertionError(f"job {job_id} never reached {states}: {snapshot}")
|
||||
|
||||
|
||||
def build_image(tag: str, *, revision: str | None = None) -> str:
|
||||
"""Build the application image; from a git revision's tree when one is named.
|
||||
|
||||
``revision`` is how the upgrade journey gets the *previous* version without a
|
||||
registry: the tree of that commit is the build context, so what it produces is the
|
||||
image that commit would have published.
|
||||
"""
|
||||
if revision is None:
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"build",
|
||||
"--build-arg",
|
||||
f"UID={os.getuid()}",
|
||||
"--build-arg",
|
||||
f"GID={os.getgid()}",
|
||||
"-t",
|
||||
tag,
|
||||
str(REPO),
|
||||
],
|
||||
check=True,
|
||||
timeout=UP_TIMEOUT_SECONDS,
|
||||
)
|
||||
return tag
|
||||
archive = subprocess.run(
|
||||
["git", "archive", "--format=tar", revision],
|
||||
cwd=REPO,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
timeout=300,
|
||||
).stdout
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"build",
|
||||
"--build-arg",
|
||||
f"UID={os.getuid()}",
|
||||
"--build-arg",
|
||||
f"GID={os.getgid()}",
|
||||
"-t",
|
||||
tag,
|
||||
"-",
|
||||
],
|
||||
input=archive,
|
||||
check=True,
|
||||
timeout=UP_TIMEOUT_SECONDS,
|
||||
)
|
||||
return tag
|
||||
|
||||
|
||||
needs_compose = pytest.mark.skipif(
|
||||
not compose_available(), reason="no Docker daemon with the compose plugin"
|
||||
)
|
||||
Reference in New Issue
Block a user