catalog-impact built its PuppetDB client without a Warn hook, so a host whose fact records all failed to decode vanished from the estate table with no warning on any output path. - wire catalog-impact's Warn hook into NodeWarnings - default an unset Warn to stderr so a dropped record is never silent - thread stderr through run() instead of writing os.Stderr directly - count dropped fact records in the explain resolution-incomplete line
puppet-catalog-tools
CLI tooling for the Puppet estate:
catalog-diff— compiles a host's catalog in a base environment and in the environment built from the branch under test, then diffs the two.catalog-impact— works out which hosts a control repo change can affect, and prints their hostnames.
Catalogs are compiled over the puppetserver API (POST /puppet/v4/catalog)
with facts read from PuppetDB. Nothing is compiled locally and nothing is
persisted on the server.
Install
dnf install puppet-catalog-tools # from the artifactapi rpm-internal repo
# or
make build # -> dist/bin/
catalog-diff
Usage
catalog-diff web01.example.net --branch benvin/motd-change
catalog-diff web01.example.net --environment benvin_motd_change --json
catalog-diff web01.example.net --branch benvin/motd-change --testonly
| Flag | Description |
|---|---|
-b, --branch |
branch under test (required unless --environment is given) |
--environment |
explicit environment name, bypasses branch name conversion |
--base |
base environment to diff against (default develop) |
--testonly |
compile the test environment only; result via exit code |
--json |
machine-readable output |
--server |
puppetserver base URL (default https://puppet.k8s.syd1.au.unkin.net:8140) |
--puppetdb |
PuppetDB base URL (default https://pdbmux.k8s.syd1.au.unkin.net) |
--cert / --key / --cacert |
client mTLS material |
--timeout |
per-compile timeout (default 10m; must be positive) |
--retry |
retries for transient compile or transport failures (default 2, range 0-20; a failed compiler-side PuppetDB query counts, a manifest error does not) |
--include-classes |
diff Class resources (ignored by default; they manage nothing) |
--ignore |
ignore resources matching Type[Title] (glob), repeatable |
--ignore-attr |
ignore a parameter on every resource, repeatable |
--display-datatype-changes |
report changes where only the datatype differs (28 vs "28") |
--display-detail-add |
expand the parameters of added resources |
--display-source-file-line |
print the manifest file and line each change came from |
Both compilations run concurrently, so a diff costs roughly one compile.
Exit codes
| Code | Meaning |
|---|---|
| 0 | catalogs compiled, no differences |
| 1 | catalog compilation failed in one or both environments |
| 2 | catalogs compiled, differences present |
| 3 | transport, auth, environment-mismatch or usage error |
--testonly prints nothing on success and returns 0, 1 or 3 — never 2.
Configuration
Precedence (lowest to highest): defaults < config file < env vars < flags.
Config file: $XDG_CONFIG_HOME/puppet-catalog-tools/config.yaml
(~/.config/puppet-catalog-tools/config.yaml). Write a default with
catalog-diff config init; print the active values with catalog-diff config show.
server_url: https://puppet.k8s.syd1.au.unkin.net:8140
puppetdb_url: https://pdbmux.k8s.syd1.au.unkin.net
cert: /etc/puppetlabs/puppet/ssl/certs/catalog-diff.pem
key: /etc/puppetlabs/puppet/ssl/private_keys/catalog-diff.pem
cacert: /etc/puppetlabs/puppet/ssl/certs/ca.pem
base_environment: develop
| Env var | Config key |
|---|---|
CATALOG_DIFF_SERVER |
server_url |
CATALOG_DIFF_PUPPETDB |
puppetdb_url |
CATALOG_DIFF_CERT |
cert |
CATALOG_DIFF_KEY |
key |
CATALOG_DIFF_CACERT |
cacert |
CATALOG_DIFF_BASE_ENVIRONMENT |
base_environment |
catalog-diff needs a client certificate whose certname is allowed to
POST /puppet/v4/catalog in the puppetserver auth.conf. PuppetDB is queried
without credentials.
Branch to environment name
Puppet environment names must match \A[a-z0-9_]+\Z, so a branch name is
converted the way g10k deploys it: every character outside [0-9A-Za-z_]
becomes _ (benvin/foo-bar -> benvin_foo_bar). Case is preserved, because
g10k preserves it — a branch with capitals therefore deploys to a directory
Puppet rejects, and catalog-diff fails fast telling you to rename the branch
or pass --environment. --environment bypasses the conversion entirely.
Environment override
Classification runs through an exec ENC (encapic -> encapi), and an
ENC-supplied environment outranks the requested one. Every request sets
options.prefer_requested_environment, and the returned catalog's own
environment is always compared against the one requested. A mismatch between
two named environments is a hard error (exit 3) rather than a silent diff of an
environment against itself. A response that decodes without an environment name
at all is reported as a protocol mismatch instead, because that is a client/
server problem rather than a classification one.
What a catalog diff cannot see
- Facts come from PuppetDB's last report, not a live agent run, so changes to custom facts are not tested.
- Provider changes and order-of-operations problems (dependency cycles) do not appear in a catalog.
- Catalogs are fetched over the API rather than compiled from a checkout, so a
change to a static file's content — without its
sourcepath changing — produces no readable text diff.
Puppet catalogs can contain unencrypted secrets. Parameters the catalog marks as sensitive are replaced with a digest so a change is still visible without the plaintext reaching stdout or a CI log; anything Puppet does not mark stays in the clear.
catalog-impact
Given a change to the puppet-prod control repo, catalog-impact works out
which files it touches and which hosts those files affect, then prints the
affected hostnames — one per line, sorted and deduplicated, ready to pipe into
a fan-out that compiles each host's catalog.
catalog-impact -r ~/src/puppet-prod # current branch vs origin/develop
catalog-impact -r ~/src/puppet-prod -b benvin/motd --explain
catalog-impact --files hieradata/common.yaml --json
git diff --name-only origin/develop... | paste -sd, | xargs catalog-impact --files
| Flag | Description |
|---|---|
-r, --repo |
path to a puppet-prod checkout (default .) |
-b, --branch |
branch under test (default: the repo's current branch) |
--base |
base ref to diff against (default origin/develop) |
--files |
explicit changed-file list, bypassing git entirely |
--puppetdb |
PuppetDB base URL (default https://pdbmux.k8s.syd1.au.unkin.net) |
--json |
structured output with per-host reasons |
--explain |
human-readable: for each host, which changed files hit it and why |
--limit |
cap the number of hosts emitted (0 = no cap) |
--timeout |
PuppetDB request timeout (default 1m) |
--puppetdb defaults to the shared configuration described under
Configuration, so puppetdb_url and CATALOG_DIFF_PUPPETDB
apply to both binaries. No credentials are needed.
Facts for the whole estate are fetched from PuppetDB in a single query and every selector is evaluated in memory, so the cost is one HTTP request regardless of how many files changed.
How a changed file becomes a host list
Classification runs through an exec ENC and manifests/site.pp is empty, so a
node's enc_role fact is its classification. Everything below keys off that
and the facts hiera.yaml interpolates.
Hieradata paths become fact selectors, mirroring the hierarchy:
| Path | Selects |
|---|---|
hieradata/nodes/<certname>.yaml |
that node |
hieradata/roles/<t1>[/<t2>[/<t3>]] |
role-tier prefix — roles/infra/puppet.yaml hits every roles::infra::puppet::* host |
hieradata/country/<c>/region/<r>/<t1>[/...] |
country and region and role-tier prefix |
hieradata/country/<c>/roles/<t1>[/...] |
country and role-tier prefix |
hieradata/country/<c>/region/<r> |
country and region |
hieradata/country/<c> |
country |
hieradata/virtual/<v>.yaml |
virtual == v |
hieradata/os/<name>/<name><major>[.<minor>].yaml |
os.name and os.release.major (and minor when present) |
hieradata/os/<name>/all_releases.yaml |
every host with that os.name |
hieradata/common.{yaml,eyaml} |
every host |
Repo-global files select every host: hiera.yaml, environment.conf,
manifests/site.pp, Puppetfile. The Puppetfile pins external modules whose
code is not in this repo, so there is nothing to trace — mapping it to the
whole estate is the deliberate conservative choice. lib/ under any module is
pluginsynced to every agent and is treated the same way.
Code paths become classes, then the classes are traced backwards:
site/roles/manifests/<t1>/<t2>/<t3>.ppis classroles::<t1>::<t2>::<t3>, which maps to hosts by exactenc_role. A role class is a leaf — nothing includes it, the ENC assigns it.site/profiles/manifests/**.ppandmodules/<mod>/manifests/**.ppbecome their autoloaded class names (init.ppcounts as both the namespace and<namespace>::init) and need a reverse lookup.modules/<mod>/{templates,files}/**andsite/profiles/{templates,files}/**are attributed to the classes whose manifests reference them throughtemplate(),epp()orpuppet:///modules/.... An unreferenced asset falls back to every class its module defines.
The reverse lookup
Finding what applies a class means searching two places, not one:
.ppfiles undersite/andmodules/forinclude,contain,inherits,class { '...': }, capitalised resource references,create_resources(),$class::varreads and defined-type usage. Comments are stripped with quote tracking and heredoc bodies are skipped, so a#in a string never truncates a line and documentation in a heredoc never becomes an edge.hieradata/**/*.{yaml,eyaml}for the class as a list item underhiera_include:.profiles::basedeclares($hiera_include - $hiera_exclude).includewith auniquemerge across the whole hierarchy, and it is included by essentially every role.
The second is not an edge case. hieradata/roles/apps/media/jellyfin.yaml is
the only thing that applies the jellyfin module — there is no include jellyfin anywhere — so a manifest-only search reports it as orphaned and finds
no hosts. A hiera_include hit is resolved through the hieradata path rules
above: the position of the listing file in the hierarchy decides which hosts
get the class. hiera_exclude entries are subtracted the same way.
The walk runs to a fixed point, because profiles include profiles several
layers deep — profiles::base alone pulls in around twenty-five. Cycles are
visited once.
Bias and limits
A missed host means an unvalidated catalog, so every ambiguity widens:
- a path matching no rule is reported on stderr and mapped to every host;
- an asset no manifest references widens to its whole module;
- an unrecognised hieradata path in a
hiera_includehit widens to every host; - a class reference that is not a literal name is reported as unresolved and
widened. A selector yields every literal name in its arms, and a bare
variable is followed through the assignments in its own file. What is left —
include "profile::role_${role}",include lookup('x')— is recorded against its file, and every class the reference could have named is treated as reaching the classes that file declares. The references appear in--explain, inunresolved_referencesin--json, on stderr in plain mode, and setsummary.resolution_incomplete.
A fact record that arrives with no certname belongs to no host, and one whose
value does not decode carries nothing to fold; either way it cannot enter the
estate table, and a host whose records all fail leaves the table with them. Both
are reported in node_warnings, in --explain and on stderr, and set
summary.resolution_incomplete too.
Paths outside the Puppet modulepath — .woodpecker/, doc/, spec/,
vendor/, Gemfile, *.md — cannot reach a catalog and are skipped.
--explain also lists changed files that mapped cleanly but reached no host,
which normally means the role they belong to has no members.
Two things the tool cannot see:
- A class nothing declares and no
hiera_includelists reaches no hosts. That is reported as an orphan on stderr rather than silently dropped. - The index is built from the checkout at
--repo, not from--branch. In CI the checkout is the branch under test; pointing--branchat a different ref than the working tree means classes the branch adds look orphaned.
Development
make build test lint fmt pre-commit
make rpm # build + package into dist/*.rpm via nfpm
Golden-file tests: go test ./internal/catalog -update rewrites them.