04ec9e6d41
Report the accumulated warnings on stderr when the estate load fails, and fold the dropped-record tally into the error. Count records rather than warning messages in --explain, by carrying the count on the Warn hook.
467 lines
15 KiB
Go
467 lines
15 KiB
Go
package puppetdb
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The shape PuppetDB answers /pdb/query/v4/facts with: a flat array of fact
|
|
// records, each naming the node it belongs to.
|
|
const factsBody = `[
|
|
{"certname":"host.example.net","environment":"develop","name":"id","value":"root"},
|
|
{"certname":"host.example.net","environment":"develop","name":"os","value":{"family":"RedHat","release":{"major":"9"}}},
|
|
{"certname":"host.example.net","environment":"develop","name":"processorcount","value":4}
|
|
]`
|
|
|
|
func assertFacts(t *testing.T, facts map[string]any) {
|
|
t.Helper()
|
|
if facts["id"] != "root" {
|
|
t.Errorf("id = %v", facts["id"])
|
|
}
|
|
if facts["processorcount"] != float64(4) {
|
|
t.Errorf("processorcount = %#v", facts["processorcount"])
|
|
}
|
|
os, ok := facts["os"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("os fact not structured: %#v", facts["os"])
|
|
}
|
|
if os["family"] != "RedHat" {
|
|
t.Errorf("os.family = %v", os["family"])
|
|
}
|
|
release, ok := os["release"].(map[string]any)
|
|
if !ok || release["major"] != "9" {
|
|
t.Errorf("nested os.release lost: %#v", os["release"])
|
|
}
|
|
}
|
|
|
|
// factsServer answers the query route with body and fails the test on any
|
|
// other path.
|
|
func factsServer(t *testing.T, body string) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/pdb/query/v4/facts" {
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func TestFactsQueriesByCertname(t *testing.T) {
|
|
var paths, queries []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
paths = append(paths, r.URL.Path)
|
|
queries = append(queries, r.URL.Query().Get("query"))
|
|
_, _ = w.Write([]byte(factsBody))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
|
|
if err != nil {
|
|
t.Fatalf("Facts: %v", err)
|
|
}
|
|
assertFacts(t, facts)
|
|
if len(paths) != 1 || paths[0] != "/pdb/query/v4/facts" {
|
|
t.Errorf("unexpected requests: %v", paths)
|
|
}
|
|
if len(queries) != 1 || queries[0] != `["=","certname","host.example.net"]` {
|
|
t.Errorf("unexpected query: %v", queries)
|
|
}
|
|
}
|
|
|
|
// A certname with characters the AST would otherwise have to escape must
|
|
// survive both JSON encoding and query-string encoding intact.
|
|
func TestFactsEscapesTheCertnameInTheQuery(t *testing.T) {
|
|
var got string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got = r.URL.Query().Get("query")
|
|
_, _ = w.Write([]byte(`[]`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
const certname = `we"ird &host`
|
|
_, _ = New(srv.URL, 5*time.Second).Facts(context.Background(), certname)
|
|
|
|
var ast []string
|
|
if err := json.Unmarshal([]byte(got), &ast); err != nil {
|
|
t.Fatalf("query %q is not valid JSON: %v", got, err)
|
|
}
|
|
if len(ast) != 3 || ast[0] != "=" || ast[1] != "certname" || ast[2] != certname {
|
|
t.Errorf("query = %q, decoded %#v", got, ast)
|
|
}
|
|
}
|
|
|
|
// The response shape as PuppetDB really returns it, structured facts and all.
|
|
func TestFactsDecodesARealResponse(t *testing.T) {
|
|
srv := factsServer(t, `[
|
|
{"certname":"host.example.net","environment":"develop","name":"id","value":"root"},
|
|
{"certname":"host.example.net","environment":"develop","name":"processorcount","value":4},
|
|
{"certname":"host.example.net","environment":"develop","name":"os","value":{"name":"AlmaLinux","distro":{"id":"AlmaLinux","release":{"full":"9.7","major":"9","minor":"7"}},"family":"RedHat","release":{"full":"9.7","major":"9","minor":"7"},"selinux":{"enabled":false},"architecture":"x86_64"}}
|
|
]`)
|
|
|
|
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
|
|
if err != nil {
|
|
t.Fatalf("Facts: %v", err)
|
|
}
|
|
assertFacts(t, facts)
|
|
os := facts["os"].(map[string]any)
|
|
if os["architecture"] != "x86_64" {
|
|
t.Errorf("os.architecture = %v", os["architecture"])
|
|
}
|
|
selinux, ok := os["selinux"].(map[string]any)
|
|
if !ok || selinux["enabled"] != false {
|
|
t.Errorf("nested os.selinux lost: %#v", os["selinux"])
|
|
}
|
|
}
|
|
|
|
// An unknown node is not an HTTP error: the query simply matches nothing.
|
|
func TestFactsEmptyResultIsAMissingNode(t *testing.T) {
|
|
srv := factsServer(t, `[]`)
|
|
|
|
_, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "nope.example.net")
|
|
if !errors.Is(err, ErrNodeNotFound) {
|
|
t.Fatalf("err = %v, want ErrNodeNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestFactsMissingNode(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":"No information is known about node nope.example.net"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
_, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "nope.example.net")
|
|
if !errors.Is(err, ErrNodeNotFound) {
|
|
t.Fatalf("err = %v, want ErrNodeNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestFactsServerError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`boom`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
_, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
|
|
if err == nil {
|
|
t.Fatal("expected an error on HTTP 500")
|
|
}
|
|
if errors.Is(err, ErrNodeNotFound) {
|
|
t.Fatalf("500 misreported as a missing node: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFactsBadJSON(t *testing.T) {
|
|
srv := factsServer(t, `not json`)
|
|
|
|
if _, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net"); err == nil {
|
|
t.Fatal("expected a parse error")
|
|
}
|
|
}
|
|
|
|
func TestFactsContextCancelled(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
time.Sleep(200 * time.Millisecond)
|
|
_, _ = w.Write([]byte(factsBody))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
|
defer cancel()
|
|
if _, err := New(srv.URL, 5*time.Second).Facts(ctx, "host.example.net"); err == nil {
|
|
t.Fatal("expected a timeout error")
|
|
}
|
|
}
|
|
|
|
// assertNamesBoth checks the mismatch error names the node asked for and the
|
|
// ones that came back, and that it is not dressed up as a parse failure.
|
|
func assertNamesBoth(t *testing.T, err error, want string, got ...string) {
|
|
t.Helper()
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("error does not name the requested certname %s: %v", want, err)
|
|
}
|
|
for _, g := range got {
|
|
if !strings.Contains(err.Error(), g) {
|
|
t.Errorf("error does not name the returned certname %s: %v", g, err)
|
|
}
|
|
}
|
|
if strings.Contains(err.Error(), "parsing facts") {
|
|
t.Errorf("a certname mismatch is not a parse failure: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFactsRejectsRecordsForAnotherNode(t *testing.T) {
|
|
srv := factsServer(t, `[
|
|
{"certname":"totally-different-host.example.net","name":"id","value":"nobody"},
|
|
{"certname":"totally-different-host.example.net","name":"os","value":{"family":"Debian"}}
|
|
]`)
|
|
|
|
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
|
|
if err == nil {
|
|
t.Fatalf("another node's facts were served as ours: %#v", facts)
|
|
}
|
|
assertNamesBoth(t, err, "host.example.net", "totally-different-host.example.net")
|
|
}
|
|
|
|
func TestFactsRejectsAMixedResponseNamingEveryNode(t *testing.T) {
|
|
srv := factsServer(t, `[
|
|
{"certname":"a.example.net","name":"id","value":"a"},
|
|
{"certname":"b.example.net","name":"id","value":"b"}
|
|
]`)
|
|
|
|
_, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
|
|
if err == nil {
|
|
t.Fatal("expected an error when no record matches the certname")
|
|
}
|
|
assertNamesBoth(t, err, "host.example.net", "a.example.net", "b.example.net")
|
|
}
|
|
|
|
func TestFactsDropsAForeignRecordFromAnOtherwiseMatchingResponse(t *testing.T) {
|
|
srv := factsServer(t, `[
|
|
{"certname":"host.example.net","name":"id","value":"root"},
|
|
{"certname":"other.example.net","name":"id","value":"nobody"},
|
|
{"certname":"host.example.net","name":"os","value":{"family":"RedHat","release":{"major":"9"}}},
|
|
{"certname":"host.example.net","name":"processorcount","value":4}
|
|
]`)
|
|
|
|
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
|
|
if err != nil {
|
|
t.Fatalf("Facts: %v", err)
|
|
}
|
|
assertFacts(t, facts)
|
|
}
|
|
|
|
// The query already names the node, so a record that asserts no certname of
|
|
// its own cannot be contradicting it.
|
|
func TestFactsAcceptsRecordsWithNoCertname(t *testing.T) {
|
|
srv := factsServer(t, `[
|
|
{"name":"id","value":"root"},
|
|
{"name":"os","value":{"family":"RedHat","release":{"major":"9"}}},
|
|
{"name":"processorcount","value":4}
|
|
]`)
|
|
|
|
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
|
|
if err != nil {
|
|
t.Fatalf("Facts: %v", err)
|
|
}
|
|
assertFacts(t, facts)
|
|
}
|
|
|
|
func TestFactsMatchesCertnameCaseInsensitively(t *testing.T) {
|
|
srv := factsServer(t, factsBody)
|
|
|
|
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "HOST.example.net")
|
|
if err != nil {
|
|
t.Fatalf("Facts: %v", err)
|
|
}
|
|
assertFacts(t, facts)
|
|
}
|
|
|
|
func TestFactsToleratesALeadingBOM(t *testing.T) {
|
|
srv := factsServer(t, "\ufeff"+factsBody)
|
|
|
|
facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
|
|
if err != nil {
|
|
t.Fatalf("Facts: %v", err)
|
|
}
|
|
assertFacts(t, facts)
|
|
}
|
|
|
|
func TestFactsSkipsUndecodableRecords(t *testing.T) {
|
|
srv := factsServer(t, `[
|
|
{"certname":"host.example.net","name":"id","value":"root"},
|
|
{"certname":"host.example.net","name":"broken"},
|
|
{"certname":"host.example.net","name":"os","value":{"family":"RedHat","release":{"major":"9"}}},
|
|
{"certname":"host.example.net","name":"processorcount","value":4}
|
|
]`)
|
|
|
|
var warnings []string
|
|
var counted int
|
|
c := New(srv.URL, 5*time.Second)
|
|
c.Warn = func(msg string, records int) {
|
|
warnings = append(warnings, msg)
|
|
counted += records
|
|
}
|
|
|
|
facts, err := c.Facts(context.Background(), "host.example.net")
|
|
if err != nil {
|
|
t.Fatalf("one bad fact must not fail the fetch: %v", err)
|
|
}
|
|
assertFacts(t, facts)
|
|
if _, ok := facts["broken"]; ok {
|
|
t.Error("a valueless fact must be dropped, not stored")
|
|
}
|
|
if len(warnings) != 1 || !strings.Contains(warnings[0], "broken") {
|
|
t.Errorf("the skipped fact must be reported, got %v", warnings)
|
|
}
|
|
if counted != 1 {
|
|
t.Errorf("Warn reported %d dropped record(s), want 1", counted)
|
|
}
|
|
}
|
|
|
|
func TestFactsWithNothingDecodableIsAnError(t *testing.T) {
|
|
srv := factsServer(t, `[{"name":"a"},{"name":"b"}]`)
|
|
|
|
_, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net")
|
|
if err == nil {
|
|
t.Fatal("a fact set in which nothing decodes must not pass as empty")
|
|
}
|
|
if !strings.Contains(err.Error(), "no usable facts") {
|
|
t.Errorf("unhelpful error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFactsByName(t *testing.T) {
|
|
var gotQuery, gotPath string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotQuery = r.URL.Query().Get("query")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[
|
|
{"certname":"a.example.net","name":"country","value":"au"},
|
|
{"certname":"a.example.net","name":"os","value":{"name":"AlmaLinux","release":{"major":"9","minor":"7"}}}
|
|
]`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
records, err := New(srv.URL, 5*time.Second).FactsByName(context.Background(), []string{"country", "os"})
|
|
if err != nil {
|
|
t.Fatalf("FactsByName: %v", err)
|
|
}
|
|
if gotPath != "/pdb/query/v4/facts" {
|
|
t.Errorf("path = %s", gotPath)
|
|
}
|
|
if gotQuery != `["extract",["certname","name","value"],["or",["=","name","country"],["=","name","os"]]]` {
|
|
t.Errorf("query = %s", gotQuery)
|
|
}
|
|
if len(records) != 2 {
|
|
t.Fatalf("records = %#v", records)
|
|
}
|
|
if records[0].Certname != "a.example.net" || records[0].Value != "au" {
|
|
t.Errorf("first record = %#v", records[0])
|
|
}
|
|
os, ok := records[1].Value.(map[string]any)
|
|
if !ok || os["name"] != "AlmaLinux" {
|
|
t.Errorf("structured os fact lost: %#v", records[1].Value)
|
|
}
|
|
}
|
|
|
|
func TestFactsByNameEmptyNames(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
t.Error("no request should be made for an empty fact list")
|
|
}))
|
|
defer srv.Close()
|
|
|
|
records, err := New(srv.URL, 5*time.Second).FactsByName(context.Background(), nil)
|
|
if err != nil || records != nil {
|
|
t.Errorf("FactsByName(nil) = %v, %v", records, err)
|
|
}
|
|
}
|
|
|
|
func TestFactsByNameServerError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte("boom"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if _, err := New(srv.URL, 5*time.Second).FactsByName(context.Background(), []string{"country"}); err == nil {
|
|
t.Fatal("expected an error for a 500")
|
|
}
|
|
}
|
|
|
|
func TestFactsByNameSkipsUndecodableRecords(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte("\ufeff" + `[
|
|
{"certname":"a.example.net","name":"country","value":"au"},
|
|
{"certname":"a.example.net","name":"broken"},
|
|
{"certname":"b.example.net","name":"broken"},
|
|
{"certname":"c.example.net","name":"broken"}
|
|
]`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
var warnings []string
|
|
var counted int
|
|
c := New(srv.URL, 5*time.Second)
|
|
c.Warn = func(msg string, records int) {
|
|
warnings = append(warnings, msg)
|
|
counted += records
|
|
}
|
|
|
|
records, err := c.FactsByName(context.Background(), []string{"country", "broken"})
|
|
if err != nil {
|
|
t.Fatalf("FactsByName: %v", err)
|
|
}
|
|
if len(records) != 1 || records[0].Name != "country" {
|
|
t.Fatalf("records = %#v", records)
|
|
}
|
|
// One query batches its drops into one message, so the count has to come
|
|
// from the hook and not from the number of messages.
|
|
if len(warnings) != 1 || !strings.Contains(warnings[0], "broken") {
|
|
t.Errorf("the skipped fact must be reported, got %v", warnings)
|
|
}
|
|
if counted != 3 {
|
|
t.Errorf("Warn reported %d dropped record(s), want 3", counted)
|
|
}
|
|
}
|
|
|
|
func TestFactsByNameWithoutWarnHookReportsOnStderr(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[{"certname":"a.example.net","name":"broken"}]`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
stderr := captureStderr(t)
|
|
if _, err := New(srv.URL, 5*time.Second).FactsByName(context.Background(), []string{"broken"}); err != nil {
|
|
t.Fatalf("FactsByName: %v", err)
|
|
}
|
|
if got := stderr(); !strings.Contains(got, "broken") {
|
|
t.Errorf("an unwired Warn must still report the drop, stderr = %q", got)
|
|
}
|
|
}
|
|
|
|
// captureStderr redirects os.Stderr for the duration of the test and returns a
|
|
// func that restores it and yields what was written.
|
|
func captureStderr(t *testing.T) func() string {
|
|
t.Helper()
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("pipe: %v", err)
|
|
}
|
|
saved := os.Stderr
|
|
os.Stderr = w
|
|
|
|
var once sync.Once
|
|
var out string
|
|
read := func() string {
|
|
once.Do(func() {
|
|
os.Stderr = saved
|
|
_ = w.Close()
|
|
b, _ := io.ReadAll(r)
|
|
_ = r.Close()
|
|
out = string(b)
|
|
})
|
|
return out
|
|
}
|
|
t.Cleanup(func() { read() })
|
|
return read
|
|
}
|