Files
puppet-catalog-tools/internal/impact/resolve.go
T
unkin-agent 8093933500
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
add the catalog-impact binary
Work out which hosts a puppet-prod change can affect, so a catalog test can
fan out over exactly those hosts instead of the whole estate.

- add cmd/catalog-impact and internal/impact
- map hieradata paths onto fact selectors from the hiera hierarchy
- trace manifest classes back to role classes through include, contain,
  class {} and hiera_include, to a fixed point
- fetch the estate fact table from pdbmux in one query via FactsByName
- widen to all hosts, loudly, for any path that matches no rule
- ship the binary and its completions in the RPM
2026-09-12 16:15:00 +10:00

140 lines
3.8 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
}
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
queue = append(queue, parent)
}
}
res.orphan = !reached
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
}