11 Commits

Author SHA1 Message Date
benvin 2652778306 Merge pull request 'Serve fact queries live and drop the cache headers' (#26) from benvin/drop-fact-caching into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #26
2026-09-13 15:04:26 +10:00
unkin-agent 5c9d1e9055 Merge main into benvin/drop-fact-caching
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Both sides added jsonContentType; keep one. The no-entry 502 test kills the
backends outright, since main replays a unanimous 500, and the cached
content-type test moves to /nodes, the only cached path left.
2026-09-13 14:54:08 +10:00
benvin 3bea2e9b71 Merge pull request 'Replay every unanimous upstream status, not just 4xx' (#27) from benvin/replay-upstream-errors into main
Reviewed-on: #27
2026-09-13 14:31:56 +10:00
unkin-agent 85c9088293 Say that owner-first covers a certname with no resolved owner
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-13 14:13:44 +10:00
unkin-agent 72380f27e6 Serve the owner's own answer on the per-certname routes
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
Unanimity is the right rule for a fan-out of peers, but the per-certname
routes are not one: a backend that does not hold the certname answers 404
to say so, not to disagree, so requiring it to agree turned the owner's
real 500 into a 502 that described neither backend.

- Add askOrder, which says whether a set of backends was asked as peers
  or owner-first, and resolve each round's replies under its own rule.
- Serve the first backend that answered on owner-routed paths, so an
  unreachable owner still falls back rather than collapsing to 502.
- Keep unanimity for the merged, meta, metrics and pass-through routes.
- Cover the owner routes: owner errors against a non-owner 404, both
  erroring differently, an unreachable owner, and a non-owner error
  behind the owner's 200.
- Record what clientRefusal's 4xx exemption assumes about client certs.
2026-09-13 14:11:44 +10:00
unkin-agent 121bfacc2f Replay every unanimous upstream status, not just 4xx
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
openvoxdb does not reserve 5xx for its own faults: the same malformed
query is a 400 on /nodes and a 500 on /facts, and /metrics answers a flat
403, so a 4xx-only replay rule made pdbmux's behaviour depend on the
route. The meta and metrics handlers held their own copy of the gateway
error and bypassed the replay entirely.

- Replay any status from 400 up that every backend agreed on, with the
  backend's own body and content type.
- Keep 502 for backends disagreeing on the status, or a backend that
  answered nothing at all.
- Route /pdb/meta, /metrics and the pass-through path through the same
  rule as the merged query handlers.
- Count a unanimous 5xx as a failed round and let it fall back to a stale
  cache entry; only a unanimous 4xx stays exempt from both.
- Answer successful queries with openvoxdb's application/json;charset=utf-8.
2026-09-13 13:35:21 +10:00
unkin-agent 24f6d73d5c Serve fact queries live and drop the cache headers
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Fact answers must be as current as a backend's own, and X-Cache/Age are
headers PuppetDB never sends.

- serve /facts, /facts/<name>[/<value>] and /fact-names live on every request
- keep the in-memory cache on merged /nodes only
- drop X-Cache and Age everywhere; /healthz still reports cache state
- answer successful queries with PuppetDB's application/json;charset=utf-8
2026-09-13 13:34:52 +10:00
benvin 5207a79ad4 Merge pull request 'Resolve the per-certname routes to the node's owning backend' (#25) from benvin/merge-per-node-fact-routes into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #25
2026-09-12 20:33:22 +10:00
unkin-agent eeb44057db Resolve the per-certname routes to the node's owning backend
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
The paths keyed on one certname took the pass-through, so a node both
backends hold answered from whichever was configured first while /facts
answered from whichever held its newer report.

- add a route claiming /pdb/query/v4/{nodes,factsets,catalogs}/<certname>
- order the backends for it by the freshness map the /facts merge uses
- try the remaining backends after the owner, replaying upstream's 404 when none holds the certname
- assert ownership, the upstream 404 body, a failed backend and query forwarding against captured openvoxdb shapes
2026-09-12 17:58:38 +10:00
benvin 94769a7864 Merge pull request 'Match backend addresses case-insensitively when redacting' (#24) from benvin/redact-backend-case into main
Reviewed-on: #24
2026-09-08 07:02:01 +10:00
benvin 6a3b277e5e Merge pull request 'Replay a unanimous upstream rejection instead of a 502' (#23) from benvin/upstream-errors into main
Reviewed-on: #23
2026-09-07 23:07:55 +10:00
16 changed files with 1412 additions and 456 deletions
+66 -50
View File
@@ -34,6 +34,7 @@ not PQL) is forwarded verbatim.
| `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/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/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/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/{nodes,factsets,catalogs}/<certname>[/...]` | Ask the backend that owns that `certname` — the same owner the `/facts` merge attributes records to — and serve its reply verbatim, success or error. The remaining backends are tried after it, so a node only one backend holds is still served, and openvoxdb's own `404` body is replayed when none holds it. |
| `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 /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 /pdb/meta/v1/version` | Fan out to all and report the **lowest** version any backend runs. | | `GET /pdb/meta/v1/version` | Fan out to all and report the **lowest** version any backend runs. |
| `GET /pdb/meta/v1/server-time` | Fan out to all and serve the first reachable backend's clock. | | `GET /pdb/meta/v1/server-time` | Fan out to all and serve the first reachable backend's clock. |
@@ -46,22 +47,38 @@ Fan-out is concurrent, and goes only to the backends the health prober currently
believes are up — see [Backend health](#backend-health). If one backend errors or believes are up — see [Backend health](#backend-health). If one backend errors or
times out, `pdbmux` serves the surviving backends' results and logs a warning; a times out, `pdbmux` serves the surviving backends' results and logs a warning; a
merged endpoint only returns `502` when **every** backend fails. Response records merged endpoint only returns `502` when **every** backend fails. Response records
are passed through as raw JSON so unknown fields survive untouched. are passed through as raw JSON so unknown fields survive untouched, under
openvoxdb's own `application/json;charset=utf-8`.
Every backend is asked the same question, so a query all of them *refuse* with Every backend is asked the same question, so a status **every** backend answered
the same client-shaped status — a `400` naming an unknown field, say — is the with is the estate's own answer, not an outage: that status, openvoxdb's own
query's fault rather than an outage: that status and openvoxdb's own explanation explanation and its content type are replayed to the client instead of a `502`,
are replayed to the client instead of a `502`, with any backend address stripped with any backend address stripped out of the body first. This holds for every
out of the body first. Backends disagreeing on the status, a `403` (`pdbmux`'s status from `400` up — openvoxdb answers `["=","name"]` with `400` on `/nodes`
own credentials, not the client's), a `404` (which records a backend holds is but `500` on `/facts`, and `/metrics/v2` with a flat `403`, so a rule drawn at
exactly what backends disagree about), `408`, `429` and every `5xx` still return `500` would replay one and swallow the other. `502` is kept for what it actually
`502`. A refused query is not counted as a partial round on `/healthz`, and describes: backends **disagreeing** on the status, or a backend that answered
nothing about it is cached. nothing at all. The rule is the same on `/pdb/query`, `/pdb/meta` and `/metrics`,
so no route answers a failure differently from any other.
Unanimity is asked for only where the backends are peers answering the same
question. The per-certname routes are not: one backend **owns** the node and the
others are fallbacks, so the owner's reply is the answer whatever they said — a
backend that does not hold the certname answers `404` to say the node is not its
own, not to disagree about the request. When the owner answers nothing at all the
first fallback that did answer stands in, so an unreachable owner does not take
the node with it, and `502` is left for a request no backend answered.
A unanimous `4xx` blames the request, so it is not counted as a partial round on
`/healthz` and nothing about it is cached. A unanimous `5xx` is the backends
reporting their own fault, so it is replayed just as faithfully but still counts
as a failed round and still falls back to a stale cache entry where there is one.
Responses carry PuppetDB's `X-Records` when the query asked for a total, and on Responses carry PuppetDB's `X-Records` when the query asked for a total, and on
the merged paths `X-Backends` (see [Backend health](#backend-health)). Cached the merged paths `X-Backends` (see [Backend health](#backend-health)). Successful
paths add two more headers `pdbmux` sets itself, `X-Cache` and `Age` — see query responses carry PuppetDB's own `Content-Type: application/json;charset=utf-8`.
[Caching](#caching). Nothing tells a client whether a response came from the cache: PuppetDB sets no
`X-Cache` or `Age`, so neither does `pdbmux` — see [Caching](#caching).
## Merge semantics ## Merge semantics
@@ -242,7 +259,7 @@ record of that name, so the route does not serve its own fan-out: it takes the
records from the `/facts` merge that produces them, which makes the `certname` records from the `/facts` merge that produces them, which makes the `certname`
set, the owner and the `environment` identical to the ones an unfiltered `/facts` set, the owner and the `environment` identical to the ones an unfiltered `/facts`
response reports, and lets the request's own `query` narrow the result upstream. response reports, and lets the request's own `query` narrow the result upstream.
That costs one `/facts` fan-out per cache miss — the widest fan-out `pdbmux` That costs one `/facts` fan-out per request — the widest fan-out `pdbmux`
makes — on a rare, user-initiated path. makes — on a rare, user-initiated path.
`/facts/pdbmux_source/<value>` pins the backend name, so it answers with the `/facts/pdbmux_source/<value>` pins the backend name, so it answers with the
@@ -264,11 +281,12 @@ such a query correctly for every operator (`not`, `or`, subqueries) and does not
pretend to for some. Read the fact from an unfiltered (or `certname`-filtered) pretend to for some. Read the fact from an unfiltered (or `certname`-filtered)
`/facts` response, from the path route above, and filter client-side. `/facts` response, from the path route above, and filter client-side.
**Not covered:** `/factsets` and `/inventory`. Both carry facts, but `pdbmux` **Not covered:** `/factsets`, `/inventory` and the per-certname routes. The
does not merge either today — they take the unmerged pass-through path, where first two carry facts but are not merged today — they take the unmerged
the answer comes from whichever backend replied first rather than from a merge pass-through path, where the answer comes from whichever backend replied first
winner, so there is no owner to attribute. Injecting there would state a rather than from a merge winner, so there is no owner to attribute. The
provenance that isn't true. per-certname routes do resolve to an owner, but their bodies are passed through
verbatim rather than rebuilt, so nothing is added to them either.
### Metadata and metrics ### Metadata and metrics
@@ -402,8 +420,8 @@ not answering.
asked, never what a merged answer means: a response built from a subset is asked, never what a merged answer means: a response built from a subset is
still served, as before. Every merged response carries `X-Backends: still served, as before. Every merged response carries `X-Backends:
<contributed>/<configured>` naming how many backends' records went into it, so <contributed>/<configured>` naming how many backends' records went into it, so
a client can tell a full answer from a partial one. On a cache hit the header a client can tell a full answer from a partial one. On a `/nodes` cache hit the
describes the stored body, not the current backend count. header describes the stored body, not the current backend count.
- **`/healthz`** gives each backend a `state` (`healthy`, `unhealthy`, - **`/healthz`** gives each backend a `state` (`healthy`, `unhealthy`,
`probe_unsupported`, `unprobed`, or `unmonitored` when probing is off), `probe_unsupported`, `unprobed`, or `unmonitored` when probing is off),
`consecutive_failures`, `consecutive_failures`,
@@ -423,21 +441,27 @@ not answering.
## Caching ## Caching
`pdbmux` caches merged `/nodes`, `/facts`, `/facts/<name>[/<value>]` and `pdbmux` caches the merged `/nodes` record set **in memory** so a busy
`/fact-names` record sets **in memory** so a busy Puppetboard does not re-fan-out Puppetboard does not re-fan-out the node list every few seconds. Its report
the same query every few seconds — its facts overview and fact drilldown are two columns only move when a node finishes a run, so a `30s` answer is a `30s`-old
of the pages that hit hardest. Everything else runs uncached — including report timestamp and nothing else.
`extract` aggregates on those paths, and
the `/pdb/meta/v1/*` and `/metrics/*` endpoints, which are served live on every **No fact-serving path is cached.** `/facts`, `/facts/<name>[/<value>]`,
request. The cache is an interface, and `/reports` gets its own (S3-backed) `/fact-names`, `/factsets*`, `/nodes/<certname>/facts`, `/fact-contents`,
backend later without further handler changes. `/fact-paths` and `/inventory` all fan out on every request, so a fact answer is
exactly as current as the backend's own. Cache fact data client-side if you want
it cached. Everything else runs uncached too — including `extract` aggregates on
`/nodes`, and the `/pdb/meta/v1/*` and `/metrics/*` endpoints. The cache is an
interface, and `/reports` gets its own (S3-backed) backend later without further
handler changes.
- **Key** — `<path>?<params>`, where the params are the ones that actually - **Key** — `<path>?<params>`, where the params are the ones that actually
determine the response, URL-encoded with keys sorted ascending and a repeated determine the response, URL-encoded with keys sorted ascending and a repeated
param's values sorted ascending. Param order in the request is therefore param's values sorted ascending. Param order in the request is therefore
irrelevant: one canonical key per distinct request. A request with no params irrelevant: one canonical key per distinct request. A request with no params
keys on the bare path. keys on the bare path.
- **TTL** — `facts_ttl`, default `30s`, **hard cap `30s`**. A larger configured - **TTL** — `facts_ttl` (the key predates the cache narrowing to `/nodes`),
default `30s`, **hard cap `30s`**. A larger configured
value is **clamped** down to the cap, not rejected, so a stray env var cannot value is **clamped** down to the cap, not rejected, so a stray env var cannot
crash-loop a container; `pdbmux config show` prints crash-loop a container; `pdbmux config show` prints
`facts_ttl : 30s (clamped from 600s, cap 30s)` when that happens. `facts_ttl: 0` `facts_ttl : 30s (clamped from 600s, cap 30s)` when that happens. `facts_ttl: 0`
@@ -446,7 +470,8 @@ backend later without further handler changes.
has passed `pdbmux` always re-queries the backends; the expired copy is served has passed `pdbmux` always re-queries the backends; the expired copy is served
**only** if every backend fails, which turns a `502` into slightly-old data. A **only** if every backend fails, which turns a `502` into slightly-old data. A
healthy backend is never shadowed by a stale entry, and a query every backend healthy backend is never shadowed by a stale entry, and a query every backend
refuses is answered with the refusal rather than the stale copy. *refuses* with a `4xx` is answered with the refusal rather than the stale copy
a unanimous `5xx` is an outage like any other and still takes the stale copy.
- **Bounded** — `facts_cache_bytes` (default 64 MiB) is a byte budget, evicted - **Bounded** — `facts_cache_bytes` (default 64 MiB) is a byte budget, evicted
least-recently-used; reads count as use, so a stale entry that is still being least-recently-used; reads count as use, so a stale entry that is still being
asked for survives. A single response larger than the whole budget is not asked for survives. A single response larger than the whole budget is not
@@ -460,29 +485,20 @@ backend later without further handler changes.
client goes away leaves the flight running for the others. The flight is client goes away leaves the flight running for the others. The flight is
cancelled once its last participant leaves, so a lone client disconnecting cancelled once its last participant leaves, so a lone client disconnecting
releases the upstream connections straight away. releases the upstream connections straight away.
- **Response headers** — every response on a cached path carries `X-Cache` - **Invisible to clients** — a cached response is byte-for-byte a live one, with
(`hit` served from a fresh entry, `miss` built by this request, `stale` the the same headers. PuppetDB emits no `X-Cache` and no `Age`, so neither does
expired-entry fallback) and `Age` in whole seconds since the served copy was `pdbmux`; read the cache state off `/healthz` instead.
stored (`0` on a `miss`). Uncached paths carry neither.
- **Visibility** — `/healthz` carries a `cache` object: `backend` - **Visibility** — `/healthz` carries a `cache` object: `backend`
(`memory`/`none`), `ttl`, `entries`, `stale_entries`, `bytes`, `serving_stale`, (`memory`/`none`), `ttl`, `entries`, `stale_entries`, `bytes`, `serving_stale`,
`stale_served` and `last_stale_served`. `serving_stale` is `true` from the `stale_served` and `last_stale_served`. `serving_stale` is `true` from the
moment a stale fallback is served until the next response comes from a live moment a stale fallback is served until the next response comes from a live
fan-out or a fresh entry. fan-out or a fresh entry.
- **Provenance is stored, not re-applied** — what a cache entry holds is the - **Merged bodies are stored, not re-merged** — an entry holds the fully merged
fully merged body, `pdbmux_source` already injected and upstream records of `/nodes` body, `pdbmux_source` already stamped on, and the
that name already dropped. Attribution names the backend that supplied the `X-Records`/`X-Backends` it was built with, so a hit reproduces all three.
data, which is a property of that fetch, so it stays correct for as long as the Attribution is a property of that fetch, so it stays correct for as long as the
body does and ages out with it — `X-Cache` and `Age` say how old both are. Two body does and ages out with it. Two requests share an entry only when they
requests can only share an entry when they share a key, and the key is path share a key, and the key is path plus query.
plus query, which is exactly what decides whether injection applies; a
name-filtered `/facts` query and a plain one therefore cache separately and
neither is ever served the other's shape. The one path that keys on less than
it is asked is the `pdbmux_source` drilldown, whose `<value>` is dropped from
the key and applied to the shared entry instead. `source_fact` and
`source_fact_enabled` are read once at startup, and the cache lives for the
same process, so changing either cannot leave differently-shaped entries
behind.
## Config ## Config
@@ -508,7 +524,7 @@ backends: # order is a tie-break only, not a ranking
merge: freshness # freshness | static merge: freshness # freshness | static
timeout: 10s # per-upstream request timeout timeout: 10s # per-upstream request timeout
freshness_ttl: 30s # freshness-map cache TTL (freshness merge only) freshness_ttl: 30s # freshness-map cache TTL (freshness merge only)
facts_ttl: 30s # /facts + /nodes response cache TTL; 0 disables, capped at 30s facts_ttl: 30s # merged /nodes response cache TTL; 0 disables, capped at 30s
facts_cache_bytes: 67108864 # byte budget for that cache (64 MiB), LRU-evicted facts_cache_bytes: 67108864 # byte budget for that cache (64 MiB), LRU-evicted
source_fact: pdbmux_source # name of the synthetic provenance fact source_fact: pdbmux_source # name of the synthetic provenance fact
source_fact_enabled: true # false serves backends' records untouched source_fact_enabled: true # false serves backends' records untouched
+178 -207
View File
@@ -61,6 +61,7 @@ type countingBackend struct {
hits map[string]int hits map[string]int
bodies map[string]string bodies map[string]string
fail bool fail bool
dead bool
block chan struct{} block chan struct{}
} }
@@ -70,12 +71,15 @@ func newCountingBackend(t *testing.T, bodies map[string]string) *countingBackend
cb.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cb.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cb.mu.Lock() cb.mu.Lock()
cb.hits[r.URL.Path]++ cb.hits[r.URL.Path]++
fail, block, body := cb.fail, cb.block, cb.bodies[r.URL.Path] fail, dead, block, body := cb.fail, cb.dead, cb.block, cb.bodies[r.URL.Path]
cb.mu.Unlock() cb.mu.Unlock()
if block != nil { if block != nil {
<-block <-block
} }
if dead {
panic(http.ErrAbortHandler)
}
if fail { if fail {
http.Error(w, "boom", http.StatusInternalServerError) http.Error(w, "boom", http.StatusInternalServerError)
return return
@@ -108,6 +112,12 @@ func (cb *countingBackend) totalHits() int {
return n return n
} }
func (cb *countingBackend) setDead(v bool) {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.dead = v
}
func (cb *countingBackend) setFail(v bool) { func (cb *countingBackend) setFail(v bool) {
cb.mu.Lock() cb.mu.Lock()
defer cb.mu.Unlock() defer cb.mu.Unlock()
@@ -126,13 +136,13 @@ func (cb *countingBackend) setBlock(ch chan struct{}) {
cb.block = ch cb.block = ch
} }
// newCachedServer builds a server whose facts cache runs on a fake clock. // newCachedServer builds a server whose /nodes cache runs on a fake clock.
func newCachedServer(t *testing.T, cfg Config) (*Server, *fakeClock) { func newCachedServer(t *testing.T, cfg Config) (*Server, *fakeClock) {
t.Helper() t.Helper()
srv := newTestServer(cfg) srv := newTestServer(cfg)
mc, ok := srv.factsCache.(*memoryCache) mc, ok := srv.nodeCache.(*memoryCache)
if !ok { if !ok {
t.Fatalf("expected a memory cache, got %T", srv.factsCache) t.Fatalf("expected a memory cache, got %T", srv.nodeCache)
} }
clk := newFakeClock() clk := newFakeClock()
mc.now = clk.now mc.now = clk.now
@@ -267,14 +277,14 @@ func TestNoopCache_AlwaysMisses(t *testing.T) {
} }
} }
func TestHandler_FactsCacheFreshHit(t *testing.T) { func TestHandler_NodesCacheFreshHit(t *testing.T) {
body := `[` + fact("h1", "role", "web", "") + `]` body := `[` + node("h1", "2026-01-01T00:00:00.000Z") + `]`
a := newCountingBackend(t, map[string]string{factsPath: body}) a := newCountingBackend(t, map[string]string{nodesPath: body})
b := newCountingBackend(t, map[string]string{factsPath: `[]`}) b := newCountingBackend(t, map[string]string{nodesPath: `[]`})
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
first := doGet(t, srv.Handler(), factsPath, `["=","name","role"]`) first := doGet(t, srv.Handler(), nodesPath, `["=","certname","h1"]`)
second := doGet(t, srv.Handler(), factsPath, `["=","name","role"]`) second := doGet(t, srv.Handler(), nodesPath, `["=","certname","h1"]`)
if first.Code != http.StatusOK || second.Code != http.StatusOK { if first.Code != http.StatusOK || second.Code != http.StatusOK {
t.Fatalf("statuses %d/%d", first.Code, second.Code) t.Fatalf("statuses %d/%d", first.Code, second.Code)
@@ -282,40 +292,40 @@ func TestHandler_FactsCacheFreshHit(t *testing.T) {
if first.Body.String() != second.Body.String() { if first.Body.String() != second.Body.String() {
t.Errorf("cache hit changed the body:\n %s\n %s", first.Body.String(), second.Body.String()) t.Errorf("cache hit changed the body:\n %s\n %s", first.Body.String(), second.Body.String())
} }
if got := a.hitCount(factsPath); got != 1 { if got := a.hitCount(nodesPath); got != 1 {
t.Errorf("backend a saw %d requests, want 1 (second served from cache)", got) t.Errorf("backend a saw %d requests, want 1 (second served from cache)", got)
} }
if got := b.hitCount(factsPath); got != 1 { if got := b.hitCount(nodesPath); got != 1 {
t.Errorf("backend b saw %d requests, want 1", got) t.Errorf("backend b saw %d requests, want 1", got)
} }
// A different query is a different key and must go upstream. // A different query is a different key and must go upstream.
doGet(t, srv.Handler(), factsPath, `["=","name","osfamily"]`) doGet(t, srv.Handler(), nodesPath, `["=","certname","h2"]`)
if got := a.hitCount(factsPath); got != 2 { if got := a.hitCount(nodesPath); got != 2 {
t.Errorf("a different query should refetch: %d requests, want 2", got) t.Errorf("a different query should refetch: %d requests, want 2", got)
} }
} }
func TestHandler_FactsCacheExpires(t *testing.T) { func TestHandler_NodesCacheExpires(t *testing.T) {
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "role", "web", "") + `]`}) a := newCountingBackend(t, map[string]string{nodesPath: `[` + node("h1", "2026-01-01T00:00:00.000Z") + `]`})
b := newCountingBackend(t, map[string]string{factsPath: `[]`}) b := newCountingBackend(t, map[string]string{nodesPath: `[]`})
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
doGet(t, srv.Handler(), factsPath, "") doGet(t, srv.Handler(), nodesPath, "")
clk.advance(31 * time.Second) clk.advance(31 * time.Second)
doGet(t, srv.Handler(), factsPath, "") doGet(t, srv.Handler(), nodesPath, "")
if got := a.hitCount(factsPath); got != 2 { if got := a.hitCount(nodesPath); got != 2 {
t.Errorf("an expired entry should refetch: %d requests, want 2", got) t.Errorf("an expired entry should refetch: %d requests, want 2", got)
} }
} }
func TestHandler_ServesStaleOnlyWhenBackendsFail(t *testing.T) { func TestHandler_ServesStaleOnlyWhenBackendsFail(t *testing.T) {
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "role", "old", "") + `]`}) a := newCountingBackend(t, map[string]string{nodesPath: `[` + node("old", "2026-01-01T00:00:00.000Z") + `]`})
b := newCountingBackend(t, map[string]string{factsPath: `[]`}) b := newCountingBackend(t, map[string]string{nodesPath: `[]`})
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
warm := doGet(t, srv.Handler(), factsPath, "") warm := doGet(t, srv.Handler(), nodesPath, "")
if warm.Code != http.StatusOK { if warm.Code != http.StatusOK {
t.Fatalf("warm-up status %d", warm.Code) t.Fatalf("warm-up status %d", warm.Code)
} }
@@ -325,8 +335,8 @@ func TestHandler_ServesStaleOnlyWhenBackendsFail(t *testing.T) {
// Backends healthy but the entry expired: fresh data wins, never the stale copy. // Backends healthy but the entry expired: fresh data wins, never the stale copy.
clk.advance(31 * time.Second) clk.advance(31 * time.Second)
a.setBody(factsPath, `[`+fact("h1", "role", "new", "")+`]`) a.setBody(nodesPath, `[`+node("new", "2026-01-01T00:00:00.000Z")+`]`)
refetch := doGet(t, srv.Handler(), factsPath, "") refetch := doGet(t, srv.Handler(), nodesPath, "")
if !strings.Contains(refetch.Body.String(), `"new"`) { if !strings.Contains(refetch.Body.String(), `"new"`) {
t.Errorf("a healthy backend must not be shadowed by the stale entry: %s", refetch.Body.String()) t.Errorf("a healthy backend must not be shadowed by the stale entry: %s", refetch.Body.String())
} }
@@ -338,7 +348,7 @@ func TestHandler_ServesStaleOnlyWhenBackendsFail(t *testing.T) {
clk.advance(31 * time.Second) clk.advance(31 * time.Second)
a.setFail(true) a.setFail(true)
b.setFail(true) b.setFail(true)
stale := doGet(t, srv.Handler(), factsPath, "") stale := doGet(t, srv.Handler(), nodesPath, "")
if stale.Code != http.StatusOK { if stale.Code != http.StatusOK {
t.Fatalf("stale fallback status %d: %s", stale.Code, stale.Body.String()) t.Fatalf("stale fallback status %d: %s", stale.Code, stale.Body.String())
} }
@@ -352,51 +362,14 @@ func TestHandler_ServesStaleOnlyWhenBackendsFail(t *testing.T) {
} }
} }
// The stored entry is the whole estate's record set and the pinned value narrows
// it per request, so the fallback has to keep narrowing: a client asking for one
// backend's records must not be handed every backend's because the entry expired.
func TestHandler_StaleSourceFactDrilldownStaysFilteredByOwner(t *testing.T) {
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "osfamily", "RedHat", "") + `]`})
b := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h2", "osfamily", "Debian", "") + `]`})
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
drilldown := sourceFactURL + "/a"
want := map[string]string{"h1": "a"}
warm := doGet(t, srv.Handler(), drilldown, "")
if warm.Code != http.StatusOK {
t.Fatalf("warm-up status %d: %s", warm.Code, warm.Body.String())
}
if got, n := sourceValues(t, warm.Body.Bytes(), defaultSourceFact); n != len(want) || !reflect.DeepEqual(got, want) {
t.Fatalf("warm-up %s = %v (%d records), want %v", drilldown, got, n, want)
}
// Every backend down and the entry expired: the stale copy is served.
clk.advance(31 * time.Second)
a.setFail(true)
b.setFail(true)
rec := doGet(t, srv.Handler(), drilldown, "")
if rec.Code != http.StatusOK {
t.Fatalf("stale fallback status %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get(cacheStatusHeader); got != "stale" {
t.Fatalf("%s = %q, want stale: the request did not take the fallback path", cacheStatusHeader, got)
}
got, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact)
if n != len(want) || !reflect.DeepEqual(got, want) {
t.Errorf("stale %s = %v (%d records), want only backend a's %v", drilldown, got, n, want)
}
}
func TestHandler_NoCacheEntryMeansBackendFailureIs502(t *testing.T) { func TestHandler_NoCacheEntryMeansBackendFailureIs502(t *testing.T) {
a := newCountingBackend(t, map[string]string{factsPath: `[]`}) a := newCountingBackend(t, map[string]string{nodesPath: `[]`})
b := newCountingBackend(t, map[string]string{factsPath: `[]`}) b := newCountingBackend(t, map[string]string{nodesPath: `[]`})
a.setFail(true) a.setDead(true)
b.setFail(true) b.setDead(true)
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
rec := doGet(t, srv.Handler(), factsPath, "") rec := doGet(t, srv.Handler(), nodesPath, "")
if rec.Code != http.StatusBadGateway { if rec.Code != http.StatusBadGateway {
t.Fatalf("status %d, want 502", rec.Code) t.Fatalf("status %d, want 502", rec.Code)
} }
@@ -406,8 +379,8 @@ func TestHandler_NoCacheEntryMeansBackendFailureIs502(t *testing.T) {
} }
func TestHandler_SingleFlightCollapsesConcurrentRequests(t *testing.T) { func TestHandler_SingleFlightCollapsesConcurrentRequests(t *testing.T) {
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "role", "web", "") + `]`}) a := newCountingBackend(t, map[string]string{nodesPath: `[` + node("h1", "2026-01-01T00:00:00.000Z") + `]`})
b := newCountingBackend(t, map[string]string{factsPath: `[]`}) b := newCountingBackend(t, map[string]string{nodesPath: `[]`})
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
h := srv.Handler() h := srv.Handler()
@@ -422,13 +395,13 @@ func TestHandler_SingleFlightCollapsesConcurrentRequests(t *testing.T) {
wg.Add(1) wg.Add(1)
go func(i int) { go func(i int) {
defer wg.Done() defer wg.Done()
codes[i] = doGet(t, h, factsPath, "").Code codes[i] = doGet(t, h, nodesPath, "").Code
}(i) }(i)
} }
// The leader is parked inside the blocked backend, so every caller that // The leader is parked inside the blocked backend, so every caller that
// reaches the handler before the release joins its flight. // reaches the handler before the release joins its flight.
waitFor(t, func() bool { return a.hitCount(factsPath) >= 1 }) waitFor(t, func() bool { return a.hitCount(nodesPath) >= 1 })
time.Sleep(250 * time.Millisecond) time.Sleep(250 * time.Millisecond)
close(release) close(release)
wg.Wait() wg.Wait()
@@ -438,10 +411,10 @@ func TestHandler_SingleFlightCollapsesConcurrentRequests(t *testing.T) {
t.Fatalf("caller %d got %d", i, code) t.Fatalf("caller %d got %d", i, code)
} }
} }
if got := a.hitCount(factsPath); got != 1 { if got := a.hitCount(nodesPath); got != 1 {
t.Errorf("backend a saw %d requests, want 1 for %d concurrent callers", got, callers) t.Errorf("backend a saw %d requests, want 1 for %d concurrent callers", got, callers)
} }
if got := b.hitCount(factsPath); got != 1 { if got := b.hitCount(nodesPath); got != 1 {
t.Errorf("backend b saw %d requests, want 1 for %d concurrent callers", got, callers) t.Errorf("backend b saw %d requests, want 1 for %d concurrent callers", got, callers)
} }
} }
@@ -557,15 +530,15 @@ func TestFlightGroup_LeaderPanicFailsLeaderAndWaiters(t *testing.T) {
} }
func TestServeCached_BuildPanicIs502ThenStale(t *testing.T) { func TestServeCached_BuildPanicIs502ThenStale(t *testing.T) {
a := newCountingBackend(t, map[string]string{factsPath: `[]`}) a := newCountingBackend(t, map[string]string{nodesPath: `[]`})
b := newCountingBackend(t, map[string]string{factsPath: `[]`}) b := newCountingBackend(t, map[string]string{nodesPath: `[]`})
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
panicBuild := func(context.Context) (cachedResponse, error) { panic("build exploded") } panicBuild := func(context.Context) (cachedResponse, error) { panic("build exploded") }
serve := func(build func(context.Context) (cachedResponse, error)) *httptest.ResponseRecorder { serve := func(build func(context.Context) (cachedResponse, error)) *httptest.ResponseRecorder {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, factsPath, nil) req := httptest.NewRequest(http.MethodGet, nodesPath, nil)
srv.serveCached(rec, req, factsPath, nil, build) srv.serveCached(rec, req, nodesPath, nil, build)
return rec return rec
} }
@@ -575,7 +548,7 @@ func TestServeCached_BuildPanicIs502ThenStale(t *testing.T) {
t.Fatalf("status %d (%s), want 502", rec.Code, rec.Body.String()) t.Fatalf("status %d (%s), want 502", rec.Code, rec.Body.String())
} }
stored := `[` + fact("h1", "role", "web", "") + `]` stored := `[` + node("h1", "2026-01-01T00:00:00.000Z") + `]`
rec = serve(func(context.Context) (cachedResponse, error) { rec = serve(func(context.Context) (cachedResponse, error) {
return cachedResponse{Body: json.RawMessage(stored), Records: 1}, nil return cachedResponse{Body: json.RawMessage(stored), Records: 1}, nil
}) })
@@ -600,16 +573,16 @@ func TestServeCached_BuildPanicIs502ThenStale(t *testing.T) {
} }
func TestServeCached_BuildPanicFailsConcurrentWaiters(t *testing.T) { func TestServeCached_BuildPanicFailsConcurrentWaiters(t *testing.T) {
a := newCountingBackend(t, map[string]string{factsPath: `[]`}) a := newCountingBackend(t, map[string]string{nodesPath: `[]`})
b := newCountingBackend(t, map[string]string{factsPath: `[]`}) b := newCountingBackend(t, map[string]string{nodesPath: `[]`})
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
entered := make(chan struct{}) entered := make(chan struct{})
release := make(chan struct{}) release := make(chan struct{})
serve := func(build func(context.Context) (cachedResponse, error)) *httptest.ResponseRecorder { serve := func(build func(context.Context) (cachedResponse, error)) *httptest.ResponseRecorder {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, factsPath, nil) req := httptest.NewRequest(http.MethodGet, nodesPath, nil)
srv.serveCached(rec, req, factsPath, nil, build) srv.serveCached(rec, req, nodesPath, nil, build)
return rec return rec
} }
@@ -710,8 +683,8 @@ func TestHandler_LeaderDisconnectDoesNotFailFollowers(t *testing.T) {
// The flight the leader started still populated the cache. // The flight the leader started still populated the cache.
warmed := doGet(t, h, nodesPath, "") warmed := doGet(t, h, nodesPath, "")
if got := warmed.Header().Get(cacheStatusHeader); got != "hit" { if got := strings.TrimSpace(warmed.Body.String()); !sameJSON(t, got, want) {
t.Errorf("%s = %q, want hit: the abandoned leader's flight must still warm the cache", cacheStatusHeader, got) t.Errorf("warmed body = %s, want %s: the abandoned leader's flight must still warm the cache", got, want)
} }
if got := a.hitCount(nodesPath); got != 1 { if got := a.hitCount(nodesPath); got != 1 {
t.Errorf("backend a saw %d requests after the cached read, want 1", got) t.Errorf("backend a saw %d requests after the cached read, want 1", got)
@@ -750,7 +723,7 @@ func TestHandler_SoloDisconnectAbortsFanOutPromptly(t *testing.T) {
}() }()
start := time.Now() start := time.Now()
req := httptest.NewRequest(http.MethodGet, factsPath, nil).WithContext(ctx) req := httptest.NewRequest(http.MethodGet, nodesPath, nil).WithContext(ctx)
srv.Handler().ServeHTTP(httptest.NewRecorder(), req) srv.Handler().ServeHTTP(httptest.NewRecorder(), req)
if elapsed := time.Since(start); elapsed >= cfg.Timeout/2 { if elapsed := time.Since(start); elapsed >= cfg.Timeout/2 {
t.Errorf("handler returned after %s, want well under cfg.Timeout %s", elapsed, cfg.Timeout) t.Errorf("handler returned after %s, want well under cfg.Timeout %s", elapsed, cfg.Timeout)
@@ -783,7 +756,7 @@ func TestHandler_DisconnectedRequestsDoNotPinBackends(t *testing.T) {
time.Sleep(disconnectAfter) time.Sleep(disconnectAfter)
cancel() cancel()
}() }()
target := factsPath + "?query=" + url.QueryEscape(strconv.Itoa(i)) target := nodesPath + "?query=" + url.QueryEscape(strconv.Itoa(i))
req := httptest.NewRequest(http.MethodGet, target, nil).WithContext(ctx) req := httptest.NewRequest(http.MethodGet, target, nil).WithContext(ctx)
h.ServeHTTP(httptest.NewRecorder(), req) h.ServeHTTP(httptest.NewRecorder(), req)
}(i) }(i)
@@ -1116,47 +1089,39 @@ func assertGoroutinesSettle(t *testing.T, baseline int, frames ...string) {
} }
} }
func TestServeCached_CacheStatusHeaders(t *testing.T) { // PuppetDB emits no X-Cache and no Age, so a client cannot tell a cached
stored := `[` + fact("h1", "role", "web", "") + `]` // response from a live one. Covers the fresh hit, the miss and the stale
a := newCountingBackend(t, map[string]string{factsPath: stored}) // fallback, which are the three ways a response can leave the cached path.
b := newCountingBackend(t, map[string]string{factsPath: `[]`}) func TestHandler_CachedResponsesCarryNoCacheHeaders(t *testing.T) {
a := newCountingBackend(t, map[string]string{nodesPath: `[` + node("h1", "2026-01-01T00:00:00.000Z") + `]`})
b := newCountingBackend(t, map[string]string{nodesPath: `[]`})
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
h := srv.Handler() h := srv.Handler()
rec := doGet(t, h, factsPath, "") assertNoCacheHeaders(t, doGet(t, h, nodesPath, ""), "miss")
if got := rec.Header().Get(cacheStatusHeader); got != "miss" {
t.Errorf("first request %s = %q, want miss", cacheStatusHeader, got)
}
if got := rec.Header().Get(ageHeader); got != "0" {
t.Errorf("first request %s = %q, want 0", ageHeader, got)
}
clk.advance(7 * time.Second) clk.advance(7 * time.Second)
rec = doGet(t, h, factsPath, "") assertNoCacheHeaders(t, doGet(t, h, nodesPath, ""), "fresh hit")
if got := rec.Header().Get(cacheStatusHeader); got != "hit" {
t.Errorf("cached request %s = %q, want hit", cacheStatusHeader, got)
}
if got := rec.Header().Get(ageHeader); got != "7" {
t.Errorf("cached request %s = %q, want 7", ageHeader, got)
}
// Past the TTL with every backend down, the stale fallback must say so.
clk.advance(24 * time.Second) clk.advance(24 * time.Second)
a.setFail(true) a.setFail(true)
b.setFail(true) b.setFail(true)
rec = doGet(t, h, factsPath, "") rec := doGet(t, h, nodesPath, "")
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("stale fallback status %d (%s)", rec.Code, rec.Body.String()) t.Fatalf("stale fallback status %d (%s)", rec.Code, rec.Body.String())
} }
if got := rec.Header().Get(cacheStatusHeader); got != "stale" { if serving, _, _ := srv.stale.snapshot(); !serving {
t.Errorf("stale fallback %s = %q, want stale", cacheStatusHeader, got) t.Fatal("the third request did not take the stale fallback path")
} }
if got := rec.Header().Get(ageHeader); got != "31" { assertNoCacheHeaders(t, rec, "stale fallback")
t.Errorf("stale fallback %s = %q, want 31 seconds since the entry was stored", ageHeader, got) }
}
want := `[` + fact("h1", "role", "web", "") + `,` + factEnv("h1", defaultSourceFact, "a", "") + `]` func assertNoCacheHeaders(t *testing.T, rec *httptest.ResponseRecorder, what string) {
if got := strings.TrimSpace(rec.Body.String()); !sameJSON(t, got, want) { t.Helper()
t.Errorf("stale body = %s, want %s", got, want) for _, h := range []string{"X-Cache", "Age"} {
if got := rec.Header().Get(h); got != "" {
t.Errorf("%s response carries %s: %q, want no such header", what, h, got)
}
} }
} }
@@ -1193,18 +1158,18 @@ func (c *recordingCache) Stats() CacheStats { return CacheStats{Backend: "record
func TestServeCached_PutRunsOnDetachedContext(t *testing.T) { func TestServeCached_PutRunsOnDetachedContext(t *testing.T) {
srv := newTestServer(cacheTestConfig("http://backend.invalid", "http://backend.invalid")) srv := newTestServer(cacheTestConfig("http://backend.invalid", "http://backend.invalid"))
cache := newRecordingCache() cache := newRecordingCache()
srv.factsCache = cache srv.nodeCache = cache
entered := make(chan struct{}) entered := make(chan struct{})
release := make(chan struct{}) release := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
req := httptest.NewRequest(http.MethodGet, factsPath, nil).WithContext(ctx) req := httptest.NewRequest(http.MethodGet, nodesPath, nil).WithContext(ctx)
served := make(chan struct{}) served := make(chan struct{})
go func() { go func() {
defer close(served) defer close(served)
srv.serveCached(httptest.NewRecorder(), req, factsPath, nil, func(context.Context) (cachedResponse, error) { srv.serveCached(httptest.NewRecorder(), req, nodesPath, nil, func(context.Context) (cachedResponse, error) {
close(entered) close(entered)
<-release <-release
return cachedResponse{Body: json.RawMessage(`[]`), Records: -1}, nil return cachedResponse{Body: json.RawMessage(`[]`), Records: -1}, nil
@@ -1236,8 +1201,8 @@ func TestServeCached_PutRunsOnDetachedContext(t *testing.T) {
} }
func TestHandler_HealthzReportsCacheState(t *testing.T) { func TestHandler_HealthzReportsCacheState(t *testing.T) {
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "role", "web", "") + `]`}) a := newCountingBackend(t, map[string]string{nodesPath: `[` + node("h1", "2026-01-01T00:00:00.000Z") + `]`})
b := newCountingBackend(t, map[string]string{factsPath: `[]`}) b := newCountingBackend(t, map[string]string{nodesPath: `[]`})
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
hr := health(t, srv) hr := health(t, srv)
@@ -1248,7 +1213,7 @@ func TestHandler_HealthzReportsCacheState(t *testing.T) {
t.Fatalf("a cold cache should be empty and not stale: %+v", hr.Cache) t.Fatalf("a cold cache should be empty and not stale: %+v", hr.Cache)
} }
doGet(t, srv.Handler(), factsPath, "") doGet(t, srv.Handler(), nodesPath, "")
hr = health(t, srv) hr = health(t, srv)
if hr.Cache.Entries != 1 || hr.Cache.StaleEntries != 0 || hr.Cache.Bytes == 0 { if hr.Cache.Entries != 1 || hr.Cache.StaleEntries != 0 || hr.Cache.Bytes == 0 {
t.Fatalf("after one request: %+v", hr.Cache) t.Fatalf("after one request: %+v", hr.Cache)
@@ -1264,7 +1229,7 @@ func TestHandler_HealthzReportsCacheState(t *testing.T) {
a.setFail(true) a.setFail(true)
b.setFail(true) b.setFail(true)
doGet(t, srv.Handler(), factsPath, "") doGet(t, srv.Handler(), nodesPath, "")
hr = health(t, srv) hr = health(t, srv)
if !hr.Cache.ServingStale || hr.Cache.StaleServed != 1 || hr.Cache.LastStale == "" { if !hr.Cache.ServingStale || hr.Cache.StaleServed != 1 || hr.Cache.LastStale == "" {
t.Errorf("staleness not surfaced in /healthz: %+v", hr.Cache) t.Errorf("staleness not surfaced in /healthz: %+v", hr.Cache)
@@ -1444,9 +1409,9 @@ func TestNewServer_ClampsFactsTTL(t *testing.T) {
cfg.CacheBytes = 1 << 20 cfg.CacheBytes = 1 << 20
srv := newTestServer(cfg) srv := newTestServer(cfg)
mc, ok := srv.factsCache.(*memoryCache) mc, ok := srv.nodeCache.(*memoryCache)
if !ok { if !ok {
t.Fatalf("expected a memory cache, got %T", srv.factsCache) t.Fatalf("expected a memory cache, got %T", srv.nodeCache)
} }
if mc.ttl != maxFactsTTL { if mc.ttl != maxFactsTTL {
t.Errorf("cache ttl = %s, want %s", mc.ttl, maxFactsTTL) t.Errorf("cache ttl = %s, want %s", mc.ttl, maxFactsTTL)
@@ -1520,35 +1485,6 @@ func stamped(t *testing.T, raw, field, value string) string {
// The injector is per-request but a cache entry is shared, so a second caller is // The injector is per-request but a cache entry is shared, so a second caller is
// served a body built for the first. Provenance names the backend that supplied // served a body built for the first. Provenance names the backend that supplied
// the data, which is a property of that fetch, so the shared body stays correct. // the data, which is a property of that fetch, so the shared body stays correct.
func TestHandler_CachedFactsKeepSourceAttribution(t *testing.T) {
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "role", "web", "") + `]`})
b := newCountingBackend(t, map[string]string{factsPath: `[]`})
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
h := srv.Handler()
first := doGet(t, h, factsPath, "")
if got := first.Header().Get(cacheStatusHeader); got != "miss" {
t.Fatalf("first %s = %q, want miss", cacheStatusHeader, got)
}
srcs, n := sourceValues(t, first.Body.Bytes(), defaultSourceFact)
if n != 1 || srcs["h1"] != "a" {
t.Fatalf("first request sources = %v (%d records), want h1 -> a", srcs, n)
}
second := doGet(t, h, factsPath, "")
if got := second.Header().Get(cacheStatusHeader); got != "hit" {
t.Fatalf("second %s = %q, want hit", cacheStatusHeader, got)
}
srcs, n = sourceValues(t, second.Body.Bytes(), defaultSourceFact)
if n != 1 || srcs["h1"] != "a" {
t.Errorf("cached request sources = %v (%d records), want exactly one h1 -> a", srcs, n)
}
if got := a.hitCount(factsPath); got != 1 {
t.Errorf("backend a saw %d requests, want 1: the second read must come from the cache", got)
}
}
// The same, for the /nodes stamp rather than the synthetic /facts record.
func TestHandler_CachedNodesKeepSourceStamp(t *testing.T) { func TestHandler_CachedNodesKeepSourceStamp(t *testing.T) {
a := newCountingBackend(t, map[string]string{nodesPath: `[` + node("h1", "2026-01-02T00:00:00.000Z") + `]`}) a := newCountingBackend(t, map[string]string{nodesPath: `[` + node("h1", "2026-01-02T00:00:00.000Z") + `]`})
b := newCountingBackend(t, map[string]string{nodesPath: `[` + node("h1", "2026-01-01T00:00:00.000Z") + `]`}) b := newCountingBackend(t, map[string]string{nodesPath: `[` + node("h1", "2026-01-01T00:00:00.000Z") + `]`})
@@ -1561,9 +1497,6 @@ func TestHandler_CachedNodesKeepSourceStamp(t *testing.T) {
} }
second := doGet(t, h, nodesPath, "") second := doGet(t, h, nodesPath, "")
if got := second.Header().Get(cacheStatusHeader); got != "hit" {
t.Fatalf("second %s = %q, want hit", cacheStatusHeader, got)
}
want := `[` + stamped(t, node("h1", "2026-01-02T00:00:00.000Z"), defaultSourceFact, "a") + `]` want := `[` + stamped(t, node("h1", "2026-01-02T00:00:00.000Z"), defaultSourceFact, "a") + `]`
if got := strings.TrimSpace(second.Body.String()); !sameJSON(t, got, want) { if got := strings.TrimSpace(second.Body.String()); !sameJSON(t, got, want) {
t.Errorf("cached body = %s, want %s", got, want) t.Errorf("cached body = %s, want %s", got, want)
@@ -1577,77 +1510,115 @@ func TestHandler_CachedNodesKeepSourceStamp(t *testing.T) {
// while the entry is served the attribution is the one that fetch had, and the // while the entry is served the attribution is the one that fetch had, and the
// rebuild after the TTL picks up the move. // rebuild after the TTL picks up the move.
func TestHandler_CachedSourceAgesWithItsData(t *testing.T) { func TestHandler_CachedSourceAgesWithItsData(t *testing.T) {
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "role", "web", "") + `]`}) a := newCountingBackend(t, map[string]string{nodesPath: `[` + node("h1", "2026-01-01T00:00:00.000Z") + `]`})
b := newCountingBackend(t, map[string]string{factsPath: `[]`}) b := newCountingBackend(t, map[string]string{nodesPath: `[]`})
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
h := srv.Handler() h := srv.Handler()
doGet(t, h, factsPath, "") doGet(t, h, nodesPath, "")
// The node moves to b while the entry is still fresh. // The node moves to b while the entry is still fresh.
a.setBody(factsPath, `[]`) a.setBody(nodesPath, `[]`)
b.setBody(factsPath, `[`+fact("h1", "role", "web", "")+`]`) b.setBody(nodesPath, `[`+node("h1", "2026-01-01T00:00:00.000Z")+`]`)
cached := doGet(t, h, factsPath, "") cached := doGet(t, h, nodesPath, "")
if got := cached.Header().Get(cacheStatusHeader); got != "hit" { if got := nodeSources(t, cached.Body.Bytes(), defaultSourceFact); got["h1"] != "a" {
t.Fatalf("%s = %q, want hit", cacheStatusHeader, got) t.Errorf("cached stamp = %v, want h1 -> a: the body and its attribution come from the same fetch", got)
} }
if srcs, _ := sourceValues(t, cached.Body.Bytes(), defaultSourceFact); srcs["h1"] != "a" { if got := a.hitCount(nodesPath); got != 1 {
t.Errorf("cached sources = %v, want h1 -> a: the body and its attribution come from the same fetch", srcs) t.Errorf("backend a saw %d requests, want 1 while the entry is fresh", got)
} }
clk.advance(31 * time.Second) clk.advance(31 * time.Second)
rebuilt := doGet(t, h, factsPath, "") rebuilt := doGet(t, h, nodesPath, "")
if srcs, _ := sourceValues(t, rebuilt.Body.Bytes(), defaultSourceFact); srcs["h1"] != "b" { if got := nodeSources(t, rebuilt.Body.Bytes(), defaultSourceFact); got["h1"] != "b" {
t.Errorf("rebuilt sources = %v, want h1 -> b once the entry expired", srcs) t.Errorf("rebuilt stamp = %v, want h1 -> b once the entry expired", got)
} }
} }
// The injection gate is a pure function of path and query, both of which are in // Fact answers must be as current as a backend's own, so every fact-serving
// the cache key, so a gated request can never be served an injected body cached // route refetches: the second request sees data that changed between the two.
// for an ungated one. func TestHandler_FactPathsAreNeverCached(t *testing.T) {
func TestHandler_SourceGateIsPerCacheKey(t *testing.T) { for _, path := range []string{
factsPath,
factsPath + "/role",
factsPath + "/role/web",
factNamesPath,
factsetsPath,
nodesPath + "/h1/facts",
"/pdb/query/v4/fact-contents",
"/pdb/query/v4/fact-paths",
"/pdb/query/v4/inventory",
} {
t.Run(path, func(t *testing.T) {
before := `[{"certname":"h1","name":"role","value":"before"}]`
after := `[{"certname":"h1","name":"role","value":"after"}]`
a := newCountingBackend(t, map[string]string{path: before})
b := newCountingBackend(t, map[string]string{path: `[]`})
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
h := srv.Handler()
if first := doGet(t, h, path, ""); !strings.Contains(first.Body.String(), "before") {
t.Fatalf("first %s = %s, want the backend's own records", path, first.Body.String())
}
a.setBody(path, after)
second := doGet(t, h, path, "")
if strings.Contains(second.Body.String(), "before") {
t.Errorf("second %s = %s, want the changed data rather than a cached copy", path, second.Body.String())
}
if !strings.Contains(second.Body.String(), "after") {
t.Errorf("second %s = %s, want the changed data", path, second.Body.String())
}
if got := a.hitCount(path); got != 2 {
t.Errorf("backend a saw %d requests for %s, want one per request", got, path)
}
})
}
}
// No fact response may carry the cache headers PuppetDB never sends, whatever
// else pdbmux is doing with the merged body.
func TestHandler_FactResponsesCarryNoCacheHeaders(t *testing.T) {
body := `[` + fact("h1", "role", "web", "") + `]` body := `[` + fact("h1", "role", "web", "") + `]`
a := newCountingBackend(t, map[string]string{factsPath: body}) for _, path := range []string{factsPath, factsPath + "/role", factNamesPath, sourceFactURL} {
b := newCountingBackend(t, map[string]string{factsPath: `[]`}) t.Run(path, func(t *testing.T) {
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) a := newCountingBackend(t, map[string]string{path: body, factsPath: body})
h := srv.Handler() b := newCountingBackend(t, map[string]string{path: `[]`, factsPath: `[]`})
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
h := srv.Handler()
const nameFiltered = `["=","name","role"]` assertNoCacheHeaders(t, doGet(t, h, path, ""), path)
for _, pass := range []string{"first", "cached"} { assertNoCacheHeaders(t, doGet(t, h, path, ""), path+" repeated")
open := doGet(t, h, factsPath, "") })
if srcs, n := sourceValues(t, open.Body.Bytes(), defaultSourceFact); n != 1 || srcs["h1"] != "a" {
t.Errorf("%s unfiltered request sources = %v (%d records), want h1 -> a", pass, srcs, n)
}
gated := doGet(t, h, factsPath, nameFiltered)
if _, n := sourceValues(t, gated.Body.Bytes(), defaultSourceFact); n != 0 {
t.Errorf("%s name-filtered request carried %d synthetic record(s), want none", pass, n)
}
if got := strings.TrimSpace(gated.Body.String()); !sameJSON(t, got, body) {
t.Errorf("%s name-filtered body = %s, want %s", pass, got, body)
}
}
if got := a.hitCount(factsPath); got != 2 {
t.Errorf("backend a saw %d requests, want 2: one per distinct cache key", got)
} }
} }
// Suppression of an upstream fact of the configured name is part of the merged // openvoxdb answers a successful query with application/json;charset=utf-8
// body, so it survives into the cache rather than being re-applied per request. // (src/puppetlabs/puppetdb/query_eng.clj:555 through http.clj:80), so a merged
func TestHandler_SuppressionSurvivesCacheHit(t *testing.T) { // response has to as well or a client can tell the proxy from the real thing.
upstream := `[` + fact("h1", defaultSourceFact, "somewhere-else", "") + `,` + fact("h1", "role", "web", "") + `]` func TestHandler_SuccessContentTypeMatchesPuppetDB(t *testing.T) {
a := newCountingBackend(t, map[string]string{factsPath: upstream}) bodies := map[string]string{
b := newCountingBackend(t, map[string]string{factsPath: `[]`}) nodesPath: `[` + node("h1", "2026-01-01T00:00:00.000Z") + `]`,
factsPath: `[` + fact("h1", "role", "web", "") + `]`,
factNamesPath: `["role"]`,
reportsPath: `[` + report("h1", "abc", "2026-01-01T00:00:00.000Z") + `]`,
}
a := newCountingBackend(t, bodies)
b := newCountingBackend(t, map[string]string{nodesPath: `[]`, factsPath: `[]`, factNamesPath: `[]`, reportsPath: `[]`})
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
h := srv.Handler() h := srv.Handler()
doGet(t, h, factsPath, "") for path := range bodies {
cached := doGet(t, h, factsPath, "") // Twice, so a cache hit is held to the same content type as a live build.
if got := cached.Header().Get(cacheStatusHeader); got != "hit" { for _, pass := range []string{"live", "repeated"} {
t.Fatalf("%s = %q, want hit", cacheStatusHeader, got) rec := doGet(t, h, path, "")
} if rec.Code != http.StatusOK {
srcs, n := sourceValues(t, cached.Body.Bytes(), defaultSourceFact) t.Fatalf("%s %s status %d (%s)", pass, path, rec.Code, rec.Body.String())
if n != 1 || srcs["h1"] != "a" { }
t.Errorf("cached sources = %v (%d records), want exactly one h1 -> a, the upstream value dropped", srcs, n) if got := rec.Header().Get("Content-Type"); got != "application/json;charset=utf-8" {
t.Errorf("%s %s Content-Type = %q, want application/json;charset=utf-8", pass, path, got)
}
}
} }
} }
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"context"
"net/http"
"strings"
)
const (
factsetsPath = "/pdb/query/v4/factsets"
catalogsPath = "/pdb/query/v4/catalogs"
certnameRouteName = "/pdb/query/v4/{nodes,factsets,catalogs}/<certname>"
)
// certnamePrefixes are the endpoints whose next path segment is a certname.
// openvoxdb resolves that segment to a parent row before it serves anything
// under it — status-not-found-response for the singleton
// (src/puppetlabs/puppetdb/http/handlers.clj:98-120,252-253,342-343,372 and
// http.clj:238-242) and parent-check for every child path
// (middleware.clj:381-398, mounted at handlers.clj:345-347,255-262,373-380) —
// so each of these paths describes exactly one node's data and 404s when the
// backend holds none of it.
var certnamePrefixes = []string{nodesPath + "/", factsetsPath + "/", catalogsPath + "/"}
// certnameFor returns the certname a path is keyed on, empty when the path is
// not one of these routes.
func certnameFor(path string) string {
for _, prefix := range certnamePrefixes {
rest, ok := strings.CutPrefix(path, prefix)
if !ok {
continue
}
certname, _, _ := strings.Cut(rest, "/")
return certname
}
return ""
}
func isCertnameRoute(path string) bool { return certnameFor(path) != "" }
// serveByOwner answers a path keyed on one certname from the backend that owns
// that certname, so a node resolves here exactly as it resolves in the merged
// collections. Without it the answer is whichever backend is configured first,
// and a node both backends hold reports one backend's facts through /facts and
// the other's through /nodes/<certname>/facts.
//
// The owner's reply is passed through whole rather than merged: openvoxdb
// answers these paths with a bare object or a parent-check 404, neither of
// which is the record array the merges are built on. The remaining backends are
// tried after it, so a node only one backend holds is still served and a
// certname no backend holds still answers with openvoxdb's own 404 body.
//
// askOwnerFirst is what makes the owner's reply the answer even when it is an
// error. A backend that does not hold the certname 404s to say so, so it has no
// opinion to weigh against the owner's.
func (s *Server) serveByOwner(w http.ResponseWriter, r *http.Request) {
s.proxyOrdered(w, r, s.ownerFirst(r.Context(), certnameFor(r.URL.Path)), askOwnerFirst)
}
// ownerFirst puts the backend holding a certname's newest report ahead of the
// rest, from the same freshness map the /facts merge attributes records with.
// Static merge has no per-certname owner, and neither has a certname absent
// from the map, so both keep configured order — the tie-break the merges use.
func (s *Server) ownerFirst(ctx context.Context, certname string) []Backend {
if s.cfg.Merge == mergeStatic || certname == "" {
return s.cfg.Backends
}
owner := s.freshnessMap(ctx, nil)[certname]
if owner == "" {
return s.cfg.Backends
}
ordered := make([]Backend, 0, len(s.cfg.Backends))
for _, b := range s.cfg.Backends {
if b.Name == owner {
ordered = append(ordered, b)
}
}
for _, b := range s.cfg.Backends {
if b.Name != owner {
ordered = append(ordered, b)
}
}
return ordered
}
+435
View File
@@ -0,0 +1,435 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
// The bodies below are the shapes openvoxdb 8.9.0 actually serves, captured
// from a live backend: a bare object for /factsets/<certname>
// (src/puppetlabs/puppetdb/http/handlers.clj:98-108,342-343), a fact array for
// /nodes/<certname>/facts (handlers.clj:373-376) and a pretty-printed
// {"error": ...} body for a certname the backend has no row for
// (http.clj:238-242 for the singleton, middleware.clj:381-398 for the child
// path, which names the parent — "node" — rather than the child).
func factsetBody(certname, timestamp, role string) string {
return fmt.Sprintf(`{
"timestamp" : %q,
"facts" : {
"data" : [ {
"name" : "role",
"value" : %q
} ]
},
"certname" : %q,
"hash" : "d775f92fb438b96276e721bb09524e6361156b06",
"producer_timestamp" : %q,
"producer" : "compiler.example.net",
"environment" : "develop"
}`, timestamp, role, certname, timestamp)
}
func nodeFactsBody(certname, role string) string {
return fmt.Sprintf(`[{"certname":%q,"environment":"develop","name":"role","value":%q}]`, certname, role)
}
func notFoundBody(kind, id string) string {
return fmt.Sprintf("{\n \"error\" : \"No information is known about %s %s\"\n}", kind, id)
}
// voxBackend answers the per-certname routes as openvoxdb does: the certnames
// it holds get a bare object or a fact array, and every other certname gets the
// parent-check 404. Each backend labels its facts with its own name, so a
// response says which backend answered it.
type voxBackend struct {
srv *httptest.Server
name string
// nodes maps a certname it holds to that node's report_timestamp.
nodes map[string]string
fail bool
// perNodeStatus and perNodeBody answer the per-certname routes with a fixed
// reply, leaving /nodes alone so the freshness map still resolves an owner.
perNodeStatus int
perNodeBody string
// perNodeDead aborts the per-certname routes without a status, standing in
// for a backend that is unreachable only for that request.
perNodeDead bool
mu sync.Mutex
gotParams map[string]url.Values
}
func newVoxBackend(t *testing.T, name string, nodes map[string]string) *voxBackend {
t.Helper()
vb := &voxBackend{name: name, nodes: nodes, gotParams: map[string]url.Values{}}
vb.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vb.mu.Lock()
vb.gotParams[r.URL.Path] = r.URL.Query()
vb.mu.Unlock()
if vb.fail {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json;charset=utf-8")
if isCertnameRoute(r.URL.Path) {
if vb.perNodeDead {
panic(http.ErrAbortHandler)
}
if vb.perNodeStatus != 0 {
w.WriteHeader(vb.perNodeStatus)
_, _ = io.WriteString(w, vb.perNodeBody)
return
}
}
if certname, ok := strings.CutPrefix(r.URL.Path, factsetsPath+"/"); ok {
ts, held := vb.nodes[certname]
if !held {
vb.notFound(w, "factset", certname)
return
}
_, _ = io.WriteString(w, factsetBody(certname, ts, vb.name))
return
}
if rest, ok := strings.CutPrefix(r.URL.Path, nodesPath+"/"); ok {
certname, _, _ := strings.Cut(rest, "/")
if _, held := vb.nodes[certname]; !held {
vb.notFound(w, "node", certname)
return
}
_, _ = io.WriteString(w, nodeFactsBody(certname, vb.name))
return
}
switch r.URL.Path {
case nodesPath:
_, _ = io.WriteString(w, "["+strings.Join(vb.nodeRecords(), ",")+"]")
case factsPath:
_, _ = io.WriteString(w, "["+strings.Join(vb.factRecords(), ",")+"]")
default:
_, _ = io.WriteString(w, "[]")
}
}))
t.Cleanup(vb.srv.Close)
return vb
}
func (vb *voxBackend) notFound(w http.ResponseWriter, kind, id string) {
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, notFoundBody(kind, id))
}
func (vb *voxBackend) nodeRecords() []string {
out := make([]string, 0, len(vb.nodes))
for certname, ts := range vb.nodes {
out = append(out, node(certname, ts))
}
return out
}
func (vb *voxBackend) factRecords() []string {
out := make([]string, 0, len(vb.nodes))
for certname := range vb.nodes {
out = append(out, fact(certname, "role", vb.name, ""))
}
return out
}
func (vb *voxBackend) params(path string) url.Values {
vb.mu.Lock()
defer vb.mu.Unlock()
return vb.gotParams[path]
}
// perNodePaths are the routes a certname resolves on, with the body field that
// names the backend that answered.
var perNodePaths = []struct {
name string
path func(certname string) string
}{
{"factsets/<certname>", func(cn string) string { return factsetsPath + "/" + cn }},
{"nodes/<certname>/facts", func(cn string) string { return nodesPath + "/" + cn + "/facts" }},
}
func TestCertnameRoutes_ServeTheBackendHoldingTheNode(t *testing.T) {
for _, tc := range []struct {
name string
aNodes map[string]string
bNodes map[string]string
certname string
want string
}{
{
name: "held only by a",
aNodes: map[string]string{"h1.example.net": "2026-07-01T00:00:00Z"},
bNodes: map[string]string{},
certname: "h1.example.net",
want: "a",
},
{
name: "held only by b",
aNodes: map[string]string{},
bNodes: map[string]string{"h1.example.net": "2026-07-01T00:00:00Z"},
certname: "h1.example.net",
want: "b",
},
{
// b reported later, so b owns the node even though a is configured
// first and would answer a plain pass-through.
name: "held by both, newer report on b",
aNodes: map[string]string{"h1.example.net": "2026-07-01T00:00:00Z"},
bNodes: map[string]string{"h1.example.net": "2026-07-20T00:00:00Z"},
certname: "h1.example.net",
want: "b",
},
{
name: "held by both, newer report on a",
aNodes: map[string]string{"h1.example.net": "2026-07-20T00:00:00Z"},
bNodes: map[string]string{"h1.example.net": "2026-07-01T00:00:00Z"},
certname: "h1.example.net",
want: "a",
},
} {
t.Run(tc.name, func(t *testing.T) {
a := newVoxBackend(t, "a", tc.aNodes)
b := newVoxBackend(t, "b", tc.bNodes)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeFreshness))
for _, rt := range perNodePaths {
rec := doGet(t, srv.Handler(), rt.path(tc.certname), "")
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d: %s", rt.name, rec.Code, rec.Body.String())
}
if got := rec.Body.String(); !strings.Contains(got, `"`+tc.want+`"`) {
t.Errorf("%s answered by the wrong backend, want %q: %s", rt.name, tc.want, got)
}
}
// The collection merge has to agree: one node cannot report one
// backend's facts here and another's through /facts.
rec := doGet(t, srv.Handler(), factsPath, `["=","certname","`+tc.certname+`"]`)
if got := factRoleValue(t, rec.Body.Bytes(), tc.certname); got != tc.want {
t.Errorf("/facts resolved %s to %q, want %q; the routes disagree", tc.certname, got, tc.want)
}
})
}
}
// factRoleValue reads the certname's "role" fact out of a merged /facts body.
func factRoleValue(t *testing.T, body []byte, certname string) string {
t.Helper()
var recs []struct {
Certname string `json:"certname"`
Name string `json:"name"`
Value string `json:"value"`
}
if err := json.Unmarshal(body, &recs); err != nil {
t.Fatalf("unmarshal %s: %v", body, err)
}
for _, rec := range recs {
if rec.Certname == certname && rec.Name == "role" {
return rec.Value
}
}
t.Fatalf("no role fact for %s in %s", certname, body)
return ""
}
// A certname neither backend holds must still look like openvoxdb: its own 404
// status and error body, not an empty success.
func TestCertnameRoutes_UnknownCertnameKeepsTheUpstream404(t *testing.T) {
a := newVoxBackend(t, "a", map[string]string{"h1.example.net": "2026-07-01T00:00:00Z"})
b := newVoxBackend(t, "b", map[string]string{"h2.example.net": "2026-07-01T00:00:00Z"})
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeFreshness))
for _, tc := range []struct {
path string
want string
}{
{factsetsPath + "/gone.example.net", notFoundBody("factset", "gone.example.net")},
{nodesPath + "/gone.example.net/facts", notFoundBody("node", "gone.example.net")},
} {
rec := doGet(t, srv.Handler(), tc.path, "")
if rec.Code != http.StatusNotFound {
t.Errorf("%s: status %d, want 404: %s", tc.path, rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != tc.want {
t.Errorf("%s: body %q, want %q", tc.path, got, tc.want)
}
if got := rec.Header().Get("Content-Type"); got != "application/json;charset=utf-8" {
t.Errorf("%s: content type %q", tc.path, got)
}
}
}
// A backend that is down is indistinguishable from one that has never heard of
// the node, so the surviving backend still answers.
func TestCertnameRoutes_OneBackendDown(t *testing.T) {
a := newVoxBackend(t, "a", map[string]string{"h1.example.net": "2026-07-20T00:00:00Z"})
b := newVoxBackend(t, "b", map[string]string{"h1.example.net": "2026-07-01T00:00:00Z"})
// a owns h1 by freshness, so the fallback is exercised only if a is skipped.
a.fail = true
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeFreshness))
for _, rt := range perNodePaths {
rec := doGet(t, srv.Handler(), rt.path("h1.example.net"), "")
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d, want the survivor's 200: %s", rt.name, rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"b"`) {
t.Errorf("%s: want b's record, got %s", rt.name, rec.Body.String())
}
}
}
// Static merge attributes no certname to a backend, so these routes keep the
// configured order the /facts merge falls back to.
func TestCertnameRoutes_StaticMergeKeepsConfiguredOrder(t *testing.T) {
a := newVoxBackend(t, "a", map[string]string{"h1.example.net": "2026-07-01T00:00:00Z"})
b := newVoxBackend(t, "b", map[string]string{"h1.example.net": "2026-07-20T00:00:00Z"})
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), factsetsPath+"/h1.example.net", "")
if !strings.Contains(rec.Body.String(), `"a"`) {
t.Errorf("static merge should keep the first configured backend, got %s", rec.Body.String())
}
}
// openvoxdb ANDs the request's own query onto the certname restriction
// (src/puppetlabs/puppetdb/http/query.clj:136-143,158-164), so the owner has to
// receive it unchanged.
func TestCertnameRoutes_ForwardQueryToTheOwner(t *testing.T) {
a := newVoxBackend(t, "a", map[string]string{"h1.example.net": "2026-07-20T00:00:00Z"})
b := newVoxBackend(t, "b", map[string]string{"h1.example.net": "2026-07-01T00:00:00Z"})
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeFreshness))
const q = `["=","name","role"]`
path := nodesPath + "/h1.example.net/facts"
if rec := doGet(t, srv.Handler(), path, q); rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if got := a.params(path).Get("query"); got != q {
t.Errorf("owner got query %q, want %q", got, q)
}
if got := b.params(path).Get("query"); got != "" {
t.Errorf("non-owner was queried with %q; only the owner should be asked", got)
}
}
func TestCertnameFor(t *testing.T) {
for _, tc := range []struct{ path, want string }{
{factsetsPath + "/h1.example.net", "h1.example.net"},
{nodesPath + "/h1.example.net", "h1.example.net"},
{nodesPath + "/h1.example.net/facts", "h1.example.net"},
{nodesPath + "/h1.example.net/facts/role/web", "h1.example.net"},
{nodesPath + "/h1.example.net/resources", "h1.example.net"},
{catalogsPath + "/h1.example.net/edges", "h1.example.net"},
// Collections and fact-name drilldowns are merged elsewhere and must
// not be mistaken for a certname.
{nodesPath, ""},
{nodesPath + "/", ""},
{factsPath, ""},
{factsPath + "/role", ""},
{factsetsPath, ""},
{reportsPath + "/abc123/events", ""},
} {
if got := certnameFor(tc.path); got != tc.want {
t.Errorf("certnameFor(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
func TestCertnameRoutes_ClaimedByTheRouteTable(t *testing.T) {
for _, path := range []string{
factsetsPath + "/h1.example.net",
nodesPath + "/h1.example.net",
nodesPath + "/h1.example.net/facts",
catalogsPath + "/h1.example.net",
} {
if got := routeFor(path).name; got != certnameRouteName {
t.Errorf("routeFor(%q) = %q, want %q", path, got, certnameRouteName)
}
}
}
// The owner holds the node, so its answer is the answer: a non-owner's 404 only
// says the node is not its own, and demanding it agree turns the owner's real
// error into a 502 that describes neither backend.
func TestCertnameRoutes_OwnerAnswerWins(t *testing.T) {
const certname = "h1.example.net"
for _, tc := range []struct {
name string
// setUp configures the owner (a, which reported later) and the backend
// that does not hold the node (b).
setUp func(a, b *voxBackend)
wantCode int
wantBody string
}{
{
name: "owner errors while the non-owner 404s",
setUp: func(a, b *voxBackend) { a.perNodeStatus, a.perNodeBody = http.StatusInternalServerError, "boom" },
wantCode: http.StatusInternalServerError,
wantBody: "boom",
},
{
name: "owner and non-owner error differently",
setUp: func(a, b *voxBackend) {
a.perNodeStatus, a.perNodeBody = http.StatusInternalServerError, "boom"
b.perNodeStatus, b.perNodeBody = http.StatusServiceUnavailable, "busy"
},
wantCode: http.StatusInternalServerError,
wantBody: "boom",
},
{
// The fallback chain is there so an unreachable owner does not take the
// node with it; a backend that did answer explains more than a 502.
name: "owner unreachable, fallback answers",
setUp: func(a, b *voxBackend) {
a.perNodeDead = true
b.perNodeStatus, b.perNodeBody = http.StatusServiceUnavailable, "busy"
},
wantCode: http.StatusServiceUnavailable,
wantBody: "busy",
},
{
name: "owner succeeds while the non-owner errors",
setUp: func(a, b *voxBackend) {
b.perNodeStatus, b.perNodeBody = http.StatusInternalServerError, "boom"
},
wantCode: http.StatusOK,
wantBody: `"a"`,
},
{
// Nothing answered at all, so there is no upstream reply to replay.
name: "every backend unreachable",
setUp: func(a, b *voxBackend) { a.perNodeDead, b.perNodeDead = true, true },
wantCode: http.StatusBadGateway,
},
} {
t.Run(tc.name, func(t *testing.T) {
a := newVoxBackend(t, "a", map[string]string{certname: "2026-07-20T00:00:00Z"})
b := newVoxBackend(t, "b", map[string]string{})
tc.setUp(a, b)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeFreshness))
for _, rt := range perNodePaths {
rec := doGet(t, srv.Handler(), rt.path(certname), "")
if rec.Code != tc.wantCode {
t.Fatalf("%s: status = %d, want %d: %s", rt.name, rec.Code, tc.wantCode, rec.Body.String())
}
if tc.wantBody != "" && !strings.Contains(rec.Body.String(), tc.wantBody) {
t.Errorf("%s: body = %q, want it to contain %q", rt.name, rec.Body.String(), tc.wantBody)
}
}
})
}
}
+5 -5
View File
@@ -59,7 +59,7 @@ type Config struct {
Merge string `yaml:"merge"` Merge string `yaml:"merge"`
Timeout time.Duration `yaml:"timeout"` Timeout time.Duration `yaml:"timeout"`
FreshnessTTL time.Duration `yaml:"freshness_ttl"` FreshnessTTL time.Duration `yaml:"freshness_ttl"`
FactsTTL time.Duration `yaml:"facts_ttl"` // 0 disables the /facts+/nodes cache FactsTTL time.Duration `yaml:"facts_ttl"` // 0 disables the merged /nodes cache
CacheBytes int64 `yaml:"facts_cache_bytes"` // byte budget for that cache CacheBytes int64 `yaml:"facts_cache_bytes"` // byte budget for that cache
SourceFact string `yaml:"source_fact"` SourceFact string `yaml:"source_fact"`
@@ -344,8 +344,8 @@ func (c Config) Validate() error {
return nil return nil
} }
// cacheEnabled reports whether a facts/nodes cache should be built: both a TTL // cacheEnabled reports whether the merged /nodes cache should be built: both a
// and a byte budget are required. // TTL and a byte budget are required.
func (c Config) cacheEnabled() bool { return c.FactsTTL > 0 && c.CacheBytes > 0 } func (c Config) cacheEnabled() bool { return c.FactsTTL > 0 && c.CacheBytes > 0 }
func writeDefaultConfig(path string) error { func writeDefaultConfig(path string) error {
@@ -366,8 +366,8 @@ func writeDefaultConfig(path string) error {
"# PDBMUX_HEALTH_PROBE_ENABLED, PDBMUX_HEALTH_PROBE_PATH,\n" + "# PDBMUX_HEALTH_PROBE_ENABLED, PDBMUX_HEALTH_PROBE_PATH,\n" +
"# PDBMUX_HEALTH_PROBE_INTERVAL, PDBMUX_HEALTH_PROBE_TIMEOUT,\n" + "# PDBMUX_HEALTH_PROBE_INTERVAL, PDBMUX_HEALTH_PROBE_TIMEOUT,\n" +
"# PDBMUX_HEALTH_PROBE_FAILURES, PDBMUX_HEALTH_PROBE_SUCCESSES.\n" + "# PDBMUX_HEALTH_PROBE_FAILURES, PDBMUX_HEALTH_PROBE_SUCCESSES.\n" +
"# facts_ttl caches merged /facts and /nodes in memory; it is capped at 30s\n" + "# facts_ttl caches merged /nodes in memory; it is capped at 30s (a larger\n" +
"# (a larger value is clamped) and 0 disables the cache.\n" + "# value is clamped) and 0 disables the cache. Fact answers are never cached.\n" +
"# health_probe_* polls each backend's status endpoint so queries skip a\n" + "# health_probe_* polls each backend's status endpoint so queries skip a\n" +
"# backend that is down; when every backend is down all are queried anyway.\n\n") "# backend that is down; when every backend is down all are queried anyway.\n\n")
if err := os.WriteFile(path, append(header, data...), 0o644); err != nil { if err := os.WriteFile(path, append(header, data...), 0o644); err != nil {
+67 -1
View File
@@ -4,6 +4,7 @@ package main
import ( import (
"context" "context"
"net/url"
"strings" "strings"
"testing" "testing"
) )
@@ -22,7 +23,7 @@ func TestRejectedQuerySurfacesUpstreamStatus(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
params := query(tc.q) params := query(tc.q)
status, upstream := h.a.queryRaw(ctx, t, tc.path, params) status, upstream := h.a.queryRaw(ctx, t, tc.path, params)
if !clientShaped(status) { if !replayableStatus(status) || status >= 500 {
t.Fatalf("backend %s answered HTTP %d for %s, which is not the client error this test needs: %s", t.Fatalf("backend %s answered HTTP %d for %s, which is not the client error this test needs: %s",
h.a.name, status, tc.q, upstream) h.a.name, status, tc.q, upstream)
} }
@@ -56,3 +57,68 @@ func TestRejectedQuerySurfacesUpstreamStatus(t *testing.T) {
}) })
} }
} }
// openvoxdb answers the same malformed clause with 400 on /nodes and 500 on
// /facts — engine.clj's rewrite-fact-query runs an unguarded nth only for the
// facts entity. Both are the backends' real answer, so both have to reach the
// client rather than one of them becoming a 502.
func TestUnanimousServerErrorSurfacesUpstreamStatus(t *testing.T) {
ctx := context.Background()
params := query(`["=","name"]`)
status, upstream := h.a.queryRaw(ctx, t, factsPath, params)
if status < 500 {
t.Skipf("backend %s answered HTTP %d for the arity bug this test needs", h.a.name, status)
}
if other, _ := h.b.queryRaw(ctx, t, factsPath, params); other != status {
t.Fatalf("backends answered %d and %d, so the failure is not unanimous", status, other)
}
resp := rawGet(t, factsPath, params)
if resp.status != status {
t.Fatalf("pdbmux answered HTTP %d, want the upstream %d: %s", resp.status, status, resp.body)
}
if got, want := strings.TrimSpace(string(resp.body)), strings.TrimSpace(string(upstream)); want != "" && got != want {
t.Errorf("pdbmux body = %q, want openvoxdb's own explanation %q", got, want)
}
for _, b := range []*backend{h.a, h.b} {
if strings.Contains(string(resp.body), b.url) {
t.Errorf("replayed body names backend %s: %q", b.name, resp.body)
}
}
}
// /metrics and /pdb/meta had their own copy of the gateway error, so a status
// both backends agreed on never reached the client on those routes.
func TestUnanimousMetricsStatusIsReplayed(t *testing.T) {
ctx := context.Background()
for _, path := range []string{"/metrics/v1/mbeans", "/metrics/v2/list"} {
t.Run(path, func(t *testing.T) {
status, _ := h.a.queryRaw(ctx, t, path, nil)
if status < 400 {
t.Skipf("%s answers HTTP %d on this estate, so there is nothing to replay", path, status)
}
if other, _ := h.b.queryRaw(ctx, t, path, nil); other != status {
t.Fatalf("backends answered %d and %d, so the status is not unanimous", status, other)
}
if resp := rawGet(t, path, nil); resp.status != status {
t.Fatalf("pdbmux answered HTTP %d, want the upstream %d: %s", resp.status, status, resp.body)
}
})
}
}
// A client cannot tell pdbmux from a PuppetDB by the content type either.
func TestSuccessContentTypeMatchesUpstream(t *testing.T) {
ctx := context.Background()
params := url.Values{"limit": {"1"}}
_, _ = h.a.queryRaw(ctx, t, nodesPath, params)
resp := rawGet(t, nodesPath, params)
if resp.status != 200 {
t.Fatalf("status %d: %s", resp.status, resp.body)
}
if got := resp.header.Get("Content-Type"); got != "application/json;charset=utf-8" {
t.Errorf("Content-Type = %q, want openvoxdb's own %q", got, "application/json;charset=utf-8")
}
}
+20 -14
View File
@@ -370,9 +370,9 @@ func TestHandler_FactsBySourceNameUnknownValueSkipsFanOut(t *testing.T) {
} }
} }
// The record set is the estate's, not the pinned value's, so every value shares // The record set is the estate's, not the pinned value's: the <value> segment is
// one entry: two valid values must not cost two whole-estate fan-outs. // applied to the merged records rather than fanned out as a path of its own.
func TestHandler_FactsBySourceNameValuesShareOneFetch(t *testing.T) { func TestHandler_FactsBySourceNameValuesFilterOneFetch(t *testing.T) {
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "osfamily", "RedHat", "") + `]`}) a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "osfamily", "RedHat", "") + `]`})
b := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h2", "osfamily", "Debian", "") + `]`}) b := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h2", "osfamily", "Debian", "") + `]`})
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
@@ -392,8 +392,8 @@ func TestHandler_FactsBySourceNameValuesShareOneFetch(t *testing.T) {
} }
} }
for _, cb := range []*countingBackend{a, b} { for _, cb := range []*countingBackend{a, b} {
if got := cb.hitCount(factsPath); got != 1 { if got := cb.hitCount(factsPath); got != 3 {
t.Errorf("%s fetched %d times, want 1 shared fetch", factsPath, got) t.Errorf("%s fetched %d times, want one uncached fetch per request", factsPath, got)
} }
if got := cb.hitCount(sourceFactURL); got != 0 { if got := cb.hitCount(sourceFactURL); got != 0 {
t.Errorf("%s was fanned out %d times, want 0", sourceFactURL, got) t.Errorf("%s was fanned out %d times, want 0", sourceFactURL, got)
@@ -678,21 +678,27 @@ func TestHandler_FactNamesSurvivesOneBackend(t *testing.T) {
} }
} }
// Both routes are merged record sets, so they use the same cache /facts does. // Every route that serves fact records fans out per request, even with the
func TestHandler_FactRoutesAreCached(t *testing.T) { // cache configured: a fact answer is only ever as old as the backend's own.
func TestHandler_FactRoutesAreNotCached(t *testing.T) {
for _, path := range []string{roleFactPath, roleFactPath + "/web", sourceFactURL, factNamesPath} { for _, path := range []string{roleFactPath, roleFactPath + "/web", sourceFactURL, factNamesPath} {
t.Run(path, func(t *testing.T) { t.Run(path, func(t *testing.T) {
// The source-fact drilldown is answered from the /facts merge, so
// that is the path its fan-out lands on.
fanOut := path
if path == sourceFactURL {
fanOut = factsPath
}
a := newFakeBackend(t, `[]`, `[]`) a := newFakeBackend(t, `[]`, `[]`)
a.bodies[path] = `[]` a.bodies[fanOut] = `[]`
b := newFakeBackend(t, `[]`, `[]`) b := newFakeBackend(t, `[]`, `[]`)
b.bodies[path] = `[]` b.bodies[fanOut] = `[]`
srv := newTestServer(cacheTestConfig(a.srv.URL, b.srv.URL)) srv := newTestServer(cacheTestConfig(a.srv.URL, b.srv.URL))
if got := doGet(t, srv.Handler(), path, "").Header().Get(cacheStatusHeader); got != "miss" { doGet(t, srv.Handler(), path, "")
t.Errorf("first %s = %q, want miss", cacheStatusHeader, got) doGet(t, srv.Handler(), path, "")
} if got := a.hits(fanOut); got != 2 {
if got := doGet(t, srv.Handler(), path, "").Header().Get(cacheStatusHeader); got != "hit" { t.Errorf("backend a saw %d requests for %s, want one per request", got, fanOut)
t.Errorf("second %s = %q, want hit", cacheStatusHeader, got)
} }
}) })
} }
+18 -3
View File
@@ -72,8 +72,10 @@ func (s *Server) rawBackend(ctx context.Context, b Backend, path, rawQuery strin
} }
} }
// aliveRaw drops backends that errored or answered non-2xx, writing a 502 and // aliveRaw drops backends that errored or answered non-2xx, returning ok=false
// returning ok=false only when none is left. // when none is left. The reply it writes then is the same one the merged query
// handlers write, so /pdb/meta and /metrics answer a failure exactly as
// /pdb/query does: a unanimous upstream error replayed, anything else a 502.
func (s *Server) aliveRaw(w http.ResponseWriter, results []rawResult, path string) ([]rawResult, bool) { func (s *Server) aliveRaw(w http.ResponseWriter, results []rawResult, path string) ([]rawResult, bool) {
var alive []rawResult var alive []rawResult
for _, res := range results { for _, res := range results {
@@ -84,12 +86,25 @@ func (s *Server) aliveRaw(w http.ResponseWriter, results []rawResult, path strin
alive = append(alive, res) alive = append(alive, res)
} }
if len(alive) == 0 { if len(alive) == 0 {
http.Error(w, "all backends failed", http.StatusBadGateway) s.writeUpstreamError(w, peerOutcome(rawUpstreamErrors(results)))
return nil, false return nil, false
} }
return alive, true return alive, true
} }
// rawUpstreamErrors reduces a raw fan-out to one entry per backend, nil where
// the backend answered 2xx or never answered at all.
func rawUpstreamErrors(results []rawResult) []*upstreamError {
errs := make([]*upstreamError, len(results))
for i, res := range results {
if res.err != nil || res.ok() {
continue
}
errs[i] = newUpstreamError(res.status, res.contentType, res.body)
}
return errs
}
func (r rawResult) reason() string { func (r rawResult) reason() string {
if r.err != nil { if r.err != nil {
return r.err.Error() return r.err.Error()
+57 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
) )
@@ -85,9 +86,9 @@ func TestMetaVersion_OneBackendDown(t *testing.T) {
func TestMetaVersion_AllBackendsDown(t *testing.T) { func TestMetaVersion_AllBackendsDown(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`) a := newFakeBackend(t, `[]`, `[]`)
a.fail = true a.dead = true
b := newFakeBackend(t, `[]`, `[]`) b := newFakeBackend(t, `[]`, `[]`)
b.fail = true b.dead = true
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic)) srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
if rec := metaGet(t, srv.Handler(), metaVersionPath); rec.Code != http.StatusBadGateway { if rec := metaGet(t, srv.Handler(), metaVersionPath); rec.Code != http.StatusBadGateway {
@@ -95,6 +96,60 @@ func TestMetaVersion_AllBackendsDown(t *testing.T) {
} }
} }
// /pdb/meta had its own copy of the gateway error, so a status both backends
// agreed on never reached the client. It answers failures the way the query
// routes do now.
func TestMeta_ReplaysUnanimousUpstreamStatus(t *testing.T) {
for _, path := range []string{metaVersionPath, metaServerTimePath} {
for _, status := range []int{http.StatusNotFound, http.StatusForbidden, http.StatusInternalServerError} {
t.Run(path+"/"+http.StatusText(status), func(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.reject, a.rejectBody = status, "upstream said no"
b.reject, b.rejectBody = status, "upstream said no"
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := metaGet(t, srv.Handler(), path)
if rec.Code != status {
t.Fatalf("status %d, want the upstream %d: %s", rec.Code, status, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "upstream said no") {
t.Errorf("body = %q, want the upstream explanation", rec.Body.String())
}
})
}
}
}
func TestMeta_DisagreeingStatusesStay502(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.reject, a.rejectBody = http.StatusNotFound, "gone"
b.reject, b.rejectBody = http.StatusInternalServerError, "boom"
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
if rec := metaGet(t, srv.Handler(), metaVersionPath); rec.Code != http.StatusBadGateway {
t.Errorf("status %d, want 502 when backends disagree", rec.Code)
}
}
// A replayed body must not name a backend, on /pdb/meta as anywhere else.
func TestMeta_ReplayedBodyIsRedacted(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.reject, a.rejectBody = http.StatusInternalServerError, "upstream "+a.srv.URL+" blew up"
b.reject, b.rejectBody = http.StatusInternalServerError, "upstream "+b.srv.URL+" blew up"
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := metaGet(t, srv.Handler(), metaVersionPath)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d, want the upstream 500", rec.Code)
}
if strings.Contains(rec.Body.String(), hostOf(t, a.srv.URL)) {
t.Errorf("replayed body names a backend: %q", rec.Body.String())
}
}
func TestMetaServerTime_FirstReachableBackend(t *testing.T) { func TestMetaServerTime_FirstReachableBackend(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`) a := newFakeBackend(t, `[]`, `[]`)
a.bodies[metaServerTimePath] = `{"server_time":"2026-08-29T01:00:00.000Z"}` a.bodies[metaServerTimePath] = `{"server_time":"2026-08-29T01:00:00.000Z"}`
+7 -1
View File
@@ -65,7 +65,13 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
writeRaw(w, alive[0]) writeRaw(w, alive[0])
return return
} }
w.Header().Set("Content-Type", "application/json") // The merged body is the backends' own payload reshaped, so it keeps their
// content type rather than announcing a different one.
contentType := alive[0].contentType
if contentType == "" {
contentType = jsonContentType
}
w.Header().Set("Content-Type", contentType)
_, _ = w.Write(body) _, _ = w.Write(body)
} }
+54 -2
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest" "net/http/httptest"
"reflect" "reflect"
"strconv" "strconv"
"strings"
"testing" "testing"
) )
@@ -229,9 +230,9 @@ func TestMetrics_OneBackendDown(t *testing.T) {
func TestMetrics_AllBackendsDown(t *testing.T) { func TestMetrics_AllBackendsDown(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`) a := newFakeBackend(t, `[]`, `[]`)
a.fail = true a.dead = true
b := newFakeBackend(t, `[]`, `[]`) b := newFakeBackend(t, `[]`, `[]`)
b.fail = true b.dead = true
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic)) srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
if rec := metaGet(t, srv.Handler(), numNodesEscaped); rec.Code != http.StatusBadGateway { if rec := metaGet(t, srv.Handler(), numNodesEscaped); rec.Code != http.StatusBadGateway {
@@ -239,6 +240,57 @@ func TestMetrics_AllBackendsDown(t *testing.T) {
} }
} }
// /metrics/v1/mbeans is a unanimous 404 on this estate and /metrics/v2 a
// unanimous 403; both used to come back as 502.
func TestMetrics_ReplaysUnanimousUpstreamStatus(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusForbidden, http.StatusInternalServerError} {
t.Run(http.StatusText(status), func(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.reject, a.rejectBody = status, "Forbidden request: /metrics/v2/list (method :get)."
b.reject, b.rejectBody = status, "Forbidden request: /metrics/v2/list (method :get)."
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := metaGet(t, srv.Handler(), numNodesEscaped)
if rec.Code != status {
t.Fatalf("status %d, want the upstream %d: %s", rec.Code, status, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Forbidden request") {
t.Errorf("body = %q, want the upstream explanation", rec.Body.String())
}
})
}
}
func TestMetrics_DisagreeingStatusesStay502(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.reject, a.rejectBody = http.StatusNotFound, "gone"
b.reject, b.rejectBody = http.StatusForbidden, "denied"
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
if rec := metaGet(t, srv.Handler(), numNodesEscaped); rec.Code != http.StatusBadGateway {
t.Errorf("status %d, want 502 when backends disagree", rec.Code)
}
}
// One backend refusing is not the estate's answer, so the other still serves.
func TestMetrics_OneRefusalStillServesTheOther(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.reject, a.rejectBody = http.StatusForbidden, "denied"
b.bodies[numNodesPath] = `{"request":{},"value":{"Value":7},"status":200}`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := metaGet(t, srv.Handler(), numNodesEscaped)
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want the surviving backend's 200: %s", rec.Code, rec.Body.String())
}
if got := metricValue(t, rec.Body.Bytes())["Value"]; got != float64(7) {
t.Errorf("Value = %v, want 7", got)
}
}
func TestMetrics_RejectsNonGET(t *testing.T) { func TestMetrics_RejectsNonGET(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`) a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`) b := newFakeBackend(t, `[]`, `[]`)
+2
View File
@@ -21,6 +21,7 @@ var aggregateProbes = map[string]string{
eventCountsPath: eventCountsPath, eventCountsPath: eventCountsPath,
aggregateEventCountsPath: aggregateEventCountsPath, aggregateEventCountsPath: aggregateEventCountsPath,
reportsPath + "/<hash>/<sub>": reportsPath + "/abc123/events", reportsPath + "/<hash>/<sub>": reportsPath + "/abc123/events",
certnameRouteName: nodesPath + "/h1/facts",
} }
func TestQueryRoutes_EveryRouteIsProbed(t *testing.T) { func TestQueryRoutes_EveryRouteIsProbed(t *testing.T) {
@@ -107,6 +108,7 @@ func TestQueryRoutes_UnsummedRoutesAreTheKnownOnes(t *testing.T) {
eventCountsPath, eventCountsPath,
factNamesPath, factNamesPath,
reportsPath + "/<hash>/<sub>", reportsPath + "/<hash>/<sub>",
certnameRouteName,
} }
var got []string var got []string
for _, rt := range queryRoutes { for _, rt := range queryRoutes {
+73 -91
View File
@@ -32,11 +32,6 @@ const (
// PuppetDB only sends this when the request carries include_total=true. // PuppetDB only sends this when the request carries include_total=true.
recordsHeader = "X-Records" recordsHeader = "X-Records"
// Set by pdbmux, not by PuppetDB: how a cache-backed response was answered
// and how old the served copy is.
cacheStatusHeader = "X-Cache"
ageHeader = "Age"
// Set by pdbmux: "<contributed>/<configured>" backends behind a merged response. // Set by pdbmux: "<contributed>/<configured>" backends behind a merged response.
backendsHeader = "X-Backends" backendsHeader = "X-Backends"
) )
@@ -50,21 +45,28 @@ type backendResult struct {
var errAllBackendsFailed = errors.New("all backends failed") var errAllBackendsFailed = errors.New("all backends failed")
// jsonContentType is the spelling openvoxdb puts on the wire for a query
// response, charset and all. Its own reader treats the space after the
// semicolon as insignificant (http.clj:55-66, simple-utf8-ctype?), but a client
// comparing the raw header must see no difference between pdbmux and a PuppetDB.
const jsonContentType = "application/json;charset=utf-8"
type Server struct { type Server struct {
cfg Config cfg Config
client *http.Client client *http.Client
log *log.Logger log *log.Logger
// factsCache is nil when caching is disabled; cacheFor hands out a noop then. // nodeCache is nil when caching is disabled; cacheFor hands out a noop then.
factsCache Cache nodeCache Cache
flights flightGroup flights flightGroup
stale staleTracker stale staleTracker
// health is nil when probing is disabled, which makes every backend healthy. // health is nil when probing is disabled, which makes every backend healthy.
health *prober health *prober
partial partialTracker partial partialTracker
// now is shared with the cache's clock so Age matches the stored timestamp. // now is shared with the cache's clock so a stale fallback is reported
// against the same timestamps the cache stamps entries with.
now func() time.Time now func() time.Time
// freshness cache (freshness merge only). // freshness cache (freshness merge only).
@@ -82,7 +84,7 @@ func NewServer(cfg Config, logger *log.Logger) *Server {
now: time.Now, now: time.Now,
} }
if cfg.cacheEnabled() { if cfg.cacheEnabled() {
s.factsCache = newMemoryCache(cfg.FactsTTL, cfg.CacheBytes) s.nodeCache = newMemoryCache(cfg.FactsTTL, cfg.CacheBytes)
} }
s.health = newProber(cfg, logger) s.health = newProber(cfg, logger)
return s return s
@@ -95,20 +97,21 @@ func (s *Server) StartProbes(ctx context.Context) { s.health.Start(ctx) }
// StopProbes stops the probing goroutines and waits for them to exit. // StopProbes stops the probing goroutines and waits for them to exit.
func (s *Server) StopProbes() { s.health.Stop() } func (s *Server) StopProbes() { s.health.Stop() }
// cacheFor picks the cache backing a request. The merged /nodes and fact record // cacheFor picks the cache backing a request. Only the merged /nodes record set
// sets — /facts, /facts/<name>[/<value>] and /fact-names — share the in-memory // is cached: its report columns move once per node run, while fact answers must
// cache; every other path is uncached until the reports cache lands, and a new // be as current as a backend's own, so no fact-serving path is cached here.
// backend is a case here rather than a change to any handler. // Every other path is uncached until the reports cache lands, and a new backend
// is a case here rather than a change to any handler.
func (s *Server) cacheFor(path string, params url.Values) (Cache, bool) { func (s *Server) cacheFor(path string, params url.Values) (Cache, bool) {
switch { switch path {
case path == factsPath, path == nodesPath, path == factNamesPath, isFactsSubPath(path): case nodesPath:
// An aggregate row is a combined count, not the merged record set the // An aggregate row is a combined count, not the merged record set the
// cache was built for, so it stays on the live path. // cache was built for, so it stays on the live path.
if spec, err := parseAggregate(params.Get("query")); spec != nil || err != nil { if spec, err := parseAggregate(params.Get("query")); spec != nil || err != nil {
return noopCache{}, false return noopCache{}, false
} }
if s.factsCache != nil { if s.nodeCache != nil {
return s.factsCache, true return s.nodeCache, true
} }
} }
return noopCache{}, false return noopCache{}, false
@@ -177,6 +180,12 @@ var queryRoutes = []route{
serve: (*Server).serveFirstHolder, serve: (*Server).serveFirstHolder,
unsummed: "one backend holds the report, so nothing is merged across backends", unsummed: "one backend holds the report, so nothing is merged across backends",
}, },
{
name: certnameRouteName,
matches: isCertnameRoute,
serve: (*Server).serveByOwner,
unsummed: "one certname's own data, from the backend that owns it; nothing is merged across backends",
},
} }
// unmergedRoute answers every path no merged route claims. // unmergedRoute answers every path no merged route claims.
@@ -521,11 +530,9 @@ func (s *Server) serveFirstHolder(w http.ResponseWriter, r *http.Request) {
alive = append(alive, res) alive = append(alive, res)
} }
if len(alive) == 0 { if len(alive) == 0 {
if ue := unanimousClientError(results); ue != nil { // Unanimity is the whole answer here too: every backend saying 404 means
s.writeUpstreamError(w, ue) // nobody holds the report, while one silent backend leaves that unknown.
return s.writeUpstreamError(w, peerOutcome(backendUpstreamErrors(results)))
}
http.Error(w, "no backend holds this report", http.StatusNotFound)
return return
} }
for _, res := range alive { for _, res := range alive {
@@ -537,8 +544,8 @@ func (s *Server) serveFirstHolder(w http.ResponseWriter, r *http.Request) {
writeJSON(w, nil) writeJSON(w, nil)
} }
// Returns an *upstreamError when every backend refused the query the same way, // Returns an *upstreamError when every backend answered the same way, and
// and errAllBackendsFailed when every backend failed for any other reason. // errAllBackendsFailed when every backend failed for any other reason.
func (s *Server) aliveResults(ctx context.Context, path string, params url.Values) ([]backendResult, error) { func (s *Server) aliveResults(ctx context.Context, path string, params url.Values) ([]backendResult, error) {
results := s.fanOut(ctx, path, params) results := s.fanOut(ctx, path, params)
@@ -548,13 +555,17 @@ func (s *Server) aliveResults(ctx context.Context, path string, params url.Value
alive = append(alive, res) alive = append(alive, res)
} }
} }
var unanimous *upstreamError
if len(alive) == 0 { if len(alive) == 0 {
// A query every backend refuses identically is the client's mistake, not an unanimous = unanimousUpstreamError(backendUpstreamErrors(results))
// outage, so it is neither logged as one nor counted as degraded service. }
if ue := unanimousClientError(results); ue != nil { // A query every backend refuses identically is the client's mistake, not an
s.log.Printf("info: every backend refused %s: %v", path, ue) // outage, so it is neither logged as one nor counted as degraded service. A
return nil, ue // unanimous 5xx is replayed just as faithfully, but it is the backends
} // reporting their own fault, so it still counts against service health.
if clientRefusal(unanimous) {
s.log.Printf("info: every backend refused %s: %v", path, unanimous)
return nil, unanimous
} }
for _, res := range results { for _, res := range results {
if res.err != nil { if res.err != nil {
@@ -563,6 +574,9 @@ func (s *Server) aliveResults(ctx context.Context, path string, params url.Value
} }
s.partial.record(len(alive), len(s.cfg.Backends), s.now()) s.partial.record(len(alive), len(s.cfg.Backends), s.now())
if len(alive) == 0 { if len(alive) == 0 {
if unanimous != nil {
return nil, unanimous
}
return nil, errAllBackendsFailed return nil, errAllBackendsFailed
} }
return alive, nil return alive, nil
@@ -578,10 +592,9 @@ type cachedResponse struct {
Configured int `json:"configured"` // backends configured at build time Configured int `json:"configured"` // backends configured at build time
} }
// recordFilter narrows a response's records after it has been built or read back // recordFilter narrows a response's records after it has been built, so a route
// from the cache, so requests differing only in the filter share one stored entry // that answers from another path's merge can pin part of it per request. It
// and one fan-out. It leaves Records alone, so it only suits responses that set // leaves Records alone, so it only suits responses that set no X-Records.
// no X-Records.
type recordFilter func([]json.RawMessage) []json.RawMessage type recordFilter func([]json.RawMessage) []json.RawMessage
func (f recordFilter) apply(resp cachedResponse) cachedResponse { func (f recordFilter) apply(resp cachedResponse) cachedResponse {
@@ -627,7 +640,7 @@ func (s *Server) serveFiltered(w http.ResponseWriter, r *http.Request, path stri
s.log.Printf("warning: cache lookup for %s failed: %v", key, err) s.log.Printf("warning: cache lookup for %s failed: %v", key, err)
case status == CacheFresh: case status == CacheFresh:
s.stale.markFresh() s.stale.markFresh()
s.writeStored(w, ent, CacheFresh, filter) s.writeStored(w, ent, filter)
return return
case status == CacheStale: case status == CacheStale:
stale = &ent stale = &ent
@@ -666,18 +679,17 @@ func (s *Server) serveFiltered(w http.ResponseWriter, r *http.Request, path stri
} }
// A refused query is answered, not degraded, so stale records are no reply // A refused query is answered, not degraded, so stale records are no reply
// to it: the client has to see why the query was rejected. // to it: the client has to see why the query was rejected.
if stale != nil && !errors.As(err, new(*upstreamError)) { if stale != nil && !clientRefusal(err) {
s.stale.markStale(s.now()) s.stale.markStale(s.now())
s.log.Printf("warning: serving stale %s from cache (stored %s): %v", s.log.Printf("warning: serving stale %s from cache (stored %s): %v",
path, stale.StoredAt.UTC().Format(time.RFC3339), err) path, stale.StoredAt.UTC().Format(time.RFC3339), err)
s.writeStored(w, *stale, CacheStale, filter) s.writeStored(w, *stale, filter)
return return
} }
s.writeUpstreamError(w, err) s.writeUpstreamError(w, err)
return return
} }
s.stale.markFresh() s.stale.markFresh()
s.setCacheHeaders(w, CacheMiss, time.Time{})
writeCached(w, filter.apply(resp)) writeCached(w, filter.apply(resp))
} }
@@ -690,41 +702,21 @@ func (s *Server) flightTimeout() time.Duration {
return defaultTimeout return defaultTimeout
} }
func (s *Server) writeStored(w http.ResponseWriter, ent CacheEntry, status CacheStatus, filter recordFilter) { // writeStored answers from a cache entry. Whether a response came from the cache
// is deliberately invisible to the client: PuppetDB sets no X-Cache or Age, so
// neither does pdbmux, and /healthz carries the cache state instead.
func (s *Server) writeStored(w http.ResponseWriter, ent CacheEntry, filter recordFilter) {
var resp cachedResponse var resp cachedResponse
if err := json.Unmarshal(ent.Body, &resp); err != nil { if err := json.Unmarshal(ent.Body, &resp); err != nil {
s.log.Printf("warning: unreadable cache entry: %v", err) s.log.Printf("warning: unreadable cache entry: %v", err)
http.Error(w, "unreadable cache entry", http.StatusBadGateway) http.Error(w, "unreadable cache entry", http.StatusBadGateway)
return return
} }
s.setCacheHeaders(w, status, ent.StoredAt)
writeCached(w, filter.apply(resp)) writeCached(w, filter.apply(resp))
} }
// setCacheHeaders labels a response from a cache-backed path: X-Cache is
// hit/stale/miss and Age is whole seconds since the served copy was stored (0
// for a response built by this request). It reads the same clock the cache
// stamps entries with, so the two never disagree.
func (s *Server) setCacheHeaders(w http.ResponseWriter, status CacheStatus, storedAt time.Time) {
label := "miss"
switch status {
case CacheFresh:
label = "hit"
case CacheStale:
label = "stale"
}
age := 0
if !storedAt.IsZero() {
if secs := int(s.now().Sub(storedAt).Seconds()); secs > 0 {
age = secs
}
}
w.Header().Set(cacheStatusHeader, label)
w.Header().Set(ageHeader, strconv.Itoa(age))
}
func writeCached(w http.ResponseWriter, resp cachedResponse) { func writeCached(w http.ResponseWriter, resp cachedResponse) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", jsonContentType)
if resp.Records >= 0 { if resp.Records >= 0 {
w.Header().Set(recordsHeader, strconv.Itoa(resp.Records)) w.Header().Set(recordsHeader, strconv.Itoa(resp.Records))
} }
@@ -905,13 +897,21 @@ func (s *Server) queryBackend(ctx context.Context, b Backend, path string, param
return recs, total, err return recs, total, err
} }
// 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. // The record shape is unknown, so a union would be guesswork: the first 2xx wins, and every backend was asked the same question, so only a reply they all gave is replayed.
func (s *Server) proxyUnmerged(w http.ResponseWriter, r *http.Request) { func (s *Server) proxyUnmerged(w http.ResponseWriter, r *http.Request) {
var fallback *bufferedResponse s.proxyOrdered(w, r, s.cfg.Backends, askPeers)
for _, b := range s.cfg.Backends { }
// proxyOrdered asks backends in the given order, which is what decides the
// answer when more than one of them holds the path. order says why they are in
// that order, which is what decides whose reply is served when none answers 2xx.
func (s *Server) proxyOrdered(w http.ResponseWriter, r *http.Request, backends []Backend, order askOrder) {
replies := make([]*upstreamError, 0, len(backends))
for _, b := range backends {
resp, err := s.passThrough(r, b) resp, err := s.passThrough(r, b)
if err != nil { if err != nil {
s.log.Printf("warning: backend %q pass-through failed for %s: %v", b.Name, r.URL.Path, err) s.log.Printf("warning: backend %q pass-through failed for %s: %v", b.Name, r.URL.Path, err)
replies = append(replies, nil)
continue continue
} }
if resp.StatusCode >= 200 && resp.StatusCode < 300 { if resp.StatusCode >= 200 && resp.StatusCode < 300 {
@@ -923,27 +923,9 @@ func (s *Server) proxyUnmerged(w http.ResponseWriter, r *http.Request) {
} }
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close() _ = resp.Body.Close()
if fallback == nil { replies = append(replies, newUpstreamError(resp.StatusCode, resp.Header.Get("Content-Type"), body))
fallback = &bufferedResponse{
status: resp.StatusCode,
contentType: resp.Header.Get("Content-Type"),
body: body,
}
}
} }
if fallback == nil { s.writeUpstreamError(w, order.outcome(replies))
http.Error(w, "all backends failed", http.StatusBadGateway)
return
}
setContentType(w, fallback.contentType)
w.WriteHeader(fallback.status)
_, _ = w.Write(s.redactBackends(fallback.body))
}
type bufferedResponse struct {
status int
contentType string
body []byte
} }
func (s *Server) passThrough(r *http.Request, b Backend) (*http.Response, error) { func (s *Server) passThrough(r *http.Request, b Backend) (*http.Response, error) {
@@ -1026,8 +1008,8 @@ type cacheHealth struct {
func (s *Server) cacheHealth() cacheHealth { func (s *Server) cacheHealth() cacheHealth {
stats := CacheStats{Backend: "none"} stats := CacheStats{Backend: "none"}
ttl := time.Duration(0) ttl := time.Duration(0)
if s.factsCache != nil { if s.nodeCache != nil {
stats = s.factsCache.Stats() stats = s.nodeCache.Stats()
ttl = s.cfg.FactsTTL ttl = s.cfg.FactsTTL
} }
serving, served, last := s.stale.snapshot() serving, served, last := s.stale.snapshot()
@@ -1095,7 +1077,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
} }
func writeJSON(w http.ResponseWriter, recs []json.RawMessage) { func writeJSON(w http.ResponseWriter, recs []json.RawMessage) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", jsonContentType)
if recs == nil { if recs == nil {
recs = []json.RawMessage{} recs = []json.RawMessage{}
} }
+9 -3
View File
@@ -29,8 +29,11 @@ type fakeBackend struct {
// totals is the X-Records count advertised per path when the request asks // totals is the X-Records count advertised per path when the request asks
// for include_total. // for include_total.
totals map[string]int totals map[string]int
fail bool // return 500 for everything fail bool // answer 500 for everything, as a backend admitting a fault
delay time.Duration // artificial latency // dead aborts the connection instead of answering, standing in for a backend
// that is unreachable rather than one that replies badly.
dead bool
delay time.Duration // artificial latency
// reject answers every request with this status and rejectBody, standing in // reject answers every request with this status and rejectBody, standing in
// for a PuppetDB refusing a query it cannot answer. // for a PuppetDB refusing a query it cannot answer.
reject int reject int
@@ -60,6 +63,9 @@ func newFakeBackend(t *testing.T, nodesBody, factsBody string) *fakeBackend {
fb.gotParams[r.URL.Path] = r.URL.Query() fb.gotParams[r.URL.Path] = r.URL.Query()
fb.rawPaths = append(fb.rawPaths, r.URL.EscapedPath()) fb.rawPaths = append(fb.rawPaths, r.URL.EscapedPath())
fb.mu.Unlock() fb.mu.Unlock()
if fb.dead {
panic(http.ErrAbortHandler)
}
if fb.fail { if fb.fail {
http.Error(w, "boom", http.StatusInternalServerError) http.Error(w, "boom", http.StatusInternalServerError)
return return
@@ -285,7 +291,7 @@ func TestHandler_OneBackendDown(t *testing.T) {
func TestHandler_BothBackendsDown(t *testing.T) { func TestHandler_BothBackendsDown(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`) a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`) b := newFakeBackend(t, `[]`, `[]`)
a.fail, b.fail = true, true a.dead, b.dead = true, true
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic)) srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), nodesPath, "") rec := doGet(t, srv.Handler(), nodesPath, "")
+123 -36
View File
@@ -35,45 +35,42 @@ func (e *upstreamError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.status, strings.TrimSpace(string(e.body))) return fmt.Sprintf("HTTP %d: %s", e.status, strings.TrimSpace(string(e.body)))
} }
// clientShaped reports whether a status blames the request rather than the // replayableStatus reports whether a status is an error a backend explained and
// backend or pdbmux itself. openvoxdb answers every bad query with 400 // so can stand as the answer when every backend gave it. Everything from 400 up
// src/puppetlabs/puppetdb/middleware.clj:98-116 and http.clj:115-129 — and its // qualifies, because openvoxdb does not reserve 5xx for its own faults: a
// other 4xx say something a client of pdbmux neither caused nor can fix: 403 is // malformed query reaches the client as a 400 only when something on the way
// pdbmux's own certificate being refused (middleware.clj:44-58); 404 is either // down happens to throw a class the query engine catches (query_eng.clj:571-583
// an absent object or an unknown path (http.clj:238-242, // catches IllegalArgumentException and the :puppetlabs.puppetdb.query/invalid
// middleware.clj:381-398), and which objects a backend holds is the one thing // ExceptionInfo), and as a 500 otherwise. ["=","name"] is a 400 on /nodes and a
// backends are meant to disagree about; 408 and 429 report a backend's timing // 500 with an empty body on /facts, because only the facts entity runs
// and capacity. A backend that times out mid-query answers 200 with a truncated // rewrite-fact-query, whose name-constraint does an unguarded (nth clause 2)
// body rather than any 4xx (query_eng.clj:463-483), so no timeout reaches here. // (engine.clj:2988-3003); a bare string or number for query is a 500 reading
// "Output of convert-query-params does not match schema" from the schema check
// in front of the parser. Drawing the line at 500 would replay one of those and
// swallow the other, which is the route-dependence clients notice.
// //
// Every 4xx left over blames the request and so is safe to replay, though none // Below 400 nothing is replayed. A 3xx carries its meaning in Location, which
// is reachable while the fan-out forwards no client header: 406 needs an Accept // the fan-out does not keep, and a non-200 2xx is not an error at all.
// the query app refuses (http/server.clj:72; must-accept-type in func replayableStatus(status int) bool {
// http.clj:136-145 is unwired), and 415 a Content-Encoding on a POST to return status >= 400
// /commands (middleware.clj:165-178), which pdbmux never proxies.
func clientShaped(status int) bool {
switch status {
case http.StatusForbidden, http.StatusNotFound,
http.StatusRequestTimeout, http.StatusTooManyRequests:
return false
}
return status >= 400 && status < 500
} }
// unanimousClientError returns the rejection to replay when every backend in a // unanimousUpstreamError returns the reply to replay when every backend answered
// fan-out refused the same query with the same client-shaped status, and nil // the same request the same way, and nil otherwise. Every backend is asked the
// otherwise. Every backend is asked the same question, so unanimity is what // same question, so unanimity is what separates the estate's real answer from a
// distinguishes a bad query from a sick estate: a transport failure, a 5xx, or // sick estate: a transport failure, or two backends disagreeing on the status,
// two backends disagreeing on the status all leave at least one backend whose // leaves at least one backend whose answer is evidence about the backend rather
// answer is evidence about the backend rather than about the request. // than about the request, and that is what 502 describes.
//
// A nil entry stands for a backend that did not answer with an HTTP status at
// all, which defeats unanimity.
// //
// The reply returned is the first in configured order, so a client retrying a // The reply returned is the first in configured order, so a client retrying a
// rejected query is told the same thing every time. // rejected query is told the same thing every time.
func unanimousClientError(results []backendResult) *upstreamError { func unanimousUpstreamError(errs []*upstreamError) *upstreamError {
var first *upstreamError var first *upstreamError
for _, res := range results { for _, ue := range errs {
var ue *upstreamError if ue == nil || !replayableStatus(ue.status) {
if !errors.As(res.err, &ue) || !clientShaped(ue.status) {
return nil return nil
} }
if first == nil { if first == nil {
@@ -85,9 +82,99 @@ func unanimousClientError(results []backendResult) *upstreamError {
return first return first
} }
// writeUpstreamError answers a fan-out that produced no records. A unanimous // askOrder says why a set of backends was asked, which is what decides whose
// client-shaped rejection is replayed with the backend's own status and // reply becomes the client's answer when none of them answered 2xx. The two
// explanation; anything else is reported as a gateway failure. // cases are not settings on one rule, they are different questions, and reading
// a reply under the wrong one is how a real answer turns into a 502.
type askOrder int
const (
// askPeers: every backend was asked the same question and any of them could
// have answered it, so each reply is an opinion about that question. Only a
// reply they all gave is the estate's answer; anything else leaves a backend
// whose reply is evidence about the backend, which is what 502 reports.
askPeers askOrder = iota
// askOwnerFirst: the path names one entity, so the backend holding it is
// asked first — or, where no owner is known, they are asked in configured
// order — and the rest only as fallbacks. A backend that does not hold the
// entity replies 404 to say exactly that, which is not a dissent from the
// holder's reply, so requiring the two to agree asks a question nobody was
// posed. The first backend that answered is the one that knows.
askOwnerFirst
)
// outcome turns a round's replies — one entry per backend in the order they were
// asked, nil where a backend produced no HTTP status at all — into the error to
// answer with.
func (o askOrder) outcome(replies []*upstreamError) error {
if o == askOwnerFirst {
return ownerOutcome(replies)
}
return peerOutcome(replies)
}
// peerOutcome is the error to answer a fan-out of peers with when it produced
// nothing: their unanimous reply where there is one, and pdbmux's gateway error
// otherwise.
func peerOutcome(errs []*upstreamError) error {
if ue := unanimousUpstreamError(errs); ue != nil {
return ue
}
return errAllBackendsFailed
}
// ownerOutcome is the error to answer an owner-routed path with: the reply of
// the first backend that answered. The owner is asked first, so that is the
// owner's own reply — it holds the entity, so its 500 is the truth about this
// request whatever a backend that does not hold the entity said.
//
// An owner that produced no status at all leaves a nil entry and the next
// backend's reply stands instead. The fallback chain exists so a node whose
// owner is unreachable is still served, and a backend that did answer explains
// more than a 502 that describes neither. Only a round where nothing answered is
// pdbmux's own gateway error.
func ownerOutcome(replies []*upstreamError) error {
for _, ue := range replies {
if ue != nil && replayableStatus(ue.status) {
return ue
}
}
return errAllBackendsFailed
}
// backendUpstreamErrors reduces a merged fan-out's results to one entry per
// backend, nil where the backend answered or failed without a status.
func backendUpstreamErrors(results []backendResult) []*upstreamError {
errs := make([]*upstreamError, len(results))
for i, res := range results {
var ue *upstreamError
if errors.As(res.err, &ue) {
errs[i] = ue
}
}
return errs
}
// clientRefusal reports whether err is a unanimous upstream rejection blaming
// the request. Only those mean the estate is healthy and the query was wrong,
// which is why they are neither counted as degraded service nor answered from
// the cache; a unanimous 5xx is replayed just the same but is the backends
// reporting their own fault.
//
// Every 4xx openvoxdb answers a fan-out with is about the request, including the
// 403 on /metrics/v2/list, which is that backend's own policy rather than an
// authentication failure. That holds only while pdbmux presents no client
// certificate: authenticate to backends and a rejected certificate becomes a
// 403 on every route at once, which this would read as a healthy estate refusing
// a bad query and hide a total outage behind a replayed 403.
func clientRefusal(err error) bool {
var ue *upstreamError
return errors.As(err, &ue) && ue != nil && ue.status < 500
}
// writeUpstreamError answers a round that produced no records. An upstream
// error the askOrder resolved to is replayed with the backend's own status,
// content type and explanation; anything else is reported as a gateway failure.
func (s *Server) writeUpstreamError(w http.ResponseWriter, err error) { func (s *Server) writeUpstreamError(w http.ResponseWriter, err error) {
var ue *upstreamError var ue *upstreamError
if !errors.As(err, &ue) { if !errors.As(err, &ue) {
@@ -98,7 +185,7 @@ func (s *Server) writeUpstreamError(w http.ResponseWriter, err error) {
if len(strings.TrimSpace(string(body))) == 0 { if len(strings.TrimSpace(string(body))) == 0 {
setContentType(w, "text/plain; charset=utf-8") setContentType(w, "text/plain; charset=utf-8")
w.WriteHeader(ue.status) w.WriteHeader(ue.status)
_, _ = fmt.Fprintf(w, "upstream rejected the query: %s\n", http.StatusText(ue.status)) _, _ = fmt.Fprintf(w, "upstream answered %d %s\n", ue.status, http.StatusText(ue.status))
return return
} }
setContentType(w, ue.contentType) setContentType(w, ue.contentType)
+213 -41
View File
@@ -14,11 +14,16 @@ import (
const badOrderBy = `Unrecognized column 'bogus' specified in :order_by` const badOrderBy = `Unrecognized column 'bogus' specified in :order_by`
// upstreamJSONContentType is spelled out rather than taken from jsonContentType,
// so the assertion is against what openvoxdb answers and not against whatever
// pdbmux happens to be configured with.
const upstreamJSONContentType = "application/json;charset=utf-8"
func upErr(status int, body string) error { func upErr(status int, body string) error {
return newUpstreamError(status, "text/plain; charset=utf-8", []byte(body)) return newUpstreamError(status, "text/plain; charset=utf-8", []byte(body))
} }
func TestUnanimousClientError_AgreementRule(t *testing.T) { func TestUnanimousUpstreamError_AgreementRule(t *testing.T) {
refused := errors.New("dial tcp: connection refused") refused := errors.New("dial tcp: connection refused")
for _, tc := range []struct { for _, tc := range []struct {
name string name string
@@ -29,18 +34,25 @@ func TestUnanimousClientError_AgreementRule(t *testing.T) {
{"all agree on 415", []error{upErr(415, "bad media"), upErr(415, "bad media")}, 415}, {"all agree on 415", []error{upErr(415, "bad media"), upErr(415, "bad media")}, 415},
{"differing bodies still agree", []error{upErr(400, "one"), upErr(400, "two")}, 400}, {"differing bodies still agree", []error{upErr(400, "one"), upErr(400, "two")}, 400},
{"disagreeing 4xx", []error{upErr(400, "bad"), upErr(404, "gone")}, 0}, {"disagreeing 4xx", []error{upErr(400, "bad"), upErr(404, "gone")}, 0},
// Both client-shaped, so only the same-status rule refuses these.
{"agree on shape, disagree on code", []error{upErr(400, "x"), upErr(415, "y")}, 0}, {"agree on shape, disagree on code", []error{upErr(400, "x"), upErr(415, "y")}, 0},
{"a majority agrees", []error{upErr(400, "x"), upErr(400, "y"), upErr(422, "z")}, 0}, {"a majority agrees", []error{upErr(400, "x"), upErr(400, "y"), upErr(422, "z")}, 0},
{"the first differs", []error{upErr(422, "z"), upErr(400, "x"), upErr(400, "y")}, 0}, {"the first differs", []error{upErr(422, "z"), upErr(400, "x"), upErr(400, "y")}, 0},
{"4xx with a 5xx", []error{upErr(400, "bad"), upErr(500, "boom")}, 0}, {"4xx with a 5xx", []error{upErr(400, "bad"), upErr(500, "boom")}, 0},
{"4xx with a transport failure", []error{upErr(400, "bad"), refused}, 0}, {"4xx with a transport failure", []error{upErr(400, "bad"), refused}, 0},
{"all 403", []error{upErr(403, "denied"), upErr(403, "denied")}, 0}, {"5xx with a transport failure", []error{upErr(500, "boom"), refused}, 0},
{"all 404", []error{upErr(404, "gone"), upErr(404, "gone")}, 0}, // Statuses the old 4xx-only rule swallowed. Every one of them is what
{"all 429", []error{upErr(429, "slow down"), upErr(429, "slow down")}, 0}, // both backends actually said, so every one of them is the answer.
{"all 408", []error{upErr(408, "too slow"), upErr(408, "too slow")}, 0}, {"all 403", []error{upErr(403, "denied"), upErr(403, "denied")}, 403},
{"all 500", []error{upErr(500, "boom"), upErr(500, "boom")}, 0}, {"all 404", []error{upErr(404, "gone"), upErr(404, "gone")}, 404},
{"all 503", []error{upErr(503, "unavailable"), upErr(503, "unavailable")}, 0}, {"all 429", []error{upErr(429, "slow down"), upErr(429, "slow down")}, 429},
{"all 408", []error{upErr(408, "too slow"), upErr(408, "too slow")}, 408},
{"all 500", []error{upErr(500, "boom"), upErr(500, "boom")}, 500},
{"all 503", []error{upErr(503, "unavailable"), upErr(503, "unavailable")}, 503},
{"disagreeing 5xx", []error{upErr(500, "boom"), upErr(503, "later")}, 0},
// Nothing below 400 is an error a backend explained, and a 3xx's meaning
// lives in a Location header the fan-out never kept.
{"all 302", []error{upErr(302, ""), upErr(302, "")}, 0},
{"all 204", []error{upErr(204, ""), upErr(204, "")}, 0},
{"no backends", nil, 0}, {"no backends", nil, 0},
{"a backend succeeded", []error{upErr(400, "bad"), nil}, 0}, {"a backend succeeded", []error{upErr(400, "bad"), nil}, 0},
} { } {
@@ -49,7 +61,7 @@ func TestUnanimousClientError_AgreementRule(t *testing.T) {
for i, err := range tc.errs { for i, err := range tc.errs {
results[i] = backendResult{name: fmt.Sprintf("b%d", i), err: err} results[i] = backendResult{name: fmt.Sprintf("b%d", i), err: err}
} }
got := unanimousClientError(results) got := unanimousUpstreamError(backendUpstreamErrors(results))
switch { switch {
case tc.want == 0 && got != nil: case tc.want == 0 && got != nil:
t.Fatalf("want no replay, got HTTP %d", got.status) t.Fatalf("want no replay, got HTTP %d", got.status)
@@ -64,17 +76,33 @@ func TestUnanimousClientError_AgreementRule(t *testing.T) {
// Two backends can explain the same rejection differently; the reply is the // Two backends can explain the same rejection differently; the reply is the
// first in configured order so a retried query is answered the same way twice. // first in configured order so a retried query is answered the same way twice.
func TestUnanimousClientError_PicksFirstInConfiguredOrder(t *testing.T) { func TestUnanimousUpstreamError_PicksFirstInConfiguredOrder(t *testing.T) {
results := []backendResult{ results := []backendResult{
{name: "a", err: upErr(400, "from a")}, {name: "a", err: upErr(400, "from a")},
{name: "b", err: upErr(400, "from b")}, {name: "b", err: upErr(400, "from b")},
} }
got := unanimousClientError(results) got := unanimousUpstreamError(backendUpstreamErrors(results))
if got == nil || !strings.Contains(string(got.body), "from a") { if got == nil || !strings.Contains(string(got.body), "from a") {
t.Fatalf("body = %q, want the first backend's", got) t.Fatalf("body = %q, want the first backend's", got)
} }
} }
// Only a 4xx says the estate is well and the query was wrong. A 5xx is replayed
// too, but it must keep counting as a backend fault.
func TestClientRefusal_OnlyFourXX(t *testing.T) {
for status, want := range map[int]bool{400: true, 404: true, 429: true, 499: true, 500: false, 503: false} {
if got := clientRefusal(upErr(status, "x")); got != want {
t.Errorf("clientRefusal(%d) = %v, want %v", status, got, want)
}
}
if clientRefusal(errAllBackendsFailed) {
t.Error("a transport failure is not a client refusal")
}
if clientRefusal(unanimousUpstreamError(nil)) {
t.Error("an absent unanimous error is not a client refusal")
}
}
func TestUpstreamError_BodyIsCapped(t *testing.T) { func TestUpstreamError_BodyIsCapped(t *testing.T) {
ue := newUpstreamError(400, "text/plain", []byte(strings.Repeat("x", upstreamBodyLimit*2))) ue := newUpstreamError(400, "text/plain", []byte(strings.Repeat("x", upstreamBodyLimit*2)))
if len(ue.body) != upstreamBodyLimit { if len(ue.body) != upstreamBodyLimit {
@@ -112,31 +140,78 @@ func TestHandler_MergedReplaysNonBadRequestStatus(t *testing.T) {
} }
} }
// A unanimous 403 is pdbmux's own credentials being refused, not the client's // A status every backend agreed on is the estate's own answer whatever it is,
// query, so it must not be handed back as the client's fault. // so the statuses the 4xx-only rule used to swallow now reach the client.
func TestHandler_MergedForbiddenStays502(t *testing.T) { func TestHandler_MergedReplaysEveryUnanimousStatus(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`) for _, tc := range []struct {
b := newFakeBackend(t, `[]`, `[]`) status int
a.reject, a.rejectBody = http.StatusForbidden, "certificate not allowed" body string
b.reject, b.rejectBody = http.StatusForbidden, "certificate not allowed" }{
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic)) {http.StatusForbidden, "certificate not allowed"},
{http.StatusNotFound, "Not Found"},
{http.StatusTooManyRequests, "slow down"},
{http.StatusInternalServerError, "Output of convert-query-params does not match schema"},
{http.StatusServiceUnavailable, "starting up"},
} {
t.Run(http.StatusText(tc.status), func(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.reject, a.rejectBody = tc.status, tc.body
b.reject, b.rejectBody = tc.status, tc.body
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
if rec := doGet(t, srv.Handler(), nodesPath, ""); rec.Code != http.StatusBadGateway { rec := doGet(t, srv.Handler(), nodesPath, "")
t.Fatalf("status = %d, want 502", rec.Code) if rec.Code != tc.status {
t.Fatalf("status = %d, want the upstream %d: %s", rec.Code, tc.status, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), tc.body) {
t.Errorf("body = %q, want the upstream explanation", rec.Body.String())
}
})
} }
} }
// Every backend answering 404 is ambiguous between a bad path and a record // A backend that answered nothing at all leaves no unanimity to replay, whatever
// nobody holds, so it stays a gateway error on a merged route. // the others said.
func TestHandler_MergedNotFoundStays502(t *testing.T) { func TestHandler_MergedServerErrorWithUnreachableStays502(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.reject, a.rejectBody = http.StatusInternalServerError, "boom"
dead := newFakeBackend(t, `[]`, `[]`)
deadURL := dead.srv.URL
dead.srv.Close()
srv := newTestServer(testConfig(a.srv.URL, deadURL, mergeStatic))
if rec := doGet(t, srv.Handler(), nodesPath, ""); rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502 when a backend never answered", rec.Code)
}
}
func TestHandler_MergedDisagreeingServerErrorsStay502(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`) a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`) b := newFakeBackend(t, `[]`, `[]`)
a.reject, a.rejectBody = http.StatusNotFound, "Not Found" a.reject, a.rejectBody = http.StatusInternalServerError, "boom"
b.reject, b.rejectBody = http.StatusNotFound, "Not Found" b.reject, b.rejectBody = http.StatusServiceUnavailable, "later"
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic)) srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
if rec := doGet(t, srv.Handler(), nodesPath, ""); rec.Code != http.StatusBadGateway { if rec := doGet(t, srv.Handler(), nodesPath, ""); rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", rec.Code) t.Fatalf("status = %d, want 502 when backends disagree", rec.Code)
}
}
// A unanimous 5xx is replayed, but it is the backends admitting a fault, so it
// still has to read as degraded service.
func TestHandler_UnanimousServerErrorIsStillADegradedRound(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.reject, a.rejectBody = http.StatusInternalServerError, "boom"
b.reject, b.rejectBody = http.StatusInternalServerError, "boom"
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
if rec := doGet(t, srv.Handler(), nodesPath, ""); rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want the upstream 500", rec.Code)
}
if hr := health(t, srv); hr.Query.PartialRounds == 0 {
t.Errorf("query health = %+v, want the 5xx counted as a failed round", hr.Query)
} }
} }
@@ -255,7 +330,7 @@ func TestHandler_ReportSubResourceRejectionReplayedButNotItsAbsence(t *testing.T
b := newFakeBackend(t, `[]`, `[]`) b := newFakeBackend(t, `[]`, `[]`)
if rec := doGet(t, newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic)).Handler(), if rec := doGet(t, newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic)).Handler(),
reportsPath+"/nope/events", ""); rec.Code != http.StatusNotFound { reportsPath+"/nope/events", ""); rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want pdbmux's own 404 when nobody holds the report", rec.Code) t.Fatalf("status = %d, want 404 when nobody holds the report", rec.Code)
} }
a.reject, a.rejectBody = http.StatusBadRequest, badOrderBy a.reject, a.rejectBody = http.StatusBadRequest, badOrderBy
@@ -296,18 +371,18 @@ func TestHandler_RejectedQueryIsNotCached(t *testing.T) {
b.reject, b.rejectBody = http.StatusBadRequest, badOrderBy b.reject, b.rejectBody = http.StatusBadRequest, badOrderBy
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
rec := doGet(t, srv.Handler(), factsPath, "") rec := doGet(t, srv.Handler(), nodesPath, "")
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want the upstream 400 replayed: %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want the upstream 400 replayed: %s", rec.Code, rec.Body.String())
} }
if n := srv.factsCache.Stats().Entries; n != 0 { if n := srv.nodeCache.Stats().Entries; n != 0 {
t.Fatalf("cache holds %d entries after a rejected query", n) t.Fatalf("cache holds %d entries after a rejected query", n)
} }
// The rejection left nothing behind, so the next good query is served fresh. // The rejection left nothing behind, so the next good query is served fresh.
a.reject, b.reject = 0, 0 a.reject, b.reject = 0, 0
a.factsBody = `[` + fact("h1", "role", "web", "") + `]` a.nodesBody = `[` + node("web", "2026-01-01T00:00:00.000Z") + `]`
ok := doGet(t, srv.Handler(), factsPath, "") ok := doGet(t, srv.Handler(), nodesPath, "")
if ok.Code != http.StatusOK || !strings.Contains(ok.Body.String(), "web") { if ok.Code != http.StatusOK || !strings.Contains(ok.Body.String(), "web") {
t.Fatalf("follow-up = %d %s", ok.Code, ok.Body.String()) t.Fatalf("follow-up = %d %s", ok.Code, ok.Body.String())
} }
@@ -316,13 +391,13 @@ func TestHandler_RejectedQueryIsNotCached(t *testing.T) {
func TestHandler_AllBackendsFailedIsNotCached(t *testing.T) { func TestHandler_AllBackendsFailedIsNotCached(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`) a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`) b := newFakeBackend(t, `[]`, `[]`)
a.fail, b.fail = true, true a.dead, b.dead = true, true
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
if rec := doGet(t, srv.Handler(), factsPath, ""); rec.Code != http.StatusBadGateway { if rec := doGet(t, srv.Handler(), nodesPath, ""); rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", rec.Code) t.Fatalf("status = %d, want 502", rec.Code)
} }
if n := srv.factsCache.Stats().Entries; n != 0 { if n := srv.nodeCache.Stats().Entries; n != 0 {
t.Fatalf("cache holds %d entries after a failed fan-out", n) t.Fatalf("cache holds %d entries after a failed fan-out", n)
} }
} }
@@ -330,18 +405,18 @@ func TestHandler_AllBackendsFailedIsNotCached(t *testing.T) {
// Stale records answer an outage. They do not answer a query the estate refused: // Stale records answer an outage. They do not answer a query the estate refused:
// the client has to be told why, not handed data for a question it did not ask. // the client has to be told why, not handed data for a question it did not ask.
func TestHandler_RejectedQueryIsNotAnsweredFromStale(t *testing.T) { func TestHandler_RejectedQueryIsNotAnsweredFromStale(t *testing.T) {
a := newFakeBackend(t, `[]`, `[`+fact("h1", "role", "old", "")+`]`) a := newFakeBackend(t, `[`+node("old", "2026-01-01T00:00:00.000Z")+`]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`) b := newFakeBackend(t, `[]`, `[]`)
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
if warm := doGet(t, srv.Handler(), factsPath, ""); warm.Code != http.StatusOK { if warm := doGet(t, srv.Handler(), nodesPath, ""); warm.Code != http.StatusOK {
t.Fatalf("warm-up status %d", warm.Code) t.Fatalf("warm-up status %d", warm.Code)
} }
clk.advance(31 * time.Second) clk.advance(31 * time.Second)
a.reject, a.rejectBody = http.StatusBadRequest, badOrderBy a.reject, a.rejectBody = http.StatusBadRequest, badOrderBy
b.reject, b.rejectBody = http.StatusBadRequest, badOrderBy b.reject, b.rejectBody = http.StatusBadRequest, badOrderBy
rec := doGet(t, srv.Handler(), factsPath, "") rec := doGet(t, srv.Handler(), nodesPath, "")
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want the rejection rather than the stale entry: %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want the rejection rather than the stale entry: %s", rec.Code, rec.Body.String())
} }
@@ -355,16 +430,16 @@ func TestHandler_RejectedQueryIsNotAnsweredFromStale(t *testing.T) {
// An outage still falls back to the stale copy, unchanged by the replay path. // An outage still falls back to the stale copy, unchanged by the replay path.
func TestHandler_OutageStillFallsBackToStale(t *testing.T) { func TestHandler_OutageStillFallsBackToStale(t *testing.T) {
a := newFakeBackend(t, `[]`, `[`+fact("h1", "role", "old", "")+`]`) a := newFakeBackend(t, `[`+node("old", "2026-01-01T00:00:00.000Z")+`]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`) b := newFakeBackend(t, `[]`, `[]`)
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL)) srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
if warm := doGet(t, srv.Handler(), factsPath, ""); warm.Code != http.StatusOK { if warm := doGet(t, srv.Handler(), nodesPath, ""); warm.Code != http.StatusOK {
t.Fatalf("warm-up status %d", warm.Code) t.Fatalf("warm-up status %d", warm.Code)
} }
clk.advance(31 * time.Second) clk.advance(31 * time.Second)
a.fail, b.fail = true, true a.fail, b.fail = true, true
rec := doGet(t, srv.Handler(), factsPath, "") rec := doGet(t, srv.Handler(), nodesPath, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "old") { if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "old") {
t.Fatalf("stale fallback = %d %s", rec.Code, rec.Body.String()) t.Fatalf("stale fallback = %d %s", rec.Code, rec.Body.String())
} }
@@ -493,3 +568,100 @@ func hostOf(t *testing.T, raw string) string {
} }
return u.Host return u.Host
} }
// Every backend answering 500 is the estate's answer on the first-holder route
// as much as on a merged one.
func TestHandler_ReportSubResourceReplaysUnanimousServerError(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.reject, a.rejectBody = http.StatusInternalServerError, "boom"
b.reject, b.rejectBody = http.StatusInternalServerError, "boom"
rec := doGet(t, newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic)).Handler(),
reportsPath+"/nope/events", "")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want the upstream 500: %s", rec.Code, rec.Body.String())
}
}
// One backend saying 404 and one saying nothing leaves it unknown whether the
// report exists, which is the gateway error's job to say.
func TestHandler_ReportSubResourceUnreachableBackendIs502(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
b.dead = true
rec := doGet(t, newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic)).Handler(),
reportsPath+"/nope/events", "")
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502 when a backend never answered", rec.Code)
}
}
// The pass-through route used to replay whichever backend answered first, even
// when the others disagreed or never answered. It follows the same rule now.
func TestProxyUnmerged_ReplayRule(t *testing.T) {
for _, tc := range []struct {
name string
aStatus int
bStatus int
bDead bool
want int
}{
{"unanimous 500 is replayed", 500, 500, false, 500},
{"unanimous 404 is replayed", 404, 404, false, 404},
{"unanimous 403 is replayed", 403, 403, false, 403},
{"disagreement is a gateway error", 400, 404, false, http.StatusBadGateway},
{"a silent backend is a gateway error", 404, 0, true, http.StatusBadGateway},
} {
t.Run(tc.name, func(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.reject, a.rejectBody = tc.aStatus, "upstream said no"
b.reject, b.rejectBody = tc.bStatus, "upstream said no"
b.dead = tc.bDead
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), resourcesPath, `["=","type","File"]`)
if rec.Code != tc.want {
t.Fatalf("status = %d, want %d: %s", rec.Code, tc.want, rec.Body.String())
}
})
}
}
// pdbmux has to be indistinguishable from a PuppetDB, and openvoxdb answers a
// query with a charset on the content type.
func TestHandler_SuccessContentTypeMatchesUpstream(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
h := srv.Handler()
for _, path := range []string{nodesPath, factsPath, factNamesPath, reportsPath} {
t.Run(path, func(t *testing.T) {
rec := doGet(t, h, path, "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != upstreamJSONContentType {
t.Errorf("Content-Type = %q, want %q", got, upstreamJSONContentType)
}
})
}
}
func TestHandler_CachedSuccessContentTypeMatchesUpstream(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
h := srv.Handler()
// The miss builds the entry and the hit replays it; both are the client's view.
for _, label := range []string{"miss", "hit"} {
rec := doGet(t, h, nodesPath, "")
if got := rec.Header().Get("Content-Type"); got != upstreamJSONContentType {
t.Errorf("%s Content-Type = %q, want %q", label, got, upstreamJSONContentType)
}
}
}