Files
photoanalyzer/tests/e2e/test_container_runtime.py

307 lines
11 KiB
Python

"""US08-02: the built image, actually built and actually run.
This is the acceptance test for the image itself, so nothing here is faked: Docker
builds from a clean context, the container starts under a chosen UID/GID against a
mounted data directory, and the assertions are made over HTTP and against the files
the container left on the host.
It is skipped without a Docker daemon — the build also needs the network for the base
image, the pinned exiftool package, and the pinned uploader release. The contract the
Dockerfile itself has to keep (pins, non-root, health target, build context) is checked
offline in ``tests/integration/test_container_image.py``, so a machine without Docker
still fails on a broken image definition; only the running proof needs the daemon. CI
builds the image on every change (US08-04), which is where this runs unskipped.
"""
from __future__ import annotations
import json
import os
import platform
import re
import socket
import subprocess
import sys
import time
from pathlib import Path
import httpx
import pytest
REPO = Path(__file__).resolve().parents[2]
IMAGE = "photo-pipeline-test:us08-02"
SECRET = "container-acceptance-secret"
HOSTNAME = "photos.test"
READY_TIMEOUT_SECONDS = 120
BUILD_TIMEOUT_SECONDS = 30 * 60
pytestmark = pytest.mark.container
def docker_available() -> bool:
try:
return subprocess.run(["docker", "info"], capture_output=True, timeout=60).returncode == 0
except (OSError, subprocess.SubprocessError):
return False
needs_docker = pytest.mark.skipif(not docker_available(), reason="no Docker daemon available")
def docker(*args: str, check: bool = True, timeout: int = 120) -> subprocess.CompletedProcess:
result = subprocess.run(
["docker", *args], capture_output=True, text=True, timeout=timeout
)
if check and result.returncode != 0:
raise AssertionError(f"docker {' '.join(args)} failed:\n{result.stdout}\n{result.stderr}")
return result
def pins() -> dict[str, str]:
"""The pinned versions, read from the Dockerfile that produced the image."""
text = (REPO / "Dockerfile").read_text()
found = dict(re.findall(r"^ARG\s+([A-Z0-9_]+)=(.+)$", text, re.MULTILINE))
return {
# The Debian package version carries a packaging suffix; exiftool reports the
# upstream version only.
"exiftool": found["EXIFTOOL_VERSION"].split("+")[0].split("-")[0],
"immich-go": found["IMMICH_GO_VERSION"],
}
def free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
@pytest.fixture(scope="module")
def image() -> str:
"""Build from a clean checkout: the build context is the repository, unmodified."""
if not docker_available():
pytest.skip("no Docker daemon available")
docker(
"build",
"--build-arg",
f"UID={os.getuid()}",
"--build-arg",
f"GID={os.getgid()}",
"-t",
IMAGE,
str(REPO),
timeout=BUILD_TIMEOUT_SECONDS,
)
return IMAGE
@pytest.fixture
def data_dir(tmp_path) -> Path:
data = tmp_path / "data"
data.mkdir()
return data
def run_detached(image: str, port: int, *args: str, data: Path | None = None) -> str:
"""Start a container. ``data`` bind-mounts the host's data directory when the test
is about the files themselves; otherwise the image's own /data is used, because a
macOS bind mount arrives with an ownership the container did not choose."""
result = docker(
"run",
"--detach",
"--rm",
"--publish",
f"127.0.0.1:{port}:8000",
*(("--volume", f"{data}:/data") if data is not None else ()),
"--env",
# Reachable from outside the container means reachable from another machine as
# far as the application is concerned, so the access secret is mandatory
# (US08-01) — the image must not weaken that.
"PHOTO_PIPELINE_HOST=0.0.0.0",
"--env",
f"PHOTO_PIPELINE_ACCESS_SECRET={SECRET}",
"--env",
f"PHOTO_PIPELINE_ALLOWED_HOSTS={HOSTNAME}",
image,
*args,
)
return result.stdout.strip()
def wait_until_ready(base: str, container: str) -> None:
deadline = time.monotonic() + READY_TIMEOUT_SECONDS
while time.monotonic() < deadline:
try:
if httpx.get(f"{base}/api/v1/health/ready", timeout=5).status_code == 200:
return
except httpx.HTTPError:
pass
if docker("inspect", "-f", "{{.State.Running}}", container, check=False).stdout.strip() in (
"false",
"",
):
break
time.sleep(0.5)
logs = docker("logs", container, check=False)
raise AssertionError(f"container never became ready:\n{logs.stdout}\n{logs.stderr}")
@pytest.fixture
def serving(image):
port = free_port()
container = run_detached(image, port, "serve")
try:
base = f"http://127.0.0.1:{port}"
wait_until_ready(base, container)
yield base, container
finally:
docker("rm", "--force", container, check=False)
def session(base: str) -> httpx.Client:
client = httpx.Client(base_url=base, timeout=30)
bootstrap = client.get("/api/v1/session", headers={"X-Access-Secret": SECRET})
assert bootstrap.status_code == 200, bootstrap.text
client.headers["X-CSRF-Token"] = bootstrap.json()["csrf_token"]
return client
# ── the image serves, and says what it contains ──────────────────────────────
@needs_docker
def test_the_container_serves_the_frontend_and_the_pinned_tool_versions(serving):
base, container = serving
index = httpx.get(f"{base}/app/index.html", timeout=30)
assert index.status_code == 200
assert "<title" in index.text.lower(), "the application shell, not an API error"
client = session(base)
try:
tools = {tool["name"]: tool for tool in client.get("/api/v1/diagnostics").json()["tools"]}
finally:
client.close()
for name, pinned in pins().items():
assert tools[name]["pinned"] == pinned, name
# Recorded *and* installed: the reported version comes from running the binary.
assert pinned in tools[name]["version"], (name, tools[name])
assert tools[name]["path"], f"{name} is not on PATH inside the image"
logs = docker("logs", container, check=False)
assert SECRET not in logs.stdout + logs.stderr, "the access secret never reaches the log"
@needs_docker
def test_the_declared_health_check_reports_readiness(serving):
"""The declared HEALTHCHECK is readiness, so Docker's own verdict is the assertion."""
_, container = serving
deadline = time.monotonic() + READY_TIMEOUT_SECONDS
status = ""
while time.monotonic() < deadline:
status = docker(
"inspect", "-f", "{{.State.Health.Status}}", container, check=False
).stdout.strip()
if status == "healthy":
break
time.sleep(1)
assert status == "healthy"
probe = docker("exec", container, "/usr/local/bin/healthcheck.sh", check=False)
assert probe.returncode == 0
# Point the probe at a port nothing serves: the same script must fail, which is
# what makes the healthy verdict above evidence rather than a default.
unready = docker(
"exec",
"--env",
"PHOTO_PIPELINE_PORT=1",
container,
"/usr/local/bin/healthcheck.sh",
check=False,
)
assert unready.returncode != 0
# ── identity: never root, always the configured owner ────────────────────────
@needs_docker
def test_the_container_refuses_to_run_as_root(image, data_dir):
result = docker(
"run",
"--rm",
"--user",
"0:0",
"--volume",
f"{data_dir}:/data",
image,
"diagnostics",
check=False,
)
assert result.returncode != 0
assert "refusing to run as root" in result.stderr + result.stdout
assert not list(data_dir.iterdir()), "a refused container writes nothing"
@needs_docker
def test_what_the_container_writes_is_owned_by_the_build_arguments(image):
"""The identity the image was built with is the identity on disk afterwards.
Asserted from inside the container so it holds on every host: a macOS bind mount
reports an ownership the container never chose. The host-side proof, which is what
the mounted library actually needs, is the Linux test below.
"""
port = free_port()
container = run_detached(image, port, "serve")
try:
wait_until_ready(f"http://127.0.0.1:{port}", container)
owner = docker(
"exec", container, "stat", "-c", "%u:%g", "/data/photo_pipeline.db"
).stdout.strip()
assert owner == f"{os.getuid()}:{os.getgid()}"
assert docker("exec", container, "id", "-u").stdout.strip() == str(os.getuid())
finally:
docker("rm", "--force", container, check=False)
@needs_docker
@pytest.mark.skipif(
platform.system() != "Linux",
reason="bind-mount ownership is virtualised by Docker Desktop on macOS/Windows",
)
def test_files_the_container_writes_keep_the_configured_ownership(image, data_dir):
docker("run", "--rm", "--volume", f"{data_dir}:/data", image, "migrate", timeout=300)
written = sorted(path for path in data_dir.rglob("*") if path.is_file())
assert written, "migrate creates the database in the mounted data directory"
for path in written:
assert (path.stat().st_uid, path.stat().st_gid) == (os.getuid(), os.getgid()), path
@needs_docker
def test_the_worker_role_runs_from_the_same_image(image, data_dir):
"""One image, two roles: the worker is the same entrypoint with another argument."""
port = free_port()
container = run_detached(image, port, "worker", "--id", "container-worker")
try:
# Taking the worker's library lock is the observable proof that it started,
# migrated, and reached its job loop — no sleep required (US07-05).
deadline = time.monotonic() + READY_TIMEOUT_SECONDS
lock = ""
while not lock and time.monotonic() < deadline:
assert docker("inspect", "-f", "{{.State.Running}}", container).stdout.strip() == (
"true"
), docker("logs", container, check=False).stdout
lock = docker("exec", container, "cat", "/data/worker.lock.json", check=False).stdout
time.sleep(0.5)
assert lock, docker("logs", container, check=False).stdout
assert json.loads(lock)["role"] == "worker"
role = docker("exec", container, "cat", "/tmp/photo-pipeline-role").stdout.strip()
assert role == "worker", "the health check can tell which role this container is"
finally:
docker("rm", "--force", container, check=False)
if __name__ == "__main__": # a quick way to run just this file
raise SystemExit(pytest.main([__file__, "-v", *sys.argv[1:]]))