Files
bootnode/cmd/fleet/main.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

75 lines
2.1 KiB
Go

// fleet serves the read-only luxd fleet overview the lux.cloud dashboard polls.
//
// It runs inside the cluster that hosts the fleet, reads every luxd pod over
// that node's own /v1 HTTP RPC, and answers one document:
//
// GET /health liveness
// GET /v1/fleets/lux/overview the fleet, optionally ?networks=a,b
//
// The response is public chain state — node IDs, versions, heights, peer
// counts — so it carries no auth and no secrets: the lux.cloud landing page
// renders it for signed-out visitors.
package main
import (
"os"
"strings"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
"github.com/hanzoai/bootnode/fleet"
)
func main() {
log := luxlog.New("module", "fleet")
cluster, err := fleet.NewInCluster()
if err != nil {
log.Error("cluster unreachable", "err", err)
os.Exit(1)
}
app := newApp(log, &fleet.Collector{Cluster: cluster, Prober: fleet.NewRPC(), Markets: fleet.NewGraph()})
addr := os.Getenv("FLEET_ADDR")
if addr == "" {
addr = ":8000"
}
log.Info("lux fleet listening", "addr", addr)
if err := app.Listen(addr); err != nil {
log.Error("serve failed", "err", err)
os.Exit(1)
}
}
// newApp is the whole HTTP surface: two routes, one of them a Group.
func newApp(log luxlog.Logger, c *fleet.Collector) *zip.App {
app := zip.New(zip.Config{Logger: log, AppName: "lux-fleet"})
app.Use(
middleware.Recover(),
middleware.RequestID(),
middleware.CORS(middleware.CORSConfig{AllowMethods: []string{"GET", "OPTIONS"}}),
)
app.Get("/health", func(ctx *zip.Ctx) error {
return ctx.JSON(200, map[string]any{"status": "ok", "k8s_enrichment": fleet.Available()})
})
app.Group("/v1/fleets/lux").Get("/overview", func(ctx *zip.Ctx) error {
return ctx.JSON(200, c.Overview(ctx.Context(), networks(ctx.Query("networks"))))
})
return app
}
// networks parses the comma-separated ?networks= filter; empty means all.
func networks(q string) []string {
var out []string
for _, n := range strings.Split(q, ",") {
if n = strings.TrimSpace(n); n != "" {
out = append(out, n)
}
}
return out
}