mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +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]>
100 lines
2.7 KiB
Go
100 lines
2.7 KiB
Go
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
|
|
}
|