Compare commits
28 Commits
v0.1.0
..
c0aab83c20
| Author | SHA1 | Date | |
|---|---|---|---|
| c0aab83c20 | |||
| 1e2acc9c14 | |||
| 394f7df3a7 | |||
| b6d59af7ef | |||
| 1ee7a2c07c | |||
| 8ad6205200 | |||
| 2391f56a11 | |||
| ffc2499f98 | |||
| d724cf0a5e | |||
| b1ecbf31ac | |||
| 03174f5ea0 | |||
| 4bb44fb331 | |||
| 6ce903a6eb | |||
| cb05c7f377 | |||
| ee82b72733 | |||
| 7b9082de08 | |||
| d88db498c0 | |||
| 9d5e9d0ed8 | |||
| 14d119f8e8 | |||
| 31ad4ae457 | |||
| 01d87412ee | |||
| ed2e5b73d6 | |||
| e2e9004784 | |||
| a7e5a143f6 | |||
| 9fc928ee67 | |||
| 3c7251cc1e | |||
| c5b8b3824a | |||
| f7fda175e6 |
+7
-10
@@ -1,23 +1,20 @@
|
||||
# Build and push the pdbmux container image on a v* tag. pdbmux is a k8s-only
|
||||
# daemon (deployed via argocd-apps), so it ships as an image. Mirrors the estate
|
||||
# convention: the woodpeckerci/plugin-docker-buildx plugin pushes to the Gitea
|
||||
# registry using the droneci / DRONECI_PASSWORD credentials.
|
||||
# plugin-docker-buildx is the CA-baked variant; artifactapi's cert is not in the default trust store.
|
||||
when:
|
||||
- event: tag
|
||||
ref: refs/tags/v*
|
||||
|
||||
steps:
|
||||
- name: docker
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||
settings:
|
||||
registry: git.unkin.net
|
||||
repo: git.unkin.net/unkin/pdbmux
|
||||
registry: artifactapi.k8s.syd1.au.unkin.net
|
||||
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/pdbmux
|
||||
dockerfile: Dockerfile
|
||||
buildkit_config: |
|
||||
[registry."artifactapi.k8s.syd1.au.unkin.net"]
|
||||
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
|
||||
build_args:
|
||||
VERSION: ${CI_COMMIT_TAG}
|
||||
username: droneci
|
||||
password:
|
||||
from_secret: DRONECI_PASSWORD
|
||||
tags:
|
||||
- ${CI_COMMIT_TAG}
|
||||
- latest
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# Container image for pdbmux, the merging PuppetDB proxy daemon. pdbmux is a
|
||||
# k8s-only service (deployed via argocd-apps), so it ships as a distroless
|
||||
# static image rather than an RPM.
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
@@ -9,7 +9,6 @@ ARCH ?= $(shell go env GOARCH)
|
||||
|
||||
all: build
|
||||
|
||||
# Build the single static binary into dist/.
|
||||
build:
|
||||
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$(BINARY) .
|
||||
|
||||
@@ -28,8 +27,6 @@ clean:
|
||||
install:
|
||||
go install $(GOFLAGS) .
|
||||
|
||||
# Bump helpers — read the latest semver tag and create the next one.
|
||||
# If no tag exists yet, start from v0.0.0.
|
||||
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
|
||||
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
|
||||
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# pdbmux — merging PuppetDB proxy
|
||||
|
||||
`pdbmux` is a small HTTP daemon that fronts **two** PuppetDB backends and serves
|
||||
a single, merged PuppetDB v4 query surface on one address. Point `node-lookup`,
|
||||
`pblastreport`, or anything else at `pdbmux` instead of a raw PuppetDB and it
|
||||
sees one consistent view spanning both.
|
||||
`pdbmux` is a small HTTP daemon that fronts **several** PuppetDB backends and
|
||||
serves a single, merged PuppetDB v4 query surface on one address. Point
|
||||
Puppetboard, or any other PuppetDB API client, at `pdbmux` instead of a raw
|
||||
PuppetDB and it sees one consistent view spanning all of them.
|
||||
|
||||
## Why
|
||||
|
||||
During the VM→k8s Puppet migration there are two PuppetDBs:
|
||||
Running more than one PuppetDB — during a migration between two of them, or
|
||||
across regions — means a given node's current data lives in exactly one at any
|
||||
moment, and consumers have to know which, or query each in turn. `pdbmux`
|
||||
merges them all so consumers don't have to know (or query twice) which PuppetDB
|
||||
a node currently lives in.
|
||||
|
||||
- **old** — the legacy Consul-registered `http://puppetdbapi.service.consul:8080`
|
||||
- **new** — the k8s `https://puppetdb.k8s.syd1.au.unkin.net` (TLS terminated at
|
||||
the gateway; backends are plain PuppetDB on 8080)
|
||||
|
||||
Nodes move from old to new as they migrate, so at any moment a given node's
|
||||
current data lives in exactly one of them. `pdbmux` merges both so consumers
|
||||
don't have to know (or query twice) which PuppetDB a node currently lives in.
|
||||
All backends are equal — `pdbmux` is never told which one to favour. Backend
|
||||
names are arbitrary labels and there is no fixed number of them. The configured
|
||||
order is used only as a tie-break, so output is reproducible.
|
||||
|
||||
## Endpoints
|
||||
|
||||
@@ -24,133 +24,232 @@ not PQL) is forwarded verbatim.
|
||||
|
||||
| Path | Behaviour |
|
||||
|---|---|
|
||||
| `GET /pdb/query/v4/nodes` | Fan out to both backends, dedupe by `certname`, keep the record with the newer `report_timestamp`. |
|
||||
| `GET /pdb/query/v4/facts` | Fan out to both, and per `certname` keep **all** facts from the backend that owns that node (see merge semantics). |
|
||||
| `GET /pdb/query/v4/*` (any other) | Transparently proxied to the **primary** backend, unmerged, streamed verbatim. |
|
||||
| `GET /healthz` | Per-backend reachability. `200 {"status":"ok"}` if all reachable, `200 degraded` if some fail, `503 down` if all fail. |
|
||||
| `GET /pdb/query/v4/nodes` | Fan out to all backends, dedupe by `certname`, keep the record with the newer `report_timestamp`. An `extract`/`count` query is **summed** instead. |
|
||||
| `GET /pdb/query/v4/facts` | Fan out to all, and per `certname` keep **all** facts from the backend that owns that node (see merge semantics). |
|
||||
| `GET /pdb/query/v4/resources` | An `extract`/`count` query is fanned out and **summed**; any other query is an unmerged pass-through. |
|
||||
| `GET /pdb/query/v4/reports` | Fan out to all and serve the **union**, deduped by report `hash`, re-ordered and re-paged across backends. |
|
||||
| `GET /pdb/query/v4/events` | Fan out to all and serve the **union**, deduped by record identity, re-ordered and re-paged. |
|
||||
| `GET /pdb/query/v4/event-counts` | Fan out to all and **sum** each subject's counts into one row per subject. |
|
||||
| `GET /pdb/query/v4/aggregate-event-counts` | Fan out to all and **sum** the summary object's counts. |
|
||||
| `GET /pdb/query/v4/reports/<hash>/{events,logs,metrics}` | Ask every backend; serve the answer from whichever backend actually holds that report. `404` when none does. |
|
||||
| `GET /pdb/query/v4/*` (any other) | No merge rule, so backends are tried in configured order and the first success is streamed back verbatim; if all reject it, the first upstream error response is replayed. |
|
||||
| `GET /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 /metrics/v2/read/<mbean>` | Fan out to all and merge the Jolokia response; numeric attributes are **summed** by default (see merge semantics). |
|
||||
| `GET /metrics/v2/list` | Fan out to all and serve the **union** of the backends' MBean trees. |
|
||||
| `GET /metrics/v1/mbeans[/<mbean>]` | Same merge, applied to the legacy envelope-less body. |
|
||||
| `GET /healthz` | Per-backend reachability plus cache state. `200 {"status":"ok"}` if all reachable, `200 degraded` if some fail, `503 down` if all fail. |
|
||||
|
||||
Fan-out is concurrent. If one backend errors or times out, `pdbmux` serves the
|
||||
survivor's results and logs a warning; a merged endpoint only returns `502` when
|
||||
surviving backends' results and logs a warning; a merged endpoint only returns `502` when
|
||||
**every** backend fails. Response records are passed through as raw JSON so
|
||||
unknown fields survive untouched.
|
||||
|
||||
## Merge semantics
|
||||
|
||||
- **`/nodes`** — dedupe by `certname`; the record with the strictly-newer
|
||||
`report_timestamp` wins. On a tie (or when a node exists in only one backend),
|
||||
the **preferred** backend's record is kept.
|
||||
- **`/facts`** — node-level granularity. For a `certname` present in both
|
||||
backends, `pdbmux` keeps **all** of that node's facts from **one** backend and
|
||||
drops the other's, chosen by the merge strategy:
|
||||
`report_timestamp` wins. On a tie, the backend listed first in `backends`
|
||||
supplies the record — a tie-break only, so the merged output is deterministic.
|
||||
- **`/facts`** — node-level granularity. For a `certname` present in more than
|
||||
one backend, `pdbmux` keeps **all** of that node's facts from **one** backend and
|
||||
drops the others', chosen by the merge strategy:
|
||||
- **`freshness`** (default) — attribute each `certname` to whichever backend
|
||||
holds its newer `report_timestamp`. `pdbmux` derives this from a per-certname
|
||||
freshness map built by querying `/nodes` from both backends, cached for
|
||||
`freshness_ttl` (default 30s). Ties/fallbacks use `prefer`.
|
||||
- **`static`** — always keep the `prefer` backend's facts for shared nodes.
|
||||
No extra `/nodes` query.
|
||||
freshness map built by querying `/nodes` from every backend, cached for
|
||||
`freshness_ttl` (default 30s).
|
||||
- **`static`** — skip the extra `/nodes` query and take each shared node's
|
||||
facts from the first backend in configured order that holds it.
|
||||
- A node present in only one backend always appears (falls back to whichever
|
||||
backend actually returned facts for it).
|
||||
- **`/reports`, `/events`** — **union**, not a per-node winner. Reports are
|
||||
immutable history, so a node's reports can legitimately exist in more than one
|
||||
backend and all of them belong in the merged view. Reports dedupe on `hash`;
|
||||
events, which carry no id of their own, dedupe on the verbatim record (a node
|
||||
reporting to more than one backend stores identical records in each).
|
||||
- **Aggregates** — `extract`/`group_by` rows are counts, not records, so each
|
||||
backend returns a partial answer that has to be **added**, not deduped. This
|
||||
covers `/event-counts`, `/aggregate-event-counts`, and any `/reports`,
|
||||
`/nodes` or `/resources` query whose `extract` carries a `["function", ...]`
|
||||
column.
|
||||
- The grouping key is the row's non-aggregate fields: for `/reports`,
|
||||
`/nodes` and `/resources` they come from the query — the plain `extract`
|
||||
fields plus any `group_by` clause — and for the event-count endpoints from
|
||||
the row itself (`subject_type`/`subject`, or `summarize_by`), whose
|
||||
remaining fields are all counts.
|
||||
- On `/nodes` this takes precedence over the `certname` merge: a count row has
|
||||
no `certname`, so deduping would collapse every backend's count into one
|
||||
backend's number. A `/nodes` query with no `function` column — including a
|
||||
plain `extract` projection — still merges by `certname`.
|
||||
- `/resources` has no cross-backend record identity to dedupe on, so only its
|
||||
aggregate queries merge; everything else stays an unmerged pass-through.
|
||||
- Rows sharing a key collapse into one with their numeric columns summed. A key
|
||||
only one backend reported is passed through byte-for-byte. An aggregate column
|
||||
that is absent or non-numeric in a row is skipped, never zeroed, so the
|
||||
backends that did report a number still count.
|
||||
- A `/reports` query with no `function` column is a projection of real reports,
|
||||
not an aggregate, and stays on the union path.
|
||||
- `include_total=true` on a summed endpoint reports the **merged** row count,
|
||||
not the sum of the backends' `X-Records`, since shared keys collapse.
|
||||
|
||||
### Metadata and metrics
|
||||
|
||||
- **`/pdb/meta/v1/version`** — when the backends agree, that version is served.
|
||||
When they differ, `pdbmux` reports the **lowest**: a client reads this as the
|
||||
feature level it may rely on, and the estate can only be relied on for what its
|
||||
oldest PuppetDB implements. Versions compare segment by segment, numerically
|
||||
where both segments are numbers (`7.9.0` < `7.12.0`), lexically otherwise.
|
||||
A backend whose body is unparseable is skipped rather than treated as lowest.
|
||||
- **`/pdb/meta/v1/server-time`** — the clock of whichever PuppetDB answered is
|
||||
not estate state and has no meaningful merge, so the first **reachable**
|
||||
backend in configured order supplies it, the same tie-break used elsewhere.
|
||||
- **`/metrics/...`** — the Jolokia envelope's `value` is merged and the rest of
|
||||
the envelope comes from the first backend (with the newest `timestamp`).
|
||||
Values merge recursively:
|
||||
- Objects merge over the **union** of their keys, so an MBean attribute only
|
||||
one backend exposes still survives.
|
||||
- Numbers combine by the attribute's own name. The default is a **sum** —
|
||||
almost everything here is a population count (`num-nodes`, `num-resources`,
|
||||
queue depth, command totals) whose estate-wide value is the total, and rates
|
||||
are additive throughput. The exceptions describe a distribution or a bound,
|
||||
where adding two servers' numbers yields a figure that was never true of
|
||||
either: `Min` takes the minimum; `Max`, `Uptime` and `StartTime` take the
|
||||
maximum; `Mean`, `Median`, `StdDev` and `*Percentile` take the unweighted
|
||||
arithmetic mean (`pdbmux` has no per-backend sample counts to weight by).
|
||||
Matching is case-insensitive.
|
||||
- Strings, booleans, arrays, nulls and mixed kinds keep the first backend's
|
||||
value — there is no sound way to add them.
|
||||
- Jolokia signals a bad MBean as a non-2xx `status` **inside** an HTTP 200.
|
||||
Such a backend is skipped; if every backend does so, the first one's error
|
||||
envelope is replayed verbatim so the client sees the real reason.
|
||||
- MBean names arrive percent-encoded over Jolokia's own `!`-escaping; the raw
|
||||
path is forwarded so neither layer is lost.
|
||||
|
||||
### Paging and ordering on the merged endpoints
|
||||
|
||||
Each backend applies `order_by`/`limit`/`offset` to its own slice only, so
|
||||
`pdbmux` re-does all three over the union:
|
||||
|
||||
- `order_by` is parsed and the merged set re-sorted by those fields (ties keep
|
||||
the merged set's existing order). A record missing an ordered field sorts first.
|
||||
- Backends are asked for the first `offset + limit` records — never an `offset`
|
||||
— and the requested window is then cut from the merged, re-sorted set.
|
||||
- `include_total=true` on a union endpoint makes `pdbmux` sum each backend's
|
||||
`X-Records` header into one merged header. Deduped records are counted once per
|
||||
backend, so the total is an upper bound. Summed endpoints report the merged row
|
||||
count instead.
|
||||
- A malformed `limit`, `offset` or `order_by` gets a `400` rather than being
|
||||
forwarded.
|
||||
|
||||
## Caching
|
||||
|
||||
`pdbmux` caches merged `/nodes` and `/facts` record sets **in memory** so a busy
|
||||
Puppetboard does not re-fan-out the same query every few seconds. Everything else
|
||||
runs uncached — including `extract`/`count` aggregates on those two paths, and
|
||||
the `/pdb/meta/v1/*` and `/metrics/*` endpoints, which are served live on every
|
||||
request. 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
|
||||
determine the response, URL-encoded with keys sorted ascending and a repeated
|
||||
param's values sorted ascending. Param order in the request is therefore
|
||||
irrelevant: one canonical key per distinct request. A request with no params
|
||||
keys on the bare path.
|
||||
- **TTL** — `facts_ttl`, default `30s`, **hard cap `30s`**. A larger configured
|
||||
value is **clamped** down to the cap, not rejected, so a stray env var cannot
|
||||
crash-loop a container; `pdbmux config show` prints
|
||||
`facts_ttl : 30s (clamped from 600s, cap 30s)` when that happens. `facts_ttl: 0`
|
||||
disables the cache entirely and the merged endpoints behave exactly as before.
|
||||
- **Stale on failure only** — an expired entry is kept, not dropped. When the TTL
|
||||
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
|
||||
healthy backend is never shadowed by a stale entry.
|
||||
- **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
|
||||
asked for survives. A single response larger than the whole budget is not
|
||||
cached at all. The budget counts stored response bodies only — cache keys and
|
||||
the list/map bookkeeping are not accounted for, so it is a target for body
|
||||
bytes rather than a hard cap on process memory.
|
||||
- **Single-flight** — concurrent requests for the same key collapse into one
|
||||
upstream fan-out; the rest wait for it and share the result.
|
||||
- **Visibility** — `/healthz` carries a `cache` object: `backend`
|
||||
(`memory`/`none`), `ttl`, `entries`, `stale_entries`, `bytes`, `serving_stale`,
|
||||
`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
|
||||
fan-out or a fresh entry.
|
||||
|
||||
## Config
|
||||
|
||||
Precedence (lowest → highest): **defaults < config file < env vars (`PDBMUX_*`) < flags**.
|
||||
|
||||
Config file: `$XDG_CONFIG_HOME/pdbmux/config.yaml`. In Kubernetes, configuration
|
||||
is supplied entirely via `PDBMUX_*` env vars (no config file), which is the
|
||||
supported deployment path — see [Deployment](#deployment).
|
||||
The config file is optional; a file, env vars, or both work equally well,
|
||||
including in a container.
|
||||
|
||||
Which file is read: `--config <path>`, else `PDBMUX_CONFIG`, else the first that
|
||||
exists of `$XDG_CONFIG_HOME/pdbmux/config.yaml` (or `$HOME/.config/pdbmux/config.yaml`),
|
||||
then `/etc/pdbmux/config.yaml`. A path given via `--config`/`PDBMUX_CONFIG` **must**
|
||||
exist — pdbmux fails rather than silently falling back — while a missing file on
|
||||
the default search path is fine. `pdbmux config show` prints the file it loaded,
|
||||
or the paths it searched.
|
||||
|
||||
```yaml
|
||||
# ~/.config/pdbmux/config.yaml (local dev; in k8s use PDBMUX_* env instead)
|
||||
listen: ":8080"
|
||||
backends:
|
||||
- name: old
|
||||
url: http://puppetdbapi.service.consul:8080
|
||||
- name: new
|
||||
url: https://puppetdb.k8s.syd1.au.unkin.net
|
||||
primary: new # backend used for non-merged /pdb/query/v4/* pass-through
|
||||
merge: freshness # freshness | static
|
||||
prefer: new # winner on ties / static merge / fallback
|
||||
timeout: 10s # per-upstream request timeout
|
||||
freshness_ttl: 30s # freshness-map cache TTL (freshness merge only)
|
||||
backends: # order is a tie-break only, not a ranking
|
||||
- name: pdb-a
|
||||
url: http://puppetdb1.example.com:8080
|
||||
- name: pdb-b
|
||||
url: https://puppetdb2.example.com
|
||||
merge: freshness # freshness | static
|
||||
timeout: 10s # per-upstream request timeout
|
||||
freshness_ttl: 30s # freshness-map cache TTL (freshness merge only)
|
||||
facts_ttl: 30s # /facts + /nodes response cache TTL; 0 disables, capped at 30s
|
||||
facts_cache_bytes: 67108864 # byte budget for that cache (64 MiB), LRU-evicted
|
||||
```
|
||||
|
||||
`backends[*].url` is a **base** URL (`scheme://host[:port]`), without the
|
||||
`/pdb/query/v4/...` path — `pdbmux` appends the path per request.
|
||||
`backends[*].url` is a **base** URL (`scheme://host[:port]`); `pdbmux` appends
|
||||
the `/pdb/query/v4/...` path per request.
|
||||
|
||||
| Env var | Overrides |
|
||||
|---|---|
|
||||
| `PDBMUX_CONFIG` | config file path (not a file key) |
|
||||
| `PDBMUX_LISTEN` | `listen` |
|
||||
| `PDBMUX_PRIMARY` | `primary` |
|
||||
| `PDBMUX_MERGE` | `merge` |
|
||||
| `PDBMUX_PREFER` | `prefer` |
|
||||
| `PDBMUX_TIMEOUT` | `timeout` (Go duration, e.g. `10s`) |
|
||||
| `PDBMUX_FRESHNESS_TTL` | `freshness_ttl` |
|
||||
| `PDBMUX_FACTS_TTL` | `facts_ttl` (clamped to 30s) |
|
||||
| `PDBMUX_FACTS_CACHE_BYTES` | `facts_cache_bytes` (plain integer bytes) |
|
||||
| `PDBMUX_BACKENDS` | whole backend list, as `name=url,name=url` |
|
||||
|
||||
Flags: `--listen`, `--primary`, `--merge`.
|
||||
Flags: `--config`, `--listen`, `--merge`.
|
||||
|
||||
`config init` writes to `--config`/`PDBMUX_CONFIG` when set, else to
|
||||
`$XDG_CONFIG_HOME/pdbmux/config.yaml`.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
pdbmux # start the proxy (serve is the default action)
|
||||
pdbmux serve # explicit
|
||||
pdbmux config init # write a default config file
|
||||
pdbmux config show # print active config after all overrides
|
||||
pdbmux version
|
||||
```
|
||||
|
||||
Point a consumer at it:
|
||||
Subcommands: `serve` (default), `config init`, `config show`, `version`. Run
|
||||
`pdbmux --help` for details. Any PuppetDB v4 client works against the `pdbmux`
|
||||
base URL in place of a PuppetDB one.
|
||||
|
||||
```bash
|
||||
node-lookup --url http://localhost:8080/pdb/query/v4/facts -R
|
||||
NODE_LOOKUP_URL=http://localhost:8080/pdb/query/v4/facts pblastreport somehost
|
||||
PDBMUX_BACKENDS='pdb-a=http://puppetdb1.example.com:8080,pdb-b=http://puppetdb2.example.com:8080' pdbmux
|
||||
curl -s --get http://localhost:8080/pdb/query/v4/nodes \
|
||||
--data-urlencode 'query=["=","certname","host1.example.com"]'
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make build # -> dist/pdbmux (CGO disabled, static)
|
||||
make test # go test -race ./...
|
||||
make lint # golangci-lint
|
||||
```
|
||||
|
||||
Requires Go 1.25+. Dependencies: `github.com/spf13/cobra` (CLI),
|
||||
`gopkg.in/yaml.v3` (config file).
|
||||
`make build` (static binary into `dist/`), `make test`, `make lint`. Requires Go 1.25+.
|
||||
|
||||
## Deployment
|
||||
|
||||
`pdbmux` runs **in Kubernetes** as a container, in line with the all-in-k8s
|
||||
estate direction — it is not shipped as a per-VM RPM/systemd service. The image
|
||||
is built and pushed on every `v*` tag (`.woodpecker/docker.yaml`) to:
|
||||
Container image only — no OS package. Every `v*` tag builds and pushes the image
|
||||
(`.woodpecker/docker.yaml`); registry and repository are pipeline settings. Tag
|
||||
with `make patch` / `minor` / `major`.
|
||||
|
||||
```
|
||||
git.unkin.net/unkin/pdbmux:<tag>
|
||||
```
|
||||
|
||||
It is a minimal static (`CGO_ENABLED=0`) binary on a distroless base
|
||||
(`Dockerfile`), configured entirely via `PDBMUX_*` env vars, with a single HTTP
|
||||
listener and `/healthz` for liveness/readiness probes.
|
||||
|
||||
The Deployment/Service/Gateway manifests live in the estate's `argocd-apps` repo
|
||||
under `apps/base/pdbmux/` (namespace `pdbmux`, 2 replicas), and it is exposed to
|
||||
VM/workstation `node-lookup` consumers over HTTPS at:
|
||||
|
||||
```
|
||||
https://pdbmux.k8s.syd1.au.unkin.net
|
||||
```
|
||||
|
||||
Locally you can still run the binary directly for development:
|
||||
|
||||
```bash
|
||||
PDBMUX_BACKENDS='old=http://puppetdbapi.service.consul:8080,new=http://puppetdb.puppet.svc.cluster.local:8080' \
|
||||
pdbmux serve
|
||||
curl -s localhost:8080/healthz
|
||||
```
|
||||
|
||||
## Version bumps
|
||||
|
||||
```bash
|
||||
make patch # tag vX.Y.(Z+1) and push (triggers the docker release)
|
||||
make minor # tag vX.(Y+1).0
|
||||
make major # tag v(X+1).0.0
|
||||
```
|
||||
A static (`CGO_ENABLED=0`) binary on a distroless base. Configure it with
|
||||
`PDBMUX_*` env vars (at minimum `PDBMUX_BACKENDS`), or mount a config file — a
|
||||
configmap at `/etc/pdbmux/config.yaml` is picked up with no env var at all, and
|
||||
any other mount path works via `PDBMUX_CONFIG`. Env vars still override file
|
||||
values, so the two mix. Run as many replicas as you like — the only state is the
|
||||
in-memory cache, which is per-replica and bounded by `facts_cache_bytes`, so size
|
||||
the memory limit above it. Use `/healthz` for liveness/readiness probes.
|
||||
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// aggregateSpec names the columns of an extract/group_by result set: keys
|
||||
// identify a row across backends, sums are the numeric aggregate columns added
|
||||
// together.
|
||||
type aggregateSpec struct {
|
||||
keys []string
|
||||
sums []string
|
||||
}
|
||||
|
||||
// parseAggregate reads a PuppetDB AST query and returns the aggregate shape of
|
||||
// its response, or nil when the query is not an aggregate — only a top-level
|
||||
// `extract` carrying at least one `["function", ...]` column produces summable
|
||||
// rows. Key columns are the plain (non-function) extract fields, unioned with an
|
||||
// explicit `group_by` clause when the query has one.
|
||||
func parseAggregate(query string) *aggregateSpec {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil
|
||||
}
|
||||
var ast []json.RawMessage
|
||||
if json.Unmarshal([]byte(query), &ast) != nil || len(ast) < 2 {
|
||||
return nil
|
||||
}
|
||||
var op string
|
||||
if json.Unmarshal(ast[0], &op) != nil || op != "extract" {
|
||||
return nil
|
||||
}
|
||||
var cols []json.RawMessage
|
||||
if json.Unmarshal(ast[1], &cols) != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
spec := &aggregateSpec{}
|
||||
for _, col := range cols {
|
||||
var name string
|
||||
if json.Unmarshal(col, &name) == nil {
|
||||
spec.keys = appendUnique(spec.keys, name)
|
||||
continue
|
||||
}
|
||||
if fn, ok := functionName(col); ok {
|
||||
spec.sums = appendUnique(spec.sums, fn)
|
||||
}
|
||||
}
|
||||
if len(spec.sums) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, node := range ast[2:] {
|
||||
for _, f := range groupByFields(node) {
|
||||
spec.keys = appendUnique(spec.keys, f)
|
||||
}
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
// functionName returns the response column an extract function produces, which
|
||||
// PuppetDB names after the function itself: ["function","count","certname"]
|
||||
// yields a "count" column.
|
||||
func functionName(col json.RawMessage) (string, bool) {
|
||||
var parts []json.RawMessage
|
||||
if json.Unmarshal(col, &parts) != nil || len(parts) < 2 {
|
||||
return "", false
|
||||
}
|
||||
var head, name string
|
||||
if json.Unmarshal(parts[0], &head) != nil || head != "function" {
|
||||
return "", false
|
||||
}
|
||||
if json.Unmarshal(parts[1], &name) != nil || name == "" {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
// groupByFields returns the field names of a ["group_by", ...] AST node, or nil
|
||||
// for any other node.
|
||||
func groupByFields(node json.RawMessage) []string {
|
||||
var parts []json.RawMessage
|
||||
if json.Unmarshal(node, &parts) != nil || len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
var head string
|
||||
if json.Unmarshal(parts[0], &head) != nil || head != "group_by" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, p := range parts[1:] {
|
||||
var name string
|
||||
if json.Unmarshal(p, &name) == nil {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendUnique(s []string, v string) []string {
|
||||
if contains(s, v) {
|
||||
return s
|
||||
}
|
||||
return append(s, v)
|
||||
}
|
||||
|
||||
// columns reports which fields of a row form its grouping key and which are
|
||||
// summed. A spec is fixed by the query, so the row is ignored.
|
||||
func (a *aggregateSpec) columns(map[string]json.RawMessage) ([]string, []string) {
|
||||
return a.keys, a.sums
|
||||
}
|
||||
|
||||
// inferredColumns derives an event-counts row's shape from the row itself: the
|
||||
// counts to add (successes, failures, noops, skips, total) are its numeric
|
||||
// fields, plus any null one — PuppetDB nulls an aggregate column when a backend
|
||||
// matched nothing — and everything else, subject_type/subject/summarize_by,
|
||||
// identifies the row. Those endpoints have a fixed response shape with no
|
||||
// numeric key field, so nothing summable is mistaken for identity.
|
||||
func inferredColumns(row map[string]json.RawMessage) ([]string, []string) {
|
||||
var keys, sums []string
|
||||
for name, val := range row {
|
||||
if isJSONNumber(val) || isJSONNull(val) {
|
||||
sums = append(sums, name)
|
||||
continue
|
||||
}
|
||||
keys = append(keys, name)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
sort.Strings(sums)
|
||||
return keys, sums
|
||||
}
|
||||
|
||||
// isJSONNumber reports whether a raw JSON value is a number.
|
||||
func isJSONNumber(raw json.RawMessage) bool {
|
||||
v := strings.TrimSpace(string(raw))
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
return v[0] == '-' || (v[0] >= '0' && v[0] <= '9')
|
||||
}
|
||||
|
||||
func isJSONNull(raw json.RawMessage) bool {
|
||||
return strings.TrimSpace(string(raw)) == "null"
|
||||
}
|
||||
|
||||
// sumGroup accumulates the rows sharing one grouping key.
|
||||
type sumGroup struct {
|
||||
raw json.RawMessage // first contributing row, verbatim
|
||||
row map[string]json.RawMessage // its decoded fields
|
||||
totals map[string]float64 // running sum per aggregate column
|
||||
merged bool // a second row was folded in
|
||||
}
|
||||
|
||||
// sumRows folds each backend's aggregate rows into one row per grouping key,
|
||||
// adding the numeric aggregate columns. columns decides, per row, which fields
|
||||
// are the key and which are summed.
|
||||
//
|
||||
// A row that is not a JSON object passes through untouched, as does the sole row
|
||||
// of a key only one backend reported — those keep their upstream bytes. An
|
||||
// aggregate column that is absent or non-numeric in a later row is left at the
|
||||
// earlier backend's value rather than being coerced to zero. results come in
|
||||
// configured backend order and the output keeps first-seen order, a tie-break
|
||||
// only.
|
||||
func sumRows(results []backendResult, columns func(map[string]json.RawMessage) ([]string, []string)) []json.RawMessage {
|
||||
type slot struct {
|
||||
raw json.RawMessage // passthrough row, when group is nil
|
||||
group *sumGroup
|
||||
}
|
||||
var order []slot
|
||||
groups := map[string]*sumGroup{}
|
||||
|
||||
for _, res := range results {
|
||||
for _, rec := range res.records {
|
||||
var row map[string]json.RawMessage
|
||||
if json.Unmarshal(rec.Raw, &row) != nil {
|
||||
order = append(order, slot{raw: rec.Raw})
|
||||
continue
|
||||
}
|
||||
keys, sums := columns(row)
|
||||
k := groupKey(row, keys)
|
||||
g, ok := groups[k]
|
||||
if !ok {
|
||||
g = &sumGroup{raw: rec.Raw, row: row, totals: map[string]float64{}}
|
||||
for _, s := range sums {
|
||||
if n, ok := numberOf(row[s]); ok {
|
||||
g.totals[s] = n
|
||||
}
|
||||
}
|
||||
groups[k] = g
|
||||
order = append(order, slot{group: g})
|
||||
continue
|
||||
}
|
||||
for _, s := range sums {
|
||||
n, ok := numberOf(row[s])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, seen := g.totals[s]; !seen {
|
||||
// First numeric value for a column the earlier row lacked.
|
||||
g.totals[s] = 0
|
||||
}
|
||||
g.totals[s] += n
|
||||
g.merged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]json.RawMessage, 0, len(order))
|
||||
for _, sl := range order {
|
||||
if sl.group == nil {
|
||||
out = append(out, sl.raw)
|
||||
continue
|
||||
}
|
||||
out = append(out, sl.group.encode())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// encode renders a group back to JSON, reusing the first row's bytes when
|
||||
// nothing was added to it.
|
||||
func (g *sumGroup) encode() json.RawMessage {
|
||||
if !g.merged {
|
||||
return g.raw
|
||||
}
|
||||
row := make(map[string]json.RawMessage, len(g.row))
|
||||
for k, v := range g.row {
|
||||
row[k] = v
|
||||
}
|
||||
for col, total := range g.totals {
|
||||
row[col] = json.RawMessage(strconv.FormatFloat(total, 'f', -1, 64))
|
||||
}
|
||||
raw, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return g.raw
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// groupKey builds a row's identity from the named fields' verbatim JSON values.
|
||||
// Every backend runs the same PuppetDB serialiser, so byte equality is a sound
|
||||
// comparison for object-valued keys such as event-counts' subject. An absent
|
||||
// field is distinct from any present value.
|
||||
func groupKey(row map[string]json.RawMessage, keys []string) string {
|
||||
var b strings.Builder
|
||||
for _, k := range keys {
|
||||
b.WriteString(k)
|
||||
b.WriteByte(0)
|
||||
if v, ok := row[k]; ok {
|
||||
b.Write(v)
|
||||
} else {
|
||||
b.WriteByte(1)
|
||||
}
|
||||
b.WriteByte(0)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// numberOf decodes a raw JSON number, reporting ok=false for anything else so
|
||||
// non-numeric aggregate columns are carried through instead of summed.
|
||||
func numberOf(raw json.RawMessage) (float64, bool) {
|
||||
if !isJSONNumber(raw) {
|
||||
return 0, false
|
||||
}
|
||||
var n float64
|
||||
if json.Unmarshal(raw, &n) != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func rows(raws ...string) []record {
|
||||
out := make([]record, 0, len(raws))
|
||||
for _, r := range raws {
|
||||
out = append(out, record{Raw: json.RawMessage(r)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decodeRows turns a merged result set into comparable maps.
|
||||
func decodeRows(t *testing.T, raws []json.RawMessage) []map[string]any {
|
||||
t.Helper()
|
||||
out := make([]map[string]any, 0, len(raws))
|
||||
for _, raw := range raws {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", raw, err)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestParseAggregate_ExtractWithGroupBy(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
if spec == nil {
|
||||
t.Fatal("expected an aggregate spec")
|
||||
}
|
||||
if !slices.Equal(spec.keys, []string{"status"}) {
|
||||
t.Errorf("keys = %v, want [status]", spec.keys)
|
||||
}
|
||||
if !slices.Equal(spec.sums, []string{"count"}) {
|
||||
t.Errorf("sums = %v, want [count]", spec.sums)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAggregate_GroupByAddsUnextractedField(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count","certname"]],["~","certname",".*"],["group_by","status"]]`)
|
||||
if spec == nil {
|
||||
t.Fatal("expected an aggregate spec")
|
||||
}
|
||||
if !slices.Equal(spec.keys, []string{"status"}) {
|
||||
t.Errorf("keys = %v, want [status] from the group_by clause", spec.keys)
|
||||
}
|
||||
if !slices.Equal(spec.sums, []string{"count"}) {
|
||||
t.Errorf("sums = %v, want [count]", spec.sums)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAggregate_NoFunctionIsNotAggregate(t *testing.T) {
|
||||
for _, q := range []string{
|
||||
``,
|
||||
`["=","certname","h1"]`,
|
||||
`["extract",["certname","hash"],["=","certname","h1"]]`, // projection, still real reports
|
||||
`not json`,
|
||||
`["extract"]`,
|
||||
`{"not":"an array"}`,
|
||||
} {
|
||||
if spec := parseAggregate(q); spec != nil {
|
||||
t.Errorf("parseAggregate(%q) = %+v, want nil", q, spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_SharedKeysAreAdded(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := sumRows([]backendResult{
|
||||
{name: "a", records: rows(`{"count":3,"status":"changed"}`, `{"count":1,"status":"failed"}`)},
|
||||
{name: "b", records: rows(`{"count":4,"status":"changed"}`, `{"count":2,"status":"failed"}`)},
|
||||
}, spec.columns)
|
||||
|
||||
want := []map[string]any{
|
||||
{"count": float64(7), "status": "changed"},
|
||||
{"count": float64(3), "status": "failed"},
|
||||
}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_DisjointKeysAreKept(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := sumRows([]backendResult{
|
||||
{name: "a", records: rows(`{"count":3,"status":"changed"}`)},
|
||||
{name: "b", records: rows(`{"count":2,"status":"skipped"}`)},
|
||||
}, spec.columns)
|
||||
|
||||
want := []map[string]any{
|
||||
{"count": float64(3), "status": "changed"},
|
||||
{"count": float64(2), "status": "skipped"},
|
||||
}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_SingleBackendRowKeepsUpstreamBytes(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
const raw = `{"count":3,"status":"changed","extra":{"kept":true}}`
|
||||
merged := sumRows([]backendResult{
|
||||
{name: "a", records: rows(raw)},
|
||||
{name: "b", records: nil},
|
||||
}, spec.columns)
|
||||
|
||||
if len(merged) != 1 || string(merged[0]) != raw {
|
||||
t.Errorf("merged = %s, want the row verbatim %s", merged, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_NonNumericAggregateColumnIsNotZeroed(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := sumRows([]backendResult{
|
||||
{name: "a", records: rows(`{"count":5,"status":"changed"}`)},
|
||||
{name: "b", records: rows(`{"count":null,"status":"changed"}`)},
|
||||
}, spec.columns)
|
||||
|
||||
want := []map[string]any{{"count": float64(5), "status": "changed"}}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want the numeric value preserved %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_MissingAggregateColumnStartsFromTheNumericRow(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := sumRows([]backendResult{
|
||||
{name: "a", records: rows(`{"status":"changed"}`)},
|
||||
{name: "b", records: rows(`{"count":6,"status":"changed"}`)},
|
||||
}, spec.columns)
|
||||
|
||||
want := []map[string]any{{"count": float64(6), "status": "changed"}}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_NonObjectRowsPassThrough(t *testing.T) {
|
||||
spec := parseAggregate(`["extract",[["function","count"],"status"],["=","certname","h1"],["group_by","status"]]`)
|
||||
merged := sumRows([]backendResult{
|
||||
{name: "a", records: rows(`"surprise"`)},
|
||||
{name: "b", records: rows(`{"count":1,"status":"changed"}`)},
|
||||
}, spec.columns)
|
||||
|
||||
if len(merged) != 2 || string(merged[0]) != `"surprise"` {
|
||||
t.Fatalf("merged = %s, want the non-object row kept as-is", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_NoKeyColumnsCollapseToOneRow(t *testing.T) {
|
||||
// ["extract",[["function","count"]],...] is a whole-estate count: one row
|
||||
// per backend, and the merged answer is their sum.
|
||||
spec := parseAggregate(`["extract",[["function","count"]],["=","certname","h1"]]`)
|
||||
if spec == nil {
|
||||
t.Fatal("expected an aggregate spec")
|
||||
}
|
||||
merged := sumRows([]backendResult{
|
||||
{name: "a", records: rows(`{"count":10}`)},
|
||||
{name: "b", records: rows(`{"count":32}`)},
|
||||
}, spec.columns)
|
||||
|
||||
want := []map[string]any{{"count": float64(42)}}
|
||||
if got := decodeRows(t, merged); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferredColumns_SplitsCountsFromIdentity(t *testing.T) {
|
||||
var row map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(`{"subject_type":"certname","subject":{"title":"h1"},"failures":1,"successes":2,"skips":null}`), &row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys, sums := inferredColumns(row)
|
||||
if !slices.Equal(keys, []string{"subject", "subject_type"}) {
|
||||
t.Errorf("keys = %v, want [subject subject_type]", keys)
|
||||
}
|
||||
// A null count is an empty aggregate, not part of the row's identity.
|
||||
if !slices.Equal(sums, []string{"failures", "skips", "successes"}) {
|
||||
t.Errorf("sums = %v, want [failures skips successes]", sums)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumRows_EventCountsPerSubject(t *testing.T) {
|
||||
merged := sumRows([]backendResult{
|
||||
{name: "a", records: rows(
|
||||
`{"subject_type":"certname","subject":{"title":"h1"},"failures":1,"successes":2,"noops":0,"skips":0}`,
|
||||
`{"subject_type":"certname","subject":{"title":"h2"},"failures":0,"successes":5,"noops":0,"skips":0}`,
|
||||
)},
|
||||
{name: "b", records: rows(
|
||||
`{"subject_type":"certname","subject":{"title":"h1"},"failures":3,"successes":4,"noops":1,"skips":0}`,
|
||||
)},
|
||||
}, inferredColumns)
|
||||
|
||||
got := decodeRows(t, merged)
|
||||
want := []map[string]any{
|
||||
{"subject_type": "certname", "subject": map[string]any{"title": "h1"},
|
||||
"failures": float64(4), "successes": float64(6), "noops": float64(1), "skips": float64(0)},
|
||||
{"subject_type": "certname", "subject": map[string]any{"title": "h2"},
|
||||
"failures": float64(0), "successes": float64(5), "noops": float64(0), "skips": float64(0)},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheStatus distinguishes the three outcomes of a lookup: nothing stored, a
|
||||
// stored entry within its TTL, and a stored entry past it.
|
||||
type CacheStatus int
|
||||
|
||||
const (
|
||||
CacheMiss CacheStatus = iota
|
||||
CacheFresh
|
||||
CacheStale
|
||||
)
|
||||
|
||||
func (s CacheStatus) String() string {
|
||||
switch s {
|
||||
case CacheFresh:
|
||||
return "fresh"
|
||||
case CacheStale:
|
||||
return "stale"
|
||||
default:
|
||||
return "miss"
|
||||
}
|
||||
}
|
||||
|
||||
// CacheEntry is a stored response body and the time it was stored.
|
||||
type CacheEntry struct {
|
||||
Body []byte
|
||||
StoredAt time.Time
|
||||
}
|
||||
|
||||
// CacheStats is the cache state reported by /healthz.
|
||||
type CacheStats struct {
|
||||
Backend string `json:"backend"`
|
||||
Entries int `json:"entries"`
|
||||
StaleEntries int `json:"stale_entries"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// Cache stores merged responses keyed by cacheKey. Get reports freshness rather
|
||||
// than hiding expired entries so a caller can fall back to a stale body when the
|
||||
// upstream fetch fails. The context and error exist for out-of-process backends
|
||||
// (the reports cache lands on S3); an in-process backend ignores both.
|
||||
//
|
||||
// A Body handed back by Get aliases the cache's copy and must not be mutated.
|
||||
type Cache interface {
|
||||
Get(ctx context.Context, key string) (CacheEntry, CacheStatus, error)
|
||||
Put(ctx context.Context, key string, body []byte) error
|
||||
Stats() CacheStats
|
||||
}
|
||||
|
||||
// cacheKey is the request path when there are no params, else "<path>?<params>"
|
||||
// where params is url.Values.Encode() over a copy whose repeated values have
|
||||
// been sorted. Encode() already sorts keys ascending, so both the order params
|
||||
// arrive in and the order of a repeated param's values are irrelevant to the
|
||||
// key: one canonical string per distinct request.
|
||||
func cacheKey(path string, params url.Values) string {
|
||||
if len(params) == 0 {
|
||||
return path
|
||||
}
|
||||
norm := make(url.Values, len(params))
|
||||
for k, vs := range params {
|
||||
sorted := append([]string(nil), vs...)
|
||||
sort.Strings(sorted)
|
||||
norm[k] = sorted
|
||||
}
|
||||
encoded := norm.Encode()
|
||||
if encoded == "" {
|
||||
return path
|
||||
}
|
||||
return path + "?" + encoded
|
||||
}
|
||||
|
||||
// noopCache is the default for every path with no cache configured, so wiring a
|
||||
// handler through the cache leaves its behaviour unchanged.
|
||||
type noopCache struct{}
|
||||
|
||||
func (noopCache) Get(context.Context, string) (CacheEntry, CacheStatus, error) {
|
||||
return CacheEntry{}, CacheMiss, nil
|
||||
}
|
||||
|
||||
func (noopCache) Put(context.Context, string, []byte) error { return nil }
|
||||
|
||||
func (noopCache) Stats() CacheStats { return CacheStats{Backend: "none"} }
|
||||
|
||||
type memoryEntry struct {
|
||||
key string
|
||||
body []byte
|
||||
storedAt time.Time
|
||||
}
|
||||
|
||||
// memoryCache is a byte-bounded LRU. Expired entries are kept, not dropped, so
|
||||
// they remain available as a stale fallback; only the byte budget evicts.
|
||||
type memoryCache struct {
|
||||
ttl time.Duration
|
||||
maxBytes int64
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
ll *list.List // front = most recently used
|
||||
items map[string]*list.Element
|
||||
bytes int64
|
||||
}
|
||||
|
||||
func newMemoryCache(ttl time.Duration, maxBytes int64) *memoryCache {
|
||||
return &memoryCache{
|
||||
ttl: ttl,
|
||||
maxBytes: maxBytes,
|
||||
now: time.Now,
|
||||
ll: list.New(),
|
||||
items: make(map[string]*list.Element),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *memoryCache) Get(_ context.Context, key string) (CacheEntry, CacheStatus, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
el, ok := c.items[key]
|
||||
if !ok {
|
||||
return CacheEntry{}, CacheMiss, nil
|
||||
}
|
||||
c.ll.MoveToFront(el)
|
||||
e := el.Value.(*memoryEntry)
|
||||
status := CacheFresh
|
||||
if c.now().Sub(e.storedAt) >= c.ttl {
|
||||
status = CacheStale
|
||||
}
|
||||
return CacheEntry{Body: e.body, StoredAt: e.storedAt}, status, nil
|
||||
}
|
||||
|
||||
func (c *memoryCache) Put(_ context.Context, key string, body []byte) error {
|
||||
// A response larger than the whole budget would evict everything else.
|
||||
if int64(len(body)) > c.maxBytes {
|
||||
return nil
|
||||
}
|
||||
stored := append([]byte(nil), body...)
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if el, ok := c.items[key]; ok {
|
||||
e := el.Value.(*memoryEntry)
|
||||
c.bytes += int64(len(stored)) - int64(len(e.body))
|
||||
e.body, e.storedAt = stored, c.now()
|
||||
c.ll.MoveToFront(el)
|
||||
} else {
|
||||
c.items[key] = c.ll.PushFront(&memoryEntry{key: key, body: stored, storedAt: c.now()})
|
||||
c.bytes += int64(len(stored))
|
||||
}
|
||||
for c.bytes > c.maxBytes {
|
||||
back := c.ll.Back()
|
||||
if back == nil {
|
||||
break
|
||||
}
|
||||
e := c.ll.Remove(back).(*memoryEntry)
|
||||
delete(c.items, e.key)
|
||||
c.bytes -= int64(len(e.body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *memoryCache) Stats() CacheStats {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
st := CacheStats{Backend: "memory", Entries: len(c.items), Bytes: c.bytes}
|
||||
now := c.now()
|
||||
for el := c.ll.Front(); el != nil; el = el.Next() {
|
||||
if now.Sub(el.Value.(*memoryEntry).storedAt) >= c.ttl {
|
||||
st.StaleEntries++
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// flightGroup collapses concurrent identical builds so N simultaneous requests
|
||||
// for one key cause one upstream fan-out.
|
||||
type flightGroup struct {
|
||||
mu sync.Mutex
|
||||
calls map[string]*flightCall
|
||||
}
|
||||
|
||||
type flightCall struct {
|
||||
wg sync.WaitGroup
|
||||
resp cachedResponse
|
||||
err error
|
||||
}
|
||||
|
||||
// flightPanic is a panic from a flight's fn, reported to the leader and to every
|
||||
// waiter as an error so callers keep their error handling (stale fallback, 502)
|
||||
// instead of seeing a zero-value success.
|
||||
type flightPanic struct {
|
||||
value any
|
||||
stack []byte
|
||||
}
|
||||
|
||||
func (p *flightPanic) Error() string {
|
||||
return fmt.Sprintf("panic building response: %v\n%s", p.value, p.stack)
|
||||
}
|
||||
|
||||
// Do returns fn's result and whether this caller shared another's in-flight run.
|
||||
func (g *flightGroup) Do(key string, fn func() (cachedResponse, error)) (resp cachedResponse, err error, shared bool) {
|
||||
g.mu.Lock()
|
||||
if g.calls == nil {
|
||||
g.calls = make(map[string]*flightCall)
|
||||
}
|
||||
if c, ok := g.calls[key]; ok {
|
||||
g.mu.Unlock()
|
||||
c.wg.Wait()
|
||||
return c.resp, c.err, true
|
||||
}
|
||||
c := &flightCall{}
|
||||
c.wg.Add(1)
|
||||
g.calls[key] = c
|
||||
g.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
c.resp, c.err = cachedResponse{}, &flightPanic{value: r, stack: debug.Stack()}
|
||||
resp, err = c.resp, c.err
|
||||
}
|
||||
// Done only after the results are stored, so waiters read them.
|
||||
c.wg.Done()
|
||||
g.mu.Lock()
|
||||
delete(g.calls, key)
|
||||
g.mu.Unlock()
|
||||
}()
|
||||
|
||||
c.resp, c.err = fn()
|
||||
return c.resp, c.err, false
|
||||
}
|
||||
|
||||
// staleTracker records stale fallbacks for /healthz. serving flips back to false
|
||||
// as soon as a response is served from a live fan-out or a fresh entry.
|
||||
type staleTracker struct {
|
||||
mu sync.Mutex
|
||||
serving bool
|
||||
served uint64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func (t *staleTracker) markStale(now time.Time) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.serving = true
|
||||
t.served++
|
||||
t.last = now
|
||||
}
|
||||
|
||||
func (t *staleTracker) markFresh() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.serving = false
|
||||
}
|
||||
|
||||
func (t *staleTracker) snapshot() (serving bool, served uint64, last time.Time) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.serving, t.served, t.last
|
||||
}
|
||||
+821
@@ -0,0 +1,821 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeClock drives the cache's TTL without sleeping.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func newFakeClock() *fakeClock { return &fakeClock{t: time.Unix(1_800_000_000, 0)} }
|
||||
|
||||
func (c *fakeClock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.t = c.t.Add(d)
|
||||
}
|
||||
|
||||
// countingBackend is a PuppetDB stand-in that counts requests per path and can
|
||||
// be made to fail or block, so cache hits and single-flight are observable.
|
||||
type countingBackend struct {
|
||||
srv *httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
hits map[string]int
|
||||
bodies map[string]string
|
||||
fail bool
|
||||
block chan struct{}
|
||||
}
|
||||
|
||||
func newCountingBackend(t *testing.T, bodies map[string]string) *countingBackend {
|
||||
t.Helper()
|
||||
cb := &countingBackend{hits: map[string]int{}, bodies: bodies}
|
||||
cb.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cb.mu.Lock()
|
||||
cb.hits[r.URL.Path]++
|
||||
fail, block, body := cb.fail, cb.block, cb.bodies[r.URL.Path]
|
||||
cb.mu.Unlock()
|
||||
|
||||
if block != nil {
|
||||
<-block
|
||||
}
|
||||
if fail {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if body == "" {
|
||||
body = "[]"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
t.Cleanup(cb.srv.Close)
|
||||
return cb
|
||||
}
|
||||
|
||||
func (cb *countingBackend) hitCount(path string) int {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
return cb.hits[path]
|
||||
}
|
||||
|
||||
func (cb *countingBackend) setFail(v bool) {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
cb.fail = v
|
||||
}
|
||||
|
||||
func (cb *countingBackend) setBody(path, body string) {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
cb.bodies[path] = body
|
||||
}
|
||||
|
||||
func (cb *countingBackend) setBlock(ch chan struct{}) {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
cb.block = ch
|
||||
}
|
||||
|
||||
// newCachedServer builds a server whose facts cache runs on a fake clock.
|
||||
func newCachedServer(t *testing.T, cfg Config) (*Server, *fakeClock) {
|
||||
t.Helper()
|
||||
srv := newTestServer(cfg)
|
||||
mc, ok := srv.factsCache.(*memoryCache)
|
||||
if !ok {
|
||||
t.Fatalf("expected a memory cache, got %T", srv.factsCache)
|
||||
}
|
||||
clk := newFakeClock()
|
||||
mc.now = clk.now
|
||||
return srv, clk
|
||||
}
|
||||
|
||||
func cacheTestConfig(aURL, bURL string) Config {
|
||||
cfg := testConfig(aURL, bURL, mergeStatic)
|
||||
cfg.FactsTTL = 30 * time.Second
|
||||
cfg.CacheBytes = defaultCacheSize
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestCacheKey_CanonicalOrdering(t *testing.T) {
|
||||
a := cacheKey(factsPath, url.Values{
|
||||
"query": {`["=","name","role"]`},
|
||||
"limit": {"10"},
|
||||
"expand": {"z", "a"},
|
||||
})
|
||||
b := cacheKey(factsPath, url.Values{
|
||||
"expand": {"a", "z"},
|
||||
"limit": {"10"},
|
||||
"query": {`["=","name","role"]`},
|
||||
})
|
||||
if a != b {
|
||||
t.Errorf("param order must not change the key:\n %s\n %s", a, b)
|
||||
}
|
||||
if !strings.HasPrefix(a, factsPath+"?") {
|
||||
t.Errorf("key must start with the path, got %q", a)
|
||||
}
|
||||
if got := cacheKey(nodesPath, nil); got != nodesPath {
|
||||
t.Errorf("no params should give the bare path, got %q", got)
|
||||
}
|
||||
if cacheKey(factsPath, url.Values{"query": {"x"}}) == cacheKey(factsPath, url.Values{"query": {"y"}}) {
|
||||
t.Error("different queries must not share a key")
|
||||
}
|
||||
if cacheKey(factsPath, url.Values{"query": {"x"}}) == cacheKey(nodesPath, url.Values{"query": {"x"}}) {
|
||||
t.Error("different paths must not share a key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryCache_FreshThenStale(t *testing.T) {
|
||||
c := newMemoryCache(30*time.Second, 1<<20)
|
||||
clk := newFakeClock()
|
||||
c.now = clk.now
|
||||
|
||||
if _, status, _ := c.Get(context.Background(), "k"); status != CacheMiss {
|
||||
t.Fatalf("empty cache should miss, got %s", status)
|
||||
}
|
||||
if err := c.Put(context.Background(), "k", []byte(`{"body":[]}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ent, status, err := c.Get(context.Background(), "k")
|
||||
if err != nil || status != CacheFresh || string(ent.Body) != `{"body":[]}` {
|
||||
t.Fatalf("want a fresh hit, got %s %q (%v)", status, ent.Body, err)
|
||||
}
|
||||
|
||||
clk.advance(29 * time.Second)
|
||||
if _, status, _ := c.Get(context.Background(), "k"); status != CacheFresh {
|
||||
t.Fatalf("inside the TTL should still be fresh, got %s", status)
|
||||
}
|
||||
|
||||
clk.advance(2 * time.Second)
|
||||
ent, status, _ = c.Get(context.Background(), "k")
|
||||
if status != CacheStale {
|
||||
t.Fatalf("past the TTL should be stale, got %s", status)
|
||||
}
|
||||
if string(ent.Body) != `{"body":[]}` {
|
||||
t.Errorf("a stale entry must still carry its body, got %q", ent.Body)
|
||||
}
|
||||
if st := c.Stats(); st.Entries != 1 || st.StaleEntries != 1 {
|
||||
t.Errorf("stats should report 1 entry, 1 stale, got %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryCache_EvictsLeastRecentlyUsed(t *testing.T) {
|
||||
body := []byte("0123456789") // 10 bytes
|
||||
c := newMemoryCache(time.Minute, 25)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, k := range []string{"a", "b"} {
|
||||
if err := c.Put(ctx, k, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Reading "a" makes "b" the eviction candidate.
|
||||
if _, status, _ := c.Get(ctx, "a"); status != CacheFresh {
|
||||
t.Fatalf("a should be cached, got %s", status)
|
||||
}
|
||||
if err := c.Put(ctx, "c", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, status, _ := c.Get(ctx, "b"); status != CacheMiss {
|
||||
t.Errorf("b was least recently used and should have been evicted, got %s", status)
|
||||
}
|
||||
for _, k := range []string{"a", "c"} {
|
||||
if _, status, _ := c.Get(ctx, k); status != CacheFresh {
|
||||
t.Errorf("%s should have survived eviction, got %s", k, status)
|
||||
}
|
||||
}
|
||||
if st := c.Stats(); st.Entries != 2 || st.Bytes != 20 {
|
||||
t.Errorf("stats after eviction = %+v, want 2 entries / 20 bytes", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryCache_SkipsOversizedEntry(t *testing.T) {
|
||||
c := newMemoryCache(time.Minute, 8)
|
||||
if err := c.Put(context.Background(), "big", []byte("123456789")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, status, _ := c.Get(context.Background(), "big"); status != CacheMiss {
|
||||
t.Error("an entry larger than the whole budget must not be stored")
|
||||
}
|
||||
if st := c.Stats(); st.Bytes != 0 {
|
||||
t.Errorf("bytes = %d, want 0", st.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopCache_AlwaysMisses(t *testing.T) {
|
||||
var c Cache = noopCache{}
|
||||
if err := c.Put(context.Background(), "k", []byte("x")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, status, _ := c.Get(context.Background(), "k"); status != CacheMiss {
|
||||
t.Errorf("noop cache must always miss, got %s", status)
|
||||
}
|
||||
if st := c.Stats(); st.Backend != "none" {
|
||||
t.Errorf("backend = %q, want none", st.Backend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_FactsCacheFreshHit(t *testing.T) {
|
||||
body := `[` + fact("h1", "role", "web", "") + `]`
|
||||
a := newCountingBackend(t, map[string]string{factsPath: body})
|
||||
b := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||
|
||||
first := doGet(t, srv.Handler(), factsPath, `["=","name","role"]`)
|
||||
second := doGet(t, srv.Handler(), factsPath, `["=","name","role"]`)
|
||||
|
||||
if first.Code != http.StatusOK || second.Code != http.StatusOK {
|
||||
t.Fatalf("statuses %d/%d", first.Code, second.Code)
|
||||
}
|
||||
if 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 {
|
||||
t.Errorf("backend a saw %d requests, want 1 (second served from cache)", got)
|
||||
}
|
||||
if got := b.hitCount(factsPath); got != 1 {
|
||||
t.Errorf("backend b saw %d requests, want 1", got)
|
||||
}
|
||||
|
||||
// A different query is a different key and must go upstream.
|
||||
doGet(t, srv.Handler(), factsPath, `["=","name","osfamily"]`)
|
||||
if got := a.hitCount(factsPath); got != 2 {
|
||||
t.Errorf("a different query should refetch: %d requests, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_FactsCacheExpires(t *testing.T) {
|
||||
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "role", "web", "") + `]`})
|
||||
b := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||
|
||||
doGet(t, srv.Handler(), factsPath, "")
|
||||
clk.advance(31 * time.Second)
|
||||
doGet(t, srv.Handler(), factsPath, "")
|
||||
|
||||
if got := a.hitCount(factsPath); got != 2 {
|
||||
t.Errorf("an expired entry should refetch: %d requests, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ServesStaleOnlyWhenBackendsFail(t *testing.T) {
|
||||
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "role", "old", "") + `]`})
|
||||
b := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||
|
||||
warm := doGet(t, srv.Handler(), factsPath, "")
|
||||
if warm.Code != http.StatusOK {
|
||||
t.Fatalf("warm-up status %d", warm.Code)
|
||||
}
|
||||
if !strings.Contains(warm.Body.String(), `"old"`) {
|
||||
t.Fatalf("warm-up body = %s", warm.Body.String())
|
||||
}
|
||||
|
||||
// Backends healthy but the entry expired: fresh data wins, never the stale copy.
|
||||
clk.advance(31 * time.Second)
|
||||
a.setBody(factsPath, `[`+fact("h1", "role", "new", "")+`]`)
|
||||
refetch := doGet(t, srv.Handler(), factsPath, "")
|
||||
if !strings.Contains(refetch.Body.String(), `"new"`) {
|
||||
t.Errorf("a healthy backend must not be shadowed by the stale entry: %s", refetch.Body.String())
|
||||
}
|
||||
if serving, _, _ := srv.stale.snapshot(); serving {
|
||||
t.Error("serving_stale must stay false while backends are healthy")
|
||||
}
|
||||
|
||||
// Every backend down and the entry expired: the stale copy is served.
|
||||
clk.advance(31 * time.Second)
|
||||
a.setFail(true)
|
||||
b.setFail(true)
|
||||
stale := doGet(t, srv.Handler(), factsPath, "")
|
||||
if stale.Code != http.StatusOK {
|
||||
t.Fatalf("stale fallback status %d: %s", stale.Code, stale.Body.String())
|
||||
}
|
||||
if !strings.Contains(stale.Body.String(), `"new"`) {
|
||||
t.Errorf("stale body = %s, want the last cached copy", stale.Body.String())
|
||||
}
|
||||
|
||||
serving, served, last := srv.stale.snapshot()
|
||||
if !serving || served != 1 || last.IsZero() {
|
||||
t.Errorf("stale tracker = %v/%d/%v, want serving=true served=1", serving, served, last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_NoCacheEntryMeansBackendFailureIs502(t *testing.T) {
|
||||
a := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||
b := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||
a.setFail(true)
|
||||
b.setFail(true)
|
||||
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||
|
||||
rec := doGet(t, srv.Handler(), factsPath, "")
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status %d, want 502", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "all backends failed") {
|
||||
t.Errorf("body = %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_SingleFlightCollapsesConcurrentRequests(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()
|
||||
|
||||
release := make(chan struct{})
|
||||
a.setBlock(release)
|
||||
b.setBlock(release)
|
||||
|
||||
const callers = 16
|
||||
var wg sync.WaitGroup
|
||||
codes := make([]int, callers)
|
||||
for i := range callers {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
codes[i] = doGet(t, h, factsPath, "").Code
|
||||
}(i)
|
||||
}
|
||||
|
||||
// The leader is parked inside the blocked backend, so every caller that
|
||||
// reaches the handler before the release joins its flight.
|
||||
waitFor(t, func() bool { return a.hitCount(factsPath) >= 1 })
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
for i, code := range codes {
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("caller %d got %d", i, code)
|
||||
}
|
||||
}
|
||||
if got := a.hitCount(factsPath); got != 1 {
|
||||
t.Errorf("backend a saw %d requests, want 1 for %d concurrent callers", got, callers)
|
||||
}
|
||||
if got := b.hitCount(factsPath); got != 1 {
|
||||
t.Errorf("backend b saw %d requests, want 1 for %d concurrent callers", got, callers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlightGroup_LeaderRunsOnce(t *testing.T) {
|
||||
var g flightGroup
|
||||
var calls, shared atomic.Int64
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
run := func() (cachedResponse, error) {
|
||||
if calls.Add(1) == 1 {
|
||||
close(entered)
|
||||
}
|
||||
<-release
|
||||
return cachedResponse{Body: json.RawMessage(`[]`), Records: -1}, nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 16 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, _, s := g.Do("k", run); s {
|
||||
shared.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
<-entered
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("fn ran %d times, want 1", calls.Load())
|
||||
}
|
||||
if shared.Load() != 15 {
|
||||
t.Errorf("%d callers shared the flight, want 15", shared.Load())
|
||||
}
|
||||
// The key is released once the flight finishes.
|
||||
if _, _, s := g.Do("k", func() (cachedResponse, error) { return cachedResponse{Records: -1}, nil }); s {
|
||||
t.Error("a later call must start its own flight")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlightGroup_LeaderPanicFailsLeaderAndWaiters(t *testing.T) {
|
||||
var g flightGroup
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
type result struct {
|
||||
resp cachedResponse
|
||||
err error
|
||||
shared bool
|
||||
}
|
||||
waiters := make([]result, 8)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
var leader result
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
leader.resp, leader.err, leader.shared = g.Do("k", func() (cachedResponse, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
panic("build exploded")
|
||||
})
|
||||
}()
|
||||
|
||||
<-entered
|
||||
for i := range waiters {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
waiters[i].resp, waiters[i].err, waiters[i].shared = g.Do("k", func() (cachedResponse, error) {
|
||||
t.Error("a waiter must not run its own fn")
|
||||
return cachedResponse{}, nil
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
// Park the waiters in Wait() before the leader panics.
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if leader.err == nil {
|
||||
t.Errorf("leader err = nil, want a panic error (resp %+v)", leader.resp)
|
||||
}
|
||||
for i, w := range waiters {
|
||||
if !w.shared {
|
||||
t.Errorf("waiter %d did not share the flight", i)
|
||||
}
|
||||
if w.err == nil {
|
||||
t.Fatalf("waiter %d unblocked with err = nil and resp %+v, want an error", i, w.resp)
|
||||
}
|
||||
if !strings.Contains(w.err.Error(), "build exploded") {
|
||||
t.Errorf("waiter %d err = %v, want the panic value", i, w.err)
|
||||
}
|
||||
}
|
||||
|
||||
// The key is released on the panic path, so a later call leads its own flight.
|
||||
resp, err, shared := g.Do("k", func() (cachedResponse, error) {
|
||||
return cachedResponse{Body: json.RawMessage(`[]`), Records: 0}, nil
|
||||
})
|
||||
if shared {
|
||||
t.Error("a call after a panicking flight must start its own flight")
|
||||
}
|
||||
if err != nil || resp.Records != 0 {
|
||||
t.Errorf("later call = (%+v, %v), want a clean success", resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeCached_BuildPanicIs502ThenStale(t *testing.T) {
|
||||
a := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||
b := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||
|
||||
panicBuild := func() (cachedResponse, error) { panic("build exploded") }
|
||||
serve := func(build func() (cachedResponse, error)) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, factsPath, nil)
|
||||
srv.serveCached(rec, req, factsPath, nil, build)
|
||||
return rec
|
||||
}
|
||||
|
||||
// No entry to fall back to: a panicking build must not look like a success.
|
||||
rec := serve(panicBuild)
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status %d (%s), want 502", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
stored := `[` + fact("h1", "role", "web", "") + `]`
|
||||
rec = serve(func() (cachedResponse, error) {
|
||||
return cachedResponse{Body: json.RawMessage(stored), Records: 1}, nil
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("priming status %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
clk.advance(31 * time.Second)
|
||||
rec = serve(panicBuild)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("stale fallback status %d (%s), want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := strings.TrimSpace(rec.Body.String()); got != stored {
|
||||
t.Errorf("stale body = %s, want %s", got, stored)
|
||||
}
|
||||
if got := rec.Header().Get(recordsHeader); got != "1" {
|
||||
t.Errorf("%s = %q, want 1", recordsHeader, got)
|
||||
}
|
||||
if hr := health(t, srv); !hr.Cache.ServingStale || hr.Cache.StaleServed != 1 {
|
||||
t.Errorf("panic fallback not counted as stale: %+v", hr.Cache)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeCached_BuildPanicFailsConcurrentWaiters(t *testing.T) {
|
||||
a := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||
b := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
serve := func(build func() (cachedResponse, error)) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, factsPath, nil)
|
||||
srv.serveCached(rec, req, factsPath, nil, build)
|
||||
return rec
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
var leaderRec *httptest.ResponseRecorder
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
leaderRec = serve(func() (cachedResponse, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
panic("build exploded")
|
||||
})
|
||||
}()
|
||||
|
||||
<-entered
|
||||
const waiters = 8
|
||||
recs := make([]*httptest.ResponseRecorder, waiters)
|
||||
for i := range waiters {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
recs[i] = serve(func() (cachedResponse, error) {
|
||||
t.Error("a waiter must not run its own build")
|
||||
return cachedResponse{}, nil
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if leaderRec.Code != http.StatusBadGateway {
|
||||
t.Errorf("leader status %d, want 502", leaderRec.Code)
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("waiter %d got %d (%s), want 502 rather than an empty success",
|
||||
i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_HealthzReportsCacheState(t *testing.T) {
|
||||
a := newCountingBackend(t, map[string]string{factsPath: `[` + fact("h1", "role", "web", "") + `]`})
|
||||
b := newCountingBackend(t, map[string]string{factsPath: `[]`})
|
||||
srv, clk := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||
|
||||
hr := health(t, srv)
|
||||
if hr.Cache.Backend != "memory" || hr.Cache.TTL != "30s" {
|
||||
t.Fatalf("cache health = %+v, want memory/30s", hr.Cache)
|
||||
}
|
||||
if hr.Cache.Entries != 0 || hr.Cache.ServingStale {
|
||||
t.Fatalf("a cold cache should be empty and not stale: %+v", hr.Cache)
|
||||
}
|
||||
|
||||
doGet(t, srv.Handler(), factsPath, "")
|
||||
hr = health(t, srv)
|
||||
if hr.Cache.Entries != 1 || hr.Cache.StaleEntries != 0 || hr.Cache.Bytes == 0 {
|
||||
t.Fatalf("after one request: %+v", hr.Cache)
|
||||
}
|
||||
|
||||
clk.advance(31 * time.Second)
|
||||
if hr = health(t, srv); hr.Cache.StaleEntries != 1 {
|
||||
t.Fatalf("expired entry should count as stale: %+v", hr.Cache)
|
||||
}
|
||||
if hr.Cache.ServingStale {
|
||||
t.Error("an expired entry alone is not serving_stale")
|
||||
}
|
||||
|
||||
a.setFail(true)
|
||||
b.setFail(true)
|
||||
doGet(t, srv.Handler(), factsPath, "")
|
||||
hr = health(t, srv)
|
||||
if !hr.Cache.ServingStale || hr.Cache.StaleServed != 1 || hr.Cache.LastStale == "" {
|
||||
t.Errorf("staleness not surfaced in /healthz: %+v", hr.Cache)
|
||||
}
|
||||
if hr.Status != "down" {
|
||||
t.Errorf("status = %q, want down", hr.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_HealthzReportsDisabledCache(t *testing.T) {
|
||||
a := newCountingBackend(t, nil)
|
||||
b := newCountingBackend(t, nil)
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic)) // FactsTTL zero
|
||||
|
||||
hr := health(t, srv)
|
||||
if hr.Cache.Backend != "none" || hr.Cache.TTL != "0" {
|
||||
t.Errorf("disabled cache health = %+v, want none/0", hr.Cache)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_UncachedPathsStillRefetch(t *testing.T) {
|
||||
a := newCountingBackend(t, map[string]string{reportsPath: `[{"hash":"h"}]`})
|
||||
b := newCountingBackend(t, map[string]string{reportsPath: `[]`})
|
||||
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||
|
||||
doGet(t, srv.Handler(), reportsPath, "")
|
||||
doGet(t, srv.Handler(), reportsPath, "")
|
||||
if got := a.hitCount(reportsPath); got != 2 {
|
||||
t.Errorf("/reports is uncached: %d requests, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_NodesRecordSetCached(t *testing.T) {
|
||||
a := newCountingBackend(t, map[string]string{nodesPath: `[{"certname":"h1"}]`})
|
||||
b := newCountingBackend(t, map[string]string{nodesPath: `[]`})
|
||||
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||
|
||||
doGet(t, srv.Handler(), nodesPath, "")
|
||||
doGet(t, srv.Handler(), nodesPath, "")
|
||||
if got := a.hitCount(nodesPath); got != 1 {
|
||||
t.Errorf("merged /nodes is cached: %d requests, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_NodesAggregateNotCached(t *testing.T) {
|
||||
a := newCountingBackend(t, map[string]string{nodesPath: `[{"count":90}]`})
|
||||
b := newCountingBackend(t, map[string]string{nodesPath: `[{"count":53}]`})
|
||||
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||
|
||||
first := doGet(t, srv.Handler(), nodesPath, nodeCountQuery)
|
||||
second := doGet(t, srv.Handler(), nodesPath, nodeCountQuery)
|
||||
if first.Code != http.StatusOK || second.Code != http.StatusOK {
|
||||
t.Fatalf("statuses %d/%d", first.Code, second.Code)
|
||||
}
|
||||
if got := a.hitCount(nodesPath); got != 2 {
|
||||
t.Errorf("/nodes aggregates are uncached: %d requests, want 2", got)
|
||||
}
|
||||
if got := counts(t, second.Body.Bytes(), "count"); !slices.Equal(got, []float64{143}) {
|
||||
t.Errorf("count = %v, want [143]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ResourcesAggregateNotCached(t *testing.T) {
|
||||
a := newCountingBackend(t, map[string]string{resourcesPath: `[{"count":7}]`})
|
||||
b := newCountingBackend(t, map[string]string{resourcesPath: `[{"count":5}]`})
|
||||
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||
|
||||
doGet(t, srv.Handler(), resourcesPath, nodeCountQuery)
|
||||
doGet(t, srv.Handler(), resourcesPath, nodeCountQuery)
|
||||
if got := a.hitCount(resourcesPath); got != 2 {
|
||||
t.Errorf("/resources aggregates are uncached: %d requests, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_MetaAndMetricsNotCached(t *testing.T) {
|
||||
bodies := map[string]string{
|
||||
metaVersionPath: `{"version":"8.4.0"}`,
|
||||
metaServerTimePath: `{"server_time":"2026-01-01T00:00:00.000Z"}`,
|
||||
"/metrics/v2/read/x": `{"request":{},"value":{"Value":1},"status":200}`,
|
||||
}
|
||||
a := newCountingBackend(t, bodies)
|
||||
b := newCountingBackend(t, bodies)
|
||||
srv, _ := newCachedServer(t, cacheTestConfig(a.srv.URL, b.srv.URL))
|
||||
|
||||
for path := range bodies {
|
||||
doGet(t, srv.Handler(), path, "")
|
||||
doGet(t, srv.Handler(), path, "")
|
||||
if got := a.hitCount(path); got != 2 {
|
||||
t.Errorf("%s is uncached: %d requests, want 2", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_FactsTTLClampedAtCap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
clearEnv(t)
|
||||
|
||||
path := filepath.Join(dir, appName, configFileName)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("facts_ttl: 5m\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.FactsTTL != maxFactsTTL {
|
||||
t.Errorf("file facts_ttl = %s, want it clamped to %s", cfg.FactsTTL, maxFactsTTL)
|
||||
}
|
||||
|
||||
t.Setenv(envPrefix+"FACTS_TTL", "10m")
|
||||
cfg, err = Load("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.FactsTTL != maxFactsTTL {
|
||||
t.Errorf("env facts_ttl = %s, want it clamped to %s", cfg.FactsTTL, maxFactsTTL)
|
||||
}
|
||||
if cfg.factsTTLClamped != 10*time.Minute {
|
||||
t.Errorf("pre-clamp value = %s, want 10m0s", cfg.factsTTLClamped)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() { printConfig(cfg) })
|
||||
if !strings.Contains(out, "facts_ttl : 30s (clamped from 600s, cap 30s)") {
|
||||
t.Errorf("config show must report the clamp, got:\n%s", out)
|
||||
}
|
||||
|
||||
t.Setenv(envPrefix+"FACTS_TTL", "5s")
|
||||
cfg, err = Load("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.FactsTTL != 5*time.Second || cfg.factsTTLClamped != 0 {
|
||||
t.Errorf("a value under the cap must pass through: %s / %s", cfg.FactsTTL, cfg.factsTTLClamped)
|
||||
}
|
||||
|
||||
t.Setenv(envPrefix+"FACTS_TTL", "0s")
|
||||
cfg, err = Load("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.cacheEnabled() {
|
||||
t.Error("facts_ttl 0 must disable the cache")
|
||||
}
|
||||
out = captureStdout(t, func() { printConfig(cfg) })
|
||||
if !strings.Contains(out, "(cache disabled)") {
|
||||
t.Errorf("config show must say the cache is off, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServer_ClampsFactsTTL(t *testing.T) {
|
||||
cfg := testConfig("http://a.invalid", "http://b.invalid", mergeStatic)
|
||||
cfg.FactsTTL = time.Hour
|
||||
cfg.CacheBytes = 1 << 20
|
||||
srv := newTestServer(cfg)
|
||||
|
||||
mc, ok := srv.factsCache.(*memoryCache)
|
||||
if !ok {
|
||||
t.Fatalf("expected a memory cache, got %T", srv.factsCache)
|
||||
}
|
||||
if mc.ttl != maxFactsTTL {
|
||||
t.Errorf("cache ttl = %s, want %s", mc.ttl, maxFactsTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_ValidateRejectsNegativeCacheSettings(t *testing.T) {
|
||||
base := testConfig("http://a.invalid", "http://b.invalid", mergeStatic)
|
||||
for name, mutate := range map[string]func(*Config){
|
||||
"negative facts_ttl": func(c *Config) { c.FactsTTL = -time.Second },
|
||||
"negative facts_cache_bytes": func(c *Config) { c.CacheBytes = -1 },
|
||||
} {
|
||||
cfg := base
|
||||
mutate(&cfg)
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Errorf("%s should not validate", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func health(t *testing.T, srv *Server) healthReport {
|
||||
t.Helper()
|
||||
rec := doGet(t, srv.Handler(), "/healthz", "")
|
||||
var hr healthReport
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &hr); err != nil {
|
||||
t.Fatalf("decode healthz: %v (%s)", err, rec.Body.String())
|
||||
}
|
||||
return hr
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !cond() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("condition not met within 5s")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -15,130 +15,162 @@ const (
|
||||
appName = "pdbmux"
|
||||
configFileName = "config.yaml"
|
||||
envPrefix = "PDBMUX_"
|
||||
envConfigPath = envPrefix + "CONFIG"
|
||||
|
||||
// systemConfigDir is the last resort in the search order, and the path a
|
||||
// container mount (configmap, secret) is expected to land on.
|
||||
systemConfigDir = "/etc/" + appName
|
||||
|
||||
// defaultListen is the default HTTP listen address.
|
||||
defaultListen = ":8080"
|
||||
// defaultOldURL / defaultNewURL are the two PuppetDBs merged during the
|
||||
// VM -> k8s migration. old = legacy Consul-registered puppetdbapi; new =
|
||||
// the k8s PuppetDB behind the gateway (TLS terminated there).
|
||||
defaultOldURL = "http://puppetdbapi.service.consul:8080"
|
||||
defaultNewURL = "https://puppetdb.k8s.syd1.au.unkin.net"
|
||||
// defaultPrimary is the backend name used for pass-through (non-merged)
|
||||
// /pdb/query/v4/* paths and as static precedence for merge fallback.
|
||||
defaultPrimary = "new"
|
||||
|
||||
defaultTimeout = 10 * time.Second
|
||||
defaultFreshnessTTL = 30 * time.Second
|
||||
|
||||
// maxFactsTTL is a hard cap, not a default: a larger configured value is
|
||||
// clamped down to it rather than rejected, so a stray env var cannot make a
|
||||
// container crash-loop.
|
||||
maxFactsTTL = 30 * time.Second
|
||||
defaultFactsTTL = 30 * time.Second
|
||||
defaultCacheSize = int64(64 << 20)
|
||||
)
|
||||
|
||||
// Backend is one upstream PuppetDB. URL is the base URL (scheme://host[:port]),
|
||||
// without the /pdb/query/v4/... path — that is appended per request.
|
||||
type Backend struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
var exampleBackends = []Backend{
|
||||
{Name: "pdb-a", URL: "http://puppetdb1.example.com:8080"},
|
||||
{Name: "pdb-b", URL: "http://puppetdb2.example.com:8080"},
|
||||
}
|
||||
|
||||
// Config holds every configurable value. Fields map 1:1 to config-file keys and
|
||||
// env vars (PDBMUX_*). See Load for precedence.
|
||||
type Config struct {
|
||||
// Listen is the HTTP listen address (host:port).
|
||||
Listen string `yaml:"listen"`
|
||||
// Backends is the ordered list of upstream PuppetDBs to fan out to.
|
||||
Backends []Backend `yaml:"backends"`
|
||||
// Primary is the backend Name used for transparent pass-through of
|
||||
// non-merged /pdb/query/v4/* paths.
|
||||
Primary string `yaml:"primary"`
|
||||
// Merge selects how /facts records are attributed to a backend when a
|
||||
// certname appears in both: "freshness" (query /nodes report_timestamp,
|
||||
// newer wins) or "static" (always prefer the Prefer backend).
|
||||
Merge string `yaml:"merge"`
|
||||
// Prefer names the backend that wins under static merge and as the
|
||||
// tie-breaker/fallback under freshness merge.
|
||||
Prefer string `yaml:"prefer"`
|
||||
// Timeout bounds each upstream request.
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
// FreshnessTTL is how long a per-certname freshness map (from /nodes) is
|
||||
// cached under the "freshness" merge strategy.
|
||||
FreshnessTTL time.Duration `yaml:"freshness_ttl"`
|
||||
type Backend struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"` // base URL only; the query path is appended per request
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Listen string `yaml:"listen"`
|
||||
Backends []Backend `yaml:"backends"` // all equal; order is only a deterministic tie-break
|
||||
Merge string `yaml:"merge"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
FreshnessTTL time.Duration `yaml:"freshness_ttl"`
|
||||
FactsTTL time.Duration `yaml:"facts_ttl"` // 0 disables the /facts+/nodes cache
|
||||
CacheBytes int64 `yaml:"facts_cache_bytes"` // byte budget for that cache
|
||||
|
||||
sourcePath string // file this config was read from, empty if none was found
|
||||
factsTTLClamped time.Duration // pre-clamp facts_ttl, zero when nothing was clamped
|
||||
}
|
||||
|
||||
// SourcePath returns the config file Load read, or "" when none was loaded.
|
||||
func (c Config) SourcePath() string { return c.sourcePath }
|
||||
|
||||
const (
|
||||
mergeFreshness = "freshness"
|
||||
mergeStatic = "static"
|
||||
)
|
||||
|
||||
// DefaultConfig returns the built-in defaults: both migration PuppetDBs,
|
||||
// freshness merge, "new" primary/preferred.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Listen: defaultListen,
|
||||
Backends: []Backend{
|
||||
{Name: "old", URL: defaultOldURL},
|
||||
{Name: "new", URL: defaultNewURL},
|
||||
},
|
||||
Primary: defaultPrimary,
|
||||
Listen: defaultListen,
|
||||
Merge: mergeFreshness,
|
||||
Prefer: defaultPrimary,
|
||||
Timeout: defaultTimeout,
|
||||
FreshnessTTL: defaultFreshnessTTL,
|
||||
FactsTTL: defaultFactsTTL,
|
||||
CacheBytes: defaultCacheSize,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigDir returns the XDG_CONFIG_HOME/pdbmux directory.
|
||||
func ExampleConfig() Config {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Backends = append([]Backend(nil), exampleBackends...)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func ConfigDir() string {
|
||||
base := os.Getenv("XDG_CONFIG_HOME")
|
||||
if base == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
return systemConfigDir
|
||||
}
|
||||
base = filepath.Join(home, ".config")
|
||||
}
|
||||
return filepath.Join(base, appName)
|
||||
}
|
||||
|
||||
// ConfigPath returns the full path to the config file.
|
||||
func ConfigPath() string {
|
||||
return filepath.Join(ConfigDir(), configFileName)
|
||||
}
|
||||
|
||||
// Load reads the config file (if present), then applies env var overrides.
|
||||
// Precedence (lowest -> highest): defaults < config file < env vars < flags
|
||||
// (flags are applied by the caller). Backends can be overridden wholesale via
|
||||
// PDBMUX_BACKENDS ("name=url,name=url").
|
||||
func Load() (Config, error) {
|
||||
// configSearchPaths lists the default config locations, highest priority first.
|
||||
func configSearchPaths() []string {
|
||||
paths := []string{ConfigPath()}
|
||||
if system := filepath.Join(systemConfigDir, configFileName); system != paths[0] {
|
||||
paths = append(paths, system)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// explicitConfigPath returns the config path named by --config or PDBMUX_CONFIG,
|
||||
// or "" when neither is set.
|
||||
func explicitConfigPath(flagPath string) string {
|
||||
if flagPath != "" {
|
||||
return flagPath
|
||||
}
|
||||
return os.Getenv(envConfigPath)
|
||||
}
|
||||
|
||||
// resolveConfigPath picks the config file to read: --config, else PDBMUX_CONFIG,
|
||||
// else the first existing default search path. explicit reports whether the path
|
||||
// was named outright, in which case a missing file is an error.
|
||||
func resolveConfigPath(flagPath string) (path string, explicit bool) {
|
||||
if p := explicitConfigPath(flagPath); p != "" {
|
||||
return p, true
|
||||
}
|
||||
paths := configSearchPaths()
|
||||
for _, p := range paths {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p, false
|
||||
}
|
||||
}
|
||||
return paths[0], false
|
||||
}
|
||||
|
||||
// Precedence: defaults < config file < env vars < flags, and flags are applied by the caller.
|
||||
func Load(flagPath string) (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
path := ConfigPath()
|
||||
path, explicit := resolveConfigPath(flagPath)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return cfg, fmt.Errorf("reading config %s: %w", path, err)
|
||||
}
|
||||
if err == nil {
|
||||
switch {
|
||||
case err == nil:
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("parsing config %s: %w", path, err)
|
||||
}
|
||||
cfg.sourcePath = path
|
||||
case os.IsNotExist(err) && !explicit:
|
||||
// No config file anywhere on the search path: defaults + env only.
|
||||
default:
|
||||
return cfg, fmt.Errorf("reading config %s: %w", path, err)
|
||||
}
|
||||
|
||||
applyEnv(&cfg, os.Getenv)
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg.clampFactsTTL()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// applyEnv overlays PDBMUX_* env vars onto cfg. getenv is injected for testing.
|
||||
// clampFactsTTL pins facts_ttl to maxFactsTTL, remembering the configured value
|
||||
// so `config show` can say the cap was applied.
|
||||
func (c *Config) clampFactsTTL() {
|
||||
if c.FactsTTL > maxFactsTTL {
|
||||
c.factsTTLClamped = c.FactsTTL
|
||||
c.FactsTTL = maxFactsTTL
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnv(cfg *Config, getenv func(string) string) {
|
||||
if v := getenv(envPrefix + "LISTEN"); v != "" {
|
||||
cfg.Listen = v
|
||||
}
|
||||
if v := getenv(envPrefix + "PRIMARY"); v != "" {
|
||||
cfg.Primary = v
|
||||
}
|
||||
if v := getenv(envPrefix + "MERGE"); v != "" {
|
||||
cfg.Merge = v
|
||||
}
|
||||
if v := getenv(envPrefix + "PREFER"); v != "" {
|
||||
cfg.Prefer = v
|
||||
}
|
||||
if v := getenv(envPrefix + "TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
cfg.Timeout = d
|
||||
@@ -149,6 +181,16 @@ func applyEnv(cfg *Config, getenv func(string) string) {
|
||||
cfg.FreshnessTTL = d
|
||||
}
|
||||
}
|
||||
if v := getenv(envPrefix + "FACTS_TTL"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
cfg.FactsTTL = d
|
||||
}
|
||||
}
|
||||
if v := getenv(envPrefix + "FACTS_CACHE_BYTES"); v != "" {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
cfg.CacheBytes = n
|
||||
}
|
||||
}
|
||||
if v := getenv(envPrefix + "BACKENDS"); v != "" {
|
||||
if bs := parseBackends(v); len(bs) > 0 {
|
||||
cfg.Backends = bs
|
||||
@@ -156,8 +198,7 @@ func applyEnv(cfg *Config, getenv func(string) string) {
|
||||
}
|
||||
}
|
||||
|
||||
// parseBackends parses "name=url,name=url" into Backends. Entries without an
|
||||
// "=" are skipped. Used for the PDBMUX_BACKENDS env override.
|
||||
// Parses the PDBMUX_BACKENDS form "name=url,name=url"; entries without an "=" are skipped.
|
||||
func parseBackends(s string) []Backend {
|
||||
var out []Backend
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
@@ -175,10 +216,19 @@ func parseBackends(s string) []Backend {
|
||||
return out
|
||||
}
|
||||
|
||||
// Validate checks the config is internally consistent and usable.
|
||||
// configHint names the file a user should edit: the one actually loaded, else
|
||||
// the default write target.
|
||||
func (c Config) configHint() string {
|
||||
if c.sourcePath != "" {
|
||||
return c.sourcePath
|
||||
}
|
||||
return ConfigPath()
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if len(c.Backends) == 0 {
|
||||
return fmt.Errorf("no backends configured")
|
||||
return fmt.Errorf("no backends configured: set %sBACKENDS to \"name=url,name=url\" or add a backends list to %s",
|
||||
envPrefix, c.configHint())
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, b := range c.Backends {
|
||||
@@ -190,49 +240,43 @@ func (c Config) Validate() error {
|
||||
}
|
||||
seen[b.Name] = true
|
||||
}
|
||||
if !seen[c.Primary] {
|
||||
return fmt.Errorf("primary %q is not a configured backend", c.Primary)
|
||||
}
|
||||
switch c.Merge {
|
||||
case mergeFreshness, mergeStatic:
|
||||
default:
|
||||
return fmt.Errorf("merge must be %q or %q, got %q", mergeFreshness, mergeStatic, c.Merge)
|
||||
}
|
||||
if !seen[c.Prefer] {
|
||||
return fmt.Errorf("prefer %q is not a configured backend", c.Prefer)
|
||||
}
|
||||
if c.Timeout <= 0 {
|
||||
return fmt.Errorf("timeout must be positive")
|
||||
}
|
||||
if c.FactsTTL < 0 {
|
||||
return fmt.Errorf("facts_ttl must not be negative (0 disables the cache)")
|
||||
}
|
||||
if c.CacheBytes < 0 {
|
||||
return fmt.Errorf("facts_cache_bytes must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrimaryBackend returns the backend named by Primary (guaranteed present after
|
||||
// Validate).
|
||||
func (c Config) PrimaryBackend() Backend {
|
||||
for _, b := range c.Backends {
|
||||
if b.Name == c.Primary {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return c.Backends[0]
|
||||
}
|
||||
// cacheEnabled reports whether a facts/nodes cache should be built: both a TTL
|
||||
// and a byte budget are required.
|
||||
func (c Config) cacheEnabled() bool { return c.FactsTTL > 0 && c.CacheBytes > 0 }
|
||||
|
||||
// writeDefaultConfig creates the config dir and writes a default config file.
|
||||
func writeDefaultConfig() error {
|
||||
dir := ConfigDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
func writeDefaultConfig(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("creating config dir: %w", err)
|
||||
}
|
||||
path := ConfigPath()
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return fmt.Errorf("config already exists at %s", path)
|
||||
}
|
||||
data, _ := yaml.Marshal(DefaultConfig())
|
||||
data, _ := yaml.Marshal(ExampleConfig())
|
||||
header := []byte("# pdbmux configuration\n" +
|
||||
"# A merging proxy over two PuppetDBs (old Consul + new k8s) during migration.\n" +
|
||||
"# Env overrides: PDBMUX_LISTEN, PDBMUX_PRIMARY, PDBMUX_MERGE, PDBMUX_PREFER,\n" +
|
||||
"# PDBMUX_TIMEOUT, PDBMUX_FRESHNESS_TTL, PDBMUX_BACKENDS (name=url,name=url).\n\n")
|
||||
"# A merging proxy presenting one PuppetDB v4 query surface over several\n" +
|
||||
"# PuppetDB backends. The backend URLs below are placeholders — edit them.\n" +
|
||||
"# Env overrides: PDBMUX_LISTEN, PDBMUX_MERGE, PDBMUX_TIMEOUT,\n" +
|
||||
"# PDBMUX_FRESHNESS_TTL, PDBMUX_FACTS_TTL, PDBMUX_FACTS_CACHE_BYTES,\n" +
|
||||
"# PDBMUX_BACKENDS (name=url,name=url).\n" +
|
||||
"# facts_ttl caches merged /facts and /nodes in memory; it is capped at 30s\n" +
|
||||
"# (a larger value is clamped) and 0 disables the cache.\n\n")
|
||||
if err := os.WriteFile(path, append(header, data...), 0o644); err != nil {
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
}
|
||||
@@ -240,8 +284,6 @@ func writeDefaultConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// durationString renders a duration for `config show` (falls back to a plain
|
||||
// seconds count for zero to avoid "0s" ambiguity in logs).
|
||||
func durationString(d time.Duration) string {
|
||||
if d == 0 {
|
||||
return "0"
|
||||
|
||||
+298
-27
@@ -1,28 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
clearEnv(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
// testConfigValid returns a minimal valid config for Validate/merge tests, with
|
||||
// neutral placeholder backends.
|
||||
func testConfigValid() Config {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Backends = []Backend{
|
||||
{Name: "a", URL: "http://localhost:18080"},
|
||||
{Name: "b", URL: "http://localhost:18081"},
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestDefaultConfig_NoBackends(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Listen != defaultListen {
|
||||
t.Errorf("listen = %q, want %q", cfg.Listen, defaultListen)
|
||||
}
|
||||
if len(cfg.Backends) != 2 || cfg.Backends[0].Name != "old" || cfg.Backends[1].Name != "new" {
|
||||
t.Errorf("unexpected default backends: %+v", cfg.Backends)
|
||||
if len(cfg.Backends) != 0 {
|
||||
t.Errorf("defaults must not ship backends, got %+v", cfg.Backends)
|
||||
}
|
||||
if cfg.Merge != mergeFreshness || cfg.Primary != "new" {
|
||||
t.Errorf("unexpected defaults merge=%s primary=%s", cfg.Merge, cfg.Primary)
|
||||
if cfg.Merge != mergeFreshness {
|
||||
t.Errorf("merge = %q, want %q", cfg.Merge, mergeFreshness)
|
||||
}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("defaults alone must not validate: backends are required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_NoBackendsLoadsButFailsValidation(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
clearEnv(t)
|
||||
|
||||
// Load itself must succeed so `config init` / `version` work unconfigured.
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
err = cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error when no backends are configured")
|
||||
}
|
||||
if !strings.Contains(err.Error(), envPrefix+"BACKENDS") {
|
||||
t.Errorf("error should name the env var to set, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_BackendsKeepConfiguredOrder(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
clearEnv(t)
|
||||
t.Setenv(envPrefix+"BACKENDS", "a=http://localhost:18080,b=http://localhost:18081")
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Backends) != 2 || cfg.Backends[0].Name != "a" || cfg.Backends[1].Name != "b" {
|
||||
t.Errorf("backends should keep the configured order, got %+v", cfg.Backends)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("a bare backend list must validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +80,8 @@ func TestLoad_FileAndEnvOverride(t *testing.T) {
|
||||
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := "listen: :9999\nmerge: static\nprimary: old\nprefer: old\n"
|
||||
body := "listen: :9999\nmerge: static\n" +
|
||||
"backends:\n - name: a\n url: http://localhost:18080\n - name: b\n url: http://localhost:18081\n"
|
||||
if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -43,24 +89,22 @@ func TestLoad_FileAndEnvOverride(t *testing.T) {
|
||||
// env beats file for listen.
|
||||
t.Setenv(envPrefix+"LISTEN", "127.0.0.1:1234")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.Listen != "127.0.0.1:1234" {
|
||||
t.Errorf("env should beat file for listen, got %q", cfg.Listen)
|
||||
}
|
||||
if cfg.Merge != mergeStatic || cfg.Primary != "old" {
|
||||
t.Errorf("file override failed: merge=%s primary=%s", cfg.Merge, cfg.Primary)
|
||||
if cfg.Merge != mergeStatic {
|
||||
t.Errorf("file override failed: merge=%s", cfg.Merge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnv_Backends(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg := testConfigValid()
|
||||
env := map[string]string{
|
||||
envPrefix + "BACKENDS": "a=http://a:8080,b=http://b:8080",
|
||||
envPrefix + "PRIMARY": "a",
|
||||
envPrefix + "PREFER": "a",
|
||||
envPrefix + "TIMEOUT": "3s",
|
||||
envPrefix + "FRESHNESS_TTL": "45s",
|
||||
}
|
||||
@@ -82,16 +126,14 @@ func TestValidate(t *testing.T) {
|
||||
}{
|
||||
{"ok", func(*Config) {}, false},
|
||||
{"no backends", func(c *Config) { c.Backends = nil }, true},
|
||||
{"dup name", func(c *Config) { c.Backends = append(c.Backends, Backend{Name: "old", URL: "x"}) }, true},
|
||||
{"dup name", func(c *Config) { c.Backends = append(c.Backends, Backend{Name: "a", URL: "x"}) }, true},
|
||||
{"missing url", func(c *Config) { c.Backends[0].URL = "" }, true},
|
||||
{"primary not a backend", func(c *Config) { c.Primary = "ghost" }, true},
|
||||
{"prefer not a backend", func(c *Config) { c.Prefer = "ghost" }, true},
|
||||
{"bad merge", func(c *Config) { c.Merge = "wrong" }, true},
|
||||
{"zero timeout", func(c *Config) { c.Timeout = 0 }, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg := testConfigValid()
|
||||
tc.mutate(&cfg)
|
||||
err := cfg.Validate()
|
||||
if (err != nil) != tc.wantErr {
|
||||
@@ -111,16 +153,245 @@ func TestParseBackends(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryBackend(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.PrimaryBackend().URL != defaultNewURL {
|
||||
t.Errorf("primary backend URL = %q, want %q", cfg.PrimaryBackend().URL, defaultNewURL)
|
||||
func TestExampleConfig_IsValidAndNeutral(t *testing.T) {
|
||||
cfg := ExampleConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("example config must validate: %v", err)
|
||||
}
|
||||
for _, b := range cfg.Backends {
|
||||
if !strings.Contains(b.URL, "example.com") {
|
||||
t.Errorf("example backend %q must use a placeholder host, got %q", b.Name, b.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testConfigBody = "listen: \":9999\"\nmerge: static\n" +
|
||||
"backends:\n - name: a\n url: http://localhost:18080\n - name: b\n url: http://localhost:18081\n"
|
||||
|
||||
func writeConfigFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(testConfigBody), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_FileOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
clearEnv(t)
|
||||
path := filepath.Join(dir, appName, configFileName)
|
||||
writeConfigFile(t, path)
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.Listen != ":9999" || cfg.Merge != mergeStatic || len(cfg.Backends) != 2 {
|
||||
t.Errorf("file values not applied: %+v", cfg)
|
||||
}
|
||||
if cfg.SourcePath() != path {
|
||||
t.Errorf("source path = %q, want %q", cfg.SourcePath(), path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EnvOnly_DefaultPathMissing(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
clearEnv(t)
|
||||
t.Setenv(envPrefix+"BACKENDS", "a=http://localhost:18080")
|
||||
t.Setenv(envPrefix+"LISTEN", "127.0.0.1:1234")
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("a missing default config file must not be an error: %v", err)
|
||||
}
|
||||
if cfg.SourcePath() != "" {
|
||||
t.Errorf("no file was loaded, source path should be empty, got %q", cfg.SourcePath())
|
||||
}
|
||||
if cfg.Listen != "127.0.0.1:1234" || len(cfg.Backends) != 1 {
|
||||
t.Errorf("env values not applied: %+v", cfg)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("env-only config should validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A mounted config file must load with neither HOME nor XDG_CONFIG_HOME set.
|
||||
func TestLoad_ExplicitPath(t *testing.T) {
|
||||
mounted := filepath.Join(t.TempDir(), "mounted.yaml")
|
||||
writeConfigFile(t, mounted)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flag string
|
||||
env string
|
||||
}{
|
||||
{"flag", mounted, ""},
|
||||
{"env", "", mounted},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
t.Setenv("HOME", "")
|
||||
clearEnv(t)
|
||||
t.Setenv(envConfigPath, tc.env)
|
||||
|
||||
cfg, err := Load(tc.flag)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.SourcePath() != mounted {
|
||||
t.Errorf("source path = %q, want %q", cfg.SourcePath(), mounted)
|
||||
}
|
||||
if cfg.Listen != ":9999" {
|
||||
t.Errorf("listen = %q, want :9999", cfg.Listen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_ExplicitPathMissingIsError(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "typo.yaml")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flag string
|
||||
env string
|
||||
}{
|
||||
{"flag", missing, ""},
|
||||
{"env", "", missing},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
clearEnv(t)
|
||||
t.Setenv(envConfigPath, tc.env)
|
||||
|
||||
_, err := Load(tc.flag)
|
||||
if err == nil {
|
||||
t.Fatal("an explicitly named config file that does not exist must fail loudly")
|
||||
}
|
||||
if !strings.Contains(err.Error(), missing) {
|
||||
t.Errorf("error should name the missing path, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_ExplicitFileStillLosesToEnv(t *testing.T) {
|
||||
mounted := filepath.Join(t.TempDir(), "mounted.yaml")
|
||||
writeConfigFile(t, mounted)
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
clearEnv(t)
|
||||
t.Setenv(envConfigPath, mounted)
|
||||
t.Setenv(envPrefix+"LISTEN", "127.0.0.1:1234")
|
||||
t.Setenv(envPrefix+"MERGE", mergeFreshness)
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.Listen != "127.0.0.1:1234" || cfg.Merge != mergeFreshness {
|
||||
t.Errorf("env must beat the file: %+v", cfg)
|
||||
}
|
||||
if len(cfg.Backends) != 2 {
|
||||
t.Errorf("unset env must leave file backends alone: %+v", cfg.Backends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigPath_Precedence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
clearEnv(t)
|
||||
defaultPath := filepath.Join(dir, appName, configFileName)
|
||||
|
||||
if got, explicit := resolveConfigPath(""); got != defaultPath || explicit {
|
||||
t.Errorf("no file anywhere: got %q explicit=%v, want %q false", got, explicit, defaultPath)
|
||||
}
|
||||
|
||||
writeConfigFile(t, defaultPath)
|
||||
if got, explicit := resolveConfigPath(""); got != defaultPath || explicit {
|
||||
t.Errorf("default search: got %q explicit=%v, want %q false", got, explicit, defaultPath)
|
||||
}
|
||||
|
||||
t.Setenv(envConfigPath, "/from/env.yaml")
|
||||
if got, explicit := resolveConfigPath(""); got != "/from/env.yaml" || !explicit {
|
||||
t.Errorf("env should beat the search path: got %q explicit=%v", got, explicit)
|
||||
}
|
||||
if got, explicit := resolveConfigPath("/from/flag.yaml"); got != "/from/flag.yaml" || !explicit {
|
||||
t.Errorf("flag should beat env: got %q explicit=%v", got, explicit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSearchPaths_EndsAtSystemDir(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
paths := configSearchPaths()
|
||||
want := filepath.Join(systemConfigDir, configFileName)
|
||||
if len(paths) != 2 || paths[1] != want {
|
||||
t.Errorf("search paths = %v, want the system path %q last", paths, want)
|
||||
}
|
||||
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
t.Setenv("HOME", "")
|
||||
if paths := configSearchPaths(); len(paths) != 1 || paths[0] != want {
|
||||
t.Errorf("without HOME/XDG the search path should be just %q, got %v", want, paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintConfig_ReportsSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
clearEnv(t)
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
printConfig(cfg)
|
||||
})
|
||||
if !strings.Contains(out, "none loaded") || !strings.Contains(out, filepath.Join(dir, appName, configFileName)) {
|
||||
t.Errorf("config show should report nothing was loaded and what it searched, got:\n%s", out)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, appName, configFileName)
|
||||
writeConfigFile(t, path)
|
||||
out = captureStdout(t, func() {
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
printConfig(cfg)
|
||||
})
|
||||
if !strings.Contains(out, path+" (loaded)") {
|
||||
t.Errorf("config show should name the loaded file, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, f func()) string {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orig := os.Stdout
|
||||
os.Stdout = w
|
||||
defer func() { os.Stdout = orig }()
|
||||
|
||||
f()
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func clearEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range []string{"LISTEN", "PRIMARY", "MERGE", "PREFER", "TIMEOUT", "FRESHNESS_TTL", "BACKENDS"} {
|
||||
for _, k := range []string{"CONFIG", "LISTEN", "MERGE", "TIMEOUT", "FRESHNESS_TTL", "FACTS_TTL", "FACTS_CACHE_BYTES", "BACKENDS"} {
|
||||
t.Setenv(envPrefix+k, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,4 @@
|
||||
// Command pdbmux is a small merging HTTP proxy over two PuppetDB backends.
|
||||
//
|
||||
// During the VM -> k8s Puppet migration there are two PuppetDBs — the legacy
|
||||
// Consul-registered one and the new k8s one — and nodes move between them as
|
||||
// they migrate. pdbmux presents a single merged PuppetDB v4 query surface so
|
||||
// node-lookup and pblastreport (and anything else) see one consistent view:
|
||||
//
|
||||
// - GET /pdb/query/v4/nodes — fan out to both backends, dedupe by certname,
|
||||
// keep the record with the newer report_timestamp.
|
||||
// - GET /pdb/query/v4/facts — fan out to both, and for a certname present in
|
||||
// both keep ALL facts from the backend holding that node's newer report
|
||||
// (freshness merge) or a static preferred backend (static merge).
|
||||
// - any other GET /pdb/query/v4/* — transparently proxied to the primary.
|
||||
// - GET /healthz — per-backend reachability.
|
||||
//
|
||||
// The query param is forwarded verbatim (PuppetDB AST JSON). If one backend
|
||||
// errors/times out, the other's results are served; only if both fail does a
|
||||
// merged endpoint return 502.
|
||||
// Command pdbmux is a small merging HTTP proxy over several PuppetDB backends.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -26,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -35,25 +19,30 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var (
|
||||
listen string
|
||||
primary string
|
||||
merge string
|
||||
cfg Config
|
||||
configPath string
|
||||
listen string
|
||||
merge string
|
||||
)
|
||||
|
||||
// Loaded lazily: --config is only known once cobra has parsed flags.
|
||||
loadConfig := func() error {
|
||||
c, err := Load(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg = c
|
||||
return nil
|
||||
}
|
||||
|
||||
serve := func(cmd *cobra.Command) error {
|
||||
if err := loadConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Flags().Changed("listen") {
|
||||
cfg.Listen = listen
|
||||
}
|
||||
if cmd.Flags().Changed("primary") {
|
||||
cfg.Primary = primary
|
||||
}
|
||||
if cmd.Flags().Changed("merge") {
|
||||
cfg.Merge = merge
|
||||
}
|
||||
@@ -65,19 +54,19 @@ func main() {
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: appName,
|
||||
Short: "Merging HTTP proxy over two PuppetDB backends.",
|
||||
Long: "pdbmux presents a single merged PuppetDB v4 query surface over the old\n" +
|
||||
"(Consul) and new (k8s) PuppetDBs during the migration, so node-lookup and\n" +
|
||||
"pblastreport see one consistent view. Running pdbmux with no subcommand\n" +
|
||||
"(or `pdbmux serve`) starts the proxy.",
|
||||
Short: "Merging HTTP proxy over several PuppetDB backends.",
|
||||
Long: "pdbmux presents a single merged PuppetDB v4 query surface over several\n" +
|
||||
"PuppetDB backends, so clients see one consistent view of nodes, facts and\n" +
|
||||
"reports spanning all of them. Running pdbmux with no subcommand (or\n" +
|
||||
"`pdbmux serve`) starts the proxy.",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return serve(cmd) },
|
||||
}
|
||||
|
||||
pf := root.PersistentFlags()
|
||||
pf.StringVar(&listen, "listen", cfg.Listen, "HTTP listen address (overrides config and PDBMUX_LISTEN)")
|
||||
pf.StringVar(&primary, "primary", cfg.Primary, "Primary backend name for non-merged pass-through")
|
||||
pf.StringVar(&merge, "merge", cfg.Merge, "Facts merge strategy: freshness or static")
|
||||
pf.StringVar(&configPath, "config", "", "Config file path (overrides PDBMUX_CONFIG and the default search path)")
|
||||
pf.StringVar(&listen, "listen", defaultListen, "HTTP listen address (overrides config and PDBMUX_LISTEN)")
|
||||
pf.StringVar(&merge, "merge", mergeFreshness, "Facts merge strategy: freshness or static")
|
||||
|
||||
serveCmd := &cobra.Command{
|
||||
Use: "serve",
|
||||
@@ -90,15 +79,24 @@ func main() {
|
||||
configCmd.AddCommand(
|
||||
&cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Write a default config file to " + ConfigPath(),
|
||||
Short: "Write a default config file (--config path, else " + ConfigPath() + ")",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return writeDefaultConfig() },
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path := explicitConfigPath(configPath)
|
||||
if path == "" {
|
||||
path = ConfigPath()
|
||||
}
|
||||
return writeDefaultConfig(path)
|
||||
},
|
||||
},
|
||||
&cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Print the active configuration",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := loadConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
printConfig(cfg)
|
||||
return nil
|
||||
},
|
||||
@@ -119,8 +117,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// runServer starts the HTTP server and blocks until SIGINT/SIGTERM, then
|
||||
// gracefully shuts down.
|
||||
func runServer(cfg Config) error {
|
||||
logger := log.New(os.Stderr, "pdbmux: ", log.LstdFlags)
|
||||
srv := NewServer(cfg, logger)
|
||||
@@ -131,8 +127,8 @@ func runServer(cfg Config) error {
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
logger.Printf("listening on %s (merge=%s primary=%s backends=%d)",
|
||||
cfg.Listen, cfg.Merge, cfg.Primary, len(cfg.Backends))
|
||||
logger.Printf("listening on %s (merge=%s backends=%d)",
|
||||
cfg.Listen, cfg.Merge, len(cfg.Backends))
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -155,15 +151,30 @@ func runServer(cfg Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
// printConfig renders the active config for `config show`.
|
||||
func factsTTLString(cfg Config) string {
|
||||
s := durationString(cfg.FactsTTL)
|
||||
switch {
|
||||
case cfg.factsTTLClamped > 0:
|
||||
return fmt.Sprintf("%s (clamped from %s, cap %s)",
|
||||
s, durationString(cfg.factsTTLClamped), durationString(maxFactsTTL))
|
||||
case !cfg.cacheEnabled():
|
||||
return s + " (cache disabled)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func printConfig(cfg Config) {
|
||||
fmt.Printf("config file : %s\n", ConfigPath())
|
||||
if p := cfg.SourcePath(); p != "" {
|
||||
fmt.Printf("config file : %s (loaded)\n", p)
|
||||
} else {
|
||||
fmt.Printf("config file : none loaded (searched %s)\n", strings.Join(configSearchPaths(), ", "))
|
||||
}
|
||||
fmt.Printf("listen : %s\n", cfg.Listen)
|
||||
fmt.Printf("primary : %s\n", cfg.Primary)
|
||||
fmt.Printf("merge : %s\n", cfg.Merge)
|
||||
fmt.Printf("prefer : %s\n", cfg.Prefer)
|
||||
fmt.Printf("timeout : %s\n", durationString(cfg.Timeout))
|
||||
fmt.Printf("freshness_ttl: %s\n", durationString(cfg.FreshnessTTL))
|
||||
fmt.Printf("facts_ttl : %s\n", factsTTLString(cfg))
|
||||
fmt.Printf("facts_cache : %d bytes\n", cfg.CacheBytes)
|
||||
fmt.Println("backends:")
|
||||
for _, b := range cfg.Backends {
|
||||
fmt.Printf(" - %-8s %s\n", b.Name, b.URL)
|
||||
|
||||
@@ -5,24 +5,20 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// record is a single PuppetDB result element kept as raw JSON so unknown fields
|
||||
// survive the merge untouched. certname/report_timestamp are decoded only for
|
||||
// merge decisions.
|
||||
// Raw is kept verbatim so unknown PuppetDB fields survive the merge.
|
||||
type record struct {
|
||||
Raw json.RawMessage
|
||||
Certname string
|
||||
ReportTimestamp string // only populated for /nodes records
|
||||
Hash string // only populated for /reports records
|
||||
}
|
||||
|
||||
// recordMeta is the subset we decode from any /nodes or /facts element to drive
|
||||
// merge decisions.
|
||||
type recordMeta struct {
|
||||
Certname string `json:"certname"`
|
||||
ReportTimestamp string `json:"report_timestamp"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
// decodeRecords turns a raw PuppetDB JSON array into records, preserving each
|
||||
// element verbatim in Raw. A body that is not a JSON array yields (nil, err).
|
||||
func decodeRecords(body []byte) ([]record, error) {
|
||||
var raws []json.RawMessage
|
||||
if err := json.Unmarshal(body, &raws); err != nil {
|
||||
@@ -36,13 +32,13 @@ func decodeRecords(body []byte) ([]record, error) {
|
||||
Raw: raw,
|
||||
Certname: m.Certname,
|
||||
ReportTimestamp: m.ReportTimestamp,
|
||||
Hash: m.Hash,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseTimestamp parses a PuppetDB RFC3339(nano) timestamp. Zero time on
|
||||
// failure sorts oldest, so a backend with a well-formed newer timestamp wins.
|
||||
// An unparseable timestamp yields the zero time, which sorts oldest.
|
||||
func parseTimestamp(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
@@ -53,10 +49,7 @@ func parseTimestamp(s string) time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// mergeNodes dedupes /nodes records by certname, keeping the one with the newer
|
||||
// report_timestamp. backends is the ordered list of (name, records) results;
|
||||
// when timestamps tie (or both are zero), the earlier backend in the slice
|
||||
// wins, so callers should order by precedence.
|
||||
// Ties keep the earlier backend's record — a deterministic tie-break, not a preference.
|
||||
func mergeNodes(results []backendResult) []json.RawMessage {
|
||||
type pick struct {
|
||||
raw json.RawMessage
|
||||
@@ -73,7 +66,6 @@ func mergeNodes(results []backendResult) []json.RawMessage {
|
||||
order = append(order, rec.Certname)
|
||||
continue
|
||||
}
|
||||
// Strictly-newer wins; ties keep the existing (earlier-backend) pick.
|
||||
if ts.After(cur.ts) {
|
||||
best[rec.Certname] = pick{raw: rec.Raw, ts: ts}
|
||||
}
|
||||
@@ -86,12 +78,10 @@ func mergeNodes(results []backendResult) []json.RawMessage {
|
||||
return out
|
||||
}
|
||||
|
||||
// freshness maps certname -> backend name that holds that node's newest report.
|
||||
// certname -> name of the backend holding that node's newest report.
|
||||
type freshness map[string]string
|
||||
|
||||
// buildFreshness computes, per certname, which backend has the newer
|
||||
// report_timestamp. results must be ordered by precedence; on a tie the
|
||||
// earlier backend wins.
|
||||
// Ties keep the earlier backend — a deterministic tie-break, not a preference.
|
||||
func buildFreshness(results []backendResult) freshness {
|
||||
type pick struct {
|
||||
backend string
|
||||
@@ -114,19 +104,9 @@ func buildFreshness(results []backendResult) freshness {
|
||||
return f
|
||||
}
|
||||
|
||||
// mergeFacts merges /facts records at node granularity: for each certname, all
|
||||
// facts from the winning backend are kept and the other backend's facts for
|
||||
// that certname are dropped.
|
||||
//
|
||||
// The winner is chosen per certname by `owner(certname)`. Callers supply owner
|
||||
// from either a freshness map (freshness merge) or a constant preferred backend
|
||||
// (static merge). When owner returns a backend that has no facts for a certname
|
||||
// (or a name not in results), records fall back to precedence order so a node
|
||||
// present in only one backend still appears.
|
||||
// owner names the winning backend per certname; a nil owner (static merge), or one holding no facts for that certname, falls back to configured order.
|
||||
func mergeFacts(results []backendResult, owner func(certname string) string) []json.RawMessage {
|
||||
// Which backends actually returned facts for each certname, in precedence
|
||||
// order, so we can fall back if the chosen owner has none.
|
||||
present := map[string][]string{} // certname -> ordered backend names
|
||||
present := map[string][]string{} // certname -> backend names, in configured order
|
||||
byKey := map[string][]json.RawMessage{}
|
||||
for _, res := range results {
|
||||
for _, rec := range res.records {
|
||||
@@ -153,8 +133,10 @@ func mergeFacts(results []backendResult, owner func(certname string) string) []j
|
||||
out := []json.RawMessage{}
|
||||
for _, cn := range order {
|
||||
backends := present[cn]
|
||||
chosen := owner(cn)
|
||||
// Fall back to precedence order if the chosen backend has no facts here.
|
||||
chosen := ""
|
||||
if owner != nil {
|
||||
chosen = owner(cn)
|
||||
}
|
||||
if !contains(backends, chosen) {
|
||||
chosen = backends[0]
|
||||
}
|
||||
|
||||
+74
-59
@@ -74,11 +74,25 @@ func fact(cn, name, val, ts string) string {
|
||||
return `{"certname":"` + cn + `","name":"` + name + `","value":"` + val + `","report_timestamp":"` + ts + `"}`
|
||||
}
|
||||
|
||||
func TestMergeNodes_NewerWins(t *testing.T) {
|
||||
old := recs(t, "old", node("h1", "2026-07-01T00:00:00Z"), node("h2", "2026-07-10T00:00:00Z"))
|
||||
nw := recs(t, "new", node("h1", "2026-07-20T00:00:00Z"), node("h3", "2026-07-05T00:00:00Z"))
|
||||
// report builds a /reports record with the fields the merge and ordering paths
|
||||
// care about.
|
||||
func report(cn, hash, receive string) string {
|
||||
return `{"certname":"` + cn + `","hash":"` + hash + `","receive_time":"` + receive +
|
||||
`","end_time":"` + receive + `","status":"changed","environment":"production"}`
|
||||
}
|
||||
|
||||
merged := mergeNodes([]backendResult{old, nw})
|
||||
// event builds an /events record, which carries its report's hash but no id of
|
||||
// its own.
|
||||
func event(cn, reportHash, resource string) string {
|
||||
return `{"certname":"` + cn + `","report":"` + reportHash + `","resource_title":"` + resource +
|
||||
`","status":"success","timestamp":"2026-07-01T00:00:00Z"}`
|
||||
}
|
||||
|
||||
func TestMergeNodes_NewerWins(t *testing.T) {
|
||||
a := recs(t, "a", node("h1", "2026-07-01T00:00:00Z"), node("h2", "2026-07-10T00:00:00Z"))
|
||||
b := recs(t, "b", node("h1", "2026-07-20T00:00:00Z"), node("h3", "2026-07-05T00:00:00Z"))
|
||||
|
||||
merged := mergeNodes([]backendResult{a, b})
|
||||
got := map[string]string{}
|
||||
for _, r := range merged {
|
||||
var m recordMeta
|
||||
@@ -86,13 +100,13 @@ func TestMergeNodes_NewerWins(t *testing.T) {
|
||||
got[m.Certname] = m.ReportTimestamp
|
||||
}
|
||||
if got["h1"] != "2026-07-20T00:00:00Z" {
|
||||
t.Errorf("h1: newer (new) should win, got %s", got["h1"])
|
||||
t.Errorf("h1: newer (b) should win, got %s", got["h1"])
|
||||
}
|
||||
if got["h2"] != "2026-07-10T00:00:00Z" {
|
||||
t.Errorf("h2: only in old, got %s", got["h2"])
|
||||
t.Errorf("h2: only in a, got %s", got["h2"])
|
||||
}
|
||||
if got["h3"] != "2026-07-05T00:00:00Z" {
|
||||
t.Errorf("h3: only in new, got %s", got["h3"])
|
||||
t.Errorf("h3: only in b, got %s", got["h3"])
|
||||
}
|
||||
if len(merged) != 3 {
|
||||
t.Errorf("expected 3 deduped nodes, got %d", len(merged))
|
||||
@@ -100,20 +114,20 @@ func TestMergeNodes_NewerWins(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMergeNodes_OneBackendOnly(t *testing.T) {
|
||||
old := recs(t, "old", node("h1", "2026-07-01T00:00:00Z"))
|
||||
// new returned nothing (e.g. empty result).
|
||||
nw := backendResult{name: "new"}
|
||||
merged := mergeNodes([]backendResult{old, nw})
|
||||
a := recs(t, "a", node("h1", "2026-07-01T00:00:00Z"))
|
||||
// b returned nothing (e.g. empty result).
|
||||
b := backendResult{name: "b"}
|
||||
merged := mergeNodes([]backendResult{a, b})
|
||||
if len(merged) != 1 || certnames(t, merged)[0] != "h1" {
|
||||
t.Fatalf("expected only h1, got %v", certnames(t, merged))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeNodes_TieKeepsEarlierBackend(t *testing.T) {
|
||||
// Equal timestamps: the backend listed first (precedence) wins.
|
||||
prefer := recs(t, "new", node("h1", "2026-07-01T00:00:00Z"))
|
||||
other := recs(t, "old", node("h1", "2026-07-01T00:00:00Z"))
|
||||
merged := mergeNodes([]backendResult{prefer, other})
|
||||
// Equal timestamps: the backend listed first wins, as a tie-break.
|
||||
first := recs(t, "b", node("h1", "2026-07-01T00:00:00Z"))
|
||||
second := recs(t, "a", node("h1", "2026-07-01T00:00:00Z"))
|
||||
merged := mergeNodes([]backendResult{first, second})
|
||||
if len(merged) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(merged))
|
||||
}
|
||||
@@ -124,8 +138,8 @@ func TestMergeNodes_TieKeepsEarlierBackend(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMergeNodes_PreservesUnknownFields(t *testing.T) {
|
||||
old := recs(t, "old", `{"certname":"h1","report_timestamp":"2026-07-01T00:00:00Z","extra":{"deep":42}}`)
|
||||
merged := mergeNodes([]backendResult{old})
|
||||
a := recs(t, "a", `{"certname":"h1","report_timestamp":"2026-07-01T00:00:00Z","extra":{"deep":42}}`)
|
||||
merged := mergeNodes([]backendResult{a})
|
||||
if len(merged) != 1 {
|
||||
t.Fatalf("expected 1 record")
|
||||
}
|
||||
@@ -136,69 +150,70 @@ func TestMergeNodes_PreservesUnknownFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeFacts_Static_PreferWins(t *testing.T) {
|
||||
// h1 in both; static prefer=new -> new's facts kept, old's dropped.
|
||||
old := recs(t, "old", fact("h1", "role", "web-old", ""), fact("h2", "role", "db-old", ""))
|
||||
nw := recs(t, "new", fact("h1", "role", "web-new", ""))
|
||||
func TestMergeFacts_NilOwnerUsesConfiguredOrder(t *testing.T) {
|
||||
// Static merge passes no owner: h1 is in both, so the first backend in the
|
||||
// slice supplies its facts.
|
||||
a := recs(t, "a", fact("h1", "role", "web-a", ""), fact("h2", "role", "db-a", ""))
|
||||
b := recs(t, "b", fact("h1", "role", "web-b", ""))
|
||||
|
||||
merged := mergeFacts([]backendResult{nw, old}, func(string) string { return "new" })
|
||||
merged := mergeFacts([]backendResult{b, a}, nil)
|
||||
got := factValues(t, merged)
|
||||
assertContains(t, got, "h1:role=web-new")
|
||||
assertNotContains(t, got, "h1:role=web-old")
|
||||
// h2 only in old -> falls back to old.
|
||||
assertContains(t, got, "h2:role=db-old")
|
||||
assertContains(t, got, "h1:role=web-b")
|
||||
assertNotContains(t, got, "h1:role=web-a")
|
||||
// h2 only in a -> still served from a.
|
||||
assertContains(t, got, "h2:role=db-a")
|
||||
}
|
||||
|
||||
func TestMergeFacts_Freshness_NewerBackendWins(t *testing.T) {
|
||||
// owner map says h1 belongs to old (older backend has the newer report),
|
||||
// h2 belongs to new. Multiple facts per node must all come from the winner.
|
||||
old := recs(t, "old",
|
||||
fact("h1", "role", "web-old", ""), fact("h1", "ip", "10.0.0.1", ""),
|
||||
fact("h2", "role", "db-old", ""))
|
||||
nw := recs(t, "new",
|
||||
fact("h1", "role", "web-new", ""), fact("h1", "ip", "10.9.9.9", ""),
|
||||
fact("h2", "role", "db-new", ""), fact("h2", "ip", "10.0.0.2", ""))
|
||||
// owner map says h1 belongs to a and h2 to b. Multiple facts per node must
|
||||
// all come from the winner.
|
||||
a := recs(t, "a",
|
||||
fact("h1", "role", "web-a", ""), fact("h1", "ip", "10.0.0.1", ""),
|
||||
fact("h2", "role", "db-a", ""))
|
||||
b := recs(t, "b",
|
||||
fact("h1", "role", "web-b", ""), fact("h1", "ip", "10.9.9.9", ""),
|
||||
fact("h2", "role", "db-b", ""), fact("h2", "ip", "10.0.0.2", ""))
|
||||
|
||||
owner := func(cn string) string {
|
||||
if cn == "h1" {
|
||||
return "old"
|
||||
return "a"
|
||||
}
|
||||
return "new"
|
||||
return "b"
|
||||
}
|
||||
merged := mergeFacts([]backendResult{nw, old}, owner)
|
||||
merged := mergeFacts([]backendResult{b, a}, owner)
|
||||
got := factValues(t, merged)
|
||||
// h1 -> all old facts, no new facts.
|
||||
assertContains(t, got, "h1:role=web-old")
|
||||
// h1 -> all a facts, no b facts.
|
||||
assertContains(t, got, "h1:role=web-a")
|
||||
assertContains(t, got, "h1:ip=10.0.0.1")
|
||||
assertNotContains(t, got, "h1:role=web-new")
|
||||
assertNotContains(t, got, "h1:role=web-b")
|
||||
assertNotContains(t, got, "h1:ip=10.9.9.9")
|
||||
// h2 -> all new facts.
|
||||
assertContains(t, got, "h2:role=db-new")
|
||||
// h2 -> all b facts.
|
||||
assertContains(t, got, "h2:role=db-b")
|
||||
assertContains(t, got, "h2:ip=10.0.0.2")
|
||||
assertNotContains(t, got, "h2:role=db-old")
|
||||
assertNotContains(t, got, "h2:role=db-a")
|
||||
}
|
||||
|
||||
func TestMergeFacts_OwnerMissingFallsBackToPrecedence(t *testing.T) {
|
||||
// owner returns a backend with no facts for h1 -> fall back to first
|
||||
// backend present (precedence order of the slice).
|
||||
prefer := recs(t, "new", fact("h1", "role", "web-new", ""))
|
||||
other := recs(t, "old", fact("h1", "role", "web-old", ""))
|
||||
merged := mergeFacts([]backendResult{prefer, other}, func(string) string { return "ghost" })
|
||||
func TestMergeFacts_OwnerMissingFallsBackToConfiguredOrder(t *testing.T) {
|
||||
// owner returns a backend with no facts for h1 -> fall back to the first
|
||||
// backend in the slice that has some.
|
||||
first := recs(t, "b", fact("h1", "role", "web-b", ""))
|
||||
second := recs(t, "a", fact("h1", "role", "web-a", ""))
|
||||
merged := mergeFacts([]backendResult{first, second}, func(string) string { return "ghost" })
|
||||
got := factValues(t, merged)
|
||||
assertContains(t, got, "h1:role=web-new") // new is first in slice
|
||||
assertNotContains(t, got, "h1:role=web-old")
|
||||
assertContains(t, got, "h1:role=web-b") // b is first in slice
|
||||
assertNotContains(t, got, "h1:role=web-a")
|
||||
}
|
||||
|
||||
func TestBuildFreshness(t *testing.T) {
|
||||
// old has newer report for h1; new has newer for h2.
|
||||
old := recs(t, "old", node("h1", "2026-07-20T00:00:00Z"), node("h2", "2026-07-01T00:00:00Z"))
|
||||
nw := recs(t, "new", node("h1", "2026-07-01T00:00:00Z"), node("h2", "2026-07-20T00:00:00Z"))
|
||||
f := buildFreshness([]backendResult{old, nw})
|
||||
if f["h1"] != "old" {
|
||||
t.Errorf("h1 should belong to old, got %q", f["h1"])
|
||||
// a holds the newer report for h1; b holds the newer one for h2.
|
||||
a := recs(t, "a", node("h1", "2026-07-20T00:00:00Z"), node("h2", "2026-07-01T00:00:00Z"))
|
||||
b := recs(t, "b", node("h1", "2026-07-01T00:00:00Z"), node("h2", "2026-07-20T00:00:00Z"))
|
||||
f := buildFreshness([]backendResult{a, b})
|
||||
if f["h1"] != "a" {
|
||||
t.Errorf("h1 should belong to a, got %q", f["h1"])
|
||||
}
|
||||
if f["h2"] != "new" {
|
||||
t.Errorf("h2 should belong to new, got %q", f["h2"])
|
||||
if f["h2"] != "b" {
|
||||
t.Errorf("h2 should belong to b, got %q", f["h2"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
metaVersionPath = "/pdb/meta/v1/version"
|
||||
metaServerTimePath = "/pdb/meta/v1/server-time"
|
||||
)
|
||||
|
||||
// rawResult is one backend's verbatim response, for endpoints whose payload is
|
||||
// not a PuppetDB record array.
|
||||
type rawResult struct {
|
||||
name string
|
||||
status int
|
||||
contentType string
|
||||
body []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// ok reports whether the backend answered 2xx.
|
||||
func (r rawResult) ok() bool {
|
||||
return r.err == nil && r.status >= 200 && r.status < 300
|
||||
}
|
||||
|
||||
// fanOutRaw asks every backend for path concurrently and returns one result per
|
||||
// backend, in configured order, without interpreting the bodies.
|
||||
func (s *Server) fanOutRaw(ctx context.Context, path, rawQuery string) []rawResult {
|
||||
results := make([]rawResult, len(s.cfg.Backends))
|
||||
var wg sync.WaitGroup
|
||||
for i, b := range s.cfg.Backends {
|
||||
wg.Add(1)
|
||||
go func(i int, b Backend) {
|
||||
defer wg.Done()
|
||||
results[i] = s.rawBackend(ctx, b, path, rawQuery)
|
||||
}(i, b)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Server) rawBackend(ctx context.Context, b Backend, path, rawQuery string) rawResult {
|
||||
target := strings.TrimRight(b.URL, "/") + path
|
||||
if rawQuery != "" {
|
||||
target += "?" + rawQuery
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return rawResult{name: b.Name, err: err}
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return rawResult{name: b.Name, err: err}
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return rawResult{name: b.Name, err: err}
|
||||
}
|
||||
return rawResult{
|
||||
name: b.Name,
|
||||
status: resp.StatusCode,
|
||||
contentType: resp.Header.Get("Content-Type"),
|
||||
body: body,
|
||||
}
|
||||
}
|
||||
|
||||
// aliveRaw drops backends that errored or answered non-2xx, writing a 502 and
|
||||
// returning ok=false only when none is left.
|
||||
func (s *Server) aliveRaw(w http.ResponseWriter, results []rawResult, path string) ([]rawResult, bool) {
|
||||
var alive []rawResult
|
||||
for _, res := range results {
|
||||
if !res.ok() {
|
||||
s.log.Printf("warning: backend %q failed for %s: %s", res.name, path, res.reason())
|
||||
continue
|
||||
}
|
||||
alive = append(alive, res)
|
||||
}
|
||||
if len(alive) == 0 {
|
||||
http.Error(w, "all backends failed", http.StatusBadGateway)
|
||||
return nil, false
|
||||
}
|
||||
return alive, true
|
||||
}
|
||||
|
||||
func (r rawResult) reason() string {
|
||||
if r.err != nil {
|
||||
return r.err.Error()
|
||||
}
|
||||
return "HTTP " + strconv.Itoa(r.status) + ": " + strings.TrimSpace(string(r.body))
|
||||
}
|
||||
|
||||
// handleMetaVersion serves /pdb/meta/v1/version. Clients (pypuppetdb, and so
|
||||
// Puppetboard's startup check) treat the answer as the feature level they may
|
||||
// rely on, so the merged answer is the *lowest* version any backend reports:
|
||||
// the estate can only be counted on for what its oldest member implements.
|
||||
func (s *Server) handleMetaVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "only GET is supported", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
alive, ok := s.aliveRaw(w, s.fanOutRaw(r.Context(), metaVersionPath, r.URL.RawQuery), metaVersionPath)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
lowest := alive[0]
|
||||
lowestVer, hasVer := metaField(lowest.body, "version")
|
||||
for _, res := range alive[1:] {
|
||||
v, ok := metaField(res.body, "version")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !hasVer || compareVersions(v, lowestVer) < 0 {
|
||||
lowest, lowestVer, hasVer = res, v, true
|
||||
}
|
||||
}
|
||||
writeRaw(w, lowest)
|
||||
}
|
||||
|
||||
// handleMetaServerTime serves /pdb/meta/v1/server-time. The clock of whichever
|
||||
// PuppetDB answered is not estate state and does not merge, so the first
|
||||
// reachable backend in configured order supplies it — the same tie-break rule
|
||||
// used everywhere else.
|
||||
func (s *Server) handleMetaServerTime(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "only GET is supported", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
alive, ok := s.aliveRaw(w, s.fanOutRaw(r.Context(), metaServerTimePath, r.URL.RawQuery), metaServerTimePath)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeRaw(w, alive[0])
|
||||
}
|
||||
|
||||
// metaField pulls a string field out of a `{"version": "..."}`-shaped body.
|
||||
func metaField(body []byte, field string) (string, bool) {
|
||||
var obj map[string]json.RawMessage
|
||||
if json.Unmarshal(body, &obj) != nil {
|
||||
return "", false
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(obj[field], &s) != nil || s == "" {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// compareVersions orders dotted version strings segment by segment, comparing
|
||||
// numerically where both segments are numbers and lexically otherwise, so
|
||||
// "7.12.1" sorts below "8.4.0" and below "7.12.2". A prefix is lower than a
|
||||
// longer string sharing it ("7.12" < "7.12.1"), and a pre-release suffix is
|
||||
// compared as text within its segment ("8.0.0" < "8.0.0-SNAPSHOT").
|
||||
func compareVersions(a, b string) int {
|
||||
as, bs := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := 0; i < len(as) && i < len(bs); i++ {
|
||||
an, aok := strconv.Atoi(as[i])
|
||||
bn, bok := strconv.Atoi(bs[i])
|
||||
if aok == nil && bok == nil {
|
||||
if an != bn {
|
||||
return sign(an - bn)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c := strings.Compare(as[i], bs[i]); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return sign(len(as) - len(bs))
|
||||
}
|
||||
|
||||
func sign(n int) int {
|
||||
switch {
|
||||
case n < 0:
|
||||
return -1
|
||||
case n > 0:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func writeRaw(w http.ResponseWriter, res rawResult) {
|
||||
setContentType(w, res.contentType)
|
||||
w.WriteHeader(res.status)
|
||||
_, _ = w.Write(res.body)
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func metaGet(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
func metaString(t *testing.T, body []byte, field string) string {
|
||||
t.Helper()
|
||||
var obj map[string]string
|
||||
if err := json.Unmarshal(body, &obj); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", body, err)
|
||||
}
|
||||
return obj[field]
|
||||
}
|
||||
|
||||
func TestMetaVersion_BackendsAgree(t *testing.T) {
|
||||
// Puppetboard's check_db_version() calls this at import and exits 2 on any
|
||||
// non-200, so a 404 here is the difference between a running dashboard and
|
||||
// CrashLoopBackOff.
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[metaVersionPath] = `{"version":"7.12.1"}`
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[metaVersionPath] = `{"version":"7.12.1"}`
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
rec := metaGet(t, srv.Handler(), metaVersionPath)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := metaString(t, rec.Body.Bytes(), "version"); got != "7.12.1" {
|
||||
t.Errorf("version = %q, want 7.12.1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaVersion_DisagreementReportsLowest(t *testing.T) {
|
||||
// The estate can only be relied on for what its oldest PuppetDB implements.
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[metaVersionPath] = `{"version":"8.4.0"}`
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[metaVersionPath] = `{"version":"7.12.1"}`
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
if got := metaString(t, metaGet(t, srv.Handler(), metaVersionPath).Body.Bytes(), "version"); got != "7.12.1" {
|
||||
t.Errorf("version = %q, want the lower 7.12.1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaVersion_LowestIsIndependentOfBackendOrder(t *testing.T) {
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[metaVersionPath] = `{"version":"7.12.1"}`
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[metaVersionPath] = `{"version":"8.4.0"}`
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
if got := metaString(t, metaGet(t, srv.Handler(), metaVersionPath).Body.Bytes(), "version"); got != "7.12.1" {
|
||||
t.Errorf("version = %q, want the lower 7.12.1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaVersion_OneBackendDown(t *testing.T) {
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.fail = true
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[metaVersionPath] = `{"version":"8.4.0"}`
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
rec := metaGet(t, srv.Handler(), metaVersionPath)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 serving the survivor, got %d", rec.Code)
|
||||
}
|
||||
if got := metaString(t, rec.Body.Bytes(), "version"); got != "8.4.0" {
|
||||
t.Errorf("version = %q, want 8.4.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaVersion_AllBackendsDown(t *testing.T) {
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.fail = true
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.fail = true
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
if rec := metaGet(t, srv.Handler(), metaVersionPath); rec.Code != http.StatusBadGateway {
|
||||
t.Errorf("status %d, want 502", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaServerTime_FirstReachableBackend(t *testing.T) {
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[metaServerTimePath] = `{"server_time":"2026-08-29T01:00:00.000Z"}`
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[metaServerTimePath] = `{"server_time":"2026-08-29T02:00:00.000Z"}`
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
got := metaString(t, metaGet(t, srv.Handler(), metaServerTimePath).Body.Bytes(), "server_time")
|
||||
if got != "2026-08-29T01:00:00.000Z" {
|
||||
t.Errorf("server_time = %q, want the first backend's", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaServerTime_SkipsDeadBackend(t *testing.T) {
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.fail = true
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[metaServerTimePath] = `{"server_time":"2026-08-29T02:00:00.000Z"}`
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
rec := metaGet(t, srv.Handler(), metaServerTimePath)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 serving the survivor, got %d", rec.Code)
|
||||
}
|
||||
if got := metaString(t, rec.Body.Bytes(), "server_time"); got != "2026-08-29T02:00:00.000Z" {
|
||||
t.Errorf("server_time = %q, want the survivor's", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaVersion_RejectsNonGET(t *testing.T) {
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, metaVersionPath, nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status %d, want 405", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"7.12.1", "7.12.1", 0},
|
||||
{"7.12.1", "8.4.0", -1},
|
||||
{"8.4.0", "7.12.1", 1},
|
||||
{"7.9.0", "7.12.0", -1}, // numeric, not lexical: 9 < 12
|
||||
{"7.12", "7.12.1", -1},
|
||||
{"8.0.0", "8.0.0-SNAPSHOT", -1},
|
||||
{"8.0.0-SNAPSHOT", "8.0.0", 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := compareVersions(c.a, c.b); got != c.want {
|
||||
t.Errorf("compareVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaField_MalformedBodyIgnored(t *testing.T) {
|
||||
// A backend serving junk must not become the "lowest" version.
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[metaVersionPath] = `not json`
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[metaVersionPath] = `{"version":"8.4.0"}`
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
if got := metaString(t, metaGet(t, srv.Handler(), metaVersionPath).Body.Bytes(), "version"); got != "8.4.0" {
|
||||
t.Errorf("version = %q, want 8.4.0 from the only parseable backend", got)
|
||||
}
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// metricsPrefix covers PuppetDB's Jolokia surface, which sits at the server root
|
||||
// rather than under /pdb: pypuppetdb's metric() reads /metrics/v2/read/<mbean>,
|
||||
// lists via /metrics/v2/list, and falls back to /metrics/v1/mbeans/<mbean>.
|
||||
const metricsPrefix = "/metrics/"
|
||||
|
||||
type mergeRule int
|
||||
|
||||
const (
|
||||
ruleSum mergeRule = iota
|
||||
ruleMin
|
||||
ruleMax
|
||||
ruleMean
|
||||
)
|
||||
|
||||
// mergeRuleFor picks how one numeric MBean attribute combines across backends.
|
||||
// The default is a sum: the metrics Puppetboard renders are population counts
|
||||
// (num-nodes, num-resources, queue depth, command totals) whose estate-wide
|
||||
// value is the total. The exceptions are attributes describing a distribution or
|
||||
// a bound rather than a quantity, where adding two servers' numbers yields a
|
||||
// figure that was never true of either.
|
||||
func mergeRuleFor(attr string) mergeRule {
|
||||
a := strings.ToLower(attr)
|
||||
switch a {
|
||||
case "min":
|
||||
return ruleMin
|
||||
case "max", "uptime", "starttime":
|
||||
return ruleMax
|
||||
case "mean", "median", "stddev":
|
||||
return ruleMean
|
||||
}
|
||||
if strings.HasSuffix(a, "percentile") {
|
||||
return ruleMean
|
||||
}
|
||||
return ruleSum
|
||||
}
|
||||
|
||||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "only GET is supported", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// MBean names carry Jolokia's !-escapes under percent-encoding; the raw path
|
||||
// is forwarded so neither layer is lost.
|
||||
path := r.URL.EscapedPath()
|
||||
alive, ok := s.aliveRaw(w, s.fanOutRaw(r.Context(), path, r.URL.RawQuery), path)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
body, ok := mergeMetrics(alive, metricAttribute(r.URL.Path))
|
||||
if !ok {
|
||||
// Every backend answered 2xx but none carried a mergeable payload — a
|
||||
// Jolokia error envelope, or a body that is not a JSON object. Replaying
|
||||
// the first keeps the upstream error text the client expects.
|
||||
writeRaw(w, alive[0])
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// metricAttribute names the single attribute a read asked for, when the URL
|
||||
// carries one (/metrics/v2/read/<mbean>/<attribute>), so a scalar response body
|
||||
// still gets the right numeric rule. Empty means the response is an object whose
|
||||
// own keys name its attributes.
|
||||
func metricAttribute(path string) string {
|
||||
rest, ok := strings.CutPrefix(path, metricsPrefix)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(rest, "/") // v2/read/<mbean>[/<attribute>]
|
||||
if len(parts) < 4 {
|
||||
return ""
|
||||
}
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// mergeMetrics folds the backends' Jolokia responses into one. A response is
|
||||
// either a Jolokia envelope ({"request":…,"value":…,"status":200}), where only
|
||||
// "value" merges and the rest comes from the first backend, or a bare attribute
|
||||
// object (metrics/v1), which merges whole. ok=false means nothing was mergeable.
|
||||
func mergeMetrics(alive []rawResult, attr string) ([]byte, bool) {
|
||||
var objs []map[string]json.RawMessage
|
||||
for _, res := range alive {
|
||||
var obj map[string]json.RawMessage
|
||||
if decodeJSON(res.body, &obj) != nil || obj == nil {
|
||||
continue
|
||||
}
|
||||
// Jolokia reports a per-request failure inside an HTTP 200.
|
||||
if n, ok := numberOf(obj["status"]); ok && (n < 200 || n >= 300) {
|
||||
continue
|
||||
}
|
||||
objs = append(objs, obj)
|
||||
}
|
||||
if len(objs) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
_, hasValue := objs[0]["value"]
|
||||
_, hasStatus := objs[0]["status"]
|
||||
if !hasValue || !hasStatus {
|
||||
vals := make([]any, 0, len(objs))
|
||||
for _, obj := range objs {
|
||||
vals = append(vals, decodedObject(obj))
|
||||
}
|
||||
return marshal(mergeMetricValue(vals, attr))
|
||||
}
|
||||
|
||||
vals := make([]any, 0, len(objs))
|
||||
for _, obj := range objs {
|
||||
var v any
|
||||
if decodeJSON(obj["value"], &v) == nil {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
merged, ok := marshal(mergeMetricValue(vals, attr))
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
out := make(map[string]json.RawMessage, len(objs[0]))
|
||||
for k, v := range objs[0] {
|
||||
out[k] = v
|
||||
}
|
||||
out["value"] = merged
|
||||
// The envelope timestamp says when the answer was produced; the newest one
|
||||
// describes the merged answer.
|
||||
if ts, ok := maxField(objs, "timestamp"); ok {
|
||||
out["timestamp"] = ts
|
||||
}
|
||||
return marshal(out)
|
||||
}
|
||||
|
||||
// maxField returns the largest numeric value of a field across the responses.
|
||||
func maxField(objs []map[string]json.RawMessage, field string) (json.RawMessage, bool) {
|
||||
var best json.RawMessage
|
||||
var bestN float64
|
||||
for _, obj := range objs {
|
||||
n, ok := numberOf(obj[field])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if best == nil || n > bestN {
|
||||
best, bestN = obj[field], n
|
||||
}
|
||||
}
|
||||
return best, best != nil
|
||||
}
|
||||
|
||||
// mergeMetricValue folds one attribute's value from every backend into one.
|
||||
// Objects merge key by key over the union of keys, so a backend missing an
|
||||
// attribute still contributes the rest. Numbers combine by the attribute's rule.
|
||||
// Anything else — strings, booleans, arrays, nulls, or a mix of kinds — keeps
|
||||
// the first backend's value, there being no sound way to add them.
|
||||
func mergeMetricValue(vals []any, attr string) any {
|
||||
if len(vals) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(vals) == 1 {
|
||||
return vals[0]
|
||||
}
|
||||
|
||||
objs := make([]map[string]any, 0, len(vals))
|
||||
for _, v := range vals {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
objs = append(objs, m)
|
||||
}
|
||||
}
|
||||
if len(objs) == len(vals) {
|
||||
out := map[string]any{}
|
||||
for _, m := range objs {
|
||||
for k := range m {
|
||||
if _, done := out[k]; done {
|
||||
continue
|
||||
}
|
||||
sub := make([]any, 0, len(objs))
|
||||
for _, o := range objs {
|
||||
if v, ok := o[k]; ok {
|
||||
sub = append(sub, v)
|
||||
}
|
||||
}
|
||||
out[k] = mergeMetricValue(sub, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
nums := make([]float64, 0, len(vals))
|
||||
for _, v := range vals {
|
||||
n, ok := v.(json.Number)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
f, err := n.Float64()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
nums = append(nums, f)
|
||||
}
|
||||
if len(nums) != len(vals) {
|
||||
return vals[0]
|
||||
}
|
||||
return combineNumbers(nums, attr)
|
||||
}
|
||||
|
||||
func combineNumbers(nums []float64, attr string) json.RawMessage {
|
||||
acc := nums[0]
|
||||
switch mergeRuleFor(attr) {
|
||||
case ruleMin:
|
||||
for _, n := range nums[1:] {
|
||||
if n < acc {
|
||||
acc = n
|
||||
}
|
||||
}
|
||||
case ruleMax:
|
||||
for _, n := range nums[1:] {
|
||||
if n > acc {
|
||||
acc = n
|
||||
}
|
||||
}
|
||||
case ruleMean:
|
||||
for _, n := range nums[1:] {
|
||||
acc += n
|
||||
}
|
||||
acc /= float64(len(nums))
|
||||
default:
|
||||
for _, n := range nums[1:] {
|
||||
acc += n
|
||||
}
|
||||
}
|
||||
return json.RawMessage(strconv.FormatFloat(acc, 'f', -1, 64))
|
||||
}
|
||||
|
||||
// decodedObject re-reads an object's fields as generic values so the whole thing
|
||||
// can go through mergeMetricValue.
|
||||
func decodedObject(obj map[string]json.RawMessage) any {
|
||||
out := make(map[string]any, len(obj))
|
||||
for k, raw := range obj {
|
||||
var v any
|
||||
if decodeJSON(raw, &v) == nil {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decodeJSON keeps integers exact by decoding numbers as json.Number.
|
||||
func decodeJSON(data []byte, v any) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.UseNumber()
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
// marshal reports ok=false rather than an error: an unmarshalable merge result
|
||||
// has only one recovery, replaying a backend's body verbatim.
|
||||
func marshal(v any) ([]byte, bool) {
|
||||
b, err := json.Marshal(v)
|
||||
return b, err == nil
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
numNodesMBean = "puppetlabs.puppetdb.population:name=num-nodes"
|
||||
numNodesPath = metricsPrefix + "v2/read/" + numNodesMBean
|
||||
// What pypuppetdb actually sends: quote() percent-encodes ':' and '='.
|
||||
numNodesEscaped = metricsPrefix + "v2/read/puppetlabs.puppetdb.population%3Aname%3Dnum-nodes"
|
||||
)
|
||||
|
||||
// jolokiaRead wraps an MBean value in the envelope PuppetDB's Jolokia returns.
|
||||
func jolokiaRead(mbean, value string, timestamp int) string {
|
||||
return `{"request":{"mbean":"` + mbean + `","type":"read"},` +
|
||||
`"value":` + value + `,"timestamp":` + strconv.Itoa(timestamp) + `,"status":200}`
|
||||
}
|
||||
|
||||
func metricValue(t *testing.T, body []byte) map[string]any {
|
||||
t.Helper()
|
||||
var env map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("unmarshal envelope %s: %v", body, err)
|
||||
}
|
||||
var val map[string]any
|
||||
if err := json.Unmarshal(env["value"], &val); err != nil {
|
||||
t.Fatalf("unmarshal value %s: %v", env["value"], err)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func TestMetrics_ReadSumsPopulationCounts(t *testing.T) {
|
||||
// Puppetboard's landing page and radiator read num-nodes when
|
||||
// DEFAULT_ENVIRONMENT is '*'; each backend only knows its own nodes.
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":90}`, 1000)
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":53}`, 2000)
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
rec := metaGet(t, srv.Handler(), numNodesEscaped)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := metricValue(t, rec.Body.Bytes())["Value"]; got != float64(143) {
|
||||
t.Errorf("Value = %v, want 143", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_EscapedMBeanNameSurvives(t *testing.T) {
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":1}`, 1)
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":1}`, 1)
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
metaGet(t, srv.Handler(), numNodesEscaped)
|
||||
if !a.sawRawPath(numNodesEscaped) {
|
||||
t.Errorf("backend saw %v, want the percent-encoded path %q", a.rawPaths, numNodesEscaped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_EnvelopeKeepsNewestTimestamp(t *testing.T) {
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":1}`, 1000)
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":1}`, 2000)
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(metaGet(t, srv.Handler(), numNodesEscaped).Body.Bytes(), &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env["timestamp"] != float64(2000) {
|
||||
t.Errorf("timestamp = %v, want 2000", env["timestamp"])
|
||||
}
|
||||
if env["status"] != float64(200) {
|
||||
t.Errorf("status = %v, want 200", env["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_PerAttributeRules(t *testing.T) {
|
||||
const mbean = "puppetlabs.puppetdb.mq:name=global.processing-time"
|
||||
path := metricsPrefix + "v2/read/" + mbean
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[path] = jolokiaRead(mbean,
|
||||
`{"Count":10,"Min":2,"Max":9,"Mean":4,"StdDev":1,"50thPercentile":3,"MeanRate":1.5}`, 1)
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[path] = jolokiaRead(mbean,
|
||||
`{"Count":6,"Min":1,"Max":20,"Mean":6,"StdDev":3,"50thPercentile":5,"MeanRate":0.5}`, 1)
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
got := metricValue(t, metaGet(t, srv.Handler(), path).Body.Bytes())
|
||||
want := map[string]any{
|
||||
"Count": float64(16), // counts add
|
||||
"Min": float64(1), // a bound stays a bound
|
||||
"Max": float64(20),
|
||||
"Mean": float64(5), // distribution stats average
|
||||
"StdDev": float64(2),
|
||||
"50thPercentile": float64(4),
|
||||
"MeanRate": float64(2), // throughput adds
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("merged value = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_ScalarReadUsesURLAttributeName(t *testing.T) {
|
||||
// /metrics/v2/read/<mbean>/<attribute> answers with a bare number, so the
|
||||
// rule has to come from the URL rather than an object key.
|
||||
const mbean = "puppetlabs.puppetdb.population:name=num-resources"
|
||||
sumPath := metricsPrefix + "v2/read/" + mbean + "/Value"
|
||||
maxPath := metricsPrefix + "v2/read/" + mbean + "/Max"
|
||||
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[sumPath] = jolokiaRead(mbean, `1000`, 1)
|
||||
a.bodies[maxPath] = jolokiaRead(mbean, `1000`, 1)
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[sumPath] = jolokiaRead(mbean, `234`, 1)
|
||||
b.bodies[maxPath] = jolokiaRead(mbean, `234`, 1)
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
for _, c := range []struct {
|
||||
path string
|
||||
want float64
|
||||
}{{sumPath, 1234}, {maxPath, 1000}} {
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(metaGet(t, srv.Handler(), c.path).Body.Bytes(), &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env["value"] != c.want {
|
||||
t.Errorf("%s value = %v, want %v", c.path, env["value"], c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_ListUnionsDomains(t *testing.T) {
|
||||
// Puppetboard's /metrics page calls metric() with no name, which is a
|
||||
// Jolokia list; a backend-local MBean must not vanish from the browse tree.
|
||||
const path = metricsPrefix + "v2/list"
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[path] = `{"value":{"java.lang":{"type=Memory":{"attr":{"HeapMemoryUsage":{"rw":false}}}}},"status":200,"timestamp":1}`
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[path] = `{"value":{"puppetlabs.puppetdb.population":{"name=num-nodes":{"attr":{"Value":{"rw":false}}}}},"status":200,"timestamp":1}`
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
got := metricValue(t, metaGet(t, srv.Handler(), path).Body.Bytes())
|
||||
if _, ok := got["java.lang"]; !ok {
|
||||
t.Errorf("java.lang missing from merged list: %v", got)
|
||||
}
|
||||
if _, ok := got["puppetlabs.puppetdb.population"]; !ok {
|
||||
t.Errorf("puppetlabs.puppetdb.population missing from merged list: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_V1BareObjectMerged(t *testing.T) {
|
||||
// metrics/v1/mbeans has no Jolokia envelope; the whole body is the value.
|
||||
const path = metricsPrefix + "v1/mbeans/" + numNodesMBean
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[path] = `{"Value":90}`
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[path] = `{"Value":53}`
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(metaGet(t, srv.Handler(), path).Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["Value"] != float64(143) {
|
||||
t.Errorf("Value = %v, want 143", got["Value"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_MissingMBeanKeepsUpstreamError(t *testing.T) {
|
||||
// Jolokia reports a bad MBean as a 200 with an error envelope, which
|
||||
// pypuppetdb turns into DoesNotComputeError; the client must still see it.
|
||||
const path = metricsPrefix + "v2/read/nope:name=nothing"
|
||||
errEnv := `{"request":{"mbean":"nope:name=nothing"},"error_type":"javax.management.InstanceNotFoundException","error":"nope:name=nothing is not registered","status":404}`
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[path] = errEnv
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[path] = errEnv
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
rec := metaGet(t, srv.Handler(), path)
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env["error"] == nil {
|
||||
t.Errorf("expected the upstream Jolokia error to be replayed, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_ErroringBackendIgnoredWhenAnotherAnswers(t *testing.T) {
|
||||
const path = metricsPrefix + "v2/read/" + numNodesMBean
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.bodies[path] = `{"request":{},"error":"boom","status":500}`
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[path] = jolokiaRead(numNodesMBean, `{"Value":53}`, 1)
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
if got := metricValue(t, metaGet(t, srv.Handler(), path).Body.Bytes())["Value"]; got != float64(53) {
|
||||
t.Errorf("Value = %v, want 53 from the backend that answered", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_OneBackendDown(t *testing.T) {
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.fail = true
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.bodies[numNodesPath] = jolokiaRead(numNodesMBean, `{"Value":53}`, 1)
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
rec := metaGet(t, srv.Handler(), numNodesEscaped)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 serving the survivor, got %d", rec.Code)
|
||||
}
|
||||
if got := metricValue(t, rec.Body.Bytes())["Value"]; got != float64(53) {
|
||||
t.Errorf("Value = %v, want 53", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_AllBackendsDown(t *testing.T) {
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
a.fail = true
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
b.fail = true
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
if rec := metaGet(t, srv.Handler(), numNodesEscaped); rec.Code != http.StatusBadGateway {
|
||||
t.Errorf("status %d, want 502", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_RejectsNonGET(t *testing.T) {
|
||||
a := newFakeBackend(t, `[]`, `[]`)
|
||||
b := newFakeBackend(t, `[]`, `[]`)
|
||||
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, numNodesEscaped, nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status %d, want 405", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMetricValue_NonNumericKeepsFirst(t *testing.T) {
|
||||
a := map[string]any{"Name": "pdb-a", "Enabled": true}
|
||||
b := map[string]any{"Name": "pdb-b", "Enabled": false}
|
||||
got, ok := mergeMetricValue([]any{a, b}, "").(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected an object, got %T", got)
|
||||
}
|
||||
if got["Name"] != "pdb-a" || got["Enabled"] != true {
|
||||
t.Errorf("merged = %v, want the first backend's strings and booleans", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeRuleFor(t *testing.T) {
|
||||
cases := map[string]mergeRule{
|
||||
"Count": ruleSum,
|
||||
"Value": ruleSum,
|
||||
"MeanRate": ruleSum,
|
||||
"queue-depth": ruleSum,
|
||||
"min": ruleMin,
|
||||
"Max": ruleMax,
|
||||
"Uptime": ruleMax,
|
||||
"StartTime": ruleMax,
|
||||
"Mean": ruleMean,
|
||||
"StdDev": ruleMean,
|
||||
"99thPercentile": ruleMean,
|
||||
}
|
||||
for attr, want := range cases {
|
||||
if got := mergeRuleFor(attr); got != want {
|
||||
t.Errorf("mergeRuleFor(%q) = %v, want %v", attr, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The first backend in results holding a key supplies the record; a key func returning ok=false means the record has no identity and is always kept.
|
||||
func mergeUnion(results []backendResult, key func(record) (string, bool)) []json.RawMessage {
|
||||
seen := make(map[string]bool)
|
||||
out := []json.RawMessage{}
|
||||
for _, res := range results {
|
||||
for _, rec := range res.records {
|
||||
if k, ok := key(rec); ok {
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
out = append(out, rec.Raw)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extract/group_by rows are synthetic and carry no hash, so two backends can legitimately emit identical ones.
|
||||
func reportKey(rec record) (string, bool) {
|
||||
if rec.Hash == "" {
|
||||
return "", false
|
||||
}
|
||||
return "hash\x00" + rec.Hash, true
|
||||
}
|
||||
|
||||
// Events carry no id, but byte-identical events from the same PuppetDB serialiser are the same change.
|
||||
func rawKey(rec record) (string, bool) { return "raw\x00" + string(rec.Raw), true }
|
||||
|
||||
type orderField struct {
|
||||
Field string
|
||||
Desc bool
|
||||
}
|
||||
|
||||
// order_by is a JSON array of {"field": ..., "order": "asc"|"desc"} objects.
|
||||
func parseOrderBy(s string) ([]orderField, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var raw []struct {
|
||||
Field string `json:"field"`
|
||||
Order string `json:"order"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &raw); err != nil {
|
||||
return nil, fmt.Errorf("order_by is not a JSON array: %w", err)
|
||||
}
|
||||
out := make([]orderField, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
if r.Field == "" {
|
||||
return nil, fmt.Errorf("order_by entry is missing a field")
|
||||
}
|
||||
out = append(out, orderField{Field: r.Field, Desc: strings.EqualFold(r.Order, "desc")})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Each backend ordered only its own slice, so the union is re-sorted here; stable, so ties keep the merged set's existing order.
|
||||
func sortRecords(recs []json.RawMessage, order []orderField) {
|
||||
if len(order) == 0 || len(recs) < 2 {
|
||||
return
|
||||
}
|
||||
objs := make([]map[string]any, len(recs))
|
||||
for i, raw := range recs {
|
||||
_ = json.Unmarshal(raw, &objs[i]) // non-objects sort as all-missing fields
|
||||
}
|
||||
idx := make([]int, len(recs))
|
||||
for i := range idx {
|
||||
idx[i] = i
|
||||
}
|
||||
sort.SliceStable(idx, func(a, b int) bool {
|
||||
oa, ob := objs[idx[a]], objs[idx[b]]
|
||||
for _, f := range order {
|
||||
c := compareValues(oa[f.Field], ob[f.Field])
|
||||
if c == 0 {
|
||||
continue
|
||||
}
|
||||
if f.Desc {
|
||||
return c > 0
|
||||
}
|
||||
return c < 0
|
||||
}
|
||||
return false
|
||||
})
|
||||
sorted := make([]json.RawMessage, len(recs))
|
||||
for i, j := range idx {
|
||||
sorted[i] = recs[j]
|
||||
}
|
||||
copy(recs, sorted)
|
||||
}
|
||||
|
||||
// Unlike types order by kind (null < bool < number < string), so a missing field sorts first.
|
||||
func compareValues(a, b any) int {
|
||||
ra, rb := valueRank(a), valueRank(b)
|
||||
if ra != rb {
|
||||
if ra < rb {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
switch av := a.(type) {
|
||||
case bool:
|
||||
bv := b.(bool)
|
||||
switch {
|
||||
case av == bv:
|
||||
return 0
|
||||
case bv:
|
||||
return -1
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
case float64:
|
||||
bv := b.(float64)
|
||||
switch {
|
||||
case av < bv:
|
||||
return -1
|
||||
case av > bv:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
case string:
|
||||
return strings.Compare(av, b.(string))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func valueRank(v any) int {
|
||||
switch v.(type) {
|
||||
case nil:
|
||||
return 0
|
||||
case bool:
|
||||
return 1
|
||||
case float64:
|
||||
return 2
|
||||
case string:
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
type paging struct {
|
||||
limit int // -1 when unset
|
||||
offset int
|
||||
order []orderField
|
||||
wantTotal bool
|
||||
}
|
||||
|
||||
func parsePaging(v url.Values) (paging, error) {
|
||||
p := paging{limit: -1}
|
||||
if s := v.Get("limit"); s != "" {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 0 {
|
||||
return p, fmt.Errorf("limit must be a non-negative integer, got %q", s)
|
||||
}
|
||||
p.limit = n
|
||||
}
|
||||
if s := v.Get("offset"); s != "" {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 0 {
|
||||
return p, fmt.Errorf("offset must be a non-negative integer, got %q", s)
|
||||
}
|
||||
p.offset = n
|
||||
}
|
||||
order, err := parseOrderBy(v.Get("order_by"))
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.order = order
|
||||
p.wantTotal = v.Get("include_total") == "true"
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Backends are asked for the first offset+limit records with no offset; the offset is applied to the union instead.
|
||||
func (p paging) upstreamParams(in url.Values) url.Values {
|
||||
out := url.Values{}
|
||||
for k, vs := range in {
|
||||
out[k] = append([]string(nil), vs...)
|
||||
}
|
||||
out.Del("offset")
|
||||
if p.limit >= 0 {
|
||||
out.Set("limit", strconv.Itoa(p.limit+p.offset))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p paging) apply(recs []json.RawMessage) []json.RawMessage {
|
||||
if p.offset >= len(recs) {
|
||||
return []json.RawMessage{}
|
||||
}
|
||||
recs = recs[p.offset:]
|
||||
if p.limit >= 0 && p.limit < len(recs) {
|
||||
recs = recs[:p.limit]
|
||||
}
|
||||
return recs
|
||||
}
|
||||
|
||||
// Returns -1 when no backend reported a count; duplicates count once per backend, so the sum is an upper bound.
|
||||
func sumTotals(results []backendResult) int {
|
||||
total := -1
|
||||
for _, res := range results {
|
||||
if res.total < 0 {
|
||||
continue
|
||||
}
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
total += res.total
|
||||
}
|
||||
return total
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMergeUnion_KeepsBothBackendsHistory(t *testing.T) {
|
||||
a := recs(t, "a", report("h1", "r1", "2026-07-01T00:00:00Z"))
|
||||
b := recs(t, "b", report("h1", "r2", "2026-07-02T00:00:00Z"))
|
||||
merged := mergeUnion([]backendResult{b, a}, reportKey)
|
||||
if got := hashesOf(t, merged); !slices.Equal(got, []string{"r2", "r1"}) {
|
||||
t.Errorf("union = %v, want both reports in configured order", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeUnion_DedupesSharedHash(t *testing.T) {
|
||||
dup := report("h1", "r1", "2026-07-01T00:00:00Z")
|
||||
merged := mergeUnion([]backendResult{recs(t, "b", dup), recs(t, "a", dup)}, reportKey)
|
||||
if got := hashesOf(t, merged); !slices.Equal(got, []string{"r1"}) {
|
||||
t.Errorf("union = %v, want a single r1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeUnion_HashlessRowsAreAllKept(t *testing.T) {
|
||||
// extract/group_by queries return synthetic rows with no hash; dropping the
|
||||
// second backend's rows as "duplicates" would silently lose half the data.
|
||||
a := recs(t, "a", `{"status":"changed","count":3}`)
|
||||
b := recs(t, "b", `{"status":"changed","count":5}`)
|
||||
merged := mergeUnion([]backendResult{a, b}, reportKey)
|
||||
if len(merged) != 2 {
|
||||
t.Errorf("expected both aggregate rows, got %d: %v", len(merged), merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeUnion_IdenticalHashlessRowsAreNotCollapsed(t *testing.T) {
|
||||
// Two backends can legitimately produce the same aggregate row; collapsing
|
||||
// them as duplicates undercounts the merged result.
|
||||
same := `{"status":"changed","count":1}`
|
||||
merged := mergeUnion([]backendResult{recs(t, "a", same), recs(t, "b", same)}, reportKey)
|
||||
if len(merged) != 2 {
|
||||
t.Errorf("expected both backends' aggregate rows, got %d: %v", len(merged), merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeUnion_EventsDedupeOnRawIdentity(t *testing.T) {
|
||||
same := event("h1", "r1", "Package[nginx]")
|
||||
other := event("h1", "r1", "Service[nginx]")
|
||||
merged := mergeUnion([]backendResult{recs(t, "b", same, other), recs(t, "a", same)}, rawKey)
|
||||
if len(merged) != 2 {
|
||||
t.Errorf("expected 2 distinct events, got %d: %v", len(merged), merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOrderBy(t *testing.T) {
|
||||
got, err := parseOrderBy(`[{"field":"receive_time","order":"desc"},{"field":"certname"}]`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []orderField{{Field: "receive_time", Desc: true}, {Field: "certname"}}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("parseOrderBy = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
if got, err := parseOrderBy(" "); err != nil || got != nil {
|
||||
t.Errorf("empty order_by = %v, %v; want nil, nil", got, err)
|
||||
}
|
||||
for _, bad := range []string{`receive_time`, `[{"order":"desc"}]`} {
|
||||
if _, err := parseOrderBy(bad); err == nil {
|
||||
t.Errorf("parseOrderBy(%q) should have failed", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortRecords_MultipleFieldsAndStability(t *testing.T) {
|
||||
raws := rawsOf(t,
|
||||
`{"certname":"b","status":"failed","hash":"r1"}`,
|
||||
`{"certname":"a","status":"changed","hash":"r2"}`,
|
||||
`{"certname":"a","status":"changed","hash":"r3"}`,
|
||||
`{"certname":"a","status":"failed","hash":"r4"}`,
|
||||
)
|
||||
sortRecords(raws, []orderField{{Field: "certname"}, {Field: "status", Desc: true}})
|
||||
// certname asc, then status desc; r2/r3 tie fully and keep input order.
|
||||
if got := hashesOf(t, raws); !slices.Equal(got, []string{"r4", "r2", "r3", "r1"}) {
|
||||
t.Errorf("sorted = %v, want [r4 r2 r3 r1]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortRecords_MissingFieldSortsFirst(t *testing.T) {
|
||||
raws := rawsOf(t,
|
||||
`{"hash":"r1","receive_time":"2026-07-01T00:00:00Z"}`,
|
||||
`{"hash":"r2"}`,
|
||||
)
|
||||
sortRecords(raws, []orderField{{Field: "receive_time"}})
|
||||
if got := hashesOf(t, raws); !slices.Equal(got, []string{"r2", "r1"}) {
|
||||
t.Errorf("sorted = %v, want the record missing the field first", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortRecords_NoOrderLeavesInputOrder(t *testing.T) {
|
||||
raws := rawsOf(t, `{"hash":"r1"}`, `{"hash":"r2"}`)
|
||||
sortRecords(raws, nil)
|
||||
if got := hashesOf(t, raws); !slices.Equal(got, []string{"r1", "r2"}) {
|
||||
t.Errorf("sorted = %v, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareValues_AcrossKinds(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b any
|
||||
want int
|
||||
}{
|
||||
{nil, false, -1},
|
||||
{false, true, -1},
|
||||
{true, 1.0, -1},
|
||||
{1.0, 2.0, -1},
|
||||
{2.0, 2.0, 0},
|
||||
{2.0, "x", -1},
|
||||
{"a", "b", -1},
|
||||
{"b", "a", 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := compareValues(c.a, c.b); got != c.want {
|
||||
t.Errorf("compareValues(%v, %v) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
if got := compareValues(c.b, c.a); got != -c.want {
|
||||
t.Errorf("compareValues(%v, %v) = %d, want %d (antisymmetry)", c.b, c.a, got, -c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePaging(t *testing.T) {
|
||||
p, err := parsePaging(url.Values{
|
||||
"limit": {"25"},
|
||||
"offset": {"50"},
|
||||
"include_total": {"true"},
|
||||
"order_by": {`[{"field":"receive_time","order":"desc"}]`},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.limit != 25 || p.offset != 50 || !p.wantTotal || len(p.order) != 1 {
|
||||
t.Fatalf("parsePaging = %+v", p)
|
||||
}
|
||||
|
||||
if p, err := parsePaging(nil); err != nil || p.limit != -1 || p.offset != 0 || p.wantTotal {
|
||||
t.Errorf("empty params = %+v, %v; want limit=-1 and no paging", p, err)
|
||||
}
|
||||
for _, bad := range []url.Values{{"limit": {"-1"}}, {"limit": {"x"}}, {"offset": {"x"}}} {
|
||||
if _, err := parsePaging(bad); err == nil {
|
||||
t.Errorf("parsePaging(%v) should have failed", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagingUpstreamParams(t *testing.T) {
|
||||
in := url.Values{
|
||||
"query": {`["=","certname","h1"]`},
|
||||
"limit": {"25"},
|
||||
"offset": {"50"},
|
||||
}
|
||||
p, err := parsePaging(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := p.upstreamParams(in)
|
||||
if out.Get("limit") != "75" {
|
||||
t.Errorf("upstream limit = %q, want 75 (offset+limit)", out.Get("limit"))
|
||||
}
|
||||
if out.Has("offset") {
|
||||
t.Errorf("upstream offset = %q, want it dropped", out.Get("offset"))
|
||||
}
|
||||
if out.Get("query") != in.Get("query") {
|
||||
t.Errorf("query should pass through verbatim, got %q", out.Get("query"))
|
||||
}
|
||||
if in.Get("limit") != "25" {
|
||||
t.Errorf("upstreamParams must not mutate the caller's params, limit is now %q", in.Get("limit"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagingUpstreamParams_NoLimitLeavesQueryUnbounded(t *testing.T) {
|
||||
in := url.Values{"offset": {"5"}}
|
||||
p, err := parsePaging(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := p.upstreamParams(in)
|
||||
if out.Has("limit") || out.Has("offset") {
|
||||
t.Errorf("upstream params = %v, want neither limit nor offset", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagingApply(t *testing.T) {
|
||||
raws := rawsOf(t, `{"hash":"r1"}`, `{"hash":"r2"}`, `{"hash":"r3"}`)
|
||||
cases := []struct {
|
||||
name string
|
||||
page paging
|
||||
want []string
|
||||
}{
|
||||
{name: "window", page: paging{limit: 1, offset: 1}, want: []string{"r2"}},
|
||||
{name: "limit past end", page: paging{limit: 10}, want: []string{"r1", "r2", "r3"}},
|
||||
{name: "offset past end", page: paging{limit: 2, offset: 9}, want: nil},
|
||||
{name: "unset limit", page: paging{limit: -1, offset: 2}, want: []string{"r3"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := hashesOf(t, c.page.apply(raws))
|
||||
if len(got) == 0 && len(c.want) == 0 {
|
||||
return
|
||||
}
|
||||
if !slices.Equal(got, c.want) {
|
||||
t.Errorf("apply = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumTotals(t *testing.T) {
|
||||
if got := sumTotals([]backendResult{{total: 40}, {total: 60}}); got != 100 {
|
||||
t.Errorf("sumTotals = %d, want 100", got)
|
||||
}
|
||||
if got := sumTotals([]backendResult{{total: -1}, {total: 7}}); got != 7 {
|
||||
t.Errorf("sumTotals should skip backends without a count, got %d", got)
|
||||
}
|
||||
if got := sumTotals([]backendResult{{total: -1}, {total: -1}}); got != -1 {
|
||||
t.Errorf("sumTotals with no counts = %d, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// rawsOf builds a raw record slice from literal JSON elements.
|
||||
func rawsOf(t *testing.T, elems ...string) []json.RawMessage {
|
||||
t.Helper()
|
||||
out := make([]json.RawMessage, 0, len(elems))
|
||||
for _, e := range elems {
|
||||
out = append(out, json.RawMessage(e))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hashesOf extracts the hash field from raw records, in order.
|
||||
func hashesOf(t *testing.T, raws []json.RawMessage) []string {
|
||||
t.Helper()
|
||||
out := make([]string, 0, len(raws))
|
||||
for _, r := range raws {
|
||||
var m recordMeta
|
||||
if err := json.Unmarshal(r, &m); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", r, err)
|
||||
}
|
||||
out = append(out, m.Hash)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -3,63 +3,99 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
factsPath = "/pdb/query/v4/facts"
|
||||
nodesPath = "/pdb/query/v4/nodes"
|
||||
queryV4 = "/pdb/query/v4/"
|
||||
factsPath = "/pdb/query/v4/facts"
|
||||
nodesPath = "/pdb/query/v4/nodes"
|
||||
resourcesPath = "/pdb/query/v4/resources"
|
||||
reportsPath = "/pdb/query/v4/reports"
|
||||
eventsPath = "/pdb/query/v4/events"
|
||||
eventCountsPath = "/pdb/query/v4/event-counts"
|
||||
aggregateEventCountsPath = "/pdb/query/v4/aggregate-event-counts"
|
||||
queryV4 = "/pdb/query/v4/"
|
||||
|
||||
// PuppetDB only sends this when the request carries include_total=true.
|
||||
recordsHeader = "X-Records"
|
||||
)
|
||||
|
||||
// backendResult is one backend's decoded response for a query. err is non-nil
|
||||
// when the backend failed (network/timeout/non-2xx); such results carry no
|
||||
// records and are excluded from the merge but logged.
|
||||
type backendResult struct {
|
||||
name string
|
||||
records []record
|
||||
total int // upstream X-Records count, or -1 when the backend sent none
|
||||
err error
|
||||
}
|
||||
|
||||
// Server proxies and merges PuppetDB queries across the configured backends.
|
||||
var errAllBackendsFailed = errors.New("all backends failed")
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
log *log.Logger
|
||||
|
||||
// factsCache is nil when caching is disabled; cacheFor hands out a noop then.
|
||||
factsCache Cache
|
||||
flights flightGroup
|
||||
stale staleTracker
|
||||
|
||||
// freshness cache (freshness merge only).
|
||||
mu sync.Mutex
|
||||
freshData freshness
|
||||
freshAt time.Time
|
||||
}
|
||||
|
||||
// NewServer builds a Server with an HTTP client bounded by cfg.Timeout.
|
||||
func NewServer(cfg Config, logger *log.Logger) *Server {
|
||||
return &Server{
|
||||
cfg.clampFactsTTL()
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
log: logger,
|
||||
}
|
||||
if cfg.cacheEnabled() {
|
||||
s.factsCache = newMemoryCache(cfg.FactsTTL, cfg.CacheBytes)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// cacheFor picks the cache backing a request. Merged /facts and /nodes record
|
||||
// sets share the in-memory cache; 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) {
|
||||
switch path {
|
||||
case factsPath, nodesPath:
|
||||
// An aggregate row is a summed count, not the merged record set the
|
||||
// cache was built for, so it stays on the live path.
|
||||
if parseAggregate(params.Get("query")) != nil {
|
||||
return noopCache{}, false
|
||||
}
|
||||
if s.factsCache != nil {
|
||||
return s.factsCache, true
|
||||
}
|
||||
}
|
||||
return noopCache{}, false
|
||||
}
|
||||
|
||||
// Handler returns the HTTP mux for the proxy.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", s.handleHealth)
|
||||
mux.HandleFunc("/pdb/query/v4/", s.handleQuery)
|
||||
mux.HandleFunc(metaVersionPath, s.handleMetaVersion)
|
||||
mux.HandleFunc(metaServerTimePath, s.handleMetaServerTime)
|
||||
mux.HandleFunc(metricsPrefix, s.handleMetrics)
|
||||
return mux
|
||||
}
|
||||
|
||||
// handleQuery dispatches /pdb/query/v4/* requests: /facts and /nodes are merged
|
||||
// across backends; every other v4 path is transparently proxied to the primary.
|
||||
func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "only GET is supported", http.StatusMethodNotAllowed)
|
||||
@@ -67,20 +103,161 @@ func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case nodesPath:
|
||||
s.serveMerged(w, r, nodesPath, s.mergeNodesResponse)
|
||||
s.serveNodes(w, r)
|
||||
case resourcesPath:
|
||||
s.serveResources(w, r)
|
||||
case factsPath:
|
||||
s.serveMerged(w, r, factsPath, s.mergeFactsResponse)
|
||||
case reportsPath:
|
||||
s.serveReports(w, r)
|
||||
case eventsPath:
|
||||
s.serveUnion(w, r, eventsPath, rawKey)
|
||||
case eventCountsPath, aggregateEventCountsPath:
|
||||
s.serveSummed(w, r, r.URL.Path, inferredColumns)
|
||||
default:
|
||||
s.proxyPrimary(w, r)
|
||||
if isReportSubResource(r.URL.Path) {
|
||||
s.serveFirstHolder(w, r)
|
||||
return
|
||||
}
|
||||
s.proxyUnmerged(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// serveMerged fans out the request to all backends, then hands the per-backend
|
||||
// results to merge to produce the response body. If every backend fails it
|
||||
// returns 502; if some fail it serves the survivors and logs a warning.
|
||||
// Matches /pdb/query/v4/reports/<hash>/{events,logs,metrics}, whose data lives in exactly one backend.
|
||||
func isReportSubResource(path string) bool {
|
||||
rest, ok := strings.CutPrefix(path, reportsPath+"/")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
hash, sub, ok := strings.Cut(rest, "/")
|
||||
if !ok || hash == "" {
|
||||
return false
|
||||
}
|
||||
switch sub {
|
||||
case "events", "logs", "metrics":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) serveMerged(w http.ResponseWriter, r *http.Request, path string, merge func([]backendResult) []json.RawMessage) {
|
||||
query := r.URL.Query().Get("query")
|
||||
results := s.fanOut(r.Context(), path, query)
|
||||
params := queryParams(r.URL.Query().Get("query"))
|
||||
s.serveCached(w, r, path, params, func() (cachedResponse, error) {
|
||||
alive, err := s.aliveResults(r.Context(), path, params)
|
||||
if err != nil {
|
||||
return cachedResponse{}, err
|
||||
}
|
||||
return cachedResponse{Body: encodeRecords(merge(alive)), Records: -1}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Reports and events are immutable history, so both backends' records belong in the merged view.
|
||||
func (s *Server) serveUnion(w http.ResponseWriter, r *http.Request, path string, key func(record) (string, bool)) {
|
||||
in := r.URL.Query()
|
||||
page, err := parsePaging(in)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Keyed on the request's own params, not the upstream ones: upstreamParams
|
||||
// folds offset into limit, so different windows would collide.
|
||||
s.serveCached(w, r, path, in, func() (cachedResponse, error) {
|
||||
alive, err := s.aliveResults(r.Context(), path, page.upstreamParams(in))
|
||||
if err != nil {
|
||||
return cachedResponse{}, err
|
||||
}
|
||||
merged := mergeUnion(alive, key)
|
||||
sortRecords(merged, page.order)
|
||||
resp := cachedResponse{Body: encodeRecords(page.apply(merged)), Records: -1}
|
||||
if page.wantTotal {
|
||||
if total := sumTotals(alive); total >= 0 {
|
||||
resp.Records = total
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
})
|
||||
}
|
||||
|
||||
// A count row carries no certname, so the certname-keyed merge would collapse every backend's count into one backend's; aggregates take the summing path instead.
|
||||
func (s *Server) serveNodes(w http.ResponseWriter, r *http.Request) {
|
||||
if spec := parseAggregate(r.URL.Query().Get("query")); spec != nil {
|
||||
s.serveSummed(w, r, nodesPath, spec.columns)
|
||||
return
|
||||
}
|
||||
s.serveMerged(w, r, nodesPath, s.mergeNodesResponse)
|
||||
}
|
||||
|
||||
// Only aggregates merge: a resource record has no cross-backend identity to dedupe on, so a plain query stays on the pass-through path.
|
||||
func (s *Server) serveResources(w http.ResponseWriter, r *http.Request) {
|
||||
if spec := parseAggregate(r.URL.Query().Get("query")); spec != nil {
|
||||
s.serveSummed(w, r, resourcesPath, spec.columns)
|
||||
return
|
||||
}
|
||||
s.proxyUnmerged(w, r)
|
||||
}
|
||||
|
||||
// An `extract` query with a `function` column returns synthetic aggregate rows that carry no identity, so they are summed rather than unioned.
|
||||
func (s *Server) serveReports(w http.ResponseWriter, r *http.Request) {
|
||||
if spec := parseAggregate(r.URL.Query().Get("query")); spec != nil {
|
||||
s.serveSummed(w, r, reportsPath, spec.columns)
|
||||
return
|
||||
}
|
||||
s.serveUnion(w, r, reportsPath, reportKey)
|
||||
}
|
||||
|
||||
// Merged rows are fewer than the backends' combined records, so include_total reports the merged count rather than a sum of X-Records.
|
||||
func (s *Server) serveSummed(w http.ResponseWriter, r *http.Request, path string, columns func(map[string]json.RawMessage) ([]string, []string)) {
|
||||
in := r.URL.Query()
|
||||
page, err := parsePaging(in)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.serveCached(w, r, path, in, func() (cachedResponse, error) {
|
||||
alive, err := s.aliveResults(r.Context(), path, page.upstreamParams(in))
|
||||
if err != nil {
|
||||
return cachedResponse{}, err
|
||||
}
|
||||
merged := sumRows(alive, columns)
|
||||
sortRecords(merged, page.order)
|
||||
resp := cachedResponse{Body: encodeRecords(page.apply(merged)), Records: -1}
|
||||
if page.wantTotal {
|
||||
resp.Records = len(merged)
|
||||
}
|
||||
return resp, nil
|
||||
})
|
||||
}
|
||||
|
||||
// A backend without the report answers 404, indistinguishable from a failure, so every backend is consulted before serving empty.
|
||||
func (s *Server) serveFirstHolder(w http.ResponseWriter, r *http.Request) {
|
||||
results := s.fanOut(r.Context(), r.URL.Path, r.URL.Query())
|
||||
|
||||
var alive []backendResult
|
||||
for _, res := range results {
|
||||
if res.err != nil {
|
||||
s.log.Printf("info: backend %q has no %s: %v", res.name, r.URL.Path, res.err)
|
||||
continue
|
||||
}
|
||||
alive = append(alive, res)
|
||||
}
|
||||
if len(alive) == 0 {
|
||||
http.Error(w, "no backend holds this report", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
for _, res := range alive {
|
||||
if len(res.records) > 0 {
|
||||
writeJSON(w, rawRecords(res.records))
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, nil)
|
||||
}
|
||||
|
||||
// Returns errAllBackendsFailed only when every backend failed.
|
||||
func (s *Server) aliveResults(ctx context.Context, path string, params url.Values) ([]backendResult, error) {
|
||||
results := s.fanOut(ctx, path, params)
|
||||
|
||||
var alive []backendResult
|
||||
for _, res := range results {
|
||||
@@ -91,59 +268,144 @@ func (s *Server) serveMerged(w http.ResponseWriter, r *http.Request, path string
|
||||
alive = append(alive, res)
|
||||
}
|
||||
if len(alive) == 0 {
|
||||
http.Error(w, "all backends failed", http.StatusBadGateway)
|
||||
return nil, errAllBackendsFailed
|
||||
}
|
||||
return alive, nil
|
||||
}
|
||||
|
||||
// cachedResponse is the stored form of a merged response: the JSON body plus the
|
||||
// X-Records value it carried, so a cache hit reproduces both.
|
||||
type cachedResponse struct {
|
||||
Body json.RawMessage `json:"body"`
|
||||
Records int `json:"records"` // -1 when the response sets no X-Records
|
||||
}
|
||||
|
||||
// serveCached answers from the cache when the entry is fresh, otherwise runs
|
||||
// build — single-flighted, so N concurrent identical requests cause one upstream
|
||||
// fan-out — and stores the result. A build failure falls back to a stale entry
|
||||
// when one exists; that is the only path on which stale data is served. Paths
|
||||
// with no cache configured run build directly, unchanged.
|
||||
func (s *Server) serveCached(w http.ResponseWriter, r *http.Request, path string, params url.Values, build func() (cachedResponse, error)) {
|
||||
cache, enabled := s.cacheFor(path, params)
|
||||
if !enabled {
|
||||
resp, err := build()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeCached(w, resp)
|
||||
return
|
||||
}
|
||||
|
||||
merged := merge(alive)
|
||||
writeJSON(w, merged)
|
||||
}
|
||||
|
||||
// mergeNodesResponse merges /nodes results (dedupe by certname, newer wins).
|
||||
func (s *Server) mergeNodesResponse(results []backendResult) []json.RawMessage {
|
||||
return mergeNodes(s.byPrecedence(results))
|
||||
}
|
||||
|
||||
// mergeFactsResponse merges /facts results at node granularity, choosing each
|
||||
// certname's owner via the configured merge strategy.
|
||||
func (s *Server) mergeFactsResponse(results []backendResult) []json.RawMessage {
|
||||
ordered := s.byPrecedence(results)
|
||||
if s.cfg.Merge == mergeStatic {
|
||||
prefer := s.cfg.Prefer
|
||||
return mergeFacts(ordered, func(string) string { return prefer })
|
||||
key := cacheKey(path, params)
|
||||
var stale *CacheEntry
|
||||
ent, status, err := cache.Get(r.Context(), key)
|
||||
switch {
|
||||
case err != nil:
|
||||
s.log.Printf("warning: cache lookup for %s failed: %v", key, err)
|
||||
case status == CacheFresh:
|
||||
s.stale.markFresh()
|
||||
s.writeStored(w, ent.Body)
|
||||
return
|
||||
case status == CacheStale:
|
||||
stale = &ent
|
||||
}
|
||||
// freshness merge: attribute each certname to the backend with the newer
|
||||
// report_timestamp, taken from a short-TTL /nodes freshness map.
|
||||
fresh := s.freshnessMap(context.Background(), ordered)
|
||||
prefer := s.cfg.Prefer
|
||||
return mergeFacts(ordered, func(cn string) string {
|
||||
if b, ok := fresh[cn]; ok {
|
||||
return b
|
||||
|
||||
resp, err, _ := s.flights.Do(key, func() (cachedResponse, error) {
|
||||
built, buildErr := build()
|
||||
if buildErr != nil {
|
||||
return cachedResponse{}, buildErr
|
||||
}
|
||||
return prefer
|
||||
body, marshalErr := json.Marshal(built)
|
||||
if marshalErr != nil {
|
||||
s.log.Printf("warning: encoding cache entry for %s failed: %v", key, marshalErr)
|
||||
return built, nil
|
||||
}
|
||||
// The leader's request may be cancelled while followers still wait.
|
||||
if putErr := cache.Put(context.WithoutCancel(r.Context()), key, body); putErr != nil {
|
||||
s.log.Printf("warning: cache store for %s failed: %v", key, putErr)
|
||||
}
|
||||
return built, nil
|
||||
})
|
||||
if err != nil {
|
||||
if stale != nil {
|
||||
s.stale.markStale(time.Now())
|
||||
s.log.Printf("warning: serving stale %s from cache (stored %s): %v",
|
||||
path, stale.StoredAt.UTC().Format(time.RFC3339), err)
|
||||
s.writeStored(w, stale.Body)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
s.stale.markFresh()
|
||||
writeCached(w, resp)
|
||||
}
|
||||
|
||||
// byPrecedence orders results so the Prefer backend comes first, giving it the
|
||||
// tie-break on equal timestamps. Remaining backends keep config order.
|
||||
func (s *Server) byPrecedence(results []backendResult) []backendResult {
|
||||
ordered := make([]backendResult, len(results))
|
||||
copy(ordered, results)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return ordered[i].name == s.cfg.Prefer && ordered[j].name != s.cfg.Prefer
|
||||
})
|
||||
return ordered
|
||||
func (s *Server) writeStored(w http.ResponseWriter, body []byte) {
|
||||
var resp cachedResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
s.log.Printf("warning: unreadable cache entry: %v", err)
|
||||
http.Error(w, "unreadable cache entry", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeCached(w, resp)
|
||||
}
|
||||
|
||||
// freshnessMap returns a per-certname owner map derived from each backend's
|
||||
// /nodes report_timestamp, cached for cfg.FreshnessTTL. On cache miss it queries
|
||||
// /nodes from all backends; a backend that fails is simply absent from the map,
|
||||
// so its certnames fall back to precedence/Prefer.
|
||||
//
|
||||
// When the incoming request already carries /nodes data (results has records),
|
||||
// we still query /nodes broadly here because a /facts query's certname set can
|
||||
// differ from what the request's query filter returned. The cache keeps this
|
||||
// cheap under load.
|
||||
func writeCached(w http.ResponseWriter, resp cachedResponse) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if resp.Records >= 0 {
|
||||
w.Header().Set(recordsHeader, strconv.Itoa(resp.Records))
|
||||
}
|
||||
// resp.Body is shared with the cache and with every caller of a single
|
||||
// flight, so it is written, never appended to.
|
||||
body := []byte(resp.Body)
|
||||
if len(body) == 0 {
|
||||
body = []byte("[]")
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
_, _ = w.Write([]byte("\n"))
|
||||
}
|
||||
|
||||
func encodeRecords(recs []json.RawMessage) json.RawMessage {
|
||||
if recs == nil {
|
||||
recs = []json.RawMessage{}
|
||||
}
|
||||
b, err := json.Marshal(recs)
|
||||
if err != nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func queryParams(query string) url.Values {
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
return url.Values{"query": []string{query}}
|
||||
}
|
||||
|
||||
func rawRecords(recs []record) []json.RawMessage {
|
||||
out := make([]json.RawMessage, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, rec.Raw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) mergeNodesResponse(results []backendResult) []json.RawMessage {
|
||||
return mergeNodes(results)
|
||||
}
|
||||
|
||||
func (s *Server) mergeFactsResponse(results []backendResult) []json.RawMessage {
|
||||
if s.cfg.Merge == mergeStatic {
|
||||
return mergeFacts(results, nil)
|
||||
}
|
||||
fresh := s.freshnessMap(context.Background(), results)
|
||||
return mergeFacts(results, func(cn string) string { return fresh[cn] })
|
||||
}
|
||||
|
||||
// Queries /nodes unfiltered rather than reusing the request's results, because a /facts query's certname set can differ.
|
||||
func (s *Server) freshnessMap(ctx context.Context, _ []backendResult) freshness {
|
||||
s.mu.Lock()
|
||||
if s.freshData != nil && time.Since(s.freshAt) < s.cfg.FreshnessTTL {
|
||||
@@ -153,8 +415,7 @@ func (s *Server) freshnessMap(ctx context.Context, _ []backendResult) freshness
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Empty query = all nodes; cheap enough for a short-TTL cache.
|
||||
nodeResults := s.fanOut(ctx, nodesPath, "")
|
||||
nodeResults := s.fanOut(ctx, nodesPath, nil)
|
||||
var alive []backendResult
|
||||
for _, res := range nodeResults {
|
||||
if res.err != nil {
|
||||
@@ -163,7 +424,7 @@ func (s *Server) freshnessMap(ctx context.Context, _ []backendResult) freshness
|
||||
}
|
||||
alive = append(alive, res)
|
||||
}
|
||||
f := buildFreshness(s.byPrecedence(alive))
|
||||
f := buildFreshness(alive)
|
||||
|
||||
s.mu.Lock()
|
||||
s.freshData = f
|
||||
@@ -172,91 +433,156 @@ func (s *Server) freshnessMap(ctx context.Context, _ []backendResult) freshness
|
||||
return f
|
||||
}
|
||||
|
||||
// fanOut queries every backend concurrently for path?query=... and returns one
|
||||
// backendResult per backend, in config order.
|
||||
func (s *Server) fanOut(ctx context.Context, path, query string) []backendResult {
|
||||
// Returns one result per backend, in config order.
|
||||
func (s *Server) fanOut(ctx context.Context, path string, params url.Values) []backendResult {
|
||||
results := make([]backendResult, len(s.cfg.Backends))
|
||||
var wg sync.WaitGroup
|
||||
for i, b := range s.cfg.Backends {
|
||||
wg.Add(1)
|
||||
go func(i int, b Backend) {
|
||||
defer wg.Done()
|
||||
recs, err := s.queryBackend(ctx, b, path, query)
|
||||
results[i] = backendResult{name: b.Name, records: recs, err: err}
|
||||
recs, total, err := s.queryBackend(ctx, b, path, params)
|
||||
results[i] = backendResult{name: b.Name, records: recs, total: total, err: err}
|
||||
}(i, b)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// queryBackend performs one GET b.URL+path?query=... and decodes the JSON array.
|
||||
func (s *Server) queryBackend(ctx context.Context, b Backend, path, query string) ([]record, error) {
|
||||
// The returned count is the upstream X-Records value, or -1 when the backend sent none.
|
||||
func (s *Server) queryBackend(ctx context.Context, b Backend, path string, params url.Values) ([]record, int, error) {
|
||||
target := strings.TrimRight(b.URL, "/") + path
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, -1, err
|
||||
}
|
||||
if query != "" {
|
||||
q := url.Values{}
|
||||
q.Set("query", query)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
if len(params) > 0 {
|
||||
req.URL.RawQuery = params.Encode()
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, -1, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, -1, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
return nil, -1, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return decodeRecords(body)
|
||||
total := -1
|
||||
if n, err := strconv.Atoi(resp.Header.Get(recordsHeader)); err == nil && n >= 0 {
|
||||
total = n
|
||||
}
|
||||
recs, err := decodeRecords(body)
|
||||
return recs, total, err
|
||||
}
|
||||
|
||||
// proxyPrimary transparently forwards a non-merged /pdb/query/v4/* request to
|
||||
// the primary backend and streams the response back verbatim.
|
||||
func (s *Server) proxyPrimary(w http.ResponseWriter, r *http.Request) {
|
||||
b := s.cfg.PrimaryBackend()
|
||||
// The record shape is unknown, so a union would be guesswork: the first 2xx wins and the first error response is replayed when none succeeds.
|
||||
func (s *Server) proxyUnmerged(w http.ResponseWriter, r *http.Request) {
|
||||
var fallback *bufferedResponse
|
||||
for _, b := range s.cfg.Backends {
|
||||
resp, err := s.passThrough(r, b)
|
||||
if err != nil {
|
||||
s.log.Printf("warning: backend %q pass-through failed for %s: %v", b.Name, r.URL.Path, err)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
setContentType(w, resp.Header.Get("Content-Type"))
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if fallback == nil {
|
||||
fallback = &bufferedResponse{
|
||||
status: resp.StatusCode,
|
||||
contentType: resp.Header.Get("Content-Type"),
|
||||
body: body,
|
||||
}
|
||||
}
|
||||
}
|
||||
if fallback == nil {
|
||||
http.Error(w, "all backends failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
setContentType(w, fallback.contentType)
|
||||
w.WriteHeader(fallback.status)
|
||||
_, _ = w.Write(fallback.body)
|
||||
}
|
||||
|
||||
type bufferedResponse struct {
|
||||
status int
|
||||
contentType string
|
||||
body []byte
|
||||
}
|
||||
|
||||
func (s *Server) passThrough(r *http.Request, b Backend) (*http.Response, error) {
|
||||
target := strings.TrimRight(b.URL, "/") + r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
return s.client.Do(req)
|
||||
}
|
||||
|
||||
func setContentType(w http.ResponseWriter, contentType string) {
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
s.log.Printf("warning: primary %q pass-through failed for %s: %v", b.Name, r.URL.Path, err)
|
||||
http.Error(w, "primary backend failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// healthReport is the /healthz JSON body.
|
||||
type healthReport struct {
|
||||
Status string `json:"status"`
|
||||
Backends map[string]string `json:"backends"` // name -> "ok" | error text
|
||||
Cache cacheHealth `json:"cache"`
|
||||
}
|
||||
|
||||
type cacheHealth struct {
|
||||
Backend string `json:"backend"` // "memory" | "none"
|
||||
TTL string `json:"ttl"`
|
||||
Entries int `json:"entries"`
|
||||
StaleEntries int `json:"stale_entries"` // cached entries past their TTL
|
||||
Bytes int64 `json:"bytes"`
|
||||
ServingStale bool `json:"serving_stale"` // last cached response came from a stale entry
|
||||
StaleServed uint64 `json:"stale_served"`
|
||||
LastStale string `json:"last_stale_served,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) cacheHealth() cacheHealth {
|
||||
stats := CacheStats{Backend: "none"}
|
||||
ttl := time.Duration(0)
|
||||
if s.factsCache != nil {
|
||||
stats = s.factsCache.Stats()
|
||||
ttl = s.cfg.FactsTTL
|
||||
}
|
||||
serving, served, last := s.stale.snapshot()
|
||||
h := cacheHealth{
|
||||
Backend: stats.Backend,
|
||||
TTL: durationString(ttl),
|
||||
Entries: stats.Entries,
|
||||
StaleEntries: stats.StaleEntries,
|
||||
Bytes: stats.Bytes,
|
||||
ServingStale: serving,
|
||||
StaleServed: served,
|
||||
}
|
||||
if !last.IsZero() {
|
||||
h.LastStale = last.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// handleHealth probes every backend's /nodes endpoint with a trivial query and
|
||||
// reports per-backend reachability. Overall status is "ok" if any backend is
|
||||
// reachable, "degraded" if some fail, "down" if all fail (503 in that case).
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
probe := `["=","certname","pdbmux-healthz-probe"]`
|
||||
results := s.fanOut(r.Context(), nodesPath, probe)
|
||||
results := s.fanOut(r.Context(), nodesPath, queryParams(probe))
|
||||
|
||||
report := healthReport{Backends: map[string]string{}}
|
||||
report := healthReport{Backends: map[string]string{}, Cache: s.cacheHealth()}
|
||||
healthy := 0
|
||||
for _, res := range results {
|
||||
if res.err != nil {
|
||||
@@ -284,7 +610,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
_ = enc.Encode(report)
|
||||
}
|
||||
|
||||
// writeJSON writes a JSON array of raw records as a PuppetDB-style response.
|
||||
func writeJSON(w http.ResponseWriter, recs []json.RawMessage) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if recs == nil {
|
||||
|
||||
+786
-83
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user