- --config / PDBMUX_CONFIG select the file; /etc/pdbmux/config.yaml joins the default search order
pdbmux — merging PuppetDB proxy
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
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. Consider two backends being merged during a migration:
- old — the PuppetDB nodes are moving off, e.g.
http://puppetdb1.example.com:8080 - new — the PuppetDB nodes are moving on to, e.g.
http://puppetdb2.example.com:8080
Nodes move from old to new as they migrate. pdbmux merges both so consumers
don't have to know (or query twice) which PuppetDB a node currently lives in.
The backend names are arbitrary labels; there is no fixed number of backends.
Endpoints
pdbmux proxies GET requests only. The query param (PuppetDB AST JSON,
not PQL) is forwarded verbatim.
| Path | Behaviour |
|---|---|
GET /pdb/query/v4/nodes |
Fan out to all backends, dedupe by certname, keep the record with the newer report_timestamp. |
GET /pdb/query/v4/facts |
Fan out to all, and per certname keep all facts from the backend that owns that node (see merge semantics). |
GET /pdb/query/v4/reports |
Fan out to all and serve the union, deduped by report hash, re-ordered and re-paged across backends. |
GET /pdb/query/v4/events |
Fan out to all and serve the union, deduped by record identity, re-ordered and re-paged. |
GET /pdb/query/v4/reports/<hash>/{events,logs,metrics} |
Ask every backend; serve the answer from whichever backend actually holds that report. 404 when neither does. |
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. |
Fan-out is concurrent. If one backend errors or times out, pdbmux serves the
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 bycertname; the record with the strictly-newerreport_timestampwins. On a tie (or when a node exists in only one backend), the preferred backend's record is kept./facts— node-level granularity. For acertnamepresent in more than one backend,pdbmuxkeeps all of that node's facts from one backend and drops the other's, chosen by the merge strategy:freshness(default) — attribute eachcertnameto whichever backend holds its newerreport_timestamp.pdbmuxderives this from a per-certname freshness map built by querying/nodesfrom every backend, cached forfreshness_ttl(default 30s). Ties/fallbacks useprefer.static— always keep thepreferbackend's facts for shared nodes. No extra/nodesquery.- 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 that migrated legitimately has reports in the old PuppetDB and the new one and both belong in the merged view. Reports dedupe onhash; events, which carry no id of their own, dedupe on the verbatim record (a node briefly reporting to both PuppetDBs stores identical records in each). Records the merge cannot key —extract/group_byaggregate rows — are never deduped, so every backend's rows pass through even when byte-identical; summing those aggregates across backends is not implemented yet.
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_byis parsed and the merged set re-sorted by those fields (ties keep backend precedence). A record missing an ordered field sorts first.- Backends are asked for the first
offset + limitrecords — never anoffset— and the requested window is then cut from the merged, re-sorted set. include_total=truemakespdbmuxsum each backend'sX-Recordsheader into one merged header. Deduped records are counted once per backend, so the total is an upper bound.- A malformed
limit,offsetororder_bygets a400rather than being forwarded.
Config
Precedence (lowest → highest): defaults < config file < env vars (PDBMUX_*) < flags.
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.
listen: ":8080"
backends:
- name: old
url: http://puppetdb1.example.com:8080
- name: new
url: https://puppetdb2.example.com
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[*].url is a base URL (scheme://host[:port]); pdbmux appends
the /pdb/query/v4/... path per request.
| Env var | Overrides |
|---|---|
PDBMUX_CONFIG |
config file path (not a file key) |
PDBMUX_LISTEN |
listen |
PDBMUX_PRIMARY |
primary |
PDBMUX_MERGE |
merge |
PDBMUX_PREFER |
prefer |
PDBMUX_TIMEOUT |
timeout (Go duration, e.g. 10s) |
PDBMUX_FRESHNESS_TTL |
freshness_ttl |
PDBMUX_BACKENDS |
whole backend list, as name=url,name=url |
Flags: --config, --listen, --primary, --merge.
config init writes to --config/PDBMUX_CONFIG when set, else to
$XDG_CONFIG_HOME/pdbmux/config.yaml.
Running
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.
PDBMUX_BACKENDS='old=http://puppetdb1.example.com:8080,new=http://puppetdb2.example.com:8080' pdbmux
curl -s --get http://localhost:8080/pdb/query/v4/nodes \
--data-urlencode 'query=["=","certname","host1.example.com"]'
Build
make build (static binary into dist/), make test, make lint. Requires Go 1.25+.
Deployment
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.
A static (CGO_ENABLED=0) binary on a distroless base. Configure it with
PDBMUX_* env vars (at minimum PDBMUX_BACKENDS), or mount a config file — a
configmap at /etc/pdbmux/config.yaml is picked up with no env var at all, and
any other mount path works via PDBMUX_CONFIG. Env vars still override file
values, so the two mix. Stateless, so run as many replicas as you like; use
/healthz for liveness/readiness probes.