evaluate_names_absent returned PASS when its step exited 0 with no output, so five mandatory checks - the ones asserting that provider API keys, forge credentials, a cluster-admin binding, and shared coordinator state are absent - could report a pass on no evidence and turn a NO_GO into a GO. Both name rules now resolve their step through one guard in _line_step, so zero observations are NOT_RUN. Regressions pin all five real catalog specs plus both reachable silence paths: a POSIX pipeline whose status comes from its last stage, and a drifted kubectl -o jsonpath. The pool claim projection emits one <volume>=<claim> line per template volume so a volume without a PVC still counts as an observation rather than reading as drift. Also closes the review's reachable hardening and evidence defects: - pin Gitea paths to atlas/titan-iac on an exact segment boundary and reject relative segments, including percent-encoded ones - forbid impersonation structurally in every mode and vantage; the inner command of kubectl exec is re-checked rather than exempted, and validate_catalog no longer guards only the operator vantage - drop flux and helm from the binary allowlist; they had no pinned release digest, so no allowlisted binary can now be admitted that the executor would refuse to attest - remove the inert --concurrency and --expect-telegram-sessions flags and the dead concurrency bound; Telegram continuity stays mandatory - read the ephemeral pull index page by page, treat the create response as an authoritative source for the pull number, close every number either source names, and surface residue_ref plus exact manual_cleanup commands when creation is uncertain - keep executable_path and executable_sha256 on unrecorded bulk-evidence steps so withholding bytes never withholds binary attestation - revert the repo-wide hygiene legacy-exception mechanism; the contract change here is purely additive and the four pre-existing over-cap files are left to the canonical contract change in PR #14/#15 - correct the runbook ruff format scope so the documented command passes Split hermes_handoff_arming.py out of hermes_handoff_ephemeral.py to keep both modules under the 500-line cap. All 16 handoff modules hold at least 95% line and branch coverage; the mutation gate is 13/13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
6.6 KiB
Python
203 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Declarative acceptance catalog: baseline, identity, routing, scopes, access.
|
|
|
|
The catalog is data on purpose. A reviewer should be able to read what the
|
|
release is asserted to satisfy without reading any plumbing, and the harness
|
|
should be able to prove structural properties over every entry — both vantages
|
|
represented, no impersonation from outside a pod, and no mutation command of
|
|
any kind — before it runs a single command.
|
|
|
|
Catalog drift is a NO_GO, not a pass. If a manifest, a field name, or a workload
|
|
is renamed, the affected checks report ``NOT_RUN`` because the evidence they
|
|
name is not there, and the release stops until either the platform or the
|
|
catalog is corrected.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from hermes_handoff_model import ALWAYS, READ, CheckSpec, Step
|
|
from hermes_handoff_policy import projection
|
|
|
|
OPERATOR = "operator"
|
|
SELF = "self"
|
|
SWITCHYARD = "switchyard"
|
|
NODE = "node"
|
|
CHAT = "chat"
|
|
VANTAGE_NAMES = (OPERATOR, SELF, SWITCHYARD, NODE, CHAT)
|
|
|
|
# `name<TAB>generation<TAB>observedGeneration<TAB>suspend<TAB>conditions`.
|
|
FLUX_PROJECTION = projection(
|
|
(
|
|
"jsonpath={range .items[*]}{.metadata.namespace}/{.metadata.name}{'\\t'}"
|
|
"{.metadata.generation}{'\\t'}{.status.observedGeneration}{'\\t'}{.spec.suspend}{'\\t'}"
|
|
"{range .status.conditions[*]}{.type}={.status},{end}{'\\n'}{end}"
|
|
).replace("'", '"')
|
|
)
|
|
|
|
ENV_NAME_PROJECTION = projection(
|
|
(
|
|
"jsonpath={range .spec.template.spec.containers[*]}{range .env[*]}{.name}{'\\n'}{end}{end}"
|
|
).replace("'", '"')
|
|
)
|
|
|
|
INIT_NAME_PROJECTION = projection(
|
|
(
|
|
"jsonpath={range .spec.template.spec.initContainers[*]}{.name}{'\\n'}{end}"
|
|
).replace("'", '"')
|
|
)
|
|
|
|
NODE_NAME_PROJECTION = projection(
|
|
"jsonpath={range .items[*]}{.spec.nodeName}{'\\n'}{end}".replace("'", '"')
|
|
)
|
|
|
|
POOL_PROJECTION = projection(
|
|
(
|
|
"jsonpath={range .items[*]}{.metadata.name}{'\\t'}{.spec.nodeName}{'\\t'}"
|
|
"{.status.phase}{'\\t'}{range .status.conditions[?(@.type==\"Ready\")]}{.status}{end}"
|
|
"{'\\t'}{range .spec.volumes[*]}{.persistentVolumeClaim.claimName}{','}{end}{'\\n'}{end}"
|
|
).replace("'", '"')
|
|
)
|
|
|
|
PROVIDER_API_KEY_NAMES = (
|
|
"ANTHROPIC_API_KEY",
|
|
"ANTHROPIC_AUTH_TOKEN",
|
|
"ANTHROPIC_BASE_URL",
|
|
"AZURE_OPENAI_API_KEY",
|
|
"CLAUDE_API_KEY",
|
|
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
"GEMINI_API_KEY",
|
|
"GOOGLE_API_KEY",
|
|
"OPENAI_API_KEY",
|
|
"OPENAI_BASE_URL",
|
|
)
|
|
FORGE_CREDENTIAL_NAMES = (
|
|
"GITEA_PASSWORD",
|
|
"GITEA_TOKEN",
|
|
"GITEA_USERNAME",
|
|
"GIT_PASSWORD",
|
|
)
|
|
|
|
CODEX_HEALTH = "/opt/data/provider-health/codex.json"
|
|
CLAUDE_HEALTH = "/opt/data/provider-health/claude.json"
|
|
CODEX_HEALTH_FIELDS = "authenticated,transport,state,model,effort,latency_ms,checked_at"
|
|
CLAUDE_HEALTH_FIELDS = (
|
|
"authenticated,api_provider,auth_method,transport,state,subscription_type,"
|
|
"model,effort,latency_ms,checked_at"
|
|
)
|
|
ROUTING_LOG = "/var/lib/switchyard/routing.jsonl"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Targets:
|
|
"""Runtime names and expected values the catalog is written against.
|
|
|
|
Everything a site can legitimately rename lives here so the assertions stay
|
|
readable and a rename is a configuration change rather than a code change.
|
|
"""
|
|
|
|
namespace: str = "hermes"
|
|
agent_deployment: str = "hermes-agent"
|
|
agent_container: str = "hermes"
|
|
chat_statefulset: str = "hermes-chat-tenant"
|
|
chat_container: str = "hermes"
|
|
chat_ordinal: int = 0
|
|
chat_router_deployment: str = "hermes-chat-router"
|
|
triage_deployment: str = "hermes"
|
|
triage_container: str = "hermes"
|
|
switchyard_deployment: str = "hermes-switchyard"
|
|
switchyard_container: str = "switchyard"
|
|
node_daemonset: str = "hermes-node-ssh-access"
|
|
node_container: str = "key-reconciler"
|
|
pool_statefulset: str = "hermes-execution-worker"
|
|
pool_replicas: int = 3
|
|
pool_selector: str = "app=hermes-execution-worker"
|
|
coordinator_claims: tuple[str, ...] = (
|
|
"hermes-agent-home",
|
|
"hermes-agent-workspace",
|
|
)
|
|
node_account: str = "hermes-agent"
|
|
node_account_identity: str = "1200:1200:/home/hermes-agent:/bin/bash"
|
|
node_count: int = 3
|
|
repo: str = "atlas/titan-iac"
|
|
remote: str = "origin"
|
|
baseline_commit: str = "ab346f55509d584e457fe26cf90be3078f7a375c"
|
|
dependency_pull_requests: tuple[int, ...] = (12, 14, 15, 16, 17, 18, 20)
|
|
dependency_heads: tuple[tuple[int, str], ...] = ()
|
|
remote_main_sha: str = ""
|
|
reviewed_pr_number: int = 19
|
|
reviewed_head_ref: str = "feature/hermes-full-handoff-acceptance"
|
|
reviewed_head_sha: str = ""
|
|
agent_image: str = ""
|
|
build_sha: str = ""
|
|
deployment_revision: str = ""
|
|
expected_suspensions: tuple[str, ...] = ()
|
|
harbor_namespace: str = "harbor"
|
|
harbor_immutability_job: str = "hermes-agent-immutability"
|
|
builder_namespace: str = "jenkins"
|
|
builder_serviceaccount: str = "hermes-image-builder"
|
|
builder_pipeline: str = "ci/Jenkinsfile.hermes-agent-image"
|
|
builder_capabilities: str = (
|
|
'add: ["CHOWN", "FOWNER", "DAC_OVERRIDE", "SETGID", "SETUID"]'
|
|
)
|
|
agent_init_containers: tuple[str, ...] = ("patch-web-session-activity",)
|
|
chat_init_containers: tuple[str, ...] = ("patch-api-server-sessions",)
|
|
chat_config_revision: str = ""
|
|
pool_worker_env: tuple[str, ...] = ("HERMES_WORKER_NODE", "HERMES_WORKER_ORDINAL")
|
|
max_evidence_age_seconds: int = 86_400
|
|
routing_tail_lines: int = 600
|
|
routing_tail_bytes: int = 512 * 1024
|
|
listing_bytes: int = 512 * 1024
|
|
git_askpass: str = "/opt/coordinator/gitea_askpass.sh"
|
|
now: Any = None
|
|
extra: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
def step(
|
|
key: str,
|
|
vantage: str,
|
|
*argv: str,
|
|
kind: str = READ,
|
|
optional: bool = False,
|
|
record: bool = True,
|
|
max_bytes: int | None = None,
|
|
) -> Step:
|
|
"""Return a probe step addressed to one vantage."""
|
|
return Step(
|
|
key=key,
|
|
vantage=vantage,
|
|
argv=tuple(argv),
|
|
kind=kind,
|
|
optional=optional,
|
|
record=record,
|
|
max_bytes=max_bytes,
|
|
)
|
|
|
|
|
|
def check(
|
|
identifier: str,
|
|
title: str,
|
|
group: str,
|
|
rule: str,
|
|
steps: Sequence[Step],
|
|
expect: dict[str, Any] | None = None,
|
|
mandatory: bool = True,
|
|
scope: str = ALWAYS,
|
|
rationale: str = "",
|
|
) -> CheckSpec:
|
|
"""Return one catalog entry."""
|
|
return CheckSpec(
|
|
id=identifier,
|
|
title=title,
|
|
group=group,
|
|
rule=rule,
|
|
steps=tuple(steps),
|
|
expect=expect or {},
|
|
mandatory=mandatory,
|
|
scope=scope,
|
|
rationale=rationale,
|
|
)
|