Files
photoanalyzer/tests/integration/test_container_image.py

285 lines
12 KiB
Python

"""US08-02: the image's build contract, its entrypoint, and its health check.
Building the image needs a Docker daemon and the network, which is what
``tests/e2e/test_container_runtime.py`` does. Everything that can be checked without
either is checked here, because the parts most likely to rot silently — a pin that
stopped being a pin, a build context that started including the library, a health
check pointed at liveness instead of readiness — are all readable from the files.
The entrypoint and health check are shell, so they are exercised as shell: run with a
stubbed ``id`` and ``python`` on ``PATH``, which is enough to prove the refusal, the
role marker, and the argument pass-through without a container.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import stat
import subprocess
import sys
from pathlib import Path
import pytest
from photo_pipeline.config import Config
from photo_pipeline.services import diagnostics
REPO = Path(__file__).resolve().parents[2]
DOCKERFILE = (REPO / "Dockerfile").read_text()
DOCKERIGNORE = (REPO / ".dockerignore").read_text()
ENTRYPOINT = REPO / "docker" / "entrypoint.sh"
HEALTHCHECK = REPO / "docker" / "healthcheck.sh"
SHA256 = re.compile(r"^[0-9a-f]{64}$")
def instructions(text: str) -> list[str]:
"""The lines that do something: comments explain, they do not build."""
return [line.strip() for line in text.splitlines() if line.strip() and not line.startswith("#")]
def build_args() -> dict[str, str]:
"""Every ``ARG name=default`` in the Dockerfile — the pins, in other words."""
found = {}
for match in re.finditer(r"^ARG\s+([A-Z0-9_]+)=(.+)$", DOCKERFILE, re.MULTILINE):
found[match.group(1)] = match.group(2).strip()
return found
# ── pins ─────────────────────────────────────────────────────────────────────
def test_the_base_image_is_pinned_by_version_and_digest():
base = build_args()["PYTHON_IMAGE"]
assert base.startswith("python:3.12.")
assert "@sha256:" in base, "a tag can be moved; a digest cannot"
assert ":latest" not in DOCKERFILE
# Both stages build from the same pinned base, so the tool that was verified in one
# is the tool that ships in the other.
assert DOCKERFILE.count("FROM ${PYTHON_IMAGE}") == 2
def test_exiftool_and_the_uploader_are_pinned_and_verified():
args = build_args()
assert re.match(r"^\d+\.\d+", args["EXIFTOOL_VERSION"])
assert re.match(r"^\d+\.\d+\.\d+$", args["IMMICH_GO_VERSION"])
for arch in ("AMD64", "ARM64"):
assert SHA256.match(args[f"IMMICH_GO_SHA256_{arch}"]), arch
# The pinned exiftool package is installed by version, not by name alone.
assert 'libimage-exiftool-perl=${EXIFTOOL_VERSION}"' in DOCKERFILE
# And the build fails if what got installed is not what was pinned.
assert "is not the pinned" in DOCKERFILE and "is not pinned" in DOCKERFILE
def test_the_uploader_download_refuses_a_mismatching_checksum(tmp_path):
"""The verification is the point of pinning a URL, so it is run, not read."""
script = REPO / "docker" / "fetch-immich-go.py"
result = subprocess.run(
[
sys.executable,
str(script),
"--version",
"0.0.0-does-not-exist",
"--sha256-amd64",
"0" * 64,
"--sha256-arm64",
"0" * 64,
"--into",
str(tmp_path),
],
capture_output=True,
text=True,
)
assert result.returncode != 0
assert not list(tmp_path.iterdir()), "nothing is written before it is verified"
def test_the_recorded_versions_are_reported_by_diagnostics(tmp_path, monkeypatch):
"""What the image records is what `diagnostics` answers with (acceptance criterion 2)."""
recorded = tmp_path / "versions.json"
recorded.write_text(json.dumps({"exiftool": "13.25", "immich-go": "0.32.0"}))
monkeypatch.setattr(diagnostics, "IMAGE_VERSIONS_FILE", recorded)
monkeypatch.setattr(diagnostics.exiftool, "version", lambda: "13.25")
monkeypatch.setattr(diagnostics, "_uploader_version", lambda _binary: "immich-go 0.32.0")
config = Config(data_dir=tmp_path / "data")
reported = {tool["name"]: tool for tool in diagnostics.tools(config)}
assert reported["exiftool"]["pinned"] == "13.25"
assert reported["immich-go"]["pinned"] == "0.32.0"
assert "0.32.0" in reported["immich-go"]["version"]
assert diagnostics.report(config)["tools"] == list(reported.values())
assert "tool_version_drift" not in {w["code"] for w in diagnostics.report(config)["warnings"]}
def test_a_replaced_tool_is_reported_as_drift(tmp_path, monkeypatch):
recorded = tmp_path / "versions.json"
recorded.write_text(json.dumps({"exiftool": "13.25"}))
monkeypatch.setattr(diagnostics, "IMAGE_VERSIONS_FILE", recorded)
monkeypatch.setattr(diagnostics.exiftool, "version", lambda: "12.57")
report = diagnostics.report(Config(data_dir=tmp_path / "data"))
drift = [w for w in report["warnings"] if w["code"] == "tool_version_drift"]
assert drift and "13.25" in drift[0]["message"] and "12.57" in drift[0]["message"]
def test_versions_are_absent_rather_than_invented_outside_a_container(tmp_path, monkeypatch):
monkeypatch.setattr(diagnostics, "IMAGE_VERSIONS_FILE", tmp_path / "nothing.json")
for tool in diagnostics.tools(Config(data_dir=tmp_path / "data")):
assert tool["pinned"] is None
# ── the final layer ──────────────────────────────────────────────────────────
def test_no_test_or_build_tooling_is_installed_in_the_image():
runtime = "\n".join(instructions(DOCKERFILE.split("AS runtime", 1)[1]))
for unwanted in ("[test]", "pytest", "playwright", "build-essential", "gcc"):
assert unwanted not in runtime, unwanted
assert "pip install --no-cache-dir -e ." in runtime
def test_neither_secrets_nor_library_data_can_enter_the_build_context():
lines = instructions(DOCKERIGNORE)
assert lines[0] == "*", "the context is deny-by-default"
allowed = {line[1:] for line in lines if line.startswith("!")}
# Everything the Dockerfile copies has to be allowed, and nothing else is.
copied = {
source
for match in re.finditer(r"^COPY (?!--from)(.+)$", DOCKERFILE, re.MULTILINE)
for source in match.group(1).split()[:-1]
}
assert {Path(source).parts[0] for source in copied} <= allowed
assert not {"data", ".git", ".env", "tests", ".venv"} & allowed
for generated in ("**/*.env", "**/*.db", "**/*.log", "**/__pycache__"):
assert generated in lines, generated
def test_the_image_runs_as_a_non_root_user_whose_ids_are_build_arguments():
args = build_args()
assert args["UID"] == "1000" and args["GID"] == "1000"
assert "USER ${UID}:${GID}" in DOCKERFILE
assert re.search(r"^USER (root|0)", DOCKERFILE, re.MULTILINE) is None
assert 'useradd --uid "${UID}" --gid "${GID}"' in DOCKERFILE
def test_the_health_check_is_readiness_and_the_default_role_is_serve():
assert "HEALTHCHECK" in DOCKERFILE
assert "/usr/local/bin/healthcheck.sh" in DOCKERFILE
assert 'CMD ["serve"]' in DOCKERFILE
assert 'ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]' in DOCKERFILE
assert "/api/v1/health/ready" in HEALTHCHECK.read_text()
assert "/api/v1/health/live" not in HEALTHCHECK.read_text()
# No supervisor: one role per container (acceptance criterion 4).
for supervisor in ("supervisord", "s6-overlay", "runit"):
assert supervisor not in DOCKERFILE
@pytest.mark.parametrize("script", [ENTRYPOINT, HEALTHCHECK])
def test_the_scripts_are_executable(script):
assert script.stat().st_mode & stat.S_IXUSR, f"{script.name} must be executable in git"
# ── the entrypoint, run as shell ─────────────────────────────────────────────
@pytest.fixture
def stubs(tmp_path):
"""A PATH where ``python`` records its arguments and ``id`` can be told a UID."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
recorded = tmp_path / "argv"
python = bin_dir / "python"
python.write_text(f'#!/bin/sh\nprintf "%s\\n" "$@" > {recorded}\nexit 0\n')
python.chmod(0o755)
(bin_dir / "id").write_text('#!/bin/sh\nprintf "%s" "${STUB_UID:-1000}"\n')
(bin_dir / "id").chmod(0o755)
return bin_dir, recorded, tmp_path / "role"
def run_script(script: Path, *args, stubs, env=None):
bin_dir, recorded, role_file = stubs
result = subprocess.run(
["/bin/sh", str(script), *args],
capture_output=True,
text=True,
env={
"PATH": f"{bin_dir}:{os.environ['PATH']}",
"PHOTO_PIPELINE_ROLE_FILE": str(role_file),
**(env or {}),
},
)
argv = recorded.read_text().splitlines() if recorded.exists() else []
return result, argv
def test_the_container_refuses_to_run_as_root(stubs):
result, argv = run_script(ENTRYPOINT, "serve", stubs=stubs, env={"STUB_UID": "0"})
assert result.returncode == 1
assert "refusing to run as root" in result.stderr
assert argv == [], "the application is never started as root"
assert not stubs[2].exists(), "not even the role marker is written"
@pytest.mark.parametrize(
"given,expected",
[
(["serve"], ["-m", "photo_pipeline", "serve"]),
(["worker", "--id", "worker-2"], ["-m", "photo_pipeline", "worker", "--id", "worker-2"]),
# Every other management command stays reachable: operating the container is
# operating the same CLI.
(["diagnostics"], ["-m", "photo_pipeline", "diagnostics"]),
([], ["-m", "photo_pipeline"]),
],
)
def test_the_role_selects_the_command_and_arguments_pass_through(given, expected, stubs):
result, argv = run_script(ENTRYPOINT, *given, stubs=stubs)
assert result.returncode == 0, result.stderr
assert argv == expected
def test_the_role_is_recorded_for_the_health_check(stubs):
run_script(ENTRYPOINT, "worker", stubs=stubs)
assert stubs[2].read_text() == "worker"
def test_the_health_check_only_probes_the_serving_role(stubs):
stubs[2].write_text("worker")
result, argv = run_script(HEALTHCHECK, stubs=stubs)
assert result.returncode == 0 and argv == [], "a worker has no endpoint to probe"
stubs[2].write_text("serve")
result, argv = run_script(HEALTHCHECK, stubs=stubs, env={"PHOTO_PIPELINE_PORT": "9123"})
assert result.returncode == 0, result.stderr
assert argv == ["-", "9123"], "the configured port is the one probed"
def test_the_health_check_fails_while_the_api_is_not_ready(stubs):
"""No python stub: the real interpreter probes a port nothing is listening on."""
stubs[2].write_text("serve")
result = subprocess.run(
["/bin/sh", str(HEALTHCHECK)],
capture_output=True,
text=True,
env={
"PATH": os.path.dirname(sys.executable) + os.pathsep + os.environ["PATH"],
"PHOTO_PIPELINE_ROLE_FILE": str(stubs[2]),
"PHOTO_PIPELINE_PORT": "1",
},
)
assert result.returncode == 1
assert "not ready" in result.stderr
def test_the_scripts_are_posix_shell():
"""They run in the image's /bin/sh, which is dash — not bash."""
shells = ["/bin/sh"] + ([dash] if (dash := shutil.which("dash")) else [])
for shell in shells:
for script in (ENTRYPOINT, HEALTHCHECK):
checked = subprocess.run([shell, "-n", str(script)], capture_output=True, text=True)
assert checked.returncode == 0, f"{shell} {script.name}: {checked.stderr}"