Work out which hosts a puppet-prod change can affect, so a catalog test can
fan out over exactly those hosts instead of the whole estate.
- add cmd/catalog-impact and internal/impact
- map hieradata paths onto fact selectors from the hiera hierarchy
- trace manifest classes back to role classes through include, contain,
class {} and hiera_include, to a fixed point
- fetch the estate fact table from pdbmux in one query via FactsByName
- widen to all hosts, loudly, for any path that matches no rule
- ship the binary and its completions in the RPM
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 pdbmux. 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/
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) |
--pdb |
pdbmux base URL (default https://pdbmux.k8s.syd1.au.unkin.net) |
--cert / --key / --cacert |
client mTLS material |
--timeout |
per-compile timeout (default 10m) |
--retry |
retries for transient compile or transport failures (default 2) |
--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
pdb_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_PDB |
pdb_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. pdbmux needs no
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 is a
hard error (exit 3) rather than a silent diff of an environment against itself.
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 |
--pdb |
pdbmux 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 |
pdbmux request timeout (default 1m) |
--pdb defaults to the shared configuration described under
Configuration, so pdb_url and CATALOG_DIFF_PDB apply to
both binaries. No credentials are needed.
Facts for the whole estate are fetched from pdbmux 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,class { '...': }, capitalised resource references and defined-type usage.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.
Paths outside the Puppet modulepath — .woodpecker/, doc/, spec/,
vendor/, Gemfile, *.md — cannot reach a catalog and are skipped.
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.