Files
unkin-agent c4d3ed24a9
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
report unresolved class references instead of resolving to silence
- walk the classes holding an unresolved reference when tracing any class
  it could have named, so a dropped edge cannot lose hosts
- list unresolved references in --explain, in unresolved_references in
  --json and on stderr, and flag summary.resolution_incomplete
- name an ordering reference as such in the --explain chain
2026-09-12 17:19:02 +10:00

167 lines
5.8 KiB
Go

package impact
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// tempRepo writes a throwaway control repo from a path -> content map.
func tempRepo(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
for rel, content := range files {
full := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
return root
}
func buildIndex(t *testing.T, files map[string]string) *Index {
t.Helper()
idx, err := BuildIndex(tempRepo(t, files))
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
return idx
}
func TestIndexRecordsUnresolvedReferenceAgainstItsFile(t *testing.T) {
idx := testIndex(t)
got := idx.UnresolvedIn("site/profiles/manifests/dynamic.pp")
want := []UnresolvedRef{{
File: "site/profiles/manifests/dynamic.pp",
Form: "include",
Expr: `"profiles::dyn_${facts['role']}"`,
Prefix: "profiles::dyn_",
}}
if !reflect.DeepEqual(got, want) {
t.Errorf("UnresolvedIn = %#v, want %#v", got, want)
}
}
func TestResolveWidensThroughAnUnresolvedPrefix(t *testing.T) {
// profiles::dynamic includes "profiles::dyn_${...}". Any class under that
// prefix must reach whatever profiles::dynamic reaches.
if got, want := resolvedHosts(t, "profiles::dyn_web"), []string{"k8s1", "k8s2", "web1"}; !reflect.DeepEqual(got, want) {
t.Errorf("profiles::dyn_web = %v, want %v", got, want)
}
// A class outside the prefix is not dragged in by it.
res := newClassResolver(testIndex(t), fixtureNodes()).resolve("profiles::unrelated_thing")
if !res.orphan {
t.Errorf("an unrelated class was widened to %v", res.hosts)
}
}
func TestResolveWidensThroughAnUnresolvedVariable(t *testing.T) {
// With no literal prefix to match on, every class is a candidate, so the
// holding class's hosts are included rather than dropped.
idx := buildIndex(t, map[string]string{
"site/roles/manifests/base.pp": "class roles::base {\n include profiles::base\n}\n",
"site/profiles/manifests/base.pp": "class profiles::base {\n include profiles::wild\n}\n",
"site/profiles/manifests/wild.pp": "class profiles::wild {\n $c = lookup('profile')\n include $c\n}\n",
"site/profiles/manifests/standalone.pp": "class profiles::standalone {\n}\n",
})
res := newClassResolver(idx, fixtureNodes()).resolve("profiles::standalone")
if got, want := uniqueSorted(hostNames(res)), []string{"bare1"}; !reflect.DeepEqual(got, want) {
t.Errorf("hosts = %v, want %v", got, want)
}
if len(res.unresolved) != 1 || res.unresolved[0].File != "site/profiles/manifests/wild.pp" {
t.Errorf("resolution did not report the unresolved reference: %#v", res.unresolved)
}
}
func hostNames(res *resolution) []string {
out := make([]string, 0, len(res.hosts))
for certname := range res.hosts {
out = append(out, certname)
}
return out
}
func TestAnalyzeReportsUnresolvedReferencesInAChangedFile(t *testing.T) {
r := analyze(t, "site/profiles/manifests/dynamic.pp")
if !r.Summary.ResolutionIncomplete {
t.Error("summary did not flag the incomplete resolution")
}
if len(r.UnresolvedRefs) != 1 || r.UnresolvedRefs[0].Prefix != "profiles::dyn_" {
t.Fatalf("UnresolvedRefs = %#v", r.UnresolvedRefs)
}
var explain bytes.Buffer
if err := RenderExplain(&explain, r); err != nil {
t.Fatalf("RenderExplain: %v", err)
}
for _, want := range []string{"resolution incomplete", "unresolved class references",
"site/profiles/manifests/dynamic.pp", "profiles::dyn_"} {
if !strings.Contains(explain.String(), want) {
t.Errorf("explain output missing %q:\n%s", want, explain.String())
}
}
var out bytes.Buffer
if err := RenderJSON(&out, r); err != nil {
t.Fatalf("RenderJSON: %v", err)
}
var doc struct {
UnresolvedRefs []UnresolvedRef `json:"unresolved_references"`
Summary struct {
ResolutionIncomplete bool `json:"resolution_incomplete"`
} `json:"summary"`
}
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !doc.Summary.ResolutionIncomplete || len(doc.UnresolvedRefs) != 1 {
t.Errorf("json = %s", out.String())
}
}
func TestAnalyzeReportsNoUnresolvedReferencesForACleanFile(t *testing.T) {
r := analyze(t, "site/profiles/manifests/leaf.pp")
if r.Summary.ResolutionIncomplete || len(r.UnresolvedRefs) != 0 {
t.Errorf("clean file reported %#v", r.UnresolvedRefs)
}
}
func TestExplainCallsAnOrderingReferenceWhatItIs(t *testing.T) {
idx := buildIndex(t, map[string]string{
"site/roles/manifests/base.pp": "class roles::base {\n include profiles::base\n}\n",
"site/profiles/manifests/base.pp": "class profiles::base {\n service { 's': require => Class['profiles::ordered'] }\n}\n",
"site/profiles/manifests/ordered.pp": "class profiles::ordered {\n}\n",
})
res := newClassResolver(idx, fixtureNodes()).resolve("profiles::ordered")
hits := res.hosts["bare1"]
if len(hits) == 0 {
t.Fatalf("profiles::ordered reached no host: %#v", res.hosts)
}
if !strings.Contains(hits[0].detail, "not a declaration") {
t.Errorf("detail = %q, want it to mark the ordering reference", hits[0].detail)
}
}
func TestExplainLeavesADeclarationUnannotated(t *testing.T) {
idx := buildIndex(t, map[string]string{
"site/roles/manifests/base.pp": "class roles::base {\n include profiles::base\n}\n",
"site/profiles/manifests/base.pp": "class profiles::base {\n include profiles::ordered\n}\n",
"site/profiles/manifests/ordered.pp": "class profiles::ordered {\n}\n",
})
res := newClassResolver(idx, fixtureNodes()).resolve("profiles::ordered")
hits := res.hosts["bare1"]
if len(hits) == 0 {
t.Fatalf("profiles::ordered reached no host: %#v", res.hosts)
}
if strings.Contains(hits[0].detail, "not a declaration") {
t.Errorf("detail = %q, an include is a declaration", hits[0].detail)
}
}