Files
unkin-agent 04ec9e6d41
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
carry dropped fact records through the failure path and count them
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.
2026-09-12 20:27:55 +10:00

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
}