fix(ops): make release provenance gate fail closed

This commit is contained in:
Hermes Agent
2026-09-13 16:34:44 +09:00
parent 8378e03dcc
commit 7dc2f65455
5 changed files with 669 additions and 167 deletions
+128 -53
View File
@@ -1,25 +1,48 @@
"""Collect release/restart provenance and enforce audit-checkout clean gate."""
#!/usr/bin/env python3
"""Collect release/restart provenance and enforce audit-checkout gates."""
from __future__ import annotations
import argparse
import json
import re
import shlex
import sys
import uuid
from datetime import UTC, datetime
from pathlib import Path
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from trex_core.adapters.persistence.ops_checks import (
collect_release_provenance,
isolate_dirty_checkout_files,
)
_DEFAULT_OUTPUT_DIR = Path("artifacts/validation/release_provenance")
_DEFAULT_ISOLATE_DIR = Path("artifacts/validation/release_provenance_isolation")
_COMMAND_CLASSES = ("deploy", "recovery", "manual-stop", "manual-start", "recreate")
_REQUIRED_CAPTURE_KEYS = frozenset(
{"pending_slots", "reconciliation_slots", "queue_consumers"}
)
_SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
def _record_path(output_dir: Path, *, instance: str, command_class: str) -> Path:
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%SZ")
return output_dir / f"{stamp}--{instance}--{command_class}.json"
def _record_path(
output_dir: Path,
*,
operation_id: str,
phase: str,
instance: str,
command_class: str,
) -> Path:
for value in (operation_id, phase, instance, command_class):
if not _SAFE_ID.fullmatch(value):
raise ValueError("record filename components must be safe identifiers")
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S.%fZ")
nonce = uuid.uuid4().hex[:12]
return output_dir / (
f"{stamp}--{operation_id}--{phase}--{instance}--{command_class}--{nonce}.json"
)
def _parse_capture_command(raw: str) -> tuple[str, list[str]]:
@@ -33,67 +56,119 @@ def _parse_capture_command(raw: str) -> tuple[str, list[str]]:
return key, parts
def _validate_capture_command(key: str, command: list[str], *, instance: str) -> None:
executable = Path(command[0]).name
args = command[1:]
expected = {
"pending_slots": ["slots", "--status", "pending", "--instance", instance, "--json"],
"reconciliation_slots": ["slots", "--status", "all", "--instance", instance, "--json"],
"queue_consumers": ["list_consumers"],
}
if key not in expected:
raise ValueError(f"unsupported capture key: {key}")
if key == "queue_consumers":
valid = executable == "rabbitmqctl" and args == expected[key]
else:
valid = executable == "trex-core" and args == expected[key]
if not valid:
raise ValueError(f"capture command is not the approved read-only form for {key}")
def _outside_checkout(path: Path, checkout: Path, *, label: str) -> Path:
resolved = path.resolve()
try:
resolved.relative_to(checkout.resolve())
except ValueError:
return resolved
raise ValueError(f"{label} must be outside the audit checkout")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--actor", required=True)
parser.add_argument("--reason", required=True)
parser.add_argument("--instance", required=True)
parser.add_argument("--command-class", required=True)
parser.add_argument("--operation-id", required=True)
parser.add_argument("--phase", required=True, choices=("pre", "post"))
parser.add_argument("--command-class", required=True, choices=_COMMAND_CLASSES)
parser.add_argument("--checkout-dir", default=".")
parser.add_argument("--expected-oci-revision")
parser.add_argument("--image-digest")
parser.add_argument("--runtime-file", action="append", default=[])
parser.add_argument("--expected-oci-revision", required=True)
parser.add_argument("--image-digest", required=True)
parser.add_argument("--runtime-file", action="append", required=True)
parser.add_argument("--broker-log-path", default="/tmp/trex-core.log")
parser.add_argument("--capture-command", action="append", default=[])
parser.add_argument("--output-dir", default=str(_DEFAULT_OUTPUT_DIR))
parser.add_argument("--isolate-dir", default=str(_DEFAULT_ISOLATE_DIR))
parser.add_argument("--allow-dirty", action="store_true")
parser.add_argument("--capture-command", action="append", required=True)
parser.add_argument("--output-dir")
parser.add_argument("--isolate-dir")
args = parser.parse_args(argv)
capture_commands = {}
for raw in args.capture_command:
key, command = _parse_capture_command(raw)
capture_commands[key] = command
try:
capture_commands: dict[str, list[str]] = {}
for raw in args.capture_command:
key, command = _parse_capture_command(raw)
if key in capture_commands:
raise ValueError(f"duplicate capture key: {key}")
_validate_capture_command(key, command, instance=args.instance)
capture_commands[key] = command
if set(capture_commands) != _REQUIRED_CAPTURE_KEYS:
missing = sorted(_REQUIRED_CAPTURE_KEYS - set(capture_commands))
extra = sorted(set(capture_commands) - _REQUIRED_CAPTURE_KEYS)
raise ValueError(f"capture command keys mismatch: missing={missing}, extra={extra}")
report = collect_release_provenance(
actor=args.actor,
reason=args.reason,
instance=args.instance,
command_class=args.command_class,
checkout_dir=args.checkout_dir,
expected_oci_revision=args.expected_oci_revision,
image_digest=args.image_digest,
runtime_files=args.runtime_file,
capture_commands=capture_commands,
broker_log_path=args.broker_log_path,
)
output_dir = Path(args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
output_path = _record_path(output_dir, instance=args.instance, command_class=args.command_class)
dirty_checkout = bool(report["checkout"]["dirty_paths"])
runtime_ok = all(
item.get("matches_expected_revision", False) for item in report["runtime_file_verification"]
)
oci_ok = bool(report["artifact_identity"]["oci_revision_matches_head"])
allow_dirty_passed = bool(args.allow_dirty and runtime_ok and oci_ok)
report["gate"]["allow_dirty"] = bool(args.allow_dirty)
report["gate"]["allow_dirty_passed"] = allow_dirty_passed
if dirty_checkout and not args.allow_dirty:
report["dirty_checkout_isolation"] = isolate_dirty_checkout_files(
checkout_dir=args.checkout_dir,
isolate_dir=args.isolate_dir,
checkout = Path(args.checkout_dir).resolve()
output_default = checkout.parent / f"{checkout.name}-release-provenance"
isolate_default = checkout.parent / f"{checkout.name}-release-provenance-isolation"
output_dir = _outside_checkout(
Path(args.output_dir) if args.output_dir else output_default,
checkout,
label="output directory",
)
isolate_dir = _outside_checkout(
Path(args.isolate_dir) if args.isolate_dir else isolate_default,
checkout,
label="isolation directory",
)
output_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
report = collect_release_provenance(
actor=args.actor,
reason=args.reason,
instance=args.instance,
command_class=args.command_class,
operation_id=args.operation_id,
phase=args.phase,
checkout_dir=checkout,
expected_oci_revision=args.expected_oci_revision,
image_digest=args.image_digest,
runtime_files=args.runtime_file,
capture_commands=capture_commands,
broker_log_path=args.broker_log_path,
)
except (RuntimeError, ValueError) as exc:
parser.error(str(exc))
output_dir.mkdir(parents=True, exist_ok=True)
output_path = _record_path(
output_dir,
operation_id=args.operation_id,
phase=args.phase,
instance=args.instance,
command_class=args.command_class,
)
dirty_checkout = bool(report["checkout"]["dirty_paths"])
if dirty_checkout:
report["dirty_checkout_isolation"] = isolate_dirty_checkout_files(
checkout_dir=checkout,
isolate_dir=isolate_dir,
)
# Exclusive create prevents a provenance record from being overwritten.
with output_path.open("x", encoding="utf-8") as fp:
fp.write(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
output_path.chmod(0o444)
print(str(output_path))
if dirty_checkout and not args.allow_dirty:
if dirty_checkout:
return 2
if report["gate"]["passed"] or allow_dirty_passed:
return 0
return 1
return 0 if report["gate"]["passed"] else 1
if __name__ == "__main__":