Files
photoanalyzer/tests/integration/test_compose_runtime.py

300 lines
13 KiB
Python

"""US08-03: the composition's contract, and the library-root check it depends on.
Bringing the stack up needs a Docker daemon and the network, which is what
``tests/e2e/test_compose_stack.py`` does. What can be checked without either is
checked here, because the parts that rot silently — a second writer that is only
prevented by convention, a data volume that stopped being the same volume for both
roles, migrations that stopped running first, a committed value in a file that must
carry none — are all readable from the files.
The startup refusal is the other half: in a container the configured library roots
must name the mount paths, and a mismatch has to fail before the lock is taken, not
at the first rename.
"""
from __future__ import annotations
import json
import os
import re
import stat
from pathlib import Path
import pytest
import yaml
from photo_pipeline import path_policy
from photo_pipeline.__main__ import main
from photo_pipeline.config import Config
REPO = Path(__file__).resolve().parents[2]
COMPOSE_FILE = REPO / "docker-compose.yml"
COMPOSE = yaml.safe_load(COMPOSE_FILE.read_text())
ENV_EXAMPLE = REPO / ".env.example"
SERVICES = COMPOSE["services"]
DATA_VOLUME = "data:/data"
def env_example_keys() -> list[str]:
"""The variables the example file declares, in file order."""
return [
line.split("=", 1)[0]
for line in ENV_EXAMPLE.read_text().splitlines()
if "=" in line and not line.lstrip().startswith("#")
]
# ── one API, one worker, one library, one volume ─────────────────────────────
def test_exactly_one_serving_and_one_working_container_from_the_same_image():
roles = {name: service["command"][0] for name, service in SERVICES.items()}
assert sorted(roles.values()) == ["migrate", "serve", "worker"]
assert [name for name, role in roles.items() if role == "serve"] == ["api"]
assert [name for name, role in roles.items() if role == "worker"] == ["worker"]
images = {service["image"] for service in SERVICES.values()}
assert len(images) == 1, "both roles must run the same build of the application"
assert "latest" not in images.pop()
# No `replicas`/`scale` key promising a second worker is fine; the lock decides.
assert not any("deploy" in service for service in SERVICES.values())
def test_both_roles_share_the_data_volume_so_the_lock_is_visible_to_both():
"""A second worker is refused by the library lock (US07-05) only if it can see it."""
for name, service in SERVICES.items():
assert DATA_VOLUME in service["volumes"], name
assert COMPOSE["volumes"]["data"]["driver"] == "local"
text = COMPOSE_FILE.read_text()
# The composition has to say why, because the failure is silent corruption.
assert "WAL" in text and re.search(r"NFS|SMB|network", text)
def test_the_library_is_a_bind_mount_whose_target_is_the_configured_root():
for name, service in SERVICES.items():
mounts = [volume for volume in service["volumes"] if volume != DATA_VOLUME]
assert len(mounts) == 1, name
source, target = re.match(r"^(\$\{.*?\}):(\$\{.*?\})$", mounts[0]).groups()
# An unset host path fails the composition rather than mounting something else.
assert source.startswith("${PHOTO_PIPELINE_LIBRARY_HOST_PATH:?")
# The container-side path and the configured root are one variable, so they
# cannot drift apart into a library that is mounted but not configured.
assert target.startswith("${PHOTO_PIPELINE_LIBRARY_ROOTS:?")
assert service["environment"]["PHOTO_PIPELINE_LIBRARY_ROOTS"] == (
"${PHOTO_PIPELINE_LIBRARY_ROOTS}"
)
assert service["environment"]["PHOTO_PIPELINE_DATA_DIR"] == "/data"
def test_migrations_run_to_completion_before_either_role_accepts_work():
"""`migrate` runs the backup-then-migrate path, and a failed upgrade exits
non-zero with its pre-migration backup intact — proven in
tests/integration/test_backup_recovery.py. What the composition adds is that
neither role starts until it succeeded."""
assert SERVICES["migrate"]["command"] == ["migrate"]
assert SERVICES["migrate"]["restart"] == "no", "a one-shot that retries is not a gate"
for role in ("api", "worker"):
assert SERVICES[role]["depends_on"] == {
"migrate": {"condition": "service_completed_successfully"}
}, role
def test_the_api_port_is_published_to_host_loopback_by_default():
published = SERVICES["api"]["ports"]
assert published == [
"${PHOTO_PIPELINE_PUBLISH_ADDRESS:-127.0.0.1}:${PHOTO_PIPELINE_PORT:-8000}:8000"
]
# Reachable from the host means reachable from elsewhere as far as the app is
# concerned, so the access secret stays mandatory (US08-01).
assert SERVICES["api"]["environment"]["PHOTO_PIPELINE_HOST"] == "0.0.0.0"
assert SERVICES["api"]["environment"]["PHOTO_PIPELINE_PORT"] == 8000
assert "PHOTO_PIPELINE_ACCESS_SECRET" not in SERVICES["api"]["environment"]
def test_containers_restart_by_themselves_and_stop_with_time_to_drain():
for role in ("api", "worker"):
assert SERVICES[role]["restart"] == "unless-stopped", role
assert SERVICES[role]["stop_grace_period"] == "30s", role
def test_the_containers_run_as_the_library_owner_and_never_as_root():
for name, service in SERVICES.items():
assert service["user"] == "${PHOTO_PIPELINE_UID:-1000}:${PHOTO_PIPELINE_GID:-1000}", name
assert service["build"]["args"]["UID"] == "${PHOTO_PIPELINE_UID:-1000}", name
# ── configuration comes from the environment, never from a committed file ────
def test_configuration_and_secrets_come_from_the_environment_only():
for name, service in SERVICES.items():
assert service["env_file"] == ["${PHOTO_PIPELINE_ENV_FILE:-.env}"], name
for key, value in service["environment"].items():
# Every value is either a variable reference or a property of the
# composition itself (the volume path, the container's own port).
composed = isinstance(value, int) or value in ("/data", "0.0.0.0")
assert composed or value.startswith("${"), (name, key, value)
assert not (REPO / ".env").is_file() or ".env" in (REPO / ".gitignore").read_text()
def test_the_example_file_lists_every_setting_and_carries_no_values():
declared = env_example_keys()
assert declared == sorted(set(declared), key=declared.index), "no variable twice"
for line in ENV_EXAMPLE.read_text().splitlines():
if "=" in line and not line.lstrip().startswith("#"):
assert line.endswith("="), f"a value in the example file: {line}"
expected = {f"PHOTO_PIPELINE_{name.upper()}" for name in Config.model_fields}
assert expected <= set(declared), sorted(expected - set(declared))
# And every variable the composition substitutes is documented there too.
substituted = set(re.findall(r"\$\{(PHOTO_PIPELINE_[A-Z_]+)", COMPOSE_FILE.read_text()))
assert substituted <= set(declared), sorted(substituted - set(declared))
def test_the_example_file_is_not_a_dotenv_that_could_be_loaded_by_accident():
"""`.env.example` must not be what `.env` is: no values means nothing to leak."""
assert ENV_EXAMPLE.name != ".env"
parsed = {k: v for k, v in _parse(ENV_EXAMPLE.read_text()).items() if v}
assert parsed == {}
def _parse(text: str) -> dict[str, str]:
from photo_pipeline.config import parse_env_file
return parse_env_file(text)
# ── the lock across container lifetimes ──────────────────────────────────────
def test_a_lock_left_by_a_container_that_is_gone_does_not_block_the_restart(
tmp_path, monkeypatch
):
"""A restarted container is a new hostname and a recycled pid 1, so the record
in the lock file proves nothing; the kernel's flock does (US08-03)."""
from photo_pipeline.services.app_lock import LibraryLock
config = Config(data_dir=tmp_path / "data", library_roots=(tmp_path,))
(tmp_path / "data").mkdir()
(tmp_path / "data" / "worker.lock.json").write_text(
json.dumps(
{
"lock_version": 1,
"role": "worker",
"pid": 1, # pid 1 of a container that no longer exists
"host": "3f2a1b9c4d5e", # its hostname was its container id
"started_at": "2026-01-01T00:00:00+00:00",
"library_roots": ["/library"],
}
)
)
monkeypatch.setattr(path_policy, "in_container", lambda: True)
taken = LibraryLock(config, "worker").acquire()
assert taken.pid == os.getpid(), "the worker must come back after a restart"
def test_a_second_worker_is_still_refused_while_the_first_holds_the_lock(tmp_path, monkeypatch):
"""The other half: the same flock refuses a concurrent second writer, whether it
is a process or another container of the same composition."""
from photo_pipeline.services.app_lock import LibraryLock, LockHeld
config = Config(data_dir=tmp_path / "data", library_roots=(tmp_path,))
monkeypatch.setattr(path_policy, "in_container", lambda: True)
first = LibraryLock(config, "worker")
first.acquire()
with pytest.raises(LockHeld, match="worker is already running"):
LibraryLock(config, "worker").acquire()
first.release()
LibraryLock(config, "worker").acquire() # free again
# ── the startup check the mount depends on ───────────────────────────────────
def test_configured_roots_that_are_mounted_and_writable_are_accepted(tmp_path):
assert path_policy.roots_refusal([tmp_path]) is None
assert path_policy.roots_refusal([]) is None, "no roots is a configuration, not a fault"
def test_an_unmounted_library_root_is_refused_by_name(tmp_path):
refusal = path_policy.roots_refusal([tmp_path / "srv" / "photos"])
assert refusal is not None
assert "does not exist" in refusal and "PHOTO_PIPELINE_LIBRARY_ROOTS" in refusal
def test_a_root_that_is_not_a_directory_or_not_readable_is_refused(tmp_path):
a_file = tmp_path / "photos.txt"
a_file.write_text("not a library")
assert "not a directory" in path_policy.roots_refusal([a_file])
unreadable = tmp_path / "unreadable"
unreadable.mkdir()
unreadable.chmod(0o000)
try:
refusal = path_policy.roots_refusal([unreadable])
finally:
unreadable.chmod(0o755)
if os.getuid() != 0: # root ignores the mode, and CI may well be root
assert refusal is not None and "not readable" in refusal
def test_an_unwritable_root_is_not_refused_here(tmp_path):
"""A bind mount's ownership is virtualised on macOS and Windows, so os.access
would refuse a working deployment. The real errno at the first rename is at
least true; this check is about the mount, not the mode."""
read_only = tmp_path / "read-only"
read_only.mkdir()
read_only.chmod(stat.S_IRUSR | stat.S_IXUSR)
try:
assert path_policy.roots_refusal([read_only]) is None
finally:
read_only.chmod(0o755)
def test_in_a_container_a_root_that_was_never_mounted_is_refused(tmp_path, monkeypatch):
"""The container-only failure: the path exists, but it belongs to the image."""
unmounted = tmp_path / "library"
(unmounted / "album").mkdir(parents=True)
refusal = path_policy.roots_refusal([unmounted], require_mount=True)
assert refusal is not None and "not on a mounted filesystem" in refusal
# A bind mount is a mount point, and a root *below* one is mounted too: a
# deployment may mount /srv and configure /srv/photos.
monkeypatch.setattr(os.path, "ismount", lambda path: Path(path) == unmounted.resolve())
assert path_policy.roots_refusal([unmounted], require_mount=True) is None
assert path_policy.roots_refusal([unmounted / "album"], require_mount=True) is None
@pytest.mark.parametrize("role", ["serve", "worker"])
def test_a_root_mismatch_refuses_at_startup_before_any_lock_is_taken(
role, tmp_path, monkeypatch, capsys
):
data = tmp_path / "data"
monkeypatch.setenv("PHOTO_PIPELINE_DATA_DIR", str(data))
monkeypatch.setenv("PHOTO_PIPELINE_LIBRARY_ROOTS", str(tmp_path / "not-mounted"))
monkeypatch.setattr(path_policy, "in_container", lambda: True)
assert main([role]) == 5
assert "does not exist" in capsys.readouterr().err
assert not list(data.glob("*.lock.json")), "nothing started, so nothing is locked"
@pytest.mark.parametrize("role", ["serve", "worker"])
def test_an_unmounted_root_on_a_host_is_not_a_reason_to_refuse(role, tmp_path, monkeypatch):
"""An archive medium that is not plugged in is a Tuesday, not a misconfiguration:
refusing would take the offline half of the library away with it (concept §9)."""
monkeypatch.setenv("PHOTO_PIPELINE_DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("PHOTO_PIPELINE_LIBRARY_ROOTS", str(tmp_path / "not-mounted"))
monkeypatch.setenv("PHOTO_PIPELINE_ACCESS_SECRET", "unused-on-loopback")
monkeypatch.setattr(path_policy, "in_container", lambda: False)
# Reaching the lock is the proof: that is the next thing either role does, and
# stopping there keeps the test out of a uvicorn/worker loop.
monkeypatch.setattr("photo_pipeline.__main__._acquire", lambda *_, **__: 99)
assert main([role]) == 99