Files
ethereum-rpc-docker/node-facts.sh
T
claude fe22be322e Fix node-facts.sh bugs and add stub-curl integration tests
- Fix grep -qE calls to handle variables starting with '-' (add -- separator)
- Remove local declarations at top level by wrapping main body in main() function
- Replace bounded-worker scheduler with simpler temp file + wait -n pattern
- Fix per-node fan-out to capture function output instead of re-calling
- Add stub-curl integration tests that catch all three original issues
- Add fixture compose files for avalanche, op-node+beacon, geth testing
- Update test path-blacklist.txt to include entry starting with -

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-06 07:22:51 +00:00

896 lines
40 KiB
Bash
Executable File

#!/bin/bash
# node-facts.sh - Produces JSON 'node facts' documents for blockchain nodes
# Usage: node-facts.sh [node_path] [--pretty]
# With node_path: produces one document for the specified node
# Without node_path: produces JSON lines (one per node on the host)
# --pretty: indent JSON output
BASEPATH="$(dirname "$0")"
# Source the environment - this gives us DOMAIN, NO_SSL, COMPOSE_FILE
if [ ! -f "$BASEPATH/.env" ]; then
echo '{"schema":1,"at":null,"host":null,"node_path":null,"network":null,"client":null,"version":null,"peers":null,"schedule":null,"signals":null,"head":null,"timing_ms":null,"error":"cannot read .env"}' >&2
exit 2
fi
source "$BASEPATH/.env"
# Check if COMPOSE_FILE is empty
if [ -z "$COMPOSE_FILE" ]; then
echo '{"schema":1,"at":null,"host":null,"node_path":null,"network":null,"client":null,"version":null,"peers":null,"schedule":null,"signals":null,"head":null,"timing_ms":null,"error":"COMPOSE_FILE is empty"}' >&2
exit 2
fi
# Handle NO_SSL/PROTO/DOMAIN
if [ -n "$NO_SSL" ]; then
PROTO="http"
DOMAIN="${DOMAIN:-0.0.0.0}"
else
PROTO="https"
fi
# Load blacklist
blacklist=()
if [ -f "$BASEPATH/path-blacklist.txt" ]; then
while IFS= read -r line || [ -n "$line" ]; do
# Skip empty lines and comments
line="$(echo "$line" | sed 's/#.*//' | xargs)"
[ -z "$line" ] && continue
blacklist+=("$line")
done < "$BASEPATH/path-blacklist.txt"
fi
# Pretty flag
PRETTY=false
args=("$@")
processed_args=()
node_path=""
for arg in "${args[@]}"; do
case "$arg" in
--pretty)
PRETTY=true
;;
*)
if [ -z "$node_path" ]; then
node_path="$arg"
else
processed_args+=("$arg")
fi
;;
esac
done
# If we have unprocessed args or node_path contains invalid chars, error
if [ ${#processed_args[@]} -gt 0 ]; then
echo "error: unknown arguments: ${processed_args[*]}" >&2
exit 1
fi
# Function to check if path should be included (not in blacklist)
should_include() {
local path="$1"
for word in "${blacklist[@]}"; do
if echo "$path" | grep -qE -- "$word"; then
return 1
fi
done
return 0
}
# Function to get all node compose files
get_node_compose_files() {
# Use COMPOSE_FILE from .env
IFS=':' read -ra parts <<< "$COMPOSE_FILE"
for part in "${parts[@]}"; do
# Remove leading ./ and trailing .yml if present
part="$(echo "$part" | sed 's|^\./||; s|\.yml$||')"
# Only include if it's a real compose file (contains hyphens, not base/rpc/etc)
if echo "$part" | grep -q -- "-" && should_include "$part"; then
echo "$part"
fi
done
}
# Function to extract network from compose file or node_path
get_network() {
local node_path="$1"
# Try to get from x-upstreams chain label in compose file
local compose_file="$BASEPATH/${node_path}.yml"
if [ -f "$compose_file" ]; then
# Look for chain: label
local chain_slug=$(grep -oP '^\s*chain:\s*\K\S+' "$compose_file" 2>/dev/null | head -1)
if [ -n "$chain_slug" ]; then
echo "$chain_slug"
return
fi
# Fallback: look for x-upstreams network
local network=$(grep -oP 'x-upstreams\.network:\s*\K\S+' "$compose_file" 2>/dev/null | head -1)
if [ -n "$network" ]; then
echo "$network"
return
fi
fi
# Extract from node_path: typically network-chain-client-...
echo "$node_path" | cut -d'-' -f1
}
# Function to extract client info from compose file
get_client_info() {
local node_path="$1"
local compose_file="$BASEPATH/${node_path}.yml"
local client_name=""
local client_image=""
local client_tag=""
local container_name=""
if [ -f "$compose_file" ]; then
# Get the main service name (usually the first non-infra service)
local services=$(grep -oP '^\s+\K\w+' "$compose_file" 2>/dev/null | head -1)
if [ -n "$services" ]; then
# Try to find image for this service
local image_line=$(grep -A 20 -- "^\s+${services}:" "$compose_file" | grep -oP -- 'image:\s*\K[^\s]+' | head -1)
if [ -n "$image_line" ]; then
# Parse image:tag
client_image="$(echo "$image_line" | cut -d':' -f1)"
client_tag="$(echo "$image_line" | cut -d':' -f2)"
fi
container_name="${node_path}_${services}_1"
fi
fi
# Determine client family from image
if [ -z "$client_name" ]; then
client_name="unknown"
if echo "$client_image" | grep -qi "avalanchego"; then
client_name="avalanchego"
elif echo "$client_image" | grep -qi "op-node"; then
client_name="op-node"
elif echo "$client_image" | grep -qi "kona"; then
client_name="kona"
elif echo "$client_image" | grep -qiE "(lighthouse|prysm|teku|nimbus|lodestar)"; then
client_name="beacon"
elif echo "$client_image" | grep -qiE "(geth|go-ethereum)"; then
client_name="geth"
elif echo "$client_image" | grep -qi "op-geth"; then
client_name="op-geth"
elif echo "$client_image" | grep -qi "bor"; then
client_name="bor"
elif echo "$client_image" | grep -qi "erigon"; then
client_name="erigon"
elif echo "$client_image" | grep -qi "nethermind"; then
client_name="nethermind"
elif echo "$client_image" | grep -qi "besu"; then
client_name="besu"
elif echo "$client_image" | grep -qi "reth"; then
client_name="reth"
elif echo "$client_image" | grep -qi "op-reth"; then
client_name="op-reth"
elif echo "$client_image" | grep -qi "nitro"; then
client_name="nitro"
elif echo "$client_image" | grep -qi "cometbft"; then
client_name="cometbft"
elif echo "$client_image" | grep -qi "zksync"; then
client_name="zksync"
fi
fi
# Output as JSON object
printf '{"name":"%s","image":"%s","tag":"%s","container":"%s"}' \
"$client_name" "$client_image" "$client_tag" "$container_name"
}
# Function to get traefik path for a node
get_traefik_path() {
local node_path="$1"
local compose_file="$BASEPATH/${node_path}.yml"
if [ -f "$compose_file" ]; then
# Extract traefik route from labels
local prefix=$(grep -oP 'stripprefix\.prefixes.*?/\K[^"]+' "$compose_file" 2>/dev/null | head -1)
if [ -n "$prefix" ]; then
echo "$prefix"
return
fi
fi
# Default: the node_path itself
echo "$node_path"
}
# Function to get container bridge IP
get_container_bridge_ip() {
local container_name="$1"
# Try to get the container's bridge IP
docker inspect "$container_name" 2>/dev/null | grep -oP '"IPAddress":\s*\K"[^"]+"' | head -1 | tr -d '"' || echo ""
}
# Function to compare semver-like versions
# Returns: -1 if a < b, 0 if a == b, 1 if a > b, 2 if either doesn't parse
compare_versions() {
local a="$1"
local b="$2"
# If either is null/empty, return 2
if [ -z "$a" ] || [ -z "$b" ]; then
echo "2"
return
fi
# Strip client prefix and build hash (keep only version part)
a=$(echo "$a" | sed 's/^[a-zA-Z]\+[_-]\{0,1\}//' | sed 's/+.*//' | sed 's/-.*//')
b=$(echo "$b" | sed 's/^[a-zA-Z]\+[_-]\{0,1\}//' | sed 's/+.*//' | sed 's/-.*//')
# If after stripping, either is empty, return 2
if [ -z "$a" ] || [ -z "$b" ]; then
echo "2"
return
fi
# Split by dots and compare numerically
IFS='.' read -ra a_parts <<< "$a"
IFS='.' read -ra b_parts <<< "$b"
local max_len=${#a_parts[@]}
[ ${#b_parts[@]} -gt $max_len ] && max_len=${#b_parts[@]}
for ((i=0; i<max_len; i++)); do
local a_num=${a_parts[$i]:-0}
local b_num=${b_parts[$i]:-0}
# Ensure numeric
if ! [[ "$a_num" =~ ^[0-9]+$ ]]; then a_num=0; fi
if ! [[ "$b_num" =~ ^[0-9]+$ ]]; then b_num=0; fi
if [ "$a_num" -lt "$b_num" ]; then
echo "-1"
return
elif [ "$a_num" -gt "$b_num" ]; then
echo "1"
return
fi
done
echo "0"
}
# Function to make JSON-RPC call
json_rpc_call() {
local url="$1"
local method="$2"
local params="$3"
local timeout="${4:-5}"
local data
if [ -z "$params" ]; then
data="{\"jsonrpc\":\"2.0\",\"method\":\"$method\",\"id\":1}"
else
data="{\"jsonrpc\":\"2.0\",\"method\":\"$method\",\"params\":$params,\"id\":1}"
fi
curl -L --ipv4 --max-time "$timeout" -s -X POST -H "Content-Type: application/json" --data "$data" "$url" 2>/dev/null || echo "null"
}
# Function to make HTTP GET call
http_get_call() {
local url="$1"
local timeout="${2:-5}"
curl -L --ipv4 --max-time "$timeout" -s "$url" 2>/dev/null || echo "null"
}
# Function to extract version from client-specific responses
parse_version() {
local client="$1"
local response="$2"
case "$client" in
avalanchego)
# info.getNodeVersion returns {"jsonrpc":"2.0","result":{"version":"1.10.0","databaseVersion":"1.0.0","vmVersions":{...}}}
echo "$response" | jq -r '.result.version // empty' 2>/dev/null || echo "null"
;;
beacon)
# eth/v1/node/version returns {"version":"Lighthouse/v5.3.0"}
echo "$response" | jq -r '.version // empty' 2>/dev/null || echo "null"
;;
geth|op-geth|bor|erigon|nethermind|besu)
# web3_clientVersion returns "Geth/v1.13.0-stable"
echo "$response" | jq -r '.result // empty' 2>/dev/null || echo "null"
;;
op-node|kona)
# optimism_version returns "v1.19.0"
echo "$response" | jq -r '.result // empty' 2>/dev/null || echo "null"
;;
reth|op-reth)
# web3_clientVersion returns "reth/v0.2.0"
echo "$response" | jq -r '.result // empty' 2>/dev/null || echo "null"
;;
*)
echo "$response" | jq -r '.result // .version // empty' 2>/dev/null || echo "null"
;;
esac
}
# Function to get node facts for a single node
get_node_facts() {
local node_path="$1"
local pretty="$2"
# Get network
local network=$(get_network "$node_path")
# Get client info
local client_info=$(get_client_info "$node_path")
local client_name=$(echo "$client_info" | jq -r '.name')
local client_image=$(echo "$client_info" | jq -r '.image')
local client_tag=$(echo "$client_info" | jq -r '.tag')
local container_name=$(echo "$client_info" | jq -r '.container')
# Get traefik path
local traefik_path=$(get_traefik_path "$node_path")
local rpc_url="${PROTO}://${DOMAIN}/${traefik_path}"
# Initialize timing object
local timing="{}"
local start_time
local end_time
# Start total timing
start_time=$(date +%s%N)
# Functions for parallel data collection
local version_result="null"
local peers_result="null"
local schedule_result="null"
local head_result="null"
local signals_result="null"
# Version collection function
get_version() {
local start=$(date +%s%N)
local v_result
case "$client_name" in
avalanchego)
# Try traefik first, then bridge IP
local info_response=$(json_rpc_call "$rpc_url/ext/info" "info.getNodeVersion" "[]" 10)
if [ "$info_response" != "null" ] && echo "$info_response" | jq -e '.result' >/dev/null 2>&1; then
v_result=$(echo "$info_response" | jq '{reported: .result.version, db_version: .result.databaseVersion, source: "info.getNodeVersion", error: null}')
else
# Try bridge IP
local bridge_ip=$(get_container_bridge_ip "$container_name")
if [ -n "$bridge_ip" ]; then
local bridge_url="http://${bridge_ip}:9650/ext/info"
info_response=$(json_rpc_call "$bridge_url" "info.getNodeVersion" "[]" 10)
if [ "$info_response" != "null" ] && echo "$info_response" | jq -e '.result' >/dev/null 2>&1; then
v_result=$(echo "$info_response" | jq '{reported: .result.version, db_version: .result.databaseVersion, source: "info.getNodeVersion (bridge)", error: null}')
else
v_result=$(jq -n '{reported: null, db_version: null, source: "info.getNodeVersion", error: "avalanche info API not reachable via traefik/bridge - needs /ext/info route"}')
fi
else
v_result=$(jq -n '{reported: null, db_version: null, source: "info.getNodeVersion", error: "avalanche info API not reachable via traefik/bridge - needs /ext/info route"}')
fi
fi
;;
beacon)
local version_response=$(http_get_call "$rpc_url/eth/v1/node/version" 5)
if [ "$version_response" != "null" ]; then
local parsed_version=$(echo "$version_response" | jq -r '.version // empty')
if [ -n "$parsed_version" ]; then
v_result=$(jq -n --arg v "$parsed_version" '{reported: $v, db_version: null, source: "eth/v1/node/version", error: null}')
else
v_result=$(jq -n '{reported: null, db_version: null, source: "eth/v1/node/version", error: "invalid response format"}')
fi
else
v_result=$(jq -n '{reported: null, db_version: null, source: "eth/v1/node/version", error: "request failed"}')
fi
;;
geth|op-geth|bor|erigon|nethermind|besu|reth|op-reth)
# Try admin_nodeInfo first for geth-family
local node_info=$(json_rpc_call "$rpc_url" "admin_nodeInfo" "[]" 5)
if [ "$node_info" != "null" ] && echo "$node_info" | jq -e '.result' >/dev/null 2>&1; then
# admin is enabled, try web3_clientVersion
local client_version=$(json_rpc_call "$rpc_url" "web3_clientVersion" "[]" 5)
if [ "$client_version" != "null" ]; then
v_result=$(echo "$client_version" | jq '{reported: .result, db_version: null, source: "web3_clientVersion", error: null}')
else
v_result=$(jq -n '{reported: null, db_version: null, source: "web3_clientVersion", error: "web3_clientVersion failed"}')
fi
else
# admin may not be enabled
local client_version=$(json_rpc_call "$rpc_url" "web3_clientVersion" "[]" 5)
if [ "$client_version" != "null" ]; then
v_result=$(echo "$client_version" | jq '{reported: .result, db_version: null, source: "web3_clientVersion", error: null}')
else
v_result=$(jq -n '{reported: null, db_version: null, source: "web3_clientVersion", error: "web3_clientVersion failed"}')
fi
fi
;;
op-node|kona)
local version_response=$(json_rpc_call "$rpc_url/node" "optimism_version" "[]" 5)
if [ "$version_response" != "null" ]; then
v_result=$(echo "$version_response" | jq '{reported: .result, db_version: null, source: "optimism_version", error: null}')
else
v_result=$(jq -n '{reported: null, db_version: null, source: "optimism_version", error: "request failed"}')
fi
;;
*)
# Try web3_clientVersion for others
local client_version=$(json_rpc_call "$rpc_url" "web3_clientVersion" "[]" 5)
if [ "$client_version" != "null" ]; then
v_result=$(echo "$client_version" | jq '{reported: .result, db_version: null, source: "web3_clientVersion", error: null}')
else
v_result=$(jq -n '{reported: null, db_version: null, source: "web3_clientVersion", error: "web3_clientVersion failed for '"$client_name"'"}')
fi
;;
esac
local end=$(date +%s%N)
local elapsed=$(( (end - start) / 1000000 ))
echo "${v_result}|${elapsed}"
}
# Peers collection function
get_peers() {
local start=$(date +%s%N)
local p_result
case "$client_name" in
avalanchego)
local peers_response=$(json_rpc_call "$rpc_url/ext/info" "info.peers" "[]" 10)
if [ "$peers_response" != "null" ] && echo "$peers_response" | jq -e '.result' >/dev/null 2>&1; then
local count=$(echo "$peers_response" | jq '.result | length')
local versions_json=$(echo "$peers_response" | jq '[.result[].version] | group_by(.?) | map({key: .[0][0], value: length}) | from_entries // {}')
p_result=$(jq -n --argjson count "$count" --argjson versions "$versions_json" '{count: $count, versions: $versions, newer_than_us: null, source: "info.peers", error: null}')
else
p_result=$(jq -n '{count: null, versions: null, newer_than_us: null, source: "info.peers", error: "request failed"}')
fi
;;
beacon)
local peers_response=$(http_get_call "$rpc_url/eth/v1/node/peers" 5)
if [ "$peers_response" != "null" ]; then
local count=$(echo "$peers_response" | jq '.data | length')
p_result=$(jq -n --argjson count "$count" '{count: $count, versions: null, newer_than_us: null, source: "eth/v1/node/peers", error: "beacon peers API carries no agent version"}')
else
p_result=$(jq -n '{count: null, versions: null, newer_than_us: null, source: "eth/v1/node/peers", error: "request failed"}')
fi
;;
geth|op-geth|bor|erigon|nethermind|besu)
local admin_enabled=true
local test_admin=$(json_rpc_call "$rpc_url" "admin_peers" "[]" 5)
if [ "$test_admin" != "null" ] && echo "$test_admin" | jq -e '.result' >/dev/null 2>&1; then
admin_enabled=true
else
admin_enabled=false
fi
if $admin_enabled; then
local peers_response=$(json_rpc_call "$rpc_url" "admin_peers" "[]" 10)
if [ "$peers_response" != "null" ]; then
local count=$(echo "$peers_response" | jq '.result | length')
# Extract version from name field
local versions_json=$(echo "$peers_response" | jq '[.result[].name] | map(split("/") | .[-1] // .[0]) | group_by(.?) | map({key: .[0][0], value: length}) | from_entries // {}')
p_result=$(jq -n --argjson count "$count" --argjson versions "$versions_json" '{count: $count, versions: $versions, newer_than_us: null, source: "admin_peers", error: null}')
else
p_result=$(jq -n '{count: null, versions: null, newer_than_us: null, source: "admin_peers", error: "request failed"}')
fi
else
p_result=$(jq -n '{count: null, versions: null, newer_than_us: null, source: "admin_peers", error: "admin namespace disabled"}')
fi
;;
op-node|kona)
local peers_response=$(json_rpc_call "$rpc_url/node" "opp2p_peers" "[true]" 10)
if [ "$peers_response" != "null" ]; then
local count=$(echo "$peers_response" | jq '.result | length')
# OP-stack peers often have userAgent but no version
local versions_json=$(echo "$peers_response" | jq '[.result[].userAgent] | group_by(.?) | map({key: .[0][0], value: length}) | from_entries // {}')
local has_versions=true
for key in $(echo "$versions_json" | jq -r 'keys[]'); do
if ! echo "$key" | grep -qE '[0-9]+\.[0-9]+'; then
has_versions=false
break
fi
done
if $has_versions; then
p_result=$(jq -n --argjson count "$count" --argjson versions "$versions_json" '{count: $count, versions: $versions, newer_than_us: null, source: "opp2p_peers", error: null}')
else
p_result=$(jq -n --argjson count "$count" '{count: $count, versions: {}, newer_than_us: null, source: "opp2p_peers", error: "userAgent carries no version"}')
fi
else
p_result=$(jq -n '{count: null, versions: null, newer_than_us: null, source: "opp2p_peers", error: "request failed"}')
fi
;;
reth|op-reth)
local admin_enabled=true
local test_admin=$(json_rpc_call "$rpc_url" "admin_peers" "[]" 5)
if [ "$test_admin" != "null" ] && echo "$test_admin" | jq -e '.result' >/dev/null 2>&1; then
local peers_response=$(json_rpc_call "$rpc_url" "admin_peers" "[]" 10)
if [ "$peers_response" != "null" ]; then
local count=$(echo "$peers_response" | jq '.result | length')
local versions_json=$(echo "$peers_response" | jq '[.result[].name] | map(split("/") | .[-1] // .[0]) | group_by(.?) | map({key: .[0][0], value: length}) | from_entries // {}')
p_result=$(jq -n --argjson count "$count" --argjson versions "$versions_json" '{count: $count, versions: $versions, newer_than_us: null, source: "admin_peers", error: null}')
else
p_result=$(jq -n '{count: null, versions: null, newer_than_us: null, source: "admin_peers", error: "request failed"}')
fi
else
p_result=$(jq -n '{count: null, versions: null, newer_than_us: null, source: "admin_peers", error: "admin namespace disabled"}')
fi
;;
*)
p_result=$(jq -n '{count: null, versions: null, newer_than_us: null, source: "unknown", error: "peers not implemented for '"$client_name"'"}')
;;
esac
local end=$(date +%s%N)
local elapsed=$(( (end - start) / 1000000 ))
echo "${p_result}|${elapsed}"
}
# Schedule collection function
get_schedule() {
local start=$(date +%s%N)
local s_result
case "$client_name" in
avalanchego)
local upgrades_response=$(json_rpc_call "$rpc_url/ext/info" "info.upgrades" "[]" 5)
if [ "$upgrades_response" != "null" ] && echo "$upgrades_response" | jq -e '.result' >/dev/null 2>&1; then
# Extract all *Time and *Height keys
local upgrades_json=$(echo "$upgrades_response" | jq 'with_entries(select(.key | test("Time$|Height$"))) | map_values({time: (if . | type == "string" and test("^[0-9]+") then (tonumber * 1000 | todateiso8601) else null end), block: (if . | type == "number" then . else null end)})')
s_result=$(jq -n --argjson upgrades "$upgrades_json" '{upgrades: $upgrades, source: "info.upgrades", error: null}')
else
s_result=$(jq -n '{upgrades: null, source: "info.upgrades", error: "request failed"}')
fi
;;
beacon)
local spec_response=$(http_get_call "$rpc_url/eth/v1/config/spec" 5)
if [ "$spec_response" != "null" ]; then
# Extract fork epochs and convert to timestamps if possible
local upgrades_json=$(echo "$spec_response" | jq 'with_entries(select(.key | test("_FORK_EPOCH$"))) | map_values({block: ., time: null})')
s_result=$(jq -n --argjson upgrades "$upgrades_json" '{upgrades: $upgrades, source: "eth/v1/config/spec", error: null}')
else
s_result=$(jq -n '{upgrades: null, source: "eth/v1/config/spec", error: "request failed"}')
fi
;;
op-node|kona)
local config_response=$(json_rpc_call "$rpc_url/node" "optimism_rollupConfig" "[]" 5)
if [ "$config_response" != "null" ]; then
# Extract all *time keys
local upgrades_json=$(echo "$config_response" | jq 'with_entries(select(.key | test("_time$"))) | map_values({time: (if . | type == "string" and tonumber? then (tonumber * 1000 | todateiso8601) else null end), block: null})')
s_result=$(jq -n --argjson upgrades "$upgrades_json" '{upgrades: $upgrades, source: "optimism_rollupConfig", error: null}')
else
s_result=$(jq -n '{upgrades: null, source: "optimism_rollupConfig", error: "request failed"}')
fi
;;
geth|op-geth|bor|erigon|nethermind|besu)
local node_info=$(json_rpc_call "$rpc_url" "admin_nodeInfo" "[]" 5)
if [ "$node_info" != "null" ] && echo "$node_info" | jq -e '.result' >/dev/null 2>&1; then
# Extract protocol upgrades from protocols.eth.config
local upgrades_json=$(echo "$node_info" | jq '.result.protocols.eth.config | with_entries(select(.key | test("Block$|Time$"))) | map_values({block: (if . | type == "number" then . else null end), time: (if . | type == "string" and tonumber? then (tonumber * 1000 | todateiso8601) else null end)}) // {}')
s_result=$(jq -n --argjson upgrades "$upgrades_json" '{upgrades: $upgrades, source: "admin_nodeInfo", error: null}')
else
s_result=$(jq -n '{upgrades: null, source: "admin_nodeInfo", error: "admin namespace disabled"}')
fi
;;
reth|op-reth)
s_result=$(jq -n '{upgrades: null, source: "none", error: "reth exposes no chain config over RPC"}')
;;
*)
s_result=$(jq -n '{upgrades: null, source: "none", error: "schedule not implemented for '"$client_name"'"}')
;;
esac
local end=$(date +%s%N)
local elapsed=$(( (end - start) / 1000000 ))
echo "${s_result}|${elapsed}"
}
# Head collection function
get_head() {
local start=$(date +%s%N)
local h_result
case "$client_name" in
avalanchego)
local info_response=$(json_rpc_call "$rpc_url/ext/info" "info.getNodeVersion" "[]" 5)
if [ "$info_response" != "null" ]; then
# Avalanche doesn't have a simple head call; use the node version info as proxy
# This is a placeholder - avalanche head would need different handling
h_result=$(jq -n '{height: null, timestamp: null, syncing: null, source: "info.getNodeVersion", error: "avalanche head not implemented"}')
else
h_result=$(jq -n '{height: null, timestamp: null, syncing: null, source: "info.getNodeVersion", error: "request failed"}')
fi
;;
beacon)
local sync_response=$(http_get_call "$rpc_url/eth/v1/node/syncing" 5)
local head_slot_response=$(http_get_call "$rpc_url/eth/v1/node/head" 5)
if [ "$sync_response" != "null" ]; then
local is_syncing=$(echo "$sync_response" | jq '.is_syncing // false')
local head_slot=$(echo "$head_slot_response" | jq -r '.data.header.message.slot // empty')
local timestamp=$(echo "$head_slot_response" | jq -r '.data.header.message.timestamp // empty')
if [ -n "$timestamp" ]; then
# Convert timestamp (seconds since epoch) to ISO
local iso_timestamp=$(date -u -d @"$timestamp" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "null")
else
local iso_timestamp="null"
fi
h_result=$(jq -n --argjson syncing "$is_syncing" --argjson height "$head_slot" --arg timestamp "$iso_timestamp" '{height: ($height | tonumber), timestamp: $timestamp, syncing: $syncing, source: "eth/v1/node/syncing + eth/v1/node/head", error: null}')
else
h_result=$(jq -n '{height: null, timestamp: null, syncing: null, source: "eth/v1/node/syncing", error: "request failed"}')
fi
;;
op-node|kona)
local sync_response=$(json_rpc_call "$rpc_url/node" "optimism_syncStatus" "[]" 5)
if [ "$sync_response" != "null" ]; then
local unsafe_l2=$(echo "$sync_response" | jq '.result.unsafe_l2')
local is_syncing=$(echo "$sync_response" | jq '.result.syncing // false')
local height=$(echo "$unsafe_l2" | jq -r '.number // empty')
local timestamp=$(echo "$unsafe_l2" | jq -r '.timestamp // empty')
if [ -n "$timestamp" ]; then
local iso_timestamp=$(date -u -d @"$timestamp" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "null")
else
local iso_timestamp="null"
fi
h_result=$(jq -n --argjson syncing "$is_syncing" --argjson height "$height" --arg timestamp "$iso_timestamp" '{height: ($height | tonumber), timestamp: $timestamp, syncing: $syncing, source: "optimism_syncStatus", error: null}')
else
h_result=$(jq -n '{height: null, timestamp: null, syncing: null, source: "optimism_syncStatus", error: "request failed"}')
fi
;;
geth|op-geth|bor|erigon|nethermind|besu|reth|op-reth)
# Try eth_syncing first
local sync_response=$(json_rpc_call "$rpc_url" "eth_syncing" "[]" 5)
if [ "$sync_response" != "null" ]; then
local is_syncing=$(echo "$sync_response" | jq '.result // false')
# If syncing, get current block
if [ "$is_syncing" = "true" ]; then
local current_block=$(echo "$sync_response" | jq -r '.result.currentBlock // empty')
local highest_block=$(echo "$sync_response" | jq -r '.result.highestBlock // empty')
h_result=$(jq -n --argjson syncing "$is_syncing" --argjson height "$current_block" '{height: ($height | tonumber), timestamp: null, syncing: $syncing, source: "eth_syncing", error: null}')
else
# Get latest block
local block_number_response=$(json_rpc_call "$rpc_url" "eth_blockNumber" "[]" 5)
local block_number=$(echo "$block_number_response" | jq -r '.result // empty')
local block_response=$(json_rpc_call "$rpc_url" "eth_getBlockByNumber" "[\"$block_number\", false]" 5)
local block_timestamp=$(echo "$block_response" | jq -r '.result.timestamp // empty')
local height=$(echo "$block_response" | jq -r '.result.number // empty')
if [ -n "$block_timestamp" ]; then
local iso_timestamp=$(date -u -d @"$((16#${block_timestamp#0x}))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "null")
else
local iso_timestamp="null"
fi
h_result=$(jq -n --argjson syncing "$is_syncing" --argjson height "$height" --arg timestamp "$iso_timestamp" '{height: ($height | tonumber), timestamp: $timestamp, syncing: $syncing, source: "eth_blockNumber + eth_getBlockByNumber", error: null}')
fi
else
h_result=$(jq -n '{height: null, timestamp: null, syncing: null, source: "eth_syncing", error: "request failed"}')
fi
;;
*)
h_result=$(jq -n '{height: null, timestamp: null, syncing: null, source: "unknown", error: "head not implemented for '"$client_name"'"}')
;;
esac
local end=$(date +%s%N)
local elapsed=$(( (end - start) / 1000000 ))
echo "${h_result}|${elapsed}"
}
# Signals collection function (Avalanche-specific for now)
get_signals() {
local start=$(date +%s%N)
local sig_result
case "$client_name" in
avalanchego)
local acps_response=$(json_rpc_call "$rpc_url/ext/info" "info.acps" "[]" 5)
if [ "$acps_response" != "null" ] && echo "$acps_response" | jq -e '.result' >/dev/null 2>&1; then
sig_result=$(echo "$acps_response" | jq '{source: "info.acps", error: null} + (.result | with_entries(select(.key | test("supporter|objector|abstainer"))))')
else
sig_result=$(jq -n '{source: "info.acps", error: "request failed"}')
fi
;;
*)
sig_result=$(jq -n '{source: "none", error: "signals not implemented for '"$client_name"'"}')
;;
esac
local end=$(date +%s%N)
local elapsed=$(( (end - start) / 1000000 ))
echo "${sig_result}|${elapsed}"
}
# Run all data collection functions in parallel and capture output
local version_pid
local peers_pid
local schedule_pid
local head_pid
local signals_pid
local version_result
local peers_result
local schedule_result
local head_result
local signals_result
version_result=$(get_version) &
version_pid=$!
peers_result=$(get_peers) &
peers_pid=$!
schedule_result=$(get_schedule) &
schedule_pid=$!
head_result=$(get_head) &
head_pid=$!
signals_result=$(get_signals) &
signals_pid=$!
# Wait for all to complete
for pid in $version_pid $peers_pid $schedule_pid $head_pid $signals_pid; do
wait "$pid"
done
# Collect results from captured output
version_json=$(echo "$version_result" | cut -d'|' -f1)
version_time=$(echo "$version_result" | cut -d'|' -f2)
peers_json=$(echo "$peers_result" | cut -d'|' -f1)
peers_time=$(echo "$peers_result" | cut -d'|' -f2)
schedule_json=$(echo "$schedule_result" | cut -d'|' -f1)
schedule_time=$(echo "$schedule_result" | cut -d'|' -f2)
head_json=$(echo "$head_result" | cut -d'|' -f1)
head_time=$(echo "$head_result" | cut -d'|' -f2)
signals_json=$(echo "$signals_result" | cut -d'|' -f1)
signals_time=$(echo "$signals_result" | cut -d'|' -f2)
# Handle case where functions returned empty
[ -z "$version_json" ] && version_json="null"
[ -z "$peers_json" ] && peers_json="null"
[ -z "$schedule_json" ] && schedule_json="null"
[ -z "$head_json" ] && head_json="null"
[ -z "$signals_json" ] && signals_json="null"
# Calculate newer_than_us if we have both our version and peer versions
local newer_than_us="null"
local newer_reason="null"
if [ "$version_json" != "null" ] && [ "$peers_json" != "null" ]; then
local our_version=$(echo "$version_json" | jq -r '.reported // empty')
local peer_versions=$(echo "$peers_json" | jq -r '.versions // {} | keys[]')
if [ -n "$our_version" ] && [ -n "$peer_versions" ]; then
local count=0
while IFS= read -r peer_ver; do
[ -z "$peer_ver" ] && continue
local cmp=$(compare_versions "$our_version" "$peer_ver")
if [ "$cmp" = "-1" ]; then
count=$((count + 1))
fi
done <<< "$peer_versions"
newer_than_us=$count
newer_reason="null"
else
newer_than_us="null"
newer_reason="null"
fi
fi
# Update peers with newer_than_us
if [ "$newer_than_us" != "null" ]; then
peers_json=$(echo "$peers_json" | jq --argjson n "$newer_than_us" '.newer_than_us = $n')
else
peers_json=$(echo "$peers_json" | jq --arg reason "$newer_reason" '.newer_than_us = null, .error = (.error // "") + "; " + $reason')
fi
# Build timing object
local timing_json=$(jq -n \
--argjson vt "${version_time:-0}" \
--argjson pt "${peers_time:-0}" \
--argjson st "${schedule_time:-0}" \
--argjson ht "${head_time:-0}" \
'{version: $vt, peers: $pt, schedule: $st, head: $ht}')
# Get current UTC timestamp
local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local hostname=$(hostname -s 2>/dev/null || echo "unknown")
# Build the final JSON document
local doc=$(jq -n \
--arg schema "1" \
--arg at "$now" \
--arg host "$hostname" \
--arg node_path "$node_path" \
--arg network "$network" \
--argjson client "$client_info" \
--argjson version "$version_json" \
--argjson peers "$peers_json" \
--argjson schedule "$schedule_json" \
--argjson signals "$signals_json" \
--argjson head "$head_json" \
--argjson timing "$timing_json" \
'{
schema: ($schema | tonumber),
at: $at,
host: $host,
node_path: $node_path,
network: $network,
client: $client,
version: $version,
peers: $peers,
schedule: $schedule,
signals: $signals,
head: $head,
timing_ms: $timing
}')
echo "$doc"
}
# Main execution function
main() {
if [ -n "$node_path" ]; then
# Single node mode
if should_include "$node_path"; then
get_node_facts "$node_path" "$PRETTY"
else
echo "error: node_path '$node_path' is blacklisted" >&2
exit 1
fi
else
# All nodes mode - iterate over all node compose files
node_files=()
while IFS= read -r node_file; do
[ -z "$node_file" ] && continue
node_files+=("$node_file")
done < <(get_node_compose_files)
# Create temp directory for output files
temp_dir=$(mktemp -d)
# Track pids and temp files
pids=()
temp_files=()
max_workers=${MAX_WORKERS:-8}
for node_file in "${node_files[@]}"; do
temp_file="$temp_dir/$(basename "$node_file" .yml).json"
( get_node_facts "$node_file" "$PRETTY" > "$temp_file" ) &
pids+=($!)
temp_files+=("$temp_file")
# Wait if we reached max workers
if [ ${#pids[@]} -ge $max_workers ]; then
wait -n
# Remove the completed pid and corresponding temp file
completed_pid="$?"
for i in "${!pids[@]}"; do
if [ "${pids[$i]}" = "$completed_pid" ]; then
unset pids[$i]
unset temp_files[$i]
break
fi
done
pids=("${pids[@]}")
temp_files=("${temp_files[@]}")
fi
done
# Wait for all remaining workers
wait
# Output results in order
for temp_file in "${temp_files[@]}"; do
cat "$temp_file"
done
# Clean up temp directory
rm -rf "$temp_dir"
fi
}
# Call main function
main
exit 0