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

158 lines
4.5 KiB
Go

package impact
import (
"sort"
"strings"
)
// hit is one reason a host is affected, before it is attached to a file.
type hit struct {
rule string
detail string
}
// classResolver walks the include graph backwards from a changed class to the
// hosts that end up with it.
type classResolver struct {
idx *Index
nodes []Node
cache map[string]*resolution
}
type resolution struct {
hosts map[string][]hit
orphan bool
// unresolved are the references whose incompleteness widened this walk.
unresolved []UnresolvedRef
}
func newClassResolver(idx *Index, nodes []Node) *classResolver {
return &classResolver{idx: idx, nodes: nodes, cache: map[string]*resolution{}}
}
// resolve returns the hosts a class reaches. The walk runs to a fixed point
// over both routes a class can be applied by — a manifest declaring it, and a
// hieradata file listing it under hiera_include — and stops at role classes,
// which the ENC assigns directly.
func (r *classResolver) resolve(class string) *resolution {
if cached, ok := r.cache[class]; ok {
return cached
}
res := &resolution{hosts: map[string][]hit{}}
r.cache[class] = res
// via records how each class was reached, so --explain can print the chain.
via := map[string]string{class: class}
seen := map[string]bool{class: true}
queue := []string{class}
reached := false
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if strings.HasPrefix(current, "roles::") {
sel := Selector{RoleExact: current}
detail := fmtDetail("role %s carries %s (%s)", current, class, via[current])
for _, certname := range sel.Select(r.nodes) {
res.hosts[certname] = append(res.hosts[certname], hit{rule: RuleRoleClass, detail: detail})
}
reached = true
}
if hosts, detail, ok := r.viaHiera(current); ok {
reached = true
for _, certname := range hosts {
res.hosts[certname] = append(res.hosts[certname],
hit{rule: RuleHieraInclude, detail: detail + " (" + via[current] + ")"})
}
}
for _, parent := range r.idx.IncludedBy(current) {
if seen[parent] {
continue
}
seen[parent] = true
via[parent] = via[current] + " <- " + parent + r.idx.EdgeNote(current, parent)
queue = append(queue, parent)
}
// A reference the parser could not name might have been this class, so
// the classes holding it are walked too rather than dropped.
for _, u := range r.idx.UnresolvedIncluders(current) {
for _, parent := range u.Classes {
if seen[parent] {
continue
}
seen[parent] = true
via[parent] = via[current] + " <- " + parent +
fmtDetail(" (unresolved %s %s in %s)", u.Ref.Form, u.Ref.Expr, u.Ref.File)
queue = append(queue, parent)
}
res.unresolved = append(res.unresolved, u.Ref)
}
}
res.orphan = !reached
res.unresolved = dedupeUnresolved(res.unresolved)
return res
}
// viaHiera applies a class that hieradata pulls in with hiera_include. The
// hierarchy position of the listing file decides which hosts get it, and
// hiera_exclude entries are subtracted because profiles::base declares
// ($hiera_include - $hiera_exclude).
func (r *classResolver) viaHiera(class string) ([]string, string, bool) {
includes := r.idx.HieraInclude(class)
if len(includes) == 0 {
return nil, "", false
}
included := map[string]bool{}
var sources []string
for _, file := range includes {
sel := hieraFileSelector(file)
for _, certname := range sel.Select(r.nodes) {
included[certname] = true
}
sources = append(sources, fmtDetail("%s [%s]", file, sel))
}
excludes := r.idx.HieraExclude(class)
var excluded []string
for _, file := range excludes {
sel := hieraFileSelector(file)
for _, certname := range sel.Select(r.nodes) {
if included[certname] {
delete(included, certname)
excluded = append(excluded, certname)
}
}
}
hosts := make([]string, 0, len(included))
for certname := range included {
hosts = append(hosts, certname)
}
sort.Strings(hosts)
detail := fmtDetail("hiera_include %s in %s", class, strings.Join(sources, ", "))
if len(excluded) > 0 {
detail += fmtDetail("; hiera_exclude in %s drops %d host(s)",
strings.Join(excludes, ", "), len(uniqueSorted(excluded)))
}
return hosts, detail, true
}
// hieraFileSelector maps a hieradata file back onto its hierarchy position. An
// unrecognised path widens to the whole estate rather than silently selecting
// nothing.
func hieraFileSelector(file string) Selector {
rel := strings.TrimPrefix(file, "hieradata/")
sel, _, ok := hieradataSelector(rel)
if !ok {
return Selector{All: true}
}
return sel
}