Files
pdbmux/metrics_test.go
T
unkin-agent b6d59af7ef
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Serve PuppetDB meta and metrics endpoints, sum node and resource counts
## Why
Puppetboard 7.0.1 cannot run against pdbmux: it exits at import when
/pdb/meta/v1/version 404s, and its landing page, metrics and radiator views
404 on the Jolokia surface.

## How
- Serve /pdb/meta/v1/version, reporting the lowest version any backend runs,
  and /pdb/meta/v1/server-time from the first reachable backend.
- Merge the Jolokia surface (/metrics/v2/read, /metrics/v2/list,
  /metrics/v1/mbeans): objects union, numeric attributes sum by default, and
  Min/Max/Uptime/StartTime plus the distribution stats take a bound or a mean.
- Route /nodes extract-count queries to the summing path ahead of the certname
  merge, and give /resources aggregates the same path.
- Document the endpoints and merge semantics in the README.
- Cover version disagreement, metric rules, escaped MBean names, count summing
  and the non-aggregate /nodes merge with httptest backends.
2026-09-05 20:41:35 +10:00

286 lines
10 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
)
const (
numNodesMBean = "puppetlabs.puppetdb.population:name=num-nodes"
numNodesPath = metricsPrefix + "v2/read/" + numNodesMBean
// What pypuppetdb actually sends: quote() percent-encodes ':' and '='.
numNodesEscaped = metricsPrefix + "v2/read/puppetlabs.puppetdb.population%3Aname%3Dnum-nodes"
)
// jolokiaRead wraps an MBean value in the envelope PuppetDB's Jolokia returns.
func jolokiaRead(mbean, value string, timestamp int) string {
return `{"request":{"mbean":"` + mbean + `","type":"read"},` +
`"value":` + value + `,"timestamp":` + strconv.Itoa(timestamp) + `,"status":200}`
}
func metricValue(t *testing.T, body []byte) map[string]any {
t.Helper()
var env map[string]json.RawMessage
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("unmarshal envelope %s: %v", body, err)
}
var val map[string]any
if err := json.Unmarshal(env["value"], &val); err != nil {
t.Fatalf("unmarshal value %s: %v", env["value"], err)
}
return val
}
func TestMetrics_ReadSumsPopulationCounts(t *testing.T) {
// Puppetboard's landing page and radiator read num-nodes when
// DEFAULT_ENVIRONMENT is '*'; each backend only knows its own nodes.
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":90}`, 1000)
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":53}`, 2000)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := metaGet(t, srv.Handler(), numNodesEscaped)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if got := metricValue(t, rec.Body.Bytes())["Value"]; got != float64(143) {
t.Errorf("Value = %v, want 143", got)
}
}
func TestMetrics_EscapedMBeanNameSurvives(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":1}`, 1)
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":1}`, 1)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
metaGet(t, srv.Handler(), numNodesEscaped)
if !a.sawRawPath(numNodesEscaped) {
t.Errorf("backend saw %v, want the percent-encoded path %q", a.rawPaths, numNodesEscaped)
}
}
func TestMetrics_EnvelopeKeepsNewestTimestamp(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":1}`, 1000)
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":1}`, 2000)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
var env map[string]any
if err := json.Unmarshal(metaGet(t, srv.Handler(), numNodesEscaped).Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env["timestamp"] != float64(2000) {
t.Errorf("timestamp = %v, want 2000", env["timestamp"])
}
if env["status"] != float64(200) {
t.Errorf("status = %v, want 200", env["status"])
}
}
func TestMetrics_PerAttributeRules(t *testing.T) {
const mbean = "puppetlabs.puppetdb.mq:name=global.processing-time"
path := metricsPrefix + "v2/read/" + mbean
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[path] = jolokiaRead(mbean,
`{"Count":10,"Min":2,"Max":9,"Mean":4,"StdDev":1,"50thPercentile":3,"MeanRate":1.5}`, 1)
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[path] = jolokiaRead(mbean,
`{"Count":6,"Min":1,"Max":20,"Mean":6,"StdDev":3,"50thPercentile":5,"MeanRate":0.5}`, 1)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
got := metricValue(t, metaGet(t, srv.Handler(), path).Body.Bytes())
want := map[string]any{
"Count": float64(16), // counts add
"Min": float64(1), // a bound stays a bound
"Max": float64(20),
"Mean": float64(5), // distribution stats average
"StdDev": float64(2),
"50thPercentile": float64(4),
"MeanRate": float64(2), // throughput adds
}
if !reflect.DeepEqual(got, want) {
t.Errorf("merged value = %v, want %v", got, want)
}
}
func TestMetrics_ScalarReadUsesURLAttributeName(t *testing.T) {
// /metrics/v2/read/<mbean>/<attribute> answers with a bare number, so the
// rule has to come from the URL rather than an object key.
const mbean = "puppetlabs.puppetdb.population:name=num-resources"
sumPath := metricsPrefix + "v2/read/" + mbean + "/Value"
maxPath := metricsPrefix + "v2/read/" + mbean + "/Max"
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[sumPath] = jolokiaRead(mbean, `1000`, 1)
a.bodies[maxPath] = jolokiaRead(mbean, `1000`, 1)
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[sumPath] = jolokiaRead(mbean, `234`, 1)
b.bodies[maxPath] = jolokiaRead(mbean, `234`, 1)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
for _, c := range []struct {
path string
want float64
}{{sumPath, 1234}, {maxPath, 1000}} {
var env map[string]any
if err := json.Unmarshal(metaGet(t, srv.Handler(), c.path).Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env["value"] != c.want {
t.Errorf("%s value = %v, want %v", c.path, env["value"], c.want)
}
}
}
func TestMetrics_ListUnionsDomains(t *testing.T) {
// Puppetboard's /metrics page calls metric() with no name, which is a
// Jolokia list; a backend-local MBean must not vanish from the browse tree.
const path = metricsPrefix + "v2/list"
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[path] = `{"value":{"java.lang":{"type=Memory":{"attr":{"HeapMemoryUsage":{"rw":false}}}}},"status":200,"timestamp":1}`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[path] = `{"value":{"puppetlabs.puppetdb.population":{"name=num-nodes":{"attr":{"Value":{"rw":false}}}}},"status":200,"timestamp":1}`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
got := metricValue(t, metaGet(t, srv.Handler(), path).Body.Bytes())
if _, ok := got["java.lang"]; !ok {
t.Errorf("java.lang missing from merged list: %v", got)
}
if _, ok := got["puppetlabs.puppetdb.population"]; !ok {
t.Errorf("puppetlabs.puppetdb.population missing from merged list: %v", got)
}
}
func TestMetrics_V1BareObjectMerged(t *testing.T) {
// metrics/v1/mbeans has no Jolokia envelope; the whole body is the value.
const path = metricsPrefix + "v1/mbeans/" + numNodesMBean
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[path] = `{"Value":90}`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[path] = `{"Value":53}`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
var got map[string]any
if err := json.Unmarshal(metaGet(t, srv.Handler(), path).Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["Value"] != float64(143) {
t.Errorf("Value = %v, want 143", got["Value"])
}
}
func TestMetrics_MissingMBeanKeepsUpstreamError(t *testing.T) {
// Jolokia reports a bad MBean as a 200 with an error envelope, which
// pypuppetdb turns into DoesNotComputeError; the client must still see it.
const path = metricsPrefix + "v2/read/nope:name=nothing"
errEnv := `{"request":{"mbean":"nope:name=nothing"},"error_type":"javax.management.InstanceNotFoundException","error":"nope:name=nothing is not registered","status":404}`
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[path] = errEnv
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[path] = errEnv
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := metaGet(t, srv.Handler(), path)
var env map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env["error"] == nil {
t.Errorf("expected the upstream Jolokia error to be replayed, got %s", rec.Body.String())
}
}
func TestMetrics_ErroringBackendIgnoredWhenAnotherAnswers(t *testing.T) {
const path = metricsPrefix + "v2/read/" + numNodesMBean
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[path] = `{"request":{},"error":"boom","status":500}`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[path] = jolokiaRead(numNodesMBean, `{"Value":53}`, 1)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
if got := metricValue(t, metaGet(t, srv.Handler(), path).Body.Bytes())["Value"]; got != float64(53) {
t.Errorf("Value = %v, want 53 from the backend that answered", got)
}
}
func TestMetrics_OneBackendDown(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.fail = true
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":53}`, 1)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := metaGet(t, srv.Handler(), numNodesEscaped)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 serving the survivor, got %d", rec.Code)
}
if got := metricValue(t, rec.Body.Bytes())["Value"]; got != float64(53) {
t.Errorf("Value = %v, want 53", got)
}
}
func TestMetrics_AllBackendsDown(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.fail = true
b := newFakeBackend(t, `[]`, `[]`)
b.fail = true
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
if rec := metaGet(t, srv.Handler(), numNodesEscaped); rec.Code != http.StatusBadGateway {
t.Errorf("status %d, want 502", rec.Code)
}
}
func TestMetrics_RejectsNonGET(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, numNodesEscaped, nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status %d, want 405", rec.Code)
}
}
func TestMergeMetricValue_NonNumericKeepsFirst(t *testing.T) {
a := map[string]any{"Name": "pdb-a", "Enabled": true}
b := map[string]any{"Name": "pdb-b", "Enabled": false}
got, ok := mergeMetricValue([]any{a, b}, "").(map[string]any)
if !ok {
t.Fatalf("expected an object, got %T", got)
}
if got["Name"] != "pdb-a" || got["Enabled"] != true {
t.Errorf("merged = %v, want the first backend's strings and booleans", got)
}
}
func TestMergeRuleFor(t *testing.T) {
cases := map[string]mergeRule{
"Count": ruleSum,
"Value": ruleSum,
"MeanRate": ruleSum,
"queue-depth": ruleSum,
"min": ruleMin,
"Max": ruleMax,
"Uptime": ruleMax,
"StartTime": ruleMax,
"Mean": ruleMean,
"StdDev": ruleMean,
"99thPercentile": ruleMean,
}
for attr, want := range cases {
if got := mergeRuleFor(attr); got != want {
t.Errorf("mergeRuleFor(%q) = %v, want %v", attr, got, want)
}
}
}