mirror of
https://github.com/luxfi/monitoring.git
synced 2026-10-30 15:24:48 +00:00
The validator-health exporter and the rpc-tip-gate were both stdlib-only
Python scripts living in ConfigMaps on a stock python image. That choice
bought one thing — no build step — and it cost the backend standard: the
serving side of this stack is Go, and these two are serving processes.
internal/luxd — what one node says it believes (tip, finalized, peers,
bootstrapped, health-with-check-names). /v1 only.
internal/kube — the four apiserver calls fleet health makes. Not
client-go: a scheme, a codec and an informer machinery
to unmarshal four fields is not a dependency, it is a
liability.
internal/fleet — what the numbers MEAN, separated from the process that
serves them, so a metric's meaning can be tested
without a cluster.
Proven, not asserted. Both implementations were pointed at one fake
cluster and diffed:
exporter : 234 lines, 22 metric families — byte-for-byte identical.
tip-gate : same 5 decisions, same 3 merge-patches, byte-for-byte.
The differential earned its keep: the gate had been ported to
eth_getBlockByNumber when the deployed one calls eth_blockNumber. Same
answer today, a different call tomorrow. Fixed to match.
Two deliberate improvements over the Python, neither a scrape-visible
change: exposition series are sorted (Python's thread-completion order
made two scrapes of a steady fleet textually different), and the gate
refuses to start on a missing setting rather than defaulting quietly.
Co-authored-by: Hanzo Dev <[email protected]>
155 lines
3.9 KiB
Go
155 lines
3.9 KiB
Go
package fleet
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/luxfi/monitoring/internal/kube"
|
|
"github.com/luxfi/monitoring/internal/luxd"
|
|
)
|
|
|
|
// GateConfig is what the tip gate needs to decide who serves public traffic.
|
|
type GateConfig struct {
|
|
Namespace string
|
|
Selector string
|
|
RPCPort string
|
|
RPCPath string
|
|
LabelKey string
|
|
LabelValue string
|
|
MaxLag uint64
|
|
Interval time.Duration
|
|
Timeout time.Duration
|
|
|
|
// KubeBase and NodeBase override in-cluster addressing, as in Config.
|
|
KubeBase string
|
|
NodeBase func(podIP string) string
|
|
}
|
|
|
|
// Gate labels the luxd pods that are keeping up and unlabels the ones that are
|
|
// not. The Service that fronts public RPC selects on that label, so this is the
|
|
// thing standing between a lagging node and a user reading stale chain state.
|
|
type Gate struct {
|
|
cfg GateConfig
|
|
kube *kube.Client
|
|
http *http.Client
|
|
// Log is where each decision goes. Injectable so tests can read it.
|
|
Log func(string)
|
|
}
|
|
|
|
func NewGate(cfg GateConfig) *Gate {
|
|
k := kube.New(cfg.Timeout)
|
|
if cfg.KubeBase != "" {
|
|
k = kube.NewAt(cfg.KubeBase, cfg.Timeout)
|
|
}
|
|
return &Gate{
|
|
cfg: cfg,
|
|
kube: k,
|
|
http: &http.Client{Timeout: cfg.Timeout},
|
|
Log: func(s string) { fmt.Println(s) },
|
|
}
|
|
}
|
|
|
|
// Run reconciles until ctx is done. It never exits the loop on error: a
|
|
// crash-looping gate stops reconciling, and the labels it leaves behind freeze
|
|
// whatever it last decided.
|
|
func (g *Gate) Run(ctx context.Context) {
|
|
for {
|
|
if err := g.Reconcile(ctx); err != nil {
|
|
g.Log(fmt.Sprintf("reconcile failed: %v", err))
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(g.cfg.Interval):
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reconcile performs one pass.
|
|
func (g *Gate) Reconcile(ctx context.Context) error {
|
|
pods, err := g.kube.ListPods(ctx, g.cfg.Namespace, g.cfg.Selector)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tips := make(map[string]*uint64, len(pods))
|
|
for i := range pods {
|
|
p := &pods[i]
|
|
tips[p.Metadata.Name] = g.tip(ctx, p)
|
|
}
|
|
|
|
var top uint64
|
|
answered := false
|
|
for _, t := range tips {
|
|
if t != nil && (!answered || *t > top) {
|
|
top, answered = *t, true
|
|
}
|
|
}
|
|
if !answered {
|
|
// Fail static. Losing sight of the fleet is not evidence that the fleet
|
|
// is unfit, and emptying the pool would take the endpoint down outright.
|
|
g.Log("no node answered; labels unchanged")
|
|
return nil
|
|
}
|
|
|
|
for i := range pods {
|
|
p := &pods[i]
|
|
name := p.Metadata.Name
|
|
t := tips[name]
|
|
want := t != nil && top-*t <= g.cfg.MaxLag
|
|
has := p.Metadata.Labels[g.cfg.LabelKey] == g.cfg.LabelValue
|
|
|
|
changed := ""
|
|
if want != has {
|
|
var v *string
|
|
if want {
|
|
v = &g.cfg.LabelValue
|
|
}
|
|
if err := g.kube.SetLabel(ctx, g.cfg.Namespace, name, g.cfg.LabelKey, v); err != nil {
|
|
return err
|
|
}
|
|
changed = " (changed)"
|
|
}
|
|
lag := "-"
|
|
if t != nil {
|
|
lag = fmt.Sprintf("%d", top-*t)
|
|
}
|
|
g.Log(fmt.Sprintf("%s tip=%s lag=%s serving=%v%s", name, tipStr(t), lag, want, changed))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// tip asks one pod for its accepted head. A pod that is not Running or has no
|
|
// IP is not asked — there is nothing to ask.
|
|
func (g *Gate) tip(ctx context.Context, p *kube.Pod) *uint64 {
|
|
ip := p.Status.PodIP
|
|
if ip == "" || p.Status.Phase != "Running" {
|
|
return nil
|
|
}
|
|
base := fmt.Sprintf("http://%s:%s", ip, g.cfg.RPCPort)
|
|
if g.cfg.NodeBase != nil {
|
|
base = g.cfg.NodeBase(ip)
|
|
}
|
|
// eth_blockNumber, not a whole block: the gate runs every few seconds
|
|
// against every validator and only ever needed the height. A node that
|
|
// will not answer returns nil, which is the honest answer for every
|
|
// failure mode that matters — refused connection, a bootstrapping node
|
|
// that 404s the chain handler, a body that is not JSON — and they all
|
|
// mean the same thing: do not send it public traffic.
|
|
c := &luxd.Client{Base: base, HTTP: g.http, CPath: g.cfg.RPCPath}
|
|
n, err := c.BlockNumber(ctx)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &n
|
|
}
|
|
|
|
func tipStr(t *uint64) string {
|
|
if t == nil {
|
|
return "None"
|
|
}
|
|
return fmt.Sprintf("%d", *t)
|
|
}
|