# 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 ```bash dnf install puppet-catalog-tools # from the artifactapi rpm-internal repo # or make build # -> dist/bin/ ``` # catalog-diff ## Usage ```bash 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`. ```yaml 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. ```bash 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](#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/.yaml` | that node | | `hieradata/roles/[/[/]]` | role-tier **prefix** — `roles/infra/puppet.yaml` hits every `roles::infra::puppet::*` host | | `hieradata/country//region//[/...]` | country and region and role-tier prefix | | `hieradata/country//roles/[/...]` | country and role-tier prefix | | `hieradata/country//region/` | country and region | | `hieradata/country/` | country | | `hieradata/virtual/.yaml` | `virtual` == v | | `hieradata/os//[.].yaml` | `os.name` and `os.release.major` (and minor when present) | | `hieradata/os//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///.pp` is class `roles::::::`, 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//manifests/**.pp` become their autoloaded class names (`init.pp` counts as both the namespace and `::init`) and need a reverse lookup. - `modules//{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 ```bash 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.