Files
llmgw/internal/router/admin_test.go
Ilker ManapandClaude Opus 5 1ea067d08e
CI / test (push) Successful in 1m38s
Give metrics a door that does not also open onto pprof
The metrics existed and nothing could record them. They shared a listener
with pprof, the management API and the dashboard, and pprof can dump the
process heap where API keys sit in plaintext — so a fleet could be monitored
or keep its heap private, and not both. That is not a choice anyone should
have to make, and it is why every previous answer to "how do I scrape this"
was a tunnel.

metrics_listen serves metrics and health and nothing else, so it can be
reached from wherever Prometheus lives. Metrics stay on the admin listener
too: adding a door is not the same as moving one, and anything already
scraping keeps working.

While there: admin_listen defaulted to :9090, every interface, with a
comment beside it saying not to expose it. A default that contradicts its
own documentation is a footgun, so it is loopback now and someone who wants
otherwise says so.

The handler is nil until the registry is built, and says so rather than
serving an empty page. Ordering has already caught this exact area once —
a collector registered after AdminHandler published nothing at all, silently
— and an empty scrape target looks like a working one.

deploy/monitoring/prometheus.yml has a starting configuration and the alerts
worth having: a node that has stopped reporting, a model that should be
serving and is not, a card kept out of placement by a fault, and a fleet
holding models nobody is asking for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:12:10 +02:00

112 lines
3.8 KiB
Go

package router
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
)
type constantCollector struct{ desc *prometheus.Desc }
func (c constantCollector) Describe(ch chan<- *prometheus.Desc) { ch <- c.desc }
func (c constantCollector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue, 1)
}
func TestARegisteredCollectorReachesTheMetricsEndpoint(t *testing.T) {
// Registering after AdminHandler silently published nothing, because that
// is where the registry is built. The fleet metrics were deployed and
// absent, and only looking at the live endpoint showed it — a wiring
// mistake that no type checks and nothing fails on.
e := newFakeEngine(t, "m")
rt := newTestRouter(t, baseConfig(config.Endpoint{ID: "a", URL: e.srv.URL, Models: []string{"m"}}))
if err := rt.RegisterMetrics(constantCollector{
desc: prometheus.NewDesc("llmgw_test_collector", "proof it was registered", nil, nil),
}); err != nil {
t.Fatal(err)
}
mux, err := rt.AdminHandler()
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "llmgw_test_collector") {
t.Error("a registered collector did not reach /metrics")
}
}
func TestTheMetricsListenerServesMetricsAndNotPprof(t *testing.T) {
// The whole reason it exists. pprof can dump the process heap, and API keys
// are in that heap in plaintext — so the listener that can be reached from
// another machine must not carry it. Sharing one meant a fleet could be
// monitored or keep its heap private, and not both.
e := newFakeEngine(t, "m")
rt := newTestRouter(t, baseConfig(config.Endpoint{ID: "a", URL: e.srv.URL, Models: []string{"m"}}))
if _, err := rt.AdminHandler(); err != nil {
t.Fatal(err)
}
h := rt.MetricsHandler()
if h == nil {
t.Fatal("no metrics handler after AdminHandler")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if rec.Code != http.StatusOK {
t.Errorf("/metrics returned %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "llmgw_") {
t.Error("/metrics served nothing recognisable")
}
for _, path := range []string{
"/debug/pprof/", "/debug/pprof/heap", "/debug/pprof/cmdline",
"/debug/pprof/profile", "/debug/pprof/trace", "/api/v1/nodes", "/admin",
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusNotFound {
t.Errorf("%s returned %d on the metrics listener; it must not be reachable there",
path, rec.Code)
}
}
}
func TestMetricsStayOnTheAdminListenerToo(t *testing.T) {
// Anything already scraping the admin listener keeps working; adding a door
// is not the same as moving one.
e := newFakeEngine(t, "m")
rt := newTestRouter(t, baseConfig(config.Endpoint{ID: "a", URL: e.srv.URL, Models: []string{"m"}}))
mux, err := rt.AdminHandler()
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if rec.Code != http.StatusOK {
t.Errorf("/metrics on the admin listener returned %d", rec.Code)
}
}
func TestTheMetricsHandlerIsNilBeforeTheRegistryExists(t *testing.T) {
// Ordering has bitten this exact area once already: a collector registered
// after AdminHandler published nothing at all, silently. Saying nil beats
// serving an empty page that looks like a working scrape target.
e := newFakeEngine(t, "m")
rt := newTestRouter(t, baseConfig(config.Endpoint{ID: "a", URL: e.srv.URL, Models: []string{"m"}}))
if rt.MetricsHandler() != nil {
t.Error("a metrics handler existed before the registry was built")
}
}