307 lines
11 KiB
Python
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:]]))
|