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.
350 lines
12 KiB
Go
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())
|
|
}
|
|
}
|