mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
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:
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user