Files
puppet-catalog-tools/internal/impact/impact_test.go
T
unkin-agent 9ff9edefa8
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
report changed files that reach no host
A hieradata file for a role with no members produces an empty host list, which
is indistinguishable from a mapping failure.

- list files that mapped cleanly but selected nobody in --explain
2026-09-12 16:26:29 +10:00

288 lines
9.7 KiB
Go

package impact
import (
"bytes"
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"git.unkin.net/unkin/puppet-catalog-tools/internal/pdbmux"
)
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 pdbmux would.
type stubFacts struct {
records []pdbmux.FactRecord
asked []string
}
func (s *stubFacts) FactsByName(_ context.Context, names []string) ([]pdbmux.FactRecord, error) {
s.asked = names
return s.records, nil
}
func TestLoadNodesFoldsStructuredOS(t *testing.T) {
src := &stubFacts{records: []pdbmux.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, err := LoadNodes(context.Background(), src)
if err != nil {
t.Fatalf("LoadNodes: %v", err)
}
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 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())
}
}