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