Files
unkin-agent 496f258cb0
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
make the catalog fixture synthetic
The capture carried a production certname, real role and class names and
internal paths into a public repo.

- replace hostnames, class names and paths with fake equivalents
- keep the shape under test: catalog envelope, string version, null
  code_id, resources with and without parameters and file/line, the
  exported and sensitive_parameters cases
- document the range of --retry and --timeout
2026-09-12 17:19:28 +10:00

425 lines
14 KiB
Go

package puppetserver
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.unkin.net/unkin/puppet-catalog-tools/internal/catalog"
)
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 line := cat.Resources[1].Line; cat.Resources[1].File == "" || line == nil || *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()
_, err := tlsClient(t, srv).Compile(context.Background(), CompileRequest{
Certname: "host.example.net", Environment: "benvin_test",
})
var re *ResponseError
if !errors.As(err, &re) {
t.Fatalf("err = %v (%T), want *ResponseError", err, err)
}
}
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)
}
}
// wrappedResponse is a synthetic OpenVoxServer 8.8 response shaped like the
// real thing: the catalog sits under a single "catalog" key.
func wrappedResponse(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", "catalog_response.json"))
if err != nil {
t.Fatal(err)
}
return raw
}
func compileFixture(t *testing.T, body []byte, environment string) (*catalog.Catalog, error) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
defer srv.Close()
return tlsClient(t, srv).Compile(context.Background(), CompileRequest{
Certname: "web01.example.test", Environment: environment,
})
}
func TestCompileUnwrapsServerResponse(t *testing.T) {
cat, err := compileFixture(t, wrappedResponse(t), "develop")
if err != nil {
t.Fatalf("Compile: %v", err)
}
if cat.Environment != "develop" || cat.Name != "web01.example.test" {
t.Fatalf("envelope not unwrapped: %+v", cat)
}
if len(cat.Resources) != 9 || len(cat.Classes) == 0 || len(cat.Tags) == 0 {
t.Fatalf("catalog body lost: %d resources, %d classes, %d tags",
len(cat.Resources), len(cat.Classes), len(cat.Tags))
}
if string(cat.Version) != `"0123456789abcdef0123456789abcdef01234567"` {
t.Errorf("version = %s, want the string form the server sends", cat.Version)
}
}
func TestCompileUnwrapsBareCatalog(t *testing.T) {
var wrapped struct {
Catalog json.RawMessage `json:"catalog"`
}
if err := json.Unmarshal(wrappedResponse(t), &wrapped); err != nil {
t.Fatal(err)
}
cat, err := compileFixture(t, wrapped.Catalog, "develop")
if err != nil {
t.Fatalf("an unwrapped catalog must still decode: %v", err)
}
if cat.Environment != "develop" || len(cat.Resources) != 9 {
t.Fatalf("unexpected catalog: %+v", cat)
}
}
func TestCompileDecodesOptionalResourceFields(t *testing.T) {
cat, err := compileFixture(t, wrappedResponse(t), "develop")
if err != nil {
t.Fatal(err)
}
byKey := map[string]catalog.Resource{}
for _, r := range cat.Resources {
byKey[r.Key()] = r
}
settings, ok := byKey["Class[Settings]"]
if !ok {
t.Fatalf("fixture lost Class[Settings]: %v", byKey)
}
if settings.Parameters != nil || len(settings.Params()) != 0 {
t.Errorf("a resource without parameters must read as an empty set: %+v", settings)
}
if settings.HasSource() || settings.Line != nil {
t.Errorf("a resource without file/line must report no source: %+v", settings)
}
anchor := byKey["Anchor[examplemod::end]"]
if anchor.Parameters != nil {
t.Errorf("anchor parameters should be absent: %+v", anchor.Parameters)
}
if !anchor.HasSource() || *anchor.Line != 111 {
t.Errorf("anchor manifest location lost: %+v", anchor)
}
pkg := byKey["Package[example-agent]"]
if pkg.Params()["ensure"] != "1.2.3" || !pkg.HasSource() {
t.Errorf("package decoded wrong: %+v", pkg)
}
key := byKey["File[/etc/example/secret.key]"]
if len(key.SensitiveParameters) != 1 || key.SensitiveParameters[0] != "content" {
t.Errorf("per-resource sensitive_parameters lost: %+v", key)
}
member := byKey["Haproxy::Balancermember[web01.example.test_9443]"]
if !member.Exported {
t.Errorf("exported flag lost: %+v", member)
}
}
func TestCompileEmptyEnvironmentIsAProtocolError(t *testing.T) {
_, err := compileFixture(t, []byte(`{"unexpected":{"environment":"develop"}}`), "develop")
var re *ResponseError
if !errors.As(err, &re) {
t.Fatalf("err = %v (%T), want *ResponseError", err, err)
}
var me *EnvironmentMismatchError
if errors.As(err, &me) {
t.Fatal("a response with no environment must not be reported as a mismatch")
}
msg := err.Error()
if !strings.Contains(msg, "protocol mismatch") {
t.Errorf("message must name the real cause: %s", msg)
}
for _, forbidden := range []string{"encapi", "ENC", "testing"} {
if strings.Contains(msg, forbidden) {
t.Errorf("a decode failure must not blame %q: %s", forbidden, msg)
}
}
}
func TestCompileErrorTransientClassification(t *testing.T) {
const pdbFailure = "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"
const missingClass = "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"
tests := []struct {
name string
err CompileError
wantRun bool
}{
{name: "puppetdb query failure", err: CompileError{Status: 500, Message: pdbFailure}, wantRun: true},
{name: "missing class", err: CompileError{Status: 500, Message: missingClass}},
{name: "manifest error at 500", err: CompileError{Status: 500, Message: "Evaluation Error: undefined method `escape' for URI:Module"}},
{name: "manifest error at 400", err: CompileError{Status: 400, Kind: "puppet.server/compilation-error", Message: "Syntax error at '}'"}},
{name: "bad gateway", err: CompileError{Status: 502, Message: "bad gateway"}, wantRun: true},
{name: "service unavailable", err: CompileError{Status: 503, Message: ""}, wantRun: true},
{name: "bad request", err: CompileError{Status: 400, Message: "no message returned"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.err.Transient(); got != tc.wantRun {
t.Errorf("Transient() = %v, want %v", got, tc.wantRun)
}
})
}
}