atlas-iac/scripts/ops/hermes_triage_demo.sh
jenkins ab16b5ca6b feat(demo): add reset and monitor commands
reset restores only what the demo itself creates: the fixture ConfigMap, the
demo repository's open repair pull requests and their branches. Real service
repositories are deliberately untouched - those issues are genuine triage
records, and deleting them to tidy a demo would destroy the evidence the
system exists to produce.

monitor streams the flow in the stages of mermaid/TestAutomation.mmd, so a
second terminal can be followed against the chart: Detect and gather, Hermes
analysis, Ariadne policy gates, Ariadne response, Inspectable outputs. Each
stage prints the evidence that moved it, since the claim the chart makes is
that every step is answerable from data rather than asserted. Read-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:57:02 -03:00

268 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Drive and narrate the Hermes automated-triage demos.
#
# hermes_triage_demo.sh fixture # autonomous loop: fail -> repair -> green
# hermes_triage_demo.sh code # proposal loop: fail -> Hermes patch -> PR
# hermes_triage_demo.sh status # current incident/alert state, no changes
# hermes_triage_demo.sh preflight # confirm the lab is ready to demo
# hermes_triage_demo.sh reset # restore the demo to its pre-run state
# hermes_triage_demo.sh monitor # stream the flow chart stages live
#
# FIRST RUN: copy hermes_triage_demo.env.example to hermes_triage_demo.env in
# this directory and fill it in. That file is git-ignored precisely so it can
# hold real tokens; this script sources it automatically, so nothing needs to
# be exported by hand.
#
# Needs kubectl access to the cluster as well. Nothing here mutates the cluster
# directly: the fixture demo only asks Jenkins to run a parameterized build,
# and the code demo only pushes a seeded defect to the demo repository.
set -euo pipefail
# Local, git-ignored credentials. Sourced before anything else so every value
# below can be overridden from it.
_DEMO_ENV="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/hermes_triage_demo.env"
# shellcheck disable=SC1090
[ -r "$_DEMO_ENV" ] && . "$_DEMO_ENV"
JENKINS_URL="${JENKINS_URL:-https://ci.bstein.dev}"
FIXTURE_JOB="hermes-triage-demo"
CODE_JOB="hermes-code-demo"
DEMO_NS="hermes-triage-demo"
CODE_REPO_DIR="${CODE_REPO_DIR:-$HOME/Development/hermes-code-demo}"
say() { printf '\n\033[1m[%s] %s\033[0m\n' "$(date -u +%H:%M:%S)" "$*"; }
note() { printf ' %s\n' "$*"; }
require_jenkins() {
if [ -z "${JENKINS_USER:-}" ] || [ -z "${JENKINS_TOKEN:-}" ]; then
echo "Missing Jenkins credentials." >&2
echo "Create $_DEMO_ENV from hermes_triage_demo.env.example and fill it in." >&2
exit 1
fi
}
jenkins_get() { curl -sk -u "$JENKINS_USER:$JENKINS_TOKEN" "$JENKINS_URL$1"; }
jenkins_post() { curl -sk -o /dev/null -w '%{http_code}' -u "$JENKINS_USER:$JENKINS_TOKEN" -X POST "$JENKINS_URL$1"; }
last_build_number() {
jenkins_get "/job/$1/api/json?tree=lastBuild[number]" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["lastBuild"]["number"])'
}
wait_for_build() { # job number -> prints result
local job="$1" num="$2" tries="${3:-120}"
for _ in $(seq 1 "$tries"); do
sleep 10
local body result building
body="$(jenkins_get "/job/$job/$num/api/json?tree=result,building" || true)"
building="$(printf '%s' "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("building"))' 2>/dev/null || echo unknown)"
if [ "$building" = "False" ]; then
result="$(printf '%s' "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("result"))')"
printf '%s' "$result"
return 0
fi
done
printf 'TIMEOUT'
}
ariadne_ticks() { # tail the autotriage decisions in human-readable form
kubectl -n maintenance logs deploy/ariadne -c ariadne --tail="${1:-400}" 2>/dev/null |
grep 'hermes autotriage tick' |
python3 -c '
import sys, json
for line in sys.stdin:
try:
d = json.loads(line)
except ValueError:
continue
print(" ", d["timestamp"][11:19], d.get("jobs"))' | tail -"${2:-5}"
}
cmd_monitor() {
exec python3 "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/hermes_triage_monitor.py"
}
# Restores only what the demo itself creates. Real service repositories are
# never touched: those issues are genuine triage records and deleting them to
# tidy a demo would destroy the evidence the system exists to produce.
cmd_reset() {
require_jenkins
say "Reset — restoring the demo to its pre-run state"
note "fixture -> healthy"
kubectl -n "$DEMO_NS" patch cm hermes-triage-demo-fixture \
--type merge -p '{"data":{"state":"healthy"}}' >/dev/null 2>&1 &&
note " fixture: $(kubectl -n "$DEMO_NS" get cm hermes-triage-demo-fixture -o jsonpath='{.data.state}')" ||
note " fixture patch failed (is the demo namespace present?)"
local gitea="${GITEA_URL:-https://scm.bstein.dev}"
if [ -z "${GITEA_TOKEN:-}" ]; then
note "GITEA_TOKEN unset; skipping pull request and branch cleanup"
else
note "closing open repair pull requests on hermes-code-demo"
local prs
prs="$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"$gitea/api/v1/repos/bstein/hermes-code-demo/pulls?state=open" |
python3 -c 'import json,sys
for p in json.load(sys.stdin):
print(p["number"], p["head"]["ref"])' 2>/dev/null || true)"
if [ -z "$prs" ]; then
note " none open"
else
while read -r num ref; do
[ -z "$num" ] && continue
curl -s -o /dev/null -X PATCH -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" -d '{"state":"closed"}' \
"$gitea/api/v1/repos/bstein/hermes-code-demo/pulls/$num"
curl -s -o /dev/null -X DELETE -H "Authorization: token $GITEA_TOKEN" \
"$gitea/api/v1/repos/bstein/hermes-code-demo/branches/$ref"
note " closed #$num and deleted branch $ref"
done <<< "$prs"
fi
fi
if [ -d "$CODE_REPO_DIR/.git" ]; then
note "restoring the demo repository working state"
( cd "$CODE_REPO_DIR" && git checkout -q master && git pull -q --ff-only 2>/dev/null || true )
if grep -q 'percent / 100' "$CODE_REPO_DIR/src/discount.py" 2>/dev/null; then
note " src/discount.py is correct; demo is armable"
else
note " src/discount.py still carries the seeded defect — merge or revert it before demoing"
fi
else
note "demo repository not cloned at $CODE_REPO_DIR; skipping"
fi
say "Ready"
note "run 'preflight' next, then arm the fixture build"
}
cmd_preflight() {
require_jenkins
say "Preflight"
note "fixture state: $(kubectl -n "$DEMO_NS" get cm hermes-triage-demo-fixture -o jsonpath='{.data.state}' 2>/dev/null || echo MISSING)"
note "ariadne image: $(kubectl -n maintenance get deploy ariadne -o jsonpath='{.spec.template.spec.containers[0].image}')"
note "autoremediation: $(kubectl -n maintenance exec deploy/ariadne -c ariadne -- printenv ARIADNE_HERMES_AUTOREMEDIATION_ENABLED 2>/dev/null)"
# Printed as a list rather than the raw comma-separated setting: this is the
# outermost safety boundary, so it is worth being able to read at a glance.
local allowlist count
allowlist="$(kubectl -n maintenance exec deploy/ariadne -c ariadne -- printenv ARIADNE_HERMES_AUTOTRIAGE_JOB_ALLOWLIST 2>/dev/null | tr ',' ' ')"
count=0
for _job in $allowlist; do count=$((count + 1)); done
note "jobs Ariadne may triage ($count):"
for _job in $allowlist; do note " - $_job"; done
note "hermes: $(kubectl -n hermes get pods -l app=hermes --no-headers | awk '{print $2, $3}')"
local queued
queued="$(jenkins_get '/queue/api/json' | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["items"]))')"
note "jenkins queue depth: $queued (demo is fastest when this is 0)"
# The Kubernetes cloud caps concurrent agent pods at containerCapStr. When
# real CI saturates that cap the demo build sits in the queue reporting
# "all nodes are offline" and the timings in the runbook do not apply.
local agents cap
cap="$(kubectl -n jenkins get cm jenkins-jcasc -o jsonpath='{.data.jenkins\.yaml}' 2>/dev/null |
grep -o 'containerCapStr: "[0-9]*"' | head -1 | grep -o '[0-9]*' || echo 5)"
agents="$(kubectl -n jenkins get pods --no-headers 2>/dev/null | grep -cE '\-[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{5}' || true)"
note "jenkins agent pods: ${agents:-0}/${cap:-5} (a full pool stalls the demo — wait for a free slot)"
local open_prs
open_prs="$(curl -s -H "Authorization: token ${GITEA_TOKEN:-}" \
"${GITEA_URL:-https://scm.bstein.dev}/api/v1/repos/bstein/hermes-code-demo/pulls?state=open" 2>/dev/null |
python3 -c 'import json,sys; print(len(json.load(sys.stdin)))' 2>/dev/null || echo '?')"
note "open hermes-code-demo PRs: $open_prs (must be 0 — the duplicate guard refuses while one is open)"
}
cmd_status() {
say "Incident state (last ticks)"
ariadne_ticks 600 8
say "Firing alerts"
kubectl -n monitoring exec deploy/vmalert-atlas-availability -- wget -qO- localhost:8880/api/v1/alerts 2>/dev/null |
python3 -c '
import json,sys
alerts = json.load(sys.stdin).get("data", {}).get("alerts", [])
print(" none" if not alerts else "")
for a in alerts:
print(" ", a["name"], a["state"], "build", a.get("labels", {}).get("build"))' 2>/dev/null ||
note "(query vmalert directly if this fails)"
say "Demo namespace"
kubectl -n "$DEMO_NS" get jobs --no-headers 2>/dev/null | sed 's/^/ /'
}
cmd_fixture() {
require_jenkins
local start_num next_num
start_num="$(last_build_number "$FIXTURE_JOB")"
next_num=$((start_num + 1))
say "Arming the demo failure (SEED_FAILURE=true) -> build #$next_num"
note "HTTP $(jenkins_post "/job/$FIXTURE_JOB/buildWithParameters?SEED_FAILURE=true")"
note "Only manual step. Everything after this is automatic."
say "Waiting for the seeded build to fail"
note "result: $(wait_for_build "$FIXTURE_JOB" "$next_num")"
say "Ariadne detects, gathers evidence, asks Hermes, authorizes, repairs"
note "the repair is a single in-process ConfigMap patch, so watch the fixture"
for _ in $(seq 1 40); do
sleep 10
if [ "$(kubectl -n "$DEMO_NS" get cm hermes-triage-demo-fixture -o jsonpath='{.data.state}' 2>/dev/null)" = "healthy" ]; then
note "fixture patched back to healthy"
break
fi
done
ariadne_ticks 400 4
say "Ariadne triggers one rebuild with seeding disabled"
note "result: $(wait_for_build "$FIXTURE_JOB" $((next_num + 1)))"
say "Resolution"
sleep 45
ariadne_ticks 200 3
note "fixture state: $(kubectl -n "$DEMO_NS" get cm hermes-triage-demo-fixture -o jsonpath='{.data.state}')"
}
cmd_code() {
require_jenkins
[ -d "$CODE_REPO_DIR/.git" ] || { echo "clone bstein/hermes-code-demo to $CODE_REPO_DIR first" >&2; exit 1; }
local start_num next_num
start_num="$(last_build_number "$CODE_JOB")"
next_num=$((start_num + 1))
say "Seeding a one-line defect in src/discount.py"
( cd "$CODE_REPO_DIR" && git checkout -q master && git pull -q &&
python3 - <<'PY'
import pathlib
p = pathlib.Path("src/discount.py")
s = p.read_text()
old, new = "percent / 100", "percent / 10"
if old not in s:
raise SystemExit("defect already present or file changed; reset master first")
p.write_text(s.replace(old, new))
PY
git commit -qam "refactor: simplify discount percentage math" && git push -q origin master )
note "pushed: a plausible-looking change that breaks three regression tests"
say "Running the test gate -> build #$next_num"
note "HTTP $(jenkins_post "/job/$CODE_JOB/build")"
note "result: $(wait_for_build "$CODE_JOB" "$next_num")"
say "Ariadne collects evidence and asks Hermes for a minimal patch"
note "Hermes returns an anchored patch as data; Ariadne validates path, size,"
note "changed lines, and that the anchor is unique, then pushes hermes-repair/$next_num"
for _ in $(seq 1 40); do
sleep 15
ariadne_ticks 300 1 | grep -q "code_fix_proposed" && break
done
ariadne_ticks 400 3
say "Pull request awaiting human review (nothing merges automatically)"
note "https://scm.bstein.dev/bstein/hermes-code-demo/pulls"
}
case "${1:-}" in
fixture) cmd_fixture ;;
code) cmd_code ;;
status) cmd_status ;;
preflight) cmd_preflight ;;
reset) cmd_reset ;;
monitor) cmd_monitor ;;
*) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' ; exit 1 ;;
esac