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

350 lines
12 KiB
Go

package impact
import (
"bytes"
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"git.unkin.net/unkin/puppet-catalog-tools/internal/puppetdb"
)
func testAnalyzer(t *testing.T) *Analyzer {
t.Helper()
return &Analyzer{Index: testIndex(t), Nodes: fixtureNodes()}
}
func analyze(t *testing.T, files ...string) *Result {
t.Helper()
return testAnalyzer(t).Analyze("origin/develop", "benvin/test", files, 0)
}
func TestAnalyzeCommonAndHieraTriggerEveryHost(t *testing.T) {
all := []string{"bare1", "k8s1", "k8s2", "prox1", "web1"}
for _, file := range []string{"hieradata/common.yaml", "hiera.yaml", "environment.conf", "Puppetfile", "manifests/site.pp"} {
r := analyze(t, file)
if got := r.Certnames(); !reflect.DeepEqual(got, all) {
t.Errorf("%s affected %v, want every host", file, got)
}
if !r.Summary.AllHostsTriggered {
t.Errorf("%s did not set all_hosts_triggered", file)
}
}
}
func TestAnalyzeHieradataRolePrefix(t *testing.T) {
r := analyze(t, "hieradata/roles/infra.yaml")
if got, want := r.Certnames(), []string{"k8s1", "k8s2", "prox1"}; !reflect.DeepEqual(got, want) {
t.Errorf("hosts = %v, want %v", got, want)
}
if r.Summary.AllHostsTriggered {
t.Error("a role-prefix change must not report the whole estate")
}
}
func TestAnalyzeRoleManifestIsExact(t *testing.T) {
r := analyze(t, "site/roles/manifests/infra/k8s/node.pp")
if got, want := r.Certnames(), []string{"k8s1", "k8s2"}; !reflect.DeepEqual(got, want) {
t.Errorf("hosts = %v, want %v", got, want)
}
if r.Hosts[0].Reasons[0].Rule != RuleRoleClass {
t.Errorf("rule = %q, want %q", r.Hosts[0].Reasons[0].Rule, RuleRoleClass)
}
}
func TestAnalyzeTemplateAttribution(t *testing.T) {
r := analyze(t, "site/profiles/templates/leaf/x.erb")
if got, want := r.Certnames(), []string{"k8s1", "k8s2", "web1"}; !reflect.DeepEqual(got, want) {
t.Errorf("hosts = %v, want %v", got, want)
}
}
func TestAnalyzeUnattributedModuleAssetWidensToTheModule(t *testing.T) {
// Nothing references this template, so it falls back to every class the
// module defines — here, the hiera_include-only jellyfin module.
r := analyze(t, "modules/jellyfin/templates/unreferenced.erb")
if got, want := r.Certnames(), []string{"web1"}; !reflect.DeepEqual(got, want) {
t.Errorf("hosts = %v, want %v", got, want)
}
}
func TestAnalyzeUnmappedFileWidensToEveryHost(t *testing.T) {
r := analyze(t, "totally/unknown/thing.txt")
if got, want := r.UnmappedFiles, []string{"totally/unknown/thing.txt"}; !reflect.DeepEqual(got, want) {
t.Errorf("unmapped = %v, want %v", got, want)
}
if len(r.Hosts) != len(fixtureNodes()) {
t.Errorf("unmapped file affected %d hosts, want all %d", len(r.Hosts), len(fixtureNodes()))
}
if r.Hosts[0].Reasons[0].Rule != RuleUnmapped {
t.Errorf("rule = %q", r.Hosts[0].Reasons[0].Rule)
}
}
func TestAnalyzeIgnoresNonPuppetPaths(t *testing.T) {
r := analyze(t, ".woodpecker/test.yaml", "modules/orphanmod/spec/orphanmod_spec.rb", "README.md")
if len(r.Hosts) != 0 {
t.Errorf("CI and spec changes affected %v", r.Certnames())
}
if len(r.IgnoredFiles) != 3 {
t.Errorf("ignored = %v", r.IgnoredFiles)
}
}
func TestAnalyzeReportsOrphanClasses(t *testing.T) {
r := analyze(t, "modules/orphanmod/manifests/init.pp")
if got, want := r.OrphanClasses, []string{"orphanmod", "orphanmod::init"}; !reflect.DeepEqual(got, want) {
t.Errorf("orphans = %v, want %v", got, want)
}
if len(r.Hosts) != 0 {
t.Errorf("orphan class reached %v", r.Certnames())
}
}
func TestAnalyzeLimit(t *testing.T) {
r := testAnalyzer(t).Analyze("origin/develop", "benvin/test", []string{"hieradata/common.yaml"}, 2)
if got, want := r.Certnames(), []string{"bare1", "k8s1"}; !reflect.DeepEqual(got, want) {
t.Errorf("hosts = %v, want %v", got, want)
}
if !r.Truncated {
t.Error("Truncated not set")
}
if r.Summary.Hosts != len(fixtureNodes()) {
t.Errorf("summary.hosts = %d, want the untruncated count", r.Summary.Hosts)
}
}
func TestAnalyzeIsDeterministic(t *testing.T) {
files := []string{
"hieradata/roles/apps/media/jellyfin.yaml",
"site/profiles/manifests/leaf.pp",
"modules/jellyfin/manifests/install.pp",
"hieradata/virtual/physical.yaml",
"totally/unknown/thing.txt",
}
var first string
for i := range 20 {
var buf bytes.Buffer
if err := RenderJSON(&buf, analyze(t, files...)); err != nil {
t.Fatalf("RenderJSON: %v", err)
}
if i == 0 {
first = buf.String()
continue
}
if buf.String() != first {
t.Fatal("output changed between runs")
}
}
}
func TestRenderJSONShape(t *testing.T) {
var buf bytes.Buffer
if err := RenderJSON(&buf, analyze(t, "hieradata/roles/apps/media/jellyfin.yaml")); err != nil {
t.Fatalf("RenderJSON: %v", err)
}
var doc struct {
Base string `json:"base"`
Branch string `json:"branch"`
ChangedFiles []string `json:"changed_files"`
UnmappedFiles []string `json:"unmapped_files"`
Hosts []struct {
Certname string `json:"certname"`
Reasons []struct {
File string `json:"file"`
Rule string `json:"rule"`
Detail string `json:"detail"`
} `json:"reasons"`
} `json:"hosts"`
Summary struct {
Hosts int `json:"hosts"`
Files int `json:"files"`
AllHostsTriggered bool `json:"all_hosts_triggered"`
} `json:"summary"`
}
if err := json.Unmarshal(buf.Bytes(), &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if doc.Base != "origin/develop" || doc.Branch != "benvin/test" {
t.Errorf("base/branch = %q/%q", doc.Base, doc.Branch)
}
if len(doc.Hosts) != 1 || doc.Hosts[0].Certname != "web1" {
t.Fatalf("hosts = %#v", doc.Hosts)
}
if len(doc.Hosts[0].Reasons) != 1 || doc.Hosts[0].Reasons[0].Rule != RuleRole {
t.Errorf("reasons = %#v", doc.Hosts[0].Reasons)
}
if doc.Summary.Files != 1 || doc.Summary.Hosts != 1 || doc.Summary.AllHostsTriggered {
t.Errorf("summary = %#v", doc.Summary)
}
}
func TestRenderPlainIsOneHostPerLine(t *testing.T) {
var buf bytes.Buffer
if err := RenderPlain(&buf, analyze(t, "hieradata/roles/infra.yaml")); err != nil {
t.Fatalf("RenderPlain: %v", err)
}
if got, want := buf.String(), "k8s1\nk8s2\nprox1\n"; got != want {
t.Errorf("output = %q, want %q", got, want)
}
}
func TestRenderExplainNamesFileRuleAndHost(t *testing.T) {
var buf bytes.Buffer
if err := RenderExplain(&buf, analyze(t, "modules/jellyfin/manifests/install.pp", "bogus/path")); err != nil {
t.Fatalf("RenderExplain: %v", err)
}
out := buf.String()
for _, want := range []string{"web1", RuleHieraInclude, "hieradata/roles/apps/media/jellyfin.yaml", "bogus/path", "unmapped files"} {
if !strings.Contains(out, want) {
t.Errorf("explain output missing %q:\n%s", want, out)
}
}
}
// stubFacts serves a canned fact table the way PuppetDB would.
type stubFacts struct {
records []puppetdb.FactRecord
asked []string
}
func (s *stubFacts) FactsByName(_ context.Context, names []string) ([]puppetdb.FactRecord, error) {
s.asked = names
return s.records, nil
}
func TestLoadNodesFoldsStructuredOS(t *testing.T) {
src := &stubFacts{records: []puppetdb.FactRecord{
{Certname: "b.example.net", Name: "enc_role", Value: "roles::infra::k8s::node"},
{Certname: "b.example.net", Name: "enc_role_tier1", Value: "infra"},
{Certname: "b.example.net", Name: "enc_role_tier2", Value: "k8s"},
{Certname: "b.example.net", Name: "enc_role_tier3", Value: "node"},
{Certname: "b.example.net", Name: "country", Value: "au"},
{Certname: "b.example.net", Name: "region", Value: "syd1"},
{Certname: "b.example.net", Name: "virtual", Value: "physical"},
{Certname: "b.example.net", Name: "os", Value: map[string]any{
"name": "AlmaLinux", "release": map[string]any{"major": "9", "minor": "7"},
}},
{Certname: "a.example.net", Name: "enc_role", Value: "roles::base"},
{Certname: "a.example.net", Name: "enc_role_tier1", Value: "base"},
}}
nodes, warnings, err := LoadNodes(context.Background(), src)
if err != nil {
t.Fatalf("LoadNodes: %v", err)
}
if len(warnings) != 0 {
t.Errorf("warnings = %v", warnings)
}
if !reflect.DeepEqual(src.asked, FactNames) {
t.Errorf("asked for %v, want %v", src.asked, FactNames)
}
if len(nodes) != 2 || nodes[0].Certname != "a.example.net" {
t.Fatalf("nodes = %#v", nodes)
}
want := Node{Certname: "b.example.net", Role: "roles::infra::k8s::node",
Tier1: "infra", Tier2: "k8s", Tier3: "node", Country: "au", Region: "syd1",
Virtual: "physical", OSName: "AlmaLinux", OSMajor: "9", OSMinor: "7"}
if nodes[1] != want {
t.Errorf("node = %#v, want %#v", nodes[1], want)
}
}
func TestLoadNodesRejectsEmptyEstate(t *testing.T) {
if _, _, err := LoadNodes(context.Background(), &stubFacts{}); err == nil {
t.Fatal("expected an error for an empty estate")
}
}
func TestLoadNodesReportsRecordsWithNoCertname(t *testing.T) {
// A record with no certname cannot be attributed to a host. Dropping it in
// silence hides a host, so it has to be reported.
src := &stubFacts{records: []puppetdb.FactRecord{
{Certname: "a.example.net", Name: "enc_role", Value: "roles::base"},
{Name: "enc_role", Value: "roles::base"},
{Name: "enc_role", Value: "roles::web"},
{Name: "country", Value: "au"},
}}
nodes, warnings, err := LoadNodes(context.Background(), src)
if err != nil {
t.Fatalf("LoadNodes: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("nodes = %#v", nodes)
}
want := []NodeWarning{
{Message: `fact "country": 1 record(s) had no certname and were dropped`, Records: 1},
{Message: `fact "enc_role": 2 record(s) had no certname and were dropped`, Records: 2},
}
if !reflect.DeepEqual(warnings, want) {
t.Errorf("warnings = %v, want %v", warnings, want)
}
r := (&Analyzer{Index: testIndex(t), Nodes: nodes, NodeWarnings: warnings}).
Analyze("base", "branch", []string{"README.md"}, 0)
if !reflect.DeepEqual(r.NodeWarnings, NodeWarningMessages(want)) {
t.Errorf("Result.NodeWarnings = %v, want %v", r.NodeWarnings, NodeWarningMessages(want))
}
if !r.Summary.ResolutionIncomplete {
t.Error("a dropped fact record left the summary looking exact")
}
// Three records in two messages: the summary counts records.
if r.Summary.DroppedFactRecords != 3 {
t.Errorf("Summary.DroppedFactRecords = %d, want 3", r.Summary.DroppedFactRecords)
}
}
func TestLoadNodesReturnsWarningsWithItsError(t *testing.T) {
// Every record is unattributable, so the table is empty and the load
// fails. The warnings are the only explanation and must survive the error.
src := &stubFacts{records: []puppetdb.FactRecord{
{Name: "enc_role", Value: "roles::base"},
{Name: "enc_role", Value: "roles::web"},
}}
nodes, warnings, err := LoadNodes(context.Background(), src)
if err == nil {
t.Fatal("expected an error for an estate table with no attributable records")
}
if len(nodes) != 0 {
t.Fatalf("nodes = %#v", nodes)
}
if got := DroppedRecords(warnings); got != 2 {
t.Errorf("warnings account for %d dropped record(s), want 2", got)
}
}
func TestAnalyzeReportsFilesThatReachNoHost(t *testing.T) {
// A hieradata file for a role nothing is assigned to maps cleanly and
// still selects nobody; so does an orphan class.
r := analyze(t, "hieradata/roles/infra/nosuch.yaml", "modules/orphanmod/manifests/init.pp", ".woodpecker/test.yaml")
want := []string{"hieradata/roles/infra/nosuch.yaml", "modules/orphanmod/manifests/init.pp"}
if !reflect.DeepEqual(r.EmptyFiles, want) {
t.Errorf("EmptyFiles = %v, want %v", r.EmptyFiles, want)
}
if len(r.Hosts) != 0 {
t.Errorf("hosts = %v", r.Certnames())
}
}
func TestAnalyzeDoesNotCallAHittingFileEmpty(t *testing.T) {
r := analyze(t, "hieradata/roles/apps/media/jellyfin.yaml", "hieradata/roles/infra/nosuch.yaml")
if got, want := r.EmptyFiles, []string{"hieradata/roles/infra/nosuch.yaml"}; !reflect.DeepEqual(got, want) {
t.Errorf("EmptyFiles = %v, want %v", got, want)
}
}
func TestRenderExplainListsFilesThatReachNoHost(t *testing.T) {
var buf bytes.Buffer
if err := RenderExplain(&buf, analyze(t, "hieradata/roles/infra/nosuch.yaml")); err != nil {
t.Fatalf("RenderExplain: %v", err)
}
if !strings.Contains(buf.String(), "reach no host") ||
!strings.Contains(buf.String(), "hieradata/roles/infra/nosuch.yaml") {
t.Errorf("explain output = %q", buf.String())
}
}