hermes: verify native planning runtime before readiness
This commit is contained in:
parent
9f0d74d01c
commit
d7d2d5430b
124
scripts/ops/hermes_suite_probe.py
Normal file
124
scripts/ops/hermes_suite_probe.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run only server-provided synthetic suites through the authenticated LAN API.
|
||||||
|
|
||||||
|
Set SUITE_PLANNING_TOKEN privately. No provider or cluster credentials are used.
|
||||||
|
The report contains operational metadata and synthetic family outputs only.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.request import HTTPSHandler, HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
HOST = "worker.bstein.dev"
|
||||||
|
ADDRESS = "192.168.22.50"
|
||||||
|
BASE = "https://worker.bstein.dev/suite-planning"
|
||||||
|
|
||||||
|
|
||||||
|
class Connection(http.client.HTTPSConnection):
|
||||||
|
"""Connect directly to the LAN IP while verifying TLS against the hostname."""
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
sock = socket.create_connection((ADDRESS, 443), self.timeout)
|
||||||
|
self.sock = self._context.wrap_socket(sock, server_hostname=HOST)
|
||||||
|
|
||||||
|
|
||||||
|
class TLS(HTTPSHandler):
|
||||||
|
"""Keep hostname validation and the operating system's trusted CA store."""
|
||||||
|
|
||||||
|
def https_open(self, req):
|
||||||
|
return self.do_open(Connection, req, context=ssl.create_default_context())
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirect(HTTPRedirectHandler):
|
||||||
|
"""Redirects are errors, never alternate destinations for credentials."""
|
||||||
|
|
||||||
|
def redirect_request(self, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def api(path, value=None, *, key=None, token=None, method=None):
|
||||||
|
"""Perform one bounded HTTPS request with no proxy, redirects, or retry."""
|
||||||
|
headers = {"Authorization": "Bearer " + (token if token is not None else os.environ["SUITE_PLANNING_TOKEN"])}
|
||||||
|
if key:
|
||||||
|
headers["Idempotency-Key"] = key
|
||||||
|
body = None
|
||||||
|
if value is not None:
|
||||||
|
body = json.dumps(value, separators=(",", ":")).encode()
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
request = Request(BASE + path, data=body, headers=headers, method=method)
|
||||||
|
opener = build_opener(ProxyHandler({}), NoRedirect(), TLS())
|
||||||
|
try:
|
||||||
|
with opener.open(request, timeout=45) as response:
|
||||||
|
return response.status, json.load(response)
|
||||||
|
except HTTPError as exc:
|
||||||
|
try:
|
||||||
|
return exc.code, json.load(exc)
|
||||||
|
finally:
|
||||||
|
exc.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run(size):
|
||||||
|
"""Submit a complete fixture, verify idempotency, poll, and audit aliases."""
|
||||||
|
code, request = api(f"/v1/synthetic/{size}")
|
||||||
|
if code != 200:
|
||||||
|
raise RuntimeError("fixture retrieval failed")
|
||||||
|
request["routing"] = {"allow_external": True, "allowed_external_providers": ["claude"]}
|
||||||
|
request["execution"] = {"strategy": "whole_suite", "max_seconds": 900, "max_cost_usd": 5}
|
||||||
|
code, preflight = api("/v1/preflight", request)
|
||||||
|
if code != 200:
|
||||||
|
return {"size": size, "status": code, "preflight": preflight}
|
||||||
|
key = str(uuid.uuid4())
|
||||||
|
started = time.monotonic()
|
||||||
|
code, job = api("/v1/jobs", request, key=key)
|
||||||
|
if code != 202:
|
||||||
|
return {"size": size, "status": code, "submission": job}
|
||||||
|
repeated_status, repeated = api("/v1/jobs", request, key=key)
|
||||||
|
assert repeated_status == 200 and repeated["job_id"] == job["job_id"]
|
||||||
|
job_id = job["job_id"]
|
||||||
|
print(json.dumps({"size": size, "job_id": job_id, "status": "accepted"}), flush=True)
|
||||||
|
while time.monotonic() - started < 960:
|
||||||
|
code, result = api(f"/v1/jobs/{job_id}/result")
|
||||||
|
if code != 200 or result["status"] in {"completed", "failed", "cancelled"}:
|
||||||
|
break
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("poll deadline exceeded; retain job ID and idempotency key")
|
||||||
|
coverage = None
|
||||||
|
if "result" in result:
|
||||||
|
coverage = Counter(c["alias"] for c in request["cases"]) == Counter(
|
||||||
|
a for g in result["result"]["groups"] for a in g["members"])
|
||||||
|
return {"size": size, "http_status": code, "request_bytes": len(json.dumps(request).encode()),
|
||||||
|
"client_wall_seconds": round(time.monotonic() - started, 3),
|
||||||
|
"exact_alias_coverage": coverage, "job": result}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Write an optional synthetic-only report and print metadata summaries."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--sizes", nargs="+", type=int, choices=(14, 75, 363), default=[14, 75, 363])
|
||||||
|
parser.add_argument("--report", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
assert api("/healthz", token="invalid")[0] == 401
|
||||||
|
assert api("/healthz")[0] == 200
|
||||||
|
assert api("/api/pull")[0] == 404
|
||||||
|
reports = []
|
||||||
|
for size in args.sizes:
|
||||||
|
report = run(size)
|
||||||
|
reports.append(report)
|
||||||
|
print(json.dumps({k: v for k, v in report.items() if k != "job"}), flush=True)
|
||||||
|
if args.report:
|
||||||
|
args.report.write_text(json.dumps(reports, indent=2) + "\n")
|
||||||
|
if any(report.get("exact_alias_coverage") is not True for report in reports):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -9,6 +9,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from suite_contract import (MAX_BODY, MAX_CASES, MAX_RESULT, MODELS, REVISION,
|
from suite_contract import (MAX_BODY, MAX_CASES, MAX_RESULT, MODELS, REVISION,
|
||||||
@ -186,6 +187,10 @@ def main():
|
|||||||
binary_hash = hashlib.sha256(Path("/opt/cli/claude").read_bytes()).hexdigest()
|
binary_hash = hashlib.sha256(Path("/opt/cli/claude").read_bytes()).hexdigest()
|
||||||
if binary_hash != os.environ["PLANNING_CLAUDE_SHA256"]:
|
if binary_hash != os.environ["PLANNING_CLAUDE_SHA256"]:
|
||||||
raise SystemExit("Pinned CLI binary mismatch")
|
raise SystemExit("Pinned CLI binary mismatch")
|
||||||
|
version = subprocess.run(["/opt/cli/claude", "--version"], capture_output=True,
|
||||||
|
text=True, timeout=10, check=True)
|
||||||
|
if version.stdout.strip() != "2.1.226 (Claude Code)":
|
||||||
|
raise SystemExit("Pinned CLI version mismatch")
|
||||||
private = ThreadingHTTPServer(("0.0.0.0", 9001), Decision)
|
private = ThreadingHTTPServer(("0.0.0.0", 9001), Decision)
|
||||||
threading.Thread(target=private.serve_forever, daemon=True).start()
|
threading.Thread(target=private.serve_forever, daemon=True).start()
|
||||||
server = ThreadingHTTPServer(("0.0.0.0", 9000), Handler)
|
server = ThreadingHTTPServer(("0.0.0.0", 9000), Handler)
|
||||||
|
|||||||
@ -36,7 +36,7 @@ spec:
|
|||||||
app: hermes-suite-planner
|
app: hermes-suite-planner
|
||||||
annotations:
|
annotations:
|
||||||
fluentbit.io/exclude: "true"
|
fluentbit.io/exclude: "true"
|
||||||
ai.bstein.dev/config-rev: suite-v1-20260929
|
ai.bstein.dev/config-rev: suite-v2-20260929
|
||||||
vault.hashicorp.com/agent-inject: "true"
|
vault.hashicorp.com/agent-inject: "true"
|
||||||
vault.hashicorp.com/agent-pre-populate-only: "true"
|
vault.hashicorp.com/agent-pre-populate-only: "true"
|
||||||
vault.hashicorp.com/agent-init-first: "true"
|
vault.hashicorp.com/agent-init-first: "true"
|
||||||
@ -87,7 +87,7 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: stage-cli
|
- name: stage-cli
|
||||||
image: python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df
|
image: python:3.13-slim@sha256:9662417aace5ae7b8e2609cce472b72a8958e134ba372808abe9cc1a0c0125e6
|
||||||
command: [python, -c]
|
command: [python, -c]
|
||||||
args:
|
args:
|
||||||
- |
|
- |
|
||||||
@ -109,7 +109,7 @@ spec:
|
|||||||
limits: {cpu: "1", memory: 1Gi}
|
limits: {cpu: "1", memory: 1Gi}
|
||||||
containers:
|
containers:
|
||||||
- name: planner
|
- name: planner
|
||||||
image: python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df
|
image: python:3.13-slim@sha256:9662417aace5ae7b8e2609cce472b72a8958e134ba372808abe9cc1a0c0125e6
|
||||||
command: [python, /opt/planner/suite_api.py]
|
command: [python, /opt/planner/suite_api.py]
|
||||||
env:
|
env:
|
||||||
- {name: PYTHONDONTWRITEBYTECODE, value: "1"}
|
- {name: PYTHONDONTWRITEBYTECODE, value: "1"}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user