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]>
309 lines
8.7 KiB
Go
309 lines
8.7 KiB
Go
// Package fleet observes the managed luxd fleet read-only and renders it as
|
|
// the document the lux.cloud dashboard polls.
|
|
//
|
|
// One concern per file:
|
|
//
|
|
// fleet.go the wire document and how a network rolls up into it
|
|
// luxd.go talking to one luxd over its /v1 HTTP RPC
|
|
// cluster.go reading the Kubernetes facts (which pods, what the operator declared)
|
|
//
|
|
// The collector runs INSIDE the cluster that hosts the fleet, so a node's dial
|
|
// address is its pod IP — there is no endpoint file to go stale. Its public
|
|
// address is whatever the node itself advertises (info.getNodeIP), which is the
|
|
// only source that cannot drift from reality.
|
|
package fleet
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Networks is the managed set, in display order. Ports and IDs mirror the
|
|
// luxd StatefulSet in each namespace.
|
|
var Networks = []Network{
|
|
{Name: "mainnet", Namespace: "lux-mainnet", HTTPPort: 9630, NetworkID: 1, ChainID: 96369},
|
|
{Name: "testnet", Namespace: "lux-testnet", HTTPPort: 9640, NetworkID: 2, ChainID: 96368},
|
|
{Name: "devnet", Namespace: "lux-devnet", HTTPPort: 9650, NetworkID: 3, ChainID: 96370},
|
|
}
|
|
|
|
// Network is one managed luxd network.
|
|
type Network struct {
|
|
Name string
|
|
Namespace string
|
|
HTTPPort int
|
|
NetworkID int
|
|
ChainID int
|
|
}
|
|
|
|
// TrackedChains are the luxd chains the dashboard shows, in display order.
|
|
var TrackedChains = []string{"C", "P", "X", "D"}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wire document. Field names and null-vs-absent are the contract with
|
|
// console/lib/fleet.ts — every field is always present, absent data is null.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ChainHeight is one chain's bootstrap state and height on one node.
|
|
type ChainHeight struct {
|
|
Chain string `json:"chain"`
|
|
Bootstrapped *bool `json:"bootstrapped"`
|
|
Height *int64 `json:"height"`
|
|
}
|
|
|
|
// Node is one luxd validator pod.
|
|
type Node struct {
|
|
PodName string `json:"pod_name"`
|
|
PodIndex int `json:"pod_index"`
|
|
Phase string `json:"phase"`
|
|
Ready bool `json:"ready"`
|
|
IP *string `json:"ip"`
|
|
RPCReachable bool `json:"rpc_reachable"`
|
|
NodeID *string `json:"node_id"`
|
|
Version *string `json:"version"`
|
|
GitCommit *string `json:"git_commit"`
|
|
DBVersion *string `json:"db_version"`
|
|
Peers *int `json:"peers"`
|
|
Healthy *bool `json:"healthy"`
|
|
Chains []ChainHeight `json:"chains"`
|
|
}
|
|
|
|
// Dex is the native D-Chain venue summary for a network.
|
|
type Dex struct {
|
|
DChainBootstrapped *bool `json:"d_chain_bootstrapped"`
|
|
Venue string `json:"venue"`
|
|
Markets *int `json:"markets"`
|
|
Note *string `json:"note"`
|
|
}
|
|
|
|
// NetworkFleet is one network's rollup.
|
|
//
|
|
// image is what the operator DECLARES (the LuxNetwork CR tag); version is what
|
|
// the nodes actually REPORT. They diverge whenever the CR is stale, and the
|
|
// dashboard must show the running truth, so observation wins for version.
|
|
type NetworkFleet struct {
|
|
Network string `json:"network"`
|
|
Namespace string `json:"namespace"`
|
|
NetworkID *int `json:"network_id"`
|
|
ChainID *int `json:"chain_id"`
|
|
Phase string `json:"phase"`
|
|
Image *string `json:"image"`
|
|
Version *string `json:"version"`
|
|
PluginSet *string `json:"plugin_set"`
|
|
VMPlugins []string `json:"vm_plugins"`
|
|
Validators int `json:"validators"`
|
|
NodesTotal int `json:"nodes_total"`
|
|
NodesReady int `json:"nodes_ready"`
|
|
NodesRPCOK int `json:"nodes_rpc_ok"`
|
|
TotalPeers int `json:"total_peers"`
|
|
Dex Dex `json:"dex"`
|
|
Nodes []Node `json:"nodes"`
|
|
Error *string `json:"error"`
|
|
}
|
|
|
|
// Overview is the single read the dashboard polls.
|
|
type Overview struct {
|
|
Cluster string `json:"cluster"`
|
|
Networks []NetworkFleet `json:"networks"`
|
|
Totals map[string]int `json:"totals"`
|
|
GeneratedAt float64 `json:"generated_at"`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Collector
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Collector assembles the overview from a Cluster and a Prober. Both are
|
|
// interfaces so the rollup can be tested without a cluster or a chain.
|
|
type Collector struct {
|
|
Cluster Cluster
|
|
Prober Prober
|
|
Markets Markets // may be nil; markets then stays null
|
|
}
|
|
|
|
// Overview collects every named network, or all of them when names is empty.
|
|
func (c *Collector) Overview(ctx context.Context, names []string) Overview {
|
|
selected := selectNetworks(names)
|
|
out := Overview{
|
|
Cluster: "lux-k8s",
|
|
Networks: make([]NetworkFleet, len(selected)),
|
|
GeneratedAt: float64(time.Now().UnixNano()) / 1e9,
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for i, n := range selected {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
out.Networks[i] = c.network(ctx, n)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
totals := map[string]int{"networks": len(out.Networks)}
|
|
for _, f := range out.Networks {
|
|
totals["validators"] += f.Validators
|
|
totals["nodes_total"] += f.NodesTotal
|
|
totals["nodes_ready"] += f.NodesReady
|
|
totals["nodes_rpc_ok"] += f.NodesRPCOK
|
|
totals["total_peers"] += f.TotalPeers
|
|
}
|
|
out.Totals = totals
|
|
return out
|
|
}
|
|
|
|
func selectNetworks(names []string) []Network {
|
|
if len(names) == 0 {
|
|
return Networks
|
|
}
|
|
var out []Network
|
|
for _, want := range names {
|
|
for _, n := range Networks {
|
|
if n.Name == want {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (c *Collector) network(ctx context.Context, n Network) NetworkFleet {
|
|
f := NetworkFleet{
|
|
Network: n.Name,
|
|
Namespace: n.Namespace,
|
|
NetworkID: ptr(n.NetworkID),
|
|
ChainID: ptr(n.ChainID),
|
|
Phase: "Unknown",
|
|
VMPlugins: []string{},
|
|
Nodes: []Node{},
|
|
Dex: Dex{Venue: "native-dchain"},
|
|
}
|
|
|
|
pods, podErr := c.Cluster.Pods(ctx, n.Namespace)
|
|
if declared, err := c.Cluster.Declared(ctx, n.Namespace); err == nil {
|
|
f.Phase = declared.Phase
|
|
f.Validators = declared.Validators
|
|
f.Image = strPtr(declared.Image)
|
|
f.PluginSet = strPtr(declared.PluginSet)
|
|
f.VMPlugins = declared.VMPlugins
|
|
}
|
|
|
|
sort.Slice(pods, func(i, j int) bool { return pods[i].Index < pods[j].Index })
|
|
f.Nodes = make([]Node, len(pods))
|
|
var wg sync.WaitGroup
|
|
for i, p := range pods {
|
|
f.Nodes[i] = Node{
|
|
PodName: p.Name, PodIndex: p.Index, Phase: p.Phase, Ready: p.Ready,
|
|
Chains: emptyChains(),
|
|
}
|
|
if p.IP == "" {
|
|
continue
|
|
}
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
c.Prober.Probe(ctx, fmt.Sprintf("http://%s:%d", p.IP, n.HTTPPort), &f.Nodes[i])
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
for i := range f.Nodes {
|
|
if f.Nodes[i].Ready {
|
|
f.NodesReady++
|
|
}
|
|
if f.Nodes[i].RPCReachable {
|
|
f.NodesRPCOK++
|
|
}
|
|
if f.Nodes[i].Peers != nil {
|
|
f.TotalPeers += *f.Nodes[i].Peers
|
|
}
|
|
}
|
|
f.NodesTotal = len(f.Nodes)
|
|
if f.Validators == 0 {
|
|
f.Validators = f.NodesTotal
|
|
}
|
|
if v := observedVersion(f.Nodes); v != "" {
|
|
f.Version = &v
|
|
} else if f.Image != nil {
|
|
f.Version = strPtr(imageTag(*f.Image))
|
|
}
|
|
if f.Phase == "Unknown" && f.NodesRPCOK > 0 {
|
|
f.Phase = "Running"
|
|
}
|
|
f.Dex = c.dex(ctx, n.Name, f.Nodes)
|
|
switch {
|
|
case podErr != nil:
|
|
f.Error = strPtr("cluster read failed: " + podErr.Error())
|
|
case f.NodesTotal == 0:
|
|
f.Error = strPtr("no luxd pods found in " + n.Namespace)
|
|
}
|
|
return f
|
|
}
|
|
|
|
// dex rolls the D-Chain bootstrap state up across nodes: true if any node has
|
|
// it, false if every reporting node says no, null if nothing reported.
|
|
func (c *Collector) dex(ctx context.Context, network string, nodes []Node) Dex {
|
|
d := Dex{Venue: "native-dchain"}
|
|
for _, n := range nodes {
|
|
for _, ch := range n.Chains {
|
|
if ch.Chain != "D" || ch.Bootstrapped == nil {
|
|
continue
|
|
}
|
|
if d.DChainBootstrapped == nil || !*d.DChainBootstrapped {
|
|
d.DChainBootstrapped = ch.Bootstrapped
|
|
}
|
|
}
|
|
}
|
|
if c.Markets != nil {
|
|
if m, ok := c.Markets.Count(ctx, network); ok {
|
|
d.Markets = &m
|
|
}
|
|
}
|
|
if d.DChainBootstrapped != nil && !*d.DChainBootstrapped {
|
|
d.Note = strPtr("D-Chain not yet bootstrapped")
|
|
}
|
|
return d
|
|
}
|
|
|
|
func emptyChains() []ChainHeight {
|
|
out := make([]ChainHeight, len(TrackedChains))
|
|
for i, c := range TrackedChains {
|
|
out[i] = ChainHeight{Chain: c}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// observedVersion is the version the running fleet reports — the lowest-index
|
|
// node that answers wins, so the value is stable across polls.
|
|
func observedVersion(nodes []Node) string {
|
|
for _, n := range nodes {
|
|
if n.Version != nil && *n.Version != "" {
|
|
return *n.Version
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func imageTag(image string) string {
|
|
for i := len(image) - 1; i >= 0; i-- {
|
|
if image[i] == ':' {
|
|
return image[i+1:]
|
|
}
|
|
if image[i] == '/' {
|
|
break
|
|
}
|
|
}
|
|
return image
|
|
}
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
func strPtr(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|