US08-02: Build a Reproducible Application Image (#97)
This commit was merged in pull request #97.
This commit is contained in:
@@ -15,10 +15,13 @@ application.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from photo_pipeline.config import Config
|
||||
from photo_pipeline.integrations import exiftool, immich_go
|
||||
from photo_pipeline.services import app_lock
|
||||
|
||||
# Below this much free space, mutating stages should stop rather than risk a
|
||||
@@ -26,6 +29,12 @@ from photo_pipeline.services import app_lock
|
||||
LOW_DISK_BYTES = 1_000_000_000
|
||||
CRITICAL_DISK_BYTES = 200_000_000
|
||||
|
||||
# Written into the container image at build time (US08-02). The image pins exiftool
|
||||
# and immich-go, and this file is how a running container reports which versions it
|
||||
# was built with — so a drifted or missing binary is visible here rather than in a
|
||||
# failed EXIF checkpoint or a misparsed upload report.
|
||||
IMAGE_VERSIONS_FILE = Path("/etc/photo-pipeline/versions.json")
|
||||
|
||||
|
||||
def _tree_bytes(path: Path) -> int:
|
||||
if not path.exists():
|
||||
@@ -69,8 +78,47 @@ def disk(path: Path) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _pinned_versions() -> dict[str, str]:
|
||||
"""The versions this image recorded at build time; empty outside a container."""
|
||||
try:
|
||||
recorded = json.loads(IMAGE_VERSIONS_FILE.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
if not isinstance(recorded, dict):
|
||||
return {}
|
||||
return {str(name): str(value) for name, value in recorded.items()}
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4)
|
||||
def _uploader_version(binary: str) -> str | None:
|
||||
"""Cached: the uploader cannot change version inside one process."""
|
||||
return immich_go.version(binary)
|
||||
|
||||
|
||||
def tools(config: Config) -> list[dict]:
|
||||
"""The external executables the pipeline shells out to, and their versions.
|
||||
|
||||
``pinned`` is what the image was built against, ``version`` is what is actually
|
||||
installed. They differ only when the binary was replaced or mounted over.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"name": "exiftool",
|
||||
"path": exiftool.find_binary(),
|
||||
"version": exiftool.version(),
|
||||
"pinned": _pinned_versions().get("exiftool"),
|
||||
},
|
||||
{
|
||||
"name": "immich-go",
|
||||
"path": immich_go.find_binary(config.immich_go_binary),
|
||||
"version": _uploader_version(config.immich_go_binary),
|
||||
"pinned": _pinned_versions().get("immich-go"),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def report(config: Config) -> dict:
|
||||
"""Sizes, disk headroom, warnings, and who currently holds the library lock."""
|
||||
"""Sizes, disk headroom, tool versions, warnings, and who holds the library lock."""
|
||||
database = config.database_path
|
||||
components = [
|
||||
_component("database", database),
|
||||
@@ -132,6 +180,23 @@ def report(config: Config) -> dict:
|
||||
}
|
||||
)
|
||||
|
||||
installed_tools = tools(config)
|
||||
for tool in installed_tools:
|
||||
# A missing tool is reported as ``version: null`` rather than warned about: on a
|
||||
# development machine the uploader is legitimately absent, and the stages that
|
||||
# need it already refuse to run. A *drifted* tool is different — the image pinned
|
||||
# a version and something replaced it.
|
||||
if tool["version"] and tool["pinned"] and tool["pinned"] not in tool["version"]:
|
||||
warnings.append(
|
||||
{
|
||||
"code": "tool_version_drift",
|
||||
"message": (
|
||||
f"{tool['name']} reports {tool['version']} but this image pinned "
|
||||
f"{tool['pinned']}"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
locks = {}
|
||||
for role in ("api", "worker"):
|
||||
holder = app_lock.LibraryLock(config, role).holder()
|
||||
@@ -151,6 +216,7 @@ def report(config: Config) -> dict:
|
||||
"components": components,
|
||||
"total_bytes": sum(component["bytes"] for component in components),
|
||||
"disk": space,
|
||||
"tools": installed_tools,
|
||||
"warnings": warnings,
|
||||
"locks": locks,
|
||||
"legacy_activity": legacy,
|
||||
|
||||
@@ -49,8 +49,16 @@ STAGES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("browser", ("tests/e2e",)),
|
||||
)
|
||||
|
||||
# Skips the gate accepts, because they describe the machine rather than the code.
|
||||
ALLOWED_SKIP_REASONS = ("exiftool not installed", "root ignores directory permissions")
|
||||
# Skips the gate accepts, because they describe the machine rather than the code. The
|
||||
# container ones (US08-02) belong here for the same reason exiftool does: the image
|
||||
# build needs a Docker daemon and the network, and its definition is still checked
|
||||
# offline in tests/integration/test_container_image.py.
|
||||
ALLOWED_SKIP_REASONS = (
|
||||
"exiftool not installed",
|
||||
"root ignores directory permissions",
|
||||
"no Docker daemon available",
|
||||
"bind-mount ownership is virtualised",
|
||||
)
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
|
||||
Reference in New Issue
Block a user