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]>
This commit is contained in:
2026-07-27 19:31:06 -07:00
co-authored by Hanzo Dev
parent 55aadd328c
commit 734997e46c
13 changed files with 1543 additions and 71 deletions
+18
View File
@@ -0,0 +1,18 @@
# syntax=docker/dockerfile:1.7
# lux-fleet — the read-only luxd fleet overview, pure Go, no Python runtime.
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY fleet/ ./fleet/
COPY cmd/fleet/ ./cmd/fleet/
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" -o /out/fleet ./cmd/fleet
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /out/fleet /usr/local/bin/fleet
EXPOSE 8000
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/fleet"]
+74
View File
@@ -0,0 +1,74 @@
// 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
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
luxlog "github.com/luxfi/log"
"github.com/hanzoai/bootnode/fleet"
)
// emptyCluster answers with no pods — the routing surface is what is under test.
type emptyCluster struct{}
func (emptyCluster) Pods(context.Context, string) ([]fleet.Pod, error) { return nil, nil }
func (emptyCluster) Declared(context.Context, string) (fleet.Declared, error) {
return fleet.Declared{}, http.ErrNoLocation
}
type noProbe struct{}
func (noProbe) Probe(context.Context, string, *fleet.Node) {}
// get drives the real router the binary serves.
func get(t *testing.T, path string) *http.Response {
t.Helper()
app := newApp(luxlog.New("module", "fleet-test"),
&fleet.Collector{Cluster: emptyCluster{}, Prober: noProbe{}})
resp, err := app.Fiber().Test(httptest.NewRequest(http.MethodGet, path, nil))
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
return resp
}
// The overview answers, and a sibling path under the same Group 404s — without
// that control a 200 proves nothing about which route served it.
func TestRoutesAndNotFoundControl(t *testing.T) {
for path, want := range map[string]int{
"/health": 200,
"/v1/fleets/lux/overview": 200,
"/v1/fleets/lux/zzzz": 404,
"/ext/fleets/lux/overview": 404,
"/v1/fleets/lux": 404,
"/api/v1/fleets/lux/overview": 404,
} {
if got := get(t, path).StatusCode; got != want {
t.Errorf("GET %s = %d, want %d", path, got, want)
}
}
}
func TestOverviewEnvelopeShape(t *testing.T) {
resp := get(t, "/v1/fleets/lux/overview?networks=devnet")
if resp.StatusCode != 200 {
t.Fatalf("HTTP %d", resp.StatusCode)
}
doc := decode(t, resp)
for _, k := range []string{"cluster", "networks", "totals", "generated_at"} {
if _, ok := doc[k]; !ok {
t.Errorf("%s is absent", k)
}
}
nets := doc["networks"].([]any)
if len(nets) != 1 || nets[0].(map[string]any)["network"] != "devnet" {
t.Errorf("?networks=devnet returned %d networks", len(nets))
}
}
func TestHealthShape(t *testing.T) {
doc := decode(t, get(t, "/health"))
if doc["status"] != "ok" {
t.Errorf("status = %v", doc["status"])
}
if _, ok := doc["k8s_enrichment"]; !ok {
t.Error("k8s_enrichment is absent")
}
}
func TestNetworksFilterParsing(t *testing.T) {
for in, want := range map[string]int{"": 0, "mainnet": 1, " mainnet , testnet ": 2, ",,": 0} {
if got := len(networks(in)); got != want {
t.Errorf("networks(%q) -> %d, want %d", in, got, want)
}
}
}
func decode(t *testing.T, resp *http.Response) map[string]any {
t.Helper()
defer resp.Body.Close()
var doc map[string]any
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
t.Fatal(err)
}
return doc
}
+200
View File
@@ -0,0 +1,200 @@
package fleet
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// Cluster is the read-only Kubernetes view the collector needs: which luxd
// pods exist and what the operator declared for the network.
type Cluster interface {
Pods(ctx context.Context, namespace string) ([]Pod, error)
Declared(ctx context.Context, namespace string) (Declared, error)
}
// Pod is one luxd StatefulSet member.
type Pod struct {
Name string
Index int
Phase string
Ready bool
IP string
}
// Declared is the shape the LuxNetwork operator CR asks for. It is an
// intention, not an observation — the CR routinely lags the running fleet.
type Declared struct {
Phase string
Validators int
Image string
PluginSet string
VMPlugins []string
}
const (
saDir = "/var/run/secrets/kubernetes.io/serviceaccount"
crdPath = "/apis/lux.cloud/v1/namespaces/%s/luxnetworks/luxd"
podsPath = "/api/v1/namespaces/%s/pods"
podPrefix = "luxd-"
)
// InCluster reads the API server with the pod's own ServiceAccount. It is the
// whole Kubernetes dependency: two GETs, no client-go.
type InCluster struct {
base string
http *http.Client
}
// Available reports whether a ServiceAccount is projected into this pod.
func Available() bool {
_, err := os.Stat(saDir + "/token")
return err == nil
}
// NewInCluster builds the API-server reader from the projected ServiceAccount.
func NewInCluster() (*InCluster, error) {
host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")
if host == "" || port == "" {
return nil, fmt.Errorf("not running in a cluster: KUBERNETES_SERVICE_HOST unset")
}
ca, err := os.ReadFile(saDir + "/ca.crt")
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(ca) {
return nil, fmt.Errorf("serviceaccount ca.crt is not a PEM bundle")
}
return &InCluster{
base: "https://" + host + ":" + port,
http: &http.Client{
Timeout: 8 * time.Second,
Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}},
},
}, nil
}
// get decodes one API-server object into out. The token is re-read per call
// because projected ServiceAccount tokens rotate while the process runs.
func (k *InCluster) get(ctx context.Context, path string, out any) error {
token, err := os.ReadFile(saDir + "/token")
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, k.base+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token)))
req.Header.Set("Accept", "application/json")
resp, err := k.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: HTTP %d", path, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// Pods lists the luxd-N pods in a namespace with the facts needed to dial and
// classify them.
func (k *InCluster) Pods(ctx context.Context, namespace string) ([]Pod, error) {
var list struct {
Items []struct {
Metadata struct {
Name string `json:"name"`
} `json:"metadata"`
Status struct {
Phase string `json:"phase"`
PodIP string `json:"podIP"`
Conditions []struct {
Type string `json:"type"`
Status string `json:"status"`
} `json:"conditions"`
} `json:"status"`
} `json:"items"`
}
if err := k.get(ctx, fmt.Sprintf(podsPath, namespace), &list); err != nil {
return nil, err
}
var out []Pod
for _, it := range list.Items {
name := it.Metadata.Name
if !strings.HasPrefix(name, podPrefix) {
continue
}
idx, err := strconv.Atoi(name[strings.LastIndex(name, "-")+1:])
if err != nil {
continue
}
p := Pod{Name: name, Index: idx, Phase: it.Status.Phase, IP: it.Status.PodIP}
if p.Phase == "" {
p.Phase = "Unknown"
}
for _, c := range it.Status.Conditions {
if c.Type == "Ready" {
p.Ready = c.Status == "True"
}
}
out = append(out, p)
}
return out, nil
}
// Declared reads the LuxNetwork CR for a namespace.
func (k *InCluster) Declared(ctx context.Context, namespace string) (Declared, error) {
var cr struct {
Spec struct {
Validators int `json:"validators"`
Image struct {
Repository string `json:"repository"`
Tag string `json:"tag"`
} `json:"image"`
PluginSource struct {
Bucket string `json:"bucket"`
VMPlugins []struct {
VMID string `json:"vmID"`
} `json:"vmPlugins"`
} `json:"pluginSource"`
} `json:"spec"`
Status struct {
Phase string `json:"phase"`
} `json:"status"`
}
if err := k.get(ctx, fmt.Sprintf(crdPath, namespace), &cr); err != nil {
return Declared{}, err
}
d := Declared{
Phase: cr.Status.Phase,
Validators: cr.Spec.Validators,
PluginSet: cr.Spec.PluginSource.Bucket,
VMPlugins: []string{},
}
if d.Phase == "" {
d.Phase = "Unknown"
}
switch repo, tag := cr.Spec.Image.Repository, cr.Spec.Image.Tag; {
case repo != "" && tag != "":
d.Image = repo + ":" + tag
case tag != "":
d.Image = tag
default:
d.Image = repo
}
for _, p := range cr.Spec.PluginSource.VMPlugins {
if p.VMID != "" {
d.VMPlugins = append(d.VMPlugins, p.VMID)
}
}
return d, nil
}
+308
View File
@@ -0,0 +1,308 @@
// 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
}
+334
View File
@@ -0,0 +1,334 @@
package fleet
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// fakeCluster stands in for the Kubernetes API server.
type fakeCluster struct {
pods map[string][]Pod
declared map[string]Declared
}
func (f fakeCluster) Pods(_ context.Context, ns string) ([]Pod, error) { return f.pods[ns], nil }
func (f fakeCluster) Declared(_ context.Context, ns string) (Declared, error) {
d, ok := f.declared[ns]
if !ok {
return Declared{}, http.ErrNoLocation
}
return d, nil
}
// luxdStub speaks exactly what luxd 1.36 speaks: /v1 answers, /ext is gone.
// paths records every path asked for, so a test can prove /ext is never tried.
type luxdStub struct {
mu sync.Mutex
paths []string
cHeight string
boot map[string]bool
peers int
}
func newLuxd() *luxdStub {
return &luxdStub{cHeight: "0x10c1cf", peers: 2,
boot: map[string]bool{"C": true, "P": true, "X": true, "D": false}}
}
func (s *luxdStub) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.paths = append(s.paths, r.URL.Path)
s.mu.Unlock()
if !strings.HasPrefix(r.URL.Path, "/v1") {
http.NotFound(w, r) // /ext was removed in luxd 1.36
return
}
if r.URL.Path == "/v1/health" {
write(w, `{"healthy":true,"error":"health reply encode failed"}`)
return
}
var req struct {
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
switch req.Method {
case "info.getNodeVersion":
write(w, `{"jsonrpc":"2.0","id":1,"result":{"version":"luxd/1.36.2","databaseVersion":"v1.4.5","gitCommit":"132fa688"}}`)
case "info.getNodeID":
write(w, `{"jsonrpc":"2.0","id":1,"result":{"nodeID":"NodeID-Mf3JfSY91oDwfBqf7rCLmhg4NDtDghw1f"}}`)
case "info.getNodeIP":
write(w, `{"jsonrpc":"2.0","id":1,"result":{"ip":"129.212.164.46:9631"}}`)
case "info.peers":
write(w, `{"jsonrpc":"2.0","id":1,"result":{"numPeers":"2","peers":[{"nodeID":"a"},{"nodeID":"b"}]}}`)
case "info.isBootstrapped":
var p struct{ Chain string }
_ = json.Unmarshal(req.Params, &p)
write(w, `{"jsonrpc":"2.0","id":1,"result":{"isBootstrapped":`+boolJSON(s.boot[p.Chain])+`}}`)
case "eth_blockNumber":
write(w, `{"jsonrpc":"2.0","id":1,"result":"`+s.cHeight+`"}`)
case "platform.getHeight":
// luxd quotes this integer; a decoder that only accepts numbers drops it.
write(w, `{"jsonrpc":"2.0","id":1,"result":{"height":"1098191"}}`)
default:
http.Error(w, "unknown method", http.StatusNotFound)
}
})
}
func write(w http.ResponseWriter, s string) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(s))
}
func boolJSON(b bool) string {
if b {
return "true"
}
return "false"
}
// collectOne runs the collector against one stub node standing in for luxd-0.
func collectOne(t *testing.T, stub *luxdStub, extra ...Pod) NetworkFleet {
t.Helper()
srv := httptest.NewServer(stub.handler())
t.Cleanup(srv.Close)
host := strings.TrimPrefix(srv.URL, "http://")
pods := append([]Pod{{Name: "luxd-0", Index: 0, Phase: "Running", Ready: true, IP: host}}, extra...)
c := &Collector{
Cluster: fakeCluster{
pods: map[string][]Pod{"lux-mainnet": pods},
declared: map[string]Declared{"lux-mainnet": {Phase: "Unknown", Validators: 5, Image: "ghcr.io/luxfi/node:v1.34.0", PluginSet: "s3://lux-plugins/v1.1.0/", VMPlugins: []string{"dexvm"}}},
},
Prober: portless{NewRPC()},
}
ov := c.Overview(context.Background(), []string{"mainnet"})
if len(ov.Networks) != 1 {
t.Fatalf("want 1 network, got %d", len(ov.Networks))
}
return ov.Networks[0]
}
// portless dials the stub's host:port verbatim instead of appending luxd's port.
type portless struct{ p Prober }
func (d portless) Probe(ctx context.Context, base string, n *Node) {
d.p.Probe(ctx, base[:strings.LastIndex(base, ":")], n)
}
func TestOverviewReportsLiveNode(t *testing.T) {
f := collectOne(t, newLuxd())
if f.NodesRPCOK != 1 {
t.Errorf("nodes_rpc_ok = %d, want 1", f.NodesRPCOK)
}
if f.NodesReady != 1 || f.NodesTotal != 1 {
t.Errorf("nodes_ready/total = %d/%d, want 1/1", f.NodesReady, f.NodesTotal)
}
if f.TotalPeers != 2 {
t.Errorf("total_peers = %d, want 2", f.TotalPeers)
}
// The CR declares v1.34.0; the fleet runs 1.36.2. Observation wins.
if f.Version == nil || *f.Version != "luxd/1.36.2" {
t.Errorf("version = %v, want luxd/1.36.2", deref(f.Version))
}
if f.Image == nil || *f.Image != "ghcr.io/luxfi/node:v1.34.0" {
t.Errorf("image = %v, want the declared CR image", deref(f.Image))
}
if f.Phase != "Running" {
t.Errorf("phase = %q, want Running", f.Phase)
}
n := f.Nodes[0]
if !n.RPCReachable {
t.Fatal("rpc_reachable = false")
}
for field, got := range map[string]*string{
"node_id": n.NodeID,
"ip": n.IP,
"git_commit": n.GitCommit,
"db_version": n.DBVersion,
} {
if got == nil {
t.Errorf("%s is null", field)
}
}
if n.IP != nil && *n.IP != "129.212.164.46" {
t.Errorf("ip = %q, want the advertised host without luxd's staking port", *n.IP)
}
heights := map[string]int64{"C": 0x10c1cf, "P": 1098191}
for _, ch := range n.Chains {
want, tracked := heights[ch.Chain]
switch {
case tracked && (ch.Height == nil || *ch.Height != want):
t.Errorf("%s height = %v, want %d", ch.Chain, ch.Height, want)
case !tracked && ch.Height != nil:
t.Errorf("%s height = %v, want null", ch.Chain, *ch.Height)
}
if ch.Bootstrapped == nil {
t.Errorf("%s bootstrapped is null", ch.Chain)
}
}
if f.Dex.DChainBootstrapped == nil || *f.Dex.DChainBootstrapped {
t.Errorf("d_chain_bootstrapped = %v, want false", f.Dex.DChainBootstrapped)
}
if f.Dex.Note == nil {
t.Error("dex note missing for an unbootstrapped D-Chain")
}
}
// The outage: the collector called /ext, luxd 1.36 answered 404, and every
// field came back null. Nothing may ask for /ext again.
func TestNeverCallsExt(t *testing.T) {
stub := newLuxd()
f := collectOne(t, stub)
if f.NodesRPCOK == 0 {
t.Fatal("no node answered: the /v1 surface is not being used")
}
stub.mu.Lock()
defer stub.mu.Unlock()
for _, p := range stub.paths {
if !strings.HasPrefix(p, "/v1/") {
t.Errorf("asked for %q; luxd only serves /v1", p)
}
if strings.HasPrefix(p, "/v1/C/") {
t.Errorf("asked for %q; chain RPC is /v1/bc/C/rpc", p)
}
}
if !contains(stub.paths, "/v1/bc/C/rpc") {
t.Error("C-Chain height was never requested at /v1/bc/C/rpc")
}
}
// A node that is down must show as down, not drag the network's truth with it.
func TestDownNodeStaysNull(t *testing.T) {
f := collectOne(t, newLuxd(), Pod{Name: "luxd-1", Index: 1, Phase: "Running", Ready: false, IP: ""})
if f.NodesTotal != 2 || f.NodesRPCOK != 1 || f.NodesReady != 1 {
t.Fatalf("total/rpc_ok/ready = %d/%d/%d, want 2/1/1", f.NodesTotal, f.NodesRPCOK, f.NodesReady)
}
down := f.Nodes[1]
if down.RPCReachable || down.Version != nil || down.IP != nil || down.Peers != nil {
t.Errorf("unreachable node reported data: %+v", down)
}
if len(down.Chains) != len(TrackedChains) {
t.Errorf("chains = %d, want %d placeholders", len(down.Chains), len(TrackedChains))
}
}
func TestTotalsAndEnvelope(t *testing.T) {
stub := newLuxd()
srv := httptest.NewServer(stub.handler())
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://")
c := &Collector{
Cluster: fakeCluster{pods: map[string][]Pod{
"lux-mainnet": {{Name: "luxd-0", Index: 0, Phase: "Running", Ready: true, IP: host}},
"lux-testnet": {{Name: "luxd-0", Index: 0, Phase: "Running", Ready: true, IP: host}},
}},
Prober: portless{NewRPC()},
}
ov := c.Overview(context.Background(), nil)
if len(ov.Networks) != 3 {
t.Fatalf("networks = %d, want all 3", len(ov.Networks))
}
if ov.Networks[0].Network != "mainnet" || ov.Networks[2].Network != "devnet" {
t.Errorf("network order = %s..%s, want mainnet..devnet", ov.Networks[0].Network, ov.Networks[2].Network)
}
for k, want := range map[string]int{"networks": 3, "nodes_total": 2, "nodes_ready": 2, "nodes_rpc_ok": 2, "total_peers": 4, "validators": 2} {
if ov.Totals[k] != want {
t.Errorf("totals[%s] = %d, want %d", k, ov.Totals[k], want)
}
}
if ov.Cluster != "lux-k8s" || ov.GeneratedAt <= 0 {
t.Errorf("envelope = %q/%v", ov.Cluster, ov.GeneratedAt)
}
// A network with no pods is an empty fleet with an error, never a missing key.
dev := ov.Networks[2]
if dev.Error == nil || dev.Nodes == nil || dev.VMPlugins == nil {
t.Errorf("empty network must still carry error + empty lists: %+v", dev)
}
}
// Every field the dashboard's TypeScript declares must be present in the JSON,
// as null rather than absent — an absent key reads as undefined and blanks a cell.
func TestWireKeysAreNeverOmitted(t *testing.T) {
f := collectOne(t, newLuxd(), Pod{Name: "luxd-1", Index: 1, Phase: "Pending", IP: ""})
blob, err := json.Marshal(Overview{Cluster: "lux-k8s", Networks: []NetworkFleet{f}, Totals: map[string]int{}})
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(blob, &doc); err != nil {
t.Fatal(err)
}
net := doc["networks"].([]any)[0].(map[string]any)
for _, k := range []string{"network", "namespace", "network_id", "chain_id", "phase", "image",
"version", "plugin_set", "vm_plugins", "validators", "nodes_total", "nodes_ready",
"nodes_rpc_ok", "total_peers", "dex", "nodes", "error"} {
if _, ok := net[k]; !ok {
t.Errorf("networks[0].%s is absent", k)
}
}
node := net["nodes"].([]any)[1].(map[string]any)
for _, k := range []string{"pod_name", "pod_index", "phase", "ready", "ip",
"rpc_reachable", "node_id", "version", "git_commit", "db_version", "peers", "healthy", "chains"} {
if _, ok := node[k]; !ok {
t.Errorf("nodes[1].%s is absent", k)
}
}
dex := net["dex"].(map[string]any)
for _, k := range []string{"d_chain_bootstrapped", "venue", "markets", "note"} {
if _, ok := dex[k]; !ok {
t.Errorf("dex.%s is absent", k)
}
}
ch := node["chains"].([]any)[0].(map[string]any)
for _, k := range []string{"chain", "bootstrapped", "height"} {
if _, ok := ch[k]; !ok {
t.Errorf("chains[0].%s is absent", k)
}
}
}
func TestNumericAcceptsQuotedAndBare(t *testing.T) {
for _, in := range []string{`"1098191"`, `1098191`} {
var n numeric
if err := n.UnmarshalJSON([]byte(in)); err != nil || n != 1098191 {
t.Errorf("%s -> %d, %v", in, n, err)
}
}
}
func TestSelectNetworks(t *testing.T) {
if got := selectNetworks(nil); len(got) != 3 {
t.Errorf("no filter -> %d networks, want 3", len(got))
}
got := selectNetworks([]string{"devnet", "nope"})
if len(got) != 1 || got[0].Name != "devnet" {
t.Errorf("filter -> %+v, want devnet only", got)
}
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}
func deref(s *string) string {
if s == nil {
return "<nil>"
}
return *s
}
+213
View File
@@ -0,0 +1,213 @@
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
}
+61
View File
@@ -0,0 +1,61 @@
package fleet
import (
"bytes"
"context"
"encoding/json"
"net/http"
"time"
)
// Markets counts the venues listed on a network's native DEX graph.
type Markets interface {
Count(ctx context.Context, network string) (int, bool)
}
// graphHost is the explorer that serves each network's DEX graph.
var graphHost = map[string]string{
"mainnet": "explorer.lux.network",
"testnet": "explorer.testnet.lux.network",
"devnet": "explorer.devnet.lux.network",
}
// Graph reads the market count from the explorer's DEX GraphQL endpoint.
// A network whose graph is not up simply reports no count.
type Graph struct{ http *http.Client }
// NewGraph returns a market counter with a short budget — the count is a
// garnish on the overview, never a reason to hold it up.
func NewGraph() *Graph { return &Graph{http: &http.Client{Timeout: 4 * time.Second}} }
// Count returns the number of markets, and whether the graph answered.
func (g *Graph) Count(ctx context.Context, network string) (int, bool) {
host, ok := graphHost[network]
if !ok {
return 0, false
}
body := []byte(`{"query":"{ markets { id } }"}`)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://"+host+"/v1/graph/lux/dex/graphql", bytes.NewReader(body))
if err != nil {
return 0, false
}
req.Header.Set("Content-Type", "application/json")
resp, err := g.http.Do(req)
if err != nil {
return 0, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, false
}
var out struct {
Data struct {
Markets []json.RawMessage `json:"markets"`
} `json:"data"`
}
if json.NewDecoder(resp.Body).Decode(&out) != nil || out.Data.Markets == nil {
return 0, false
}
return len(out.Data.Markets), true
}
+30 -1
View File
@@ -1,5 +1,34 @@
module github.com/hanzoai/bootnode
go 1.26.2
go 1.26.5
require github.com/go-chi/chi/v5 v5.1.0
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d // indirect
github.com/evanw/esbuild v0.28.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.4 // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/luxfi/log v1.4.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
github.com/zap-proto/fiber/v3 v3.2.1 // indirect
github.com/zap-proto/go v1.3.0 // indirect
github.com/zap-proto/http v0.3.1 // indirect
github.com/zap-proto/zip v1.17.2 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)
+53
View File
@@ -1,2 +1,55 @@
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/evanw/esbuild v0.28.1 h1:ds+yuRyUaZGx++GR56CrCeuXh8PVhVM4xq8v7PNELFc=
github.com/evanw/esbuild v0.28.1/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec=
github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
github.com/zap-proto/fiber/v3 v3.2.1 h1:k45oKyTwySPtGt8sPz2Ao8OUHc7pDEhai8Np2Ym6Jbg=
github.com/zap-proto/fiber/v3 v3.2.1/go.mod h1:eDm2z+ufJrkuE4MeX0Mea4oc/p7/HpXiZjVB+BXCKOA=
github.com/zap-proto/go v1.3.0 h1:S3rMoawwhH/BbSZ4G8zG05hJoQnMSMDPzIq75diCTqE=
github.com/zap-proto/go v1.3.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
github.com/zap-proto/http v0.3.1 h1:A2rCPWYCX866eAsdiWuns0dvWnBmViZtGm4pwX7jwlY=
github.com/zap-proto/http v0.3.1/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
github.com/zap-proto/zip v1.17.2 h1:YD6MF49D63z8VIYThQ4iG0RWG6Cdh3vX0vvXKz5DXPw=
github.com/zap-proto/zip v1.17.2/go.mod h1:EKMmUX9wCPvpkhpMBQRqa17YVXNXRYEjj7X+65Y+J9E=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+8
View File
@@ -23,6 +23,14 @@ images:
dockerfile: ./console/Dockerfile
repo: ghcr.io/bootnode/bootnode
tag-suffix: web
# The Lux fleet API: pure Go, built from the repo-root module. Hanzo repos
# publish to ghcr.io/hanzoai — the two entries above are the legacy refs the
# running bootnode Deployments still pull.
- name: fleet
context: .
dockerfile: ./Dockerfile.fleet
repo: ghcr.io/hanzoai/bootnode
tag-suffix: fleet
# Commands run on the arc runner after the image builds. Non-zero fails the run.
test:
+72
View File
@@ -0,0 +1,72 @@
# The bootnode-k8s half of the fleet API.
#
# api.cloud.lux.network resolves to bootnode-k8s, but the collector has to run
# in lux-k8s to see the fleet (see lux-fleet.yaml). So on this side the
# lux-fleet Service keeps its name and its ingress path and stops selecting
# pods: its endpoint is the lux-k8s ingress load balancer, a stable address,
# reached over HTTPS with the original Host header intact.
#
# Only /v1/fleets/lux crosses. Every other api.cloud.lux.network path is served
# by web3-platform-api in this cluster and is not touched.
#
# Apply to: do-sfo3-bootnode-k8s.
---
apiVersion: v1
kind: Service
metadata:
name: lux-fleet
namespace: bootnode
labels:
app: lux-fleet
spec:
type: ClusterIP
# No selector: the endpoints are declared below, not derived from pods here.
ports:
- name: http
port: 80
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: lux-fleet-luxk8s
namespace: bootnode
labels:
kubernetes.io/service-name: lux-fleet
addressType: IPv4
ports:
- name: http
port: 443
protocol: TCP
endpoints:
# do-sfo3-lux-k8s ingress load balancer (lux-system/hanzo-ingress).
- addresses: ["134.199.138.27"]
conditions:
ready: true
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: lux-fleet
namespace: bootnode
annotations:
# The endpoint above is a TLS listener; it presents a certificate for a
# name it does not own, which is fine because routing is by Host.
nginx.ingress.kubernetes.io/backend-protocol: HTTPS
nginx.ingress.kubernetes.io/proxy-ssl-verify: "off"
nginx.ingress.kubernetes.io/proxy-read-timeout: "30"
spec:
ingressClassName: nginx
rules:
- host: api.cloud.lux.network
http:
paths:
- path: /v1/fleets/lux
pathType: Prefix
backend:
service:
name: lux-fleet
port:
number: 80
+73 -70
View File
@@ -1,35 +1,65 @@
# Lux Fleet API — read-only live overview of the managed luxd fleet.
#
# A minimal, isolated FastAPI surface (bootnode.fleet_app) reusing the bootnode
# api image. It observes the luxd fleet on do-sfo3-lux-k8s strictly READ-ONLY
# through that cluster's API server: the LuxNetwork CR for the declared shape,
# the luxd pods for phase/readiness/IP, and each pod's luxd JSON-RPC via the
# API server's pod proxy for node version, node ID, peers, per-chain bootstrap
# (C/P/X/D), C-Chain eth_blockNumber, P-Chain platform.getHeight and a native
# D-Chain DEX summary. It serves GET /v1/fleets/lux/overview — the data source
# for the lux.cloud fleet dashboard.
# One Go binary (cmd/fleet), no Python runtime. It runs INSIDE lux-k8s, where
# the fleet is, so a node's dial address is simply its pod IP: no endpoint file
# to go stale, no cross-cluster credential to mint, and no API-server pod proxy
# carrying chain RPC. Every chain fact comes from the node itself over luxd's
# /v1 HTTP surface — /ext was removed in luxd 1.36 and 404s — plus the pod list
# and the LuxNetwork CR for the operator's declared shape.
#
# The pod proxy is the only way in: luxd RPC is cluster-internal and the luxd
# Services are ClusterIP, so there is no public per-pod endpoint to call. The
# read-only lux-k8s credential (LUX_K8S_KUBECONFIG) is therefore required, and
# it is the same credential used for both the k8s reads and the RPC.
# Nothing is mutated: every call is a GET or a read-only JSON-RPC query. The
# ClusterRole grants list/get on pods and luxnetworks and nothing else — no
# pods/proxy, no exec, no writes.
#
# Nothing on the luxd nets is mutated — every call is a GET or a read-only
# JSON-RPC query.
# Public URL. api.cloud.lux.network resolves to bootnode-k8s, so that cluster's
# nginx forwards only the /v1/fleets/lux path here, across the two clusters —
# see lux-fleet-bridge.yaml. Every other path on that host keeps going to
# web3-platform-api, untouched.
#
# Why a separate deployment: the read-only fleet view is orthogonal to the api
# monolith and depends only on the self-contained collector, so it ships and
# scales independently behind the same api.cloud.lux.network ingress.
# Apply to: do-sfo3-lux-k8s.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: lux-fleet
namespace: lux-cloud
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: lux-fleet
rules:
- apiGroups: [""]
resources: [pods]
verbs: [get, list]
- apiGroups: [lux.cloud]
resources: [luxnetworks]
verbs: [get, list]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: lux-fleet
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: lux-fleet
subjects:
- kind: ServiceAccount
name: lux-fleet
namespace: lux-cloud
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: lux-fleet
namespace: bootnode
namespace: lux-cloud
labels:
app: lux-fleet
app.kubernetes.io/part-of: bootnode
spec:
replicas: 2
selector:
@@ -39,65 +69,41 @@ spec:
metadata:
labels:
app: lux-fleet
app.kubernetes.io/part-of: bootnode
spec:
# GHCR pull creds come from the default ServiceAccount (ghcr-pull),
# matching the other bootnode deployments.
serviceAccountName: lux-fleet
containers:
- name: lux-fleet
# Reuses the bootnode api image (hanzo.yml → ghcr.io/bootnode/bootnode
# as <sha>-amd64-api). Pinned, so a rollout is a deliberate act.
image: ghcr.io/bootnode/bootnode:57f4a1b-amd64-api
imagePullPolicy: Always
command: ["uvicorn", "bootnode.fleet_app:app", "--host", "0.0.0.0", "--port", "8000"]
# Built by hanzo.yml from Dockerfile.fleet. Pinned, so a rollout is
# a deliberate act.
image: ghcr.io/hanzoai/bootnode:fleet-v1.0.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8000
name: http
env:
- name: LUX_K8S_KUBECONFIG
value: /etc/lux-k8s/kubeconfig
- name: ALLOWED_ORIGINS
value: "https://lux.cloud,https://www.lux.cloud,https://cloud.lux.network"
volumeMounts:
- name: lux-k8s
mountPath: /etc/lux-k8s
readOnly: true
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
httpGet: { path: /health, port: 8000 }
initialDelaySeconds: 2
periodSeconds: 15
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
httpGet: { path: /health, port: 8000 }
initialDelaySeconds: 5
periodSeconds: 30
resources:
requests:
cpu: 100m
memory: 192Mi
limits:
cpu: 750m
memory: 512Mi
requests: { cpu: 25m, memory: 32Mi }
limits: { cpu: 500m, memory: 128Mi }
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsUser: 65532
allowPrivilegeEscalation: false
volumes:
# Read-only lux-k8s credential (ServiceAccount bootnode:lux-fleet-reader
# in lux-k8s; see lux-fleet-reader-rbac.yaml, applied to lux-k8s).
- name: lux-k8s
secret:
secretName: lux-k8s-reader-kubeconfig
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
---
apiVersion: v1
kind: Service
metadata:
name: lux-fleet
namespace: bootnode
namespace: lux-cloud
labels:
app: lux-fleet
spec:
@@ -105,30 +111,27 @@ spec:
selector:
app: lux-fleet
ports:
- port: 80
- name: http
port: 80
targetPort: 8000
name: http
---
# Route only the read-only overview path to the fleet service. All other
# api.cloud.lux.network paths continue to the main api (web3-platform-api).
# Higher-specificity path wins under nginx ingress. TLS for this host is
# already owned/terminated by the existing api ingress (cloud-lux-network-api),
# so this object only adds the path rule and CORS — no duplicate cert.
# Claims only the fleet path on the shared host. TLS for api.cloud.lux.network
# is terminated on bootnode-k8s; this cluster is reached over its own ingress,
# which serves its default certificate for an SNI it does not own. The hop is
# routed by Host header, not by certificate, and the forwarder does not verify.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: lux-fleet
namespace: bootnode
# CORS is handled by the app (fleet_app CORSMiddleware) to avoid duplicate
# Access-Control-* headers — do not also enable nginx CORS here.
namespace: lux-cloud
spec:
ingressClassName: nginx
ingressClassName: ingress
rules:
- host: api.cloud.lux.network
http:
paths:
- path: /v1/fleets/lux/overview
- path: /v1/fleets/lux
pathType: Prefix
backend:
service: