US08-05: Automate Container Deployment Acceptance
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""Application management CLI:
|
||||
``python -m photo_pipeline {serve,migrate,worker,import-legacy-scores,backup,verify-backup,restore,diagnostics}``.
|
||||
``python -m photo_pipeline {serve,migrate,worker,import-legacy-scores,backup,verify-backup,restore,diagnostics,benchmark,release-gate,container-gate,dry-run,approve-dry-run}``.
|
||||
|
||||
``serve`` and ``worker`` take the library process lock for their role (US07-05):
|
||||
two workers, or the frozen CLI running beside the app, would each be safe on their
|
||||
@@ -70,6 +70,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
"release-gate", help="Run every suite in an isolated stack and keep the evidence"
|
||||
)
|
||||
gate_cmd.add_argument("--output", help="Evidence directory (default: data/release/<stamp>)")
|
||||
container_cmd = commands.add_parser(
|
||||
"container-gate",
|
||||
help="Provision the composition from the built image, run the phase_h "
|
||||
"acceptance suite against it, destroy it, and keep the evidence (US08-05)",
|
||||
)
|
||||
container_cmd.add_argument(
|
||||
"--output", help="Evidence directory (default: data/container-gate/<stamp>)"
|
||||
)
|
||||
dry_cmd = commands.add_parser(
|
||||
"dry-run", help="Read-only reconciliation of the configured library (US07-07)"
|
||||
)
|
||||
@@ -155,6 +163,31 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
)
|
||||
return 0 if report["ok"] else 1
|
||||
|
||||
if args.command == "container-gate":
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from photo_pipeline.services import release
|
||||
|
||||
# The suite provisions and destroys the composition itself; what this command
|
||||
# adds is the single entry point and the retained evidence. No skip is an
|
||||
# environment limit here: a gate that did not reach the containers proved
|
||||
# nothing about them.
|
||||
output = args.output or Path(config.data_dir) / "container-gate" / datetime.now(
|
||||
timezone.utc
|
||||
).strftime("%Y%m%dT%H%M%SZ")
|
||||
report = release.run_gate(
|
||||
config,
|
||||
output=output,
|
||||
stages=release.CONTAINER_STAGES,
|
||||
allowed_skips=release.CONTAINER_ALLOWED_SKIP_REASONS,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{k: v for k, v in report.items() if k not in ("stages", "matrix")}, indent=2
|
||||
)
|
||||
)
|
||||
return 0 if report["ok"] else 1
|
||||
|
||||
if args.command == "dry-run":
|
||||
from photo_pipeline.services import release
|
||||
|
||||
|
||||
@@ -60,6 +60,17 @@ ALLOWED_SKIP_REASONS = (
|
||||
"bind-mount ownership is virtualised",
|
||||
)
|
||||
|
||||
# The container acceptance gate (US08-05): the deployed application, verified the way
|
||||
# the host application is. Deliberately one stage selected by marker, so adding a
|
||||
# phase_h test is enough to put it in front of a deploy.
|
||||
CONTAINER_STAGES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("container", ("tests/e2e", "-m", "phase_h")),
|
||||
)
|
||||
|
||||
# Nothing. This gate exists to prove the *deployed* runtime, and every reason a check
|
||||
# would skip here — no daemon, no compose, no browser — means it was not proven.
|
||||
CONTAINER_ALLOWED_SKIP_REASONS: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
@@ -175,13 +186,15 @@ def _run_stage(label: str, paths: tuple[str, ...], *, repo: Path, log_dir: Path)
|
||||
return StageResult(label, command, result.returncode, elapsed, summary, skipped)
|
||||
|
||||
|
||||
def unexpected_skips(results: list[StageResult]) -> list[str]:
|
||||
def unexpected_skips(
|
||||
results: list[StageResult], allowed: tuple[str, ...] = ALLOWED_SKIP_REASONS
|
||||
) -> 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)
|
||||
if not any(reason in line for reason in allowed)
|
||||
]
|
||||
|
||||
|
||||
@@ -190,6 +203,7 @@ def run_gate(
|
||||
*,
|
||||
output: Path | str | None = None,
|
||||
stages: tuple[tuple[str, tuple[str, ...]], ...] = STAGES,
|
||||
allowed_skips: tuple[str, ...] = ALLOWED_SKIP_REASONS,
|
||||
repo: Path | None = None,
|
||||
) -> dict:
|
||||
"""Run every suite in an isolated stack and retain checksummed evidence.
|
||||
@@ -206,13 +220,14 @@ def run_gate(
|
||||
logs = directory / "logs"
|
||||
logs.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
started_at = _now()
|
||||
matrix = story_matrix(repo)
|
||||
results = [_run_stage(label, paths, repo=repo, log_dir=logs) for label, paths in stages]
|
||||
skips = unexpected_skips(results)
|
||||
skips = unexpected_skips(results, allowed_skips)
|
||||
|
||||
report = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"started_at": _now().isoformat(),
|
||||
"started_at": started_at.isoformat(),
|
||||
"revision": revision(),
|
||||
"python": sys.version.split()[0],
|
||||
"platform": os.uname().sysname,
|
||||
|
||||
Reference in New Issue
Block a user