237 lines
9.0 KiB
Python
237 lines
9.0 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Fail-closed acceptance harness for the Hermes full handoff.
|
||
|
|
|
||
|
|
Read-only by default. The harness collects evidence from two genuinely separate
|
||
|
|
places — an external operator vantage and the Hermes agent probing itself from
|
||
|
|
inside its own pod — classifies every check PASS / FAIL / NOT_RUN /
|
||
|
|
NOT_APPLICABLE, and emits machine-readable JSON alongside a short human summary.
|
||
|
|
|
||
|
|
Any mandatory FAIL or NOT_RUN is a NO_GO, and so is a harness-level problem: an
|
||
|
|
unreachable vantage, a catalog whose evidence no longer exists, an expired
|
||
|
|
deadline, or two vantages that turn out to be the same principal. Nothing here
|
||
|
|
rounds an absence of evidence up to a pass.
|
||
|
|
|
||
|
|
scripts/ops/hermes_handoff_acceptance.py --context atlas-operator
|
||
|
|
scripts/ops/hermes_handoff_acceptance.py --output build/handoff.json
|
||
|
|
|
||
|
|
Mutation lives behind a separate arming flag, needs an exact confirmation
|
||
|
|
phrase and a unique ref, refuses protected push targets before any network
|
||
|
|
call, and verifies its own cleanup. A default run reports those checks NOT_RUN.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import datetime as dt
|
||
|
|
import json
|
||
|
|
import sys
|
||
|
|
from collections.abc import Sequence
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
|
|
|
||
|
|
from hermes_handoff_catalog import Targets # noqa: E402
|
||
|
|
from hermes_handoff_ephemeral import ( # noqa: E402
|
||
|
|
CONFIRMATION,
|
||
|
|
ArmingError,
|
||
|
|
ArmRequest,
|
||
|
|
preflight,
|
||
|
|
run_armed,
|
||
|
|
)
|
||
|
|
from hermes_handoff_exec import ( # noqa: E402
|
||
|
|
DEFAULT_COMMAND_TIMEOUT,
|
||
|
|
DEFAULT_RUN_DEADLINE,
|
||
|
|
SELF,
|
||
|
|
Runner,
|
||
|
|
)
|
||
|
|
from hermes_handoff_model import GO, Report, unscreened_fields, utc_now # noqa: E402
|
||
|
|
from hermes_handoff_policy import ARMED, READ_ONLY # noqa: E402
|
||
|
|
from hermes_handoff_redaction import DEFAULT_MAX_BYTES # noqa: E402
|
||
|
|
from hermes_handoff_run import ( # noqa: E402
|
||
|
|
build_catalog,
|
||
|
|
build_report,
|
||
|
|
resolve_vantages,
|
||
|
|
unavailable_report,
|
||
|
|
)
|
||
|
|
|
||
|
|
EXIT_GO = 0
|
||
|
|
EXIT_NO_GO = 1
|
||
|
|
EXIT_USAGE = 2
|
||
|
|
|
||
|
|
|
||
|
|
def build_parser() -> argparse.ArgumentParser:
|
||
|
|
"""Return the command-line interface for the harness."""
|
||
|
|
parser = argparse.ArgumentParser(
|
||
|
|
prog="hermes_handoff_acceptance",
|
||
|
|
description=__doc__,
|
||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
|
|
)
|
||
|
|
parser.add_argument("--namespace", default="hermes", help="Hermes namespace.")
|
||
|
|
parser.add_argument("--context", help="Kubernetes context for the operator vantage.")
|
||
|
|
parser.add_argument("--kubeconfig", help="Kubeconfig for the operator vantage.")
|
||
|
|
parser.add_argument("--output", type=Path, help="Write the JSON report here instead of stdout.")
|
||
|
|
parser.add_argument("--json-only", action="store_true", help="Suppress the human summary.")
|
||
|
|
parser.add_argument(
|
||
|
|
"--command-timeout", type=float, default=DEFAULT_COMMAND_TIMEOUT, help="Per-command seconds."
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--deadline", type=float, default=DEFAULT_RUN_DEADLINE, help="Whole-run seconds."
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--max-output-bytes", type=int, default=DEFAULT_MAX_BYTES, help="Per-capture byte budget."
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--baseline-commit", default=Targets.baseline_commit, help="Commit main must descend from."
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--dependency-pr",
|
||
|
|
type=int,
|
||
|
|
action="append",
|
||
|
|
default=None,
|
||
|
|
help="Pull request that must be merged before this release is certifiable (repeatable).",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--expected-suspension",
|
||
|
|
action="append",
|
||
|
|
default=None,
|
||
|
|
metavar="NAMESPACE/NAME",
|
||
|
|
help="Flux object allowed to be suspended (repeatable).",
|
||
|
|
)
|
||
|
|
parser.add_argument("--node-count", type=int, default=1, help="Nodes the fleet must cover.")
|
||
|
|
parser.add_argument("--pool-replicas", type=int, default=3, help="Expected pool worker count.")
|
||
|
|
parser.add_argument(
|
||
|
|
"--chat-config-revision", default="", help="Expected chat pod-template config revision."
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--chat-ordinal",
|
||
|
|
type=int,
|
||
|
|
default=0,
|
||
|
|
help="Chat tenant ordinal that carries the Telegram identity.",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--expect-telegram-sessions",
|
||
|
|
action="store_true",
|
||
|
|
help="Require the selected chat tenant to hold durable Telegram-sourced sessions.",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--pool-worker-env",
|
||
|
|
action="append",
|
||
|
|
default=None,
|
||
|
|
help="Environment variable the pool workers must declare (repeatable).",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--max-evidence-age",
|
||
|
|
type=int,
|
||
|
|
default=86_400,
|
||
|
|
help="Seconds before provider and routing evidence is treated as stale.",
|
||
|
|
)
|
||
|
|
armed = parser.add_argument_group("ephemeral mutation mode")
|
||
|
|
armed.add_argument(
|
||
|
|
"--arm-ephemeral-push",
|
||
|
|
action="store_true",
|
||
|
|
help="Permit one ephemeral branch push and draft pull request, then remove both.",
|
||
|
|
)
|
||
|
|
armed.add_argument("--confirm", default="", help=f"Exact confirmation phrase: {CONFIRMATION!r}.")
|
||
|
|
armed.add_argument("--ephemeral-token", default="", help="Unique 8-64 character ref suffix.")
|
||
|
|
armed.add_argument("--ephemeral-base", default="main", help="Pull-request base branch.")
|
||
|
|
armed.add_argument("--remote", default="origin", help="Git remote for the ephemeral push.")
|
||
|
|
armed.add_argument("--repo", default=Targets.repo, help="owner/name of the forge repository.")
|
||
|
|
return parser
|
||
|
|
|
||
|
|
|
||
|
|
def targets_from_args(arguments: argparse.Namespace, now: dt.datetime) -> Targets:
|
||
|
|
"""Return the catalog targets a parsed command line describes."""
|
||
|
|
defaults = Targets()
|
||
|
|
return Targets(
|
||
|
|
namespace=arguments.namespace,
|
||
|
|
baseline_commit=arguments.baseline_commit,
|
||
|
|
dependency_pull_requests=tuple(arguments.dependency_pr or defaults.dependency_pull_requests),
|
||
|
|
expected_suspensions=tuple(arguments.expected_suspension or ()),
|
||
|
|
node_count=arguments.node_count,
|
||
|
|
pool_replicas=arguments.pool_replicas,
|
||
|
|
chat_config_revision=arguments.chat_config_revision,
|
||
|
|
chat_ordinal=arguments.chat_ordinal,
|
||
|
|
expect_telegram_sessions=arguments.expect_telegram_sessions,
|
||
|
|
pool_worker_env=tuple(arguments.pool_worker_env or ()),
|
||
|
|
max_evidence_age_seconds=arguments.max_evidence_age,
|
||
|
|
repo=arguments.repo,
|
||
|
|
remote=arguments.remote,
|
||
|
|
now=now,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def emit(report: Report, arguments: argparse.Namespace, stream=None) -> list[str]:
|
||
|
|
"""Write the report, refusing to publish one that still looks credential-shaped."""
|
||
|
|
payload = report.as_dict()
|
||
|
|
offenders = unscreened_fields(payload)
|
||
|
|
if offenders:
|
||
|
|
payload = {
|
||
|
|
"harness": payload["harness"],
|
||
|
|
"decision": "NO_GO",
|
||
|
|
"harness_errors": [
|
||
|
|
"the rendered report failed its own credential screening at "
|
||
|
|
+ ", ".join(offenders[:8])
|
||
|
|
],
|
||
|
|
}
|
||
|
|
rendered = json.dumps(payload, indent=2, sort_keys=True, default=str)
|
||
|
|
if arguments.output:
|
||
|
|
arguments.output.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
arguments.output.write_text(rendered + "\n", encoding="utf-8")
|
||
|
|
else:
|
||
|
|
print(rendered, file=stream or sys.stdout)
|
||
|
|
if not arguments.json_only:
|
||
|
|
print(report.render_summary(), file=sys.stderr)
|
||
|
|
return offenders
|
||
|
|
|
||
|
|
|
||
|
|
def _armed_results(arguments: argparse.Namespace, runner: Runner, vantages) -> list:
|
||
|
|
request = ArmRequest(
|
||
|
|
repo=arguments.repo,
|
||
|
|
remote=arguments.remote,
|
||
|
|
token=arguments.ephemeral_token,
|
||
|
|
confirmation=arguments.confirm,
|
||
|
|
base=arguments.ephemeral_base,
|
||
|
|
)
|
||
|
|
preflight(request, arguments.repo)
|
||
|
|
vantage = vantages.get(SELF) or vantages["operator"]
|
||
|
|
return run_armed(runner, vantage, request)
|
||
|
|
|
||
|
|
|
||
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
||
|
|
"""Run the acceptance sweep and return the process exit status."""
|
||
|
|
arguments = build_parser().parse_args(argv)
|
||
|
|
now = dt.datetime.now(dt.timezone.utc)
|
||
|
|
started_at = utc_now(lambda: now)
|
||
|
|
targets = targets_from_args(arguments, now)
|
||
|
|
mode = ARMED if arguments.arm_ephemeral_push else READ_ONLY
|
||
|
|
|
||
|
|
runner = Runner(
|
||
|
|
mode=mode,
|
||
|
|
command_timeout=arguments.command_timeout,
|
||
|
|
deadline_seconds=arguments.deadline,
|
||
|
|
max_bytes=arguments.max_output_bytes,
|
||
|
|
)
|
||
|
|
vantages, records = resolve_vantages(runner, targets, arguments.kubeconfig, arguments.context)
|
||
|
|
|
||
|
|
armed_results: list = []
|
||
|
|
if arguments.arm_ephemeral_push:
|
||
|
|
try:
|
||
|
|
armed_results = _armed_results(arguments, runner, vantages)
|
||
|
|
except ArmingError as exc:
|
||
|
|
report = unavailable_report(mode, started_at, f"arming refused: {exc}", targets)
|
||
|
|
emit(report, arguments)
|
||
|
|
return EXIT_NO_GO
|
||
|
|
|
||
|
|
specs = build_catalog(targets)
|
||
|
|
report = build_report(
|
||
|
|
runner, targets, specs, vantages, records, mode, started_at, armed_results
|
||
|
|
)
|
||
|
|
offenders = emit(report, arguments)
|
||
|
|
if offenders:
|
||
|
|
return EXIT_NO_GO
|
||
|
|
return EXIT_GO if report.decision == GO else EXIT_NO_GO
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__": # pragma: no cover - exercised through main()
|
||
|
|
raise SystemExit(main())
|