Files
pdbmux/meta.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

196 lines
5.3 KiB
Go

package main
import (
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"sync"
)
const (
metaVersionPath = "/pdb/meta/v1/version"
metaServerTimePath = "/pdb/meta/v1/server-time"
)
// rawResult is one backend's verbatim response, for endpoints whose payload is
// not a PuppetDB record array.
type rawResult struct {
name string
status int
contentType string
body []byte
err error
}
// ok reports whether the backend answered 2xx.
func (r rawResult) ok() bool {
return r.err == nil && r.status >= 200 && r.status < 300
}
// fanOutRaw asks every backend for path concurrently and returns one result per
// backend, in configured order, without interpreting the bodies.
func (s *Server) fanOutRaw(ctx context.Context, path, rawQuery string) []rawResult {
results := make([]rawResult, len(s.cfg.Backends))
var wg sync.WaitGroup
for i, b := range s.cfg.Backends {
wg.Add(1)
go func(i int, b Backend) {
defer wg.Done()
results[i] = s.rawBackend(ctx, b, path, rawQuery)
}(i, b)
}
wg.Wait()
return results
}
func (s *Server) rawBackend(ctx context.Context, b Backend, path, rawQuery string) rawResult {
target := strings.TrimRight(b.URL, "/") + path
if rawQuery != "" {
target += "?" + rawQuery
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return rawResult{name: b.Name, err: err}
}
resp, err := s.client.Do(req)
if err != nil {
return rawResult{name: b.Name, err: err}
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return rawResult{name: b.Name, err: err}
}
return rawResult{
name: b.Name,
status: resp.StatusCode,
contentType: resp.Header.Get("Content-Type"),
body: body,
}
}
// aliveRaw drops backends that errored or answered non-2xx, writing a 502 and
// returning ok=false only when none is left.
func (s *Server) aliveRaw(w http.ResponseWriter, results []rawResult, path string) ([]rawResult, bool) {
var alive []rawResult
for _, res := range results {
if !res.ok() {
s.log.Printf("warning: backend %q failed for %s: %s", res.name, path, res.reason())
continue
}
alive = append(alive, res)
}
if len(alive) == 0 {
http.Error(w, "all backends failed", http.StatusBadGateway)
return nil, false
}
return alive, true
}
func (r rawResult) reason() string {
if r.err != nil {
return r.err.Error()
}
return "HTTP " + strconv.Itoa(r.status) + ": " + strings.TrimSpace(string(r.body))
}
// handleMetaVersion serves /pdb/meta/v1/version. Clients (pypuppetdb, and so
// Puppetboard's startup check) treat the answer as the feature level they may
// rely on, so the merged answer is the *lowest* version any backend reports:
// the estate can only be counted on for what its oldest member implements.
func (s *Server) handleMetaVersion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "only GET is supported", http.StatusMethodNotAllowed)
return
}
alive, ok := s.aliveRaw(w, s.fanOutRaw(r.Context(), metaVersionPath, r.URL.RawQuery), metaVersionPath)
if !ok {
return
}
lowest := alive[0]
lowestVer, hasVer := metaField(lowest.body, "version")
for _, res := range alive[1:] {
v, ok := metaField(res.body, "version")
if !ok {
continue
}
if !hasVer || compareVersions(v, lowestVer) < 0 {
lowest, lowestVer, hasVer = res, v, true
}
}
writeRaw(w, lowest)
}
// handleMetaServerTime serves /pdb/meta/v1/server-time. The clock of whichever
// PuppetDB answered is not estate state and does not merge, so the first
// reachable backend in configured order supplies it — the same tie-break rule
// used everywhere else.
func (s *Server) handleMetaServerTime(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "only GET is supported", http.StatusMethodNotAllowed)
return
}
alive, ok := s.aliveRaw(w, s.fanOutRaw(r.Context(), metaServerTimePath, r.URL.RawQuery), metaServerTimePath)
if !ok {
return
}
writeRaw(w, alive[0])
}
// metaField pulls a string field out of a `{"version": "..."}`-shaped body.
func metaField(body []byte, field string) (string, bool) {
var obj map[string]json.RawMessage
if json.Unmarshal(body, &obj) != nil {
return "", false
}
var s string
if json.Unmarshal(obj[field], &s) != nil || s == "" {
return "", false
}
return s, true
}
// compareVersions orders dotted version strings segment by segment, comparing
// numerically where both segments are numbers and lexically otherwise, so
// "7.12.1" sorts below "8.4.0" and below "7.12.2". A prefix is lower than a
// longer string sharing it ("7.12" < "7.12.1"), and a pre-release suffix is
// compared as text within its segment ("8.0.0" < "8.0.0-SNAPSHOT").
func compareVersions(a, b string) int {
as, bs := strings.Split(a, "."), strings.Split(b, ".")
for i := 0; i < len(as) && i < len(bs); i++ {
an, aok := strconv.Atoi(as[i])
bn, bok := strconv.Atoi(bs[i])
if aok == nil && bok == nil {
if an != bn {
return sign(an - bn)
}
continue
}
if c := strings.Compare(as[i], bs[i]); c != 0 {
return c
}
}
return sign(len(as) - len(bs))
}
func sign(n int) int {
switch {
case n < 0:
return -1
case n > 0:
return 1
default:
return 0
}
}
func writeRaw(w http.ResponseWriter, res rawResult) {
setContentType(w, res.contentType)
w.WriteHeader(res.status)
_, _ = w.Write(res.body)
}