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>
This commit is contained in:
2026-10-06 07:22:51 +00:00
parent d57d19276e
commit fe22be322e
7 changed files with 367 additions and 83 deletions
+11
View File
@@ -0,0 +1,11 @@
version: "3"
services:
client:
image: ava-labs/avalanchego:v1.10.0
labels:
- "traefik.http.routers.avalanche-mainnet-go.rule=PathPrefix(`/avalanche-mainnet-go`)"
- "traefik.http.middlewares.avalanche-mainnet-go.stripprefix.prefixes=/avalanche-mainnet-go"
x-upstreams:
network: avalanche
chain: avalanche-mainnet
chain: avalanche-mainnet
@@ -0,0 +1,11 @@
version: "3"
services:
client:
image: sigp/lighthouse:v5.3.0
labels:
- "traefik.http.routers.ethereum-mainnet-lighthouse.rule=PathPrefix(`/ethereum-mainnet-lighthouse`)"
- "traefik.http.middlewares.ethereum-mainnet-lighthouse.stripprefix.prefixes=/ethereum-mainnet-lighthouse"
x-upstreams:
network: ethereum
chain: ethereum-mainnet
chain: ethereum-mainnet
+11
View File
@@ -0,0 +1,11 @@
version: "3"
services:
node:
image: us-docker.pkg.dev/oplabs/op-node:v1.19.0
labels:
- "traefik.http.routers.op-base-mainnet.rule=PathPrefix(`/op-base-mainnet`)"
- "traefik.http.middlewares.op-base-mainnet.stripprefix.prefixes=/op-base-mainnet"
x-upstreams:
network: base
chain: op-base-mainnet
chain: op-base-mainnet
+1
View File
@@ -4,3 +4,4 @@ prysm
beacon beacon
nimbus nimbus
ws ws
-node
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
# Simple stub curl for node-facts.sh testing
# Returns minimal valid JSON responses
# Extract the URL (last http/https argument)
URL=""
METHOD=""
for arg in "$@"; do
if [[ "$arg" == http* || "$arg" == https* ]]; then
URL="$arg"
fi
# Look for method in JSON data arguments
if echo "$arg" | grep -q "method"; then
METHOD=$(echo "$arg" | sed -n 's/.*"method":"\([^"]*\)".*/\1/p' | head -1)
[ -n "$METHOD" ] && break
fi
done
# If method not found in data args, try to extract from the URL
if [ -z "$METHOD" ]; then
# Extract method from URL path for REST endpoints
case "$URL" in
*eth/v1/node/version*) METHOD="eth/v1/node/version" ;;
*eth/v1/node/peers*) METHOD="eth/v1/node/peers" ;;
*eth/v1/config/spec*) METHOD="eth/v1/config/spec" ;;
*eth/v1/node/syncing*) METHOD="eth/v1/node/syncing" ;;
*eth/v1/node/head*) METHOD="eth/v1/node/head" ;;
*optimism_version*) METHOD="optimism_version" ;;
*opp2p_peers*) METHOD="opp2p_peers" ;;
*optimism_rollupConfig*) METHOD="optimism_rollupConfig" ;;
*optimism_syncStatus*) METHOD="optimism_syncStatus" ;;
esac
fi
# Respond based on URL and method
case "$URL" in
*avalanche-mainnet-go*)
case "$METHOD" in
info.getNodeVersion)
echo '{"jsonrpc":"2.0","result":{"version":"avalanche/1.10.0","databaseVersion":"1.0.0"},"id":1}'
;;
info.peers)
echo '{"jsonrpc":"2.0","result":[{"version":"avalanche/1.10.0"},{"version":"avalanche/1.9.0"}],"id":1}'
;;
info.upgrades)
echo '{"jsonrpc":"2.0","result":{"apricotPhase1Time":4200000},"id":1}'
;;
*)
# For /ext/info without method, return version info
if [[ "$URL" == *"/ext/info"* ]]; then
echo '{"jsonrpc":"2.0","result":{"version":"avalanche/1.10.0","databaseVersion":"1.0.0"},"id":1}'
else
echo '{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}'
fi
;;
esac
;;
*ethereum-mainnet-lighthouse* | *beacon* | *lighthouse*)
case "$METHOD" in
eth/v1/node/version*)
echo '{"version":"Lighthouse/v5.3.0"}'
;;
eth/v1/node/peers*)
echo '{"data":[]}'
;;
eth/v1/config/spec*)
echo '{"data":{}}'
;;
eth/v1/node/syncing*)
echo '{"data":{"head_slot":"100","syncing":false}}'
;;
eth/v1/node/head*)
echo '{"data":{"slot":"100","timestamp":"2026-10-06T12:00:00Z"}}'
;;
*)
echo '{"message":"Not found"}'
;;
esac
;;
*op-base-mainnet* | *op-node* | *kona*)
case "$METHOD" in
optimism_version* | */node*)
echo '{"result":"v1.19.0"}'
;;
opp2p_peers*)
echo '{"result":[]}'
;;
optimism_rollupConfig*)
echo '{"result":{}}'
;;
optimism_syncStatus*)
echo '{"result":{"current_block_number":"100","syncing":false}}'
;;
*)
echo '{"message":"Not found"}'
;;
esac
;;
*ethereum-mainnet-geth* | *geth*)
case "$METHOD" in
web3_clientVersion)
echo '{"jsonrpc":"2.0","result":"Geth/v1.13.0-stable","id":1}'
;;
admin_peers)
echo '{"jsonrpc":"2.0","result":[{"clientId":"Geth/v1.13.0"},{"clientId":"Geth/v1.12.0"}],"id":1}'
;;
eth_blockNumber)
echo '{"jsonrpc":"2.0","result":"0x64","id":1}'
;;
eth_getBlockByNumber)
echo '{"jsonrpc":"2.0","result":{"timestamp":"0x65a6b3d4","number":"0x64"},"id":1}'
;;
eth_syncing)
echo '{"jsonrpc":"2.0","result":false,"id":1}'
;;
*)
echo '{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}'
;;
esac
;;
*)
echo '{"jsonrpc":"2.0","error":{"code":-32601,"message":"Unknown endpoint"},"id":1}'
;;
esac
exit 0
+70 -78
View File
@@ -71,7 +71,7 @@ fi
should_include() { should_include() {
local path="$1" local path="$1"
for word in "${blacklist[@]}"; do for word in "${blacklist[@]}"; do
if echo "$path" | grep -qE "$word"; then if echo "$path" | grep -qE -- "$word"; then
return 1 return 1
fi fi
done done
@@ -86,7 +86,7 @@ get_node_compose_files() {
# Remove leading ./ and trailing .yml if present # Remove leading ./ and trailing .yml if present
part="$(echo "$part" | sed 's|^\./||; s|\.yml$||')" part="$(echo "$part" | sed 's|^\./||; s|\.yml$||')"
# Only include if it's a real compose file (contains hyphens, not base/rpc/etc) # Only include if it's a real compose file (contains hyphens, not base/rpc/etc)
if echo "$part" | grep -q "-" && should_include "$part"; then if echo "$part" | grep -q -- "-" && should_include "$part"; then
echo "$part" echo "$part"
fi fi
done done
@@ -133,7 +133,7 @@ get_client_info() {
if [ -n "$services" ]; then if [ -n "$services" ]; then
# Try to find image for this service # 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) local image_line=$(grep -A 20 -- "^\s+${services}:" "$compose_file" | grep -oP -- 'image:\s*\K[^\s]+' | head -1)
if [ -n "$image_line" ]; then if [ -n "$image_line" ]; then
# Parse image:tag # Parse image:tag
client_image="$(echo "$image_line" | cut -d':' -f1)" client_image="$(echo "$image_line" | cut -d':' -f1)"
@@ -698,52 +698,51 @@ get_node_facts() {
echo "${sig_result}|${elapsed}" echo "${sig_result}|${elapsed}"
} }
# Run all data collection functions in parallel # Run all data collection functions in parallel and capture output
local version_pid local version_pid
local peers_pid local peers_pid
local schedule_pid local schedule_pid
local head_pid local head_pid
local signals_pid local signals_pid
local version_result
local peers_result
local schedule_result
local head_result
local signals_result
get_version & version_result=$(get_version) &
version_pid=$! version_pid=$!
get_peers & peers_result=$(get_peers) &
peers_pid=$! peers_pid=$!
get_schedule & schedule_result=$(get_schedule) &
schedule_pid=$! schedule_pid=$!
get_head & head_result=$(get_head) &
head_pid=$! head_pid=$!
get_signals & signals_result=$(get_signals) &
signals_pid=$! signals_pid=$!
# Wait for all to complete # Wait for all to complete
local results=()
for pid in $version_pid $peers_pid $schedule_pid $head_pid $signals_pid; do for pid in $version_pid $peers_pid $schedule_pid $head_pid $signals_pid; do
wait "$pid" wait "$pid"
done done
# Collect results # Collect results from captured output
version_result=$(get_version)
version_json=$(echo "$version_result" | cut -d'|' -f1) version_json=$(echo "$version_result" | cut -d'|' -f1)
version_time=$(echo "$version_result" | cut -d'|' -f2) version_time=$(echo "$version_result" | cut -d'|' -f2)
peers_result=$(get_peers)
peers_json=$(echo "$peers_result" | cut -d'|' -f1) peers_json=$(echo "$peers_result" | cut -d'|' -f1)
peers_time=$(echo "$peers_result" | cut -d'|' -f2) peers_time=$(echo "$peers_result" | cut -d'|' -f2)
schedule_result=$(get_schedule)
schedule_json=$(echo "$schedule_result" | cut -d'|' -f1) schedule_json=$(echo "$schedule_result" | cut -d'|' -f1)
schedule_time=$(echo "$schedule_result" | cut -d'|' -f2) schedule_time=$(echo "$schedule_result" | cut -d'|' -f2)
head_result=$(get_head)
head_json=$(echo "$head_result" | cut -d'|' -f1) head_json=$(echo "$head_result" | cut -d'|' -f1)
head_time=$(echo "$head_result" | cut -d'|' -f2) head_time=$(echo "$head_result" | cut -d'|' -f2)
signals_result=$(get_signals)
signals_json=$(echo "$signals_result" | cut -d'|' -f1) signals_json=$(echo "$signals_result" | cut -d'|' -f1)
signals_time=$(echo "$signals_result" | cut -d'|' -f2) signals_time=$(echo "$signals_result" | cut -d'|' -f2)
@@ -829,76 +828,69 @@ get_node_facts() {
echo "$doc" echo "$doc"
} }
# Main execution # Main execution function
if [ -n "$node_path" ]; then main() {
# Single node mode if [ -n "$node_path" ]; then
if should_include "$node_path"; then # Single node mode
get_node_facts "$node_path" "$PRETTY" 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 else
echo "error: node_path '$node_path' is blacklisted" >&2 # All nodes mode - iterate over all node compose files
exit 1 node_files=()
fi while IFS= read -r node_file; do
else [ -z "$node_file" ] && continue
# All nodes mode - iterate over all node compose files node_files+=("$node_file")
local node_files=() done < <(get_node_compose_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 # Create temp directory for output files
local max_workers=${MAX_WORKERS:-8} temp_dir=$(mktemp -d)
local pids=()
local results=()
local current_workers=0
local index=0
for node_file in "${node_files[@]}"; do # Track pids and temp files
# Wait if we have too many workers pids=()
while [ $current_workers -ge $max_workers ]; do temp_files=()
for i in "${!pids[@]}"; do max_workers=${MAX_WORKERS:-8}
if ! kill -0 "${pids[$i]}" 2>/dev/null; then
# Process completed for node_file in "${node_files[@]}"; do
local result=$(wait "${pids[$i]}" 2>/dev/null && echo "" || echo "") temp_file="$temp_dir/$(basename "$node_file" .yml).json"
results+=("$result") ( get_node_facts "$node_file" "$PRETTY" > "$temp_file" ) &
unset pids[$i] pids+=($!)
pids=("${pids[@]}") temp_files+=("$temp_file")
current_workers=$((current_workers - 1))
break # Wait if we reached max workers
fi if [ ${#pids[@]} -ge $max_workers ]; then
done wait -n
# If still at max, sleep briefly # Remove the completed pid and corresponding temp file
if [ $current_workers -ge $max_workers ]; then completed_pid="$?"
sleep 0.1 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 fi
done done
# Start new worker # Wait for all remaining workers
{ wait
local result=$(get_node_facts "$node_file" "$PRETTY")
echo "${result}"
} &
pids+=($!)
current_workers=$((current_workers + 1))
index=$((index + 1))
done
# Wait for remaining workers # Output results in order
for pid in "${pids[@]}"; do for temp_file in "${temp_files[@]}"; do
wait "$pid" 2>/dev/null cat "$temp_file"
done done
# Since we're outputting JSON lines, we already printed results in the workers # Clean up temp directory
# But to maintain order, let's do a simpler sequential approach for JSON lines rm -rf "$temp_dir"
# Actually, let's redo this properly for streaming JSON lines fi
}
# Clear the partial parallel results and do sequential for JSON lines # Call main function
echo "" # Separator main
# 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 exit 0
+131
View File
@@ -67,6 +67,137 @@ fi
rm -rf "$TMP_DIR" rm -rf "$TMP_DIR"
echo "=== Testing stub-curl integration ==="
# Stub-curl test: test with fixture compose directory and stub curl
STUB_TEST_DIR="$BASEPATH/node-facts-test"
if [ ! -d "$STUB_TEST_DIR" ]; then
echo "SKIP: Stub test directory not found"
else
# Test 6: Test with stub curl - single node mode
TMP_DIR=$(mktemp -d)
cd "$TMP_DIR" || exit 1
# Copy test files
cp -r "$STUB_TEST_DIR"/* .
# Make stub curl executable and put it first in PATH
STUB_CURL_DIR="$TMP_DIR/stub-curl"
chmod +x "$STUB_CURL_DIR/curl"
# Test single node mode with stub curl
ORIGINAL_PATH="$PATH"
export PATH="$STUB_CURL_DIR:$PATH"
START_TIME=$(date +%s)
output=$(timeout 60 ./node-facts.sh "avalanche-mainnet-go" 2>&1)
END_TIME=$(date +%s)
exit_code=$?
export PATH="$ORIGINAL_PATH"
if [ $exit_code -eq 0 ] && [ $((END_TIME - START_TIME)) -lt 10 ]; then
echo "PASS: Stub curl single node mode exits 0 and completes in < 10s"
# Validate it's JSON and has the right schema
if echo "$output" | jq -e '.schema, .node_path, .network, .client' >/dev/null 2>&1; then
echo "PASS: Single node output is valid JSON with required schema fields"
else
echo "FAIL: Single node output is not valid JSON schema"
echo "Output: $output"
rm -rf "$TMP_DIR"
exit 1
fi
else
echo "FAIL: Stub curl single node mode failed with exit code $exit_code, took $((END_TIME - START_TIME))s"
echo "Output: $output"
rm -rf "$TMP_DIR"
exit 1
fi
# Test 7: Test with stub curl - all nodes mode
START_TIME=$(date +%s)
output=$(timeout 60 ./node-facts.sh 2>&1)
END_TIME=$(date +%s)
exit_code=$?
if [ $exit_code -eq 0 ] && [ $((END_TIME - START_TIME)) -lt 10 ]; then
echo "PASS: Stub curl all-nodes mode exits 0 and completes in < 10s"
# Count JSON lines - should have at least 3 valid JSON lines (one per node)
json_lines=$(echo "$output" | grep -c '^{.*}$' || echo "0")
if [ "$json_lines" -ge 3 ]; then
echo "PASS: All-nodes mode produced $json_lines JSON lines (>= 3)"
else
echo "FAIL: All-nodes mode produced only $json_lines JSON lines, expected >= 3"
echo "Output: $output"
rm -rf "$TMP_DIR"
exit 1
fi
# Validate each JSON line against schema
valid_count=0
while IFS= read -r line; do
if echo "$line" | jq -e '.schema, .at, .host, .node_path, .network, .client, .version, .peers, .schedule, .signals, .head, .timing_ms' >/dev/null 2>&1; then
valid_count=$((valid_count + 1))
else
echo "Invalid JSON line: $line"
fi
done <<< "$(echo "$output" | grep '^{.*}$')"
if [ $valid_count -ge 3 ]; then
echo "PASS: All $valid_count JSON lines have valid schema v1 fields"
else
echo "FAIL: Only $valid_count valid JSON lines out of $json_lines total"
rm -rf "$TMP_DIR"
exit 1
fi
# Check for no stderr noise (grep/sed/jq errors)
if echo "$output" | grep -q "grep: invalid argument\|local: can only be used\|sed:\|jq:"; then
echo "FAIL: Found stderr noise in output: $(echo "$output" | grep "grep: invalid argument\|local: can only be used\|sed:\|jq:")"
rm -rf "$TMP_DIR"
exit 1
else
echo "PASS: No stderr noise (grep/sed/jq errors) in output"
fi
else
echo "FAIL: Stub curl all-nodes mode failed with exit code $exit_code, took $((END_TIME - START_TIME))s"
echo "Output: $output"
rm -rf "$TMP_DIR"
exit 1
fi
# Test 8: Test that path-blacklist with - prefix works
# Add a compose file that should be excluded by the -node blacklist entry
echo "version: '3'" > test-node-blacklist.yml
echo "services:" >> test-node-blacklist.yml
echo " client:" >> test-node-blacklist.yml
echo " image: test/node-client:v1.0.0" >> test-node-blacklist.yml
echo "chain: test-node-blacklist" >> test-node-blacklist.yml
# Update COMPOSE_FILE to include the blacklisted file
export COMPOSE_FILE="avalanche-mainnet-go.yml:test-node-blacklist.yml"
output=$(timeout 60 ./node-facts.sh 2>&1)
exit_code=$?
if [ $exit_code -eq 0 ]; then
# Should not include test-node-blacklist
if echo "$output" | grep -q "test-node-blacklist"; then
echo "FAIL: Blacklisted node was included in output"
rm -rf "$TMP_DIR"
exit 1
else
echo "PASS: Blacklist with - prefix works correctly"
fi
else
echo "FAIL: Blacklist test failed with exit code $exit_code"
rm -rf "$TMP_DIR"
exit 1
fi
rm -rf "$TMP_DIR"
fi
echo "=== Testing JSON schema validation ===" echo "=== Testing JSON schema validation ==="
# Function to validate schema # Function to validate schema