53007a9257
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
348 lines
11 KiB
Go
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())
|
|
}
|
|
}
|