Add repo scaffold and the catalog-diff CLI #1

Merged
benvin merged 9 commits from benvin/scaffold-catalog-diff into main 2026-09-12 18:18:20 +10:00
31 changed files with 4944 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
# built binaries (repo root only — not the cmd/ source dirs)
/catalog-diff
# cross-compiled release artifacts (e.g. catalog-diff-linux-amd64)
/catalog-diff-*
dist/
+32
View File
@@ -0,0 +1,32 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
# dnephin's go-vet/go-unit-tests hooks resolve packages from a root Go file,
# which this module does not have (every binary lives under cmd/).
- repo: local
hooks:
- id: go-vet
name: go vet
entry: go vet ./...
language: system
types: [go]
pass_filenames: false
- id: go-unit-tests
name: go test
entry: go test ./...
language: system
types: [go]
pass_filenames: false
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: build
image: golang:1.25
commands:
- make build
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+145
View File
@@ -0,0 +1,145 @@
when:
- event: tag
steps:
- name: test
image: golang:1.25
commands:
- go test -race ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Build every binary into dist/ (consumed by the RPM step) plus the
# cross-platform binaries attached to the Gitea release.
- name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- make build VERSION=${CI_COMMIT_TAG}
# Shell variables/expansions are escaped as $$ so Woodpecker leaves them
# for the shell instead of substituting them (as pipeline vars) at parse
# time. ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$.
- |
for name in catalog-diff; do
for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
os="$${osarch%/*}"; arch="$${osarch#*/}"
GOOS="$$os" GOARCH="$$arch" \
go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" \
-o "$${name}-$${os}-$${arch}" "./cmd/$${name}"
done
done
depends_on: [test]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Package the built binaries + generated shell completions into an RPM.
- name: package
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
commands:
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
depends_on: [build]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Publish the RPM to the artifactapi local rpm repo (a real yum repo;
# repodata regenerates automatically).
- name: upload-rpm
image: git.unkin.net/unkin/almalinux9-base:20260606
commands:
- |
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
REPO="rpm-internal"
for rpm in dist/*.rpm; do
FILE=$$(basename "$$rpm")
# artifactapi has no HEAD route (returns 405); probe with GET against
# the served path (RPMs are stored under Packages/) to avoid re-upload.
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
if [ "$$code" = "200" ]; then
echo "$$FILE already exists in $$REPO (HTTP $$code); skipping upload"
continue
fi
echo "Uploading $$FILE to $$REPO (existence probe returned $$code)"
curl -f -X PUT \
"$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \
-H "Content-Type: application/x-rpm" \
--data-binary @"$$rpm"
done
depends_on: [package]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
# Cut a Gitea release with the cross-platform binaries attached.
- name: release
image: git.unkin.net/unkin/almalinux9-base:20260606
environment:
RELEASER_TOKEN:
from_secret: RELEASER_TOKEN
commands:
- |
curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea
tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check
# Several tags can point at the same commit, so skip tags on the current
# commit and pick the newest semver tag that is a real ancestor.
CUR_SHA=$$(git rev-list -n1 "${CI_COMMIT_TAG}")
PREV_TAG=""
for t in $$(git tag --sort=-v:refname); do
[ "$$t" = "${CI_COMMIT_TAG}" ] && continue
[ "$$(git rev-list -n1 "$$t")" = "$$CUR_SHA" ] && continue
if git merge-base --is-ancestor "$$t" "${CI_COMMIT_TAG}" 2>/dev/null; then
PREV_TAG="$$t"; break
fi
done
if [ -n "$$PREV_TAG" ]; then
NOTES=$$(git log "$${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s")
else
NOTES=$$(git log --pretty=format:"- %s")
fi
tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "$${NOTES}" --login gitea --repo "${CI_REPO}"
RPM=$$(ls dist/*.rpm 2>/dev/null | head -1)
ASSETS="catalog-diff-linux-amd64 catalog-diff-linux-arm64 catalog-diff-darwin-amd64 catalog-diff-darwin-arm64"
[ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM"
sha256sum $$ASSETS > sha256sums.txt
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \
--login gitea --repo "${CI_REPO}"
depends_on: [upload-rpm]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
+33
View File
@@ -0,0 +1,33 @@
when:
- event: pull_request
steps:
- name: lint
image: golangci/golangci-lint:latest
commands:
- golangci-lint run ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: test
image: golang:1.25
commands:
- go test -v -race ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+89
View File
@@ -0,0 +1,89 @@
# AGENTS.md
## Project Overview
Go CLI tooling for the Puppet estate, shipped as one RPM
(`puppet-catalog-tools`). Every tool is a separate `main` package under `cmd/`;
shared code lives in `internal/`.
- **`catalog-diff`** — compiles a host's catalog twice (base environment and
the environment built from the branch under test) over the puppetserver API
and diffs the result.
## Structure
```
cmd/catalog-diff/ # the CLI (flags, exit codes, orchestration)
internal/catalog/ # catalog types, normalisation, diff engine, renderers,
# branch.go (BranchToEnvironment)
internal/puppetserver/ # mTLS client for POST /puppet/v4/catalog + /status/v1/simple
internal/puppetdb/ # unauthenticated fact client for PuppetDB
internal/config/ # XDG config file + env var loading
packaging/nfpm.yaml # RPM spec (envsubst-templated)
scripts/build-rpm.sh # completions + nfpm packaging
.woodpecker/ # CI: build, test, pre-commit (PR) + release (tag)
```
Adding a binary: create `cmd/<name>/`, add the name to `BINARIES` in the
Makefile and to `scripts/build-rpm.sh`, and add its files to
`packaging/nfpm.yaml`.
## Transport
- Catalogs: `POST /puppet/v4/catalog` with `persistence` off,
`options.prefer_requested_environment: true`, facts and synthesised trusted
facts in the body. Auth is client mTLS against the estate Puppet CA; the
certname must be allowed in the puppetserver `auth.conf`.
- Facts: `GET /pdb/query/v4/facts` with the AST query
`["=","certname","<certname>"]`. No auth, no client cert. The response is a
flat array of `{certname, environment, name, value}` records; a record for
another node is never folded into the result.
- The two services have different auth models and stay separate clients.
## Gotchas
- The compiler wraps its response as `{"catalog": {...}}`. `decodeCatalog`
unwraps it and still accepts a bare catalog; decoding straight into
`catalog.Catalog` silently yields an empty one.
- Only `type`, `title`, `tags` and `exported` are present on every resource.
`parameters` is absent on class/stage/anchor resources and `file`/`line` on
resources with no manifest location, so `Resource.Line` is a pointer and
`Resource.Params()` maps absent onto empty.
- An ENC-supplied environment outranks the requested one. Every compile checks
the returned catalog's `environment` and hard-fails on a mismatch — never
relax that check. A returned environment that is empty is a decode failure,
not a mismatch, and is reported as such.
- The compiler returns HTTP 500 for manifest errors too, so retryability is
decided from the error text: a failed PuppetDB query (`server_urls`,
`/pdb/query/`) is transient, a manifest error never is.
- Branch names are converted the way g10k does it (`\W` -> `_`, case
preserved). The rule lives in one function, `catalog.BranchToEnvironment`.
- Exit codes are a contract: 0 same, 1 compile failed, 2 differences, 3
transport/auth/mismatch/usage. `--testonly` never returns 2.
- Numeric flags are validated up front (`validate`). A negative `--retry` used
to skip the compile loop and leave a nil error inside `exitError`; that type
now prints safely whatever it holds.
- The two compiles share a cancellable context: the first failure abandons its
sibling. An error that is only that cancellation is not the cause and is not
what gets reported.
- Normalisation carries a pile of false-positive fixes (classes and exported
resources skipped, `before`/`require` dropped, `notify`/`subscribe` sorted,
File titles keyed by `path`, datatype-only changes suppressed). Removing one
floods the output with noise.
## Testing
```bash
make test # go test -v -race ./...
```
Everything is stubbed with `httptest` (a TLS server for puppetserver, a plain
one for PuppetDB) — no live estate access is required. Renderer output is
covered by golden files under `internal/catalog/testdata`; regenerate with
`go test ./internal/catalog -update`.
`internal/puppetserver/testdata/catalog_response.json` is a synthetic
OpenVoxServer 8.8 response, kept for its shape: the `catalog` envelope, a
string `version`, a null `code_id`, and resources that omit `parameters`,
`file` and `line`, plus the exported and `sensitive_parameters` cases. This
repo is public — hostnames, class names and paths in fixtures stay fake.
+78
View File
@@ -0,0 +1,78 @@
# All shipped binaries. Every tool is a separate main package under cmd/, so
# adding one is a matter of adding its name here.
BINARIES := catalog-diff
DIST := dist
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
.PHONY: all build test lint fmt pre-commit clean install completions rpm rpm-package patch minor major _tag
all: build
# Build every binary into dist/ so the nfpm packaging step
# (scripts/build-rpm.sh) can find them. Each main package needs its own -o, so
# they are built individually rather than with a single ./... invocation.
build:
@for b in $(BINARIES); do \
echo "building $$b"; \
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$$b ./cmd/$$b || exit 1; \
done
test:
go test -v -race ./...
lint:
golangci-lint run ./...
fmt:
gofmt -w .
pre-commit:
pre-commit run --all-files
clean:
rm -rf $(DIST) $(BINARIES)
install:
go install $(GOFLAGS) ./...
# Generate bash/zsh/fish completions for every binary into dist/completions.
completions: build
@mkdir -p $(DIST)/completions
@for b in $(BINARIES); do \
$(DIST)/$$b completion bash > $(DIST)/completions/$$b.bash; \
$(DIST)/$$b completion zsh > $(DIST)/completions/_$$b; \
$(DIST)/$$b completion fish > $(DIST)/completions/$$b.fish; \
done
# Build the binaries then package them (with completions) into an RPM via nfpm.
rpm: build rpm-package
# Package already-built binaries into an RPM (used by CI after the build step).
rpm-package:
./scripts/build-rpm.sh $(VERSION)
# Bump helpers — read the latest semver tag and create the next one.
# If no tag exists yet, start from v0.0.0.
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
_tag:
git push origin $(TAG)
+130 -1
View File
@@ -1,3 +1,132 @@
# puppet-catalog-tools
Go CLIs to diff Puppet catalogs between branches and map puppet-prod changes to affected hosts
CLI tooling for the Puppet estate. Currently one binary:
- **`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.
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/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.
## 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.
+451
View File
@@ -0,0 +1,451 @@
// Command catalog-diff compiles a host's Puppet catalog in two environments
// and reports what changes between them.
package main
import (
"context"
"errors"
"fmt"
"io"
"math/rand"
"os"
"sync"
"time"
"github.com/spf13/cobra"
"git.unkin.net/unkin/puppet-catalog-tools/internal/catalog"
"git.unkin.net/unkin/puppet-catalog-tools/internal/config"
"git.unkin.net/unkin/puppet-catalog-tools/internal/puppetdb"
"git.unkin.net/unkin/puppet-catalog-tools/internal/puppetserver"
)
var version = "dev"
// Exit codes, also documented in --help and the README.
const (
exitSame = 0 // compiled, no differences
exitCompile = 1 // catalog compilation failed
exitDifferent = 2 // compiled, differences present
exitTransport = 3 // transport, auth, environment-mismatch or usage error
)
// maxRetry bounds --retry: past this the backoff is already pinned at its
// ceiling and the run outlives any review it could inform.
const maxRetry = 20
const longHelp = `Compile a host's Puppet catalog in a base environment and in the environment
built from the branch under test, then diff the two.
Branch names are converted to environment names the way g10k deploys them:
every character outside [0-9A-Za-z_] becomes "_". Use --environment to bypass
that conversion.
Exit codes:
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 compiles the test environment only and returns 0, 1 or 3 (never 2).`
type options struct {
branch string
environment string
base string
testonly bool
jsonOut bool
server string
puppetdb string
cert string
key string
cacert string
timeout time.Duration
retry int
includeClasses bool
ignoreResources []string
ignoreAttrs []string
datatypeChanges bool
detailAdd bool
sourceFileLine bool
}
// exitError carries the exit code a failure should produce.
type exitError struct {
code int
err error
}
func (e *exitError) Error() string {
if e == nil {
return "<nil>"
}
if e.err == nil {
return fmt.Sprintf("failed with exit code %d", e.code)
}
return e.err.Error()
}
func (e *exitError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func fail(code int, format string, args ...any) *exitError {
return &exitError{code: code, err: fmt.Errorf(format, args...)}
}
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
os.Exit(exitTransport)
}
opts := &options{}
root := &cobra.Command{
Use: "catalog-diff <hostname>",
Short: "Diff a host's Puppet catalog between two environments",
Long: longHelp,
Args: cobra.ExactArgs(1),
Version: version,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return run(cmd.Context(), cmd.OutOrStdout(), args[0], opts)
},
}
f := root.Flags()
f.StringVarP(&opts.branch, "branch", "b", "", "branch under test (required unless --environment is given)")
f.StringVar(&opts.environment, "environment", "", "explicit puppet environment name, bypasses branch name conversion")
f.StringVar(&opts.base, "base", cfg.BaseEnvironment, "base environment to diff against")
f.BoolVar(&opts.testonly, "testonly", false, "compile the test environment only; no output, result via exit code")
f.BoolVar(&opts.jsonOut, "json", false, "machine-readable output")
f.StringVar(&opts.server, "server", cfg.ServerURL, "puppetserver base URL")
f.StringVar(&opts.puppetdb, "puppetdb", cfg.PuppetDBURL, "PuppetDB base URL")
f.StringVar(&opts.cert, "cert", cfg.Cert, "client certificate PEM (CATALOG_DIFF_CERT)")
f.StringVar(&opts.key, "key", cfg.Key, "client private key PEM (CATALOG_DIFF_KEY)")
f.StringVar(&opts.cacert, "cacert", cfg.CACert, "CA bundle PEM used to verify puppetserver (CATALOG_DIFF_CACERT)")
f.DurationVar(&opts.timeout, "timeout", 10*time.Minute, "per-compile timeout")
f.IntVar(&opts.retry, "retry", 2, "retries for transient compile or transport failures")
f.BoolVar(&opts.includeClasses, "include-classes", false, "diff Class resources, which manage nothing themselves")
f.StringArrayVar(&opts.ignoreResources, "ignore", nil, "ignore resources matching Type[Title] (glob), repeatable")
f.StringArrayVar(&opts.ignoreAttrs, "ignore-attr", nil, "ignore a parameter on every resource, repeatable")
f.BoolVar(&opts.datatypeChanges, "display-datatype-changes", false, "report changes where only the datatype differs (28 vs \"28\")")
f.BoolVar(&opts.detailAdd, "display-detail-add", false, "expand the parameters of added resources")
f.BoolVar(&opts.sourceFileLine, "display-source-file-line", false, "print the manifest file and line each change came from")
root.AddCommand(configCmd())
if err := root.Execute(); err != nil {
// errSilent only carries an exit code; the diff is already printed.
if !errors.Is(err, errSilent) {
fmt.Fprintln(os.Stderr, "error:", err)
}
var ee *exitError
if errors.As(err, &ee) {
os.Exit(ee.code)
}
os.Exit(exitTransport)
}
}
func configCmd() *cobra.Command {
cmd := &cobra.Command{Use: "config", Short: "Manage configuration"}
cmd.AddCommand(&cobra.Command{
Use: "init",
Short: "Write a default config file to " + config.Path(),
RunE: func(_ *cobra.Command, _ []string) error {
path, err := config.WriteDefault()
if err != nil {
return err
}
fmt.Println("Config written to", path)
return nil
},
})
cmd.AddCommand(&cobra.Command{
Use: "show",
Short: "Print the active configuration",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
fmt.Printf("config file : %s\n", config.Path())
fmt.Printf("server_url : %s\n", cfg.ServerURL)
fmt.Printf("puppetdb_url : %s\n", cfg.PuppetDBURL)
fmt.Printf("cert : %s\n", cfg.Cert)
fmt.Printf("key : %s\n", cfg.Key)
fmt.Printf("cacert : %s\n", cfg.CACert)
fmt.Printf("base_environment : %s\n", cfg.BaseEnvironment)
return nil
},
})
return cmd
}
// validate rejects flag values the rest of the run cannot act on. A negative
// retry count would skip the compile loop entirely and a non-positive timeout
// cancels every request before it is sent, so both are usage errors.
func validate(opts *options) error {
if opts.retry < 0 {
return fail(exitTransport, "--retry must be zero or more, got %d", opts.retry)
}
if opts.retry > maxRetry {
return fail(exitTransport, "--retry must be at most %d, got %d", maxRetry, opts.retry)
}
if opts.timeout <= 0 {
return fail(exitTransport, "--timeout must be positive, got %s", opts.timeout)
}
return nil
}
// resolveEnvironment turns the flags into the environment to test.
func resolveEnvironment(opts *options) (string, error) {
if opts.environment != "" && opts.branch != "" {
return "", fail(exitTransport, "--branch and --environment are mutually exclusive")
}
if opts.environment != "" {
if !catalog.ValidEnvironmentName(opts.environment) {
return "", fail(exitTransport, "%q is not a valid puppet environment name (must match [a-z0-9_]+)", opts.environment)
}
return opts.environment, nil
}
if opts.branch == "" {
return "", fail(exitTransport, "one of --branch or --environment is required")
}
env := catalog.BranchToEnvironment(opts.branch)
if !catalog.ValidEnvironmentName(env) {
return "", fail(exitTransport,
"branch %q deploys as environment %q, which puppet rejects (environment names must match [a-z0-9_]+). "+
"Rename the branch or pass --environment", opts.branch, env)
}
return env, nil
}
func run(ctx context.Context, out io.Writer, hostname string, opts *options) error {
if ctx == nil {
ctx = context.Background()
}
if err := validate(opts); err != nil {
return err
}
testEnv, err := resolveEnvironment(opts)
if err != nil {
return err
}
if !catalog.ValidEnvironmentName(opts.base) {
return fail(exitTransport, "%q is not a valid base environment name (must match [a-z0-9_]+)", opts.base)
}
if testEnv == opts.base && !opts.testonly {
return fail(exitTransport, "test and base environments are both %q; there is nothing to diff", testEnv)
}
tlsCfg, err := puppetserver.TLSConfig(opts.cert, opts.key, opts.cacert)
if err != nil {
return &exitError{code: exitTransport, err: err}
}
server := puppetserver.New(opts.server, tlsCfg, opts.timeout)
facts := puppetdb.New(opts.puppetdb, opts.timeout)
facts.Warn = func(msg string) { fmt.Fprintln(os.Stderr, "warning:", msg) }
healthCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := server.Health(healthCtx); err != nil {
return &exitError{code: exitTransport, err: err}
}
factValues, err := facts.Facts(ctx, hostname)
if err != nil {
return &exitError{code: exitTransport, err: err}
}
if opts.testonly {
_, err := compile(ctx, server, hostname, testEnv, factValues, opts)
return err
}
// Both compilations are independent, so run them together: one host costs
// one compile's wall clock rather than two. The first failure cancels its
// sibling — a quick manifest error should not wait out a full compile.
compileCtx, abandon := context.WithCancel(ctx)
defer abandon()
var (
wg sync.WaitGroup
testCat, baseCat *catalog.Catalog
testErr, baseErr error
)
wg.Add(2)
go func() {
defer wg.Done()
testCat, testErr = compile(compileCtx, server, hostname, testEnv, factValues, opts)
if testErr != nil {
abandon()
}
}()
go func() {
defer wg.Done()
baseCat, baseErr = compile(compileCtx, server, hostname, opts.base, factValues, opts)
if baseErr != nil {
abandon()
}
}()
wg.Wait()
// Report the test environment's failure first: it is the one under review,
// unless it only stopped because the base compile failed first.
switch {
case testErr != nil && baseErr != nil:
if abandoned(ctx, testErr) {
return baseErr
}
return testErr
case testErr != nil:
return testErr
case baseErr != nil:
return baseErr
}
normOpts := catalog.Options{
IncludeClasses: opts.includeClasses,
IgnoreResources: opts.ignoreResources,
IgnoreAttrs: opts.ignoreAttrs,
}
changes := catalog.Diff(
catalog.Normalize(baseCat, normOpts),
catalog.Normalize(testCat, normOpts),
catalog.DiffOptions{DisplayDatatypeChanges: opts.datatypeChanges},
)
report := catalog.NewReport(hostname, opts.base, testEnv, changes)
if opts.jsonOut {
err = catalog.RenderJSON(out, report)
} else {
err = catalog.RenderText(out, report, catalog.RenderOptions{
Colour: isTerminal(os.Stdout),
DetailAdd: opts.detailAdd,
SourceFileLine: opts.sourceFileLine,
})
}
if err != nil {
return &exitError{code: exitTransport, err: err}
}
if len(changes) > 0 {
return &exitError{code: exitDifferent, err: errSilent}
}
return nil
}
// errSilent marks an exit code that carries no message of its own: the diff
// itself has already been printed.
var errSilent = errors.New("differences present")
// abandoned reports whether err is only the fallout of cancelling a compile
// because its sibling failed first, rather than a failure in its own right.
func abandoned(ctx context.Context, err error) bool {
return ctx.Err() == nil && errors.Is(err, context.Canceled)
}
// compile runs one compilation, retrying transient failures, and maps the
// final failure onto an exit code.
func compile(ctx context.Context, server *puppetserver.Client, hostname, environment string, facts map[string]any, opts *options) (*catalog.Catalog, error) {
var lastErr error
for attempt := 0; attempt <= opts.retry; attempt++ {
if attempt > 0 {
// The cancellation, not the error that earned the retry, is why
// this compile stopped.
if err := sleepCtx(ctx, backoff(attempt)); err != nil {
if lastErr != nil {
err = fmt.Errorf("%w while retrying after: %v", err, lastErr)
}
return nil, &exitError{code: exitTransport, err: err}
}
}
cat, err := server.Compile(ctx, puppetserver.CompileRequest{
Certname: hostname,
Environment: environment,
Facts: facts,
TrustedFacts: puppetserver.TrustedFacts(hostname),
})
if err == nil {
return cat, nil
}
lastErr = err
if !transient(err) {
break
}
}
if lastErr == nil {
return nil, &exitError{code: exitTransport, err: fmt.Errorf("no compile was attempted for %s in %s", hostname, environment)}
}
var compileErr *puppetserver.CompileError
if errors.As(lastErr, &compileErr) {
return nil, &exitError{code: exitCompile, err: lastErr}
}
return nil, &exitError{code: exitTransport, err: lastErr}
}
// transient reports whether a failure is worth retrying: a transport error, a
// server-side 5xx, or a compile the compiler's own PuppetDB query broke. A
// manifest that does not compile, an environment mismatch and an unusable
// response shape are all settled answers and are not retried.
func transient(err error) bool {
var mismatch *puppetserver.EnvironmentMismatchError
if errors.As(err, &mismatch) {
return false
}
var respErr *puppetserver.ResponseError
if errors.As(err, &respErr) {
return false
}
var compileErr *puppetserver.CompileError
if errors.As(err, &compileErr) {
return compileErr.Transient()
}
return true
}
// backoff grows exponentially from 2s and adds up to 50% jitter, so parallel
// compiles do not retry in lockstep against an autoscaling compiler pool. The
// shift is capped: an unbounded one overflows into a negative delay.
func backoff(attempt int) time.Duration {
const (
start = 2 * time.Second
limit = 30 * time.Second
)
d := limit
if attempt >= 1 && attempt < 32 {
if shifted := start << (attempt - 1); shifted > 0 && shifted < limit {
d = shifted
}
}
return d + time.Duration(rand.Int63n(int64(d/2)))
}
func sleepCtx(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func isTerminal(f *os.File) bool {
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
+585
View File
@@ -0,0 +1,585 @@
package main
import (
"bytes"
"context"
"encoding/json"
"encoding/pem"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
const factsBody = `[
{"certname":"host.example.net","environment":"develop","name":"os","value":{"family":"RedHat"}}
]`
func catalogFor(environment, content string) string {
return `{"name":"host.example.net","environment":"` + environment + `","version":1,"classes":["settings"],
"resources":[
{"type":"File","title":"/etc/motd","file":"site.pp","line":9,"parameters":{"ensure":"file","content":"` + content + `"}}
]}`
}
// testServers stands up a fake puppetserver (TLS) and PuppetDB (plain), and
// returns options wired to them with the server's own cert as the CA bundle.
func testServers(t *testing.T, compile http.HandlerFunc) *options {
t.Helper()
puppet := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/status/v1/simple" {
_, _ = w.Write([]byte("running"))
return
}
compile(w, r)
}))
t.Cleanup(puppet.Close)
pdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(factsBody))
}))
t.Cleanup(pdb.Close)
caPath := filepath.Join(t.TempDir(), "ca.pem")
block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: puppet.Certificate().Raw})
if err := os.WriteFile(caPath, block, 0o600); err != nil {
t.Fatal(err)
}
return &options{
base: "develop",
server: puppet.URL,
puppetdb: pdb.URL,
cacert: caPath,
timeout: 10 * time.Second,
}
}
func exitCode(t *testing.T, err error) int {
t.Helper()
if err == nil {
return exitSame
}
var ee *exitError
if !errors.As(err, &ee) {
t.Fatalf("error carries no exit code: %v", err)
}
return ee.code
}
func TestResolveEnvironment(t *testing.T) {
tests := []struct {
name string
opts options
want string
wantCode int
}{
{name: "branch is sanitised", opts: options{branch: "benvin/foo-bar"}, want: "benvin_foo_bar"},
{name: "explicit environment wins", opts: options{environment: "develop"}, want: "develop"},
{name: "neither given", opts: options{}, wantCode: exitTransport},
{name: "both given", opts: options{branch: "a", environment: "b"}, wantCode: exitTransport},
{name: "branch with capitals is rejected", opts: options{branch: "benvin/PUP-1"}, wantCode: exitTransport},
{name: "invalid explicit environment", opts: options{environment: "Not-Valid"}, wantCode: exitTransport},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := resolveEnvironment(&tc.opts)
if tc.wantCode != 0 {
if code := exitCode(t, err); code != tc.wantCode {
t.Fatalf("exit code %d, want %d (err %v)", code, tc.wantCode, err)
}
return
}
if err != nil {
t.Fatalf("resolveEnvironment: %v", err)
}
if got != tc.want {
t.Errorf("= %q, want %q", got, tc.want)
}
})
}
}
func TestResolveEnvironmentCapitalsMessage(t *testing.T) {
_, err := resolveEnvironment(&options{branch: "benvin/PUP-1"})
if err == nil {
t.Fatal("expected an error")
}
msg := err.Error()
for _, want := range []string{"benvin/PUP-1", "benvin_PUP_1", "--environment"} {
if !strings.Contains(msg, want) {
t.Errorf("message missing %q: %s", want, msg)
}
}
}
func TestRunDiff(t *testing.T) {
opts := testServers(t, func(w http.ResponseWriter, r *http.Request) {
body := readEnvironment(t, r)
if body == "develop" {
_, _ = w.Write([]byte(catalogFor("develop", "old")))
return
}
_, _ = w.Write([]byte(catalogFor("benvin_test", "new")))
})
opts.environment = "benvin_test"
var out bytes.Buffer
err := run(context.Background(), &out, "host.example.net", opts)
if code := exitCode(t, err); code != exitDifferent {
t.Fatalf("exit code %d, want %d (err %v)", code, exitDifferent, err)
}
if !strings.Contains(out.String(), "File[/etc/motd] content: old -> new") {
t.Errorf("diff not rendered:\n%s", out.String())
}
}
func TestRunJSON(t *testing.T) {
opts := testServers(t, func(w http.ResponseWriter, r *http.Request) {
if readEnvironment(t, r) == "develop" {
_, _ = w.Write([]byte(catalogFor("develop", "old")))
return
}
_, _ = w.Write([]byte(catalogFor("benvin_test", "new")))
})
opts.environment = "benvin_test"
opts.jsonOut = true
var out bytes.Buffer
err := run(context.Background(), &out, "host.example.net", opts)
if code := exitCode(t, err); code != exitDifferent {
t.Fatalf("exit code %d, want %d (err %v)", code, exitDifferent, err)
}
if !strings.Contains(out.String(), `"base_environment": "develop"`) {
t.Errorf("unexpected json:\n%s", out.String())
}
}
func TestRunTestonlyIsSilent(t *testing.T) {
var compiled []string
opts := testServers(t, func(w http.ResponseWriter, r *http.Request) {
env := readEnvironment(t, r)
compiled = append(compiled, env)
_, _ = w.Write([]byte(catalogFor(env, "new")))
})
opts.environment = "benvin_test"
opts.testonly = true
var out bytes.Buffer
if err := run(context.Background(), &out, "host.example.net", opts); err != nil {
t.Fatalf("run: %v", err)
}
if out.Len() != 0 {
t.Errorf("--testonly printed output: %q", out.String())
}
if len(compiled) != 1 || compiled[0] != "benvin_test" {
t.Errorf("--testonly must compile the test environment only, compiled %v", compiled)
}
}
func TestRunCompilationFailureExit1(t *testing.T) {
opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"kind":"puppet.server/compilation-error","message":"Server Error: Syntax error at '}'","issue_kind":"PARSE_ERROR"}`))
})
opts.environment = "benvin_test"
err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)
if code := exitCode(t, err); code != exitCompile {
t.Fatalf("exit code %d, want %d (err %v)", code, exitCompile, err)
}
}
func TestRunEnvironmentMismatchExit3(t *testing.T) {
opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) {
// The ENC overrides the requested environment.
_, _ = w.Write([]byte(catalogFor("develop", "new")))
})
opts.environment = "benvin_test"
err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)
if code := exitCode(t, err); code != exitTransport {
t.Fatalf("exit code %d, want %d (err %v)", code, exitTransport, err)
}
if !strings.Contains(err.Error(), "environment mismatch") {
t.Errorf("unhelpful mismatch error: %v", err)
}
}
func TestRunUsageErrors(t *testing.T) {
opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(catalogFor("develop", "old")))
})
if code := exitCode(t, run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)); code != exitTransport {
t.Errorf("missing --branch/--environment: exit %d, want %d", code, exitTransport)
}
same := *opts
same.environment = "develop"
if code := exitCode(t, run(context.Background(), &bytes.Buffer{}, "host.example.net", &same)); code != exitTransport {
t.Errorf("base == test: exit %d, want %d", code, exitTransport)
}
badCA := *opts
badCA.environment = "benvin_test"
badCA.cacert = "/nonexistent/ca.pem"
if code := exitCode(t, run(context.Background(), &bytes.Buffer{}, "host.example.net", &badCA)); code != exitTransport {
t.Errorf("unreadable CA bundle: exit %d, want %d", code, exitTransport)
}
}
func TestRunUnreachableServerExit3(t *testing.T) {
opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(catalogFor("benvin_test", "new")))
})
opts.environment = "benvin_test"
opts.server = "https://127.0.0.1:1"
if code := exitCode(t, run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)); code != exitTransport {
t.Errorf("exit %d, want %d", code, exitTransport)
}
}
// readEnvironment returns the environment a compile request asked for.
func readEnvironment(t *testing.T, r *http.Request) string {
t.Helper()
var body struct {
Environment string `json:"environment"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decoding compile request: %v", err)
}
return body.Environment
}
func TestRunIdenticalCatalogsExit0(t *testing.T) {
opts := testServers(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(catalogFor(readEnvironment(t, r), "same")))
})
opts.environment = "benvin_test"
var out bytes.Buffer
if err := run(context.Background(), &out, "host.example.net", opts); err != nil {
t.Fatalf("identical catalogs must exit 0: %v", err)
}
if !strings.Contains(out.String(), "No differences between environments.") {
t.Errorf("unexpected output:\n%s", out.String())
}
}
func TestRunRetriesTransientFailures(t *testing.T) {
var attempts int
var mu sync.Mutex
opts := testServers(t, func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
attempts++
first := attempts == 1
mu.Unlock()
if first {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("bad gateway"))
return
}
_, _ = w.Write([]byte(catalogFor(readEnvironment(t, r), "same")))
})
opts.environment = "benvin_test"
opts.retry = 2
if err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts); err != nil {
t.Fatalf("a 502 should have been retried: %v", err)
}
mu.Lock()
defer mu.Unlock()
if attempts < 3 {
t.Errorf("expected a retry, saw %d compile attempts", attempts)
}
}
func TestRunDoesNotRetryCompilationErrors(t *testing.T) {
var attempts int
var mu sync.Mutex
opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
attempts++
mu.Unlock()
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"kind":"puppet.server/compilation-error","message":"Syntax error"}`))
})
opts.environment = "benvin_test"
opts.retry = 3
opts.testonly = true
if code := exitCode(t, run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)); code != exitCompile {
t.Fatalf("exit %d, want %d", code, exitCompile)
}
mu.Lock()
defer mu.Unlock()
if attempts != 1 {
t.Errorf("a manifest error must not be retried, saw %d attempts", attempts)
}
}
func TestRunIgnoreFlags(t *testing.T) {
opts := testServers(t, func(w http.ResponseWriter, r *http.Request) {
if readEnvironment(t, r) == "develop" {
_, _ = w.Write([]byte(catalogFor("develop", "old")))
return
}
_, _ = w.Write([]byte(catalogFor("benvin_test", "new")))
})
opts.environment = "benvin_test"
opts.ignoreResources = []string{"File[/etc/*]"}
var out bytes.Buffer
if err := run(context.Background(), &out, "host.example.net", opts); err != nil {
t.Fatalf("ignored resource should leave no differences: %v", err)
}
if !strings.Contains(out.String(), "No differences") {
t.Errorf("--ignore not applied:\n%s", out.String())
}
}
// wrappedCatalogFor is the shape OpenVoxServer 8.8 actually returns: the
// catalog under a single "catalog" key.
func wrappedCatalogFor(environment, content string) string {
return `{"catalog":` + catalogFor(environment, content) + `}`
}
func TestRunAgainstWrappedResponses(t *testing.T) {
opts := testServers(t, func(w http.ResponseWriter, r *http.Request) {
if readEnvironment(t, r) == "develop" {
_, _ = w.Write([]byte(wrappedCatalogFor("develop", "old")))
return
}
_, _ = w.Write([]byte(wrappedCatalogFor("benvin_test", "new")))
})
opts.environment = "benvin_test"
var out bytes.Buffer
err := run(context.Background(), &out, "host.example.net", opts)
if code := exitCode(t, err); code != exitDifferent {
t.Fatalf("exit code %d, want %d (err %v)", code, exitDifferent, err)
}
if !strings.Contains(out.String(), "File[/etc/motd] content: old -> new") {
t.Errorf("wrapped response not diffed:\n%s", out.String())
}
}
func TestRunUnknownResponseShapeDoesNotBlameTheENC(t *testing.T) {
var attempts int
var mu sync.Mutex
opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
attempts++
mu.Unlock()
_, _ = w.Write([]byte(`{"unexpected":{"environment":"benvin_test"}}`))
})
opts.environment = "benvin_test"
opts.testonly = true
opts.retry = 3
err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)
if code := exitCode(t, err); code != exitTransport {
t.Fatalf("exit %d, want %d (err %v)", code, exitTransport, err)
}
if strings.Contains(err.Error(), "encapi") || strings.Contains(err.Error(), "environment mismatch") {
t.Errorf("a decode failure must not be reported as an ENC problem: %v", err)
}
mu.Lock()
defer mu.Unlock()
if attempts != 1 {
t.Errorf("an unusable response shape must not be retried, saw %d attempts", attempts)
}
}
const pdbFailureBody = "Internal Server Error: org.jruby.exceptions.RuntimeError: (PreformattedError) Evaluation Error: " +
"Error while evaluating a Resource Statement, Evaluation Error: Error while evaluating a Function Call, " +
"Failed to execute '/pdb/query/v4' on at least 1 of the following 'server_urls': https://puppetdb.example.net " +
"(file: /etc/puppetlabs/code/environments/develop/site/profiles/manifests/app/client.pp, line: 31, column: 29) " +
"on node host.example.net"
func TestRunRetriesPuppetDBQueryFailures(t *testing.T) {
var attempts int
var mu sync.Mutex
opts := testServers(t, func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
attempts++
first := attempts == 1
mu.Unlock()
if first {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(pdbFailureBody))
return
}
_, _ = w.Write([]byte(wrappedCatalogFor(readEnvironment(t, r), "same")))
})
opts.environment = "benvin_test"
opts.testonly = true
opts.retry = 2
if err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts); err != nil {
t.Fatalf("a failed PuppetDB query should have been retried: %v", err)
}
mu.Lock()
defer mu.Unlock()
if attempts != 2 {
t.Errorf("expected one retry, saw %d compile attempts", attempts)
}
}
func TestRunDoesNotRetryManifestErrorsReturnedAs500(t *testing.T) {
var attempts int
var mu sync.Mutex
opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
attempts++
mu.Unlock()
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Internal Server Error: org.jruby.exceptions.RuntimeError: (Error) " +
"Could not find class roles::app::demo::node for host.example.net on node host.example.net"))
})
opts.environment = "benvin_test"
opts.testonly = true
opts.retry = 3
if code := exitCode(t, run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)); code != exitCompile {
t.Fatalf("exit %d, want %d", code, exitCompile)
}
mu.Lock()
defer mu.Unlock()
if attempts != 1 {
t.Errorf("a manifest error must not be retried even at HTTP 500, saw %d attempts", attempts)
}
}
func TestValidateRejectsOutOfRangeFlags(t *testing.T) {
tests := []struct {
name string
opts options
want string
}{
{name: "negative retry", opts: options{retry: -1, timeout: time.Minute}, want: "--retry"},
{name: "retry above the cap", opts: options{retry: maxRetry + 1, timeout: time.Minute}, want: "--retry"},
{name: "zero timeout", opts: options{}, want: "--timeout"},
{name: "negative timeout", opts: options{timeout: -time.Second}, want: "--timeout"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validate(&tc.opts)
if code := exitCode(t, err); code != exitTransport {
t.Fatalf("exit %d, want %d", code, exitTransport)
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("message must name %s: %v", tc.want, err)
}
})
}
}
func TestValidateAcceptsBoundaryValues(t *testing.T) {
for _, opts := range []options{
{retry: 0, timeout: time.Nanosecond},
{retry: maxRetry, timeout: time.Hour},
} {
if err := validate(&opts); err != nil {
t.Errorf("validate(%+v) = %v, want nil", opts, err)
}
}
}
func TestRunRejectsANegativeRetryWithoutPanicking(t *testing.T) {
opts := testServers(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(catalogFor(readEnvironment(t, r), "same")))
})
opts.environment = "benvin_test"
opts.retry = -1
err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)
if code := exitCode(t, err); code != exitTransport {
t.Fatalf("exit %d, want %d (err %v)", code, exitTransport, err)
}
// main prints this before exiting; a nil inner error used to panic here.
if msg := err.Error(); !strings.Contains(msg, "--retry") {
t.Errorf("unhelpful usage error: %s", msg)
}
}
func TestExitErrorWithNoInnerErrorIsPrintable(t *testing.T) {
e := &exitError{code: exitTransport}
if msg := e.Error(); !strings.Contains(msg, "3") {
t.Errorf("Error() = %q, want the exit code", msg)
}
if e.Unwrap() != nil {
t.Error("Unwrap of an empty exitError must be nil")
}
var nilErr *exitError
if msg := nilErr.Error(); msg == "" {
t.Error("a nil *exitError must still print something")
}
}
func TestBackoffStaysWithinBounds(t *testing.T) {
for _, attempt := range []int{0, 1, 2, 5, 31, 32, 64, 1 << 30} {
d := backoff(attempt)
if d <= 0 || d > 45*time.Second {
t.Errorf("backoff(%d) = %s, out of range", attempt, d)
}
}
}
func TestRunCancelsTheSiblingCompileOnFirstFailure(t *testing.T) {
const slow = 4 * time.Second
opts := testServers(t, func(w http.ResponseWriter, r *http.Request) {
if readEnvironment(t, r) == "develop" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"kind":"puppet.server/compilation-error","message":"Syntax error"}`))
return
}
select {
case <-r.Context().Done():
case <-time.After(slow):
_, _ = w.Write([]byte(catalogFor("benvin_test", "new")))
}
})
opts.environment = "benvin_test"
start := time.Now()
err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)
elapsed := time.Since(start)
// The base compile failed for real; the cancelled sibling is not the cause.
if code := exitCode(t, err); code != exitCompile {
t.Fatalf("exit %d, want %d (err %v)", code, exitCompile, err)
}
if elapsed > slow/2 {
t.Errorf("the sibling compile was not cancelled: run took %s", elapsed)
}
}
func TestRunReportsCancellationDuringRetryBackoff(t *testing.T) {
opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("bad gateway"))
})
opts.environment = "benvin_test"
opts.testonly = true
opts.retry = 5
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
err := run(ctx, &bytes.Buffer{}, "host.example.net", opts)
if code := exitCode(t, err); code != exitTransport {
t.Fatalf("exit %d, want %d (err %v)", code, exitTransport, err)
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("the cancellation must be the reported cause, got %v", err)
}
}
+13
View File
@@ -0,0 +1,13 @@
module git.unkin.net/unkin/puppet-catalog-tools
go 1.25.0
require (
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
+13
View File
@@ -0,0 +1,13 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+23
View File
@@ -0,0 +1,23 @@
package catalog
import "regexp"
// reInvalidCharacters mirrors g10k's branch sanitisation (\W -> "_").
var reInvalidCharacters = regexp.MustCompile(`\W`)
// reValidEnvironment is Puppet's own environment name rule.
var reValidEnvironment = regexp.MustCompile(`^[a-z0-9_]+$`)
// BranchToEnvironment converts a git branch name into the environment
// directory name g10k deploys it as: every character outside [0-9A-Za-z_]
// becomes "_". Case is preserved, because g10k preserves it.
func BranchToEnvironment(branch string) string {
return reInvalidCharacters.ReplaceAllString(branch, "_")
}
// ValidEnvironmentName reports whether name is a legal Puppet environment
// name. g10k can produce names Puppet rejects (it keeps uppercase), so
// callers must check the result of BranchToEnvironment before using it.
func ValidEnvironmentName(name string) bool {
return reValidEnvironment.MatchString(name)
}
+48
View File
@@ -0,0 +1,48 @@
package catalog
import "testing"
func TestBranchToEnvironment(t *testing.T) {
tests := []struct {
branch string
want string
}{
{"develop", "develop"},
{"benvin/foo-bar", "benvin_foo_bar"},
{"benvin/scaffold-catalog-diff", "benvin_scaffold_catalog_diff"},
{"feature/PUP-1234", "feature_PUP_1234"},
{"release/1.2.3", "release_1_2_3"},
{"already_ok_123", "already_ok_123"},
{"dots.and spaces", "dots_and_spaces"},
{"trailing/", "trailing_"},
{"", ""},
{"UPPER", "UPPER"},
{"héllo", "h_llo"},
}
for _, tc := range tests {
if got := BranchToEnvironment(tc.branch); got != tc.want {
t.Errorf("BranchToEnvironment(%q) = %q, want %q", tc.branch, got, tc.want)
}
}
}
func TestValidEnvironmentName(t *testing.T) {
tests := []struct {
name string
want bool
}{
{"develop", true},
{"benvin_foo_bar", true},
{"env123", true},
{"feature_PUP_1234", false},
{"UPPER", false},
{"has-dash", false},
{"has.dot", false},
{"", false},
}
for _, tc := range tests {
if got := ValidEnvironmentName(tc.name); got != tc.want {
t.Errorf("ValidEnvironmentName(%q) = %v, want %v", tc.name, got, tc.want)
}
}
}
+243
View File
@@ -0,0 +1,243 @@
package catalog
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"path"
"sort"
"strconv"
"strings"
)
// Catalog is the subset of a Puppet v4 catalog this tool reads.
type Catalog struct {
Name string `json:"name"`
Version json.RawMessage `json:"version"`
Environment string `json:"environment"`
CodeID string `json:"code_id"`
CatalogUUID string `json:"catalog_uuid"`
Tags []string `json:"tags"`
Classes []string `json:"classes"`
SensitiveParameters []string `json:"sensitive_parameters"`
Resources []Resource `json:"resources"`
}
// Resource is a single catalog resource. Only type, title, tags and exported
// are guaranteed: the compiler omits parameters on class, stage and anchor
// resources, and omits file and line on resources with no manifest location.
// Line is a pointer so an omitted line stays distinguishable from line 0.
type Resource struct {
Type string `json:"type"`
Title string `json:"title"`
Parameters map[string]any `json:"parameters"`
Tags []string `json:"tags"`
Exported bool `json:"exported"`
File string `json:"file"`
Line *int `json:"line"`
SensitiveParameters []string `json:"sensitive_parameters"`
}
// Key is the "Type[Title]" identity used to match resources across catalogs.
func (r Resource) Key() string { return r.Type + "[" + r.Title + "]" }
// Params returns the resource's parameters, mapping an omitted "parameters"
// key onto an empty set so absent and empty compare and render identically.
func (r Resource) Params() map[string]any {
if r.Parameters == nil {
return map[string]any{}
}
return r.Parameters
}
// HasSource reports whether the compiler supplied a manifest location.
func (r Resource) HasSource() bool { return r.File != "" && r.Line != nil }
// Options controls normalisation. The zero value is the default behaviour:
// classes ignored, exported resources skipped, ordering-only metaparameters
// dropped.
type Options struct {
// IncludeClasses keeps Class resources, which manage nothing themselves.
IncludeClasses bool
// IgnoreResources holds "Type[Title]" patterns (glob) to drop entirely.
IgnoreResources []string
// IgnoreAttrs holds parameter names to drop from every resource.
IgnoreAttrs []string
}
// noiseParameters never carry change signal. before/require are ordering only
// and their targets are rarely visible; notify/subscribe are kept because they
// cause real work, but their arrays are sorted before comparison.
var noiseParameters = map[string]bool{
"loglevel": true,
"stage": true,
"before": true,
"require": true,
}
// relationshipParameters are the kept metaparameters whose value Puppet writes
// inconsistently as either a scalar or an array.
var relationshipParameters = map[string]bool{
"notify": true,
"subscribe": true,
}
// Normalize indexes a catalog by resource key, applying every normalisation
// that stops known false positives: classes and exported resources dropped,
// noise parameters stripped, File titles keyed by path, relationship
// metaparameters sorted, sensitive values redacted.
func Normalize(c *Catalog, opts Options) map[string]Resource {
out := make(map[string]Resource, len(c.Resources))
for _, r := range c.Resources {
if r.Exported {
continue
}
if !opts.IncludeClasses && r.Type == "Class" {
continue
}
title := normalizeTitle(r)
r.Parameters = cleanParameters(r, c.SensitiveParameters, opts.IgnoreAttrs)
r.Title = title
r.Tags = sortedTags(r.Tags)
if matchesAny(r.Key(), opts.IgnoreResources) {
continue
}
out[r.Key()] = r
}
return out
}
// normalizeTitle keys a File by its path parameter, so file{'my-name':
// path=>'/etc/foo'} and file{'/etc/foo':} compare equal. cleanParameters has
// already removed the path parameter.
func normalizeTitle(r Resource) string {
if r.Type != "File" {
return r.Title
}
if p, ok := r.Params()["path"].(string); ok && p != "" {
return p
}
return r.Title
}
func cleanParameters(r Resource, catalogSensitive, ignoreAttrs []string) map[string]any {
sensitive := map[string]bool{}
for _, name := range append(append([]string(nil), catalogSensitive...), r.SensitiveParameters...) {
sensitive[name] = true
}
ignored := map[string]bool{}
for _, name := range ignoreAttrs {
ignored[name] = true
}
params := make(map[string]any, len(r.Parameters))
for k, v := range r.Params() {
if noiseParameters[k] || ignored[k] || strings.HasPrefix(k, "_") {
continue
}
if r.Type == "File" && k == "path" {
continue
}
if sensitive[k] {
params[k] = redact(v)
continue
}
if relationshipParameters[k] {
params[k] = sortedRelationship(v)
continue
}
params[k] = v
}
return params
}
// sortedRelationship turns a scalar into a one-element list and sorts the
// result, so notify => "Service[x]" and notify => ["Service[x]"] compare equal
// and list order never registers as a change.
func sortedRelationship(v any) any {
items, ok := v.([]any)
if !ok {
items = []any{v}
}
sorted := append([]any(nil), items...)
sort.SliceStable(sorted, func(i, j int) bool {
return stringRepr(sorted[i]) < stringRepr(sorted[j])
})
return sorted
}
// redact replaces a sensitive value with a digest, so a change is still
// visible but the plaintext never reaches stdout or a CI log.
func redact(v any) string {
sum := sha256.Sum256([]byte(stringRepr(v)))
return "redacted:sha256:" + hex.EncodeToString(sum[:])[:16]
}
func sortedTags(tags []string) []string {
if len(tags) == 0 {
return nil
}
out := append([]string(nil), tags...)
sort.Strings(out)
return out
}
// matchesAny reports whether a "Type[Title]" key matches one of the patterns.
// A pattern may glob in either half; a pattern without brackets matches the
// type and any title.
func matchesAny(key string, patterns []string) bool {
for _, p := range patterns {
if matchesPattern(key, p) {
return true
}
}
return false
}
func matchesPattern(key, pattern string) bool {
keyType, keyTitle := splitKey(key)
patType, patTitle := splitKey(pattern)
if patTitle == "" && !strings.Contains(pattern, "[") {
patTitle = "*"
}
typeOK, err := path.Match(patType, keyType)
if err != nil || !typeOK {
return false
}
titleOK, err := path.Match(patTitle, keyTitle)
return err == nil && titleOK
}
func splitKey(key string) (string, string) {
open := strings.Index(key, "[")
if open < 0 || !strings.HasSuffix(key, "]") {
return key, ""
}
return key[:open], key[open+1 : len(key)-1]
}
// stringRepr renders a value the way the datatype-insensitive comparison sees
// it: nil and "" are the same string, 28 and "28" are the same string.
func stringRepr(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case bool:
return fmt.Sprintf("%t", t)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case int:
return strconv.Itoa(t)
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}
+273
View File
@@ -0,0 +1,273 @@
package catalog
import (
"encoding/json"
"sort"
"strconv"
)
// Diff types, mirroring octocatalog-diff's taxonomy.
const (
DiffAdded = "+" // whole resource added
DiffRemoved = "-" // whole resource removed
DiffChanged = "~" // attribute changed
DiffElemented = "!" // element changed inside a nested array or hash
)
// Severities.
const (
SeverityCritical = "critical"
SeverityNormal = "normal"
)
// criticalTypes are the resource types whose changes can break a host.
var criticalTypes = map[string]bool{
"Service": true,
"Package": true,
"File": true,
"Exec": true,
"Mount": true,
"User": true,
"Group": true,
"Cron": true,
"Systemd::Unit_file": true,
"Systemd::Service": true,
"Docker::Image": true,
"Docker::Container": true,
}
// IsCritical reports whether a resource type is in the critical set.
func IsCritical(resourceType string) bool { return criticalTypes[resourceType] }
// Severity buckets a resource type.
func Severity(resourceType string) string {
if IsCritical(resourceType) {
return SeverityCritical
}
return SeverityNormal
}
// Change is one difference between the base and test catalogs. Structure is
// the path to the changed value, e.g. ["parameters","device"].
type Change struct {
DiffType string `json:"diff_type"`
Type string `json:"type"`
Title string `json:"title"`
Structure []string `json:"structure"`
OldValue any `json:"old_value"`
NewValue any `json:"new_value"`
OldFile string `json:"old_file,omitempty"`
OldLine *int `json:"old_line,omitempty"`
NewFile string `json:"new_file,omitempty"`
NewLine *int `json:"new_line,omitempty"`
Severity string `json:"severity"`
// Datatype marks a change where only the datatype differs (28 vs "28").
Datatype bool `json:"datatype,omitempty"`
}
// Key is the "Type[Title]" identity of the resource the change belongs to.
func (c Change) Key() string { return c.Type + "[" + c.Title + "]" }
// Summary counts a change set.
type Summary struct {
Added int `json:"added"`
Removed int `json:"removed"`
Changed int `json:"changed"`
Critical int `json:"critical"`
}
// DiffOptions controls what the comparison reports.
type DiffOptions struct {
// DisplayDatatypeChanges keeps changes where only the datatype differs.
DisplayDatatypeChanges bool
}
// Diff compares two normalised catalogs. Whole-resource additions and removals
// are determined from the key sets first, so a new resource is never confused
// with a new element inside an existing one. file and line are reported for
// context but never drive equality, so a resource moving line is not a change.
// The result is sorted, so the same inputs always render identically.
func Diff(base, test map[string]Resource, opts DiffOptions) []Change {
var changes []Change
for key, baseRes := range base {
if _, ok := test[key]; ok {
continue
}
changes = append(changes, Change{
DiffType: DiffRemoved,
Type: baseRes.Type,
Title: baseRes.Title,
OldValue: baseRes.Params(),
OldFile: baseRes.File,
OldLine: baseRes.Line,
Severity: Severity(baseRes.Type),
})
}
for key, testRes := range test {
if _, ok := base[key]; ok {
continue
}
changes = append(changes, Change{
DiffType: DiffAdded,
Type: testRes.Type,
Title: testRes.Title,
NewValue: testRes.Params(),
NewFile: testRes.File,
NewLine: testRes.Line,
Severity: Severity(testRes.Type),
})
}
for key, baseRes := range base {
testRes, ok := test[key]
if !ok {
continue
}
changes = append(changes, diffParameters(baseRes, testRes, opts)...)
}
sort.SliceStable(changes, func(i, j int) bool {
a, b := changes[i], changes[j]
if a.Key() != b.Key() {
return a.Key() < b.Key()
}
if a.DiffType != b.DiffType {
return a.DiffType < b.DiffType
}
return structureKey(a.Structure) < structureKey(b.Structure)
})
return changes
}
func structureKey(s []string) string {
out := ""
for _, part := range s {
out += "\x00" + part
}
return out
}
func diffParameters(baseRes, testRes Resource, opts DiffOptions) []Change {
proto := Change{
Type: testRes.Type,
Title: testRes.Title,
OldFile: baseRes.File,
OldLine: baseRes.Line,
NewFile: testRes.File,
NewLine: testRes.Line,
Severity: Severity(testRes.Type),
}
return walk(proto, []string{"parameters"},
anyMap(baseRes.Params()), anyMap(testRes.Params()), opts)
}
func anyMap(m map[string]any) any {
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = v
}
return out
}
// walk compares two values at a path, recursing into matching containers so a
// change deep inside a hash or array is reported as an element change ("!")
// against the smallest structure that actually differs.
func walk(proto Change, structure []string, oldVal, newVal any, opts DiffOptions) []Change {
if deepEqual(oldVal, newVal) {
return nil
}
oldMap, oldIsMap := oldVal.(map[string]any)
newMap, newIsMap := newVal.(map[string]any)
if oldIsMap && newIsMap {
var changes []Change
for _, k := range unionKeys(oldMap, newMap) {
changes = append(changes, walk(proto, child(structure, k), oldMap[k], newMap[k], opts)...)
}
return changes
}
oldList, oldIsList := oldVal.([]any)
newList, newIsList := newVal.([]any)
if oldIsList && newIsList && len(oldList) == len(newList) {
var changes []Change
for i := range oldList {
changes = append(changes, walk(proto, child(structure, strconv.Itoa(i)), oldList[i], newList[i], opts)...)
}
return changes
}
datatypeOnly := stringRepr(oldVal) == stringRepr(newVal)
if datatypeOnly && !opts.DisplayDatatypeChanges {
return nil
}
change := proto
change.Structure = append([]string(nil), structure...)
change.OldValue = oldVal
change.NewValue = newVal
change.Datatype = datatypeOnly
// A difference directly on a parameter is an attribute change; anything
// deeper is an element change inside that attribute.
if len(structure) <= 2 {
change.DiffType = DiffChanged
} else {
change.DiffType = DiffElemented
}
return []Change{change}
}
func unionKeys(a, b map[string]any) []string {
seen := make(map[string]bool, len(a)+len(b))
keys := make([]string, 0, len(a)+len(b))
for k := range a {
seen[k] = true
keys = append(keys, k)
}
for k := range b {
if !seen[k] {
keys = append(keys, k)
}
}
sort.Strings(keys)
return keys
}
// child extends a structure path without aliasing the parent's backing array.
func child(structure []string, part string) []string {
out := make([]string, 0, len(structure)+1)
return append(append(out, structure...), part)
}
// deepEqual compares two decoded JSON values structurally. Encoding both sides
// sorts map keys, so map ordering never registers as a change.
func deepEqual(a, b any) bool {
ab, errA := json.Marshal(a)
bb, errB := json.Marshal(b)
if errA != nil || errB != nil {
return false
}
return string(ab) == string(bb)
}
// Summarise counts a change set.
func Summarise(changes []Change) Summary {
var s Summary
for _, c := range changes {
switch c.DiffType {
case DiffAdded:
s.Added++
case DiffRemoved:
s.Removed++
default:
s.Changed++
}
if c.Severity == SeverityCritical {
s.Critical++
}
}
return s
}
+388
View File
@@ -0,0 +1,388 @@
package catalog
import (
"encoding/json"
"testing"
)
func mustCatalog(t *testing.T, body string) *Catalog {
t.Helper()
var c Catalog
if err := json.Unmarshal([]byte(body), &c); err != nil {
t.Fatalf("parsing catalog fixture: %v", err)
}
return &c
}
const baseCatalog = `{
"name":"host.example.net","environment":"develop","version":1,
"classes":["settings","motd"],
"resources":[
{"type":"Stage","title":"main","parameters":{"name":"main"}},
{"type":"Class","title":"Motd","parameters":{"content":"old"}},
{"type":"File","title":"/etc/motd","file":"site.pp","line":10,"tags":["file","z","a"],"parameters":{"ensure":"file","content":"old","loglevel":"notice","_meta":"x","require":"Package[vim]","notify":["Service[sshd]","Service[chronyd]"]}},
{"type":"Service","title":"sshd","file":"ssh.pp","line":4,"tags":["service"],"parameters":{"ensure":"running","enable":true,"port":28}},
{"type":"Notify","title":"gone","file":"old.pp","line":1,"parameters":{"message":"bye"}},
{"type":"Package","title":"vim","parameters":{"ensure":"installed"}},
{"type":"Exec","title":"reload","parameters":{"command":"/bin/true","environment":{"HOME":"/root","PATH":"/bin"}}},
{"type":"Nagios_service","title":"exported","exported":true,"parameters":{"check":"x"}},
{"type":"Mysql::Db","title":"app","sensitive_parameters":["password"],"parameters":{"user":"app","password":"hunter2"}}
]
}`
const testCatalog = `{
"name":"host.example.net","environment":"benvin_test","version":2,
"classes":["settings","motd"],
"resources":[
{"type":"Stage","title":"main","parameters":{"name":"main"}},
{"type":"Class","title":"Motd","parameters":{"content":"new"}},
{"type":"File","title":"motd-managed","file":"site.pp","line":19,"tags":["a","file","z"],"parameters":{"path":"/etc/motd","ensure":"file","content":"new","loglevel":"debug","stage":"main","_meta":"y","require":"Package[vim2]","notify":["Service[chronyd]","Service[sshd]"]}},
{"type":"Service","title":"sshd","file":"ssh.pp","line":4,"tags":["service"],"parameters":{"ensure":"running","enable":true,"port":"28"}},
{"type":"Notify","title":"added","file":"new.pp","line":3,"parameters":{"message":"hi"}},
{"type":"Package","title":"vim","parameters":{"ensure":"installed","provider":"dnf"}},
{"type":"Exec","title":"reload","parameters":{"command":"/bin/true","environment":{"HOME":"/root","PATH":"/usr/bin"}}},
{"type":"Nagios_service","title":"exported","exported":true,"parameters":{"check":"y"}},
{"type":"Mysql::Db","title":"app","sensitive_parameters":["password"],"parameters":{"user":"app","password":"hunter3"}}
]
}`
func normalized(t *testing.T, body string, opts Options) map[string]Resource {
t.Helper()
return Normalize(mustCatalog(t, body), opts)
}
func changeSet(t *testing.T) []Change {
t.Helper()
return Diff(
normalized(t, baseCatalog, Options{}),
normalized(t, testCatalog, Options{}),
DiffOptions{},
)
}
func intPtr(n int) *int { return &n }
func byKey(changes []Change) map[string][]Change {
out := map[string][]Change{}
for _, c := range changes {
out[c.Key()] = append(out[c.Key()], c)
}
return out
}
func TestNormalizeDropsNoiseParameters(t *testing.T) {
res := normalized(t, testCatalog, Options{})
motd, ok := res["File[/etc/motd]"]
if !ok {
t.Fatalf("File not re-keyed by its path parameter: %v", res)
}
for _, drop := range []string{"loglevel", "stage", "_meta", "before", "require", "path"} {
if _, present := motd.Parameters[drop]; present {
t.Errorf("parameter %q should have been dropped", drop)
}
}
if motd.Parameters["content"] != "new" {
t.Errorf("real parameters dropped: %+v", motd.Parameters)
}
if motd.File != "site.pp" || motd.Line == nil || *motd.Line != 19 {
t.Errorf("file/line context lost: %+v", motd)
}
if got := motd.Tags; len(got) != 3 || got[0] != "a" || got[2] != "z" {
t.Errorf("tags not sorted: %v", got)
}
}
func TestNormalizeSkipsClassesAndExported(t *testing.T) {
res := normalized(t, testCatalog, Options{})
if _, ok := res["Class[Motd]"]; ok {
t.Error("Class resources must be ignored by default")
}
if _, ok := res["Nagios_service[exported]"]; ok {
t.Error("exported resources must be skipped")
}
withClasses := normalized(t, testCatalog, Options{IncludeClasses: true})
if _, ok := withClasses["Class[Motd]"]; !ok {
t.Error("--include-classes must keep Class resources")
}
}
func TestNormalizeRedactsSensitiveParameters(t *testing.T) {
base := normalized(t, baseCatalog, Options{})["Mysql::Db[app]"]
test := normalized(t, testCatalog, Options{})["Mysql::Db[app]"]
for _, r := range []Resource{base, test} {
value, _ := r.Parameters["password"].(string)
if value == "hunter2" || value == "hunter3" {
t.Fatalf("sensitive value leaked: %q", value)
}
if len(value) < len("redacted:sha256:") || value[:16] != "redacted:sha256:" {
t.Fatalf("unexpected redaction %q", value)
}
}
if base.Parameters["password"] == test.Parameters["password"] {
t.Error("a changed secret must still show as a change")
}
}
func TestNormalizeIgnoreFlags(t *testing.T) {
res := normalized(t, testCatalog, Options{
IgnoreResources: []string{"Notify[*]", "Package[vim]"},
IgnoreAttrs: []string{"ensure"},
})
if _, ok := res["Notify[added]"]; ok {
t.Error("--ignore glob did not drop Notify[added]")
}
if _, ok := res["Package[vim]"]; ok {
t.Error("--ignore did not drop Package[vim]")
}
if _, ok := res["File[/etc/motd]"].Parameters["ensure"]; ok {
t.Error("--ignore-attr did not drop the ensure parameter")
}
}
func TestNormalizeRelationshipParameters(t *testing.T) {
scalar := Normalize(&Catalog{Resources: []Resource{
{Type: "Service", Title: "a", Parameters: map[string]any{"notify": "Service[b]"}},
}}, Options{})
list := Normalize(&Catalog{Resources: []Resource{
{Type: "Service", Title: "a", Parameters: map[string]any{"notify": []any{"Service[b]"}}},
}}, Options{})
if changes := Diff(scalar, list, DiffOptions{}); len(changes) != 0 {
t.Errorf("scalar and single-element array must compare equal: %+v", changes)
}
unsorted := Normalize(&Catalog{Resources: []Resource{
{Type: "Service", Title: "a", Parameters: map[string]any{"subscribe": []any{"File[y]", "File[x]"}}},
}}, Options{})
sorted := Normalize(&Catalog{Resources: []Resource{
{Type: "Service", Title: "a", Parameters: map[string]any{"subscribe": []any{"File[x]", "File[y]"}}},
}}, Options{})
if changes := Diff(unsorted, sorted, DiffOptions{}); len(changes) != 0 {
t.Errorf("relationship array order must not matter: %+v", changes)
}
}
func TestDiffResourceAddedRemoved(t *testing.T) {
changes := byKey(changeSet(t))
added := changes["Notify[added]"]
if len(added) != 1 || added[0].DiffType != DiffAdded {
t.Fatalf("added resource not detected: %+v", added)
}
if added[0].NewValue == nil || added[0].OldValue != nil {
t.Errorf("added resource must carry only new_value: %+v", added[0])
}
removed := changes["Notify[gone]"]
if len(removed) != 1 || removed[0].DiffType != DiffRemoved {
t.Fatalf("removed resource not detected: %+v", removed)
}
if removed[0].OldValue == nil || removed[0].NewValue != nil {
t.Errorf("removed resource must carry only old_value: %+v", removed[0])
}
}
func TestDiffAttributeAndElementChanges(t *testing.T) {
changes := byKey(changeSet(t))
motd := changes["File[/etc/motd]"]
if len(motd) != 1 {
t.Fatalf("expected one change on the motd file, got %+v", motd)
}
if motd[0].DiffType != DiffChanged {
t.Errorf("attribute change must be %q: %+v", DiffChanged, motd[0])
}
if got := motd[0].Structure; len(got) != 2 || got[0] != "parameters" || got[1] != "content" {
t.Errorf("unexpected structure %v", got)
}
if motd[0].OldValue != "old" || motd[0].NewValue != "new" {
t.Errorf("unexpected values: %+v", motd[0])
}
exec := changes["Exec[reload]"]
if len(exec) != 1 || exec[0].DiffType != DiffElemented {
t.Fatalf("nested change must be %q: %+v", DiffElemented, exec)
}
if got := exec[0].Structure; len(got) != 3 || got[2] != "PATH" {
t.Errorf("nested structure not reported: %v", got)
}
if exec[0].OldValue != "/bin" || exec[0].NewValue != "/usr/bin" {
t.Errorf("nested values wrong: %+v", exec[0])
}
}
func TestDiffSuppressesDatatypeChanges(t *testing.T) {
if changes := byKey(changeSet(t))["Service[sshd]"]; len(changes) != 0 {
t.Errorf("28 vs \"28\" must be suppressed by default: %+v", changes)
}
shown := Diff(
normalized(t, baseCatalog, Options{}),
normalized(t, testCatalog, Options{}),
DiffOptions{DisplayDatatypeChanges: true},
)
sshd := byKey(shown)["Service[sshd]"]
if len(sshd) != 1 || !sshd[0].Datatype {
t.Fatalf("--display-datatype-changes must surface the change: %+v", sshd)
}
}
func TestDiffIgnoresFileLineMoves(t *testing.T) {
base := map[string]Resource{"File[/a]": {Type: "File", Title: "/a", File: "a.pp", Line: intPtr(1),
Parameters: map[string]any{"ensure": "file"}}}
test := map[string]Resource{"File[/a]": {Type: "File", Title: "/a", File: "b.pp", Line: intPtr(99),
Parameters: map[string]any{"ensure": "file"}}}
if changes := Diff(base, test, DiffOptions{}); len(changes) != 0 {
t.Errorf("a resource moving file/line is not a change: %+v", changes)
}
}
func TestDiffSeverity(t *testing.T) {
for _, c := range changeSet(t) {
want := SeverityNormal
if IsCritical(c.Type) {
want = SeverityCritical
}
if c.Severity != want {
t.Errorf("%s severity = %q, want %q", c.Key(), c.Severity, want)
}
}
if !IsCritical("Systemd::Unit_file") || !IsCritical("Docker::Container") {
t.Error("critical type list incomplete")
}
if IsCritical("Notify") {
t.Error("Notify must not be critical")
}
}
func TestDiffDeterministicOrdering(t *testing.T) {
first := changeSet(t)
for i := 0; i < 20; i++ {
next := changeSet(t)
if len(first) != len(next) {
t.Fatalf("change count varies: %d vs %d", len(first), len(next))
}
for j := range first {
if first[j].Key() != next[j].Key() || structureKey(first[j].Structure) != structureKey(next[j].Structure) {
t.Fatalf("ordering is not stable at %d: %+v vs %+v", j, first[j], next[j])
}
}
}
for i := 1; i < len(first); i++ {
if first[i-1].Key() > first[i].Key() {
t.Fatalf("changes not sorted by resource: %q before %q", first[i-1].Key(), first[i].Key())
}
}
}
func TestDiffIgnoresMapOrdering(t *testing.T) {
base := map[string]Resource{"File[/a]": {Type: "File", Title: "/a", Parameters: map[string]any{
"opts": map[string]any{"b": 1, "a": 2},
}}}
test := map[string]Resource{"File[/a]": {Type: "File", Title: "/a", Parameters: map[string]any{
"opts": map[string]any{"a": 2, "b": 1},
}}}
if changes := Diff(base, test, DiffOptions{}); len(changes) != 0 {
t.Errorf("map key ordering reported as a change: %+v", changes)
}
}
func TestSummarise(t *testing.T) {
s := Summarise(changeSet(t))
if s.Added != 1 || s.Removed != 1 {
t.Errorf("unexpected summary: %+v", s)
}
if s.Changed != 4 {
t.Errorf("changed = %d, want 4 (motd content, vim provider, exec PATH, db password)", s.Changed)
}
if s.Critical != 3 {
t.Errorf("critical = %d, want 3 (File, Package, Exec)", s.Critical)
}
}
func TestDiffNoChanges(t *testing.T) {
norm := normalized(t, baseCatalog, Options{})
if changes := Diff(norm, norm, DiffOptions{}); len(changes) != 0 {
t.Errorf("identical catalogs produced changes: %+v", changes)
}
}
// Resources shaped like an OpenVoxServer 8.8 catalog. Class, stage and anchor
// resources carry no "parameters" key, and resources with no manifest location
// carry neither "file" nor "line".
const optionalFieldCatalog = `{
"name":"web01.example.test","environment":"develop","version":"0123abc",
"resources":[
{"type":"Class","title":"Settings","tags":["class","settings"],"exported":false,"kind":"unknown"},
{"type":"Anchor","title":"examplemod::end","tags":["anchor","examplemod::end"],"file":"init.pp","line":111,"exported":false,"kind":"compilable_type"},
{"type":"File","title":"/opt/puppetlabs/puppet/cache/examplemod/modules/example_local.loaded","tags":["file"],"file":"module.pp","line":177,"exported":false,"kind":"compilable_type"},
{"type":"Stage","title":"main","tags":["stage"],"exported":false,"kind":"compilable_type","parameters":{"name":"main"}}
]
}`
func TestNormalizeAcceptsMissingParameters(t *testing.T) {
res := normalized(t, optionalFieldCatalog, Options{IncludeClasses: true})
for _, key := range []string{"Class[Settings]", "Anchor[examplemod::end]"} {
r, ok := res[key]
if !ok {
t.Fatalf("%s dropped: %v", key, res)
}
if r.Parameters == nil {
t.Errorf("%s: absent parameters must normalise to an empty map", key)
}
if len(r.Parameters) != 0 {
t.Errorf("%s: unexpected parameters %v", key, r.Parameters)
}
}
}
func TestDiffTreatsMissingParametersAsEmpty(t *testing.T) {
absent := normalized(t, `{"resources":[{"type":"Anchor","title":"a","tags":["anchor"],"exported":false}]}`, Options{})
empty := normalized(t, `{"resources":[{"type":"Anchor","title":"a","tags":["anchor"],"exported":false,"parameters":{}}]}`, Options{})
if changes := Diff(absent, empty, DiffOptions{}); len(changes) != 0 {
t.Errorf("absent and empty parameters must compare equal: %+v", changes)
}
if changes := Diff(empty, absent, DiffOptions{}); len(changes) != 0 {
t.Errorf("absent and empty parameters must compare equal in both directions: %+v", changes)
}
}
func TestDiffRemovedResourceWithoutParametersCarriesEmptyValue(t *testing.T) {
base := normalized(t, optionalFieldCatalog, Options{IncludeClasses: true})
changes := byKey(Diff(base, map[string]Resource{}, DiffOptions{}))
removed := changes["Class[Settings]"]
if len(removed) != 1 {
t.Fatalf("expected one removal, got %+v", removed)
}
params, ok := removed[0].OldValue.(map[string]any)
if !ok || len(params) != 0 {
t.Errorf("a resource without parameters must report an empty set, got %#v", removed[0].OldValue)
}
}
func TestNormalizeKeepsMissingSourceDistinctFromLineZero(t *testing.T) {
res := normalized(t, optionalFieldCatalog, Options{IncludeClasses: true})
settings := res["Class[Settings]"]
if settings.HasSource() {
t.Errorf("a resource with no file/line must report no source: %+v", settings)
}
if settings.Line != nil {
t.Errorf("an absent line must stay absent, got %d", *settings.Line)
}
anchor := res["Anchor[examplemod::end]"]
if !anchor.HasSource() || anchor.File != "init.pp" || *anchor.Line != 111 {
t.Errorf("manifest location lost: %+v", anchor)
}
zero := Resource{Type: "File", Title: "/a", File: "site.pp", Line: intPtr(0)}
if !zero.HasSource() {
t.Error("line 0 with a file is a real location")
}
}
+220
View File
@@ -0,0 +1,220 @@
package catalog
import (
"encoding/json"
"fmt"
"io"
"sort"
"strings"
)
// Report is the full result of a catalog-diff run and the shape of --json.
type Report struct {
Hostname string `json:"hostname"`
BaseEnvironment string `json:"base_environment"`
TestEnvironment string `json:"test_environment"`
Compiled map[string]bool `json:"compiled"`
Changes []Change `json:"changes"`
Summary Summary `json:"summary"`
}
// NewReport builds a report from a change set.
func NewReport(hostname, baseEnv, testEnv string, changes []Change) Report {
if changes == nil {
changes = []Change{}
}
return Report{
Hostname: hostname,
BaseEnvironment: baseEnv,
TestEnvironment: testEnv,
Compiled: map[string]bool{baseEnv: true, testEnv: true},
Changes: changes,
Summary: Summarise(changes),
}
}
// RenderJSON writes the report as indented JSON.
func RenderJSON(w io.Writer, r Report) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(r)
}
// RenderOptions controls the text renderer.
type RenderOptions struct {
// Colour emits ANSI escapes; callers set it only for a TTY.
Colour bool
// DetailAdd expands the parameters of added resources.
DetailAdd bool
// SourceFileLine appends the originating manifest file and line.
SourceFileLine bool
}
const (
ansiReset = "\033[0m"
ansiBold = "\033[1m"
ansiRed = "\033[31m"
)
// RenderText writes the human/agent readable report. Changes are grouped
// critical first, then ordered by resource key, so CI logs diff cleanly
// between runs.
func RenderText(w io.Writer, r Report, opts RenderOptions) error {
var b strings.Builder
bold := func(s string) string {
if !opts.Colour {
return s
}
return ansiBold + s + ansiReset
}
red := func(s string) string {
if !opts.Colour {
return s
}
return ansiRed + s + ansiReset
}
b.WriteString(bold("Puppet Catalog Diff") + "\n")
b.WriteString(strings.Repeat("=", 50) + "\n")
fmt.Fprintf(&b, "Host : %s\n", r.Hostname)
fmt.Fprintf(&b, "Base environment : %s\n", r.BaseEnvironment)
fmt.Fprintf(&b, "Test environment : %s\n", r.TestEnvironment)
fmt.Fprintf(&b, "Compiled : %s\n", compiledLine(r.Compiled))
fmt.Fprintf(&b, "Changes : %d critical, %d added, %d removed, %d changed\n\n",
r.Summary.Critical, r.Summary.Added, r.Summary.Removed, r.Summary.Changed)
if len(r.Changes) == 0 {
b.WriteString("No differences between environments.\n")
_, err := io.WriteString(w, b.String())
return err
}
var critical, other []Change
for _, c := range r.Changes {
if c.Severity == SeverityCritical {
critical = append(critical, c)
} else {
other = append(other, c)
}
}
writeGroup := func(title string, changes []Change) {
if len(changes) == 0 {
return
}
b.WriteString(title + "\n")
b.WriteString(strings.Repeat("-", 50) + "\n")
for _, c := range changes {
for _, line := range describe(c, opts) {
b.WriteString(" " + line + "\n")
}
}
b.WriteString("\n")
}
writeGroup(red(bold("CRITICAL")), critical)
writeGroup(bold("OTHER"), other)
_, err := io.WriteString(w, strings.TrimRight(b.String(), "\n")+"\n")
return err
}
func compiledLine(compiled map[string]bool) string {
envs := make([]string, 0, len(compiled))
for env := range compiled {
envs = append(envs, env)
}
sort.Strings(envs)
parts := make([]string, 0, len(envs))
for _, env := range envs {
status := "failed"
if compiled[env] {
status = "ok"
}
parts = append(parts, env+"="+status)
}
return strings.Join(parts, " ")
}
// describe renders one change as one or more lines. Added resources collapse
// to a single line unless DetailAdd is set; changed resources always expand.
func describe(c Change, opts RenderOptions) []string {
switch c.DiffType {
case DiffAdded:
lines := []string{fmt.Sprintf("+ %s added%s", c.Key(), location(c.NewFile, c.NewLine, opts))}
if opts.DetailAdd {
lines = append(lines, parameterLines(" ", c.NewValue)...)
}
return lines
case DiffRemoved:
return []string{fmt.Sprintf("- %s removed%s", c.Key(), location(c.OldFile, c.OldLine, opts))}
default:
note := ""
if c.Datatype {
note = " (datatype only)"
}
return []string{fmt.Sprintf("%s %s %s: %s -> %s%s%s",
c.DiffType, c.Key(), structurePath(c.Structure),
renderValue(c.OldValue), renderValue(c.NewValue), note,
location(c.NewFile, c.NewLine, opts))}
}
}
// structurePath drops the leading "parameters" element, which every attribute
// change shares, and joins the rest.
func structurePath(structure []string) string {
if len(structure) > 0 && structure[0] == "parameters" {
structure = structure[1:]
}
if len(structure) == 0 {
return "(resource)"
}
return strings.Join(structure, ".")
}
// location renders the manifest source of a change. A resource the compiler
// gave no file or line for is reported as unknown rather than as an empty
// path or line 0, which would read as a real location.
func location(file string, line *int, opts RenderOptions) string {
if !opts.SourceFileLine {
return ""
}
if file == "" || line == nil {
return " (source unknown)"
}
return fmt.Sprintf(" (%s:%d)", file, *line)
}
func parameterLines(indent string, value any) []string {
params, ok := value.(map[string]any)
if !ok {
return nil
}
names := make([]string, 0, len(params))
for name := range params {
names = append(names, name)
}
sort.Strings(names)
lines := make([]string, 0, len(names))
for _, name := range names {
lines = append(lines, fmt.Sprintf("%s%s: %s", indent, name, renderValue(params[name])))
}
return lines
}
// renderValue prints a value compactly: strings bare, everything else as
// compact JSON, and an absent value as "<absent>".
func renderValue(v any) string {
if v == nil {
return "<absent>"
}
if s, ok := v.(string); ok {
return s
}
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
+174
View File
@@ -0,0 +1,174 @@
package catalog
import (
"bytes"
"flag"
"os"
"path/filepath"
"strings"
"testing"
)
var update = flag.Bool("update", false, "rewrite the golden files")
func golden(t *testing.T, name string, got []byte) {
t.Helper()
path := filepath.Join("testdata", name)
if *update {
if err := os.WriteFile(path, got, 0o644); err != nil {
t.Fatalf("writing golden %s: %v", path, err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading golden %s: %v (run go test -update)", path, err)
}
if !bytes.Equal(want, got) {
t.Errorf("output does not match %s\n--- want ---\n%s\n--- got ---\n%s", path, want, got)
}
}
func testReport(t *testing.T) Report {
t.Helper()
return NewReport("host.example.net", "develop", "benvin_test", changeSet(t))
}
func TestRenderTextGolden(t *testing.T) {
var buf bytes.Buffer
if err := RenderText(&buf, testReport(t), RenderOptions{SourceFileLine: true}); err != nil {
t.Fatalf("RenderText: %v", err)
}
golden(t, "report.txt", buf.Bytes())
}
func TestRenderJSONGolden(t *testing.T) {
var buf bytes.Buffer
if err := RenderJSON(&buf, testReport(t)); err != nil {
t.Fatalf("RenderJSON: %v", err)
}
golden(t, "report.json", buf.Bytes())
}
func TestRenderTextNoChanges(t *testing.T) {
var buf bytes.Buffer
if err := RenderText(&buf, NewReport("host.example.net", "develop", "benvin_test", nil), RenderOptions{}); err != nil {
t.Fatalf("RenderText: %v", err)
}
if !strings.Contains(buf.String(), "No differences between environments.") {
t.Errorf("unexpected empty-diff output:\n%s", buf.String())
}
}
func TestRenderTextColourOnlyWhenAsked(t *testing.T) {
var plain, coloured bytes.Buffer
report := testReport(t)
if err := RenderText(&plain, report, RenderOptions{}); err != nil {
t.Fatal(err)
}
if err := RenderText(&coloured, report, RenderOptions{Colour: true}); err != nil {
t.Fatal(err)
}
if strings.Contains(plain.String(), "\033[") {
t.Error("plain output contains ANSI escapes")
}
if !strings.Contains(coloured.String(), "\033[") {
t.Error("coloured output contains no ANSI escapes")
}
}
func TestRenderTextGroupsCriticalFirst(t *testing.T) {
var buf bytes.Buffer
if err := RenderText(&buf, testReport(t), RenderOptions{SourceFileLine: true}); err != nil {
t.Fatal(err)
}
out := buf.String()
crit, other := strings.Index(out, "CRITICAL"), strings.Index(out, "OTHER")
if crit < 0 || other < 0 || crit > other {
t.Errorf("critical changes not listed first:\n%s", out)
}
}
func TestRenderJSONEmptyChangesIsArray(t *testing.T) {
var buf bytes.Buffer
if err := RenderJSON(&buf, NewReport("host.example.net", "develop", "benvin_test", nil)); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), `"changes": []`) {
t.Errorf("empty change list must render as [], got:\n%s", buf.String())
}
}
func TestRenderTextCollapsesAddedResources(t *testing.T) {
report := testReport(t)
var collapsed bytes.Buffer
if err := RenderText(&collapsed, report, RenderOptions{}); err != nil {
t.Fatal(err)
}
if strings.Contains(collapsed.String(), "message: hi") {
t.Errorf("added resource expanded without --display-detail-add:\n%s", collapsed.String())
}
var detailed bytes.Buffer
if err := RenderText(&detailed, report, RenderOptions{DetailAdd: true}); err != nil {
t.Fatal(err)
}
if !strings.Contains(detailed.String(), "message: hi") {
t.Errorf("--display-detail-add did not expand the added resource:\n%s", detailed.String())
}
}
func TestRenderTextSourceFileLineIsOptional(t *testing.T) {
report := testReport(t)
var plain bytes.Buffer
if err := RenderText(&plain, report, RenderOptions{}); err != nil {
t.Fatal(err)
}
if strings.Contains(plain.String(), "site.pp") {
t.Errorf("file:line printed without --display-source-file-line:\n%s", plain.String())
}
var located bytes.Buffer
if err := RenderText(&located, report, RenderOptions{SourceFileLine: true}); err != nil {
t.Fatal(err)
}
if !strings.Contains(located.String(), "site.pp:19") {
t.Errorf("--display-source-file-line did not print the source:\n%s", located.String())
}
}
func TestRenderTextMarksMissingSourceUnknown(t *testing.T) {
changes := []Change{
{DiffType: DiffAdded, Type: "Class", Title: "Settings", Severity: SeverityNormal},
{DiffType: DiffRemoved, Type: "File", Title: "/etc/motd", OldFile: "site.pp", OldLine: intPtr(9), Severity: SeverityCritical},
}
var buf bytes.Buffer
if err := RenderText(&buf, NewReport("host.example.net", "develop", "benvin_test", changes), RenderOptions{SourceFileLine: true}); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.Contains(out, "Class[Settings] added (source unknown)") {
t.Errorf("a resource with no manifest location must render as unknown:\n%s", out)
}
if strings.Contains(out, ":0)") {
t.Errorf("an absent line must never render as line 0:\n%s", out)
}
if !strings.Contains(out, "(site.pp:9)") {
t.Errorf("a real location must still render:\n%s", out)
}
}
func TestRenderJSONOmitsAbsentSource(t *testing.T) {
changes := []Change{{DiffType: DiffAdded, Type: "Class", Title: "Settings", Severity: SeverityNormal}}
var buf bytes.Buffer
if err := RenderJSON(&buf, NewReport("host.example.net", "develop", "benvin_test", changes)); err != nil {
t.Fatal(err)
}
for _, key := range []string{"new_file", "new_line", "old_file", "old_line"} {
if strings.Contains(buf.String(), key) {
t.Errorf("%s must be omitted when the compiler gave no location:\n%s", key, buf.String())
}
}
}
+96
View File
@@ -0,0 +1,96 @@
{
"hostname": "host.example.net",
"base_environment": "develop",
"test_environment": "benvin_test",
"compiled": {
"benvin_test": true,
"develop": true
},
"changes": [
{
"diff_type": "!",
"type": "Exec",
"title": "reload",
"structure": [
"parameters",
"environment",
"PATH"
],
"old_value": "/bin",
"new_value": "/usr/bin",
"severity": "critical"
},
{
"diff_type": "~",
"type": "File",
"title": "/etc/motd",
"structure": [
"parameters",
"content"
],
"old_value": "old",
"new_value": "new",
"old_file": "site.pp",
"old_line": 10,
"new_file": "site.pp",
"new_line": 19,
"severity": "critical"
},
{
"diff_type": "~",
"type": "Mysql::Db",
"title": "app",
"structure": [
"parameters",
"password"
],
"old_value": "redacted:sha256:f52fbd32b2b3b86f",
"new_value": "redacted:sha256:fb8c2e2b85ca81eb",
"severity": "normal"
},
{
"diff_type": "+",
"type": "Notify",
"title": "added",
"structure": null,
"old_value": null,
"new_value": {
"message": "hi"
},
"new_file": "new.pp",
"new_line": 3,
"severity": "normal"
},
{
"diff_type": "-",
"type": "Notify",
"title": "gone",
"structure": null,
"old_value": {
"message": "bye"
},
"new_value": null,
"old_file": "old.pp",
"old_line": 1,
"severity": "normal"
},
{
"diff_type": "~",
"type": "Package",
"title": "vim",
"structure": [
"parameters",
"provider"
],
"old_value": null,
"new_value": "dnf",
"severity": "critical"
}
],
"summary": {
"added": 1,
"removed": 1,
"changed": 4,
"critical": 3
}
}
+19
View File
@@ -0,0 +1,19 @@
Puppet Catalog Diff
==================================================
Host : host.example.net
Base environment : develop
Test environment : benvin_test
Compiled : benvin_test=ok develop=ok
Changes : 3 critical, 1 added, 1 removed, 4 changed
CRITICAL
--------------------------------------------------
! Exec[reload] environment.PATH: /bin -> /usr/bin (source unknown)
~ File[/etc/motd] content: old -> new (site.pp:19)
~ Package[vim] provider: <absent> -> dnf (source unknown)
OTHER
--------------------------------------------------
~ Mysql::Db[app] password: redacted:sha256:f52fbd32b2b3b86f -> redacted:sha256:fb8c2e2b85ca81eb (source unknown)
+ Notify[added] added (new.pp:3)
- Notify[gone] removed (old.pp:1)
+113
View File
@@ -0,0 +1,113 @@
// Package config loads the shared configuration for the puppet-catalog-tools
// binaries. Precedence (lowest to highest): defaults < config file < env vars
// < CLI flags (applied by the caller).
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
const (
appName = "puppet-catalog-tools"
configFileName = "config.yaml"
// DefaultServerURL is the k8s OpenVoxServer compiler pool. The legacy VM
// masters behind puppet.query.consul remain reachable via --server.
DefaultServerURL = "https://puppet.k8s.syd1.au.unkin.net:8140"
// DefaultPuppetDBURL is the PuppetDB facts are read from.
DefaultPuppetDBURL = "https://pdbmux.k8s.syd1.au.unkin.net"
// DefaultBaseEnvironment is the environment branches are diffed against.
DefaultBaseEnvironment = "develop"
)
// Config holds every configurable value. Fields map 1:1 to config file keys.
type Config struct {
ServerURL string `yaml:"server_url"`
PuppetDBURL string `yaml:"puppetdb_url"`
Cert string `yaml:"cert"`
Key string `yaml:"key"`
CACert string `yaml:"cacert"`
BaseEnvironment string `yaml:"base_environment"`
}
// Default returns the built-in configuration.
func Default() Config {
return Config{
ServerURL: DefaultServerURL,
PuppetDBURL: DefaultPuppetDBURL,
BaseEnvironment: DefaultBaseEnvironment,
}
}
// Dir returns the XDG config directory for these tools.
func Dir() string {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, _ := os.UserHomeDir()
base = filepath.Join(home, ".config")
}
return filepath.Join(base, appName)
}
// Path returns the full path to the config file.
func Path() string { return filepath.Join(Dir(), configFileName) }
// Load reads the config file (if present), then applies env var overrides.
func Load() (Config, error) {
cfg := Default()
path := Path()
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return cfg, fmt.Errorf("reading config %s: %w", path, err)
}
if err == nil {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parsing config %s: %w", path, err)
}
}
env := map[string]*string{
"CATALOG_DIFF_SERVER": &cfg.ServerURL,
"CATALOG_DIFF_PUPPETDB": &cfg.PuppetDBURL,
"CATALOG_DIFF_CERT": &cfg.Cert,
"CATALOG_DIFF_KEY": &cfg.Key,
"CATALOG_DIFF_CACERT": &cfg.CACert,
"CATALOG_DIFF_BASE_ENVIRONMENT": &cfg.BaseEnvironment,
}
for name, field := range env {
if v := os.Getenv(name); v != "" {
*field = v
}
}
return cfg, nil
}
// WriteDefault creates the config directory and writes a default config file.
// It refuses to overwrite an existing file.
func WriteDefault() (string, error) {
if err := os.MkdirAll(Dir(), 0o755); err != nil {
return "", fmt.Errorf("creating config dir: %w", err)
}
path := Path()
if _, err := os.Stat(path); err == nil {
return "", fmt.Errorf("config already exists at %s", path)
}
data, err := yaml.Marshal(Default())
if err != nil {
return "", err
}
header := "# puppet-catalog-tools configuration\n" +
"# Overridable with CATALOG_DIFF_SERVER, CATALOG_DIFF_PUPPETDB,\n" +
"# CATALOG_DIFF_CERT, CATALOG_DIFF_KEY, CATALOG_DIFF_CACERT,\n" +
"# CATALOG_DIFF_BASE_ENVIRONMENT.\n\n"
if err := os.WriteFile(path, append([]byte(header), data...), 0o644); err != nil {
return "", fmt.Errorf("writing config: %w", err)
}
return path, nil
}
+91
View File
@@ -0,0 +1,91 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadDefaults(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ServerURL != DefaultServerURL || cfg.PuppetDBURL != DefaultPuppetDBURL {
t.Errorf("unexpected defaults: %+v", cfg)
}
if cfg.BaseEnvironment != DefaultBaseEnvironment {
t.Errorf("base environment = %q", cfg.BaseEnvironment)
}
}
func TestLoadPrecedence(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
if err := os.MkdirAll(filepath.Join(dir, appName), 0o755); err != nil {
t.Fatal(err)
}
body := "server_url: https://file.example:8140\npuppetdb_url: https://file.example\ncert: /file/cert.pem\n"
if err := os.WriteFile(Path(), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ServerURL != "https://file.example:8140" {
t.Errorf("config file ignored: %q", cfg.ServerURL)
}
if cfg.Cert != "/file/cert.pem" {
t.Errorf("cert = %q", cfg.Cert)
}
t.Setenv("CATALOG_DIFF_SERVER", "https://env.example:8140")
t.Setenv("CATALOG_DIFF_CERT", "/env/cert.pem")
cfg, err = Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ServerURL != "https://env.example:8140" {
t.Errorf("env did not override file: %q", cfg.ServerURL)
}
if cfg.Cert != "/env/cert.pem" {
t.Errorf("cert = %q", cfg.Cert)
}
if cfg.PuppetDBURL != "https://file.example" {
t.Errorf("unset env clobbered file value: %q", cfg.PuppetDBURL)
}
}
func TestLoadBadYAML(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
if err := os.MkdirAll(filepath.Join(dir, appName), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(Path(), []byte("server_url: [unclosed\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(); err == nil {
t.Fatal("expected a parse error")
}
}
func TestWriteDefault(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
path, err := WriteDefault()
if err != nil {
t.Fatalf("WriteDefault: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("config not written: %v", err)
}
if _, err := WriteDefault(); err == nil {
t.Fatal("expected refusal to overwrite an existing config")
}
}
+182
View File
@@ -0,0 +1,182 @@
// Package puppetdb reads node facts from PuppetDB's query API. It is GET-only
// and unauthenticated — deliberately a separate client from the mTLS
// puppetserver one.
package puppetdb
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// ErrNodeNotFound is returned when PuppetDB knows no facts for a certname.
var ErrNodeNotFound = errors.New("node not found in PuppetDB")
// Client queries PuppetDB.
type Client struct {
BaseURL string
HTTP *http.Client
// Warn, when set, receives a message for every fact record that could not
// be decoded and was therefore dropped.
Warn func(string)
}
// New returns a client for baseURL.
func New(baseURL string, timeout time.Duration) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTP: &http.Client{Timeout: timeout},
}
}
type factRecord struct {
Certname string `json:"certname"`
Name string `json:"name"`
Value json.RawMessage `json:"value"`
}
// Facts returns the node's facts as the values map a catalog request needs.
// It reads /pdb/query/v4/facts with an AST query selecting the certname, which
// answers with a flat array of fact records.
func (c *Client) Facts(ctx context.Context, certname string) (map[string]any, error) {
query, err := json.Marshal([]string{"=", "certname", certname})
if err != nil {
return nil, fmt.Errorf("building facts query for %s: %w", certname, err)
}
body, status, err := c.get(ctx, "/pdb/query/v4/facts", string(query))
if err != nil {
return nil, err
}
if status == http.StatusNotFound {
return nil, fmt.Errorf("%w: %s", ErrNodeNotFound, certname)
}
if status != http.StatusOK {
return nil, fmt.Errorf("puppetdb returned %d for %s facts: %s", status, certname, snippet(body))
}
records, err := decodeFacts(body, certname)
if err != nil {
return nil, err
}
if len(records) == 0 {
return nil, fmt.Errorf("%w: %s", ErrNodeNotFound, certname)
}
return c.fold(certname, records)
}
// decodeFacts reads the fact records the query returns. A record belonging to
// another node is never substituted for the one that was asked for. A record
// that carries no certname of its own cannot be contradicting the query that
// named one, so it is kept.
func decodeFacts(body []byte, certname string) ([]factRecord, error) {
body = bytes.TrimPrefix(body, []byte("\xef\xbb\xbf"))
var records []factRecord
if err := json.Unmarshal(body, &records); err != nil {
return nil, fmt.Errorf("parsing facts for %s: %w", certname, err)
}
kept := make([]factRecord, 0, len(records))
for _, r := range records {
if r.Certname == "" || sameCertname(r.Certname, certname) {
kept = append(kept, r)
}
}
if len(kept) == 0 && len(records) > 0 {
return nil, fmt.Errorf("no facts for %s: puppetdb returned %s",
certname, strings.Join(certnames(records), ", "))
}
return kept, nil
}
// sameCertname compares certnames case-insensitively: they are DNS names, so
// folding can only absorb a certname that was normalised for case, never mask
// a different node.
func sameCertname(got, want string) bool {
return got != "" && strings.EqualFold(got, want)
}
// certnames lists the distinct nodes a response spoke for, in the order they
// appeared, so a mismatch error names what actually came back.
func certnames(records []factRecord) []string {
seen := make(map[string]bool, len(records))
names := make([]string, 0, len(records))
for _, r := range records {
name := r.Certname
if name == "" {
name = "(unnamed)"
}
if seen[name] {
continue
}
seen[name] = true
names = append(names, name)
}
return names
}
func (c *Client) get(ctx context.Context, path, query string) ([]byte, int, error) {
target := c.BaseURL + path + "?" + url.Values{"query": {query}}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("puppetdb request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("reading puppetdb response: %w", err)
}
return body, resp.StatusCode, nil
}
// fold turns fact records into the flat name -> value map the catalog API
// wants. Structured facts keep their nested shape. An undecodable record is
// dropped and reported: one bad fact must not cost the whole set. A response
// in which nothing decodes is a failed fetch, not an empty fact set.
func (c *Client) fold(certname string, records []factRecord) (map[string]any, error) {
values := make(map[string]any, len(records))
var skipped []string
for _, r := range records {
if r.Name == "" {
continue
}
var v any
if len(r.Value) == 0 || json.Unmarshal(r.Value, &v) != nil {
skipped = append(skipped, r.Name)
continue
}
values[r.Name] = v
}
if len(values) == 0 {
return nil, fmt.Errorf("no usable facts for %s: all %d records were undecodable", certname, len(records))
}
if len(skipped) > 0 && c.Warn != nil {
c.Warn(fmt.Sprintf("skipped %d undecodable fact(s) for %s: %s",
len(skipped), certname, strings.Join(skipped, ", ")))
}
return values, nil
}
func snippet(body []byte) string {
s := strings.TrimSpace(string(body))
if len(s) > 200 {
return s[:200] + "..."
}
return s
}
+317
View File
@@ -0,0 +1,317 @@
package puppetdb
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// The shape PuppetDB answers /pdb/query/v4/facts with: a flat array of fact
// records, each naming the node it belongs to.
const factsBody = `[
{"certname":"host.example.net","environment":"develop","name":"id","value":"root"},
{"certname":"host.example.net","environment":"develop","name":"os","value":{"family":"RedHat","release":{"major":"9"}}},
{"certname":"host.example.net","environment":"develop","name":"processorcount","value":4}
]`
func assertFacts(t *testing.T, facts map[string]any) {
t.Helper()
if facts["id"] != "root" {
t.Errorf("id = %v", facts["id"])
}
if facts["processorcount"] != float64(4) {
t.Errorf("processorcount = %#v", facts["processorcount"])
}
os, ok := facts["os"].(map[string]any)
if !ok {
t.Fatalf("os fact not structured: %#v", facts["os"])
}
if os["family"] != "RedHat" {
t.Errorf("os.family = %v", os["family"])
}
release, ok := os["release"].(map[string]any)
if !ok || release["major"] != "9" {
t.Errorf("nested os.release lost: %#v", os["release"])
}
}
// factsServer answers the query route with body and fails the test on any
// other path.
func factsServer(t *testing.T, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/pdb/query/v4/facts" {
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
func TestFactsQueriesByCertname(t *testing.T) {
var paths, queries []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
queries = append(queries, r.URL.Query().Get("query"))
_, _ = w.Write([]byte(factsBody))
}))
defer srv.Close()
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
if err != nil {
t.Fatalf("Facts: %v", err)
}
assertFacts(t, facts)
if len(paths) != 1 || paths[0] != "/pdb/query/v4/facts" {
t.Errorf("unexpected requests: %v", paths)
}
if len(queries) != 1 || queries[0] != `["=","certname","host.example.net"]` {
t.Errorf("unexpected query: %v", queries)
}
}
// A certname with characters the AST would otherwise have to escape must
// survive both JSON encoding and query-string encoding intact.
func TestFactsEscapesTheCertnameInTheQuery(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.URL.Query().Get("query")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
const certname = `we"ird &host`
_, _ = New(srv.URL, 5*time.Second).Facts(context.Background(), certname)
var ast []string
if err := json.Unmarshal([]byte(got), &ast); err != nil {
t.Fatalf("query %q is not valid JSON: %v", got, err)
}
if len(ast) != 3 || ast[0] != "=" || ast[1] != "certname" || ast[2] != certname {
t.Errorf("query = %q, decoded %#v", got, ast)
}
}
// The response shape as PuppetDB really returns it, structured facts and all.
func TestFactsDecodesARealResponse(t *testing.T) {
srv := factsServer(t, `[
{"certname":"host.example.net","environment":"develop","name":"id","value":"root"},
{"certname":"host.example.net","environment":"develop","name":"processorcount","value":4},
{"certname":"host.example.net","environment":"develop","name":"os","value":{"name":"AlmaLinux","distro":{"id":"AlmaLinux","release":{"full":"9.7","major":"9","minor":"7"}},"family":"RedHat","release":{"full":"9.7","major":"9","minor":"7"},"selinux":{"enabled":false},"architecture":"x86_64"}}
]`)
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
if err != nil {
t.Fatalf("Facts: %v", err)
}
assertFacts(t, facts)
os := facts["os"].(map[string]any)
if os["architecture"] != "x86_64" {
t.Errorf("os.architecture = %v", os["architecture"])
}
selinux, ok := os["selinux"].(map[string]any)
if !ok || selinux["enabled"] != false {
t.Errorf("nested os.selinux lost: %#v", os["selinux"])
}
}
// An unknown node is not an HTTP error: the query simply matches nothing.
func TestFactsEmptyResultIsAMissingNode(t *testing.T) {
srv := factsServer(t, `[]`)
_, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "nope.example.net")
if !errors.Is(err, ErrNodeNotFound) {
t.Fatalf("err = %v, want ErrNodeNotFound", err)
}
}
func TestFactsMissingNode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"No information is known about node nope.example.net"}`))
}))
defer srv.Close()
_, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "nope.example.net")
if !errors.Is(err, ErrNodeNotFound) {
t.Fatalf("err = %v, want ErrNodeNotFound", err)
}
}
func TestFactsServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`boom`))
}))
defer srv.Close()
_, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
if err == nil {
t.Fatal("expected an error on HTTP 500")
}
if errors.Is(err, ErrNodeNotFound) {
t.Fatalf("500 misreported as a missing node: %v", err)
}
}
func TestFactsBadJSON(t *testing.T) {
srv := factsServer(t, `not json`)
if _, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net"); err == nil {
t.Fatal("expected a parse error")
}
}
func TestFactsContextCancelled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(200 * time.Millisecond)
_, _ = w.Write([]byte(factsBody))
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if _, err := New(srv.URL, 5*time.Second).Facts(ctx, "host.example.net"); err == nil {
t.Fatal("expected a timeout error")
}
}
// assertNamesBoth checks the mismatch error names the node asked for and the
// ones that came back, and that it is not dressed up as a parse failure.
func assertNamesBoth(t *testing.T, err error, want string, got ...string) {
t.Helper()
if !strings.Contains(err.Error(), want) {
t.Errorf("error does not name the requested certname %s: %v", want, err)
}
for _, g := range got {
if !strings.Contains(err.Error(), g) {
t.Errorf("error does not name the returned certname %s: %v", g, err)
}
}
if strings.Contains(err.Error(), "parsing facts") {
t.Errorf("a certname mismatch is not a parse failure: %v", err)
}
}
func TestFactsRejectsRecordsForAnotherNode(t *testing.T) {
srv := factsServer(t, `[
{"certname":"totally-different-host.example.net","name":"id","value":"nobody"},
{"certname":"totally-different-host.example.net","name":"os","value":{"family":"Debian"}}
]`)
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
if err == nil {
t.Fatalf("another node's facts were served as ours: %#v", facts)
}
assertNamesBoth(t, err, "host.example.net", "totally-different-host.example.net")
}
func TestFactsRejectsAMixedResponseNamingEveryNode(t *testing.T) {
srv := factsServer(t, `[
{"certname":"a.example.net","name":"id","value":"a"},
{"certname":"b.example.net","name":"id","value":"b"}
]`)
_, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
if err == nil {
t.Fatal("expected an error when no record matches the certname")
}
assertNamesBoth(t, err, "host.example.net", "a.example.net", "b.example.net")
}
func TestFactsDropsAForeignRecordFromAnOtherwiseMatchingResponse(t *testing.T) {
srv := factsServer(t, `[
{"certname":"host.example.net","name":"id","value":"root"},
{"certname":"other.example.net","name":"id","value":"nobody"},
{"certname":"host.example.net","name":"os","value":{"family":"RedHat","release":{"major":"9"}}},
{"certname":"host.example.net","name":"processorcount","value":4}
]`)
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
if err != nil {
t.Fatalf("Facts: %v", err)
}
assertFacts(t, facts)
}
// The query already names the node, so a record that asserts no certname of
// its own cannot be contradicting it.
func TestFactsAcceptsRecordsWithNoCertname(t *testing.T) {
srv := factsServer(t, `[
{"name":"id","value":"root"},
{"name":"os","value":{"family":"RedHat","release":{"major":"9"}}},
{"name":"processorcount","value":4}
]`)
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
if err != nil {
t.Fatalf("Facts: %v", err)
}
assertFacts(t, facts)
}
func TestFactsMatchesCertnameCaseInsensitively(t *testing.T) {
srv := factsServer(t, factsBody)
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "HOST.example.net")
if err != nil {
t.Fatalf("Facts: %v", err)
}
assertFacts(t, facts)
}
func TestFactsToleratesALeadingBOM(t *testing.T) {
srv := factsServer(t, "\ufeff"+factsBody)
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
if err != nil {
t.Fatalf("Facts: %v", err)
}
assertFacts(t, facts)
}
func TestFactsSkipsUndecodableRecords(t *testing.T) {
srv := factsServer(t, `[
{"certname":"host.example.net","name":"id","value":"root"},
{"certname":"host.example.net","name":"broken"},
{"certname":"host.example.net","name":"os","value":{"family":"RedHat","release":{"major":"9"}}},
{"certname":"host.example.net","name":"processorcount","value":4}
]`)
var warnings []string
c := New(srv.URL, 5*time.Second)
c.Warn = func(msg string) { warnings = append(warnings, msg) }
facts, err := c.Facts(context.Background(), "host.example.net")
if err != nil {
t.Fatalf("one bad fact must not fail the fetch: %v", err)
}
assertFacts(t, facts)
if _, ok := facts["broken"]; ok {
t.Error("a valueless fact must be dropped, not stored")
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "broken") {
t.Errorf("the skipped fact must be reported, got %v", warnings)
}
}
func TestFactsWithNothingDecodableIsAnError(t *testing.T) {
srv := factsServer(t, `[{"name":"a"},{"name":"b"}]`)
_, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
if err == nil {
t.Fatal("a fact set in which nothing decodes must not pass as empty")
}
if !strings.Contains(err.Error(), "no usable facts") {
t.Errorf("unhelpful error: %v", err)
}
}
+367
View File
@@ -0,0 +1,367 @@
// Package puppetserver compiles catalogs against an OpenVoxServer/Puppet
// Server 8 compiler over client mTLS. It is a separate client from puppetdb:
// different endpoint, different auth model.
package puppetserver
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"git.unkin.net/unkin/puppet-catalog-tools/internal/catalog"
)
// Client talks to a puppetserver compiler.
type Client struct {
BaseURL string
HTTP *http.Client
}
// New returns a client for baseURL using tlsCfg for transport and client auth.
func New(baseURL string, tlsCfg *tls.Config, timeout time.Duration) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTP: &http.Client{
Timeout: timeout,
Transport: &http.Transport{TLSClientConfig: tlsCfg},
},
}
}
// TLSConfig builds a client mTLS config from PEM files. certFile/keyFile are
// the client certificate issued by the estate's Puppet CA; caFile is the CA
// bundle used to verify the server.
func TLSConfig(certFile, keyFile, caFile string) (*tls.Config, error) {
cfg := &tls.Config{MinVersion: tls.VersionTLS12}
if (certFile == "") != (keyFile == "") {
return nil, errors.New("--cert and --key must be given together")
}
if certFile != "" {
pair, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("loading client certificate: %w", err)
}
cfg.Certificates = []tls.Certificate{pair}
}
if caFile != "" {
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("reading CA bundle: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no certificates found in %s", caFile)
}
cfg.RootCAs = pool
}
return cfg, nil
}
// CompileError is a catalog that the server refused to compile.
type CompileError struct {
Environment string
Status int
Kind string
Message string
}
func (e *CompileError) Error() string {
msg := e.Message
if msg == "" {
msg = "no message returned"
}
if e.Kind != "" {
return fmt.Sprintf("catalog compilation failed in environment %q (HTTP %d, %s): %s",
e.Environment, e.Status, e.Kind, msg)
}
return fmt.Sprintf("catalog compilation failed in environment %q (HTTP %d): %s",
e.Environment, e.Status, msg)
}
// puppetDBMarkers appear in a compile failure caused by the compiler's own
// PuppetDB query rather than by the manifest. The estate sees these
// intermittently and the same node compiles on the next attempt.
var puppetDBMarkers = []string{
"server_urls",
"/pdb/query/",
"/pdb/cmd/",
}
// manifestMarkers appear in a failure the manifest itself causes. Recompiling
// the same code cannot change the outcome.
var manifestMarkers = []string{
"evaluation error",
"syntax error",
"could not find class",
"could not find declared class",
"could not find data item",
"parse error",
"compilation-error",
}
// Transient reports whether the failure is worth retrying. A failed PuppetDB
// query is checked first because the compiler reports it as an evaluation
// error, which otherwise reads as a manifest problem. Anything the manifest
// caused is never retried; every other 5xx is.
func (e *CompileError) Transient() bool {
haystack := strings.ToLower(e.Message + " " + e.Kind)
for _, marker := range puppetDBMarkers {
if strings.Contains(haystack, marker) {
return true
}
}
for _, marker := range manifestMarkers {
if strings.Contains(haystack, marker) {
return false
}
}
return e.Status >= 500
}
// EnvironmentMismatchError means the server compiled a different, named
// environment from the one asked for. It is only raised when both names are
// non-empty: an unnamed environment is a decode failure, not an ENC decision,
// and is reported as a ResponseError instead.
type EnvironmentMismatchError struct {
Certname string
Requested string
Returned string
}
func (e *EnvironmentMismatchError) Error() string {
return fmt.Sprintf(
"environment mismatch for %s: requested %q but the server compiled %q. "+
"options.prefer_requested_environment did not override the environment the node "+
"resolved to; the usual cause is an ENC-supplied environment (encapic -> encapi) "+
"for a node whose encapi status is not \"testing\"",
e.Certname, e.Requested, e.Returned)
}
// ResponseError is a 200 response this client could not turn into a catalog:
// it did not decode, or it decoded without an environment name. Both mean the
// response shape is not the one expected, so retrying cannot help.
type ResponseError struct {
Certname string
Size int
Err error
}
func (e *ResponseError) Error() string {
if e.Err != nil {
return fmt.Sprintf("catalog response for %s did not decode (%d bytes): %v",
e.Certname, e.Size, e.Err)
}
return fmt.Sprintf(
"catalog response for %s decoded without an environment name (%d bytes): "+
"the compiler returned a response shape this client does not understand. "+
"This is a client/server protocol mismatch; nothing about the node's "+
"classification or environment needs changing",
e.Certname, e.Size)
}
func (e *ResponseError) Unwrap() error { return e.Err }
// CompileRequest describes one catalog compilation.
type CompileRequest struct {
Certname string
Environment string
Facts map[string]any
TrustedFacts map[string]any
}
type catalogRequest struct {
Certname string `json:"certname"`
Environment string `json:"environment"`
Persistence persistence `json:"persistence"`
Facts factValues `json:"facts"`
TrustedFacts factValues `json:"trusted_facts"`
TransactionUUID string `json:"transaction_uuid"`
Options map[string]any `json:"options"`
}
type persistence struct {
Facts bool `json:"facts"`
Catalog bool `json:"catalog"`
}
type factValues struct {
Values map[string]any `json:"values"`
}
// Compile compiles one catalog. It always verifies that the catalog the server
// returned is for the environment that was requested.
func (c *Client) Compile(ctx context.Context, req CompileRequest) (*catalog.Catalog, error) {
facts := req.Facts
if facts == nil {
facts = map[string]any{}
}
trusted := req.TrustedFacts
if trusted == nil {
trusted = TrustedFacts(req.Certname)
}
uuid, err := transactionUUID()
if err != nil {
return nil, err
}
body, err := json.Marshal(catalogRequest{
Certname: req.Certname,
Environment: req.Environment,
Persistence: persistence{Facts: false, Catalog: false},
Facts: factValues{Values: facts},
TrustedFacts: factValues{Values: trusted},
TransactionUUID: uuid,
Options: map[string]any{
"prefer_requested_environment": true,
"capture_logs": false,
"log_level": "info",
},
})
if err != nil {
return nil, fmt.Errorf("encoding catalog request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/puppet/v4/catalog", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
resp, err := c.HTTP.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("catalog request to %s failed: %w", c.BaseURL, err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading catalog response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, compileError(req.Environment, resp.StatusCode, raw)
}
cat, err := decodeCatalog(raw)
if err != nil {
return nil, &ResponseError{Certname: req.Certname, Size: len(raw), Err: err}
}
if cat.Environment == "" {
return nil, &ResponseError{Certname: req.Certname, Size: len(raw)}
}
if cat.Environment != req.Environment {
return nil, &EnvironmentMismatchError{
Certname: req.Certname,
Requested: req.Environment,
Returned: cat.Environment,
}
}
return cat, nil
}
// catalogEnvelope is the wrapper OpenVoxServer / Puppet Server 8 returns from
// POST /puppet/v4/catalog: the catalog itself sits under a single "catalog"
// key.
type catalogEnvelope struct {
Catalog *catalog.Catalog `json:"catalog"`
}
// decodeCatalog accepts both the wrapped response and a bare catalog, so a
// compiler that does not wrap still works.
func decodeCatalog(raw []byte) (*catalog.Catalog, error) {
var env catalogEnvelope
if err := json.Unmarshal(raw, &env); err == nil && env.Catalog != nil {
return env.Catalog, nil
}
var bare catalog.Catalog
if err := json.Unmarshal(raw, &bare); err != nil {
return nil, err
}
return &bare, nil
}
// Health checks the unauthenticated /status/v1/simple endpoint.
func (c *Client) Health(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/status/v1/simple", nil)
if err != nil {
return err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("puppetserver %s unreachable: %w", c.BaseURL, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading puppetserver status: %w", err)
}
status := strings.TrimSpace(string(body))
if resp.StatusCode != http.StatusOK || status != "running" {
return fmt.Errorf("puppetserver %s is not running (HTTP %d: %s)", c.BaseURL, resp.StatusCode, status)
}
return nil
}
// compileError decodes a Puppet error body. Puppet Server reports the failure
// kind as "kind" or "issue_kind" depending on the error path.
func compileError(environment string, status int, raw []byte) error {
var body struct {
Kind string `json:"kind"`
IssueKind string `json:"issue_kind"`
Message string `json:"message"`
}
e := &CompileError{Environment: environment, Status: status}
if err := json.Unmarshal(raw, &body); err != nil {
e.Message = strings.TrimSpace(string(raw))
} else {
e.Kind = body.Kind
if e.Kind == "" {
e.Kind = body.IssueKind
}
e.Message = body.Message
if e.Message == "" {
e.Message = strings.TrimSpace(string(raw))
}
}
if len(e.Message) > 4000 {
e.Message = e.Message[:4000] + "..."
}
return e
}
// TrustedFacts synthesises the trusted facts hash for a certname, matching the
// shape Puppet builds from a client certificate.
func TrustedFacts(certname string) map[string]any {
hostname, domain, _ := strings.Cut(certname, ".")
return map[string]any{
"authenticated": "remote",
"certname": certname,
"extensions": map[string]any{},
"hostname": hostname,
"domain": domain,
}
}
func transactionUUID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generating transaction uuid: %w", err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
+424
View File
@@ -0,0 +1,424 @@
package puppetserver
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.unkin.net/unkin/puppet-catalog-tools/internal/catalog"
)
const catalogBody = `{
"tags":["settings"],
"name":"host.example.net",
"version":1757649971,
"code_id":null,
"catalog_uuid":"6e4f9c7a-1111-4a2b-9c3d-000000000001",
"catalog_format":1,
"environment":"benvin_test",
"resources":[
{"type":"Stage","title":"main","tags":["stage"],"exported":false,"parameters":{"name":"main"}},
{"type":"File","title":"/etc/motd","tags":["file"],"exported":false,"file":"/etc/puppetlabs/code/environments/benvin_test/manifests/site.pp","line":12,"parameters":{"ensure":"file","content":"hello"}}
],
"edges":[{"source":"Stage[main]","target":"File[/etc/motd]"}],
"classes":["settings"]
}`
// puppetErrorBody is the shape Puppet Server returns for a manifest that does
// not compile.
const puppetErrorBody = `{"kind":"puppet.server/compilation-error","message":"Server Error: Evaluation Error: Error while evaluating a Function Call, Could not find data item foo in any Hiera data file (file: /etc/puppetlabs/code/environments/benvin_test/manifests/site.pp, line: 12, column: 5) on node host.example.net","issue_kind":"RUNTIME_ERROR"}`
func tlsClient(t *testing.T, srv *httptest.Server) *Client {
t.Helper()
pool := x509.NewCertPool()
pool.AddCert(srv.Certificate())
return New(srv.URL, &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, 5*time.Second)
}
func TestCompileSuccess(t *testing.T) {
var got catalogRequest
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/puppet/v4/catalog" {
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decoding request: %v", err)
}
_, _ = w.Write([]byte(catalogBody))
}))
defer srv.Close()
cat, err := tlsClient(t, srv).Compile(context.Background(), CompileRequest{
Certname: "host.example.net",
Environment: "benvin_test",
Facts: map[string]any{"os": map[string]any{"family": "RedHat"}},
})
if err != nil {
t.Fatalf("Compile: %v", err)
}
if cat.Environment != "benvin_test" || len(cat.Resources) != 2 {
t.Fatalf("unexpected catalog: %+v", cat)
}
if line := cat.Resources[1].Line; cat.Resources[1].File == "" || line == nil || *line != 12 {
t.Errorf("resource context lost: %+v", cat.Resources[1])
}
if got.Certname != "host.example.net" || got.Environment != "benvin_test" {
t.Errorf("request certname/environment wrong: %+v", got)
}
if got.Persistence.Facts || got.Persistence.Catalog {
t.Errorf("persistence must be off: %+v", got.Persistence)
}
if got.Options["prefer_requested_environment"] != true {
t.Errorf("prefer_requested_environment not set: %+v", got.Options)
}
if got.TransactionUUID == "" {
t.Error("no transaction_uuid sent")
}
if got.Facts.Values["os"] == nil {
t.Errorf("facts not sent: %+v", got.Facts)
}
if got.TrustedFacts.Values["certname"] != "host.example.net" {
t.Errorf("trusted facts not synthesised: %+v", got.TrustedFacts)
}
}
func TestCompileError(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(puppetErrorBody))
}))
defer srv.Close()
_, err := tlsClient(t, srv).Compile(context.Background(), CompileRequest{
Certname: "host.example.net",
Environment: "benvin_test",
})
var ce *CompileError
if !errors.As(err, &ce) {
t.Fatalf("err = %v (%T), want *CompileError", err, err)
}
if ce.Status != http.StatusBadRequest || ce.Kind != "puppet.server/compilation-error" {
t.Errorf("unexpected compile error: %+v", ce)
}
if ce.Environment != "benvin_test" {
t.Errorf("environment not recorded: %q", ce.Environment)
}
if !strings.Contains(ce.Error(), "Could not find data item foo") {
t.Errorf("puppet message lost: %s", ce.Error())
}
}
func TestCompileServerError(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Internal Server Error"))
}))
defer srv.Close()
_, err := tlsClient(t, srv).Compile(context.Background(), CompileRequest{
Certname: "host.example.net", Environment: "benvin_test",
})
var ce *CompileError
if !errors.As(err, &ce) || ce.Status != http.StatusInternalServerError {
t.Fatalf("err = %v, want a 500 CompileError", err)
}
}
func TestCompileEnvironmentMismatch(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(catalogBody))
}))
defer srv.Close()
_, err := tlsClient(t, srv).Compile(context.Background(), CompileRequest{
Certname: "host.example.net",
Environment: "benvin_other",
})
var me *EnvironmentMismatchError
if !errors.As(err, &me) {
t.Fatalf("err = %v (%T), want *EnvironmentMismatchError", err, err)
}
if me.Requested != "benvin_other" || me.Returned != "benvin_test" {
t.Errorf("mismatch detail wrong: %+v", me)
}
msg := me.Error()
if !strings.Contains(msg, "benvin_other") || !strings.Contains(msg, "benvin_test") || !strings.Contains(msg, "encapi") {
t.Errorf("mismatch message must name both environments and the ENC: %s", msg)
}
}
func TestCompileTimeout(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(500 * time.Millisecond)
_, _ = w.Write([]byte(catalogBody))
}))
defer srv.Close()
pool := x509.NewCertPool()
pool.AddCert(srv.Certificate())
c := New(srv.URL, &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, 30*time.Millisecond)
_, err := c.Compile(context.Background(), CompileRequest{
Certname: "host.example.net", Environment: "benvin_test",
})
if err == nil {
t.Fatal("expected a timeout error")
}
var ce *CompileError
if errors.As(err, &ce) {
t.Fatalf("timeout misreported as a compile error: %v", err)
}
}
func TestCompileBadCatalogJSON(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("not json"))
}))
defer srv.Close()
_, err := tlsClient(t, srv).Compile(context.Background(), CompileRequest{
Certname: "host.example.net", Environment: "benvin_test",
})
var re *ResponseError
if !errors.As(err, &re) {
t.Fatalf("err = %v (%T), want *ResponseError", err, err)
}
}
func TestHealth(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/status/v1/simple" {
t.Errorf("unexpected path %s", r.URL.Path)
}
_, _ = w.Write([]byte("running"))
}))
defer srv.Close()
if err := tlsClient(t, srv).Health(context.Background()); err != nil {
t.Fatalf("Health: %v", err)
}
}
func TestHealthUnhealthy(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("starting"))
}))
defer srv.Close()
if err := tlsClient(t, srv).Health(context.Background()); err == nil {
t.Fatal("expected an error for a non-running server")
}
}
func TestTrustedFacts(t *testing.T) {
tf := TrustedFacts("host.example.net")
if tf["certname"] != "host.example.net" || tf["hostname"] != "host" || tf["domain"] != "example.net" {
t.Errorf("unexpected trusted facts: %+v", tf)
}
if tf["authenticated"] != "remote" {
t.Errorf("authenticated = %v", tf["authenticated"])
}
if _, ok := tf["extensions"].(map[string]any); !ok {
t.Errorf("extensions must be a hash: %#v", tf["extensions"])
}
short := TrustedFacts("standalone")
if short["hostname"] != "standalone" || short["domain"] != "" {
t.Errorf("unqualified certname handled wrong: %+v", short)
}
}
func TestTLSConfig(t *testing.T) {
if _, err := TLSConfig("cert.pem", "", ""); err == nil {
t.Fatal("expected an error when --key is missing")
}
if _, err := TLSConfig("/nonexistent/cert.pem", "/nonexistent/key.pem", ""); err == nil {
t.Fatal("expected an error for unreadable client certificate")
}
cfg, err := TLSConfig("", "", "")
if err != nil {
t.Fatalf("TLSConfig: %v", err)
}
if len(cfg.Certificates) != 0 || cfg.RootCAs != nil {
t.Errorf("empty config should carry no material: %+v", cfg)
}
}
func TestTransactionUUIDUnique(t *testing.T) {
a, err := transactionUUID()
if err != nil {
t.Fatal(err)
}
b, err := transactionUUID()
if err != nil {
t.Fatal(err)
}
if a == b || len(a) != 36 {
t.Errorf("bad uuids %q %q", a, b)
}
}
// wrappedResponse is a synthetic OpenVoxServer 8.8 response shaped like the
// real thing: the catalog sits under a single "catalog" key.
func wrappedResponse(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", "catalog_response.json"))
if err != nil {
t.Fatal(err)
}
return raw
}
func compileFixture(t *testing.T, body []byte, environment string) (*catalog.Catalog, error) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
defer srv.Close()
return tlsClient(t, srv).Compile(context.Background(), CompileRequest{
Certname: "web01.example.test", Environment: environment,
})
}
func TestCompileUnwrapsServerResponse(t *testing.T) {
cat, err := compileFixture(t, wrappedResponse(t), "develop")
if err != nil {
t.Fatalf("Compile: %v", err)
}
if cat.Environment != "develop" || cat.Name != "web01.example.test" {
t.Fatalf("envelope not unwrapped: %+v", cat)
}
if len(cat.Resources) != 9 || len(cat.Classes) == 0 || len(cat.Tags) == 0 {
t.Fatalf("catalog body lost: %d resources, %d classes, %d tags",
len(cat.Resources), len(cat.Classes), len(cat.Tags))
}
if string(cat.Version) != `"0123456789abcdef0123456789abcdef01234567"` {
t.Errorf("version = %s, want the string form the server sends", cat.Version)
}
}
func TestCompileUnwrapsBareCatalog(t *testing.T) {
var wrapped struct {
Catalog json.RawMessage `json:"catalog"`
}
if err := json.Unmarshal(wrappedResponse(t), &wrapped); err != nil {
t.Fatal(err)
}
cat, err := compileFixture(t, wrapped.Catalog, "develop")
if err != nil {
t.Fatalf("an unwrapped catalog must still decode: %v", err)
}
if cat.Environment != "develop" || len(cat.Resources) != 9 {
t.Fatalf("unexpected catalog: %+v", cat)
}
}
func TestCompileDecodesOptionalResourceFields(t *testing.T) {
cat, err := compileFixture(t, wrappedResponse(t), "develop")
if err != nil {
t.Fatal(err)
}
byKey := map[string]catalog.Resource{}
for _, r := range cat.Resources {
byKey[r.Key()] = r
}
settings, ok := byKey["Class[Settings]"]
if !ok {
t.Fatalf("fixture lost Class[Settings]: %v", byKey)
}
if settings.Parameters != nil || len(settings.Params()) != 0 {
t.Errorf("a resource without parameters must read as an empty set: %+v", settings)
}
if settings.HasSource() || settings.Line != nil {
t.Errorf("a resource without file/line must report no source: %+v", settings)
}
anchor := byKey["Anchor[examplemod::end]"]
if anchor.Parameters != nil {
t.Errorf("anchor parameters should be absent: %+v", anchor.Parameters)
}
if !anchor.HasSource() || *anchor.Line != 111 {
t.Errorf("anchor manifest location lost: %+v", anchor)
}
pkg := byKey["Package[example-agent]"]
if pkg.Params()["ensure"] != "1.2.3" || !pkg.HasSource() {
t.Errorf("package decoded wrong: %+v", pkg)
}
key := byKey["File[/etc/example/secret.key]"]
if len(key.SensitiveParameters) != 1 || key.SensitiveParameters[0] != "content" {
t.Errorf("per-resource sensitive_parameters lost: %+v", key)
}
member := byKey["Haproxy::Balancermember[web01.example.test_9443]"]
if !member.Exported {
t.Errorf("exported flag lost: %+v", member)
}
}
func TestCompileEmptyEnvironmentIsAProtocolError(t *testing.T) {
_, err := compileFixture(t, []byte(`{"unexpected":{"environment":"develop"}}`), "develop")
var re *ResponseError
if !errors.As(err, &re) {
t.Fatalf("err = %v (%T), want *ResponseError", err, err)
}
var me *EnvironmentMismatchError
if errors.As(err, &me) {
t.Fatal("a response with no environment must not be reported as a mismatch")
}
msg := err.Error()
if !strings.Contains(msg, "protocol mismatch") {
t.Errorf("message must name the real cause: %s", msg)
}
for _, forbidden := range []string{"encapi", "ENC", "testing"} {
if strings.Contains(msg, forbidden) {
t.Errorf("a decode failure must not blame %q: %s", forbidden, msg)
}
}
}
func TestCompileErrorTransientClassification(t *testing.T) {
const pdbFailure = "Internal Server Error: org.jruby.exceptions.RuntimeError: (PreformattedError) Evaluation Error: " +
"Error while evaluating a Resource Statement, Evaluation Error: Error while evaluating a Function Call, " +
"Failed to execute '/pdb/query/v4' on at least 1 of the following 'server_urls': https://puppetdb.example.net " +
"(file: /etc/puppetlabs/code/environments/develop/site/profiles/manifests/app/client.pp, line: 31, column: 29) " +
"on node host.example.net"
const missingClass = "Internal Server Error: org.jruby.exceptions.RuntimeError: (Error) " +
"Could not find class roles::app::demo::node for host.example.net on node host.example.net"
tests := []struct {
name string
err CompileError
wantRun bool
}{
{name: "puppetdb query failure", err: CompileError{Status: 500, Message: pdbFailure}, wantRun: true},
{name: "missing class", err: CompileError{Status: 500, Message: missingClass}},
{name: "manifest error at 500", err: CompileError{Status: 500, Message: "Evaluation Error: undefined method `escape' for URI:Module"}},
{name: "manifest error at 400", err: CompileError{Status: 400, Kind: "puppet.server/compilation-error", Message: "Syntax error at '}'"}},
{name: "bad gateway", err: CompileError{Status: 502, Message: "bad gateway"}, wantRun: true},
{name: "service unavailable", err: CompileError{Status: 503, Message: ""}, wantRun: true},
{name: "bad request", err: CompileError{Status: 400, Message: "no message returned"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.err.Transient(); got != tc.wantRun {
t.Errorf("Transient() = %v, want %v", got, tc.wantRun)
}
})
}
}
+262
View File
@@ -0,0 +1,262 @@
{
"catalog": {
"tags": [
"settings",
"roles::app::demo::node"
],
"name": "web01.example.test",
"version": "0123456789abcdef0123456789abcdef01234567",
"code_id": null,
"catalog_uuid": "00000000-0000-4000-8000-0000000000aa",
"catalog_format": 2,
"environment": "develop",
"resources": [
{
"type": "Stage",
"title": "main",
"tags": [
"stage"
],
"exported": false,
"kind": "compilable_type",
"parameters": {
"name": "main"
}
},
{
"type": "Class",
"title": "Settings",
"tags": [
"class",
"settings"
],
"exported": false,
"kind": "unknown"
},
{
"type": "Class",
"title": "Profiles::App::Agent",
"tags": [
"class",
"profiles::app::agent",
"profiles",
"app",
"agent",
"profiles::base",
"base",
"roles::app::demo::node",
"roles",
"demo",
"node"
],
"exported": false,
"kind": "unknown",
"parameters": {
"version": "1.2.3",
"manage_service": true
}
},
{
"type": "Anchor",
"title": "examplemod::end",
"tags": [
"anchor",
"examplemod::end",
"examplemod",
"end",
"class",
"examplemod::module",
"module",
"example_local",
"profiles::app::module",
"profiles",
"app",
"profiles::base",
"base",
"roles::app::demo::node",
"roles",
"demo",
"node"
],
"file": "/etc/puppetlabs/code/environments/develop/external_modules/examplemod/manifests/init.pp",
"line": 111,
"exported": false,
"kind": "compilable_type"
},
{
"type": "File",
"title": "/opt/puppetlabs/puppet/cache/examplemod/modules/example_local.loaded",
"tags": [
"file",
"examplemod::module",
"examplemod",
"module",
"example_local",
"class",
"profiles::app::module",
"profiles",
"app",
"profiles::base",
"base",
"roles::app::demo::node",
"roles",
"demo",
"node"
],
"file": "/etc/puppetlabs/code/environments/develop/external_modules/examplemod/manifests/module.pp",
"line": 177,
"exported": false,
"kind": "compilable_type"
},
{
"type": "Package",
"title": "example-agent",
"tags": [
"package",
"example-agent",
"class",
"profiles::app::agent",
"profiles",
"app",
"agent",
"profiles::base",
"base",
"roles::app::demo::node",
"roles",
"demo",
"node"
],
"file": "/etc/puppetlabs/code/environments/develop/site/profiles/manifests/app/agent.pp",
"line": 58,
"exported": false,
"kind": "compilable_type",
"parameters": {
"ensure": "1.2.3",
"require": "Yumrepo[example]"
}
},
{
"type": "Service",
"title": "example-agent",
"tags": [
"service",
"example-agent",
"class",
"profiles::app::agent",
"profiles",
"agent",
"profiles::base",
"base",
"roles::app::demo::node",
"roles",
"demo",
"node"
],
"file": "/etc/puppetlabs/code/environments/develop/site/profiles/manifests/app/agent.pp",
"line": 81,
"exported": false,
"kind": "compilable_type",
"parameters": {
"ensure": "running",
"enable": true,
"hasrestart": true,
"require": "Package[example-agent]"
}
},
{
"type": "File",
"title": "/etc/example/secret.key",
"tags": [
"file",
"class",
"profiles::app::secret",
"profiles",
"app",
"secret",
"profiles::app::base",
"base",
"profiles::base",
"roles::app::demo::node",
"roles",
"demo",
"node"
],
"file": "/etc/puppetlabs/code/environments/develop/site/profiles/manifests/app/secret.pp",
"line": 97,
"exported": false,
"kind": "compilable_type",
"parameters": {
"ensure": "file",
"owner": "root",
"group": "root",
"mode": "0600",
"show_diff": false,
"content": "REDACTED-IN-FIXTURE"
},
"sensitive_parameters": [
"content"
]
},
{
"type": "Haproxy::Balancermember",
"title": "web01.example.test_9443",
"tags": [
"be_example_dashboard_test",
"haproxy::balancermember",
"haproxy",
"balancermember",
"web01.example.test_9443",
"profiles::app::balancermember",
"profiles",
"balancermember",
"class",
"profiles::app::dashboard",
"app",
"dashboard",
"profiles::base",
"base",
"roles::app::demo::node",
"roles",
"demo",
"node"
],
"file": "/etc/puppetlabs/code/environments/develop/site/profiles/manifests/app/balancermember.pp",
"line": 12,
"exported": true,
"kind": "defined_type",
"parameters": {
"listening_service": "be_example_dashboard",
"ports": [
9443
],
"server_names": "web01",
"ipaddresses": "192.0.2.9",
"options": [
"cookie web01",
"ssl",
"verify none",
"check",
"inter 2s",
"rise 3",
"fall 2"
],
"tag": "be_example_dashboard_test"
}
}
],
"edges": [
{
"source": "Stage[main]",
"target": "Class[Settings]"
},
{
"source": "Stage[main]",
"target": "Class[main]"
}
],
"classes": [
"settings",
"profiles::app::agent"
]
}
}
+45
View File
@@ -0,0 +1,45 @@
---
# nfpm config for the puppet-catalog-tools RPM.
# Rendered through envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`.
name: ${PACKAGE_NAME}
version: ${PACKAGE_VERSION}
release: ${PACKAGE_RELEASE}
arch: ${PACKAGE_ARCH}
platform: ${PACKAGE_PLATFORM}
section: default
priority: extra
description: "${PACKAGE_DESCRIPTION}"
maintainer: ${PACKAGE_MAINTAINER}
homepage: ${PACKAGE_HOMEPAGE}
license: ${PACKAGE_LICENSE}
disable_globbing: false
replaces:
- puppet-catalog-tools
provides:
- puppet-catalog-tools
contents:
- src: dist/catalog-diff
dst: /usr/bin/catalog-diff
file_info:
mode: 0755
owner: root
group: root
# Shell completions (generated by scripts/build-rpm.sh before packaging).
- src: dist/completions/catalog-diff.bash
dst: /usr/share/bash-completion/completions/catalog-diff
file_info:
mode: 0644
- src: dist/completions/_catalog-diff
dst: /usr/share/zsh/site-functions/_catalog-diff
file_info:
mode: 0644
- src: dist/completions/catalog-diff.fish
dst: /usr/share/fish/vendor_completions.d/catalog-diff.fish
file_info:
mode: 0644
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
#
# Package the (already built) puppet-catalog-tools binaries into an RPM with
# nfpm, bundling generated bash/zsh/fish shell completions.
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}" # strip a leading v
PACKAGE="puppet-catalog-tools"
BINARIES=(catalog-diff)
DIST="dist"
for b in "${BINARIES[@]}"; do
if [ ! -f "${DIST}/${b}" ]; then
echo "ERROR: ${DIST}/${b} not found; run 'make build' first" >&2
exit 1
fi
done
# Generate shell completions from the freshly built binaries so they always
# match the shipped flags/subcommands.
COMP_DIR="${DIST}/completions"
mkdir -p "${COMP_DIR}"
for b in "${BINARIES[@]}"; do
"./${DIST}/${b}" completion bash >"${COMP_DIR}/${b}.bash"
"./${DIST}/${b}" completion zsh >"${COMP_DIR}/_${b}"
"./${DIST}/${b}" completion fish >"${COMP_DIR}/${b}.fish"
done
export PACKAGE_NAME="${PACKAGE}"
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="Puppet catalog tooling: catalog-diff compiles a host's catalog in two environments and diffs them"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/puppet-catalog-tools"
export PACKAGE_LICENSE="MIT"
envsubst <packaging/nfpm.yaml >"${DIST}/nfpm.yaml"
nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm
echo "Built:"
ls -1 "${DIST}"/*.rpm