Files
puppet-catalog-tools/cmd/catalog-diff/main_test.go
T
unkin-agent 6e4ee54203 validate flag ranges and make exitError printable when empty
A negative --retry skipped the compile loop, leaving a nil error inside
exitError that panicked when printed.

- reject a negative or oversized --retry and a non-positive --timeout as
  usage errors (exit 3)
- print an exit code when exitError carries no inner error
- cap the backoff shift so a large --retry cannot overflow
- cancel the sibling compile on the first failure, and do not report that
  cancellation as the cause
- report a context cancellation during the retry backoff instead of the
  stale compile error
2026-09-12 17:19:28 +10:00

586 lines
18 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())
}
}
// 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)
}
}