285 lines
12 KiB
Python
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}"
|