From b947d7ddf38ea2ef585ce03e654fb199791c06da Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 6 Oct 2026 07:36:38 +0000 Subject: [PATCH] node-facts.sh: operator-verified rewrite (python; parallel; fixture self-test) - see ansible_showcase/node-facts-plan.md --- README.md | 14 + node-facts-test/avalanche-mainnet-go.yml | 11 - .../avalanche-mainnet-go-pruned-leveldb.yml | 5 + .../ethereum-mainnet-geth-pruned.yml | 11 - .../ethereum-mainnet-lighthouse.yml | 11 - ...ainnet-geth-pruned-pebble-path--nimbus.yml | 10 + node-facts-test/fixtures/admin_nodeInfo.json | 1 + node-facts-test/fixtures/admin_peers.json | 1 + node-facts-test/fixtures/docker_ps.json | 5 + .../fixtures/eth_getBlockByNumber.json | 1 + node-facts-test/fixtures/eth_syncing.json | 1 + node-facts-test/fixtures/info.acps.json | 1 + .../fixtures/info.getNodeVersion.json | 1 + node-facts-test/fixtures/info.peers.json | 1 + node-facts-test/fixtures/info.upgrades.json | 1 + node-facts-test/fixtures/opp2p_peers.json | 1 + .../fixtures/optimism_rollupConfig.json | 1 + .../fixtures/optimism_syncStatus.json | 1 + .../fixtures/optimism_version.json | 1 + node-facts-test/fixtures/v1_config_spec.json | 1 + node-facts-test/fixtures/v1_node_peers.json | 1 + node-facts-test/fixtures/v1_node_syncing.json | 1 + node-facts-test/fixtures/v1_node_version.json | 1 + .../fixtures/web3_clientVersion.json | 1 + node-facts-test/op-base-mainnet.yml | 11 - .../op-mainnet-op-reth-pruned-trace--kona.yml | 10 + node-facts-test/path-blacklist.txt | 9 +- node-facts-test/stub-curl/curl | 127 -- node-facts.sh | 1139 ++++------------- test-node-facts.sh | 388 +----- 30 files changed, 367 insertions(+), 1401 deletions(-) delete mode 100644 node-facts-test/avalanche-mainnet-go.yml create mode 100644 node-facts-test/avalanche/go/avalanche-mainnet-go-pruned-leveldb.yml delete mode 100644 node-facts-test/ethereum-mainnet-geth-pruned.yml delete mode 100644 node-facts-test/ethereum-mainnet-lighthouse.yml create mode 100644 node-facts-test/ethereum/geth/ethereum-mainnet-geth-pruned-pebble-path--nimbus.yml create mode 100644 node-facts-test/fixtures/admin_nodeInfo.json create mode 100644 node-facts-test/fixtures/admin_peers.json create mode 100644 node-facts-test/fixtures/docker_ps.json create mode 100644 node-facts-test/fixtures/eth_getBlockByNumber.json create mode 100644 node-facts-test/fixtures/eth_syncing.json create mode 100644 node-facts-test/fixtures/info.acps.json create mode 100644 node-facts-test/fixtures/info.getNodeVersion.json create mode 100644 node-facts-test/fixtures/info.peers.json create mode 100644 node-facts-test/fixtures/info.upgrades.json create mode 100644 node-facts-test/fixtures/opp2p_peers.json create mode 100644 node-facts-test/fixtures/optimism_rollupConfig.json create mode 100644 node-facts-test/fixtures/optimism_syncStatus.json create mode 100644 node-facts-test/fixtures/optimism_version.json create mode 100644 node-facts-test/fixtures/v1_config_spec.json create mode 100644 node-facts-test/fixtures/v1_node_peers.json create mode 100644 node-facts-test/fixtures/v1_node_syncing.json create mode 100644 node-facts-test/fixtures/v1_node_version.json create mode 100644 node-facts-test/fixtures/web3_clientVersion.json delete mode 100644 node-facts-test/op-base-mainnet.yml create mode 100644 node-facts-test/op/reth/op-mainnet-op-reth-pruned-trace--kona.yml delete mode 100755 node-facts-test/stub-curl/curl diff --git a/README.md b/README.md index a6fe04ab..dfc948a3 100644 --- a/README.md +++ b/README.md @@ -833,6 +833,20 @@ debugging reference, see [VIBE.md](VIBE.md). | `restore-volumes.sh ` | 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`, `//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 diff --git a/node-facts-test/avalanche-mainnet-go.yml b/node-facts-test/avalanche-mainnet-go.yml deleted file mode 100644 index 18447969..00000000 --- a/node-facts-test/avalanche-mainnet-go.yml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/node-facts-test/avalanche/go/avalanche-mainnet-go-pruned-leveldb.yml b/node-facts-test/avalanche/go/avalanche-mainnet-go-pruned-leveldb.yml new file mode 100644 index 00000000..58422fbf --- /dev/null +++ b/node-facts-test/avalanche/go/avalanche-mainnet-go-pruned-leveldb.yml @@ -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" diff --git a/node-facts-test/ethereum-mainnet-geth-pruned.yml b/node-facts-test/ethereum-mainnet-geth-pruned.yml deleted file mode 100644 index ad766331..00000000 --- a/node-facts-test/ethereum-mainnet-geth-pruned.yml +++ /dev/null @@ -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 diff --git a/node-facts-test/ethereum-mainnet-lighthouse.yml b/node-facts-test/ethereum-mainnet-lighthouse.yml deleted file mode 100644 index dc9b9ab6..00000000 --- a/node-facts-test/ethereum-mainnet-lighthouse.yml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/node-facts-test/ethereum/geth/ethereum-mainnet-geth-pruned-pebble-path--nimbus.yml b/node-facts-test/ethereum/geth/ethereum-mainnet-geth-pruned-pebble-path--nimbus.yml new file mode 100644 index 00000000..58debd72 --- /dev/null +++ b/node-facts-test/ethereum/geth/ethereum-mainnet-geth-pruned-pebble-path--nimbus.yml @@ -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`)" diff --git a/node-facts-test/fixtures/admin_nodeInfo.json b/node-facts-test/fixtures/admin_nodeInfo.json new file mode 100644 index 00000000..1d669fee --- /dev/null +++ b/node-facts-test/fixtures/admin_nodeInfo.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocols":{"eth":{"config":{"homesteadBlock":1150000,"shanghaiTime":1681338455,"cancunTime":1710338135,"pragueTime":1746612311}}}}} diff --git a/node-facts-test/fixtures/admin_peers.json b/node-facts-test/fixtures/admin_peers.json new file mode 100644 index 00000000..c24dff74 --- /dev/null +++ b/node-facts-test/fixtures/admin_peers.json @@ -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"}]} diff --git a/node-facts-test/fixtures/docker_ps.json b/node-facts-test/fixtures/docker_ps.json new file mode 100644 index 00000000..ac47b508 --- /dev/null +++ b/node-facts-test/fixtures/docker_ps.json @@ -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":""}] diff --git a/node-facts-test/fixtures/eth_getBlockByNumber.json b/node-facts-test/fixtures/eth_getBlockByNumber.json new file mode 100644 index 00000000..c6dbb7d0 --- /dev/null +++ b/node-facts-test/fixtures/eth_getBlockByNumber.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"number":"0x18ec9c7","timestamp":"0x68e3a1f0"}} diff --git a/node-facts-test/fixtures/eth_syncing.json b/node-facts-test/fixtures/eth_syncing.json new file mode 100644 index 00000000..b437802f --- /dev/null +++ b/node-facts-test/fixtures/eth_syncing.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":false} diff --git a/node-facts-test/fixtures/info.acps.json b/node-facts-test/fixtures/info.acps.json new file mode 100644 index 00000000..1690037b --- /dev/null +++ b/node-facts-test/fixtures/info.acps.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"acps":{"194":{"supportWeight":"1","supporters":["a","b"],"objectors":[],"abstainers":[]}}}} diff --git a/node-facts-test/fixtures/info.getNodeVersion.json b/node-facts-test/fixtures/info.getNodeVersion.json new file mode 100644 index 00000000..a124bd72 --- /dev/null +++ b/node-facts-test/fixtures/info.getNodeVersion.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"version":"avalanchego/1.15.0","databaseVersion":"v1.4.5","vmVersions":{"evm":"v1.15.0"}}} diff --git a/node-facts-test/fixtures/info.peers.json b/node-facts-test/fixtures/info.peers.json new file mode 100644 index 00000000..1bf8730e --- /dev/null +++ b/node-facts-test/fixtures/info.peers.json @@ -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"}]}} diff --git a/node-facts-test/fixtures/info.upgrades.json b/node-facts-test/fixtures/info.upgrades.json new file mode 100644 index 00000000..52a0491a --- /dev/null +++ b/node-facts-test/fixtures/info.upgrades.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"etnaTime":"2024-12-16T17:00:00Z","heliconTime":"2026-09-22T15:00:00Z"}} diff --git a/node-facts-test/fixtures/opp2p_peers.json b/node-facts-test/fixtures/opp2p_peers.json new file mode 100644 index 00000000..43a4dbef --- /dev/null +++ b/node-facts-test/fixtures/opp2p_peers.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"peers":{"p1":{"userAgent":"kona"},"p2":{"userAgent":"optimism"},"p3":{"userAgent":"kona"}}}} diff --git a/node-facts-test/fixtures/optimism_rollupConfig.json b/node-facts-test/fixtures/optimism_rollupConfig.json new file mode 100644 index 00000000..137c66d7 --- /dev/null +++ b/node-facts-test/fixtures/optimism_rollupConfig.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"canyon_time":1704992401,"ecotone_time":1710374401,"isthmus_time":1746806401,"karst_time":1783180801}} diff --git a/node-facts-test/fixtures/optimism_syncStatus.json b/node-facts-test/fixtures/optimism_syncStatus.json new file mode 100644 index 00000000..c667dd33 --- /dev/null +++ b/node-facts-test/fixtures/optimism_syncStatus.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"unsafe_l2":{"number":157836486,"timestamp":1791353000},"safe_l2":{"number":157836400}}} diff --git a/node-facts-test/fixtures/optimism_version.json b/node-facts-test/fixtures/optimism_version.json new file mode 100644 index 00000000..cb4bfcc2 --- /dev/null +++ b/node-facts-test/fixtures/optimism_version.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":"v1.7.0-abcdef"} diff --git a/node-facts-test/fixtures/v1_config_spec.json b/node-facts-test/fixtures/v1_config_spec.json new file mode 100644 index 00000000..f69b7b7c --- /dev/null +++ b/node-facts-test/fixtures/v1_config_spec.json @@ -0,0 +1 @@ +{"data":{"ALTAIR_FORK_EPOCH":"74240","ELECTRA_FORK_EPOCH":"364032","FULU_FORK_EPOCH":"411392","FAR_FUTURE":"18446744073709551615"}} diff --git a/node-facts-test/fixtures/v1_node_peers.json b/node-facts-test/fixtures/v1_node_peers.json new file mode 100644 index 00000000..aa67e4aa --- /dev/null +++ b/node-facts-test/fixtures/v1_node_peers.json @@ -0,0 +1 @@ +{"data":[{"state":"connected"},{"state":"connected"},{"state":"disconnected"}]} diff --git a/node-facts-test/fixtures/v1_node_syncing.json b/node-facts-test/fixtures/v1_node_syncing.json new file mode 100644 index 00000000..e28d88b2 --- /dev/null +++ b/node-facts-test/fixtures/v1_node_syncing.json @@ -0,0 +1 @@ +{"data":{"head_slot":"15370643","is_syncing":false}} diff --git a/node-facts-test/fixtures/v1_node_version.json b/node-facts-test/fixtures/v1_node_version.json new file mode 100644 index 00000000..2de20477 --- /dev/null +++ b/node-facts-test/fixtures/v1_node_version.json @@ -0,0 +1 @@ +{"data":{"version":"Nimbus/v26.8.0-404a00-stateofus"}} diff --git a/node-facts-test/fixtures/web3_clientVersion.json b/node-facts-test/fixtures/web3_clientVersion.json new file mode 100644 index 00000000..90baeb9f --- /dev/null +++ b/node-facts-test/fixtures/web3_clientVersion.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":"Geth/v1.17.4-stable/linux-amd64/go1.24"} diff --git a/node-facts-test/op-base-mainnet.yml b/node-facts-test/op-base-mainnet.yml deleted file mode 100644 index 90662507..00000000 --- a/node-facts-test/op-base-mainnet.yml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/node-facts-test/op/reth/op-mainnet-op-reth-pruned-trace--kona.yml b/node-facts-test/op/reth/op-mainnet-op-reth-pruned-trace--kona.yml new file mode 100644 index 00000000..e42cdd62 --- /dev/null +++ b/node-facts-test/op/reth/op-mainnet-op-reth-pruned-trace--kona.yml @@ -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`)" diff --git a/node-facts-test/path-blacklist.txt b/node-facts-test/path-blacklist.txt index 91898da4..56ecafcc 100644 --- a/node-facts-test/path-blacklist.txt +++ b/node-facts-test/path-blacklist.txt @@ -1,7 +1,2 @@ -lighthouse -prism -prysm -beacon -nimbus -ws --node +-de +health diff --git a/node-facts-test/stub-curl/curl b/node-facts-test/stub-curl/curl deleted file mode 100755 index 8c9adb31..00000000 --- a/node-facts-test/stub-curl/curl +++ /dev/null @@ -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 \ No newline at end of file diff --git a/node-facts.sh b/node-facts.sh index 390bd1ac..56f6fda0 100755 --- a/node-facts.sh +++ b/node-facts.sh @@ -1,896 +1,289 @@ -#!/bin/bash +#!/usr/bin/env python3 +"""node-facts.sh [node_path ...] [--pretty] (hand-maintained; ansible_showcase/node-facts-plan.md) -# 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 +One JSON 'node facts' document per node, same schema (v1) for every client family: own version, peer +version histogram + newer_than_us, protocol upgrade schedule the running config knows, chain signals, +head. Modelled on avalanchego's Info API; simulated best-effort for the others. Read-only: compose +files + docker ps/inspect + HTTP through the local traefik (like sync-status.sh). Parallel over nodes +and over the per-node calls. Every field present; null + reason instead of silence. +Exit 0 always once .env is readable; exit 2 if .env/COMPOSE_FILE is missing (one error line). +""" +import concurrent.futures as cf, datetime, json, os, re, socket, subprocess, sys, time, urllib.request, ssl -BASEPATH="$(dirname "$0")" +BASE = os.path.dirname(os.path.abspath(__file__)) +INFRA = {"base", "rpc", "monitoring", "backup-http", "drpc", "drpc-free", "squidfura", "squidfura-backend", "oracle", "extra"} +NOW = lambda: datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") +SEMVER = re.compile(r"(\d+)\.(\d+)\.(\d+)") +CTX = ssl.create_default_context() -# 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 +def env(): + e = {} + try: + for line in open(os.path.join(BASE, ".env")): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1); e[k] = v.strip().strip('"').strip("'") + except Exception: + return None + return e -# 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 +FIXTURES = os.environ.get("NODE_FACTS_FIXTURES") # test mode: answer HTTP from /.json, no network -# 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 +def _fixture(url, body): + key = (body or {}).get("method") if body else url.split("/eth/", 1)[-1].replace("/", "_") if "/eth/" in url else os.path.basename(url) + f = os.path.join(FIXTURES, f"{key}.json") + if os.path.exists(f): return 1, 200, open(f).read(), None + return 1, None, None, f"fixture missing: {key}" -# 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 -} +def http(url, body=None, timeout=5.0): + """-> (ms, status, text|None, err|None)""" + if FIXTURES: return _fixture(url, body) + t0 = time.time() + try: + req = urllib.request.Request(url, data=(json.dumps(body).encode() if body is not None else None), + headers={"content-type": "application/json", "user-agent": "node-facts/1"}) + with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r: + return int((time.time() - t0) * 1000), r.status, r.read().decode(errors="replace"), None + except urllib.error.HTTPError as ex: + return int((time.time() - t0) * 1000), ex.code, None, f"http {ex.code}" + except Exception as ex: + return int((time.time() - t0) * 1000), None, None, f"{type(ex).__name__}: {str(ex)[:80]}" -# 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 -} +def rpc(url, method, params=None, timeout=5.0): + ms, st, txt, err = http(url, {"jsonrpc": "2.0", "id": 1, "method": method, "params": params if params is not None else []}, timeout) + if err: return ms, None, err + try: + d = json.loads(txt) + except Exception: + return ms, None, "non-json response" + if "error" in d and d["error"]: return ms, None, f"rpc error: {str(d['error'].get('message', d['error']))[:80]}" + return ms, d.get("result"), None -# 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" -} +def rest(url, timeout=5.0): + ms, st, txt, err = http(url, None, timeout) + if err: return ms, None, err + try: return ms, json.loads(txt), None + except Exception: return ms, None, "non-json response" -# 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/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" -} +def docker_ps(): + out = subprocess.run(["docker", "ps", "--format", '{{.Names}}\t{{.Image}}\t{{.Label "com.docker.compose.service"}}\t{{.Label "com.docker.compose.project.config_files"}}'], + capture_output=True, text=True, timeout=20).stdout + rows = [] + for line in out.splitlines(): + p = line.split("\t") + if len(p) == 4: rows.append({"container": p[0], "image": p[1], "service": p[2], "files": p[3]}) + return rows -# 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" -} +FAMILY = [("avalanchego", "avalanchego"), ("kona-node", "kona"), ("kona", "kona"), ("op-node", "op-node"), ("conduit-op-reth", "op-reth"), ("op-reth", "op-reth"), + ("op-geth", "op-geth"), ("op-erigon", "erigon"), ("lighthouse", "beacon"), ("prysm", "beacon"), ("nimbus", "beacon"), ("teku", "beacon"), ("lodestar", "beacon"), + ("grandine", "beacon"), ("client-go", "geth"), ("bnb-chain/bsc", "geth"), ("bsc", "geth"), ("/geth", "geth"), ("geth", "geth"), ("bor", "bor"), ("erigon", "erigon"), + ("nethermind", "nethermind"), ("besu", "besu"), ("reth-bsc", "reth"), ("bera-reth", "reth"), ("reth", "reth"), ("nitro", "nitro"), ("cometbft", "cometbft"), + ("gaiad", "cometbft"), ("external-node", "zksync-en"), ("maru", "maru"), ("arc-consensus", "arc"), ("arc-execution", "arc")] -# 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 +def family_of(image): + low = image.lower() + for sub, fam in FAMILY: + if sub in low: return fam + return "unknown" -exit 0 \ No newline at end of file + +def labels_of(compose_text): + """traefik stripprefix prefixes found in the compose file -> set of paths like 'linea-mainnet', 'linea-mainnet/node'""" + return set(re.findall(r"stripprefix\.prefixes=/([^\"'\s,]+)", compose_text)) + + +def node_facts(env_, node, containers, pretty=False): + proto = "http" if env_.get("NO_SSL") else "https"; domain = env_.get("DOMAIN") or "0.0.0.0" + compose = os.path.join(BASE, node + ".yml") + try: text = open(compose).read() + except Exception as ex: + return {"schema": 1, "at": NOW(), "host": socket.gethostname().split(".")[0], "node_path": node, "network": None, "client": None, + "version": None, "peers": None, "schedule": None, "signals": None, "head": None, "timing_ms": None, "error": f"compose file unreadable: {ex}"} + # services defined in THIS compose file (top-level keys under services:) -> containers by compose service label + svc_names = set(re.findall(r"^ ([A-Za-z0-9_.-]+):\s*$", text, re.M)) - {"services", "volumes", "networks", "x-upstreams"} + mine = [c for c in containers if c["service"] in svc_names] + paths = labels_of(text) + client_paths = sorted(p for p in paths if "/" not in p); node_paths = sorted(p for p in paths if p.endswith("/node")) + # beacon REST route (base node template): rule PathPrefix(`//eth`) with stripprefix / -> URL base//eth/... + eth_paths = sorted(set(re.findall(r"PathPrefix\(`/([^`]+?)/eth`\)", text))) + base = f"{proto}://{domain}" + # execution-side container = the one whose service does not end with -node / known CL images + exec_c = next((c for c in mine if family_of(c["image"]) not in ("kona", "op-node", "beacon", "maru", "arc") and not c["service"].endswith("-node")), None) or (mine[0] if mine else None) + cl_c = next((c for c in mine if family_of(c["image"]) in ("kona", "op-node", "beacon", "maru")), None) + fam = family_of(exec_c["image"]) if exec_c else "unknown" + net = os.path.basename(node).split("-")[0] if "/" in node else node + m = re.search(r"\n\s+network:\s*([\w-]+)|chain:\s*\"?([\w-]+)", text) + network = (m.group(1) or m.group(2)) if m else net + def imgtag(c): return (c["image"].rsplit(":", 1) + [""])[:2] if c else ("", "") + client = {"name": fam, "image": imgtag(exec_c)[0], "tag": imgtag(exec_c)[1], "container": exec_c["container"] if exec_c else None, + "consensus": ({"name": family_of(cl_c["image"]), "image": imgtag(cl_c)[0], "tag": imgtag(cl_c)[1], "container": cl_c["container"]} if cl_c else None)} + url = f"{base}/{client_paths[0]}" if client_paths else None + timing = {} + R = {"version": None, "peers": None, "schedule": None, "signals": None, "head": None} + def nul(src, err): return {"source": src, "error": err} + + # ---- per-family collectors (each returns dict for one field) ---- + def v_rpc(): + if not url: return {"reported": None, "db_version": None, **nul("web3_clientVersion", "no traefik route for the client")} + ms, r, e = rpc(url, "web3_clientVersion"); timing["version"] = ms + return {"reported": r.strip().splitlines()[0][:120] if isinstance(r, str) and r.strip() else None, "db_version": None, **nul("web3_clientVersion", e)} + def p_admin(): + if not url: return {"count": None, "versions": None, "newer_than_us": None, **nul("admin_peers", "no traefik route for the client")} + ms, r, e = rpc(url, "admin_peers", timeout=10); timing["peers"] = ms + if e or not isinstance(r, list): return {"count": None, "versions": None, "newer_than_us": None, **nul("admin_peers", e or "admin namespace disabled or unexpected result")} + hist = {} + for p in r: + name = (p.get("name") or "?"); ver = name.split("/")[1] if "/" in name else name + hist[ver] = hist.get(ver, 0) + 1 + return {"count": len(r), "versions": hist, "newer_than_us": None, **nul("admin_peers", None)} + def s_nodeinfo(): + if not url: return {"upgrades": None, **nul("admin_nodeInfo", "no traefik route for the client")} + ms, r, e = rpc(url, "admin_nodeInfo"); timing["schedule"] = ms + cfg = (((r or {}).get("protocols") or {}).get("eth") or {}).get("config") if isinstance(r, dict) else None + if e or not isinstance(cfg, dict): return {"upgrades": None, **nul("admin_nodeInfo", e or "admin namespace disabled or no chain config in nodeInfo")} + ups = {} + for k, v in cfg.items(): + if k.endswith("Time") and isinstance(v, int): ups[k[:-4]] = {"time": datetime.datetime.fromtimestamp(v, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "block": None} + elif k.endswith("Block") and isinstance(v, int): ups[k[:-5]] = {"time": None, "block": v} + return {"upgrades": ups, **nul("admin_nodeInfo", None)} + def h_eth(): + if not url: return {"height": None, "timestamp": None, "syncing": None, **nul("eth_syncing+eth_getBlockByNumber", "no traefik route for the client")} + ms1, s, e1 = rpc(url, "eth_syncing"); ms2, b, e2 = rpc(url, "eth_getBlockByNumber", ["latest", False]); timing["head"] = ms1 + ms2 + h = {"height": int(b["number"], 16) if isinstance(b, dict) and b.get("number") else None, + "timestamp": datetime.datetime.fromtimestamp(int(b["timestamp"], 16), datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if isinstance(b, dict) and b.get("timestamp") else None, + "syncing": (s is not False) if (s is not None and not e1) else None} + return {**h, **nul("eth_syncing+eth_getBlockByNumber", e2 or e1)} + def avax(): + # Info API is not routed through traefik; try the client route's host with /ext/info replaced, then bridge IP + cand = [] + if url: cand.append(url.rsplit("/", 1)[0] + "/" + client_paths[0].split("/")[0] + "-info") # optional future route + try: + ip = subprocess.run(["docker", "inspect", exec_c["container"], "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}"], capture_output=True, text=True, timeout=10).stdout.split() + if ip: cand.append(f"http://{ip[0]}:9650") + except Exception: pass + info = None; used = None; err = "avalanche info API not reachable (needs /ext/info route or host->bridge access)" + for c in cand: + ms, r, e = rpc(c + "/ext/info", "info.getNodeVersion", {}); timing["version"] = ms + if not e and isinstance(r, dict): info, used = c + "/ext/info", c; break + err = e or err + if not info: + R["version"] = {"reported": None, "db_version": None, **nul("info.getNodeVersion", err)} + R["peers"] = {"count": None, "versions": None, "newer_than_us": None, **nul("info.peers", err)} + R["schedule"] = {"upgrades": None, **nul("info.upgrades", err)}; R["signals"] = None + R["head"] = h_eth(); return + def call(m): + ms, r, e = rpc(info, m, {}, timeout=10); return ms, r, e + with cf.ThreadPoolExecutor(4) as ex: + fv, fp, fu, fa = ex.submit(call, "info.getNodeVersion"), ex.submit(call, "info.peers"), ex.submit(call, "info.upgrades"), ex.submit(call, "info.acps") + ms, r, e = fv.result(); timing["version"] = ms + R["version"] = {"reported": (r or {}).get("version") if isinstance(r, dict) else None, "db_version": (r or {}).get("databaseVersion") if isinstance(r, dict) else None, + "vm_versions": (r or {}).get("vmVersions") if isinstance(r, dict) else None, **nul("info.getNodeVersion", e)} + ms, r, e = fp.result(); timing["peers"] = ms + if not e and isinstance(r, dict): + hist = {} + for p in r.get("peers") or []: hist[p.get("version") or "?"] = hist.get(p.get("version") or "?", 0) + 1 + R["peers"] = {"count": len(r.get("peers") or []), "versions": hist, "newer_than_us": None, **nul("info.peers", None)} + else: R["peers"] = {"count": None, "versions": None, "newer_than_us": None, **nul("info.peers", e or "unexpected result")} + ms, r, e = fu.result(); timing["schedule"] = ms + R["schedule"] = {"upgrades": ({k[:-4]: {"time": v, "block": None} for k, v in r.items() if k.endswith("Time")} if isinstance(r, dict) else None), **nul("info.upgrades", e)} + ms, r, e = fa.result() + R["signals"] = ({"acps": {k: {"supporters": len(v.get("supporters") or []), "objectors": len(v.get("objectors") or []), "abstainers": len(v.get("abstainers") or []), "supportWeight": v.get("supportWeight")} + for k, v in (r.get("acps") or {}).items()}, "source": "info.acps", "error": None} if not e and isinstance(r, dict) else {"acps": None, "source": "info.acps", "error": e}) + R["head"] = h_eth() + def opnode(): + nurl = f"{base}/{node_paths[0]}" if node_paths else None + C = {"version": None, "peers": None, "schedule": None, "head": None} + if not nurl: + C["peers"] = {"count": None, "versions": None, "newer_than_us": None, **nul("opp2p_peers", "no //node route")} + C["schedule"] = {"upgrades": None, **nul("optimism_rollupConfig", "no //node route")}; R["consensus"] = C; return + with cf.ThreadPoolExecutor(4) as ex: + fp, fc, fs, fv = ex.submit(rpc, nurl, "opp2p_peers", [True], 10), ex.submit(rpc, nurl, "optimism_rollupConfig"), ex.submit(rpc, nurl, "optimism_syncStatus"), ex.submit(rpc, nurl, "optimism_version") + ms, r, e = fv.result(); timing["consensus_version"] = ms + C["version"] = {"reported": r if isinstance(r, str) else None, **nul("optimism_version", e)} + ms, r, e = fp.result(); timing["consensus_peers"] = ms + if not e and isinstance(r, dict): + hist = {} + for p in (r.get("peers") or {}).values(): + ua = p.get("userAgent") or "?"; hist[ua] = hist.get(ua, 0) + 1 + C["peers"] = {"count": len(r.get("peers") or {}), "versions": hist, "newer_than_us": None, **nul("opp2p_peers", None if any(semver(k) for k in hist) else "userAgent carries no version")} + else: C["peers"] = {"count": None, "versions": None, "newer_than_us": None, **nul("opp2p_peers", e or "unexpected result")} + ms, r, e = fc.result(); timing["consensus_schedule"] = ms + C["schedule"] = {"upgrades": ({k[:-5]: {"time": datetime.datetime.fromtimestamp(v, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "block": None} + for k, v in r.items() if k.endswith("_time") and isinstance(v, int)} if isinstance(r, dict) else None), **nul("optimism_rollupConfig", e)} + ms, r, e = fs.result() + if not e and isinstance(r, dict) and isinstance(r.get("unsafe_l2"), dict): + u = r["unsafe_l2"]; C["head"] = {"height": u.get("number"), "timestamp": datetime.datetime.fromtimestamp(u["timestamp"], datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if u.get("timestamp") else None, + "syncing": None, "unsafe_minus_safe": (u.get("number") or 0) - ((r.get("safe_l2") or {}).get("number") or 0), **nul("optimism_syncStatus", None)} + else: C["head"] = {"height": None, "timestamp": None, "syncing": None, **nul("optimism_syncStatus", e or "unexpected result")} + R["consensus"] = C + def beacon(): + eurl = f"{base}/{eth_paths[0]}" if eth_paths else None # eth/v1/... is appended below + if not eurl: + R["consensus_version"] = None; return + with cf.ThreadPoolExecutor(4) as ex: + fv, fp, fs, fc = ex.submit(rest, eurl + "/eth/v1/node/version"), ex.submit(rest, eurl + "/eth/v1/node/peers", 10), ex.submit(rest, eurl + "/eth/v1/node/syncing"), ex.submit(rest, eurl + "/eth/v1/config/spec") + ms, r, e = fv.result(); timing["consensus_version"] = ms + cv = {"reported": ((r or {}).get("data") or {}).get("version") if isinstance(r, dict) else None, **nul("eth/v1/node/version", e)} + ms, r, e = fp.result() + cp = ({"count": sum(1 for x in ((r or {}).get("data") or []) if (x.get("state") or "connected") == "connected"), "versions": None, "newer_than_us": None, **nul("eth/v1/node/peers", "beacon peers API carries no agent version")} if not e and isinstance(r, dict) + else {"count": None, "versions": None, "newer_than_us": None, **nul("eth/v1/node/peers", e or "unexpected result")}) + ms, r, e = fs.result() + d = (r or {}).get("data") if isinstance(r, dict) else None + ch = ({"head_slot": int(d.get("head_slot")) if d.get("head_slot") is not None else None, "syncing": d.get("is_syncing"), **nul("eth/v1/node/syncing", None)} if isinstance(d, dict) else {"head_slot": None, "syncing": None, **nul("eth/v1/node/syncing", e)}) + ms, r, e = fc.result() + spec = (r or {}).get("data") if isinstance(r, dict) else None + cs = ({"upgrades": {k[:-11]: {"epoch": int(v)} for k, v in spec.items() if k.endswith("_FORK_EPOCH") and str(v).isdigit() and int(v) < 2**62}, **nul("eth/v1/config/spec", None)} if isinstance(spec, dict) else {"upgrades": None, **nul("eth/v1/config/spec", e)}) + R["consensus"] = {"version": cv, "peers": cp, "head": ch, "schedule": cs} + + # ---- dispatch ---- + t0 = time.time() + if fam == "avalanchego": + avax() + else: + with cf.ThreadPoolExecutor(4) as ex: + fv, fp, fs, fh = ex.submit(v_rpc), ex.submit(p_admin), ex.submit(s_nodeinfo), ex.submit(h_eth) + R["version"], R["peers"], R["schedule"], R["head"] = fv.result(), fp.result(), fs.result(), fh.result() + if fam in ("reth", "op-reth") and R["schedule"].get("upgrades") is None: + R["schedule"]["error"] = "reth exposes no chain config over RPC" + if fam in ("nitro", "zksync-en", "cometbft", "maru", "arc", "unknown") and R["peers"].get("count") is None: + R["peers"]["error"] = f"no peer API via RPC for {fam}" + if node_paths and (cl_c is None or family_of(cl_c["image"]) in ("kona", "op-node")): opnode() + if eth_paths: beacon() + # newer_than_us + mine_v = semver((R["version"] or {}).get("reported")); hist = (R["peers"] or {}).get("versions") + if mine_v and hist: + R["peers"]["newer_than_us"] = sum(n for v, n in hist.items() if (semver(v) or (0,)) > mine_v) + doc = {"schema": 1, "at": NOW(), "host": socket.gethostname().split(".")[0], "node_path": node, "network": network, "client": client, + "version": R["version"], "peers": R["peers"], "schedule": R["schedule"], "signals": R.get("signals"), "head": R["head"], + "consensus": R.get("consensus"), "timing_ms": {**timing, "total": int((time.time() - t0) * 1000)}} + return doc + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("--")]; pretty = "--pretty" in sys.argv + e = env() + if not e or not e.get("COMPOSE_FILE"): + print(json.dumps({"schema": 1, "at": NOW(), "host": None, "node_path": None, "error": "cannot read .env or COMPOSE_FILE empty"})); sys.exit(2) + nodes = args or [f[:-4] for f in e["COMPOSE_FILE"].split(":") if f.endswith(".yml") and os.path.basename(f)[:-4] not in INFRA and not os.path.basename(f).startswith(("drpc", "squidfura", "oracle"))] + try: containers = docker_ps() if not FIXTURES else json.load(open(os.path.join(FIXTURES, "docker_ps.json"))) + except Exception: containers = [] + with cf.ThreadPoolExecutor(int(os.environ.get("MAX_WORKERS", "8"))) as ex: + for doc in ex.map(lambda n: node_facts(e, n, containers), nodes): + print(json.dumps(doc, indent=2 if pretty else None), flush=True) + + +if __name__ == "__main__": + main() diff --git a/test-node-facts.sh b/test-node-facts.sh index cff9cc07..a553c424 100755 --- a/test-node-facts.sh +++ b/test-node-facts.sh @@ -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 \ No newline at end of file +# 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; }