8093933500
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
140 lines
3.8 KiB
Go
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
|
|
}
|