diff --git a/scripts/arc-check.sh b/scripts/arc-check.sh index 244f9c3..321602d 100755 --- a/scripts/arc-check.sh +++ b/scripts/arc-check.sh @@ -173,12 +173,17 @@ if [[ -n "$EL_RPC_URL" ]]; then "$EL_RPC_URL" || return 1 } - el_peers_json=$(el_rpc "admin_peers") || die "Failed to fetch admin_peers from ${EL_RPC_URL}" - el_peer_count=$(echo "$el_peers_json" | jq '.result | length') - - echo "Connected: ${el_peer_count} peers" - echo "$el_peers_json" | jq -r '.result[] | - " \(.name // "unknown") \(if .network.inbound then "inbound" else "outbound" end) \(.enode | split("@")[1])"' + if ! el_peers_json=$(el_rpc "admin_peers"); then + echo "$(yellow "WARN: could not reach ${EL_RPC_URL} (admin_peers) — skipping")" + elif el_err=$(echo "$el_peers_json" | jq -r '.error.message // empty') && [[ -n "$el_err" ]]; then + echo "$(yellow "WARN: admin_peers unavailable: ${el_err} (is the admin namespace enabled?)")" + else + el_peer_count=$(echo "$el_peers_json" | jq '.result | length') + + echo "Connected: ${el_peer_count} peers" + echo "$el_peers_json" | jq -r '.result[] | + " \(.name // "unknown") \(if .network.inbound then "inbound" else "outbound" end) \(.enode | split("@")[1])"' + fi fi # --- proposal history ------------------------------------------------------- @@ -187,6 +192,7 @@ section "Proposal History (last $HISTORY_DEPTH heights)" proposed_count=0 failed_count=0 +unavailable_count=0 start_height=$((height - HISTORY_DEPTH + 1)) if [[ $start_height -lt 1 ]]; then start_height=1; fi total_checked=$((height - start_height + 1)) @@ -195,12 +201,13 @@ BAR_WIDTH=30 progress_bar() { local done=$1 total=$2 + [[ "$total" -eq 0 ]] && return local pct=$((done * 100 / total)) local filled=$((done * BAR_WIDTH / total)) local empty=$((BAR_WIDTH - filled)) printf '\r [%s%s] %3d%% (%d/%d)' \ - "$(printf '#%.0s' $(seq 1 "$filled") 2>/dev/null)" \ - "$(printf '.%.0s' $(seq 1 "$empty") 2>/dev/null)" \ + "$(printf '%*s' "$filled" '' | tr ' ' '#')" \ + "$(printf '%*s' "$empty" '' | tr ' ' '.')" \ "$pct" "$done" "$total" >&2 } @@ -215,7 +222,10 @@ for (( h = start_height; h <= height; h++ )); do scan_i=$((h - start_height + 1)) progress_bar "$scan_i" "$total_checked" - pm=$(rpc_get "/proposal-monitor?height=$h" 2>/dev/null) || continue + if ! pm=$(rpc_get "/proposal-monitor?height=$h" 2>/dev/null); then + unavailable_count=$((unavailable_count + 1)) + continue + fi pm_proposer=$(echo "$pm" | jq -r '.proposer') pm_success=$(echo "$pm" | jq -r '.successful // "null"') @@ -238,23 +248,37 @@ for (( h = start_height; h <= height; h++ )); do done printf '\r\033[K' >&2 +checked=$((total_checked - unavailable_count)) + echo "" -if [[ $total_checked -gt 0 ]]; then - pct=$(echo "scale=1; $proposed_count * 100 / $total_checked" | bc) +if [[ $unavailable_count -gt 0 ]]; then + echo "Proposal data unavailable for ${unavailable_count}/${total_checked} heights $(yellow "(not counted)")" +fi + +if [[ $checked -gt 0 ]]; then + pct=$(echo "scale=1; $proposed_count * 100 / $checked" | bc) else pct="0.0" fi -if [[ $proposed_count -eq 0 ]]; then - echo "Proposed ${proposed_count}/${total_checked} blocks — $(red "never selected as proposer")" +if [[ $checked -eq 0 ]]; then + echo "Proposed: $(yellow "no proposal data available")" +elif [[ $proposed_count -gt 0 ]]; then + echo "Proposed ${proposed_count}/${checked} blocks (${pct}%) — $(green "ok")" +elif [[ -z "$my_pk" ]]; then + echo "Proposed ${proposed_count}/${checked} blocks — $(yellow "not in validator set (expected for non-validator nodes)")" else - echo "Proposed ${proposed_count}/${total_checked} blocks (${pct}%) — $(green "ok")" + echo "Proposed ${proposed_count}/${checked} blocks — $(red "never selected as proposer")" fi -if [[ $failed_count -eq 0 ]]; then - echo "All ${total_checked} proposals decided successfully: $(green "yes")" +checked_str="${checked} proposals" +[[ $unavailable_count -gt 0 ]] && checked_str="${checked} checked proposals" +if [[ $checked -eq 0 ]]; then + echo "All checked proposals decided successfully: $(yellow "no proposal data available")" +elif [[ $failed_count -eq 0 ]]; then + echo "All ${checked_str} decided successfully: $(green "yes")" else - echo "All ${total_checked} proposals decided successfully: $(red "no") (${failed_count} failed)" + echo "All ${checked_str} decided successfully: $(red "no") (${failed_count} failed)" fi # --- live monitoring -------------------------------------------------------- diff --git a/scripts/test_arc_check.sh b/scripts/test_arc_check.sh new file mode 100755 index 0000000..8634121 --- /dev/null +++ b/scripts/test_arc_check.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# +# test_arc_check.sh — exercises scripts/arc-check.sh against a mock RPC server. +# +# Env vars controlling scenario (read by the inline python mock): +# MOCK_IN_SET=1|0 node address is present in validator_set +# MOCK_ADMIN_DISABLED=1|0 admin_peers returns the -32601 error body +# MOCK_PM_MISSING="7 8 9" heights that 404 on /proposal-monitor +# MOCK_HEIGHT=12 current height (default 12) +# +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ARC_CHECK="${ARC_CHECK_BIN:-$SCRIPT_DIR/arc-check.sh}" + +PASS=0 +FAIL=0 + +ok() { echo "PASS: $1"; PASS=$((PASS + 1)); } +bad() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } + +strip_ansi() { sed -E $'s/\x1b\\[[0-9;]*m//g' <<<"$1"; } + +assert_contains() { + local haystack needle="$2" label="$3" + haystack=$(strip_ansi "$1") + if grep -qF -- "$needle" <<<"$haystack"; then ok "$label"; else bad "$label (expected to contain: $needle)"; fi +} + +assert_not_contains() { + local haystack needle="$2" label="$3" + haystack=$(strip_ansi "$1") + if grep -qF -- "$needle" <<<"$haystack"; then bad "$label (expected NOT to contain: $needle)"; else ok "$label"; fi +} + +MOCK_PID="" +MOCK_PORT="" + +start_mock() { + local port + port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()') + + python3 - "$port" <<'PYEOF' & +import http.server, json, os, sys, re + +PORT = int(sys.argv[1]) +HEIGHT = int(os.environ.get("MOCK_HEIGHT", "12")) +IN_SET = os.environ.get("MOCK_IN_SET", "1") == "1" +ADMIN_DISABLED = os.environ.get("MOCK_ADMIN_DISABLED", "0") == "1" +MISSING = set(int(x) for x in os.environ.get("MOCK_PM_MISSING", "").split() if x) + +MY_ADDR = "arc1myaddress" +OTHER_ADDR = "arc1otheraddress" + +VALIDATORS = [ + {"address": OTHER_ADDR, "voting_power": 10, + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "public_key_hex": "0x00"}, +] +if IN_SET: + VALIDATORS.append({ + "address": MY_ADDR, "voting_power": 5, + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "public_key_hex": "0x00", + }) + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _json(self, obj, code=200): + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/health": + self._json({"status": "ok"}) + elif self.path == "/status": + self._json({ + "height": HEIGHT, "round": 0, "address": MY_ADDR, + "public_key": "0xaa", "proposer": MY_ADDR, + "db_latest_height": HEIGHT, "db_earliest_height": 1, + "sync_state": "synced", + "validator_set": { + "total_voting_power": sum(v["voting_power"] for v in VALIDATORS), + "count": len(VALIDATORS), + "validators": VALIDATORS, + }, + }) + elif self.path == "/network-state": + self._json({ + "local_node": {}, "peers": [ + {"peer_id": "p1", "p2p_address": "a", "consensus_address": "b", + "moniker": "peer1", "peer_type": "full", "connection_direction": "outbound", + "score": 1.0, "topics": []}, + ], "persistent_peer_ids": [], + "persistent_peer_addrs": [], + "validator_set": {"total_voting_power": 0, "count": 0, "validators": []}, + }) + elif self.path.startswith("/proposal-monitor"): + m = re.search(r"height=(\d+)", self.path) + h = int(m.group(1)) if m else -1 + if h in MISSING: + self._json({"error": "Proposal monitor data not found"}, code=404) + return + proposer = MY_ADDR if (IN_SET and h % 4 == 0) else OTHER_ADDR + self._json({ + "height": h, "proposer": proposer, + "start_time": "t", "proposal_receive_time": "t", + "value_id": "v", "successful": True, "synced": True, + "proposal_delay_ms": 12, + }) + else: + self._json({"error": "not found"}, code=404) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) if length else b"{}" + try: + req = json.loads(body) + except Exception: + req = {} + if req.get("method") == "admin_peers": + if ADMIN_DISABLED: + self._json({"jsonrpc": "2.0", "id": req.get("id", 1), + "error": {"code": -32601, "message": "Method not found"}}) + else: + self._json({"jsonrpc": "2.0", "id": req.get("id", 1), "result": [ + {"name": "peer1", "network": {"inbound": True}, + "enode": "enode://abc@1.2.3.4:30303"}, + ]}) + else: + self._json({"jsonrpc": "2.0", "id": req.get("id", 1), "result": None}) + +http.server.HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() +PYEOF + MOCK_PID=$! + MOCK_PORT="$port" + + for _ in $(seq 1 50); do + curl -sf "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && return 0 + sleep 0.1 + done + echo "mock server failed to start" >&2 + return 1 +} + +stop_mock() { + [[ -n "$MOCK_PID" ]] && kill "$MOCK_PID" >/dev/null 2>&1 + wait "$MOCK_PID" 2>/dev/null + MOCK_PID="" +} + +trap 'stop_mock' EXIT + +# --- T1: admin namespace disabled ------------------------------------------- + +echo "=== T1: admin disabled ===" +MOCK_IN_SET=1 MOCK_ADMIN_DISABLED=1 MOCK_HEIGHT=5 MOCK_PM_MISSING="" start_mock +OUT=$("$ARC_CHECK" --cl "http://127.0.0.1:${MOCK_PORT}" --el "http://127.0.0.1:${MOCK_PORT}" 2>&1) +RC=$? +stop_mock + +assert_contains "$OUT" "WARN: admin_peers unavailable" "T1 warns about admin_peers" +assert_contains "$OUT" "Proposal History" "T1 report continues to Proposal History" +assert_not_contains "$OUT" "Connected: 0 peers" "T1 does not print misleading 0 peers" +if [[ $RC -eq 0 ]]; then ok "T1 exit code 0"; else bad "T1 exit code 0 (got $RC)"; fi + +# --- T2: missing heights ----------------------------------------------------- + +echo "=== T2: missing heights ===" +MOCK_IN_SET=1 MOCK_ADMIN_DISABLED=0 MOCK_HEIGHT=12 MOCK_PM_MISSING="7 8 9" start_mock +OUT=$("$ARC_CHECK" --cl "http://127.0.0.1:${MOCK_PORT}" 2>&1) +stop_mock + +assert_contains "$OUT" "unavailable for 3/12" "T2 reports 3/12 unavailable" +assert_not_contains "$OUT" "All 12 checked" "T2 does not claim all 12 checked" +assert_contains "$OUT" "All 9 checked proposals decided successfully" "T2 reports 9 checked" + +# --- T3: not in validator set ------------------------------------------------- + +echo "=== T3: not in validator set ===" +MOCK_IN_SET=0 MOCK_ADMIN_DISABLED=0 MOCK_HEIGHT=8 MOCK_PM_MISSING="" start_mock +OUT=$("$ARC_CHECK" --cl "http://127.0.0.1:${MOCK_PORT}" 2>&1) +stop_mock + +assert_contains "$OUT" "not in validator set (expected" "T3 explains non-validator status" +assert_not_contains "$OUT" "never selected as proposer" "T3 does not print red never-selected verdict" + +# --- T4: in set, proposer sometimes us --------------------------------------- + +echo "=== T4: in set, some heights ours ===" +MOCK_IN_SET=1 MOCK_ADMIN_DISABLED=0 MOCK_HEIGHT=8 MOCK_PM_MISSING="" start_mock +OUT=$("$ARC_CHECK" --cl "http://127.0.0.1:${MOCK_PORT}" 2>&1) +stop_mock + +assert_contains "$OUT" "OURS" "T4 marks our proposed heights" +assert_contains "$OUT" "— ok" "T4 shows ok verdict" + +# --- T5: progress bar padding at the boundaries ------------------------------- + +echo "=== T5: progress bar padding ===" +zero_pad="$(printf '%*s' 0 '' | tr ' ' '#')" +full_pad="$(printf '%*s' 30 '' | tr ' ' '#')" +if [[ -z "$zero_pad" ]]; then ok "T5 zero padding is empty"; else bad "T5 zero padding is empty (got '${zero_pad}')"; fi +if [[ ${#full_pad} -eq 30 ]]; then ok "T5 full padding has length 30"; else bad "T5 full padding has length 30 (got ${#full_pad})"; fi + +echo "" +echo "====================" +echo "PASS: $PASS FAIL: $FAIL" +if [[ $FAIL -gt 0 ]]; then exit 1; fi +exit 0