Files
zeekayandHanzo Dev 936a59512c feat: the two fleet daemons are Go
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]>
2026-07-27 19:44:53 -07:00

190 lines
5.3 KiB
Go

// Package kube is the smallest in-cluster apiserver client that answers the
// three questions fleet health actually asks: what is this pod doing, what has
// it been saying, and please change this one label.
//
// It is deliberately not client-go. client-go brings a scheme, a codec, a
// informer machinery and ~40MB of transitive dependency to marshal four fields
// out of a Pod. The whole surface here is one Do method plus three callers.
package kube
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
const (
apiServer = "https://kubernetes.default.svc"
saDir = "/var/run/secrets/kubernetes.io/serviceaccount"
)
// Client talks to the apiserver with the pod's own service-account identity.
type Client struct {
http *http.Client
base string
dir string
}
// New builds a client from the projected service-account volume. A missing CA
// is not fatal here — it surfaces on the first call, inside the caller's retry
// loop, rather than killing a process before its loop exists.
func New(timeout time.Duration) *Client {
tr := &http.Transport{}
if pem, err := os.ReadFile(saDir + "/ca.crt"); err == nil {
pool := x509.NewCertPool()
if pool.AppendCertsFromPEM(pem) {
tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
}
}
return &Client{
http: &http.Client{Timeout: timeout, Transport: tr},
base: apiServer,
dir: saDir,
}
}
// NewAt points a client at an arbitrary base with no credentials. It exists so
// the differential tests can drive the real code against a fake apiserver.
func NewAt(base string, timeout time.Duration) *Client {
return &Client{http: &http.Client{Timeout: timeout}, base: base}
}
// token is re-read on every call: projected service-account tokens are rotated
// underneath a running pod, so caching one is a slow-motion outage.
func (c *Client) token() string {
if c.dir == "" {
return ""
}
b, err := os.ReadFile(c.dir + "/token")
if err != nil {
return ""
}
return string(bytes.TrimSpace(b))
}
// Do performs one apiserver call. body non-nil sends a merge-patch.
func (c *Client) Do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, r)
if err != nil {
return nil, err
}
if tok := c.token(); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
if body != nil {
req.Header.Set("Content-Type", "application/merge-patch+json")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("%s %s: %s", method, path, resp.Status)
}
return out, nil
}
// Pod is the slice of a Pod fleet health reads.
type Pod struct {
Metadata struct {
Name string `json:"name"`
Labels map[string]string `json:"labels"`
} `json:"metadata"`
Status struct {
Phase string `json:"phase"`
PodIP string `json:"podIP"`
ContainerStatuses []struct {
RestartCount int `json:"restartCount"`
} `json:"containerStatuses"`
Conditions []struct {
Type string `json:"type"`
Status string `json:"status"`
} `json:"conditions"`
} `json:"status"`
}
// Restarts is the sum across containers, matching kubectl's own accounting.
func (p *Pod) Restarts() int {
n := 0
for _, cs := range p.Status.ContainerStatuses {
n += cs.RestartCount
}
return n
}
// Ready reports the Ready condition. A pod with no Ready condition is not ready.
func (p *Pod) Ready() bool {
for _, c := range p.Status.Conditions {
if c.Type == "Ready" {
return c.Status == "True"
}
}
return false
}
// GetPod fetches one pod.
func (c *Client) GetPod(ctx context.Context, ns, name string) (*Pod, error) {
b, err := c.Do(ctx, http.MethodGet, "/api/v1/namespaces/"+ns+"/pods/"+name, nil)
if err != nil {
return nil, err
}
var p Pod
if err := json.Unmarshal(b, &p); err != nil {
return nil, err
}
return &p, nil
}
// ListPods fetches every pod matching a label selector.
func (c *Client) ListPods(ctx context.Context, ns, selector string) ([]Pod, error) {
p := "/api/v1/namespaces/" + ns + "/pods?labelSelector=" + url.QueryEscape(selector)
b, err := c.Do(ctx, http.MethodGet, p, nil)
if err != nil {
return nil, err
}
var out struct {
Items []Pod `json:"items"`
}
if err := json.Unmarshal(b, &out); err != nil {
return nil, err
}
return out.Items, nil
}
// Logs reads a container's log with the given query string.
func (c *Client) Logs(ctx context.Context, ns, name, query string) (string, error) {
b, err := c.Do(ctx, http.MethodGet, "/api/v1/namespaces/"+ns+"/pods/"+name+"/log?"+query, nil)
return string(b), err
}
// SetLabel adds or (with value "") removes one label. A JSON null value is how
// the apiserver is told to delete a key in a merge-patch — the whole reason
// this takes a *string rather than a string.
func (c *Client) SetLabel(ctx context.Context, ns, name, key string, value *string) error {
patch, err := json.Marshal(map[string]any{
"metadata": map[string]any{"labels": map[string]any{key: value}},
})
if err != nil {
return err
}
_, err = c.Do(ctx, http.MethodPatch, "/api/v1/namespaces/"+ns+"/pods/"+name, patch)
return err
}