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
266 lines
8.5 KiB
Go
266 lines
8.5 KiB
Go
package puppetserver
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
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 cat.Resources[1].File == "" || cat.Resources[1].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()
|
|
|
|
if _, err := tlsClient(t, srv).Compile(context.Background(), CompileRequest{
|
|
Certname: "host.example.net", Environment: "benvin_test",
|
|
}); err == nil {
|
|
t.Fatal("expected a parse error")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|