Files
puppet-catalog-tools/internal/puppetserver/puppetserver_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

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)
}
}