mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
The lux.cloud dashboard was blank because the collector could not reach a
single validator. Two independent breaks, either one fatal:
1. It called /ext. luxd 1.36 serves /v1 only — /ext/info is 404 — and the
collector's _rpc() maps any non-200 to None, so every field came back
null with no error anywhere.
2. It dialled 15 hardcoded public LoadBalancer IPs. There are now zero
LoadBalancer Services in lux-mainnet/testnet/devnet; those IPs do not
answer at all (connect timeout, not 404).
Both are the same root defect: the observer was outside the thing it observes
and had to be told where to look. So the observer moves in. Running in lux-k8s,
a validator's address is its pod IP, read from the pod list on every
collection — there is nothing left to configure and nothing left to go stale.
Chain facts come from the node itself over /v1: info.getNodeVersion,
getNodeID, getNodeIP, peers, isBootstrapped per chain, eth_blockNumber at
/v1/bc/C/rpc (the bc/ segment is load-bearing; /v1/C/rpc is not a route) and
platform.getHeight at /v1/bc/P, which quotes its integer.
That also collapses the MODE A / MODE B split: there is one path, not two.
Two corrections to what the old collector reported:
version is now what the fleet RUNS, not what the CR declares. The mainnet
LuxNetwork CR asks for ghcr.io/luxfi/node:v1.34.0, a tag that does not
exist; the nodes are on luxd/1.36.2. The dashboard must show the running
truth, so observation wins and image keeps the declared value.
external_ip becomes ip, matching console/lib/fleet.ts, and carries the
address the node advertises for itself (info.getNodeIP) rather than an
address someone wrote down.
Degraded is reported as degraded. A node that does not answer keeps every
field null and counts against nodes_rpc_ok; it is never smoothed over.
Placement: this stays in hanzoai/bootnode rather than folding into
hanzoai/cloud. cloud is not deployed on lux-k8s, and standing the whole
monolith up inside the chain cluster to run a read-only collector is a far
larger change than the collector itself. The surface is registered with zip's
Group; the route set is unchanged.
Deploy: Deployment/Service/RBAC in lux-k8s (lux-fleet.yaml, read-only —
list/get on pods and luxnetworks, no pods/proxy, no exec). The public URL
resolves to bootnode-k8s, so that cluster forwards just /v1/fleets/lux across
(lux-fleet-bridge.yaml); no other path on the host is touched.
Tests: a luxd stub that 404s anything outside /v1 and quotes platform height;
assertions that every probe path is under /v1, that no wire key is ever
omitted (an absent key reads as undefined and blanks a cell), that a down node
stays null, and that /v1/fleets/lux/zzzz is 404 next to a 200 on /overview.
Co-authored-by: Hanzo Dev <[email protected]>
214 lines
5.5 KiB
Go
214 lines
5.5 KiB
Go
package fleet
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Prober fills in one node's live self-report. It never fails: a node that
|
|
// does not answer is a node whose fields stay null, which is the truth the
|
|
// dashboard has to render.
|
|
type Prober interface {
|
|
Probe(ctx context.Context, base string, n *Node)
|
|
}
|
|
|
|
// RPC talks to luxd over its /v1 HTTP surface.
|
|
//
|
|
// /v1 is the only surface luxd serves — /ext was removed, and the collector
|
|
// that still called it read 404 on every request and reported a fleet of
|
|
// zeros. Chain RPC lives under /v1/bc/<chain>, so the C-Chain is
|
|
// /v1/bc/C/rpc (never /v1/C/rpc, which is not a route).
|
|
type RPC struct{ http *http.Client }
|
|
|
|
// NewRPC returns a prober with luxd-appropriate timeouts: a node that is wedged
|
|
// must not hold the whole overview hostage.
|
|
func NewRPC() *RPC {
|
|
return &RPC{http: &http.Client{
|
|
Timeout: 6 * time.Second,
|
|
Transport: &http.Transport{DialContext: (&net.Dialer{Timeout: 2500 * time.Millisecond}).DialContext},
|
|
}}
|
|
}
|
|
|
|
// call posts one JSON-RPC request and decodes result into out.
|
|
func (r *RPC) call(ctx context.Context, base, path, method string, params, out any) error {
|
|
body, err := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0", "id": 1, "method": method, "params": params,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := r.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("%s %s: HTTP %d", method, path, resp.StatusCode)
|
|
}
|
|
var env struct {
|
|
Result json.RawMessage `json:"result"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
|
return err
|
|
}
|
|
if env.Error != nil {
|
|
return fmt.Errorf("%s: %s", method, env.Error.Message)
|
|
}
|
|
if out == nil || len(env.Result) == 0 {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(env.Result, out)
|
|
}
|
|
|
|
// Probe fills n from the node at base. Every call is independent and
|
|
// best-effort: a partial answer is still worth rendering.
|
|
func (r *RPC) Probe(ctx context.Context, base string, n *Node) {
|
|
const info = "/v1/info"
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
ver struct{ Version, GitCommit, DatabaseVersion string }
|
|
nid struct{ NodeID string }
|
|
nip struct{ IP string }
|
|
peer struct {
|
|
Peers []json.RawMessage `json:"peers"`
|
|
}
|
|
health *bool
|
|
boot = make([]*bool, len(TrackedChains))
|
|
cHeight *int64
|
|
pHeight *int64
|
|
verOK bool
|
|
nidOK bool
|
|
nipOK bool
|
|
peerOK bool
|
|
)
|
|
|
|
run := func(fn func()) { wg.Add(1); go func() { defer wg.Done(); fn() }() }
|
|
|
|
run(func() { verOK = r.call(ctx, base, info, "info.getNodeVersion", nil, &ver) == nil })
|
|
run(func() { nidOK = r.call(ctx, base, info, "info.getNodeID", nil, &nid) == nil })
|
|
run(func() { nipOK = r.call(ctx, base, info, "info.getNodeIP", nil, &nip) == nil })
|
|
run(func() { peerOK = r.call(ctx, base, info, "info.peers", map[string]any{}, &peer) == nil })
|
|
run(func() { health = r.health(ctx, base) })
|
|
|
|
for i, chain := range TrackedChains {
|
|
run(func() {
|
|
var res struct {
|
|
IsBootstrapped bool `json:"isBootstrapped"`
|
|
}
|
|
if r.call(ctx, base, info, "info.isBootstrapped", map[string]any{"chain": chain}, &res) == nil {
|
|
boot[i] = &res.IsBootstrapped
|
|
}
|
|
})
|
|
}
|
|
run(func() {
|
|
var hex string
|
|
if r.call(ctx, base, "/v1/bc/C/rpc", "eth_blockNumber", []any{}, &hex) != nil {
|
|
return
|
|
}
|
|
if v, err := strconv.ParseInt(strings.TrimPrefix(hex, "0x"), 16, 64); err == nil {
|
|
cHeight = &v
|
|
}
|
|
})
|
|
run(func() {
|
|
var res struct {
|
|
Height numeric `json:"height"`
|
|
}
|
|
if r.call(ctx, base, "/v1/bc/P", "platform.getHeight", nil, &res) != nil {
|
|
return
|
|
}
|
|
v := int64(res.Height)
|
|
pHeight = &v
|
|
})
|
|
wg.Wait()
|
|
|
|
if verOK && ver.Version != "" {
|
|
n.RPCReachable = true
|
|
n.Version = strPtr(ver.Version)
|
|
n.GitCommit = strPtr(ver.GitCommit)
|
|
n.DBVersion = strPtr(ver.DatabaseVersion)
|
|
}
|
|
if nidOK {
|
|
n.NodeID = strPtr(nid.NodeID)
|
|
}
|
|
if nipOK {
|
|
n.IP = strPtr(hostOnly(nip.IP))
|
|
}
|
|
if peerOK {
|
|
n.Peers = ptr(len(peer.Peers))
|
|
}
|
|
n.Healthy = health
|
|
|
|
for i, chain := range TrackedChains {
|
|
ch := ChainHeight{Chain: chain, Bootstrapped: boot[i]}
|
|
switch chain {
|
|
case "C":
|
|
ch.Height = cHeight
|
|
case "P":
|
|
ch.Height = pHeight
|
|
}
|
|
n.Chains[i] = ch
|
|
}
|
|
}
|
|
|
|
// health reads luxd's own health verdict. Some builds fail to encode the full
|
|
// reply and answer 503 with a valid body, so 503 is read, not discarded; any
|
|
// other outcome is unknown rather than unhealthy.
|
|
func (r *RPC) health(ctx context.Context, base string) *bool {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/v1/health", nil)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
resp, err := r.http.Do(req)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusServiceUnavailable {
|
|
return nil
|
|
}
|
|
var res struct {
|
|
Healthy *bool `json:"healthy"`
|
|
}
|
|
if json.NewDecoder(resp.Body).Decode(&res) != nil {
|
|
return nil
|
|
}
|
|
return res.Healthy
|
|
}
|
|
|
|
func hostOnly(addr string) string {
|
|
if host, _, err := net.SplitHostPort(addr); err == nil {
|
|
return host
|
|
}
|
|
return addr
|
|
}
|
|
|
|
// numeric accepts a JSON number or a decimal string — luxd quotes some
|
|
// integers (platform.getHeight) and not others.
|
|
type numeric int64
|
|
|
|
func (n *numeric) UnmarshalJSON(b []byte) error {
|
|
v, err := strconv.ParseInt(string(bytes.Trim(b, `"`)), 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*n = numeric(v)
|
|
return nil
|
|
}
|