Merge pull request 'Add repo scaffold and the catalog-diff CLI' (#1) from benvin/scaffold-catalog-diff into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+19
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Executable
+49
@@ -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
|
||||
Reference in New Issue
Block a user