Files
bootnode/fleet/luxd.go
T
zeekayandHanzo Dev 734997e46c feat(fleet): the Lux fleet API is Go, and it runs where the fleet is
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]>
2026-07-27 19:31:06 -07:00

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
}