PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 11s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 12s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 5s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 11s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 2s
When ZULIP_API_KEY is unset or contains 'placeholder'/'REDACTED', skip the global Zulip server leg with a ⏭ marker instead of failing the whole script. The pi/Tanko/kagentz legs do not need the Zulip API key and keep their verdicts. Tracked as: zulip-health-credential-placeholder-20260913 (captain-held) This removes the repeated 'Action required' noise every cycle while keeping the credential enforcement loud and visible.
212 lines
9.6 KiB
Bash
Executable File
212 lines
9.6 KiB
Bash
Executable File
#!/bin/bash
|
|
# /root/scripts/zulip-monitor.sh — Zulip Mesh Health Monitor
|
|
# Implements zulip-health.prose.md v3
|
|
# Runs every 15 min via cron. Alerts: Zulip private DM to the owner plus a stream post to #agent-hub on topic 'zulip-health'.
|
|
# Legs: global Zulip server, Platform A pi/Abiba (the Zulip bridge), Platform B
|
|
# Tanko (DSH), Platform C Agent Zero (kagentz). The former Platform B Hermes
|
|
# agent leg is retired — see the note after the Tanko leg.
|
|
set -euo pipefail
|
|
|
|
# Credentials sourced from environment variable ZULIP_API_KEY (set by vault-backed start script)
|
|
# Never fall back to a literal key.
|
|
# When unset/placeholder, the server leg is skipped (not the whole script) — the pi/Tanko/kagentz
|
|
# legs do not need the Zulip API key. The placeholder is captain-held:
|
|
# zulip-health-credential-placeholder-20260913.
|
|
ZULIP_API_KEY="${ZULIP_API_KEY:-}"
|
|
ZULIP_SITE="https://chat.sysloggh.net"
|
|
ZULIP_EMAIL="abiba-bot@chat.sysloggh.net"
|
|
OWNER_ZULIP_ID="9"
|
|
|
|
# Track whether the Zulip API credential is usable
|
|
ZULIP_CRED_OK=1
|
|
if [ -z "$ZULIP_API_KEY" ] || [[ "$ZULIP_API_KEY" == *"placeholder"* ]] || [[ "$ZULIP_API_KEY" == *"REDACTED"* ]]; then
|
|
ZULIP_CRED_OK=0
|
|
fi
|
|
|
|
LOG="/root/zulip-health-monitor.log"
|
|
TIMESTAMP=$(date -u '+%Y-%m-%d %H:%M UTC')
|
|
ISSUES=0
|
|
echo "=== Zulip Health Check — $TIMESTAMP ===" >> "$LOG"
|
|
|
|
notify() {
|
|
local severity="$1" msg="$2"
|
|
echo "[$severity] $msg"
|
|
|
|
# Zulip DM to owner (skip if no credential)
|
|
if [ "$ZULIP_CRED_OK" -eq 1 ]; then
|
|
local content="${severity} Zulip Monitor: ${msg}"
|
|
local form
|
|
form="type=private&to=%5B${OWNER_ZULIP_ID}%5D&content=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''${content}'''))")"
|
|
curl -sf -X POST "${ZULIP_SITE}/api/v1/messages" \
|
|
-u "${ZULIP_EMAIL}:${ZULIP_API_KEY}" \
|
|
-d "${form}" > /dev/null 2>&1 || true
|
|
# Zulip stream post to #agent-hub on topic 'zulip-health'
|
|
local stream_content="${severity} Zulip Monitor: ${msg}"
|
|
curl -sf -X POST "${ZULIP_SITE}/api/v1/messages" \
|
|
-u "${ZULIP_EMAIL}:${ZULIP_API_KEY}" \
|
|
-d "type=stream&to=%5B7%5D&topic=zulip-health&content=$(printf '%s' "${stream_content}" | python3 -c "import sys,urllib.parse; print(urllib.parse.quote_from_bytes(sys.stdin.buffer.read()))")" \
|
|
> /dev/null 2>&1 \
|
|
|| echo " WARN: stream alert to #agent-hub (zulip-health) delivery failed (curl exit $?)">> "$LOG"
|
|
fi
|
|
}
|
|
|
|
# ── Global: Zulip Server ──
|
|
# Skip when credential is placeholder/absent (captain-held item)
|
|
if [ "$ZULIP_CRED_OK" -eq 0 ]; then
|
|
echo " Server: ⏭ skipped: credential placeholder, held for captain (zulip-health-credential-placeholder-20260913)" >> "$LOG"
|
|
else
|
|
SERVER_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 \
|
|
https://chat.sysloggh.net/api/v1/server_settings \
|
|
-u "${ZULIP_EMAIL}:${ZULIP_API_KEY}" 2>/dev/null) || SERVER_CODE="000"
|
|
SERVER_CODE=$(printf '%s' "$SERVER_CODE" | tr -d '[:space:]')
|
|
[ -n "$SERVER_CODE" ] || SERVER_CODE="000"
|
|
if [ "$SERVER_CODE" != "200" ]; then
|
|
notify "🔴" "Zulip server returned HTTP $SERVER_CODE"
|
|
ISSUES=$((ISSUES + 1))
|
|
else
|
|
echo " Server: ✅ HTTP 200" >> "$LOG"
|
|
fi
|
|
fi
|
|
|
|
# ── Platform A: pi (Abiba) ──
|
|
# Probes the pi Zulip extension health endpoint (:9200/health, served by the
|
|
# extension's startHealthServer; shape documented in zulip-health.prose.md).
|
|
# FAIL-SAFE contract (pinned by tests/zulip-monitor-abiba.sh): connection state
|
|
# lives NESTED at zulip.connected / zulip.last_error — there is no top-level
|
|
# `connected` and no retry counter in the payload. A fetch error, non-2xx
|
|
# response, empty/unparseable body, or payload missing a boolean
|
|
# zulip.connected is a PROBE FAILURE: it alerts and NEVER calls pm2 restart.
|
|
# pm2 restart runs ONLY on affirmative zulip.connected=false.
|
|
# -- abiba-leg-start (verbatim-extracted by tests/zulip-monitor-abiba.sh)
|
|
PI_HTTP=$(curl -s -o /dev/null --connect-timeout 5 --max-time 10 -w '%{http_code}' http://localhost:9200/health 2>/dev/null) || PI_HTTP="000"
|
|
PI_HTTP=$(printf '%s' "$PI_HTTP" | tr -d '[:space:]')
|
|
[ -n "$PI_HTTP" ] || PI_HTTP="000"
|
|
PI_BODY=$(curl -s --connect-timeout 5 --max-time 10 http://localhost:9200/health 2>/dev/null || true)
|
|
PI_STATE=$(printf '%s' "$PI_BODY" | python3 -c '
|
|
import sys, json
|
|
code = sys.argv[1]
|
|
body = sys.stdin.read()
|
|
try:
|
|
d = json.loads(body)
|
|
except Exception:
|
|
sys.stdout.write("probe-failed|unparseable body")
|
|
sys.exit(0)
|
|
if not code.startswith("2"):
|
|
sys.stdout.write("probe-failed|HTTP %s" % code)
|
|
sys.exit(0)
|
|
if not isinstance(d, dict) or not isinstance(d.get("zulip"), dict):
|
|
sys.stdout.write("probe-failed|missing zulip.connected")
|
|
sys.exit(0)
|
|
z = d["zulip"]
|
|
if "connected" not in z or not isinstance(z["connected"], bool):
|
|
sys.stdout.write("probe-failed|missing or non-boolean zulip.connected")
|
|
sys.exit(0)
|
|
err = z.get("last_error") or ""
|
|
if z["connected"]:
|
|
if err:
|
|
sys.stdout.write("degraded|%s" % err)
|
|
else:
|
|
sys.stdout.write("healthy|%s" % z.get("messages_processed", 0))
|
|
else:
|
|
sys.stdout.write("disconnected|")
|
|
' "$PI_HTTP" 2>/dev/null) || PI_STATE="probe-failed|python error"
|
|
PI_VERDICT=${PI_STATE%%|*}
|
|
PI_DETAIL=${PI_STATE#*|}
|
|
|
|
case "$PI_VERDICT" in
|
|
healthy)
|
|
echo " Abiba: ✅ Connected (processed=$PI_DETAIL)" >> "$LOG" ;;
|
|
degraded)
|
|
notify "🟡" "Abiba pi extension error: ${PI_DETAIL:0:100}"
|
|
echo " Abiba: 🟡 Error: ${PI_DETAIL:0:100}" >> "$LOG" ;;
|
|
disconnected)
|
|
notify "🔴" "Abiba pi extension DISCONNECTED — restarting"
|
|
pm2 restart abiba-zulip 2>/dev/null || true
|
|
ISSUES=$((ISSUES + 1))
|
|
echo " Abiba: ❌ Disconnected — restarted" >> "$LOG" ;;
|
|
probe-failed)
|
|
notify "🟠" "Abiba pi extension health probe FAILED (${PI_DETAIL}; HTTP $PI_HTTP) — NOT restarting, manual check needed"
|
|
ISSUES=$((ISSUES + 1))
|
|
echo " Abiba: ⚠️ Probe failed (${PI_DETAIL}; HTTP $PI_HTTP) — NOT restarted" >> "$LOG" ;;
|
|
*)
|
|
notify "🟠" "Abiba pi extension health probe returned unexpected verdict (${PI_STATE}) — NOT restarting, manual check needed"
|
|
ISSUES=$((ISSUES + 1))
|
|
echo " Abiba: ⚠️ Unexpected probe verdict (${PI_STATE}) — NOT restarted" >> "$LOG" ;;
|
|
esac
|
|
# -- abiba-leg-end
|
|
|
|
# ── Platform B: Tanko (DSH dsh-web on amdpve CT 112) ──
|
|
# Direct SSH to 192.168.68.122 is not a dependency of this monitor — per-worker
|
|
# key availability varies — so probes run from the amdpve vantage via `pct exec`.
|
|
# Tanko's Zulip gateway runs as the dsh-web systemd unit inside CT 112 on amdpve
|
|
# (192.168.68.15). The gateway binds 127.0.0.1:3080 loopback-only by design — a
|
|
# remote :3080 probe is refused and is NOT a fault.
|
|
TANKO_SVC=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@192.168.68.15 \
|
|
"pct exec 112 -- systemctl is-active dsh-web" 2>/dev/null || true)
|
|
[ -n "$TANKO_SVC" ] || TANKO_SVC="unknown"
|
|
TANKO_HTTP=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@192.168.68.15 \
|
|
"pct exec 112 -- curl -s --connect-timeout 5 --max-time 10 -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/" 2>/dev/null || true)
|
|
[ -n "$TANKO_HTTP" ] || TANKO_HTTP="000"
|
|
|
|
if [ "$TANKO_SVC" != "active" ]; then
|
|
notify "🔴" "Tanko (DSH dsh-web) service state: $TANKO_SVC — needs restart"
|
|
ISSUES=$((ISSUES + 1))
|
|
echo " Tanko: ❌ service=$TANKO_SVC" >> "$LOG"
|
|
elif [ "$TANKO_HTTP" = "000" ]; then
|
|
notify "🔴" "Tanko (DSH dsh-web) HTTP :3080 connection refused/timeout — needs restart"
|
|
ISSUES=$((ISSUES + 1))
|
|
echo " Tanko: ❌ http=000 (refused/timeout)" >> "$LOG"
|
|
else
|
|
case "$TANKO_HTTP" in
|
|
200|301|302|307|308|401|403)
|
|
echo " Tanko: ✅ service=active http=$TANKO_HTTP" >> "$LOG" ;;
|
|
*)
|
|
notify "🟡" "Tanko (DSH dsh-web) HTTP :3080 answered $TANKO_HTTP — running, unexpected status"
|
|
echo " Tanko: 🟡 service=active http=$TANKO_HTTP (running, warning)" >> "$LOG" ;;
|
|
esac
|
|
fi
|
|
|
|
# ── Removed: the former "Platform B: Hermes" agent leg ──
|
|
# Captain ruling 2026-09-10: that agent moved off this host onto her own
|
|
# container (kagentz CT 105 on minipve, dedicated `hermes` user) and is now
|
|
# monitored on her side — see the out-of-scope note in zulip-health.prose.md.
|
|
# The old leg ssh'd to her former CT 100 deployment and read its Hermes gateway
|
|
# state, which reported "unknown" on every run and posted a false 🔴 DM plus an
|
|
# #agent-hub stream alert. Do NOT re-add a probe for her: this monitor must
|
|
# never contact her former host.
|
|
|
|
# ── Platform C: Agent Zero (kagentz) ──
|
|
AZ_A2A_CODE=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@192.168.68.14 \
|
|
"docker exec agent-zero curl -s --connect-timeout 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:80/a2a/ 2>/dev/null" 2>/dev/null) || AZ_A2A_CODE="000"
|
|
AZ_A2A_CODE=$(printf '%s' "$AZ_A2A_CODE" | tr -d '[:space:]')
|
|
[ -n "$AZ_A2A_CODE" ] || AZ_A2A_CODE="000"
|
|
|
|
if [ "$AZ_A2A_CODE" = "000" ]; then
|
|
notify "🔴" "kagentz A2A server DOWN (connection failed)"
|
|
ISSUES=$((ISSUES + 1))
|
|
echo " kagentz: ❌ A2A down (HTTP 000)" >> "$LOG"
|
|
else
|
|
case "$AZ_A2A_CODE" in
|
|
200|401)
|
|
echo " kagentz: ✅ A2A alive (HTTP $AZ_A2A_CODE)" >> "$LOG" ;;
|
|
*)
|
|
notify "🟡" "kagentz A2A server answered HTTP $AZ_A2A_CODE — running, unexpected status"
|
|
ISSUES=$((ISSUES + 1))
|
|
echo " kagentz: 🟡 A2A unexpected http=$AZ_A2A_CODE (running, warning)" >> "$LOG" ;;
|
|
esac
|
|
fi
|
|
|
|
# ── Summary ──
|
|
if [ "$ISSUES" -eq 0 ]; then
|
|
if [ "$ZULIP_CRED_OK" -eq 0 ]; then
|
|
echo " Result: ✅ All healthy (server leg skipped: credential placeholder)" >> "$LOG"
|
|
else
|
|
echo " Result: ✅ All healthy" >> "$LOG"
|
|
fi
|
|
else
|
|
echo " Result: 🔴 $ISSUES issue(s) found" >> "$LOG"
|
|
notify "🔴" "$ISSUES issue(s) found — check /root/zulip-health-monitor.log"
|
|
fi
|
|
|
|
echo "" >> "$LOG"
|