Files
photoanalyzer/photo_pipeline/services/release.py

400 lines
15 KiB
Python

"""The release gate, the real-library dry run, and the approval that unlocks
mutation (US07-07, concept §18 release gates).
Three things live here because they are one decision:
1. **The gate** — one command that provisions an isolated stack, runs every suite in
a fixed order, and retains versioned evidence with checksums. A release is not
"the tests passed on my machine last Tuesday"; it is a report that says which
revision, which suites, how long, and what the artefacts hash to.
2. **The dry run** — a strictly read-only pass over the real photo library that
answers "what would this application do to it?" before it is allowed to do
anything. It opens no file for writing, creates no database rows, and touches no
metadata; it counts, classifies, and reconciles against whatever the database
already knows.
3. **The approval** — a person reads that report and signs it off for exactly the
library roots it describes. Until then, with
``PHOTO_PIPELINE_REQUIRE_DRY_RUN_APPROVAL`` set, every mutating request is
refused. Change the roots, or produce a newer report, and the approval no longer
matches: it approves *that* reconciliation, not the idea of mutating.
"""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
import time
from collections import Counter
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from photo_pipeline import path_policy
from photo_pipeline.config import Config
SCHEMA_VERSION = 1
APPROVAL_NAME = "dry-run-approval.json"
CHECKSUMS_NAME = "CHECKSUMS.sha256"
REPORT_NAME = "release-report.json"
# The suites, in the order a failure is cheapest to read: units before the stacks
# they compose. ``label`` is what the report and the operator see.
STAGES: tuple[tuple[str, tuple[str, ...]], ...] = (
("unit", ("tests/unit",)),
("characterization", ("tests/characterization",)),
("integration", ("tests/integration",)),
("browser", ("tests/e2e",)),
)
# 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):
pass
def _now() -> datetime:
return datetime.now(timezone.utc)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def sha256_bytes(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
def revision() -> str | None:
"""The commit this gate ran against, when the tree is a git checkout."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
timeout=10,
cwd=str(Path(__file__).resolve().parents[2]),
)
except (OSError, subprocess.SubprocessError):
return None
return result.stdout.strip() or None
# ── the story matrix ─────────────────────────────────────────────────────────
def story_matrix(repo: Path | None = None) -> dict:
"""Every backlog story, and how it is covered.
A story is ``delivered`` (mapped to test files that exist) or ``planned`` (an
accepted, not-yet-implemented story). Anything else — a story file nobody
mapped, or a mapping to a file that is gone — is a hole in the matrix, and the
gate fails on it rather than reporting a green run over missing coverage.
"""
repo = repo or Path(__file__).resolve().parents[2]
traceability = json.loads((repo / "tests" / "story_traceability.json").read_text())
mapped: dict[str, list[str]] = traceability["stories"]
planned: list[str] = traceability.get("planned", [])
stories = sorted(
"-".join(path.stem.split("-")[:2])
for path in (repo / "delivery_backlog" / "stories").glob("US*.md")
)
missing_tests = [
f"{story}: {rel}"
for story, files in mapped.items()
for rel in files
if not (repo / rel).is_file()
]
unmapped = [s for s in stories if s not in mapped and s not in planned]
unknown = [s for s in list(mapped) + planned if s not in stories]
overlap = sorted(set(mapped) & set(planned))
return {
"stories": len(stories),
"delivered": sorted(mapped),
"planned": sorted(planned),
"problems": [
*(f"story with no tests and not planned: {s}" for s in unmapped),
*(f"mapped test file is missing — {entry}" for entry in missing_tests),
*(f"mapped story is not in the backlog: {s}" for s in unknown),
*(f"story is both delivered and planned: {s}" for s in overlap),
],
}
# ── the gate ─────────────────────────────────────────────────────────────────
@dataclass
class StageResult:
label: str
command: list[str]
returncode: int
seconds: float
summary: str
skipped: list[str]
def as_dict(self) -> dict:
return {
"stage": self.label,
"command": self.command,
"returncode": self.returncode,
"seconds": round(self.seconds, 2),
"summary": self.summary,
"skipped": self.skipped,
"ok": self.returncode == 0,
}
def _run_stage(label: str, paths: tuple[str, ...], *, repo: Path, log_dir: Path) -> StageResult:
command = [sys.executable, "-m", "pytest", *paths, "-q", "-rs"]
started = time.monotonic()
result = subprocess.run(command, cwd=str(repo), capture_output=True, text=True)
elapsed = time.monotonic() - started
output = result.stdout + result.stderr
(log_dir / f"{label}.log").write_text(output)
lines = [line for line in output.splitlines() if line.strip()]
summary = lines[-1] if lines else ""
skipped = [line for line in lines if line.startswith("SKIPPED")]
return StageResult(label, command, result.returncode, elapsed, summary, skipped)
def unexpected_skips(results: list[StageResult]) -> list[str]:
"""Skips the gate will not accept: everything but the documented environment ones."""
return [
line
for result in results
for line in result.skipped
if not any(reason in line for reason in ALLOWED_SKIP_REASONS)
]
def run_gate(
config: Config,
*,
output: Path | str | None = None,
stages: tuple[tuple[str, tuple[str, ...]], ...] = STAGES,
repo: Path | None = None,
) -> dict:
"""Run every suite in an isolated stack and retain checksummed evidence.
The stack is isolated by construction: each pytest run builds its own temporary
data directories and libraries, so the gate never reads or writes the operator's
photos. What it keeps afterwards is the report, the per-stage logs, and a
checksum file over both.
"""
repo = repo or Path(__file__).resolve().parents[2]
directory = Path(output) if output else Path(config.data_dir) / "release" / _now().strftime(
"%Y%m%dT%H%M%SZ"
)
logs = directory / "logs"
logs.mkdir(parents=True, exist_ok=True)
matrix = story_matrix(repo)
results = [_run_stage(label, paths, repo=repo, log_dir=logs) for label, paths in stages]
skips = unexpected_skips(results)
report = {
"schema_version": SCHEMA_VERSION,
"started_at": _now().isoformat(),
"revision": revision(),
"python": sys.version.split()[0],
"platform": os.uname().sysname,
"matrix": matrix,
"stages": [result.as_dict() for result in results],
"unexpected_skips": skips,
"failures": [result.label for result in results if result.returncode != 0],
}
report["ok"] = not report["failures"] and not matrix["problems"] and not skips
report["finished_at"] = _now().isoformat()
(directory / REPORT_NAME).write_text(json.dumps(report, indent=2))
# The evidence is only evidence if it can be shown to be the evidence that was
# produced. Checksums are the honest version of "signed" without a key: a real
# signature belongs to whatever key management the release actually has.
checksums = "\n".join(
f"{sha256_file(path)} {path.relative_to(directory)}"
for path in sorted(directory.rglob("*"))
if path.is_file() and path.name != CHECKSUMS_NAME
)
(directory / CHECKSUMS_NAME).write_text(checksums + "\n")
report["evidence"] = str(directory)
return report
# ── the real-library dry run ─────────────────────────────────────────────────
def dry_run(config: Config, *, roots: tuple[Path, ...] | None = None) -> dict:
"""Read-only reconciliation of the configured library. Changes nothing.
Opens no file for writing, writes no database row, and reads only what
``os.stat`` and the existing database already say. The point is to be able to
look at a real library — the one with the irreplaceable photos in it — and see
what the application believes about it before it is allowed to act.
"""
roots = roots or tuple(Path(root) for root in config.library_roots)
if not roots:
raise ReleaseError("no library roots are configured")
by_extension: Counter = Counter()
folders: set[str] = set()
files: list[str] = []
unreadable: list[str] = []
excluded = 0
total_bytes = 0
for root in roots:
if not Path(root).is_dir():
raise ReleaseError(f"library root {root} is not a directory")
for path in sorted(Path(root).rglob("*")):
if path.is_dir():
# Never traverse into an excluded directory, and never report its
# contents: proving exclusion must not require opening it.
if path_policy.is_excluded(path):
excluded += 1
continue
if path_policy.is_excluded(path):
continue
try:
stat = path.stat()
except OSError:
unreadable.append(str(path))
continue
files.append(str(path))
folders.add(str(path.parent))
by_extension[path.suffix.lower() or "(none)"] += 1
total_bytes += stat.st_size
known = _known_paths(config)
on_disk = set(files)
report = {
"schema_version": SCHEMA_VERSION,
"generated_at": _now().isoformat(),
"revision": revision(),
"library_roots": [str(root) for root in roots],
"files": len(files),
"folders": len(folders),
"bytes": total_bytes,
"excluded_directories": excluded,
"unreadable": unreadable,
"by_extension": dict(sorted(by_extension.items())),
"reconciliation": {
"known_to_database": len(known),
"already_registered": len(on_disk & known),
"new_to_the_application": len(on_disk - known),
"recorded_but_absent": sorted(known - on_disk)[:100],
"recorded_but_absent_total": len(known - on_disk),
},
"mutation": "none — this pass is read-only",
}
report["checksum"] = sha256_bytes(
json.dumps(report, sort_keys=True).encode("utf-8")
)
return report
def _known_paths(config: Config) -> set[str]:
"""Current asset paths the database holds, or an empty set if there is none."""
if not config.database_path.exists():
return set()
from sqlalchemy import select
from photo_pipeline.db import create_db_engine, create_session_factory
from photo_pipeline.models import Asset
engine = create_db_engine(config.database_url)
try:
with create_session_factory(engine)() as session:
return {
path
for path in session.scalars(select(Asset.current_path))
if path is not None
}
except Exception:
return set()
finally:
engine.dispose()
# ── the approval ─────────────────────────────────────────────────────────────
def approval_path(config: Config) -> Path:
return Path(config.data_dir) / APPROVAL_NAME
def approve(config: Config, report: dict | Path | str, *, approver: str) -> dict:
"""Record that a person read this reconciliation and accepts mutation for it."""
if isinstance(report, (str, Path)):
report = json.loads(Path(report).read_text())
if "checksum" not in report:
raise ReleaseError("this is not a dry-run report: it has no checksum")
record = {
"schema_version": SCHEMA_VERSION,
"approved_at": _now().isoformat(),
"approved_by": approver,
"report_checksum": report["checksum"],
"library_roots": report["library_roots"],
"files": report["files"],
"revision": report.get("revision"),
}
path = approval_path(config)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(record, indent=2))
return record
def mutation_blockers(config: Config) -> list[dict]:
"""Why mutation must stay refused, or an empty list.
Only enforced when ``require_dry_run_approval`` is configured — the loopback
developer setup keeps working unchanged, and an operator turns this on before
pointing the application at the library they cannot replace.
"""
if not config.require_dry_run_approval:
return []
path = approval_path(config)
if not path.exists():
return [
{
"code": "dry_run_not_approved",
"message": (
"run `python -m photo_pipeline dry-run` and approve its report "
"before mutation is enabled"
),
}
]
try:
record = json.loads(path.read_text())
except ValueError:
return [{"code": "approval_unreadable", "message": f"{path} is not readable JSON"}]
approved_roots = [str(root) for root in record.get("library_roots", [])]
configured = [str(root) for root in config.library_roots]
if sorted(approved_roots) != sorted(configured):
return [
{
"code": "approval_scope_mismatch",
"message": (
f"the approval covers {approved_roots}, but the configured library "
f"is {configured}; run a new dry run"
),
}
]
return []