392 lines
15 KiB
Python
392 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.
|
|
ALLOWED_SKIP_REASONS = ("exiftool not installed", "root ignores directory permissions")
|
|
|
|
|
|
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 []
|