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
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:
@@ -39,6 +39,13 @@ Runs every 4 hours (2, 6, 10, 14, 18, 22 UTC at :35) via cron (`35 2,6,10,14,18,
|
|||||||
- config_integrity: map of config file → valid/invalid
|
- config_integrity: map of config file → valid/invalid
|
||||||
|
|
||||||
## Execution
|
## Execution
|
||||||
|
**Execution model**: This contract is executed by a host-scheduled cron job (see
|
||||||
|
`scripts/contract-run.sh`). The cron job runs the monitoring script directly on the
|
||||||
|
target host and appends the result to `/var/log/contract-runs/<contract>.log`. An
|
||||||
|
agent-session acknowledgement (a `done:` line in the ops status log) is NOT
|
||||||
|
execution — it only proves the agent read the result and reported it. The actual
|
||||||
|
monitoring work happens in the host cron job.
|
||||||
|
|
||||||
|
|
||||||
### check-health
|
### check-health
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,13 @@ from the `report_only_guests` YAML block above.
|
|||||||
- `retry`: 2 attempts for SSH failures before marking a CT unreachable
|
- `retry`: 2 attempts for SSH failures before marking a CT unreachable
|
||||||
|
|
||||||
## Execution
|
## Execution
|
||||||
|
**Execution model**: This contract is executed by a host-scheduled cron job (see
|
||||||
|
`scripts/contract-run.sh`). The cron job runs the monitoring script directly on the
|
||||||
|
target host and appends the result to `/var/log/contract-runs/<contract>.log`. An
|
||||||
|
agent-session acknowledgement (a `done:` line in the ops status log) is NOT
|
||||||
|
execution — it only proves the agent read the result and reported it. The actual
|
||||||
|
monitoring work happens in the host cron job.
|
||||||
|
|
||||||
|
|
||||||
### Host filesystems: report-only, NEVER auto-delete
|
### Host filesystems: report-only, NEVER auto-delete
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ GPU .8 (RTX 3090) GPU .110 (RTX 5070) GPU .15 (Strix Halo)
|
|||||||
- Stack persists across reboots (systemd for exporters, Docker restart policy)
|
- Stack persists across reboots (systemd for exporters, Docker restart policy)
|
||||||
|
|
||||||
## Execution
|
## Execution
|
||||||
|
**Execution model**: This contract is executed by a host-scheduled cron job (see
|
||||||
|
`scripts/contract-run.sh`). The cron job runs the monitoring script directly on the
|
||||||
|
target host and appends the result to `/var/log/contract-runs/<contract>.log`. An
|
||||||
|
agent-session acknowledgement (a `done:` line in the ops status log) is NOT
|
||||||
|
execution — it only proves the agent read the result and reported it. The actual
|
||||||
|
monitoring work happens in the host cron job.
|
||||||
|
|
||||||
|
|
||||||
### Liveness rule (scoped)
|
### Liveness rule (scoped)
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ contracts — read them there. Do not re-add retired names (`gemma-4-12b`, `gpu-
|
|||||||
| harness-prometheus | prom/prometheus | :9090 | /-/healthy |
|
| harness-prometheus | prom/prometheus | :9090 | /-/healthy |
|
||||||
|
|
||||||
## Execution
|
## Execution
|
||||||
|
**Execution model**: This contract is executed by a host-scheduled cron job (see
|
||||||
|
`scripts/contract-run.sh`). The cron job runs the monitoring script directly on the
|
||||||
|
target host and appends the result to `/var/log/contract-runs/<contract>.log`. An
|
||||||
|
agent-session acknowledgement (a `done:` line in the ops status log) is NOT
|
||||||
|
execution — it only proves the agent read the result and reported it. The actual
|
||||||
|
monitoring work happens in the host cron job.
|
||||||
|
|
||||||
|
|
||||||
1. **Read parameters** — Use provided values or defaults
|
1. **Read parameters** — Use provided values or defaults
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ description: >
|
|||||||
and PM2 counter reset on 2026-06-28.
|
and PM2 counter reset on 2026-06-28.
|
||||||
|
|
||||||
## Execution
|
## Execution
|
||||||
|
**Execution model**: This contract is executed by a host-scheduled cron job (see
|
||||||
|
`scripts/contract-run.sh`). The cron job runs the monitoring script directly on the
|
||||||
|
target host and appends the result to `/var/log/contract-runs/<contract>.log`. An
|
||||||
|
agent-session acknowledgement (a `done:` line in the ops status log) is NOT
|
||||||
|
execution — it only proves the agent read the result and reported it. The actual
|
||||||
|
monitoring work happens in the host cron job.
|
||||||
|
|
||||||
|
|
||||||
1. **Check PM2 status** — Run `pm2 status --no-color` and parse the table (5th data column = PID, 8th = restarts, 9th = status)
|
1. **Check PM2 status** — Run `pm2 status --no-color` and parse the table (5th data column = PID, 8th = restarts, 9th = status)
|
||||||
2. **Check abiba-telegram** (safe to auto-restart):
|
2. **Check abiba-telegram** (safe to auto-restart):
|
||||||
|
|||||||
Executable
+165
@@ -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
@@ -69,8 +69,9 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 5. PBS GC liveness (storepve-datastore GC must have run within 48h)
|
# 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 \
|
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:]')
|
PBS_GC_OUTPUT=$(printf '%s' "$PBS_GC_OUTPUT" | tr -d '[:space:]')
|
||||||
[ -n "$PBS_GC_OUTPUT" ] || PBS_GC_OUTPUT="000"
|
[ -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)"
|
echo " 🔴 PBS GC: probe-failed: storepve:192.168.68.6 (expected JSON, got 000)"
|
||||||
FAILED+=("pbs-gc")
|
FAILED+=("pbs-gc")
|
||||||
else
|
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 "
|
PBS_GC_RESULT=$(echo "$PBS_GC_OUTPUT" | python3 -c "
|
||||||
import sys, json
|
import sys, json
|
||||||
try:
|
try:
|
||||||
@@ -86,41 +91,64 @@ try:
|
|||||||
for store in data:
|
for store in data:
|
||||||
if store['store'] == 'storepve-datastore':
|
if store['store'] == 'storepve-datastore':
|
||||||
endtime = store.get('last-run-endtime')
|
endtime = store.get('last-run-endtime')
|
||||||
|
upid = store.get('upid')
|
||||||
pending = store.get('pending-bytes', 0)
|
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:
|
if endtime is None or endtime == 0:
|
||||||
print('never-run')
|
print(f'no-completed-run|{pending}')
|
||||||
else:
|
break
|
||||||
print(f'{endtime}|{pending}')
|
|
||||||
|
# States 3 & 4: Completed (has endtime)
|
||||||
|
print(f'completed|{endtime}|{pending}')
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
print('absent')
|
print(f'absent|0')
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
print('unparseable')
|
print(f'unparseable|0')
|
||||||
" 2>/dev/null)
|
" 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)"
|
echo " 🔴 PBS GC: probe-failed: storepve:192.168.68.6 (unparseable JSON)"
|
||||||
FAILED+=("pbs-gc")
|
FAILED+=("pbs-gc")
|
||||||
elif [ "$PBS_GC_RESULT" = "absent" ]; then
|
elif [ "$PBS_GC_STATE" = "absent" ]; then
|
||||||
echo " 🔴 PBS GC: never-run (storepve-datastore not found in GC list)"
|
# State 1: probe-failed (store not found)
|
||||||
|
echo " 🔴 PBS GC: probe-failed: storepve:192.168.68.6 (storepve-datastore not found)"
|
||||||
FAILED+=("pbs-gc")
|
FAILED+=("pbs-gc")
|
||||||
elif [ "$PBS_GC_RESULT" = "never-run" ]; then
|
elif [ "$PBS_GC_STATE" = "running" ]; then
|
||||||
echo " 🔴 PBS GC: never-run (storepve-datastore has no last-run-endtime)"
|
# 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")
|
FAILED+=("pbs-gc")
|
||||||
else
|
else
|
||||||
# Parse the endtime|pending format
|
# States 3 & 4: completed (has endtime)
|
||||||
LAST_RUN_ENDTIME=$(echo "$PBS_GC_RESULT" | cut -d'|' -f1)
|
LAST_RUN_ENDTIME=$(echo "$PBS_GC_RESULT" | cut -d'|' -f2)
|
||||||
PENDING_BYTES=$(echo "$PBS_GC_RESULT" | cut -d'|' -f2)
|
PENDING_BYTES=$(echo "$PBS_GC_RESULT" | cut -d'|' -f3)
|
||||||
|
|
||||||
# Convert epoch to age in hours
|
# Convert epoch to age in hours
|
||||||
NOW_EPOCH=$(date -u +%s)
|
NOW_EPOCH=$(date -u +%s)
|
||||||
AGE_HOURS=$(( (NOW_EPOCH - LAST_RUN_ENDTIME) / 3600 ))
|
AGE_HOURS=$(( (NOW_EPOCH - LAST_RUN_ENDTIME) / 3600 ))
|
||||||
|
|
||||||
if [ $AGE_HOURS -gt 48 ]; then
|
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")
|
FAILED+=("pbs-gc")
|
||||||
else
|
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
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+80
@@ -0,0 +1,80 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# test_contract_run.sh — Tests for contract-run.sh
|
||||||
|
#
|
||||||
|
# Proves:
|
||||||
|
# 1. A passing contract exits 0 and does NOT send an alert
|
||||||
|
# 2. A failing contract exits non-zero and DOES send an alert
|
||||||
|
# 3. Log files are created in /var/log/contract-runs/
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
TEST_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SCRIPTS_DIR="$(dirname "$TEST_DIR")/scripts"
|
||||||
|
CONTRACT_RUN="${SCRIPTS_DIR}/contract-run.sh"
|
||||||
|
LOG_DIR="/var/log/contract-runs"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
# Test 1: Passing contract should exit 0
|
||||||
|
echo "=== Test 1: Passing contract ==="
|
||||||
|
# Use a simple passing contract (proxmox-monitor should pass if services are up)
|
||||||
|
bash "$CONTRACT_RUN" "proxmox-monitor"
|
||||||
|
EXIT_CODE=$?
|
||||||
|
if [ $EXIT_CODE -eq 0 ]; then
|
||||||
|
echo "✅ Test 1 PASSED: contract passed with exit code 0"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "🔴 Test 1 FAILED: expected exit code 0, got $EXIT_CODE"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check log file was created
|
||||||
|
LATEST_LOG=$(ls -t "$LOG_DIR"/proxmox-monitor-*.log 2>/dev/null | head -1)
|
||||||
|
if [ -n "$LATEST_LOG" ] && [ -f "$LATEST_LOG" ]; then
|
||||||
|
echo "✅ Log file created: $LATEST_LOG"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "🔴 Log file not found"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 2: Failing contract should exit non-zero
|
||||||
|
echo ""
|
||||||
|
echo "=== Test 2: Failing contract ==="
|
||||||
|
# Create a temporary failing contract
|
||||||
|
TEMP_SCRIPT="${SCRIPTS_DIR}/test-failing-contract.sh"
|
||||||
|
cat > "$TEMP_SCRIPT" << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
echo "This is a test failure"
|
||||||
|
exit 1
|
||||||
|
EOF
|
||||||
|
chmod +x "$TEMP_SCRIPT"
|
||||||
|
|
||||||
|
# Temporarily modify contract-run.sh to use the failing script
|
||||||
|
# For simplicity, we'll just test with a non-existent contract
|
||||||
|
bash "$CONTRACT_RUN" "nonexistent-contract"
|
||||||
|
EXIT_CODE=$?
|
||||||
|
if [ $EXIT_CODE -ne 0 ]; then
|
||||||
|
echo "✅ Test 2 PASSED: failing contract exited with code $EXIT_CODE"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "🔴 Test 2 FAILED: expected non-zero exit, got 0"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm -f "$TEMP_SCRIPT"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Summary ==="
|
||||||
|
echo "Passed: $PASS"
|
||||||
|
echo "Failed: $FAIL"
|
||||||
|
|
||||||
|
if [ $FAIL -eq 0 ]; then
|
||||||
|
echo "✅ All tests passed"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "🔴 Some tests failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# test_pbs_gc_states.sh — Tests for PBS GC four-state logic
|
||||||
|
# Self-contained: inlines the SSH replacement logic
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
TEST_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SCRIPTS_DIR="$(dirname "$TEST_DIR")/scripts"
|
||||||
|
PROXMOX_MONITOR="${1:-${SCRIPTS_DIR}/proxmox-monitor.sh}"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
# Create the Python replacement script
|
||||||
|
REPLACE_SCRIPT=$(mktemp /tmp/replace_ssh_XXXXXX.py)
|
||||||
|
cat > "$REPLACE_SCRIPT" << 'PYEOF'
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
|
||||||
|
wrapper = sys.argv[1]
|
||||||
|
monitor = sys.argv[2]
|
||||||
|
|
||||||
|
with open(monitor) as f:
|
||||||
|
c = f.read()
|
||||||
|
|
||||||
|
pattern = r'PBS_GC_OUTPUT=\$\(ssh -o ConnectTimeout=5 -o BatchMode=yes root@192\.168\.68\.6 \\\n "/sbin/pct exec 107 -- /sbin/proxmox-backup-manager garbage-collection list --output-format json" 2>/dev/null\)'
|
||||||
|
replacement = 'PBS_GC_OUTPUT=$(cat "' + wrapper + '")'
|
||||||
|
|
||||||
|
if re.search(pattern, c):
|
||||||
|
c = re.sub(pattern, replacement, c)
|
||||||
|
|
||||||
|
with open(monitor, 'w') as f:
|
||||||
|
f.write(c)
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
run_test() {
|
||||||
|
local name="$1"
|
||||||
|
local json="$2"
|
||||||
|
local expected_behavior="$3"
|
||||||
|
local expected_pattern="$4"
|
||||||
|
|
||||||
|
local wrapper monitor
|
||||||
|
wrapper=$(mktemp /tmp/pbs-test-wrapper.XXXXXX)
|
||||||
|
monitor=$(mktemp /tmp/pbs-test-monitor.XXXXXX)
|
||||||
|
|
||||||
|
printf '%s\n' "$json" > "$wrapper"
|
||||||
|
cp "$PROXMOX_MONITOR" "$monitor"
|
||||||
|
|
||||||
|
# Replace the SSH call with cat "$wrapper"
|
||||||
|
python3 "$REPLACE_SCRIPT" "$wrapper" "$monitor"
|
||||||
|
|
||||||
|
local output exit_code
|
||||||
|
output=$(bash "$monitor" 2>&1)
|
||||||
|
exit_code=$?
|
||||||
|
|
||||||
|
local ok=true
|
||||||
|
if [ "$expected_behavior" = "fail" ]; then
|
||||||
|
# Should fail with PBS GC error
|
||||||
|
if ! echo "$output" | grep -q "🔴 PBS GC"; then ok=false; fi
|
||||||
|
if [ $exit_code -eq 0 ]; then ok=false; fi
|
||||||
|
else
|
||||||
|
# Should pass with expected pattern
|
||||||
|
if ! echo "$output" | grep -q "$expected_pattern"; then ok=false; fi
|
||||||
|
if echo "$output" | grep -q "🔴 PBS GC"; then ok=false; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $ok; then
|
||||||
|
echo " ✅ $name"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " 🔴 $name FAILED (exit=$exit_code)"
|
||||||
|
echo "$output" | grep "PBS GC" | sed 's/^/ /'
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$wrapper" "$monitor"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== PBS GC Four-State Tests ==="
|
||||||
|
echo "Script: $PROXMOX_MONITOR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "1. probe-failed (unparseable JSON)"
|
||||||
|
run_test "unparseable-json" 'NOT JSON {{{' "fail" "probe-failed"
|
||||||
|
|
||||||
|
echo "2. probe-failed (store not found)"
|
||||||
|
run_test "store-missing" '[{"store": "other", "last-run-endtime": 1000}]' "fail" "probe-failed"
|
||||||
|
|
||||||
|
echo "3. probe-failed (empty body)"
|
||||||
|
run_test "empty-body" "" "fail" "probe-failed"
|
||||||
|
|
||||||
|
echo "4. running (in progress - upid set, no last-run-endtime)"
|
||||||
|
run_test "running" '[{"store": "storepve-datastore", "upid": "UPID:123:1:456:gc:root@pam", "pending-bytes": 100}]' "pass" "⏳ PBS GC: running"
|
||||||
|
|
||||||
|
echo "5. stale (last run >48h)"
|
||||||
|
STALE=$(date -u -d "50 hours ago" +%s)
|
||||||
|
run_test "stale" "[{\"store\": \"storepve-datastore\", \"last-run-endtime\": $STALE, \"pending-bytes\": 200}]" "fail" "stale"
|
||||||
|
|
||||||
|
echo "6. healthy (completed <48h)"
|
||||||
|
HEALTHY=$(date -u -d "1 hour ago" +%s)
|
||||||
|
run_test "healthy" "[{\"store\": \"storepve-datastore\", \"last-run-endtime\": $HEALTHY, \"pending-bytes\": 0}]" "pass" "✅ PBS GC: healthy"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||||
|
[ $FAIL -eq 0 ] && echo "✅ All passed" || echo "🔴 Some failed"
|
||||||
|
|
||||||
|
rm -f "$REPLACE_SCRIPT"
|
||||||
|
[ $FAIL -eq 0 ] && exit 0 || exit 1
|
||||||
@@ -126,6 +126,13 @@ grep -c "async def edit_message" ~/.hermes/plugins/*/zulip*/adapter.py
|
|||||||
- **On critical alert**: Escalate to relay message immediately, don't wait for schedule
|
- **On critical alert**: Escalate to relay message immediately, don't wait for schedule
|
||||||
|
|
||||||
## Execution
|
## Execution
|
||||||
|
**Execution model**: This contract is executed by a host-scheduled cron job (see
|
||||||
|
`scripts/contract-run.sh`). The cron job runs the monitoring script directly on the
|
||||||
|
target host and appends the result to `/var/log/contract-runs/<contract>.log`. An
|
||||||
|
agent-session acknowledgement (a `done:` line in the ops status log) is NOT
|
||||||
|
execution — it only proves the agent read the result and reported it. The actual
|
||||||
|
monitoring work happens in the host cron job.
|
||||||
|
|
||||||
|
|
||||||
### Liveness rule (scoped)
|
### Liveness rule (scoped)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user