unkin-agent 2e2562018f index every name in an include list
include/contain/require take a list of names; parse the argument list so
the second and later names are indexed too.
2026-09-12 16:46:51 +10:00
2026-09-12 16:15:00 +10:00
2026-09-12 16:15:00 +10:00
2026-09-12 16:15:00 +10:00
2026-09-12 16:15:00 +10:00
2026-09-12 16:15:00 +10:00

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 source path 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>.pp is class roles::<t1>::<t2>::<t3>, which maps to hosts by exact enc_role. A role class is a leaf — nothing includes it, the ENC assigns it.
  • site/profiles/manifests/**.pp and modules/<mod>/manifests/**.pp become their autoloaded class names (init.pp counts as both the namespace and <namespace>::init) and need a reverse lookup.
  • modules/<mod>/{templates,files}/** and site/profiles/{templates,files}/** are attributed to the classes whose manifests reference them through template(), epp() or puppet:///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:

  1. .pp files under site/ and modules/ for include, contain, inherits, class { '...': }, capitalised resource references, create_resources(), $class::var reads and defined-type usage.
  2. hieradata/**/*.{yaml,eyaml} for the class as a list item under hiera_include:. profiles::base declares ($hiera_include - $hiera_exclude).include with a unique merge 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_include hit widens to every host.

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_include lists 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 --branch at 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.

S
Description
Go CLIs to diff Puppet catalogs between branches and map puppet-prod changes to affected hosts
Readme 490 KiB
Languages
Go 98.1%
Makefile 1.1%
Shell 0.8%