496f258cb0
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
425 lines
14 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|