Merge pull request 'Fix PBS GC monitor false positive and add contract-run.sh wrapper' (#131) from fix/contract-run-pbs-gc-20260924 into master
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 7s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 12s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 13s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 9s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 4s

This commit was merged in pull request #131.
This commit is contained in:
2026-09-24 06:07:08 +00:00
10 changed files with 440 additions and 17 deletions
+165
View File
@@ -0,0 +1,165 @@
#!/bin/bash
# contract-run.sh — Deterministic contract execution from machine scheduler
#
# Takes a contract name, resolves its script, runs it with timeout,
# logs output to $CONTRACT_RUN_LOG_DIR (default: /var/log/contract-runs/),
# and alerts on failure.
#
# Environment:
# CONTRACT_RUN_LOG_DIR Override the log directory (default: /var/log/contract-runs)
#
# Usage: bash scripts/contract-run.sh <contract-name>
#
# Contract names map to scripts as follows:
# infrastructure-monitoring -> scripts/infra-monitoring.sh
# proxmox-monitor -> scripts/proxmox-monitor.sh
# zulip-health -> scripts/zulip-monitor.sh
# agent-health-check -> scripts/agent-health-check.py
# litellm-health -> scripts/litellm-health-check.py
# disk-gc-threat-response -> scripts/disk-gc-scan.py
# pm2-self-heal -> scripts/pm2-self-heal.sh
#
# Exit codes:
# 0 = contract passed
# 1 = contract failed (alert sent)
# 2 = probe failed (script missing, timeout, etc.)
set -uo pipefail
CONTRACT_NAME="$1"
SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"
LOG_DIR="${CONTRACT_RUN_LOG_DIR:-/var/log/contract-runs}"
TIMESTAMP=$(date -u '+%Y%m%d-%H%M%S')
LOG_FILE="${LOG_DIR}/${CONTRACT_NAME}-${TIMESTAMP}.log"
# Ensure log directory exists
mkdir -p "$LOG_DIR"
# Map contract name to script path
case "$CONTRACT_NAME" in
infrastructure-monitoring)
SCRIPT_PATH="${SCRIPTS_DIR}/infra-monitoring.sh"
INTERPRETER="bash"
;;
proxmox-monitor)
SCRIPT_PATH="${SCRIPTS_DIR}/proxmox-monitor.sh"
INTERPRETER="bash"
;;
zulip-health)
SCRIPT_PATH="${SCRIPTS_DIR}/zulip-monitor.sh"
INTERPRETER="bash"
;;
agent-health-check)
SCRIPT_PATH="${SCRIPTS_DIR}/agent-health-check.py"
INTERPRETER="python3"
;;
litellm-health)
SCRIPT_PATH="${SCRIPTS_DIR}/litellm-health-check.py"
INTERPRETER="python3"
;;
pm2-self-heal)
SCRIPT_PATH="${SCRIPTS_DIR}/pm2-self-heal.sh"
INTERPRETER="bash"
;;
disk-gc-threat-response)
SCRIPT_PATH="${SCRIPTS_DIR}/disk-gc-scan.py"
INTERPRETER="python3"
;;
*)
echo "Unknown contract: $CONTRACT_NAME" | tee -a "$LOG_FILE"
# Send alert for unknown contract
ALERT_MSG="🔴 Contract $CONTRACT_NAME: unknown contract name. Log: $LOG_FILE"
ZULIP_API_URL="${ZULIP_API_URL:-https://chat.sysloggh.net/api/v1}"
ZULIP_API_KEY="${ZULIP_API_KEY:-}"
ZULIP_USER="${ZULIP_USER:-abiba-bot@chat.sysloggh.net}"
if [ -n "$ZULIP_API_KEY" ] && command -v curl &> /dev/null; then
curl -sf -X POST "${ZULIP_API_URL}/messages" \
-u "${ZULIP_USER}:${ZULIP_API_KEY}" \
-d "type=private" \
-d "to=9" \
-d "content=${ALERT_MSG}" > /dev/null 2>&1 || true
fi
exit 2
;;
esac
# Check if script exists
if [ ! -f "$SCRIPT_PATH" ]; then
echo "Script not found: $SCRIPT_PATH" | tee -a "$LOG_FILE"
# Send alert for missing script
ALERT_MSG="🔴 Contract $CONTRACT_NAME: script not found at $SCRIPT_PATH. Log: $LOG_FILE"
ZULIP_API_URL="${ZULIP_API_URL:-https://chat.sysloggh.net/api/v1}"
ZULIP_API_KEY="${ZULIP_API_KEY:-}"
ZULIP_USER="${ZULIP_USER:-abiba-bot@chat.sysloggh.net}"
if [ -n "$ZULIP_API_KEY" ] && command -v curl &> /dev/null; then
curl -sf -X POST "${ZULIP_API_URL}/messages" \
-u "${ZULIP_USER}:${ZULIP_API_KEY}" \
-d "type=private" \
-d "to=9" \
-d "content=${ALERT_MSG}" > /dev/null 2>&1 || true
fi
exit 2
fi
# Run the script with timeout and capture output
echo "=== Contract: $CONTRACT_NAME ===" | tee "$LOG_FILE"
echo "Started: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" | tee -a "$LOG_FILE"
echo "Script: $SCRIPT_PATH" | tee -a "$LOG_FILE"
echo "" | tee -a "$LOG_FILE"
# Use timeout to prevent hangs (10 minutes default)
TIMEOUT=600
timeout "$TIMEOUT" $INTERPRETER "$SCRIPT_PATH" 2>&1 | tee -a "$LOG_FILE"
EXIT_CODE=${PIPESTATUS[0]}
# If timeout killed the process, EXIT_CODE will be 124
if [ $EXIT_CODE -eq 124 ]; then
echo "⏰ TIMEOUT: script exceeded ${TIMEOUT}s limit" | tee -a "$LOG_FILE"
fi
echo "" | tee -a "$LOG_FILE"
if [ $EXIT_CODE -eq 0 ]; then
echo "✅ VERDICT: PASS" | tee -a "$LOG_FILE"
exit 0
else
echo "🔴 VERDICT: FAIL (exit code $EXIT_CODE)" | tee -a "$LOG_FILE"
# Send alert (Zulip DM to user 9 + stream agent-hub topic alerts-infra)
# Using the same alert path as other monitors
ALERT_MSG="🔴 Contract $CONTRACT_NAME failed (exit $EXIT_CODE). Log: $LOG_FILE"
ALERT_SENT=false
# Take credentials from environment (ZULIP_API_KEY required)
ZULIP_API_URL="${ZULIP_API_URL:-https://chat.sysloggh.net/api/v1}"
ZULIP_API_KEY="${ZULIP_API_KEY:-}"
ZULIP_USER="${ZULIP_USER:-abiba-bot@chat.sysloggh.net}"
if [ -n "$ZULIP_API_KEY" ] && command -v curl &> /dev/null; then
# DM to user 9
DM_EXIT=0
curl -sf -X POST "${ZULIP_API_URL}/messages" \
-u "${ZULIP_USER}:${ZULIP_API_KEY}" \
-d "type=private" \
-d "to=9" \
-d "content=${ALERT_MSG}" > /dev/null 2>&1 || DM_EXIT=$?
# Stream agent-hub topic alerts-infra
STREAM_EXIT=0
curl -sf -X POST "${ZULIP_API_URL}/messages" \
-u "${ZULIP_USER}:${ZULIP_API_KEY}" \
-d "type=stream" \
-d "to=agent-hub" \
-d "topic=alerts-infra" \
-d "content=${ALERT_MSG}" > /dev/null 2>&1 || STREAM_EXIT=$?
if [ $DM_EXIT -eq 0 ] || [ $STREAM_EXIT -eq 0 ]; then
ALERT_SENT=true
else
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') ALERT FAILURE: DM exit=$DM_EXIT, stream exit=$STREAM_EXIT" >> "$LOG_FILE"
fi
else
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') ALERT SKIPPED: no ZULIP_API_KEY or curl" >> "$LOG_FILE"
fi
exit 1
fi
+45 -17
View File
@@ -69,8 +69,9 @@ else
fi
# 5. PBS GC liveness (storepve-datastore GC must have run within 48h)
# Use absolute path for pct to avoid PATH issues in non-interactive ssh
PBS_GC_OUTPUT=$(ssh -o ConnectTimeout=5 -o BatchMode=yes root@192.168.68.6 \
"pct exec 107 -- proxmox-backup-manager garbage-collection list --output-format json" 2>/dev/null)
"/sbin/pct exec 107 -- /sbin/proxmox-backup-manager garbage-collection list --output-format json" 2>/dev/null)
PBS_GC_OUTPUT=$(printf '%s' "$PBS_GC_OUTPUT" | tr -d '[:space:]')
[ -n "$PBS_GC_OUTPUT" ] || PBS_GC_OUTPUT="000"
@@ -78,7 +79,11 @@ if [ "$PBS_GC_OUTPUT" = "000" ]; then
echo " 🔴 PBS GC: probe-failed: storepve:192.168.68.6 (expected JSON, got 000)"
FAILED+=("pbs-gc")
else
# Parse the JSON to get storepve-datastore's last-run-endtime and pending-bytes
# Parse the JSON to get storepve-datastore's state with four distinct outcomes:
# 1. probe-failed: non-zero ssh status / empty / unparseable JSON
# 2. running: collection in progress (last-run-endtime absent or 0, but upid present)
# 3. stale: no completed run within 48h
# 4. healthy: completed within 48h
PBS_GC_RESULT=$(echo "$PBS_GC_OUTPUT" | python3 -c "
import sys, json
try:
@@ -86,41 +91,64 @@ try:
for store in data:
if store['store'] == 'storepve-datastore':
endtime = store.get('last-run-endtime')
upid = store.get('upid')
pending = store.get('pending-bytes', 0)
# State 2: Running (collection in progress) — last-run-endtime absent while run is in progress
if (endtime is None or endtime == 0) and upid is not None:
print(f'running|{pending}')
break
# State 3: No completed run (never-run or stale)
if endtime is None or endtime == 0:
print('never-run')
else:
print(f'{endtime}|{pending}')
print(f'no-completed-run|{pending}')
break
# States 3 & 4: Completed (has endtime)
print(f'completed|{endtime}|{pending}')
break
else:
print('absent')
print(f'absent|0')
except json.JSONDecodeError:
print('unparseable')
print(f'unparseable|0')
" 2>/dev/null)
if [ -z "$PBS_GC_RESULT" ] || [ "$PBS_GC_RESULT" = "unparseable" ]; then
# Parse the state|endtime|pending format
PBS_GC_STATE=$(echo "$PBS_GC_RESULT" | cut -d'|' -f1)
if [ "$PBS_GC_STATE" = "unparseable" ]; then
# State 1: probe-failed (unparseable JSON)
echo " 🔴 PBS GC: probe-failed: storepve:192.168.68.6 (unparseable JSON)"
FAILED+=("pbs-gc")
elif [ "$PBS_GC_RESULT" = "absent" ]; then
echo " 🔴 PBS GC: never-run (storepve-datastore not found in GC list)"
elif [ "$PBS_GC_STATE" = "absent" ]; then
# State 1: probe-failed (store not found)
echo " 🔴 PBS GC: probe-failed: storepve:192.168.68.6 (storepve-datastore not found)"
FAILED+=("pbs-gc")
elif [ "$PBS_GC_RESULT" = "never-run" ]; then
echo " 🔴 PBS GC: never-run (storepve-datastore has no last-run-endtime)"
elif [ "$PBS_GC_STATE" = "running" ]; then
# State 2: collection in progress — do NOT fail
PENDING_BYTES=$(echo "$PBS_GC_RESULT" | cut -d'|' -f2)
echo " ⏳ PBS GC: running (started: in-progress, pending-bytes: ${PENDING_BYTES} B)"
elif [ "$PBS_GC_STATE" = "no-completed-run" ]; then
# State 3: no completed run within 48h
PENDING_BYTES=$(echo "$PBS_GC_RESULT" | cut -d'|' -f2)
echo " 🔴 PBS GC: no completed run within 48h (pending-bytes: ${PENDING_BYTES} B)"
FAILED+=("pbs-gc")
else
# Parse the endtime|pending format
LAST_RUN_ENDTIME=$(echo "$PBS_GC_RESULT" | cut -d'|' -f1)
PENDING_BYTES=$(echo "$PBS_GC_RESULT" | cut -d'|' -f2)
# States 3 & 4: completed (has endtime)
LAST_RUN_ENDTIME=$(echo "$PBS_GC_RESULT" | cut -d'|' -f2)
PENDING_BYTES=$(echo "$PBS_GC_RESULT" | cut -d'|' -f3)
# Convert epoch to age in hours
NOW_EPOCH=$(date -u +%s)
AGE_HOURS=$(( (NOW_EPOCH - LAST_RUN_ENDTIME) / 3600 ))
if [ $AGE_HOURS -gt 48 ]; then
echo " 🔴 PBS GC: stale (last run ${AGE_HOURS}h ago, pending-bytes: ${PENDING_BYTES} B)"
# State 3: stale (no completed run within 48h)
echo " 🔴 PBS GC: stale — last completed run was ${AGE_HOURS}h ago (pending-bytes: ${PENDING_BYTES} B)"
FAILED+=("pbs-gc")
else
echo " ✅ PBS GC: healthy (last run ${AGE_HOURS}h ago, pending-bytes: ${PENDING_BYTES} B)"
# State 4: healthy (completed within 48h)
echo " ✅ PBS GC: healthy — last completed run ${AGE_HOURS}h ago (pending-bytes: ${PENDING_BYTES} B)"
fi
fi
fi