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

236 lines
8.0 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
const estateBody = `[
{"certname":"web1.example.net","name":"enc_role","value":"roles::apps::media::jellyfin"},
{"certname":"web1.example.net","name":"enc_role_tier1","value":"apps"},
{"certname":"web1.example.net","name":"enc_role_tier2","value":"media"},
{"certname":"web1.example.net","name":"enc_role_tier3","value":"jellyfin"},
{"certname":"web1.example.net","name":"country","value":"au"},
{"certname":"web1.example.net","name":"region","value":"syd1"},
{"certname":"web1.example.net","name":"virtual","value":"lxc"},
{"certname":"web1.example.net","name":"os","value":{"name":"AlmaLinux","release":{"major":"9","minor":"7"}}},
{"certname":"k8s1.example.net","name":"enc_role","value":"roles::infra::k8s::node"},
{"certname":"k8s1.example.net","name":"enc_role_tier1","value":"infra"},
{"certname":"k8s1.example.net","name":"enc_role_tier2","value":"k8s"},
{"certname":"k8s1.example.net","name":"enc_role_tier3","value":"node"},
{"certname":"k8s1.example.net","name":"country","value":"au"},
{"certname":"k8s1.example.net","name":"region","value":"syd1"},
{"certname":"k8s1.example.net","name":"virtual","value":"physical"},
{"certname":"k8s1.example.net","name":"os","value":{"name":"AlmaLinux","release":{"major":"9","minor":"8"}}}
]`
func fakePuppetDB(t *testing.T) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(estateBody))
}))
t.Cleanup(srv.Close)
return srv.URL
}
func TestRunWithExplicitFileList(t *testing.T) {
var out bytes.Buffer
opts := &options{
repo: "../../internal/impact/testdata/repo",
base: "origin/develop",
files: []string{"modules/jellyfin/manifests/install.pp"},
puppetdb: fakePuppetDB(t),
timeout: 5 * time.Second,
}
if err := run(context.Background(), &out, io.Discard, opts); err != nil {
t.Fatalf("run: %v", err)
}
if got, want := out.String(), "web1.example.net\n"; got != want {
t.Errorf("output = %q, want %q", got, want)
}
}
func TestRunExplain(t *testing.T) {
var out bytes.Buffer
opts := &options{
repo: "../../internal/impact/testdata/repo",
base: "origin/develop",
files: []string{"hieradata/common.yaml"},
puppetdb: fakePuppetDB(t),
explain: true,
timeout: 5 * time.Second,
}
if err := run(context.Background(), &out, io.Discard, opts); err != nil {
t.Fatalf("run: %v", err)
}
if !strings.Contains(out.String(), "the whole estate is affected") {
t.Errorf("explain output = %q", out.String())
}
}
func TestRunRejectsConflictingOutputFlags(t *testing.T) {
opts := &options{jsonOut: true, explain: true}
if err := run(context.Background(), &bytes.Buffer{}, io.Discard, opts); err == nil {
t.Fatal("expected an error for --json with --explain")
}
}
func TestRunRejectsNegativeLimit(t *testing.T) {
opts := &options{limit: -1}
if err := run(context.Background(), &bytes.Buffer{}, io.Discard, opts); err == nil {
t.Fatal("expected an error for a negative --limit")
}
}
// undecodableEstateBody strips the value from every one of web1's records, so
// each decodes to nothing and the host leaves the estate table with it.
const undecodableEstateBody = `[
{"certname":"web1.example.net","name":"enc_role"},
{"certname":"web1.example.net","name":"country"},
{"certname":"web1.example.net","name":"os"},
{"certname":"k8s1.example.net","name":"enc_role","value":"roles::infra::k8s::node"},
{"certname":"k8s1.example.net","name":"country","value":"au"},
{"certname":"k8s1.example.net","name":"os","value":{"name":"AlmaLinux","release":{"major":"9","minor":"8"}}}
]`
func fakePuppetDBBody(t *testing.T, body string) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv.URL
}
func droppedFactOptions(t *testing.T) *options {
t.Helper()
return &options{
repo: "../../internal/impact/testdata/repo",
base: "origin/develop",
files: []string{"modules/jellyfin/manifests/install.pp"},
puppetdb: fakePuppetDBBody(t, undecodableEstateBody),
timeout: 5 * time.Second,
}
}
func TestRunReportsDroppedFactRecordsOnStderr(t *testing.T) {
var out, errOut bytes.Buffer
if err := run(context.Background(), &out, &errOut, droppedFactOptions(t)); err != nil {
t.Fatalf("run: %v", err)
}
if out.String() != "" {
t.Fatalf("web1 should have dropped out of the estate table, output = %q", out.String())
}
for _, want := range []string{"skipped 3 undecodable fact(s)", "the estate table may be missing hosts"} {
if !strings.Contains(errOut.String(), want) {
t.Errorf("stderr = %q, want it to mention %q", errOut.String(), want)
}
}
if got := strings.Count(errOut.String(), "\n"); got != 1 {
t.Errorf("stderr has %d warning line(s), want 1: %q", got, errOut.String())
}
}
func TestRunReportsDroppedFactRecordsInJSON(t *testing.T) {
var out bytes.Buffer
opts := droppedFactOptions(t)
opts.jsonOut = true
if err := run(context.Background(), &out, io.Discard, opts); err != nil {
t.Fatalf("run: %v", err)
}
var doc struct {
NodeWarnings []string `json:"node_warnings"`
Summary struct {
ResolutionIncomplete bool `json:"resolution_incomplete"`
DroppedFactRecords int `json:"dropped_fact_records"`
} `json:"summary"`
}
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("decoding --json output: %v", err)
}
if len(doc.NodeWarnings) != 1 {
t.Errorf("node_warnings = %v, want the one batched warning", doc.NodeWarnings)
}
if !doc.Summary.ResolutionIncomplete {
t.Error("resolution_incomplete is false after records were dropped")
}
if doc.Summary.DroppedFactRecords != 3 {
t.Errorf("summary.dropped_fact_records = %d, want 3", doc.Summary.DroppedFactRecords)
}
}
func TestRunReportsDroppedFactRecordsInExplain(t *testing.T) {
var out bytes.Buffer
opts := droppedFactOptions(t)
opts.explain = true
if err := run(context.Background(), &out, io.Discard, opts); err != nil {
t.Fatalf("run: %v", err)
}
// web1 dropped three records in one warning, so the headline must count
// records, not messages.
for _, want := range []string{
"fact records dropped",
"3 fact record(s) dropped",
"skipped 3 undecodable fact(s)",
} {
if !strings.Contains(out.String(), want) {
t.Errorf("explain output = %q, want it to mention %q", out.String(), want)
}
}
}
// allUndecodableEstateBody strips the value from every record of every host,
// so the estate table comes back empty and the load fails.
const allUndecodableEstateBody = `[
{"certname":"web1.example.net","name":"enc_role"},
{"certname":"web1.example.net","name":"country"},
{"certname":"k8s1.example.net","name":"enc_role"},
{"certname":"k8s1.example.net","name":"country"}
]`
func TestRunReportsDroppedFactRecordsWhenTheLoadFails(t *testing.T) {
for _, tc := range []struct {
name string
jsonOut bool
explain bool
}{
{name: "plain"},
{name: "json", jsonOut: true},
{name: "explain", explain: true},
} {
t.Run(tc.name, func(t *testing.T) {
var out, errOut bytes.Buffer
opts := &options{
repo: "../../internal/impact/testdata/repo",
base: "origin/develop",
files: []string{"modules/jellyfin/manifests/install.pp"},
puppetdb: fakePuppetDBBody(t, allUndecodableEstateBody),
jsonOut: tc.jsonOut,
explain: tc.explain,
timeout: 5 * time.Second,
}
err := run(context.Background(), &out, &errOut, opts)
if err == nil {
t.Fatal("an empty estate table must fail the run")
}
if !strings.Contains(err.Error(), "4 fact record(s) dropped") {
t.Errorf("error = %q, want it to account for the 4 dropped records", err)
}
if !strings.Contains(errOut.String(), "skipped 4 undecodable fact(s)") {
t.Errorf("stderr = %q, want the dropped records reported", errOut.String())
}
})
}
}