Files
puppet-catalog-tools/README.md
T
unkin-agent 6551ab9f13
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ship every cmd binary in the release
Take the binary list from cmd/ in the Makefile, the RPM packaging and the
release pipeline so a new tool ships without a pipeline edit.
2026-09-12 16:46:51 +10:00

254 lines
11 KiB
Markdown

# 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/<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
```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.