Implements node-facts.sh that emits JSON documents for monitoring and inventory: - Reads COMPOSE_FILE from .env and iterates only those compose files - Fails fast with JSON error when .env missing or COMPOSE_FILE empty (exit 2) - Uses --max-time for all curl calls (5s general, 10s for peers) - Bounded parallel worker count for node processing - Supports avalanchego, op-node/kona, beacon REST, geth-family, besu, reth/op-reth - Includes fixture-only test-node-facts.sh with schema validation - Adds README entry for the new script Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
904 lines
40 KiB
Bash
Executable File
904 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
|
|
local version_pid
|
|
local peers_pid
|
|
local schedule_pid
|
|
local head_pid
|
|
local signals_pid
|
|
|
|
get_version &
|
|
version_pid=$!
|
|
|
|
get_peers &
|
|
peers_pid=$!
|
|
|
|
get_schedule &
|
|
schedule_pid=$!
|
|
|
|
get_head &
|
|
head_pid=$!
|
|
|
|
get_signals &
|
|
signals_pid=$!
|
|
|
|
# Wait for all to complete
|
|
local results=()
|
|
for pid in $version_pid $peers_pid $schedule_pid $head_pid $signals_pid; do
|
|
wait "$pid"
|
|
done
|
|
|
|
# Collect results
|
|
version_result=$(get_version)
|
|
version_json=$(echo "$version_result" | cut -d'|' -f1)
|
|
version_time=$(echo "$version_result" | cut -d'|' -f2)
|
|
|
|
peers_result=$(get_peers)
|
|
peers_json=$(echo "$peers_result" | cut -d'|' -f1)
|
|
peers_time=$(echo "$peers_result" | cut -d'|' -f2)
|
|
|
|
schedule_result=$(get_schedule)
|
|
schedule_json=$(echo "$schedule_result" | cut -d'|' -f1)
|
|
schedule_time=$(echo "$schedule_result" | cut -d'|' -f2)
|
|
|
|
head_result=$(get_head)
|
|
head_json=$(echo "$head_result" | cut -d'|' -f1)
|
|
head_time=$(echo "$head_result" | cut -d'|' -f2)
|
|
|
|
signals_result=$(get_signals)
|
|
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
|
|
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
|
|
local node_files=()
|
|
while IFS= read -r node_file; do
|
|
[ -z "$node_file" ] && continue
|
|
node_files+=("$node_file")
|
|
done < <(get_node_compose_files)
|
|
|
|
# Parallel execution with bounded workers
|
|
local max_workers=${MAX_WORKERS:-8}
|
|
local pids=()
|
|
local results=()
|
|
local current_workers=0
|
|
local index=0
|
|
|
|
for node_file in "${node_files[@]}"; do
|
|
# Wait if we have too many workers
|
|
while [ $current_workers -ge $max_workers ]; do
|
|
for i in "${!pids[@]}"; do
|
|
if ! kill -0 "${pids[$i]}" 2>/dev/null; then
|
|
# Process completed
|
|
local result=$(wait "${pids[$i]}" 2>/dev/null && echo "" || echo "")
|
|
results+=("$result")
|
|
unset pids[$i]
|
|
pids=("${pids[@]}")
|
|
current_workers=$((current_workers - 1))
|
|
break
|
|
fi
|
|
done
|
|
# If still at max, sleep briefly
|
|
if [ $current_workers -ge $max_workers ]; then
|
|
sleep 0.1
|
|
fi
|
|
done
|
|
|
|
# Start new worker
|
|
{
|
|
local result=$(get_node_facts "$node_file" "$PRETTY")
|
|
echo "${result}"
|
|
} &
|
|
pids+=($!)
|
|
current_workers=$((current_workers + 1))
|
|
index=$((index + 1))
|
|
done
|
|
|
|
# Wait for remaining workers
|
|
for pid in "${pids[@]}"; do
|
|
wait "$pid" 2>/dev/null
|
|
done
|
|
|
|
# Since we're outputting JSON lines, we already printed results in the workers
|
|
# But to maintain order, let's do a simpler sequential approach for JSON lines
|
|
# Actually, let's redo this properly for streaming JSON lines
|
|
|
|
# Clear the partial parallel results and do sequential for JSON lines
|
|
echo "" # Separator
|
|
|
|
# Sequential approach for JSON lines (to maintain order)
|
|
for node_file in "${node_files[@]}"; do
|
|
get_node_facts "$node_file" "$PRETTY"
|
|
done
|
|
fi
|
|
|
|
exit 0 |