node-facts.sh: operator-verified rewrite (python; parallel; fixture self-test) - see ansible_showcase/node-facts-plan.md
This commit is contained in:
@@ -833,6 +833,20 @@ debugging reference, see [VIBE.md](VIBE.md).
|
||||
| `restore-volumes.sh <name>` | Restore from backup |
|
||||
| `node-facts.sh` | Emit JSON node facts documents for monitoring and inventory |
|
||||
|
||||
### node-facts.sh — one JSON facts document per node
|
||||
|
||||
`./node-facts.sh [node_path ...] [--pretty]` prints one JSON line per node (schema v1): running client
|
||||
image/tag and reported version, peer count + peer version histogram + `newer_than_us`, the protocol
|
||||
upgrade schedule the running config knows (chain config / rollup config / beacon spec / avalanche
|
||||
`info.upgrades`), chain signals (avalanche ACPs), head, and for OP-stack / beacon pairs a `consensus`
|
||||
block with the same fields for the consensus client. Every field is present; anything a client cannot
|
||||
provide is `null` with a `reason`. Read-only: compose files + `docker ps` + HTTP through the local
|
||||
traefik routes (`/<node>`, `/<node>/node`, `/<node>/eth`), parallel over nodes and calls, ~200 ms per
|
||||
host. Exit 2 with one error line if `.env`/`COMPOSE_FILE` is unreadable. Consumers: JobServer verb
|
||||
`node-facts`, controller `node-facts-collect` (CouchDB `node_facts:*`), Checkmk `node_facts_check.py`.
|
||||
Self-test (no network, no docker): `./test-node-facts.sh` runs the real main body against
|
||||
`node-facts-test/` fixtures via `NODE_FACTS_FIXTURES`.
|
||||
|
||||
---
|
||||
|
||||
## 16. Learning More
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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,5 @@
|
||||
services:
|
||||
avalanche-mainnet:
|
||||
image: ${AVALANCHE_GO_IMAGE:-avaplatform/avalanchego}:${AVALANCHE_MAINNET_GO_VERSION:-v1.15.0}
|
||||
labels:
|
||||
- "traefik.http.middlewares.avalanche-mainnet-stripprefix.stripprefix.prefixes=/avalanche-mainnet"
|
||||
@@ -1,11 +0,0 @@
|
||||
version: "3"
|
||||
services:
|
||||
client:
|
||||
image: ethereum/client-go:v1.13.0
|
||||
labels:
|
||||
- "traefik.http.routers.ethereum-mainnet-geth-pruned.rule=PathPrefix(`/ethereum-mainnet-geth-pruned`)"
|
||||
- "traefik.http.middlewares.ethereum-mainnet-geth-pruned.stripprefix.prefixes=/ethereum-mainnet-geth-pruned"
|
||||
x-upstreams:
|
||||
network: ethereum
|
||||
chain: ethereum-mainnet
|
||||
chain: ethereum-mainnet
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
ethereum-mainnet-geth:
|
||||
image: ethereum/client-go:v1.17.4
|
||||
labels:
|
||||
- "traefik.http.middlewares.ethereum-mainnet-geth-stripprefix.stripprefix.prefixes=/ethereum-mainnet-geth"
|
||||
ethereum-mainnet-geth-node:
|
||||
image: statusim/nimbus-eth2:multiarch-v26.8.0
|
||||
labels:
|
||||
- "traefik.http.middlewares.ethereum-mainnet-geth-node-stripprefix.stripprefix.prefixes=/ethereum-mainnet-geth"
|
||||
- "traefik.http.routers.ethereum-mainnet-geth-node.rule=Host(`$DOMAIN`) && PathPrefix(`/ethereum-mainnet-geth/eth`)"
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocols":{"eth":{"config":{"homesteadBlock":1150000,"shanghaiTime":1681338455,"cancunTime":1710338135,"pragueTime":1746612311}}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":[{"name":"Geth/v1.17.7-stable/linux-amd64"},{"name":"Geth/v1.17.7-stable/linux-amd64"},{"name":"reth/v2.7.0-abc/x86_64"},{"name":"Geth/v1.16.9-stable/linux"}]}
|
||||
@@ -0,0 +1,5 @@
|
||||
[{"container":"rpc-avalanche-mainnet-1","image":"avaplatform/avalanchego:v1.15.0","service":"avalanche-mainnet","files":""},
|
||||
{"container":"rpc-op-mainnet-op-reth-pruned-1","image":"us-docker.pkg.dev/oplabs-tools-artifacts/images/op-reth:v2.5.0","service":"op-mainnet-op-reth-pruned","files":""},
|
||||
{"container":"rpc-op-mainnet-op-reth-pruned-node-1","image":"us-docker.pkg.dev/oplabs-tools-artifacts/images/kona-node:v1.7.0","service":"op-mainnet-op-reth-pruned-node","files":""},
|
||||
{"container":"rpc-ethereum-mainnet-geth-1","image":"ethereum/client-go:v1.17.4","service":"ethereum-mainnet-geth","files":""},
|
||||
{"container":"rpc-ethereum-mainnet-geth-node-1","image":"statusim/nimbus-eth2:multiarch-v26.8.0","service":"ethereum-mainnet-geth-node","files":""}]
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"number":"0x18ec9c7","timestamp":"0x68e3a1f0"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":false}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"acps":{"194":{"supportWeight":"1","supporters":["a","b"],"objectors":[],"abstainers":[]}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"version":"avalanchego/1.15.0","databaseVersion":"v1.4.5","vmVersions":{"evm":"v1.15.0"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"numPeers":3,"peers":[{"version":"avalanchego/1.15.0"},{"version":"avalanchego/1.15.1"},{"version":"avalanchego/1.15.1"}]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"etnaTime":"2024-12-16T17:00:00Z","heliconTime":"2026-09-22T15:00:00Z"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"peers":{"p1":{"userAgent":"kona"},"p2":{"userAgent":"optimism"},"p3":{"userAgent":"kona"}}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"canyon_time":1704992401,"ecotone_time":1710374401,"isthmus_time":1746806401,"karst_time":1783180801}}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"unsafe_l2":{"number":157836486,"timestamp":1791353000},"safe_l2":{"number":157836400}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":"v1.7.0-abcdef"}
|
||||
@@ -0,0 +1 @@
|
||||
{"data":{"ALTAIR_FORK_EPOCH":"74240","ELECTRA_FORK_EPOCH":"364032","FULU_FORK_EPOCH":"411392","FAR_FUTURE":"18446744073709551615"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"data":[{"state":"connected"},{"state":"connected"},{"state":"disconnected"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"data":{"head_slot":"15370643","is_syncing":false}}
|
||||
@@ -0,0 +1 @@
|
||||
{"data":{"version":"Nimbus/v26.8.0-404a00-stateofus"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":"Geth/v1.17.4-stable/linux-amd64/go1.24"}
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
op-mainnet-op-reth-pruned:
|
||||
image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-reth:${OP_MAINNET_RETH_VERSION:-v2.5.0}
|
||||
labels:
|
||||
- "traefik.http.middlewares.op-mainnet-op-reth-pruned-stripprefix.stripprefix.prefixes=/op-mainnet-op-reth-pruned"
|
||||
op-mainnet-op-reth-pruned-node:
|
||||
image: us-docker.pkg.dev/oplabs-tools-artifacts/images/kona-node:v1.7.0
|
||||
labels:
|
||||
- "traefik.http.middlewares.op-mainnet-op-reth-pruned-node-stripprefix.stripprefix.prefixes=/op-mainnet-op-reth-pruned/node"
|
||||
- "traefik.http.routers.op-mainnet-op-reth-pruned-node.rule=Host(`$DOMAIN`) && PathPrefix(`/op-mainnet-op-reth-pruned/node`)"
|
||||
@@ -1,7 +1,2 @@
|
||||
lighthouse
|
||||
prism
|
||||
prysm
|
||||
beacon
|
||||
nimbus
|
||||
ws
|
||||
-node
|
||||
-de
|
||||
health
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
#!/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
|
||||
+289
-896
File diff suppressed because it is too large
Load Diff
+38
-350
@@ -1,351 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Fixture-only test for node-facts.sh
|
||||
# Tests parsing/assembly functions with canned JSON responses
|
||||
# Must never open network connections
|
||||
|
||||
BASEPATH="$(dirname "$0")"
|
||||
|
||||
# Send heartbeat to prevent watchdog kill
|
||||
touch "/root/.mcp-jobs/vibe-node-changes/20261006-070518-re-dispatch-of-a-change-a-prior-run-alre-3723628/heartbeat" 2>/dev/null || true
|
||||
|
||||
echo "=== Testing node-facts.sh (fixture-only) ==="
|
||||
|
||||
# Test 1: Check that the script exists and is executable
|
||||
if [ ! -x "$BASEPATH/node-facts.sh" ]; then
|
||||
echo "FAIL: node-facts.sh is not executable"
|
||||
exit 1
|
||||
else
|
||||
echo "PASS: node-facts.sh is executable"
|
||||
fi
|
||||
|
||||
# Test 2: Check syntax
|
||||
bash -n "$BASEPATH/node-facts.sh" && echo "PASS: Script syntax is valid" || { echo "FAIL: Script syntax error"; exit 1; }
|
||||
|
||||
# Test 3: Check that it exits with code 2 when .env is missing
|
||||
TMP_DIR=$(mktemp -d)
|
||||
cp "$BASEPATH/node-facts.sh" "$TMP_DIR/"
|
||||
cd "$TMP_DIR" || exit 1
|
||||
|
||||
output=$(./node-facts.sh 2>&1)
|
||||
exit_code=$?
|
||||
|
||||
if [ $exit_code -eq 2 ] && echo "$output" | grep -q "cannot read .env"; then
|
||||
echo "PASS: Exits with code 2 when .env is missing with JSON error"
|
||||
else
|
||||
echo "FAIL: Expected exit code 2 with JSON error, got $exit_code with output: $output"
|
||||
rm -rf "$TMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 4: Check that it exits with code 2 when COMPOSE_FILE is empty
|
||||
echo "NO_SSL=true" > .env
|
||||
output=$(./node-facts.sh 2>&1)
|
||||
exit_code=$?
|
||||
|
||||
if [ $exit_code -eq 2 ] && echo "$output" | grep -q "COMPOSE_FILE is empty"; then
|
||||
echo "PASS: Exits with code 2 when COMPOSE_FILE is empty with JSON error"
|
||||
else
|
||||
echo "FAIL: Expected exit code 2 with JSON error, got $exit_code with output: $output"
|
||||
rm -rf "$TMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 5: Test fail-fast completes within 1 second
|
||||
START_TIME=$(date +%s)
|
||||
output=$(timeout 1 ./node-facts.sh 2>&1)
|
||||
END_TIME=$(date +%s)
|
||||
exit_code=$?
|
||||
|
||||
if [ $((END_TIME - START_TIME)) -le 1 ]; then
|
||||
echo "PASS: Script fails fast (within 1 second) when .env is missing"
|
||||
else
|
||||
echo "FAIL: Script took too long to fail"
|
||||
rm -rf "$TMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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 ==="
|
||||
|
||||
# Function to validate schema
|
||||
validate_schema() {
|
||||
local json_file="$1"
|
||||
|
||||
# Check all required top-level fields exist
|
||||
jq -e '.schema, .at, .host, .node_path, .network, .client, .version, .peers, .schedule, .signals, .head, .timing_ms' "$json_file" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Test with fixture JSON files
|
||||
TMP_DIR=$(mktemp -d)
|
||||
|
||||
# Test avalanche schema
|
||||
cat > "$TMP_DIR/avalanche.json" << 'EOF'
|
||||
{
|
||||
"schema": 1,
|
||||
"at": "2026-10-06T06:00:00Z",
|
||||
"host": "test-host",
|
||||
"node_path": "avalanche-mainnet-go",
|
||||
"network": "avalanche",
|
||||
"client": {"name": "avalanchego", "image": "ava-labs/avalanchego", "tag": "v1.10.0", "container": "avalanche-mainnet-go_client_1"},
|
||||
"version": {"reported": "1.10.0", "db_version": "1.0.0", "source": "info.getNodeVersion", "error": null},
|
||||
"peers": {"count": 50, "versions": {"1.10.0": 25, "1.9.0": 15}, "newer_than_us": 0, "source": "info.peers", "error": null},
|
||||
"schedule": {"upgrades": {"apricotPhase1Time": {"time": "2021-04-27T00:00:00Z", "block": 4200000}}, "source": "info.upgrades", "error": null},
|
||||
"signals": {"acp123": {"supporter": 30, "objector": 5}, "source": "info.acps", "error": null},
|
||||
"head": {"height": null, "timestamp": null, "syncing": null, "source": "info.getNodeVersion", "error": "avalanche head not implemented"},
|
||||
"timing_ms": {"version": 45, "peers": 120, "schedule": 25, "head": 5}
|
||||
}
|
||||
EOF
|
||||
|
||||
if validate_schema "$TMP_DIR/avalanche.json"; then
|
||||
echo "PASS: Avalanche schema is valid"
|
||||
else
|
||||
echo "FAIL: Avalanche schema validation failed"
|
||||
rm -rf "$TMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test beacon schema
|
||||
cat > "$TMP_DIR/beacon.json" << 'EOF'
|
||||
{
|
||||
"schema": 1,
|
||||
"at": "2026-10-06T06:00:00Z",
|
||||
"host": "test-host",
|
||||
"node_path": "ethereum-mainnet-lighthouse",
|
||||
"network": "ethereum",
|
||||
"client": {"name": "beacon", "image": "sigp/lighthouse", "tag": "v5.3.0", "container": "ethereum-mainnet-lighthouse_client_1"},
|
||||
"version": {"reported": "Lighthouse/v5.3.0", "db_version": null, "source": "eth/v1/node/version", "error": null},
|
||||
"peers": {"count": 25, "versions": null, "newer_than_us": null, "source": "eth/v1/node/peers", "error": "beacon peers API carries no agent version"},
|
||||
"schedule": {"upgrades": {"ALTAIR_FORK_EPOCH": {"block": 74240, "time": null}}, "source": "eth/v1/config/spec", "error": null},
|
||||
"signals": null,
|
||||
"head": {"height": 12345678, "timestamp": "2026-10-06T05:59:59Z", "syncing": false, "source": "eth/v1/node/syncing", "error": null},
|
||||
"timing_ms": {"version": 30, "peers": 40, "schedule": 20, "head": 50}
|
||||
}
|
||||
EOF
|
||||
|
||||
if validate_schema "$TMP_DIR/beacon.json"; then
|
||||
echo "PASS: Beacon schema is valid"
|
||||
else
|
||||
echo "FAIL: Beacon schema validation failed"
|
||||
rm -rf "$TMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test geth schema
|
||||
cat > "$TMP_DIR/geth.json" << 'EOF'
|
||||
{
|
||||
"schema": 1,
|
||||
"at": "2026-10-06T06:00:00Z",
|
||||
"host": "test-host",
|
||||
"node_path": "ethereum-mainnet-geth-pruned",
|
||||
"network": "ethereum",
|
||||
"client": {"name": "geth", "image": "ethereum/client-go", "tag": "v1.13.0", "container": "ethereum-mainnet-geth-pruned_client_1"},
|
||||
"version": {"reported": "Geth/v1.13.0-stable", "db_version": null, "source": "web3_clientVersion", "error": null},
|
||||
"peers": {"count": 50, "versions": {"Geth/v1.13.0": 25, "Geth/v1.12.0": 15}, "newer_than_us": 5, "source": "admin_peers", "error": null},
|
||||
"schedule": {"upgrades": {"byzantiumBlock": {"block": 4370000, "time": null}}, "source": "admin_nodeInfo", "error": null},
|
||||
"signals": null,
|
||||
"head": {"height": 21000000, "timestamp": "2026-10-06T05:59:59Z", "syncing": false, "source": "eth_blockNumber", "error": null},
|
||||
"timing_ms": {"version": 20, "peers": 80, "schedule": 30, "head": 60}
|
||||
}
|
||||
EOF
|
||||
|
||||
if validate_schema "$TMP_DIR/geth.json"; then
|
||||
echo "PASS: Geth schema is valid"
|
||||
else
|
||||
echo "FAIL: Geth schema validation failed"
|
||||
rm -rf "$TMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test op-node schema
|
||||
cat > "$TMP_DIR/opnode.json" << 'EOF'
|
||||
{
|
||||
"schema": 1,
|
||||
"at": "2026-10-06T06:00:00Z",
|
||||
"host": "test-host",
|
||||
"node_path": "op-base-mainnet",
|
||||
"network": "base",
|
||||
"client": {"name": "op-node", "image": "us-docker.pkg.dev/oplabs/op-node", "tag": "v1.19.0", "container": "op-base-mainnet_node_1"},
|
||||
"version": {"reported": "v1.19.0", "db_version": null, "source": "optimism_version", "error": null},
|
||||
"peers": {"count": 10, "versions": {"op-node/v1.19.0": 5}, "newer_than_us": 0, "source": "opp2p_peers", "error": null},
|
||||
"schedule": {"upgrades": {"canyon_time": {"time": "2023-08-10T00:00:00Z", "block": null}}, "source": "optimism_rollupConfig", "error": null},
|
||||
"signals": null,
|
||||
"head": {"height": 12345678, "timestamp": "2026-10-06T05:59:59Z", "syncing": false, "source": "optimism_syncStatus", "error": null},
|
||||
"timing_ms": {"version": 25, "peers": 90, "schedule": 35, "head": 45}
|
||||
}
|
||||
EOF
|
||||
|
||||
if validate_schema "$TMP_DIR/opnode.json"; then
|
||||
echo "PASS: OP-Node schema is valid"
|
||||
else
|
||||
echo "FAIL: OP-Node schema validation failed"
|
||||
rm -rf "$TMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test reth schema (with null schedule)
|
||||
cat > "$TMP_DIR/reth.json" << 'EOF'
|
||||
{
|
||||
"schema": 1,
|
||||
"at": "2026-10-06T06:00:00Z",
|
||||
"host": "test-host",
|
||||
"node_path": "base-mainnet-reth",
|
||||
"network": "base",
|
||||
"client": {"name": "reth", "image": "ghcr.io/base/node-reth", "tag": "v1.0.0", "container": "base-mainnet-reth_client_1"},
|
||||
"version": {"reported": "reth/v1.0.0", "db_version": null, "source": "clientVersion", "error": null},
|
||||
"peers": {"count": 20, "versions": {"reth/v1.0.0": 15}, "newer_than_us": 2, "source": "admin_peers", "error": null},
|
||||
"schedule": {"upgrades": null, "source": null, "error": "reth exposes no chain config over RPC"},
|
||||
"signals": null,
|
||||
"head": {"height": 12345678, "timestamp": "2026-10-06T05:59:59Z", "syncing": false, "source": "eth_blockNumber", "error": null},
|
||||
"timing_ms": {"version": 15, "peers": 60, "schedule": 5, "head": 30}
|
||||
}
|
||||
EOF
|
||||
|
||||
if validate_schema "$TMP_DIR/reth.json"; then
|
||||
echo "PASS: Reth schema is valid"
|
||||
else
|
||||
echo "FAIL: Reth schema validation failed"
|
||||
rm -rf "$TMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
rm -rf "$TMP_DIR"
|
||||
|
||||
# Send final heartbeat
|
||||
touch "/root/.mcp-jobs/vibe-node-changes/20261006-070518-re-dispatch-of-a-change-a-prior-run-alre-3723628/heartbeat" 2>/dev/null || true
|
||||
|
||||
echo "SUCCESS: All fixture tests passed"
|
||||
exit 0
|
||||
# Fixture-only self-test for node-facts.sh: runs the REAL main body against node-facts-test/ (fake .env,
|
||||
# compose files, canned HTTP answers via NODE_FACTS_FIXTURES, canned docker ps). No network, no docker.
|
||||
set -u
|
||||
cd "$(dirname "$0")"
|
||||
T=node-facts-test; fail=0
|
||||
t0=$(date +%s%N)
|
||||
W=$(mktemp -d); cp -r $T/. $W/; cp node-facts.sh $W/node-facts.sh
|
||||
out=$(cd $W && NODE_FACTS_FIXTURES="$W/fixtures" timeout 30 ./node-facts.sh); rc=$?; rm -rf $W
|
||||
ms=$(( ($(date +%s%N) - t0) / 1000000 ))
|
||||
n=$(printf '%s\n' "$out" | grep -c '^{')
|
||||
[ $rc -eq 0 ] || { echo "FAIL: exit $rc"; fail=1; }
|
||||
[ "$n" -eq 3 ] || { echo "FAIL: expected 3 documents, got $n"; fail=1; }
|
||||
[ $ms -lt 10000 ] || { echo "FAIL: took ${ms}ms"; fail=1; }
|
||||
OUTF=$(mktemp); printf '%s\n' "$out" > "$OUTF"
|
||||
python3 - "$OUTF" <<'PY' || fail=1
|
||||
import json,sys
|
||||
req=["schema","at","host","node_path","network","client","version","peers","schedule","signals","head","consensus","timing_ms"]
|
||||
docs=[json.loads(l) for l in open(sys.argv[1]) if l.startswith("{")]
|
||||
by={d["node_path"].split("/")[-1]:d for d in docs}
|
||||
for d in docs:
|
||||
miss=[k for k in req if k not in d]; assert not miss, f"{d['node_path']}: missing {miss}"
|
||||
a=by["avalanche-mainnet-go-pruned-leveldb"]; assert a["client"]["name"]=="avalanchego", a["client"]
|
||||
assert a["peers"]["count"]==3 and a["peers"]["newer_than_us"]==2, a["peers"]
|
||||
assert "helicon" in a["schedule"]["upgrades"] and a["signals"]["acps"]["194"]["supporters"]==2
|
||||
o=by["op-mainnet-op-reth-pruned-trace--kona"]; assert o["client"]["consensus"]["name"]=="kona", o["client"]
|
||||
assert o["consensus"]["peers"]["count"]==3 and "karst" in o["consensus"]["schedule"]["upgrades"] and o["consensus"]["head"]["height"]==157836486
|
||||
g=by["ethereum-mainnet-geth-pruned-pebble-path--nimbus"]; assert g["client"]["name"]=="geth"
|
||||
assert g["peers"]["count"]==4 and g["peers"]["newer_than_us"]==3, g["peers"]
|
||||
assert g["schedule"]["upgrades"]["prague"]["time"].startswith("2025-05-07")
|
||||
assert g["consensus"]["version"]["reported"].startswith("Nimbus") and g["consensus"]["peers"]["count"]==2 and "FULU" in g["consensus"]["schedule"]["upgrades"] and "FAR_FUTURE" not in g["consensus"]["schedule"]["upgrades"]
|
||||
assert g["head"]["height"]==0x18ec9c7
|
||||
print("PASS: 3 documents, schema v1, avalanche/op-stack/geth+beacon assertions")
|
||||
PY
|
||||
rm -f "$OUTF"
|
||||
# fail-fast without .env
|
||||
d=$(mktemp -d); cp node-facts.sh $d/; t1=$(date +%s%N); (cd $d && timeout 10 ./node-facts.sh >/dev/null 2>&1); rc=$?; ms2=$(( ($(date +%s%N) - t1) / 1000000 )); rm -rf $d
|
||||
[ $rc -eq 2 ] && [ $ms2 -lt 2000 ] && echo "PASS: fail-fast exit 2 in ${ms2}ms without .env" || { echo "FAIL: fail-fast rc=$rc ${ms2}ms"; fail=1; }
|
||||
[ $fail -eq 0 ] && echo "SUCCESS (${ms}ms)" || { echo "FAILED"; exit 1; }
|
||||
|
||||
Reference in New Issue
Block a user