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]>
201 lines
5.2 KiB
Go
201 lines
5.2 KiB
Go
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
|
|
}
|