Decides whether the Hermes platform handoff is fit to release, and refuses to round an absence of evidence up to a pass. The harness is read-only by default and classifies 71 checks PASS / FAIL / NOT_RUN / NOT_APPLICABLE. Any mandatory FAIL or NOT_RUN is NO_GO, and so is a harness-level problem: an unreachable vantage, a catalog entry whose evidence no longer exists, an expired deadline, or an evaluator that raised. Evidence comes from two vantages that cannot cover for each other: an external read-only operator kubeconfig, and the Hermes agent probing itself from inside its own pod. Before any check runs, the harness asks each vantage who it is and stops if they are the same principal, because dual-vantage evidence from one identity is a restatement rather than a corroboration. `--as` is rejected for every operator-side command and reachable only as the inner command of a `kubectl exec`, so impersonation can never stand in for a real self-probe. A deny check needs a live refused request, not only an authorization review. Two safety properties are structural rather than conventional, enforced where an argv becomes a subprocess: the default mode mutates nothing (mutating verbs require a server dry run; there is deliberately no live TokenRequest probe, because a successful one would mint a real credential), and no probe can pull a credential value into a report (no vault/sops/curl, secrets readable only with -o name, environment probes list names, shell only through frozen reviewed templates). Captures are bounded before they are screened, and the rendered report is re-screened before it is written. Mutation lives behind a separate arming flag with an exact confirmation phrase, a caller-supplied unique ref, a preflight that refuses a protected push target before any network call, and a cleanup whose verification is itself mandatory. A default run reports those four checks NOT_RUN. The catalog is declarative so a reviewer reads what is asserted rather than how it is plumbed, and so structural properties can be proven over every entry before a run. Catalog drift surfaces as NOT_RUN, which stops the release. docs/hermes_full_handoff_acceptance.md carries the merge order for PRs #14-#18 on top of the merged #13 baseline, the image build and Flux rollout, the rollback point for each step, the go/no-go checklist, and the limits that are asserted rather than exercised. Validation: 295 handoff tests pass with 100% line coverage on all 15 new modules; the full unit suite is 647 passed with two failures that reproduce unchanged on origin/main; Ruff, py_compile, kustomize render, and a diff credential screen are clean; a live read-only run against Atlas returns NO_GO for the pre-merge cluster with no unscreened fields in the report.
237 lines
9.0 KiB
Python
Executable File
237 lines
9.0 KiB
Python
Executable File
#!/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())
|