Files
unkin-agent 04ec9e6d41
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
carry dropped fact records through the failure path and count them
Report the accumulated warnings on stderr when the estate load fails, and fold
the dropped-record tally into the error. Count records rather than warning
messages in --explain, by carrying the count on the Warn hook.
2026-09-12 20:27:55 +10:00

276 lines
13 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 PuppetDB. 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`) |
| `--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`.
```yaml
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 `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 |
| `--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](#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>.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. 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.
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;
- 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`, in `unresolved_references` in `--json`, on stderr in plain mode,
and set `summary.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, counted in
`summary.dropped_fact_records`, and set `summary.resolution_incomplete` too. If
the drops empty the table altogether the run fails, and the warnings and the
tally go to stderr with the error rather than dying with it.
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.