24 Commits

Author SHA1 Message Date
benvin 1ee7a2c07c Merge pull request 'config: drop primary/prefer and treat all backends equally' (#9) from benvin/drop-primary into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #9
2026-09-05 16:07:37 +10:00
unkin-agent 8ad6205200 docs: reword reports/events union without old/new framing
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-05 13:49:02 +10:00
unkin-agent 2391f56a11 config: drop primary/prefer and treat all backends equally
- unmerged /pdb/query/v4/* paths now go to the first backend that answers, not a designated primary
2026-09-05 13:49:02 +10:00
benvin ffc2499f98 Merge pull request 'Support a config file alongside env vars in containers' (#10) from benvin/config-file-and-env into main
Reviewed-on: #10
2026-09-05 13:42:17 +10:00
benvin d724cf0a5e Merge pull request 'feat: sum aggregate rows across backends on event-counts and /reports' (#8) from benvin/aggregate-sums into main
Reviewed-on: #8
2026-09-05 13:06:44 +10:00
unkin-agent b1ecbf31ac feat: sum aggregate rows across backends on event-counts and /reports
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
- group by the row's non-aggregate fields and add the numeric columns; X-Records on a summed endpoint is the merged row count
2026-09-05 12:29:48 +10:00
unkin-agent 03174f5ea0 Support a config file alongside env vars in containers
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
- --config / PDBMUX_CONFIG select the file; /etc/pdbmux/config.yaml joins the default search order
2026-09-05 12:29:14 +10:00
benvin 4bb44fb331 Merge pull request 'docs: strip over-commenting from README and source' (#6) from benvin/comment-cleanup into main
Reviewed-on: #6
2026-09-05 12:22:52 +10:00
unkin-agent 6ce903a6eb merge main
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-05 11:52:24 +10:00
benvin cb05c7f377 Merge pull request 'config: drop estate-specific defaults and docs' (#7) from benvin/generic-config into main
Reviewed-on: #7
2026-09-05 11:47:20 +10:00
unkin-agent ee82b72733 Merge origin/main into benvin/generic-config
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Keep the generic wording for the package doc, --help text and README intro
while taking the /reports and /events merge from #5.
2026-09-05 11:44:28 +10:00
unkin-agent 7b9082de08 docs: strip over-commenting from server.go and reports.go
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-05 11:42:43 +10:00
unkin-agent d88db498c0 Merge origin/main into benvin/comment-cleanup 2026-09-05 11:41:38 +10:00
unkin-agent 9d5e9d0ed8 config: drop estate-specific defaults and docs
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Remove hardcoded internal PuppetDB URLs and site-specific wording so the
project is publishable as-is.

- backends have no default; require config file or PDBMUX_BACKENDS
- primary/prefer default to the first configured backend
- config init writes example.com placeholders
- Load no longer validates, so config init/version work unconfigured
- genericise README, package doc, help text and Dockerfile comment
2026-09-05 11:40:05 +10:00
benvin 14d119f8e8 Merge pull request 'feat: merge /reports and /events across both PuppetDBs' (#5) from benvin/reports-merge into main
Reviewed-on: #5
2026-09-05 11:38:31 +10:00
unkin-agent 31ad4ae457 docs: strip over-commenting from README and source
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-05 11:38:24 +10:00
unkin-agent 01d87412ee fix: never dedupe hash-less report rows across backends
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
An extract/count()/group_by query returns synthetic rows with no report
hash, which reportKey fell back to keying by verbatim raw bytes. Two
backends emitting a byte-identical aggregate row (e.g.
{"status":"changed","count":1}) therefore collapsed into one, silently
undercounting the merged result and contradicting the documented
guarantee that no backend's rows are dropped.

Give the mergeUnion key func an ok return: false means the record has no
dedupe identity and is always kept. reportKey returns ok=false for
hash-less rows; hash-keyed report dedupe and event verbatim-identity
dedupe are unchanged.

Add TestMergeUnion_IdenticalHashlessRowsAreNotCollapsed covering the
collision case, and reword the README line to say aggregate rows pass
through even when byte-identical.
2026-09-05 11:33:34 +10:00
unkin-agent ed2e5b73d6 feat: merge /reports and /events across both PuppetDBs
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Reports are immutable history, so a node that migrated has reports in the
old PuppetDB and the new one; serve the union rather than picking a single
owning backend as /facts does.

Re-apply order_by/limit/offset over the merged set and sum X-Records, since
each backend only orders and pages its own slice.
2026-09-05 11:22:36 +10:00
benvin e2e9004784 Merge pull request 'ci: add buildkit_config CA trust for artifactapi push' (#4) from benvin/buildx-ca-config into main
Reviewed-on: #4
2026-08-15 18:47:07 +10:00
unkin-agent a7e5a143f6 ci: add buildkit_config CA trust for artifactapi push
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
2026-08-15 18:31:05 +10:00
benvin 9fc928ee67 Merge pull request 'ci: use CA-baked plugin-docker-buildx image for artifactapi push' (#3) from benvin/buildx-ca-plugin-image into main
Reviewed-on: #3
2026-08-15 18:19:14 +10:00
unkin-agent 3c7251cc1e ci: use CA-baked plugin-docker-buildx image for artifactapi push
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
The upstream woodpeckerci/plugin-docker-buildx image does not trust the
internal CA, so buildx pushes to the artifactapi local docker registry
fail on TLS verification. Switch the docker push step to the CA-baked
plugin-docker-buildx image published to docker-internal, which bundles
the internal CA and pushes cleanly.
2026-08-15 18:04:23 +10:00
benvin c5b8b3824a Merge pull request 'ci: push images to artifactapi registry instead of gitea' (#2) from benvin/push-artifactapi into main
Reviewed-on: #2
2026-07-30 20:55:07 +10:00
unkinben f7fda175e6 ci: push images to artifactapi registry instead of gitea
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Hard switch of the docker push target from the Gitea registry to the
artifactapi local docker registry (docker-internal); the Gitea VM and its
registry are being retired. Drops the droneci/DRONECI_PASSWORD creds since
artifactapi accepts unauthenticated in-cluster pushes. Also updates the README image path.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-30 00:34:59 +10:00
15 changed files with 2475 additions and 561 deletions
+7 -10
View File
@@ -1,23 +1,20 @@
# Build and push the pdbmux container image on a v* tag. pdbmux is a k8s-only
# daemon (deployed via argocd-apps), so it ships as an image. Mirrors the estate
# convention: the woodpeckerci/plugin-docker-buildx plugin pushes to the Gitea
# registry using the droneci / DRONECI_PASSWORD credentials.
# plugin-docker-buildx is the CA-baked variant; artifactapi's cert is not in the default trust store.
when:
- event: tag
ref: refs/tags/v*
steps:
- name: docker
image: woodpeckerci/plugin-docker-buildx
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
settings:
registry: git.unkin.net
repo: git.unkin.net/unkin/pdbmux
registry: artifactapi.k8s.syd1.au.unkin.net
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/pdbmux
dockerfile: Dockerfile
buildkit_config: |
[registry."artifactapi.k8s.syd1.au.unkin.net"]
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
build_args:
VERSION: ${CI_COMMIT_TAG}
username: droneci
password:
from_secret: DRONECI_PASSWORD
tags:
- ${CI_COMMIT_TAG}
- latest
-3
View File
@@ -1,6 +1,3 @@
# Container image for pdbmux, the merging PuppetDB proxy daemon. pdbmux is a
# k8s-only service (deployed via argocd-apps), so it ships as a distroless
# static image rather than an RPM.
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git
-3
View File
@@ -9,7 +9,6 @@ ARCH ?= $(shell go env GOARCH)
all: build
# Build the single static binary into dist/.
build:
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$(BINARY) .
@@ -28,8 +27,6 @@ clean:
install:
go install $(GOFLAGS) .
# Bump helpers — read the latest semver tag and create the next one.
# If no tag exists yet, start from v0.0.0.
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
+104 -94
View File
@@ -1,21 +1,21 @@
# pdbmux — merging PuppetDB proxy
`pdbmux` is a small HTTP daemon that fronts **two** PuppetDB backends and serves
a single, merged PuppetDB v4 query surface on one address. Point `node-lookup`,
`pblastreport`, or anything else at `pdbmux` instead of a raw PuppetDB and it
sees one consistent view spanning both.
`pdbmux` is a small HTTP daemon that fronts **several** PuppetDB backends and
serves a single, merged PuppetDB v4 query surface on one address. Point
Puppetboard, or any other PuppetDB API client, at `pdbmux` instead of a raw
PuppetDB and it sees one consistent view spanning all of them.
## Why
During the VM→k8s Puppet migration there are two PuppetDBs:
Running more than one PuppetDB — during a migration between two of them, or
across regions — means a given node's current data lives in exactly one at any
moment, and consumers have to know which, or query each in turn. `pdbmux`
merges them all so consumers don't have to know (or query twice) which PuppetDB
a node currently lives in.
- **old** — the legacy Consul-registered `http://puppetdbapi.service.consul:8080`
- **new** — the k8s `https://puppetdb.k8s.syd1.au.unkin.net` (TLS terminated at
the gateway; backends are plain PuppetDB on 8080)
Nodes move from old to new as they migrate, so at any moment a given node's
current data lives in exactly one of them. `pdbmux` merges both so consumers
don't have to know (or query twice) which PuppetDB a node currently lives in.
All backends are equal — `pdbmux` is never told which one to favour. Backend
names are arbitrary labels and there is no fixed number of them. The configured
order is used only as a tie-break, so output is reproducible.
## Endpoints
@@ -24,133 +24,143 @@ not PQL) is forwarded verbatim.
| Path | Behaviour |
|---|---|
| `GET /pdb/query/v4/nodes` | Fan out to both backends, dedupe by `certname`, keep the record with the newer `report_timestamp`. |
| `GET /pdb/query/v4/facts` | Fan out to both, and per `certname` keep **all** facts from the backend that owns that node (see merge semantics). |
| `GET /pdb/query/v4/*` (any other) | Transparently proxied to the **primary** backend, unmerged, streamed verbatim. |
| `GET /pdb/query/v4/nodes` | Fan out to all backends, dedupe by `certname`, keep the record with the newer `report_timestamp`. |
| `GET /pdb/query/v4/facts` | Fan out to all, and per `certname` keep **all** facts from the backend that owns that node (see merge semantics). |
| `GET /pdb/query/v4/reports` | Fan out to all and serve the **union**, deduped by report `hash`, re-ordered and re-paged across backends. |
| `GET /pdb/query/v4/events` | Fan out to all and serve the **union**, deduped by record identity, re-ordered and re-paged. |
| `GET /pdb/query/v4/event-counts` | Fan out to all and **sum** each subject's counts into one row per subject. |
| `GET /pdb/query/v4/aggregate-event-counts` | Fan out to all and **sum** the summary object's counts. |
| `GET /pdb/query/v4/reports/<hash>/{events,logs,metrics}` | Ask every backend; serve the answer from whichever backend actually holds that report. `404` when none does. |
| `GET /pdb/query/v4/*` (any other) | No merge rule, so backends are tried in configured order and the first success is streamed back verbatim; if all reject it, the first upstream error response is replayed. |
| `GET /healthz` | Per-backend reachability. `200 {"status":"ok"}` if all reachable, `200 degraded` if some fail, `503 down` if all fail. |
Fan-out is concurrent. If one backend errors or times out, `pdbmux` serves the
survivor's results and logs a warning; a merged endpoint only returns `502` when
surviving backends' results and logs a warning; a merged endpoint only returns `502` when
**every** backend fails. Response records are passed through as raw JSON so
unknown fields survive untouched.
## Merge semantics
- **`/nodes`** — dedupe by `certname`; the record with the strictly-newer
`report_timestamp` wins. On a tie (or when a node exists in only one backend),
the **preferred** backend's record is kept.
- **`/facts`** — node-level granularity. For a `certname` present in both
backends, `pdbmux` keeps **all** of that node's facts from **one** backend and
drops the other's, chosen by the merge strategy:
`report_timestamp` wins. On a tie, the backend listed first in `backends`
supplies the record — a tie-break only, so the merged output is deterministic.
- **`/facts`** — node-level granularity. For a `certname` present in more than
one backend, `pdbmux` keeps **all** of that node's facts from **one** backend and
drops the others', chosen by the merge strategy:
- **`freshness`** (default) — attribute each `certname` to whichever backend
holds its newer `report_timestamp`. `pdbmux` derives this from a per-certname
freshness map built by querying `/nodes` from both backends, cached for
`freshness_ttl` (default 30s). Ties/fallbacks use `prefer`.
- **`static`** — always keep the `prefer` backend's facts for shared nodes.
No extra `/nodes` query.
freshness map built by querying `/nodes` from every backend, cached for
`freshness_ttl` (default 30s).
- **`static`** — skip the extra `/nodes` query and take each shared node's
facts from the first backend in configured order that holds it.
- A node present in only one backend always appears (falls back to whichever
backend actually returned facts for it).
- **`/reports`, `/events`** — **union**, not a per-node winner. Reports are
immutable history, so a node's reports can legitimately exist in more than one
backend and all of them belong in the merged view. Reports dedupe on `hash`;
events, which carry no id of their own, dedupe on the verbatim record (a node
reporting to more than one backend stores identical records in each).
- **Aggregates** — `extract`/`group_by` rows are counts, not records, so each
backend returns a partial answer that has to be **added**, not deduped. This
covers `/event-counts`, `/aggregate-event-counts`, and a `/reports` query whose
`extract` carries a `["function", ...]` column.
- The grouping key is the row's non-aggregate fields: for `/reports` they come
from the query — the plain `extract` fields plus any `group_by` clause — and
for the event-count endpoints from the row itself (`subject_type`/`subject`,
or `summarize_by`), whose remaining fields are all counts.
- Rows sharing a key collapse into one with their numeric columns summed. A key
only one backend reported is passed through byte-for-byte. An aggregate column
that is absent or non-numeric in a row is skipped, never zeroed, so the
backends that did report a number still count.
- A `/reports` query with no `function` column is a projection of real reports,
not an aggregate, and stays on the union path.
- `include_total=true` on a summed endpoint reports the **merged** row count,
not the sum of the backends' `X-Records`, since shared keys collapse.
### Paging and ordering on the merged endpoints
Each backend applies `order_by`/`limit`/`offset` to its own slice only, so
`pdbmux` re-does all three over the union:
- `order_by` is parsed and the merged set re-sorted by those fields (ties keep
the merged set's existing order). A record missing an ordered field sorts first.
- Backends are asked for the first `offset + limit` records — never an `offset`
— and the requested window is then cut from the merged, re-sorted set.
- `include_total=true` on a union endpoint makes `pdbmux` sum each backend's
`X-Records` header into one merged header. Deduped records are counted once per
backend, so the total is an upper bound. Summed endpoints report the merged row
count instead.
- A malformed `limit`, `offset` or `order_by` gets a `400` rather than being
forwarded.
## Config
Precedence (lowest → highest): **defaults < config file < env vars (`PDBMUX_*`) < flags**.
Config file: `$XDG_CONFIG_HOME/pdbmux/config.yaml`. In Kubernetes, configuration
is supplied entirely via `PDBMUX_*` env vars (no config file), which is the
supported deployment path — see [Deployment](#deployment).
The config file is optional; a file, env vars, or both work equally well,
including in a container.
Which file is read: `--config <path>`, else `PDBMUX_CONFIG`, else the first that
exists of `$XDG_CONFIG_HOME/pdbmux/config.yaml` (or `$HOME/.config/pdbmux/config.yaml`),
then `/etc/pdbmux/config.yaml`. A path given via `--config`/`PDBMUX_CONFIG` **must**
exist — pdbmux fails rather than silently falling back — while a missing file on
the default search path is fine. `pdbmux config show` prints the file it loaded,
or the paths it searched.
```yaml
# ~/.config/pdbmux/config.yaml (local dev; in k8s use PDBMUX_* env instead)
listen: ":8080"
backends:
- name: old
url: http://puppetdbapi.service.consul:8080
- name: new
url: https://puppetdb.k8s.syd1.au.unkin.net
primary: new # backend used for non-merged /pdb/query/v4/* pass-through
backends: # order is a tie-break only, not a ranking
- name: pdb-a
url: http://puppetdb1.example.com:8080
- name: pdb-b
url: https://puppetdb2.example.com
merge: freshness # freshness | static
prefer: new # winner on ties / static merge / fallback
timeout: 10s # per-upstream request timeout
freshness_ttl: 30s # freshness-map cache TTL (freshness merge only)
```
`backends[*].url` is a **base** URL (`scheme://host[:port]`), without the
`/pdb/query/v4/...` path — `pdbmux` appends the path per request.
`backends[*].url` is a **base** URL (`scheme://host[:port]`); `pdbmux` appends
the `/pdb/query/v4/...` path per request.
| Env var | Overrides |
|---|---|
| `PDBMUX_CONFIG` | config file path (not a file key) |
| `PDBMUX_LISTEN` | `listen` |
| `PDBMUX_PRIMARY` | `primary` |
| `PDBMUX_MERGE` | `merge` |
| `PDBMUX_PREFER` | `prefer` |
| `PDBMUX_TIMEOUT` | `timeout` (Go duration, e.g. `10s`) |
| `PDBMUX_FRESHNESS_TTL` | `freshness_ttl` |
| `PDBMUX_BACKENDS` | whole backend list, as `name=url,name=url` |
Flags: `--listen`, `--primary`, `--merge`.
Flags: `--config`, `--listen`, `--merge`.
`config init` writes to `--config`/`PDBMUX_CONFIG` when set, else to
`$XDG_CONFIG_HOME/pdbmux/config.yaml`.
## Running
```bash
pdbmux # start the proxy (serve is the default action)
pdbmux serve # explicit
pdbmux config init # write a default config file
pdbmux config show # print active config after all overrides
pdbmux version
```
Point a consumer at it:
Subcommands: `serve` (default), `config init`, `config show`, `version`. Run
`pdbmux --help` for details. Any PuppetDB v4 client works against the `pdbmux`
base URL in place of a PuppetDB one.
```bash
node-lookup --url http://localhost:8080/pdb/query/v4/facts -R
NODE_LOOKUP_URL=http://localhost:8080/pdb/query/v4/facts pblastreport somehost
PDBMUX_BACKENDS='pdb-a=http://puppetdb1.example.com:8080,pdb-b=http://puppetdb2.example.com:8080' pdbmux
curl -s --get http://localhost:8080/pdb/query/v4/nodes \
--data-urlencode 'query=["=","certname","host1.example.com"]'
```
## Build
```bash
make build # -> dist/pdbmux (CGO disabled, static)
make test # go test -race ./...
make lint # golangci-lint
```
Requires Go 1.25+. Dependencies: `github.com/spf13/cobra` (CLI),
`gopkg.in/yaml.v3` (config file).
`make build` (static binary into `dist/`), `make test`, `make lint`. Requires Go 1.25+.
## Deployment
`pdbmux` runs **in Kubernetes** as a container, in line with the all-in-k8s
estate direction — it is not shipped as a per-VM RPM/systemd service. The image
is built and pushed on every `v*` tag (`.woodpecker/docker.yaml`) to:
Container image only — no OS package. Every `v*` tag builds and pushes the image
(`.woodpecker/docker.yaml`); registry and repository are pipeline settings. Tag
with `make patch` / `minor` / `major`.
```
git.unkin.net/unkin/pdbmux:<tag>
```
It is a minimal static (`CGO_ENABLED=0`) binary on a distroless base
(`Dockerfile`), configured entirely via `PDBMUX_*` env vars, with a single HTTP
listener and `/healthz` for liveness/readiness probes.
The Deployment/Service/Gateway manifests live in the estate's `argocd-apps` repo
under `apps/base/pdbmux/` (namespace `pdbmux`, 2 replicas), and it is exposed to
VM/workstation `node-lookup` consumers over HTTPS at:
```
https://pdbmux.k8s.syd1.au.unkin.net
```
Locally you can still run the binary directly for development:
```bash
PDBMUX_BACKENDS='old=http://puppetdbapi.service.consul:8080,new=http://puppetdb.puppet.svc.cluster.local:8080' \
pdbmux serve
curl -s localhost:8080/healthz
```
## Version bumps
```bash
make patch # tag vX.Y.(Z+1) and push (triggers the docker release)
make minor # tag vX.(Y+1).0
make major # tag v(X+1).0.0
```
A static (`CGO_ENABLED=0`) binary on a distroless base. Configure it with
`PDBMUX_*` env vars (at minimum `PDBMUX_BACKENDS`), or mount a config file — a
configmap at `/etc/pdbmux/config.yaml` is picked up with no env var at all, and
any other mount path works via `PDBMUX_CONFIG`. Env vars still override file
values, so the two mix. Stateless, so run as many replicas as you like; use
`/healthz` for liveness/readiness probes.
+270
View File
@@ -0,0 +1,270 @@
package main
import (
"encoding/json"
"sort"
"strconv"
"strings"
)
// aggregateSpec names the columns of an extract/group_by result set: keys
// identify a row across backends, sums are the numeric aggregate columns added
// together.
type aggregateSpec struct {
keys []string
sums []string
}
// parseAggregate reads a PuppetDB AST query and returns the aggregate shape of
// its response, or nil when the query is not an aggregate — only a top-level
// `extract` carrying at least one `["function", ...]` column produces summable
// rows. Key columns are the plain (non-function) extract fields, unioned with an
// explicit `group_by` clause when the query has one.
func parseAggregate(query string) *aggregateSpec {
if strings.TrimSpace(query) == "" {
return nil
}
var ast []json.RawMessage
if json.Unmarshal([]byte(query), &ast) != nil || len(ast) < 2 {
return nil
}
var op string
if json.Unmarshal(ast[0], &op) != nil || op != "extract" {
return nil
}
var cols []json.RawMessage
if json.Unmarshal(ast[1], &cols) != nil {
return nil
}
spec := &aggregateSpec{}
for _, col := range cols {
var name string
if json.Unmarshal(col, &name) == nil {
spec.keys = appendUnique(spec.keys, name)
continue
}
if fn, ok := functionName(col); ok {
spec.sums = appendUnique(spec.sums, fn)
}
}
if len(spec.sums) == 0 {
return nil
}
for _, node := range ast[2:] {
for _, f := range groupByFields(node) {
spec.keys = appendUnique(spec.keys, f)
}
}
return spec
}
// functionName returns the response column an extract function produces, which
// PuppetDB names after the function itself: ["function","count","certname"]
// yields a "count" column.
func functionName(col json.RawMessage) (string, bool) {
var parts []json.RawMessage
if json.Unmarshal(col, &parts) != nil || len(parts) < 2 {
return "", false
}
var head, name string
if json.Unmarshal(parts[0], &head) != nil || head != "function" {
return "", false
}
if json.Unmarshal(parts[1], &name) != nil || name == "" {
return "", false
}
return name, true
}
// groupByFields returns the field names of a ["group_by", ...] AST node, or nil
// for any other node.
func groupByFields(node json.RawMessage) []string {
var parts []json.RawMessage
if json.Unmarshal(node, &parts) != nil || len(parts) < 2 {
return nil
}
var head string
if json.Unmarshal(parts[0], &head) != nil || head != "group_by" {
return nil
}
var out []string
for _, p := range parts[1:] {
var name string
if json.Unmarshal(p, &name) == nil {
out = append(out, name)
}
}
return out
}
func appendUnique(s []string, v string) []string {
if contains(s, v) {
return s
}
return append(s, v)
}
// columns reports which fields of a row form its grouping key and which are
// summed. A spec is fixed by the query, so the row is ignored.
func (a *aggregateSpec) columns(map[string]json.RawMessage) ([]string, []string) {
return a.keys, a.sums
}
// inferredColumns derives an event-counts row's shape from the row itself: the
// counts to add (successes, failures, noops, skips, total) are its numeric
// fields, plus any null one — PuppetDB nulls an aggregate column when a backend
// matched nothing — and everything else, subject_type/subject/summarize_by,
// identifies the row. Those endpoints have a fixed response shape with no
// numeric key field, so nothing summable is mistaken for identity.
func inferredColumns(row map[string]json.RawMessage) ([]string, []string) {
var keys, sums []string
for name, val := range row {
if isJSONNumber(val) || isJSONNull(val) {
sums = append(sums, name)
continue
}
keys = append(keys, name)
}
sort.Strings(keys)
sort.Strings(sums)
return keys, sums
}
// isJSONNumber reports whether a raw JSON value is a number.
func isJSONNumber(raw json.RawMessage) bool {
v := strings.TrimSpace(string(raw))
if v == "" {
return false
}
return v[0] == '-' || (v[0] >= '0' && v[0] <= '9')
}
func isJSONNull(raw json.RawMessage) bool {
return strings.TrimSpace(string(raw)) == "null"
}
// sumGroup accumulates the rows sharing one grouping key.
type sumGroup struct {
raw json.RawMessage // first contributing row, verbatim
row map[string]json.RawMessage // its decoded fields
totals map[string]float64 // running sum per aggregate column
merged bool // a second row was folded in
}
// sumRows folds each backend's aggregate rows into one row per grouping key,
// adding the numeric aggregate columns. columns decides, per row, which fields
// are the key and which are summed.
//
// A row that is not a JSON object passes through untouched, as does the sole row
// of a key only one backend reported — those keep their upstream bytes. An
// aggregate column that is absent or non-numeric in a later row is left at the
// earlier backend's value rather than being coerced to zero. results come in
// configured backend order and the output keeps first-seen order, a tie-break
// only.
func sumRows(results []backendResult, columns func(map[string]json.RawMessage) ([]string, []string)) []json.RawMessage {
type slot struct {
raw json.RawMessage // passthrough row, when group is nil
group *sumGroup
}
var order []slot
groups := map[string]*sumGroup{}
for _, res := range results {
for _, rec := range res.records {
var row map[string]json.RawMessage
if json.Unmarshal(rec.Raw, &row) != nil {
order = append(order, slot{raw: rec.Raw})
continue
}
keys, sums := columns(row)
k := groupKey(row, keys)
g, ok := groups[k]
if !ok {
g = &sumGroup{raw: rec.Raw, row: row, totals: map[string]float64{}}
for _, s := range sums {
if n, ok := numberOf(row[s]); ok {
g.totals[s] = n
}
}
groups[k] = g
order = append(order, slot{group: g})
continue
}
for _, s := range sums {
n, ok := numberOf(row[s])
if !ok {
continue
}
if _, seen := g.totals[s]; !seen {
// First numeric value for a column the earlier row lacked.
g.totals[s] = 0
}
g.totals[s] += n
g.merged = true
}
}
}
out := make([]json.RawMessage, 0, len(order))
for _, sl := range order {
if sl.group == nil {
out = append(out, sl.raw)
continue
}
out = append(out, sl.group.encode())
}
return out
}
// encode renders a group back to JSON, reusing the first row's bytes when
// nothing was added to it.
func (g *sumGroup) encode() json.RawMessage {
if !g.merged {
return g.raw
}
row := make(map[string]json.RawMessage, len(g.row))
for k, v := range g.row {
row[k] = v
}
for col, total := range g.totals {
row[col] = json.RawMessage(strconv.FormatFloat(total, 'f', -1, 64))
}
raw, err := json.Marshal(row)
if err != nil {
return g.raw
}
return raw
}
// groupKey builds a row's identity from the named fields' verbatim JSON values.
// Every backend runs the same PuppetDB serialiser, so byte equality is a sound
// comparison for object-valued keys such as event-counts' subject. An absent
// field is distinct from any present value.
func groupKey(row map[string]json.RawMessage, keys []string) string {
var b strings.Builder
for _, k := range keys {
b.WriteString(k)
b.WriteByte(0)
if v, ok := row[k]; ok {
b.Write(v)
} else {
b.WriteByte(1)
}
b.WriteByte(0)
}
return b.String()
}
// numberOf decodes a raw JSON number, reporting ok=false for anything else so
// non-numeric aggregate columns are carried through instead of summed.
func numberOf(raw json.RawMessage) (float64, bool) {
if !isJSONNumber(raw) {
return 0, false
}
var n float64
if json.Unmarshal(raw, &n) != nil {
return 0, false
}
return n, true
}
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"encoding/json"
"reflect"
"slices"
"testing"
)
func rows(raws ...string) []record {
out := make([]record, 0, len(raws))
for _, r := range raws {
out = append(out, record{Raw: json.RawMessage(r)})
}
return out
}
// decodeRows turns a merged result set into comparable maps.
func decodeRows(t *testing.T, raws []json.RawMessage) []map[string]any {
t.Helper()
out := make([]map[string]any, 0, len(raws))
for _, raw := range raws {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
out = append(out, m)
}
return out
}
func TestParseAggregate_ExtractWithGroupBy(t *testing.T) {
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
if spec == nil {
t.Fatal("expected an aggregate spec")
}
if !slices.Equal(spec.keys, []string{"status"}) {
t.Errorf("keys = %v, want [status]", spec.keys)
}
if !slices.Equal(spec.sums, []string{"count"}) {
t.Errorf("sums = %v, want [count]", spec.sums)
}
}
func TestParseAggregate_GroupByAddsUnextractedField(t *testing.T) {
spec := parseAggregate(`["extract",[["function","count","certname"]],["~","certname",".*"],["group_by","status"]]`)
if spec == nil {
t.Fatal("expected an aggregate spec")
}
if !slices.Equal(spec.keys, []string{"status"}) {
t.Errorf("keys = %v, want [status] from the group_by clause", spec.keys)
}
if !slices.Equal(spec.sums, []string{"count"}) {
t.Errorf("sums = %v, want [count]", spec.sums)
}
}
func TestParseAggregate_NoFunctionIsNotAggregate(t *testing.T) {
for _, q := range []string{
``,
`["=","certname","h1"]`,
`["extract",["certname","hash"],["=","certname","h1"]]`, // projection, still real reports
`not json`,
`["extract"]`,
`{"not":"an array"}`,
} {
if spec := parseAggregate(q); spec != nil {
t.Errorf("parseAggregate(%q) = %+v, want nil", q, spec)
}
}
}
func TestSumRows_SharedKeysAreAdded(t *testing.T) {
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
merged := sumRows([]backendResult{
{name: "a", records: rows(`{"count":3,"status":"changed"}`, `{"count":1,"status":"failed"}`)},
{name: "b", records: rows(`{"count":4,"status":"changed"}`, `{"count":2,"status":"failed"}`)},
}, spec.columns)
want := []map[string]any{
{"count": float64(7), "status": "changed"},
{"count": float64(3), "status": "failed"},
}
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
t.Errorf("merged = %v, want %v", got, want)
}
}
func TestSumRows_DisjointKeysAreKept(t *testing.T) {
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
merged := sumRows([]backendResult{
{name: "a", records: rows(`{"count":3,"status":"changed"}`)},
{name: "b", records: rows(`{"count":2,"status":"skipped"}`)},
}, spec.columns)
want := []map[string]any{
{"count": float64(3), "status": "changed"},
{"count": float64(2), "status": "skipped"},
}
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
t.Errorf("merged = %v, want %v", got, want)
}
}
func TestSumRows_SingleBackendRowKeepsUpstreamBytes(t *testing.T) {
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
const raw = `{"count":3,"status":"changed","extra":{"kept":true}}`
merged := sumRows([]backendResult{
{name: "a", records: rows(raw)},
{name: "b", records: nil},
}, spec.columns)
if len(merged) != 1 || string(merged[0]) != raw {
t.Errorf("merged = %s, want the row verbatim %s", merged, raw)
}
}
func TestSumRows_NonNumericAggregateColumnIsNotZeroed(t *testing.T) {
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
merged := sumRows([]backendResult{
{name: "a", records: rows(`{"count":5,"status":"changed"}`)},
{name: "b", records: rows(`{"count":null,"status":"changed"}`)},
}, spec.columns)
want := []map[string]any{{"count": float64(5), "status": "changed"}}
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
t.Errorf("merged = %v, want the numeric value preserved %v", got, want)
}
}
func TestSumRows_MissingAggregateColumnStartsFromTheNumericRow(t *testing.T) {
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
merged := sumRows([]backendResult{
{name: "a", records: rows(`{"status":"changed"}`)},
{name: "b", records: rows(`{"count":6,"status":"changed"}`)},
}, spec.columns)
want := []map[string]any{{"count": float64(6), "status": "changed"}}
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
t.Errorf("merged = %v, want %v", got, want)
}
}
func TestSumRows_NonObjectRowsPassThrough(t *testing.T) {
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
merged := sumRows([]backendResult{
{name: "a", records: rows(`"surprise"`)},
{name: "b", records: rows(`{"count":1,"status":"changed"}`)},
}, spec.columns)
if len(merged) != 2 || string(merged[0]) != `"surprise"` {
t.Fatalf("merged = %s, want the non-object row kept as-is", merged)
}
}
func TestSumRows_NoKeyColumnsCollapseToOneRow(t *testing.T) {
// ["extract",[["function","count"]],...] is a whole-estate count: one row
// per backend, and the merged answer is their sum.
spec := parseAggregate(`["extract",[["function","count"]],["=","certname","h1"]]`)
if spec == nil {
t.Fatal("expected an aggregate spec")
}
merged := sumRows([]backendResult{
{name: "a", records: rows(`{"count":10}`)},
{name: "b", records: rows(`{"count":32}`)},
}, spec.columns)
want := []map[string]any{{"count": float64(42)}}
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
t.Errorf("merged = %v, want %v", got, want)
}
}
func TestInferredColumns_SplitsCountsFromIdentity(t *testing.T) {
var row map[string]json.RawMessage
if err := json.Unmarshal([]byte(`{"subject_type":"certname","subject":{"title":"h1"},"failures":1,"successes":2,"skips":null}`), &row); err != nil {
t.Fatal(err)
}
keys, sums := inferredColumns(row)
if !slices.Equal(keys, []string{"subject", "subject_type"}) {
t.Errorf("keys = %v, want [subject subject_type]", keys)
}
// A null count is an empty aggregate, not part of the row's identity.
if !slices.Equal(sums, []string{"failures", "skips", "successes"}) {
t.Errorf("sums = %v, want [failures skips successes]", sums)
}
}
func TestSumRows_EventCountsPerSubject(t *testing.T) {
merged := sumRows([]backendResult{
{name: "a", records: rows(
`{"subject_type":"certname","subject":{"title":"h1"},"failures":1,"successes":2,"noops":0,"skips":0}`,
`{"subject_type":"certname","subject":{"title":"h2"},"failures":0,"successes":5,"noops":0,"skips":0}`,
)},
{name: "b", records: rows(
`{"subject_type":"certname","subject":{"title":"h1"},"failures":3,"successes":4,"noops":1,"skips":0}`,
)},
}, inferredColumns)
got := decodeRows(t, merged)
want := []map[string]any{
{"subject_type": "certname", "subject": map[string]any{"title": "h1"},
"failures": float64(4), "successes": float64(6), "noops": float64(1), "skips": float64(0)},
{"subject_type": "certname", "subject": map[string]any{"title": "h2"},
"failures": float64(0), "successes": float64(5), "noops": float64(0), "skips": float64(0)},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("merged = %v, want %v", got, want)
}
}
+97 -100
View File
@@ -15,130 +15,140 @@ const (
appName = "pdbmux"
configFileName = "config.yaml"
envPrefix = "PDBMUX_"
envConfigPath = envPrefix + "CONFIG"
// systemConfigDir is the last resort in the search order, and the path a
// container mount (configmap, secret) is expected to land on.
systemConfigDir = "/etc/" + appName
// defaultListen is the default HTTP listen address.
defaultListen = ":8080"
// defaultOldURL / defaultNewURL are the two PuppetDBs merged during the
// VM -> k8s migration. old = legacy Consul-registered puppetdbapi; new =
// the k8s PuppetDB behind the gateway (TLS terminated there).
defaultOldURL = "http://puppetdbapi.service.consul:8080"
defaultNewURL = "https://puppetdb.k8s.syd1.au.unkin.net"
// defaultPrimary is the backend name used for pass-through (non-merged)
// /pdb/query/v4/* paths and as static precedence for merge fallback.
defaultPrimary = "new"
defaultTimeout = 10 * time.Second
defaultFreshnessTTL = 30 * time.Second
)
// Backend is one upstream PuppetDB. URL is the base URL (scheme://host[:port]),
// without the /pdb/query/v4/... path — that is appended per request.
type Backend struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
var exampleBackends = []Backend{
{Name: "pdb-a", URL: "http://puppetdb1.example.com:8080"},
{Name: "pdb-b", URL: "http://puppetdb2.example.com:8080"},
}
// Config holds every configurable value. Fields map 1:1 to config-file keys and
// env vars (PDBMUX_*). See Load for precedence.
type Config struct {
// Listen is the HTTP listen address (host:port).
Listen string `yaml:"listen"`
// Backends is the ordered list of upstream PuppetDBs to fan out to.
Backends []Backend `yaml:"backends"`
// Primary is the backend Name used for transparent pass-through of
// non-merged /pdb/query/v4/* paths.
Primary string `yaml:"primary"`
// Merge selects how /facts records are attributed to a backend when a
// certname appears in both: "freshness" (query /nodes report_timestamp,
// newer wins) or "static" (always prefer the Prefer backend).
Merge string `yaml:"merge"`
// Prefer names the backend that wins under static merge and as the
// tie-breaker/fallback under freshness merge.
Prefer string `yaml:"prefer"`
// Timeout bounds each upstream request.
Timeout time.Duration `yaml:"timeout"`
// FreshnessTTL is how long a per-certname freshness map (from /nodes) is
// cached under the "freshness" merge strategy.
FreshnessTTL time.Duration `yaml:"freshness_ttl"`
type Backend struct {
Name string `yaml:"name"`
URL string `yaml:"url"` // base URL only; the query path is appended per request
}
type Config struct {
Listen string `yaml:"listen"`
Backends []Backend `yaml:"backends"` // all equal; order is only a deterministic tie-break
Merge string `yaml:"merge"`
Timeout time.Duration `yaml:"timeout"`
FreshnessTTL time.Duration `yaml:"freshness_ttl"`
sourcePath string // file this config was read from, empty if none was found
}
// SourcePath returns the config file Load read, or "" when none was loaded.
func (c Config) SourcePath() string { return c.sourcePath }
const (
mergeFreshness = "freshness"
mergeStatic = "static"
)
// DefaultConfig returns the built-in defaults: both migration PuppetDBs,
// freshness merge, "new" primary/preferred.
func DefaultConfig() Config {
return Config{
Listen: defaultListen,
Backends: []Backend{
{Name: "old", URL: defaultOldURL},
{Name: "new", URL: defaultNewURL},
},
Primary: defaultPrimary,
Listen: defaultListen,
Merge: mergeFreshness,
Prefer: defaultPrimary,
Timeout: defaultTimeout,
FreshnessTTL: defaultFreshnessTTL,
}
}
// ConfigDir returns the XDG_CONFIG_HOME/pdbmux directory.
func ExampleConfig() Config {
cfg := DefaultConfig()
cfg.Backends = append([]Backend(nil), exampleBackends...)
return cfg
}
func ConfigDir() string {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, _ := os.UserHomeDir()
if home == "" {
return systemConfigDir
}
base = filepath.Join(home, ".config")
}
return filepath.Join(base, appName)
}
// ConfigPath returns the full path to the config file.
func ConfigPath() string {
return filepath.Join(ConfigDir(), configFileName)
}
// Load reads the config file (if present), then applies env var overrides.
// Precedence (lowest -> highest): defaults < config file < env vars < flags
// (flags are applied by the caller). Backends can be overridden wholesale via
// PDBMUX_BACKENDS ("name=url,name=url").
func Load() (Config, error) {
// configSearchPaths lists the default config locations, highest priority first.
func configSearchPaths() []string {
paths := []string{ConfigPath()}
if system := filepath.Join(systemConfigDir, configFileName); system != paths[0] {
paths = append(paths, system)
}
return paths
}
// explicitConfigPath returns the config path named by --config or PDBMUX_CONFIG,
// or "" when neither is set.
func explicitConfigPath(flagPath string) string {
if flagPath != "" {
return flagPath
}
return os.Getenv(envConfigPath)
}
// resolveConfigPath picks the config file to read: --config, else PDBMUX_CONFIG,
// else the first existing default search path. explicit reports whether the path
// was named outright, in which case a missing file is an error.
func resolveConfigPath(flagPath string) (path string, explicit bool) {
if p := explicitConfigPath(flagPath); p != "" {
return p, true
}
paths := configSearchPaths()
for _, p := range paths {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, false
}
}
return paths[0], false
}
// Precedence: defaults < config file < env vars < flags, and flags are applied by the caller.
func Load(flagPath string) (Config, error) {
cfg := DefaultConfig()
path := ConfigPath()
path, explicit := resolveConfigPath(flagPath)
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return cfg, fmt.Errorf("reading config %s: %w", path, err)
}
if err == nil {
switch {
case err == nil:
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parsing config %s: %w", path, err)
}
cfg.sourcePath = path
case os.IsNotExist(err) && !explicit:
// No config file anywhere on the search path: defaults + env only.
default:
return cfg, fmt.Errorf("reading config %s: %w", path, err)
}
applyEnv(&cfg, os.Getenv)
if err := cfg.Validate(); err != nil {
return cfg, err
}
return cfg, nil
}
// applyEnv overlays PDBMUX_* env vars onto cfg. getenv is injected for testing.
func applyEnv(cfg *Config, getenv func(string) string) {
if v := getenv(envPrefix + "LISTEN"); v != "" {
cfg.Listen = v
}
if v := getenv(envPrefix + "PRIMARY"); v != "" {
cfg.Primary = v
}
if v := getenv(envPrefix + "MERGE"); v != "" {
cfg.Merge = v
}
if v := getenv(envPrefix + "PREFER"); v != "" {
cfg.Prefer = v
}
if v := getenv(envPrefix + "TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
cfg.Timeout = d
@@ -156,8 +166,7 @@ func applyEnv(cfg *Config, getenv func(string) string) {
}
}
// parseBackends parses "name=url,name=url" into Backends. Entries without an
// "=" are skipped. Used for the PDBMUX_BACKENDS env override.
// Parses the PDBMUX_BACKENDS form "name=url,name=url"; entries without an "=" are skipped.
func parseBackends(s string) []Backend {
var out []Backend
for _, part := range strings.Split(s, ",") {
@@ -175,10 +184,19 @@ func parseBackends(s string) []Backend {
return out
}
// Validate checks the config is internally consistent and usable.
// configHint names the file a user should edit: the one actually loaded, else
// the default write target.
func (c Config) configHint() string {
if c.sourcePath != "" {
return c.sourcePath
}
return ConfigPath()
}
func (c Config) Validate() error {
if len(c.Backends) == 0 {
return fmt.Errorf("no backends configured")
return fmt.Errorf("no backends configured: set %sBACKENDS to \"name=url,name=url\" or add a backends list to %s",
envPrefix, c.configHint())
}
seen := map[string]bool{}
for _, b := range c.Backends {
@@ -190,49 +208,30 @@ func (c Config) Validate() error {
}
seen[b.Name] = true
}
if !seen[c.Primary] {
return fmt.Errorf("primary %q is not a configured backend", c.Primary)
}
switch c.Merge {
case mergeFreshness, mergeStatic:
default:
return fmt.Errorf("merge must be %q or %q, got %q", mergeFreshness, mergeStatic, c.Merge)
}
if !seen[c.Prefer] {
return fmt.Errorf("prefer %q is not a configured backend", c.Prefer)
}
if c.Timeout <= 0 {
return fmt.Errorf("timeout must be positive")
}
return nil
}
// PrimaryBackend returns the backend named by Primary (guaranteed present after
// Validate).
func (c Config) PrimaryBackend() Backend {
for _, b := range c.Backends {
if b.Name == c.Primary {
return b
}
}
return c.Backends[0]
}
// writeDefaultConfig creates the config dir and writes a default config file.
func writeDefaultConfig() error {
dir := ConfigDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
func writeDefaultConfig(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("creating config dir: %w", err)
}
path := ConfigPath()
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("config already exists at %s", path)
}
data, _ := yaml.Marshal(DefaultConfig())
data, _ := yaml.Marshal(ExampleConfig())
header := []byte("# pdbmux configuration\n" +
"# A merging proxy over two PuppetDBs (old Consul + new k8s) during migration.\n" +
"# Env overrides: PDBMUX_LISTEN, PDBMUX_PRIMARY, PDBMUX_MERGE, PDBMUX_PREFER,\n" +
"# PDBMUX_TIMEOUT, PDBMUX_FRESHNESS_TTL, PDBMUX_BACKENDS (name=url,name=url).\n\n")
"# A merging proxy presenting one PuppetDB v4 query surface over several\n" +
"# PuppetDB backends. The backend URLs below are placeholders — edit them.\n" +
"# Env overrides: PDBMUX_LISTEN, PDBMUX_MERGE, PDBMUX_TIMEOUT,\n" +
"# PDBMUX_FRESHNESS_TTL, PDBMUX_BACKENDS (name=url,name=url).\n\n")
if err := os.WriteFile(path, append(header, data...), 0o644); err != nil {
return fmt.Errorf("writing config: %w", err)
}
@@ -240,8 +239,6 @@ func writeDefaultConfig() error {
return nil
}
// durationString renders a duration for `config show` (falls back to a plain
// seconds count for zero to avoid "0s" ambiguity in logs).
func durationString(d time.Duration) string {
if d == 0 {
return "0"
+298 -27
View File
@@ -1,28 +1,73 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestLoad_Defaults(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
clearEnv(t)
cfg, err := Load()
if err != nil {
t.Fatal(err)
// testConfigValid returns a minimal valid config for Validate/merge tests, with
// neutral placeholder backends.
func testConfigValid() Config {
cfg := DefaultConfig()
cfg.Backends = []Backend{
{Name: "a", URL: "http://localhost:18080"},
{Name: "b", URL: "http://localhost:18081"},
}
return cfg
}
func TestDefaultConfig_NoBackends(t *testing.T) {
cfg := DefaultConfig()
if cfg.Listen != defaultListen {
t.Errorf("listen = %q, want %q", cfg.Listen, defaultListen)
}
if len(cfg.Backends) != 2 || cfg.Backends[0].Name != "old" || cfg.Backends[1].Name != "new" {
t.Errorf("unexpected default backends: %+v", cfg.Backends)
if len(cfg.Backends) != 0 {
t.Errorf("defaults must not ship backends, got %+v", cfg.Backends)
}
if cfg.Merge != mergeFreshness || cfg.Primary != "new" {
t.Errorf("unexpected defaults merge=%s primary=%s", cfg.Merge, cfg.Primary)
if cfg.Merge != mergeFreshness {
t.Errorf("merge = %q, want %q", cfg.Merge, mergeFreshness)
}
if err := cfg.Validate(); err == nil {
t.Error("defaults alone must not validate: backends are required")
}
}
func TestLoad_NoBackendsLoadsButFailsValidation(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
clearEnv(t)
// Load itself must succeed so `config init` / `version` work unconfigured.
cfg, err := Load("")
if err != nil {
t.Fatalf("load: %v", err)
}
err = cfg.Validate()
if err == nil {
t.Fatal("expected a validation error when no backends are configured")
}
if !strings.Contains(err.Error(), envPrefix+"BACKENDS") {
t.Errorf("error should name the env var to set, got: %v", err)
}
}
func TestLoad_BackendsKeepConfiguredOrder(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
clearEnv(t)
t.Setenv(envPrefix+"BACKENDS", "a=http://localhost:18080,b=http://localhost:18081")
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if len(cfg.Backends) != 2 || cfg.Backends[0].Name != "a" || cfg.Backends[1].Name != "b" {
t.Errorf("backends should keep the configured order, got %+v", cfg.Backends)
}
if err := cfg.Validate(); err != nil {
t.Errorf("a bare backend list must validate: %v", err)
}
}
@@ -35,7 +80,8 @@ func TestLoad_FileAndEnvOverride(t *testing.T) {
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatal(err)
}
body := "listen: :9999\nmerge: static\nprimary: old\nprefer: old\n"
body := "listen: :9999\nmerge: static\n" +
"backends:\n - name: a\n url: http://localhost:18080\n - name: b\n url: http://localhost:18081\n"
if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
@@ -43,24 +89,22 @@ func TestLoad_FileAndEnvOverride(t *testing.T) {
// env beats file for listen.
t.Setenv(envPrefix+"LISTEN", "127.0.0.1:1234")
cfg, err := Load()
cfg, err := Load("")
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Listen != "127.0.0.1:1234" {
t.Errorf("env should beat file for listen, got %q", cfg.Listen)
}
if cfg.Merge != mergeStatic || cfg.Primary != "old" {
t.Errorf("file override failed: merge=%s primary=%s", cfg.Merge, cfg.Primary)
if cfg.Merge != mergeStatic {
t.Errorf("file override failed: merge=%s", cfg.Merge)
}
}
func TestApplyEnv_Backends(t *testing.T) {
cfg := DefaultConfig()
cfg := testConfigValid()
env := map[string]string{
envPrefix + "BACKENDS": "a=http://a:8080,b=http://b:8080",
envPrefix + "PRIMARY": "a",
envPrefix + "PREFER": "a",
envPrefix + "TIMEOUT": "3s",
envPrefix + "FRESHNESS_TTL": "45s",
}
@@ -82,16 +126,14 @@ func TestValidate(t *testing.T) {
}{
{"ok", func(*Config) {}, false},
{"no backends", func(c *Config) { c.Backends = nil }, true},
{"dup name", func(c *Config) { c.Backends = append(c.Backends, Backend{Name: "old", URL: "x"}) }, true},
{"dup name", func(c *Config) { c.Backends = append(c.Backends, Backend{Name: "a", URL: "x"}) }, true},
{"missing url", func(c *Config) { c.Backends[0].URL = "" }, true},
{"primary not a backend", func(c *Config) { c.Primary = "ghost" }, true},
{"prefer not a backend", func(c *Config) { c.Prefer = "ghost" }, true},
{"bad merge", func(c *Config) { c.Merge = "wrong" }, true},
{"zero timeout", func(c *Config) { c.Timeout = 0 }, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := DefaultConfig()
cfg := testConfigValid()
tc.mutate(&cfg)
err := cfg.Validate()
if (err != nil) != tc.wantErr {
@@ -111,16 +153,245 @@ func TestParseBackends(t *testing.T) {
}
}
func TestPrimaryBackend(t *testing.T) {
cfg := DefaultConfig()
if cfg.PrimaryBackend().URL != defaultNewURL {
t.Errorf("primary backend URL = %q, want %q", cfg.PrimaryBackend().URL, defaultNewURL)
func TestExampleConfig_IsValidAndNeutral(t *testing.T) {
cfg := ExampleConfig()
if err := cfg.Validate(); err != nil {
t.Fatalf("example config must validate: %v", err)
}
for _, b := range cfg.Backends {
if !strings.Contains(b.URL, "example.com") {
t.Errorf("example backend %q must use a placeholder host, got %q", b.Name, b.URL)
}
}
}
const testConfigBody = "listen: \":9999\"\nmerge: static\n" +
"backends:\n - name: a\n url: http://localhost:18080\n - name: b\n url: http://localhost:18081\n"
func writeConfigFile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(testConfigBody), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoad_FileOnly(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
clearEnv(t)
path := filepath.Join(dir, appName, configFileName)
writeConfigFile(t, path)
cfg, err := Load("")
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Listen != ":9999" || cfg.Merge != mergeStatic || len(cfg.Backends) != 2 {
t.Errorf("file values not applied: %+v", cfg)
}
if cfg.SourcePath() != path {
t.Errorf("source path = %q, want %q", cfg.SourcePath(), path)
}
}
func TestLoad_EnvOnly_DefaultPathMissing(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
clearEnv(t)
t.Setenv(envPrefix+"BACKENDS", "a=http://localhost:18080")
t.Setenv(envPrefix+"LISTEN", "127.0.0.1:1234")
cfg, err := Load("")
if err != nil {
t.Fatalf("a missing default config file must not be an error: %v", err)
}
if cfg.SourcePath() != "" {
t.Errorf("no file was loaded, source path should be empty, got %q", cfg.SourcePath())
}
if cfg.Listen != "127.0.0.1:1234" || len(cfg.Backends) != 1 {
t.Errorf("env values not applied: %+v", cfg)
}
if err := cfg.Validate(); err != nil {
t.Errorf("env-only config should validate: %v", err)
}
}
// A mounted config file must load with neither HOME nor XDG_CONFIG_HOME set.
func TestLoad_ExplicitPath(t *testing.T) {
mounted := filepath.Join(t.TempDir(), "mounted.yaml")
writeConfigFile(t, mounted)
for _, tc := range []struct {
name string
flag string
env string
}{
{"flag", mounted, ""},
{"env", "", mounted},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("HOME", "")
clearEnv(t)
t.Setenv(envConfigPath, tc.env)
cfg, err := Load(tc.flag)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.SourcePath() != mounted {
t.Errorf("source path = %q, want %q", cfg.SourcePath(), mounted)
}
if cfg.Listen != ":9999" {
t.Errorf("listen = %q, want :9999", cfg.Listen)
}
})
}
}
func TestLoad_ExplicitPathMissingIsError(t *testing.T) {
missing := filepath.Join(t.TempDir(), "typo.yaml")
for _, tc := range []struct {
name string
flag string
env string
}{
{"flag", missing, ""},
{"env", "", missing},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
clearEnv(t)
t.Setenv(envConfigPath, tc.env)
_, err := Load(tc.flag)
if err == nil {
t.Fatal("an explicitly named config file that does not exist must fail loudly")
}
if !strings.Contains(err.Error(), missing) {
t.Errorf("error should name the missing path, got: %v", err)
}
})
}
}
func TestLoad_ExplicitFileStillLosesToEnv(t *testing.T) {
mounted := filepath.Join(t.TempDir(), "mounted.yaml")
writeConfigFile(t, mounted)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
clearEnv(t)
t.Setenv(envConfigPath, mounted)
t.Setenv(envPrefix+"LISTEN", "127.0.0.1:1234")
t.Setenv(envPrefix+"MERGE", mergeFreshness)
cfg, err := Load("")
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Listen != "127.0.0.1:1234" || cfg.Merge != mergeFreshness {
t.Errorf("env must beat the file: %+v", cfg)
}
if len(cfg.Backends) != 2 {
t.Errorf("unset env must leave file backends alone: %+v", cfg.Backends)
}
}
func TestResolveConfigPath_Precedence(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
clearEnv(t)
defaultPath := filepath.Join(dir, appName, configFileName)
if got, explicit := resolveConfigPath(""); got != defaultPath || explicit {
t.Errorf("no file anywhere: got %q explicit=%v, want %q false", got, explicit, defaultPath)
}
writeConfigFile(t, defaultPath)
if got, explicit := resolveConfigPath(""); got != defaultPath || explicit {
t.Errorf("default search: got %q explicit=%v, want %q false", got, explicit, defaultPath)
}
t.Setenv(envConfigPath, "/from/env.yaml")
if got, explicit := resolveConfigPath(""); got != "/from/env.yaml" || !explicit {
t.Errorf("env should beat the search path: got %q explicit=%v", got, explicit)
}
if got, explicit := resolveConfigPath("/from/flag.yaml"); got != "/from/flag.yaml" || !explicit {
t.Errorf("flag should beat env: got %q explicit=%v", got, explicit)
}
}
func TestConfigSearchPaths_EndsAtSystemDir(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
paths := configSearchPaths()
want := filepath.Join(systemConfigDir, configFileName)
if len(paths) != 2 || paths[1] != want {
t.Errorf("search paths = %v, want the system path %q last", paths, want)
}
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("HOME", "")
if paths := configSearchPaths(); len(paths) != 1 || paths[0] != want {
t.Errorf("without HOME/XDG the search path should be just %q, got %v", want, paths)
}
}
func TestPrintConfig_ReportsSource(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
clearEnv(t)
out := captureStdout(t, func() {
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
printConfig(cfg)
})
if !strings.Contains(out, "none loaded") || !strings.Contains(out, filepath.Join(dir, appName, configFileName)) {
t.Errorf("config show should report nothing was loaded and what it searched, got:\n%s", out)
}
path := filepath.Join(dir, appName, configFileName)
writeConfigFile(t, path)
out = captureStdout(t, func() {
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
printConfig(cfg)
})
if !strings.Contains(out, path+" (loaded)") {
t.Errorf("config show should name the loaded file, got:\n%s", out)
}
}
func captureStdout(t *testing.T, f func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
orig := os.Stdout
os.Stdout = w
defer func() { os.Stdout = orig }()
f()
if err := w.Close(); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if _, err := buf.ReadFrom(r); err != nil {
t.Fatal(err)
}
return buf.String()
}
func clearEnv(t *testing.T) {
t.Helper()
for _, k := range []string{"LISTEN", "PRIMARY", "MERGE", "PREFER", "TIMEOUT", "FRESHNESS_TTL", "BACKENDS"} {
for _, k := range []string{"CONFIG", "LISTEN", "MERGE", "TIMEOUT", "FRESHNESS_TTL", "BACKENDS"} {
t.Setenv(envPrefix+k, "")
}
}
+45 -48
View File
@@ -1,21 +1,4 @@
// Command pdbmux is a small merging HTTP proxy over two PuppetDB backends.
//
// During the VM -> k8s Puppet migration there are two PuppetDBs — the legacy
// Consul-registered one and the new k8s one — and nodes move between them as
// they migrate. pdbmux presents a single merged PuppetDB v4 query surface so
// node-lookup and pblastreport (and anything else) see one consistent view:
//
// - GET /pdb/query/v4/nodes — fan out to both backends, dedupe by certname,
// keep the record with the newer report_timestamp.
// - GET /pdb/query/v4/facts — fan out to both, and for a certname present in
// both keep ALL facts from the backend holding that node's newer report
// (freshness merge) or a static preferred backend (static merge).
// - any other GET /pdb/query/v4/* — transparently proxied to the primary.
// - GET /healthz — per-backend reachability.
//
// The query param is forwarded verbatim (PuppetDB AST JSON). If one backend
// errors/times out, the other's results are served; only if both fail does a
// merged endpoint return 502.
// Command pdbmux is a small merging HTTP proxy over several PuppetDB backends.
package main
import (
@@ -26,6 +9,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -35,25 +19,30 @@ import (
var version = "dev"
func main() {
cfg, err := Load()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
os.Exit(1)
}
var (
listen string
primary string
merge string
cfg Config
configPath string
listen string
merge string
)
// Loaded lazily: --config is only known once cobra has parsed flags.
loadConfig := func() error {
c, err := Load(configPath)
if err != nil {
return err
}
cfg = c
return nil
}
serve := func(cmd *cobra.Command) error {
if err := loadConfig(); err != nil {
return err
}
if cmd.Flags().Changed("listen") {
cfg.Listen = listen
}
if cmd.Flags().Changed("primary") {
cfg.Primary = primary
}
if cmd.Flags().Changed("merge") {
cfg.Merge = merge
}
@@ -65,19 +54,19 @@ func main() {
root := &cobra.Command{
Use: appName,
Short: "Merging HTTP proxy over two PuppetDB backends.",
Long: "pdbmux presents a single merged PuppetDB v4 query surface over the old\n" +
"(Consul) and new (k8s) PuppetDBs during the migration, so node-lookup and\n" +
"pblastreport see one consistent view. Running pdbmux with no subcommand\n" +
"(or `pdbmux serve`) starts the proxy.",
Short: "Merging HTTP proxy over several PuppetDB backends.",
Long: "pdbmux presents a single merged PuppetDB v4 query surface over several\n" +
"PuppetDB backends, so clients see one consistent view of nodes, facts and\n" +
"reports spanning all of them. Running pdbmux with no subcommand (or\n" +
"`pdbmux serve`) starts the proxy.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error { return serve(cmd) },
}
pf := root.PersistentFlags()
pf.StringVar(&listen, "listen", cfg.Listen, "HTTP listen address (overrides config and PDBMUX_LISTEN)")
pf.StringVar(&primary, "primary", cfg.Primary, "Primary backend name for non-merged pass-through")
pf.StringVar(&merge, "merge", cfg.Merge, "Facts merge strategy: freshness or static")
pf.StringVar(&configPath, "config", "", "Config file path (overrides PDBMUX_CONFIG and the default search path)")
pf.StringVar(&listen, "listen", defaultListen, "HTTP listen address (overrides config and PDBMUX_LISTEN)")
pf.StringVar(&merge, "merge", mergeFreshness, "Facts merge strategy: freshness or static")
serveCmd := &cobra.Command{
Use: "serve",
@@ -90,15 +79,24 @@ func main() {
configCmd.AddCommand(
&cobra.Command{
Use: "init",
Short: "Write a default config file to " + ConfigPath(),
Short: "Write a default config file (--config path, else " + ConfigPath() + ")",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error { return writeDefaultConfig() },
RunE: func(cmd *cobra.Command, args []string) error {
path := explicitConfigPath(configPath)
if path == "" {
path = ConfigPath()
}
return writeDefaultConfig(path)
},
},
&cobra.Command{
Use: "show",
Short: "Print the active configuration",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if err := loadConfig(); err != nil {
return err
}
printConfig(cfg)
return nil
},
@@ -119,8 +117,6 @@ func main() {
}
}
// runServer starts the HTTP server and blocks until SIGINT/SIGTERM, then
// gracefully shuts down.
func runServer(cfg Config) error {
logger := log.New(os.Stderr, "pdbmux: ", log.LstdFlags)
srv := NewServer(cfg, logger)
@@ -131,8 +127,8 @@ func runServer(cfg Config) error {
ReadHeaderTimeout: 10 * time.Second,
}
logger.Printf("listening on %s (merge=%s primary=%s backends=%d)",
cfg.Listen, cfg.Merge, cfg.Primary, len(cfg.Backends))
logger.Printf("listening on %s (merge=%s backends=%d)",
cfg.Listen, cfg.Merge, len(cfg.Backends))
errCh := make(chan error, 1)
go func() {
@@ -155,13 +151,14 @@ func runServer(cfg Config) error {
}
}
// printConfig renders the active config for `config show`.
func printConfig(cfg Config) {
fmt.Printf("config file : %s\n", ConfigPath())
if p := cfg.SourcePath(); p != "" {
fmt.Printf("config file : %s (loaded)\n", p)
} else {
fmt.Printf("config file : none loaded (searched %s)\n", strings.Join(configSearchPaths(), ", "))
}
fmt.Printf("listen : %s\n", cfg.Listen)
fmt.Printf("primary : %s\n", cfg.Primary)
fmt.Printf("merge : %s\n", cfg.Merge)
fmt.Printf("prefer : %s\n", cfg.Prefer)
fmt.Printf("timeout : %s\n", durationString(cfg.Timeout))
fmt.Printf("freshness_ttl: %s\n", durationString(cfg.FreshnessTTL))
fmt.Println("backends:")
+14 -32
View File
@@ -5,24 +5,20 @@ import (
"time"
)
// record is a single PuppetDB result element kept as raw JSON so unknown fields
// survive the merge untouched. certname/report_timestamp are decoded only for
// merge decisions.
// Raw is kept verbatim so unknown PuppetDB fields survive the merge.
type record struct {
Raw json.RawMessage
Certname string
ReportTimestamp string // only populated for /nodes records
Hash string // only populated for /reports records
}
// recordMeta is the subset we decode from any /nodes or /facts element to drive
// merge decisions.
type recordMeta struct {
Certname string `json:"certname"`
ReportTimestamp string `json:"report_timestamp"`
Hash string `json:"hash"`
}
// decodeRecords turns a raw PuppetDB JSON array into records, preserving each
// element verbatim in Raw. A body that is not a JSON array yields (nil, err).
func decodeRecords(body []byte) ([]record, error) {
var raws []json.RawMessage
if err := json.Unmarshal(body, &raws); err != nil {
@@ -36,13 +32,13 @@ func decodeRecords(body []byte) ([]record, error) {
Raw: raw,
Certname: m.Certname,
ReportTimestamp: m.ReportTimestamp,
Hash: m.Hash,
})
}
return out, nil
}
// parseTimestamp parses a PuppetDB RFC3339(nano) timestamp. Zero time on
// failure sorts oldest, so a backend with a well-formed newer timestamp wins.
// An unparseable timestamp yields the zero time, which sorts oldest.
func parseTimestamp(s string) time.Time {
if s == "" {
return time.Time{}
@@ -53,10 +49,7 @@ func parseTimestamp(s string) time.Time {
return time.Time{}
}
// mergeNodes dedupes /nodes records by certname, keeping the one with the newer
// report_timestamp. backends is the ordered list of (name, records) results;
// when timestamps tie (or both are zero), the earlier backend in the slice
// wins, so callers should order by precedence.
// Ties keep the earlier backend's record — a deterministic tie-break, not a preference.
func mergeNodes(results []backendResult) []json.RawMessage {
type pick struct {
raw json.RawMessage
@@ -73,7 +66,6 @@ func mergeNodes(results []backendResult) []json.RawMessage {
order = append(order, rec.Certname)
continue
}
// Strictly-newer wins; ties keep the existing (earlier-backend) pick.
if ts.After(cur.ts) {
best[rec.Certname] = pick{raw: rec.Raw, ts: ts}
}
@@ -86,12 +78,10 @@ func mergeNodes(results []backendResult) []json.RawMessage {
return out
}
// freshness maps certname -> backend name that holds that node's newest report.
// certname -> name of the backend holding that node's newest report.
type freshness map[string]string
// buildFreshness computes, per certname, which backend has the newer
// report_timestamp. results must be ordered by precedence; on a tie the
// earlier backend wins.
// Ties keep the earlier backend — a deterministic tie-break, not a preference.
func buildFreshness(results []backendResult) freshness {
type pick struct {
backend string
@@ -114,19 +104,9 @@ func buildFreshness(results []backendResult) freshness {
return f
}
// mergeFacts merges /facts records at node granularity: for each certname, all
// facts from the winning backend are kept and the other backend's facts for
// that certname are dropped.
//
// The winner is chosen per certname by `owner(certname)`. Callers supply owner
// from either a freshness map (freshness merge) or a constant preferred backend
// (static merge). When owner returns a backend that has no facts for a certname
// (or a name not in results), records fall back to precedence order so a node
// present in only one backend still appears.
// owner names the winning backend per certname; a nil owner (static merge), or one holding no facts for that certname, falls back to configured order.
func mergeFacts(results []backendResult, owner func(certname string) string) []json.RawMessage {
// Which backends actually returned facts for each certname, in precedence
// order, so we can fall back if the chosen owner has none.
present := map[string][]string{} // certname -> ordered backend names
present := map[string][]string{} // certname -> backend names, in configured order
byKey := map[string][]json.RawMessage{}
for _, res := range results {
for _, rec := range res.records {
@@ -153,8 +133,10 @@ func mergeFacts(results []backendResult, owner func(certname string) string) []j
out := []json.RawMessage{}
for _, cn := range order {
backends := present[cn]
chosen := owner(cn)
// Fall back to precedence order if the chosen backend has no facts here.
chosen := ""
if owner != nil {
chosen = owner(cn)
}
if !contains(backends, chosen) {
chosen = backends[0]
}
+74 -59
View File
@@ -74,11 +74,25 @@ func fact(cn, name, val, ts string) string {
return `{"certname":"` + cn + `","name":"` + name + `","value":"` + val + `","report_timestamp":"` + ts + `"}`
}
func TestMergeNodes_NewerWins(t *testing.T) {
old := recs(t, "old", node("h1", "2026-07-01T00:00:00Z"), node("h2", "2026-07-10T00:00:00Z"))
nw := recs(t, "new", node("h1", "2026-07-20T00:00:00Z"), node("h3", "2026-07-05T00:00:00Z"))
// report builds a /reports record with the fields the merge and ordering paths
// care about.
func report(cn, hash, receive string) string {
return `{"certname":"` + cn + `","hash":"` + hash + `","receive_time":"` + receive +
`","end_time":"` + receive + `","status":"changed","environment":"production"}`
}
merged := mergeNodes([]backendResult{old, nw})
// event builds an /events record, which carries its report's hash but no id of
// its own.
func event(cn, reportHash, resource string) string {
return `{"certname":"` + cn + `","report":"` + reportHash + `","resource_title":"` + resource +
`","status":"success","timestamp":"2026-07-01T00:00:00Z"}`
}
func TestMergeNodes_NewerWins(t *testing.T) {
a := recs(t, "a", node("h1", "2026-07-01T00:00:00Z"), node("h2", "2026-07-10T00:00:00Z"))
b := recs(t, "b", node("h1", "2026-07-20T00:00:00Z"), node("h3", "2026-07-05T00:00:00Z"))
merged := mergeNodes([]backendResult{a, b})
got := map[string]string{}
for _, r := range merged {
var m recordMeta
@@ -86,13 +100,13 @@ func TestMergeNodes_NewerWins(t *testing.T) {
got[m.Certname] = m.ReportTimestamp
}
if got["h1"] != "2026-07-20T00:00:00Z" {
t.Errorf("h1: newer (new) should win, got %s", got["h1"])
t.Errorf("h1: newer (b) should win, got %s", got["h1"])
}
if got["h2"] != "2026-07-10T00:00:00Z" {
t.Errorf("h2: only in old, got %s", got["h2"])
t.Errorf("h2: only in a, got %s", got["h2"])
}
if got["h3"] != "2026-07-05T00:00:00Z" {
t.Errorf("h3: only in new, got %s", got["h3"])
t.Errorf("h3: only in b, got %s", got["h3"])
}
if len(merged) != 3 {
t.Errorf("expected 3 deduped nodes, got %d", len(merged))
@@ -100,20 +114,20 @@ func TestMergeNodes_NewerWins(t *testing.T) {
}
func TestMergeNodes_OneBackendOnly(t *testing.T) {
old := recs(t, "old", node("h1", "2026-07-01T00:00:00Z"))
// new returned nothing (e.g. empty result).
nw := backendResult{name: "new"}
merged := mergeNodes([]backendResult{old, nw})
a := recs(t, "a", node("h1", "2026-07-01T00:00:00Z"))
// b returned nothing (e.g. empty result).
b := backendResult{name: "b"}
merged := mergeNodes([]backendResult{a, b})
if len(merged) != 1 || certnames(t, merged)[0] != "h1" {
t.Fatalf("expected only h1, got %v", certnames(t, merged))
}
}
func TestMergeNodes_TieKeepsEarlierBackend(t *testing.T) {
// Equal timestamps: the backend listed first (precedence) wins.
prefer := recs(t, "new", node("h1", "2026-07-01T00:00:00Z"))
other := recs(t, "old", node("h1", "2026-07-01T00:00:00Z"))
merged := mergeNodes([]backendResult{prefer, other})
// Equal timestamps: the backend listed first wins, as a tie-break.
first := recs(t, "b", node("h1", "2026-07-01T00:00:00Z"))
second := recs(t, "a", node("h1", "2026-07-01T00:00:00Z"))
merged := mergeNodes([]backendResult{first, second})
if len(merged) != 1 {
t.Fatalf("expected 1 record, got %d", len(merged))
}
@@ -124,8 +138,8 @@ func TestMergeNodes_TieKeepsEarlierBackend(t *testing.T) {
}
func TestMergeNodes_PreservesUnknownFields(t *testing.T) {
old := recs(t, "old", `{"certname":"h1","report_timestamp":"2026-07-01T00:00:00Z","extra":{"deep":42}}`)
merged := mergeNodes([]backendResult{old})
a := recs(t, "a", `{"certname":"h1","report_timestamp":"2026-07-01T00:00:00Z","extra":{"deep":42}}`)
merged := mergeNodes([]backendResult{a})
if len(merged) != 1 {
t.Fatalf("expected 1 record")
}
@@ -136,69 +150,70 @@ func TestMergeNodes_PreservesUnknownFields(t *testing.T) {
}
}
func TestMergeFacts_Static_PreferWins(t *testing.T) {
// h1 in both; static prefer=new -> new's facts kept, old's dropped.
old := recs(t, "old", fact("h1", "role", "web-old", ""), fact("h2", "role", "db-old", ""))
nw := recs(t, "new", fact("h1", "role", "web-new", ""))
func TestMergeFacts_NilOwnerUsesConfiguredOrder(t *testing.T) {
// Static merge passes no owner: h1 is in both, so the first backend in the
// slice supplies its facts.
a := recs(t, "a", fact("h1", "role", "web-a", ""), fact("h2", "role", "db-a", ""))
b := recs(t, "b", fact("h1", "role", "web-b", ""))
merged := mergeFacts([]backendResult{nw, old}, func(string) string { return "new" })
merged := mergeFacts([]backendResult{b, a}, nil)
got := factValues(t, merged)
assertContains(t, got, "h1:role=web-new")
assertNotContains(t, got, "h1:role=web-old")
// h2 only in old -> falls back to old.
assertContains(t, got, "h2:role=db-old")
assertContains(t, got, "h1:role=web-b")
assertNotContains(t, got, "h1:role=web-a")
// h2 only in a -> still served from a.
assertContains(t, got, "h2:role=db-a")
}
func TestMergeFacts_Freshness_NewerBackendWins(t *testing.T) {
// owner map says h1 belongs to old (older backend has the newer report),
// h2 belongs to new. Multiple facts per node must all come from the winner.
old := recs(t, "old",
fact("h1", "role", "web-old", ""), fact("h1", "ip", "10.0.0.1", ""),
fact("h2", "role", "db-old", ""))
nw := recs(t, "new",
fact("h1", "role", "web-new", ""), fact("h1", "ip", "10.9.9.9", ""),
fact("h2", "role", "db-new", ""), fact("h2", "ip", "10.0.0.2", ""))
// owner map says h1 belongs to a and h2 to b. Multiple facts per node must
// all come from the winner.
a := recs(t, "a",
fact("h1", "role", "web-a", ""), fact("h1", "ip", "10.0.0.1", ""),
fact("h2", "role", "db-a", ""))
b := recs(t, "b",
fact("h1", "role", "web-b", ""), fact("h1", "ip", "10.9.9.9", ""),
fact("h2", "role", "db-b", ""), fact("h2", "ip", "10.0.0.2", ""))
owner := func(cn string) string {
if cn == "h1" {
return "old"
return "a"
}
return "new"
return "b"
}
merged := mergeFacts([]backendResult{nw, old}, owner)
merged := mergeFacts([]backendResult{b, a}, owner)
got := factValues(t, merged)
// h1 -> all old facts, no new facts.
assertContains(t, got, "h1:role=web-old")
// h1 -> all a facts, no b facts.
assertContains(t, got, "h1:role=web-a")
assertContains(t, got, "h1:ip=10.0.0.1")
assertNotContains(t, got, "h1:role=web-new")
assertNotContains(t, got, "h1:role=web-b")
assertNotContains(t, got, "h1:ip=10.9.9.9")
// h2 -> all new facts.
assertContains(t, got, "h2:role=db-new")
// h2 -> all b facts.
assertContains(t, got, "h2:role=db-b")
assertContains(t, got, "h2:ip=10.0.0.2")
assertNotContains(t, got, "h2:role=db-old")
assertNotContains(t, got, "h2:role=db-a")
}
func TestMergeFacts_OwnerMissingFallsBackToPrecedence(t *testing.T) {
// owner returns a backend with no facts for h1 -> fall back to first
// backend present (precedence order of the slice).
prefer := recs(t, "new", fact("h1", "role", "web-new", ""))
other := recs(t, "old", fact("h1", "role", "web-old", ""))
merged := mergeFacts([]backendResult{prefer, other}, func(string) string { return "ghost" })
func TestMergeFacts_OwnerMissingFallsBackToConfiguredOrder(t *testing.T) {
// owner returns a backend with no facts for h1 -> fall back to the first
// backend in the slice that has some.
first := recs(t, "b", fact("h1", "role", "web-b", ""))
second := recs(t, "a", fact("h1", "role", "web-a", ""))
merged := mergeFacts([]backendResult{first, second}, func(string) string { return "ghost" })
got := factValues(t, merged)
assertContains(t, got, "h1:role=web-new") // new is first in slice
assertNotContains(t, got, "h1:role=web-old")
assertContains(t, got, "h1:role=web-b") // b is first in slice
assertNotContains(t, got, "h1:role=web-a")
}
func TestBuildFreshness(t *testing.T) {
// old has newer report for h1; new has newer for h2.
old := recs(t, "old", node("h1", "2026-07-20T00:00:00Z"), node("h2", "2026-07-01T00:00:00Z"))
nw := recs(t, "new", node("h1", "2026-07-01T00:00:00Z"), node("h2", "2026-07-20T00:00:00Z"))
f := buildFreshness([]backendResult{old, nw})
if f["h1"] != "old" {
t.Errorf("h1 should belong to old, got %q", f["h1"])
// a holds the newer report for h1; b holds the newer one for h2.
a := recs(t, "a", node("h1", "2026-07-20T00:00:00Z"), node("h2", "2026-07-01T00:00:00Z"))
b := recs(t, "b", node("h1", "2026-07-01T00:00:00Z"), node("h2", "2026-07-20T00:00:00Z"))
f := buildFreshness([]backendResult{a, b})
if f["h1"] != "a" {
t.Errorf("h1 should belong to a, got %q", f["h1"])
}
if f["h2"] != "new" {
t.Errorf("h2 should belong to new, got %q", f["h2"])
if f["h2"] != "b" {
t.Errorf("h2 should belong to b, got %q", f["h2"])
}
}
+222
View File
@@ -0,0 +1,222 @@
package main
import (
"encoding/json"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
)
// The first backend in results holding a key supplies the record; a key func returning ok=false means the record has no identity and is always kept.
func mergeUnion(results []backendResult, key func(record) (string, bool)) []json.RawMessage {
seen := make(map[string]bool)
out := []json.RawMessage{}
for _, res := range results {
for _, rec := range res.records {
if k, ok := key(rec); ok {
if seen[k] {
continue
}
seen[k] = true
}
out = append(out, rec.Raw)
}
}
return out
}
// extract/group_by rows are synthetic and carry no hash, so two backends can legitimately emit identical ones.
func reportKey(rec record) (string, bool) {
if rec.Hash == "" {
return "", false
}
return "hash\x00" + rec.Hash, true
}
// Events carry no id, but byte-identical events from the same PuppetDB serialiser are the same change.
func rawKey(rec record) (string, bool) { return "raw\x00" + string(rec.Raw), true }
type orderField struct {
Field string
Desc bool
}
// order_by is a JSON array of {"field": ..., "order": "asc"|"desc"} objects.
func parseOrderBy(s string) ([]orderField, error) {
if strings.TrimSpace(s) == "" {
return nil, nil
}
var raw []struct {
Field string `json:"field"`
Order string `json:"order"`
}
if err := json.Unmarshal([]byte(s), &raw); err != nil {
return nil, fmt.Errorf("order_by is not a JSON array: %w", err)
}
out := make([]orderField, 0, len(raw))
for _, r := range raw {
if r.Field == "" {
return nil, fmt.Errorf("order_by entry is missing a field")
}
out = append(out, orderField{Field: r.Field, Desc: strings.EqualFold(r.Order, "desc")})
}
return out, nil
}
// Each backend ordered only its own slice, so the union is re-sorted here; stable, so ties keep the merged set's existing order.
func sortRecords(recs []json.RawMessage, order []orderField) {
if len(order) == 0 || len(recs) < 2 {
return
}
objs := make([]map[string]any, len(recs))
for i, raw := range recs {
_ = json.Unmarshal(raw, &objs[i]) // non-objects sort as all-missing fields
}
idx := make([]int, len(recs))
for i := range idx {
idx[i] = i
}
sort.SliceStable(idx, func(a, b int) bool {
oa, ob := objs[idx[a]], objs[idx[b]]
for _, f := range order {
c := compareValues(oa[f.Field], ob[f.Field])
if c == 0 {
continue
}
if f.Desc {
return c > 0
}
return c < 0
}
return false
})
sorted := make([]json.RawMessage, len(recs))
for i, j := range idx {
sorted[i] = recs[j]
}
copy(recs, sorted)
}
// Unlike types order by kind (null < bool < number < string), so a missing field sorts first.
func compareValues(a, b any) int {
ra, rb := valueRank(a), valueRank(b)
if ra != rb {
if ra < rb {
return -1
}
return 1
}
switch av := a.(type) {
case bool:
bv := b.(bool)
switch {
case av == bv:
return 0
case bv:
return -1
default:
return 1
}
case float64:
bv := b.(float64)
switch {
case av < bv:
return -1
case av > bv:
return 1
default:
return 0
}
case string:
return strings.Compare(av, b.(string))
}
return 0
}
func valueRank(v any) int {
switch v.(type) {
case nil:
return 0
case bool:
return 1
case float64:
return 2
case string:
return 3
default:
return 4
}
}
type paging struct {
limit int // -1 when unset
offset int
order []orderField
wantTotal bool
}
func parsePaging(v url.Values) (paging, error) {
p := paging{limit: -1}
if s := v.Get("limit"); s != "" {
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return p, fmt.Errorf("limit must be a non-negative integer, got %q", s)
}
p.limit = n
}
if s := v.Get("offset"); s != "" {
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return p, fmt.Errorf("offset must be a non-negative integer, got %q", s)
}
p.offset = n
}
order, err := parseOrderBy(v.Get("order_by"))
if err != nil {
return p, err
}
p.order = order
p.wantTotal = v.Get("include_total") == "true"
return p, nil
}
// Backends are asked for the first offset+limit records with no offset; the offset is applied to the union instead.
func (p paging) upstreamParams(in url.Values) url.Values {
out := url.Values{}
for k, vs := range in {
out[k] = append([]string(nil), vs...)
}
out.Del("offset")
if p.limit >= 0 {
out.Set("limit", strconv.Itoa(p.limit+p.offset))
}
return out
}
func (p paging) apply(recs []json.RawMessage) []json.RawMessage {
if p.offset >= len(recs) {
return []json.RawMessage{}
}
recs = recs[p.offset:]
if p.limit >= 0 && p.limit < len(recs) {
recs = recs[:p.limit]
}
return recs
}
// Returns -1 when no backend reported a count; duplicates count once per backend, so the sum is an upper bound.
func sumTotals(results []backendResult) int {
total := -1
for _, res := range results {
if res.total < 0 {
continue
}
if total < 0 {
total = 0
}
total += res.total
}
return total
}
+254
View File
@@ -0,0 +1,254 @@
package main
import (
"encoding/json"
"net/url"
"slices"
"testing"
)
func TestMergeUnion_KeepsBothBackendsHistory(t *testing.T) {
a := recs(t, "a", report("h1", "r1", "2026-07-01T00:00:00Z"))
b := recs(t, "b", report("h1", "r2", "2026-07-02T00:00:00Z"))
merged := mergeUnion([]backendResult{b, a}, reportKey)
if got := hashesOf(t, merged); !slices.Equal(got, []string{"r2", "r1"}) {
t.Errorf("union = %v, want both reports in configured order", got)
}
}
func TestMergeUnion_DedupesSharedHash(t *testing.T) {
dup := report("h1", "r1", "2026-07-01T00:00:00Z")
merged := mergeUnion([]backendResult{recs(t, "b", dup), recs(t, "a", dup)}, reportKey)
if got := hashesOf(t, merged); !slices.Equal(got, []string{"r1"}) {
t.Errorf("union = %v, want a single r1", got)
}
}
func TestMergeUnion_HashlessRowsAreAllKept(t *testing.T) {
// extract/group_by queries return synthetic rows with no hash; dropping the
// second backend's rows as "duplicates" would silently lose half the data.
a := recs(t, "a", `{"status":"changed","count":3}`)
b := recs(t, "b", `{"status":"changed","count":5}`)
merged := mergeUnion([]backendResult{a, b}, reportKey)
if len(merged) != 2 {
t.Errorf("expected both aggregate rows, got %d: %v", len(merged), merged)
}
}
func TestMergeUnion_IdenticalHashlessRowsAreNotCollapsed(t *testing.T) {
// Two backends can legitimately produce the same aggregate row; collapsing
// them as duplicates undercounts the merged result.
same := `{"status":"changed","count":1}`
merged := mergeUnion([]backendResult{recs(t, "a", same), recs(t, "b", same)}, reportKey)
if len(merged) != 2 {
t.Errorf("expected both backends' aggregate rows, got %d: %v", len(merged), merged)
}
}
func TestMergeUnion_EventsDedupeOnRawIdentity(t *testing.T) {
same := event("h1", "r1", "Package[nginx]")
other := event("h1", "r1", "Service[nginx]")
merged := mergeUnion([]backendResult{recs(t, "b", same, other), recs(t, "a", same)}, rawKey)
if len(merged) != 2 {
t.Errorf("expected 2 distinct events, got %d: %v", len(merged), merged)
}
}
func TestParseOrderBy(t *testing.T) {
got, err := parseOrderBy(`[{"field":"receive_time","order":"desc"},{"field":"certname"}]`)
if err != nil {
t.Fatal(err)
}
want := []orderField{{Field: "receive_time", Desc: true}, {Field: "certname"}}
if !slices.Equal(got, want) {
t.Errorf("parseOrderBy = %v, want %v", got, want)
}
if got, err := parseOrderBy(" "); err != nil || got != nil {
t.Errorf("empty order_by = %v, %v; want nil, nil", got, err)
}
for _, bad := range []string{`receive_time`, `[{"order":"desc"}]`} {
if _, err := parseOrderBy(bad); err == nil {
t.Errorf("parseOrderBy(%q) should have failed", bad)
}
}
}
func TestSortRecords_MultipleFieldsAndStability(t *testing.T) {
raws := rawsOf(t,
`{"certname":"b","status":"failed","hash":"r1"}`,
`{"certname":"a","status":"changed","hash":"r2"}`,
`{"certname":"a","status":"changed","hash":"r3"}`,
`{"certname":"a","status":"failed","hash":"r4"}`,
)
sortRecords(raws, []orderField{{Field: "certname"}, {Field: "status", Desc: true}})
// certname asc, then status desc; r2/r3 tie fully and keep input order.
if got := hashesOf(t, raws); !slices.Equal(got, []string{"r4", "r2", "r3", "r1"}) {
t.Errorf("sorted = %v, want [r4 r2 r3 r1]", got)
}
}
func TestSortRecords_MissingFieldSortsFirst(t *testing.T) {
raws := rawsOf(t,
`{"hash":"r1","receive_time":"2026-07-01T00:00:00Z"}`,
`{"hash":"r2"}`,
)
sortRecords(raws, []orderField{{Field: "receive_time"}})
if got := hashesOf(t, raws); !slices.Equal(got, []string{"r2", "r1"}) {
t.Errorf("sorted = %v, want the record missing the field first", got)
}
}
func TestSortRecords_NoOrderLeavesInputOrder(t *testing.T) {
raws := rawsOf(t, `{"hash":"r1"}`, `{"hash":"r2"}`)
sortRecords(raws, nil)
if got := hashesOf(t, raws); !slices.Equal(got, []string{"r1", "r2"}) {
t.Errorf("sorted = %v, want unchanged", got)
}
}
func TestCompareValues_AcrossKinds(t *testing.T) {
cases := []struct {
a, b any
want int
}{
{nil, false, -1},
{false, true, -1},
{true, 1.0, -1},
{1.0, 2.0, -1},
{2.0, 2.0, 0},
{2.0, "x", -1},
{"a", "b", -1},
{"b", "a", 1},
}
for _, c := range cases {
if got := compareValues(c.a, c.b); got != c.want {
t.Errorf("compareValues(%v, %v) = %d, want %d", c.a, c.b, got, c.want)
}
if got := compareValues(c.b, c.a); got != -c.want {
t.Errorf("compareValues(%v, %v) = %d, want %d (antisymmetry)", c.b, c.a, got, -c.want)
}
}
}
func TestParsePaging(t *testing.T) {
p, err := parsePaging(url.Values{
"limit": {"25"},
"offset": {"50"},
"include_total": {"true"},
"order_by": {`[{"field":"receive_time","order":"desc"}]`},
})
if err != nil {
t.Fatal(err)
}
if p.limit != 25 || p.offset != 50 || !p.wantTotal || len(p.order) != 1 {
t.Fatalf("parsePaging = %+v", p)
}
if p, err := parsePaging(nil); err != nil || p.limit != -1 || p.offset != 0 || p.wantTotal {
t.Errorf("empty params = %+v, %v; want limit=-1 and no paging", p, err)
}
for _, bad := range []url.Values{{"limit": {"-1"}}, {"limit": {"x"}}, {"offset": {"x"}}} {
if _, err := parsePaging(bad); err == nil {
t.Errorf("parsePaging(%v) should have failed", bad)
}
}
}
func TestPagingUpstreamParams(t *testing.T) {
in := url.Values{
"query": {`["=","certname","h1"]`},
"limit": {"25"},
"offset": {"50"},
}
p, err := parsePaging(in)
if err != nil {
t.Fatal(err)
}
out := p.upstreamParams(in)
if out.Get("limit") != "75" {
t.Errorf("upstream limit = %q, want 75 (offset+limit)", out.Get("limit"))
}
if out.Has("offset") {
t.Errorf("upstream offset = %q, want it dropped", out.Get("offset"))
}
if out.Get("query") != in.Get("query") {
t.Errorf("query should pass through verbatim, got %q", out.Get("query"))
}
if in.Get("limit") != "25" {
t.Errorf("upstreamParams must not mutate the caller's params, limit is now %q", in.Get("limit"))
}
}
func TestPagingUpstreamParams_NoLimitLeavesQueryUnbounded(t *testing.T) {
in := url.Values{"offset": {"5"}}
p, err := parsePaging(in)
if err != nil {
t.Fatal(err)
}
out := p.upstreamParams(in)
if out.Has("limit") || out.Has("offset") {
t.Errorf("upstream params = %v, want neither limit nor offset", out)
}
}
func TestPagingApply(t *testing.T) {
raws := rawsOf(t, `{"hash":"r1"}`, `{"hash":"r2"}`, `{"hash":"r3"}`)
cases := []struct {
name string
page paging
want []string
}{
{name: "window", page: paging{limit: 1, offset: 1}, want: []string{"r2"}},
{name: "limit past end", page: paging{limit: 10}, want: []string{"r1", "r2", "r3"}},
{name: "offset past end", page: paging{limit: 2, offset: 9}, want: nil},
{name: "unset limit", page: paging{limit: -1, offset: 2}, want: []string{"r3"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := hashesOf(t, c.page.apply(raws))
if len(got) == 0 && len(c.want) == 0 {
return
}
if !slices.Equal(got, c.want) {
t.Errorf("apply = %v, want %v", got, c.want)
}
})
}
}
func TestSumTotals(t *testing.T) {
if got := sumTotals([]backendResult{{total: 40}, {total: 60}}); got != 100 {
t.Errorf("sumTotals = %d, want 100", got)
}
if got := sumTotals([]backendResult{{total: -1}, {total: 7}}); got != 7 {
t.Errorf("sumTotals should skip backends without a count, got %d", got)
}
if got := sumTotals([]backendResult{{total: -1}, {total: -1}}); got != -1 {
t.Errorf("sumTotals with no counts = %d, want -1", got)
}
}
// rawsOf builds a raw record slice from literal JSON elements.
func rawsOf(t *testing.T, elems ...string) []json.RawMessage {
t.Helper()
out := make([]json.RawMessage, 0, len(elems))
for _, e := range elems {
out = append(out, json.RawMessage(e))
}
return out
}
// hashesOf extracts the hash field from raw records, in order.
func hashesOf(t *testing.T, raws []json.RawMessage) []string {
t.Helper()
out := make([]string, 0, len(raws))
for _, r := range raws {
var m recordMeta
if err := json.Unmarshal(r, &m); err != nil {
t.Fatalf("unmarshal %s: %v", r, err)
}
out = append(out, m.Hash)
}
return out
}
+223 -102
View File
@@ -8,28 +8,32 @@ import (
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
factsPath = "/pdb/query/v4/facts"
nodesPath = "/pdb/query/v4/nodes"
queryV4 = "/pdb/query/v4/"
factsPath = "/pdb/query/v4/facts"
nodesPath = "/pdb/query/v4/nodes"
reportsPath = "/pdb/query/v4/reports"
eventsPath = "/pdb/query/v4/events"
eventCountsPath = "/pdb/query/v4/event-counts"
aggregateEventCountsPath = "/pdb/query/v4/aggregate-event-counts"
queryV4 = "/pdb/query/v4/"
// PuppetDB only sends this when the request carries include_total=true.
recordsHeader = "X-Records"
)
// backendResult is one backend's decoded response for a query. err is non-nil
// when the backend failed (network/timeout/non-2xx); such results carry no
// records and are excluded from the merge but logged.
type backendResult struct {
name string
records []record
total int // upstream X-Records count, or -1 when the backend sent none
err error
}
// Server proxies and merges PuppetDB queries across the configured backends.
type Server struct {
cfg Config
client *http.Client
@@ -41,7 +45,6 @@ type Server struct {
freshAt time.Time
}
// NewServer builds a Server with an HTTP client bounded by cfg.Timeout.
func NewServer(cfg Config, logger *log.Logger) *Server {
return &Server{
cfg: cfg,
@@ -50,7 +53,6 @@ func NewServer(cfg Config, logger *log.Logger) *Server {
}
}
// Handler returns the HTTP mux for the proxy.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealth)
@@ -58,8 +60,6 @@ func (s *Server) Handler() http.Handler {
return mux
}
// handleQuery dispatches /pdb/query/v4/* requests: /facts and /nodes are merged
// across backends; every other v4 path is transparently proxied to the primary.
func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "only GET is supported", http.StatusMethodNotAllowed)
@@ -70,17 +70,129 @@ func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
s.serveMerged(w, r, nodesPath, s.mergeNodesResponse)
case factsPath:
s.serveMerged(w, r, factsPath, s.mergeFactsResponse)
case reportsPath:
s.serveReports(w, r)
case eventsPath:
s.serveUnion(w, r, eventsPath, rawKey)
case eventCountsPath, aggregateEventCountsPath:
s.serveSummed(w, r, r.URL.Path, inferredColumns)
default:
s.proxyPrimary(w, r)
if isReportSubResource(r.URL.Path) {
s.serveFirstHolder(w, r)
return
}
s.proxyUnmerged(w, r)
}
}
// serveMerged fans out the request to all backends, then hands the per-backend
// results to merge to produce the response body. If every backend fails it
// returns 502; if some fail it serves the survivors and logs a warning.
// Matches /pdb/query/v4/reports/<hash>/{events,logs,metrics}, whose data lives in exactly one backend.
func isReportSubResource(path string) bool {
rest, ok := strings.CutPrefix(path, reportsPath+"/")
if !ok {
return false
}
hash, sub, ok := strings.Cut(rest, "/")
if !ok || hash == "" {
return false
}
switch sub {
case "events", "logs", "metrics":
return true
}
return false
}
func (s *Server) serveMerged(w http.ResponseWriter, r *http.Request, path string, merge func([]backendResult) []json.RawMessage) {
query := r.URL.Query().Get("query")
results := s.fanOut(r.Context(), path, query)
alive, ok := s.aliveResults(w, r, path, queryParams(r.URL.Query().Get("query")))
if !ok {
return
}
writeJSON(w, merge(alive))
}
// Reports and events are immutable history, so both backends' records belong in the merged view.
func (s *Server) serveUnion(w http.ResponseWriter, r *http.Request, path string, key func(record) (string, bool)) {
in := r.URL.Query()
page, err := parsePaging(in)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
alive, ok := s.aliveResults(w, r, path, page.upstreamParams(in))
if !ok {
return
}
merged := mergeUnion(alive, key)
sortRecords(merged, page.order)
if page.wantTotal {
if total := sumTotals(alive); total >= 0 {
w.Header().Set(recordsHeader, strconv.Itoa(total))
}
}
writeJSON(w, page.apply(merged))
}
// An `extract` query with a `function` column returns synthetic aggregate rows that carry no identity, so they are summed rather than unioned.
func (s *Server) serveReports(w http.ResponseWriter, r *http.Request) {
if spec := parseAggregate(r.URL.Query().Get("query")); spec != nil {
s.serveSummed(w, r, reportsPath, spec.columns)
return
}
s.serveUnion(w, r, reportsPath, reportKey)
}
// Merged rows are fewer than the backends' combined records, so include_total reports the merged count rather than a sum of X-Records.
func (s *Server) serveSummed(w http.ResponseWriter, r *http.Request, path string, columns func(map[string]json.RawMessage) ([]string, []string)) {
in := r.URL.Query()
page, err := parsePaging(in)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
alive, ok := s.aliveResults(w, r, path, page.upstreamParams(in))
if !ok {
return
}
merged := sumRows(alive, columns)
sortRecords(merged, page.order)
if page.wantTotal {
w.Header().Set(recordsHeader, strconv.Itoa(len(merged)))
}
writeJSON(w, page.apply(merged))
}
// A backend without the report answers 404, indistinguishable from a failure, so every backend is consulted before serving empty.
func (s *Server) serveFirstHolder(w http.ResponseWriter, r *http.Request) {
results := s.fanOut(r.Context(), r.URL.Path, r.URL.Query())
var alive []backendResult
for _, res := range results {
if res.err != nil {
s.log.Printf("info: backend %q has no %s: %v", res.name, r.URL.Path, res.err)
continue
}
alive = append(alive, res)
}
if len(alive) == 0 {
http.Error(w, "no backend holds this report", http.StatusNotFound)
return
}
for _, res := range alive {
if len(res.records) > 0 {
writeJSON(w, rawRecords(res.records))
return
}
}
writeJSON(w, nil)
}
// Writes a 502 and returns ok=false only when every backend failed.
func (s *Server) aliveResults(w http.ResponseWriter, r *http.Request, path string, params url.Values) ([]backendResult, bool) {
results := s.fanOut(r.Context(), path, params)
var alive []backendResult
for _, res := range results {
@@ -92,58 +204,39 @@ func (s *Server) serveMerged(w http.ResponseWriter, r *http.Request, path string
}
if len(alive) == 0 {
http.Error(w, "all backends failed", http.StatusBadGateway)
return
return nil, false
}
merged := merge(alive)
writeJSON(w, merged)
return alive, true
}
func queryParams(query string) url.Values {
if query == "" {
return nil
}
return url.Values{"query": []string{query}}
}
func rawRecords(recs []record) []json.RawMessage {
out := make([]json.RawMessage, 0, len(recs))
for _, rec := range recs {
out = append(out, rec.Raw)
}
return out
}
// mergeNodesResponse merges /nodes results (dedupe by certname, newer wins).
func (s *Server) mergeNodesResponse(results []backendResult) []json.RawMessage {
return mergeNodes(s.byPrecedence(results))
return mergeNodes(results)
}
// mergeFactsResponse merges /facts results at node granularity, choosing each
// certname's owner via the configured merge strategy.
func (s *Server) mergeFactsResponse(results []backendResult) []json.RawMessage {
ordered := s.byPrecedence(results)
if s.cfg.Merge == mergeStatic {
prefer := s.cfg.Prefer
return mergeFacts(ordered, func(string) string { return prefer })
return mergeFacts(results, nil)
}
// freshness merge: attribute each certname to the backend with the newer
// report_timestamp, taken from a short-TTL /nodes freshness map.
fresh := s.freshnessMap(context.Background(), ordered)
prefer := s.cfg.Prefer
return mergeFacts(ordered, func(cn string) string {
if b, ok := fresh[cn]; ok {
return b
}
return prefer
})
fresh := s.freshnessMap(context.Background(), results)
return mergeFacts(results, func(cn string) string { return fresh[cn] })
}
// byPrecedence orders results so the Prefer backend comes first, giving it the
// tie-break on equal timestamps. Remaining backends keep config order.
func (s *Server) byPrecedence(results []backendResult) []backendResult {
ordered := make([]backendResult, len(results))
copy(ordered, results)
sort.SliceStable(ordered, func(i, j int) bool {
return ordered[i].name == s.cfg.Prefer && ordered[j].name != s.cfg.Prefer
})
return ordered
}
// freshnessMap returns a per-certname owner map derived from each backend's
// /nodes report_timestamp, cached for cfg.FreshnessTTL. On cache miss it queries
// /nodes from all backends; a backend that fails is simply absent from the map,
// so its certnames fall back to precedence/Prefer.
//
// When the incoming request already carries /nodes data (results has records),
// we still query /nodes broadly here because a /facts query's certname set can
// differ from what the request's query filter returned. The cache keeps this
// cheap under load.
// Queries /nodes unfiltered rather than reusing the request's results, because a /facts query's certname set can differ.
func (s *Server) freshnessMap(ctx context.Context, _ []backendResult) freshness {
s.mu.Lock()
if s.freshData != nil && time.Since(s.freshAt) < s.cfg.FreshnessTTL {
@@ -153,8 +246,7 @@ func (s *Server) freshnessMap(ctx context.Context, _ []backendResult) freshness
}
s.mu.Unlock()
// Empty query = all nodes; cheap enough for a short-TTL cache.
nodeResults := s.fanOut(ctx, nodesPath, "")
nodeResults := s.fanOut(ctx, nodesPath, nil)
var alive []backendResult
for _, res := range nodeResults {
if res.err != nil {
@@ -163,7 +255,7 @@ func (s *Server) freshnessMap(ctx context.Context, _ []backendResult) freshness
}
alive = append(alive, res)
}
f := buildFreshness(s.byPrecedence(alive))
f := buildFreshness(alive)
s.mu.Lock()
s.freshData = f
@@ -172,89 +264,119 @@ func (s *Server) freshnessMap(ctx context.Context, _ []backendResult) freshness
return f
}
// fanOut queries every backend concurrently for path?query=... and returns one
// backendResult per backend, in config order.
func (s *Server) fanOut(ctx context.Context, path, query string) []backendResult {
// Returns one result per backend, in config order.
func (s *Server) fanOut(ctx context.Context, path string, params url.Values) []backendResult {
results := make([]backendResult, 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()
recs, err := s.queryBackend(ctx, b, path, query)
results[i] = backendResult{name: b.Name, records: recs, err: err}
recs, total, err := s.queryBackend(ctx, b, path, params)
results[i] = backendResult{name: b.Name, records: recs, total: total, err: err}
}(i, b)
}
wg.Wait()
return results
}
// queryBackend performs one GET b.URL+path?query=... and decodes the JSON array.
func (s *Server) queryBackend(ctx context.Context, b Backend, path, query string) ([]record, error) {
// The returned count is the upstream X-Records value, or -1 when the backend sent none.
func (s *Server) queryBackend(ctx context.Context, b Backend, path string, params url.Values) ([]record, int, error) {
target := strings.TrimRight(b.URL, "/") + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
return nil, -1, err
}
if query != "" {
q := url.Values{}
q.Set("query", query)
req.URL.RawQuery = q.Encode()
if len(params) > 0 {
req.URL.RawQuery = params.Encode()
}
resp, err := s.client.Do(req)
if err != nil {
return nil, err
return nil, -1, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
return nil, -1, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
return nil, -1, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return decodeRecords(body)
total := -1
if n, err := strconv.Atoi(resp.Header.Get(recordsHeader)); err == nil && n >= 0 {
total = n
}
recs, err := decodeRecords(body)
return recs, total, err
}
// proxyPrimary transparently forwards a non-merged /pdb/query/v4/* request to
// the primary backend and streams the response back verbatim.
func (s *Server) proxyPrimary(w http.ResponseWriter, r *http.Request) {
b := s.cfg.PrimaryBackend()
// The record shape is unknown, so a union would be guesswork: the first 2xx wins and the first error response is replayed when none succeeds.
func (s *Server) proxyUnmerged(w http.ResponseWriter, r *http.Request) {
var fallback *bufferedResponse
for _, b := range s.cfg.Backends {
resp, err := s.passThrough(r, b)
if err != nil {
s.log.Printf("warning: backend %q pass-through failed for %s: %v", b.Name, r.URL.Path, err)
continue
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
setContentType(w, resp.Header.Get("Content-Type"))
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
_ = resp.Body.Close()
return
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if fallback == nil {
fallback = &bufferedResponse{
status: resp.StatusCode,
contentType: resp.Header.Get("Content-Type"),
body: body,
}
}
}
if fallback == nil {
http.Error(w, "all backends failed", http.StatusBadGateway)
return
}
setContentType(w, fallback.contentType)
w.WriteHeader(fallback.status)
_, _ = w.Write(fallback.body)
}
type bufferedResponse struct {
status int
contentType string
body []byte
}
func (s *Server) passThrough(r *http.Request, b Backend) (*http.Response, error) {
target := strings.TrimRight(b.URL, "/") + r.URL.Path
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
return nil, err
}
return s.client.Do(req)
}
func setContentType(w http.ResponseWriter, contentType string) {
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
resp, err := s.client.Do(req)
if err != nil {
s.log.Printf("warning: primary %q pass-through failed for %s: %v", b.Name, r.URL.Path, err)
http.Error(w, "primary backend failed", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
// healthReport is the /healthz JSON body.
type healthReport struct {
Status string `json:"status"`
Backends map[string]string `json:"backends"` // name -> "ok" | error text
}
// handleHealth probes every backend's /nodes endpoint with a trivial query and
// reports per-backend reachability. Overall status is "ok" if any backend is
// reachable, "degraded" if some fail, "down" if all fail (503 in that case).
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
probe := `["=","certname","pdbmux-healthz-probe"]`
results := s.fanOut(r.Context(), nodesPath, probe)
results := s.fanOut(r.Context(), nodesPath, queryParams(probe))
report := healthReport{Backends: map[string]string{}}
healthy := 0
@@ -284,7 +406,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
_ = enc.Encode(report)
}
// writeJSON writes a JSON array of raw records as a PuppetDB-style response.
func writeJSON(w http.ResponseWriter, recs []json.RawMessage) {
w.Header().Set("Content-Type", "application/json")
if recs == nil {
+657 -83
View File
@@ -7,7 +7,11 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"slices"
"strconv"
"strings"
"sync"
"testing"
"time"
)
@@ -15,26 +19,55 @@ import (
// fakeBackend is an httptest PuppetDB that returns canned bodies per path and
// records the query params it received.
type fakeBackend struct {
srv *httptest.Server
nodesBody string
factsBody string
fail bool // return 500 for everything
delay time.Duration // artificial latency
gotQueries map[string]string
srv *httptest.Server
nodesBody string
factsBody string
// bodies holds extra canned responses keyed by path (reports, events, a
// report's sub-resources). A path under /reports/ that is absent from bodies
// answers 404, like a PuppetDB that does not hold that report.
bodies map[string]string
// totals is the X-Records count advertised per path when the request asks
// for include_total.
totals map[string]int
fail bool // return 500 for everything
delay time.Duration // artificial latency
mu sync.Mutex
gotParams map[string]url.Values
}
func newFakeBackend(t *testing.T, nodesBody, factsBody string) *fakeBackend {
t.Helper()
fb := &fakeBackend{nodesBody: nodesBody, factsBody: factsBody, gotQueries: map[string]string{}}
fb := &fakeBackend{
nodesBody: nodesBody,
factsBody: factsBody,
bodies: map[string]string{},
totals: map[string]int{},
gotParams: map[string]url.Values{},
}
fb.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if fb.delay > 0 {
time.Sleep(fb.delay)
}
fb.gotQueries[r.URL.Path] = r.URL.Query().Get("query")
fb.mu.Lock()
fb.gotParams[r.URL.Path] = r.URL.Query()
fb.mu.Unlock()
if fb.fail {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
if body, ok := fb.bodies[r.URL.Path]; ok {
if n, ok := fb.totals[r.URL.Path]; ok && r.URL.Query().Get("include_total") == "true" {
w.Header().Set(recordsHeader, strconv.Itoa(n))
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, truncate(t, body, r.URL.Query().Get("limit")))
return
}
if strings.HasPrefix(r.URL.Path, reportsPath+"/") {
http.Error(w, "no report with that hash", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case nodesPath:
@@ -49,13 +82,48 @@ func newFakeBackend(t *testing.T, nodesBody, factsBody string) *fakeBackend {
return fb
}
func testConfig(oldURL, newURL, merge string) Config {
// params returns the query params the backend saw for a path, and whether it was
// asked for that path at all.
func (fb *fakeBackend) params(path string) (url.Values, bool) {
fb.mu.Lock()
defer fb.mu.Unlock()
v, ok := fb.gotParams[path]
return v, ok
}
// gotQuery returns the PuppetDB query param the backend saw for a path.
func (fb *fakeBackend) gotQuery(path string) string {
v, _ := fb.params(path)
return v.Get("query")
}
// truncate applies an upstream limit param to a canned JSON array body, the way
// a real PuppetDB would, so paging tests exercise the proxy's re-paging.
func truncate(t *testing.T, body, limit string) string {
t.Helper()
n, err := strconv.Atoi(limit)
if err != nil {
return body
}
var raws []json.RawMessage
if err := json.Unmarshal([]byte(body), &raws); err != nil {
return body
}
if n < len(raws) {
raws = raws[:n]
}
out, err := json.Marshal(raws)
if err != nil {
t.Fatalf("re-marshal truncated body: %v", err)
}
return string(out)
}
func testConfig(aURL, bURL, merge string) Config {
return Config{
Listen: ":0",
Backends: []Backend{{Name: "old", URL: oldURL}, {Name: "new", URL: newURL}},
Primary: "new",
Backends: []Backend{{Name: "a", URL: aURL}, {Name: "b", URL: bURL}},
Merge: merge,
Prefer: "new",
Timeout: 2 * time.Second,
FreshnessTTL: 30 * time.Second,
}
@@ -78,11 +146,11 @@ func doGet(t *testing.T, h http.Handler, path, query string) *httptest.ResponseR
}
func TestHandler_NodesMerged(t *testing.T) {
old := newFakeBackend(t,
a := newFakeBackend(t,
`[`+node("h1", "2026-07-01T00:00:00Z")+`,`+node("h2", "2026-07-10T00:00:00Z")+`]`, `[]`)
nw := newFakeBackend(t,
b := newFakeBackend(t,
`[`+node("h1", "2026-07-20T00:00:00Z")+`]`, `[]`)
srv := newTestServer(testConfig(old.srv.URL, nw.srv.URL, mergeStatic))
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), nodesPath, `["=","certname","h1"]`)
if rec.Code != http.StatusOK {
@@ -97,76 +165,78 @@ func TestHandler_NodesMerged(t *testing.T) {
}
for _, m := range got {
if m.Certname == "h1" && m.ReportTimestamp != "2026-07-20T00:00:00Z" {
t.Errorf("h1 should be new's newer record, got %s", m.ReportTimestamp)
t.Errorf("h1 should be the newer record, got %s", m.ReportTimestamp)
}
}
}
func TestHandler_QueryPassthrough(t *testing.T) {
old := newFakeBackend(t, `[]`, `[]`)
nw := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(old.srv.URL, nw.srv.URL, mergeStatic))
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
q := `["=","certname","abc.example.net"]`
doGet(t, srv.Handler(), factsPath, q)
if old.gotQueries[factsPath] != q {
t.Errorf("old backend got query %q, want %q", old.gotQueries[factsPath], q)
if a.gotQuery(factsPath) != q {
t.Errorf("a backend got query %q, want %q", a.gotQuery(factsPath), q)
}
if nw.gotQueries[factsPath] != q {
t.Errorf("new backend got query %q, want %q", nw.gotQueries[factsPath], q)
if b.gotQuery(factsPath) != q {
t.Errorf("b backend got query %q, want %q", b.gotQuery(factsPath), q)
}
}
func TestHandler_FactsStaticMerge(t *testing.T) {
old := newFakeBackend(t, `[]`,
`[`+fact("h1", "role", "web-old", "")+`,`+fact("h2", "role", "db-old", "")+`]`)
nw := newFakeBackend(t, `[]`,
`[`+fact("h1", "role", "web-new", "")+`]`)
srv := newTestServer(testConfig(old.srv.URL, nw.srv.URL, mergeStatic))
// Static merge ignores timestamps: a shared certname resolves to the first
// backend in configured order that holds it.
a := newFakeBackend(t, `[]`,
`[`+fact("h1", "role", "web-a", "")+`,`+fact("h2", "role", "db-a", "")+`]`)
b := newFakeBackend(t, `[]`,
`[`+fact("h1", "role", "web-b", "")+`,`+fact("h3", "role", "db-b", "")+`]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), factsPath, `["=","name","role"]`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "web-new") || strings.Contains(body, "web-old") {
t.Errorf("static prefer=new should keep web-new, drop web-old: %s", body)
if !strings.Contains(body, "web-a") || strings.Contains(body, "web-b") {
t.Errorf("h1 should resolve to the first backend holding it: %s", body)
}
if !strings.Contains(body, "db-old") {
t.Errorf("h2 only in old should survive: %s", body)
if !strings.Contains(body, "db-a") || !strings.Contains(body, "db-b") {
t.Errorf("nodes held by only one backend must all survive: %s", body)
}
}
func TestHandler_FactsFreshnessMerge(t *testing.T) {
// Freshness: old holds h1's newer report; new holds h2's newer report.
old := newFakeBackend(t,
// Freshness: a holds h1's newer report; b holds h2's newer report.
a := newFakeBackend(t,
`[`+node("h1", "2026-07-20T00:00:00Z")+`,`+node("h2", "2026-07-01T00:00:00Z")+`]`,
`[`+fact("h1", "role", "web-old", "")+`,`+fact("h2", "role", "db-old", "")+`]`)
nw := newFakeBackend(t,
`[`+fact("h1", "role", "web-a", "")+`,`+fact("h2", "role", "db-a", "")+`]`)
b := newFakeBackend(t,
`[`+node("h1", "2026-07-01T00:00:00Z")+`,`+node("h2", "2026-07-20T00:00:00Z")+`]`,
`[`+fact("h1", "role", "web-new", "")+`,`+fact("h2", "role", "db-new", "")+`]`)
srv := newTestServer(testConfig(old.srv.URL, nw.srv.URL, mergeFreshness))
`[`+fact("h1", "role", "web-b", "")+`,`+fact("h2", "role", "db-b", "")+`]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeFreshness))
rec := doGet(t, srv.Handler(), factsPath, `["=","name","role"]`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// h1 -> old (newer report there); h2 -> new.
if !strings.Contains(body, "web-old") || strings.Contains(body, "web-new") {
t.Errorf("h1 should resolve to old: %s", body)
// h1 -> a (newer report there); h2 -> b.
if !strings.Contains(body, "web-a") || strings.Contains(body, "web-b") {
t.Errorf("h1 should resolve to a: %s", body)
}
if !strings.Contains(body, "db-new") || strings.Contains(body, "db-old") {
t.Errorf("h2 should resolve to new: %s", body)
if !strings.Contains(body, "db-b") || strings.Contains(body, "db-a") {
t.Errorf("h2 should resolve to b: %s", body)
}
}
func TestHandler_OneBackendDown(t *testing.T) {
old := newFakeBackend(t, `[]`, `[]`)
old.fail = true
nw := newFakeBackend(t,
a := newFakeBackend(t, `[]`, `[]`)
a.fail = true
b := newFakeBackend(t,
`[`+node("h1", "2026-07-20T00:00:00Z")+`]`, `[]`)
srv := newTestServer(testConfig(old.srv.URL, nw.srv.URL, mergeStatic))
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), nodesPath, "")
if rec.Code != http.StatusOK {
@@ -178,10 +248,10 @@ func TestHandler_OneBackendDown(t *testing.T) {
}
func TestHandler_BothBackendsDown(t *testing.T) {
old := newFakeBackend(t, `[]`, `[]`)
nw := newFakeBackend(t, `[]`, `[]`)
old.fail, nw.fail = true, true
srv := newTestServer(testConfig(old.srv.URL, nw.srv.URL, mergeStatic))
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.fail, b.fail = true, true
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), nodesPath, "")
if rec.Code != http.StatusBadGateway {
@@ -189,32 +259,66 @@ func TestHandler_BothBackendsDown(t *testing.T) {
}
}
func TestHandler_PassThroughToPrimary(t *testing.T) {
// A non-merged v4 path (e.g. /reports) goes only to the primary (new).
old := newFakeBackend(t, `[]`, `[]`)
nw := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(old.srv.URL, nw.srv.URL, mergeStatic))
func TestHandler_PassThroughFirstAnswer(t *testing.T) {
// A path with no merge rule (e.g. /resources) is served by the first backend
// that answers; the rest are not asked at all.
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), "/pdb/query/v4/reports", `["=","certname","h1"]`)
const path = "/pdb/query/v4/resources"
rec := doGet(t, srv.Handler(), path, `["=","certname","h1"]`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "/pdb/query/v4/reports") {
if !strings.Contains(rec.Body.String(), path) {
t.Errorf("expected pass-through body, got %s", rec.Body.String())
}
// Only primary (new) should have been queried.
if _, hit := old.gotQueries["/pdb/query/v4/reports"]; hit {
t.Errorf("non-primary backend should not be queried for pass-through")
if _, hit := a.params(path); !hit {
t.Errorf("first backend should be queried for pass-through")
}
if _, hit := nw.gotQueries["/pdb/query/v4/reports"]; !hit {
t.Errorf("primary backend should be queried for pass-through")
if _, hit := b.params(path); hit {
t.Errorf("later backends should not be queried once one answers")
}
}
func TestHandler_PassThroughFallsBackToNextBackend(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.fail = true
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
const path = "/pdb/query/v4/resources"
rec := doGet(t, srv.Handler(), path, "")
if rec.Code != http.StatusOK {
t.Fatalf("expected the surviving backend to serve it, got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), path) {
t.Errorf("expected pass-through body, got %s", rec.Body.String())
}
}
func TestHandler_PassThroughReplaysUpstreamError(t *testing.T) {
// Every backend rejects it, so PuppetDB's own status reaches the client
// rather than a synthetic 502.
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.fail, b.fail = true, true
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), "/pdb/query/v4/resources", "")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected the upstream 500 replayed, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "boom") {
t.Errorf("expected the upstream body, got %s", rec.Body.String())
}
}
func TestHandler_PostRejected(t *testing.T) {
old := newFakeBackend(t, `[]`, `[]`)
nw := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(old.srv.URL, nw.srv.URL, mergeStatic))
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
req := httptest.NewRequest(http.MethodPost, factsPath, nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
@@ -224,9 +328,9 @@ func TestHandler_PostRejected(t *testing.T) {
}
func TestHandler_Health(t *testing.T) {
old := newFakeBackend(t, `[]`, `[]`)
nw := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(old.srv.URL, nw.srv.URL, mergeStatic))
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), "/healthz", "")
if rec.Code != http.StatusOK {
@@ -236,16 +340,16 @@ func TestHandler_Health(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &hr); err != nil {
t.Fatal(err)
}
if hr.Status != "ok" || hr.Backends["old"] != "ok" || hr.Backends["new"] != "ok" {
if hr.Status != "ok" || hr.Backends["a"] != "ok" || hr.Backends["b"] != "ok" {
t.Fatalf("unexpected health: %+v", hr)
}
}
func TestHandler_HealthDegradedAndDown(t *testing.T) {
old := newFakeBackend(t, `[]`, `[]`)
nw := newFakeBackend(t, `[]`, `[]`)
old.fail = true
srv := newTestServer(testConfig(old.srv.URL, nw.srv.URL, mergeStatic))
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.fail = true
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), "/healthz", "")
var hr healthReport
@@ -257,7 +361,7 @@ func TestHandler_HealthDegradedAndDown(t *testing.T) {
t.Errorf("degraded should still be 200, got %d", rec.Code)
}
nw.fail = true
b.fail = true
rec = doGet(t, srv.Handler(), "/healthz", "")
_ = json.Unmarshal(rec.Body.Bytes(), &hr)
if hr.Status != "down" || rec.Code != http.StatusServiceUnavailable {
@@ -266,21 +370,491 @@ func TestHandler_HealthDegradedAndDown(t *testing.T) {
}
func TestFreshnessCache_Reused(t *testing.T) {
old := newFakeBackend(t,
a := newFakeBackend(t,
`[`+node("h1", "2026-07-20T00:00:00Z")+`]`,
`[`+fact("h1", "role", "web-old", "")+`]`)
nw := newFakeBackend(t,
`[`+fact("h1", "role", "web-a", "")+`]`)
b := newFakeBackend(t,
`[`+node("h1", "2026-07-01T00:00:00Z")+`]`,
`[`+fact("h1", "role", "web-new", "")+`]`)
srv := newTestServer(testConfig(old.srv.URL, nw.srv.URL, mergeFreshness))
`[`+fact("h1", "role", "web-b", "")+`]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeFreshness))
// Two facts queries; the freshness /nodes probe should be cached after the
// first, so query recording only reflects the last observed nodes query but
// results stay consistent (h1 -> old).
// results stay consistent (h1 -> a).
for i := 0; i < 2; i++ {
rec := doGet(t, srv.Handler(), factsPath, `["=","name","role"]`)
if !strings.Contains(rec.Body.String(), "web-old") {
t.Fatalf("iteration %d: expected h1->old, got %s", i, rec.Body.String())
if !strings.Contains(rec.Body.String(), "web-a") {
t.Fatalf("iteration %d: expected h1->a, got %s", i, rec.Body.String())
}
}
}
// doGetParams issues a GET with an arbitrary param set, for the paging/ordering
// params the reports endpoints accept.
func doGetParams(t *testing.T, h http.Handler, path string, params url.Values) *httptest.ResponseRecorder {
t.Helper()
target := path
if len(params) > 0 {
target += "?" + params.Encode()
}
req := httptest.NewRequest(http.MethodGet, target, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// hashes extracts report hashes from a merged response body, in order.
func hashes(t *testing.T, body []byte) []string {
t.Helper()
var raws []json.RawMessage
if err := json.Unmarshal(body, &raws); err != nil {
t.Fatalf("unmarshal %s: %v", body, err)
}
return hashesOf(t, raws)
}
const receiveDesc = `[{"field":"receive_time","order":"desc"}]`
func TestHandler_ReportsUnioned(t *testing.T) {
// h1 moved between backends: earlier reports are in a, later ones in b.
// Both must show up, unlike /facts where one backend wins the node.
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[reportsPath] = `[` + report("h1", "r2", "2026-07-10T00:00:00Z") + `,` +
report("h1", "r1", "2026-07-01T00:00:00Z") + `]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[reportsPath] = `[` + report("h1", "r4", "2026-07-30T00:00:00Z") + `,` +
report("h1", "r3", "2026-07-20T00:00:00Z") + `]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), reportsPath, url.Values{
"query": {`["=","certname","h1"]`},
"order_by": {receiveDesc},
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
got := hashes(t, rec.Body.Bytes())
want := []string{"r4", "r3", "r2", "r1"}
if !slices.Equal(got, want) {
t.Errorf("merged reports = %v, want %v (union re-sorted by receive_time desc)", got, want)
}
}
func TestHandler_ReportsDedupedByHash(t *testing.T) {
// A node reporting to both PuppetDBs mid-migration stores the same report
// hash in each; the merged view must show it once.
dup := report("h1", "r1", "2026-07-01T00:00:00Z")
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[reportsPath] = `[` + dup + `]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[reportsPath] = `[` + dup + `]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), reportsPath, nil)
if got := hashes(t, rec.Body.Bytes()); !slices.Equal(got, []string{"r1"}) {
t.Errorf("merged reports = %v, want one r1", got)
}
}
func TestHandler_ReportsPagedAcrossBackends(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[reportsPath] = `[` + report("h1", "r5", "2026-07-05T00:00:00Z") + `,` +
report("h1", "r3", "2026-07-03T00:00:00Z") + `,` +
report("h1", "r1", "2026-07-01T00:00:00Z") + `]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[reportsPath] = `[` + report("h1", "r6", "2026-07-06T00:00:00Z") + `,` +
report("h1", "r4", "2026-07-04T00:00:00Z") + `,` +
report("h1", "r2", "2026-07-02T00:00:00Z") + `]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), reportsPath, url.Values{
"order_by": {receiveDesc},
"limit": {"2"},
"offset": {"2"},
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
// Globally-ordered page 2 of the union, not each backend's own page 2.
if got := hashes(t, rec.Body.Bytes()); !slices.Equal(got, []string{"r4", "r3"}) {
t.Errorf("page = %v, want [r4 r3]", got)
}
// Each backend must be asked for the first offset+limit records so the
// merged window is fully covered.
for name, fb := range map[string]*fakeBackend{"a": a, "b": b} {
p, ok := fb.params(reportsPath)
if !ok {
t.Fatalf("%s backend was not queried", name)
}
if p.Get("limit") != "4" {
t.Errorf("%s backend got limit=%q, want 4 (offset+limit)", name, p.Get("limit"))
}
if p.Has("offset") {
t.Errorf("%s backend got offset=%q, want it applied locally instead", name, p.Get("offset"))
}
}
}
func TestHandler_ReportsIncludeTotalSummed(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[reportsPath] = `[` + report("h1", "r1", "2026-07-01T00:00:00Z") + `]`
a.totals[reportsPath] = 40
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[reportsPath] = `[` + report("h1", "r2", "2026-07-02T00:00:00Z") + `]`
b.totals[reportsPath] = 60
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), reportsPath, url.Values{
"include_total": {"true"},
"limit": {"1"},
})
if got := rec.Header().Get(recordsHeader); got != "100" {
t.Errorf("%s = %q, want 100 (sum of both backends)", recordsHeader, got)
}
}
func TestHandler_ReportsNoTotalWhenNotRequested(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[reportsPath] = `[]`
a.totals[reportsPath] = 40
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[reportsPath] = `[]`
b.totals[reportsPath] = 60
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), reportsPath, nil)
if got := rec.Header().Get(recordsHeader); got != "" {
t.Errorf("%s = %q, want it unset without include_total", recordsHeader, got)
}
}
func TestHandler_ReportsBadPagingParam(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
for _, params := range []url.Values{
{"limit": {"lots"}},
{"offset": {"-1"}},
{"order_by": {"receive_time"}},
} {
rec := doGetParams(t, srv.Handler(), reportsPath, params)
if rec.Code != http.StatusBadRequest {
t.Errorf("%v: expected 400, got %d", params, rec.Code)
}
}
}
func TestHandler_EventsUnioned(t *testing.T) {
// Puppetboard fetches a report's events as /events?query=["=","report",hash],
// and the report may live in either backend.
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[eventsPath] = `[` + event("h1", "r1", "Package[nginx]") + `]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[eventsPath] = `[` + event("h1", "r2", "Service[nginx]") + `]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), eventsPath, `["=","certname","h1"]`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Package[nginx]") || !strings.Contains(body, "Service[nginx]") {
t.Errorf("expected both backends' events: %s", body)
}
}
func TestHandler_EventsDedupedByIdentity(t *testing.T) {
dup := event("h1", "r1", "Package[nginx]")
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[eventsPath] = `[` + dup + `]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[eventsPath] = `[` + dup + `]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), eventsPath, "")
var got []json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Errorf("expected the duplicate event once, got %d: %s", len(got), rec.Body.String())
}
}
func TestHandler_ReportSubResourceFromHoldingBackend(t *testing.T) {
// Only a holds report r1, so its logs come from a; an unmerged pass-through
// to whichever backend answered first could have 404'd.
const path = reportsPath + "/r1/logs"
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[path] = `[{"level":"notice","message":"from-a"}]`
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), path, "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "from-a") {
t.Errorf("expected the holding backend's logs, got %s", rec.Body.String())
}
}
func TestHandler_ReportSubResourceMissingEverywhere(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), reportsPath+"/nope/events", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 when no backend holds the report, got %d", rec.Code)
}
}
func TestHandler_ReportsOneBackendDown(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.fail = true
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[reportsPath] = `[` + report("h1", "r1", "2026-07-01T00:00:00Z") + `]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), reportsPath, url.Values{"order_by": {receiveDesc}})
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 serving the survivor, got %d", rec.Code)
}
if got := hashes(t, rec.Body.Bytes()); !slices.Equal(got, []string{"r1"}) {
t.Errorf("merged reports = %v, want [r1]", got)
}
}
// eventCount builds one /event-counts row for a certname.
func eventCount(certname string, successes, failures, noops, skips int) string {
return `{"subject_type":"certname","subject":{"title":"` + certname + `"},` +
`"successes":` + strconv.Itoa(successes) +
`,"failures":` + strconv.Itoa(failures) +
`,"noops":` + strconv.Itoa(noops) +
`,"skips":` + strconv.Itoa(skips) + `}`
}
// counts decodes a numeric column out of a merged aggregate body, in order.
func counts(t *testing.T, body []byte, field string) []float64 {
t.Helper()
var rows []map[string]any
if err := json.Unmarshal(body, &rows); err != nil {
t.Fatalf("unmarshal %s: %v", body, err)
}
out := make([]float64, 0, len(rows))
for _, r := range rows {
n, _ := r[field].(float64)
out = append(out, n)
}
return out
}
func TestHandler_EventCountsSummedPerSubject(t *testing.T) {
// A node reporting to both PuppetDBs has its run counted in each; the
// merged view is the sum, not two rows.
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[eventCountsPath] = `[` + eventCount("h1", 4, 3, 1, 0) + `]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[eventCountsPath] = `[` + eventCount("h1", 2, 1, 0, 0) + `,` + eventCount("h2", 5, 0, 0, 0) + `]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), eventCountsPath, url.Values{
"query": {`["=","certname","h1"]`},
"summarize_by": {"certname"},
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
// Rows come out in configured backend order, so h1 leads.
if got := counts(t, rec.Body.Bytes(), "successes"); !slices.Equal(got, []float64{6, 5}) {
t.Errorf("successes = %v, want [6 5]", got)
}
if got := counts(t, rec.Body.Bytes(), "failures"); !slices.Equal(got, []float64{4, 0}) {
t.Errorf("failures = %v, want [4 0]", got)
}
}
func TestHandler_EventCountsDisjointSubjectsPassThrough(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[eventCountsPath] = `[` + eventCount("h1", 1, 0, 0, 0) + `]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[eventCountsPath] = `[` + eventCount("h2", 2, 0, 0, 0) + `]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), eventCountsPath, url.Values{"summarize_by": {"certname"}})
if got := counts(t, rec.Body.Bytes(), "successes"); !slices.Equal(got, []float64{1, 2}) {
t.Errorf("successes = %v, want [1 2] (both nodes, untouched)", got)
}
// summarize_by must reach the backends verbatim.
for name, fb := range map[string]*fakeBackend{"a": a, "b": b} {
p, _ := fb.params(eventCountsPath)
if p.Get("summarize_by") != "certname" {
t.Errorf("%s backend got summarize_by=%q, want certname", name, p.Get("summarize_by"))
}
}
}
func TestHandler_EventCountsRecordsIsMergedRowCount(t *testing.T) {
// Each backend reports one row; they share a subject, so the merged total
// is one — not the two the backends' own X-Records add up to.
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[eventCountsPath] = `[` + eventCount("h1", 1, 0, 0, 0) + `]`
a.totals[eventCountsPath] = 1
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[eventCountsPath] = `[` + eventCount("h1", 2, 0, 0, 0) + `]`
b.totals[eventCountsPath] = 1
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), eventCountsPath, url.Values{
"summarize_by": {"certname"},
"include_total": {"true"},
})
if got := rec.Header().Get(recordsHeader); got != "1" {
t.Errorf("%s = %q, want 1 (merged rows, not 2)", recordsHeader, got)
}
}
func TestHandler_AggregateEventCountsSummed(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[aggregateEventCountsPath] =
`[{"successes":2,"failures":1,"noops":0,"skips":3,"total":6,"summarize_by":"certname"}]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[aggregateEventCountsPath] =
`[{"successes":5,"failures":4,"noops":1,"skips":0,"total":10,"summarize_by":"certname"}]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), aggregateEventCountsPath, url.Values{"summarize_by": {"certname"}})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var got []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("expected one summary object, got %d: %s", len(got), rec.Body.String())
}
want := map[string]any{
"successes": float64(7), "failures": float64(5), "noops": float64(1),
"skips": float64(3), "total": float64(16), "summarize_by": "certname",
}
if !reflect.DeepEqual(got[0], want) {
t.Errorf("summary = %v, want %v", got[0], want)
}
}
func TestHandler_AggregateEventCountsNullColumnSurvives(t *testing.T) {
// PuppetDB returns null totals for an empty result set; summing must not
// crash or blank out the backend that does have numbers.
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[aggregateEventCountsPath] =
`[{"successes":null,"failures":null,"total":null,"summarize_by":"certname"}]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[aggregateEventCountsPath] =
`[{"successes":3,"failures":0,"total":3,"summarize_by":"certname"}]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), aggregateEventCountsPath, url.Values{"summarize_by": {"certname"}})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if got := counts(t, rec.Body.Bytes(), "total"); !slices.Equal(got, []float64{3}) {
t.Errorf("total = %v, want [3]", got)
}
}
const statusCountQuery = `["extract",[["function","count"],"status"],["~","certname",".*"],["group_by","status"]]`
func TestHandler_ReportsAggregateSummed(t *testing.T) {
// Puppetboard's daily-reports chart: each backend counts only its own
// reports, so the merged chart needs the per-status sums.
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[reportsPath] = `[{"count":4,"status":"changed"},{"count":2,"status":"failed"}]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[reportsPath] = `[{"count":3,"status":"changed"},{"count":9,"status":"unchanged"}]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), reportsPath, url.Values{"query": {statusCountQuery}})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var got []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
byStatus := map[string]float64{}
for _, row := range got {
s, _ := row["status"].(string)
n, _ := row["count"].(float64)
byStatus[s] = n
}
want := map[string]float64{"changed": 7, "failed": 2, "unchanged": 9}
if !reflect.DeepEqual(byStatus, want) {
t.Errorf("counts = %v, want %v", byStatus, want)
}
}
func TestHandler_ReportsAggregateRecordsIsMergedRowCount(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[reportsPath] = `[{"count":4,"status":"changed"}]`
a.totals[reportsPath] = 1
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[reportsPath] = `[{"count":3,"status":"changed"}]`
b.totals[reportsPath] = 1
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), reportsPath, url.Values{
"query": {statusCountQuery},
"include_total": {"true"},
})
if got := rec.Header().Get(recordsHeader); got != "1" {
t.Errorf("%s = %q, want 1 (one merged status row)", recordsHeader, got)
}
}
func TestHandler_ReportsNonAggregateStillUnioned(t *testing.T) {
// An extract with no function is a projection of real reports, so the
// union — not a sum — is still the right merge.
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[reportsPath] = `[` + report("h1", "r1", "2026-07-01T00:00:00Z") + `]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[reportsPath] = `[` + report("h1", "r2", "2026-07-02T00:00:00Z") + `]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), reportsPath, url.Values{
"query": {`["extract",["hash","certname"],["=","certname","h1"]]`},
"order_by": {receiveDesc},
})
if got := hashes(t, rec.Body.Bytes()); !slices.Equal(got, []string{"r2", "r1"}) {
t.Errorf("merged reports = %v, want [r2 r1]", got)
}
}
func TestHandler_EventCountsOneBackendDown(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.fail = true
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[eventCountsPath] = `[` + eventCount("h1", 2, 0, 0, 0) + `]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), eventCountsPath, url.Values{"summarize_by": {"certname"}})
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 serving the survivor, got %d", rec.Code)
}
if got := counts(t, rec.Body.Bytes(), "successes"); !slices.Equal(got, []float64{2}) {
t.Errorf("successes = %v, want [2]", got)
}
}
func TestHandler_EventCountsBadPagingParam(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), eventCountsPath, url.Values{"limit": {"lots"}})
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 for a malformed limit, got %d", rec.Code)
}
}