Files
puppet-catalog-tools/cmd/catalog-diff/main_test.go
T
unkin-agent 53007a9257
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
ci/woodpecker/pr/test Pipeline was canceled
add repo scaffold and the catalog-diff CLI
Compile a host's catalog in a base and a test environment over the
puppetserver API, then diff the two.

- add catalog-diff CLI with cobra flags, config precedence and exit codes
- add puppetserver mTLS client for POST /puppet/v4/catalog
- add pdbmux fact client
- add catalog normalisation, diff engine and text/JSON renderers
- add Makefile, nfpm RPM packaging and woodpecker pipelines
2026-09-12 15:54:25 +10:00

348 lines
11 KiB
Go

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 pdbmux (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,
pdb: 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())
}
}