Files
puppet-catalog-tools/internal/impact/impact.go
T
unkin-agent 9ff9edefa8
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
report changed files that reach no host
A hieradata file for a role with no members produces an empty host list, which
is indistinguishable from a mapping failure.

- list files that mapped cleanly but selected nobody in --explain
2026-09-12 16:26:29 +10:00

179 lines
4.9 KiB
Go

package impact
import (
"slices"
"sort"
)
// Reason explains why one changed file put one host in the result.
type Reason struct {
File string `json:"file"`
Rule string `json:"rule"`
Detail string `json:"detail"`
}
// Host is an affected node and every reason it is affected.
type Host struct {
Certname string `json:"certname"`
Reasons []Reason `json:"reasons"`
}
// Summary is the headline count.
type Summary struct {
Hosts int `json:"hosts"`
Files int `json:"files"`
AllHostsTriggered bool `json:"all_hosts_triggered"`
}
// Result is the whole analysis, and the --json document.
type Result struct {
Base string `json:"base"`
Branch string `json:"branch"`
ChangedFiles []string `json:"changed_files"`
Hosts []Host `json:"hosts"`
UnmappedFiles []string `json:"unmapped_files"`
Summary Summary `json:"summary"`
// IgnoredFiles are changed files outside the modulepath — CI, docs,
// specs — which cannot reach a catalog.
IgnoredFiles []string `json:"-"`
// OrphanClasses are classes a changed file defines that nothing declares
// and no hieradata file pulls in.
OrphanClasses []string `json:"-"`
// EmptyFiles are changed files that mapped cleanly but reached no host,
// which usually means the role they belong to has no members.
EmptyFiles []string `json:"-"`
// Truncated is set when --limit cut the host list.
Truncated bool `json:"-"`
// TotalHosts is the estate size the selectors were evaluated against.
TotalHosts int `json:"-"`
}
// Certnames returns the affected hostnames, sorted.
func (r *Result) Certnames() []string {
out := make([]string, 0, len(r.Hosts))
for _, h := range r.Hosts {
out = append(out, h.Certname)
}
return out
}
// Analyzer evaluates changed files against one estate fact table.
type Analyzer struct {
Index *Index
Nodes []Node
}
// Analyze maps changed files onto hosts. Files are processed in the order
// given; everything in the result is sorted, so output does not move between
// runs.
func (a *Analyzer) Analyze(base, branch string, files []string, limit int) *Result {
resolver := newClassResolver(a.Index, a.Nodes)
reasons := map[string][]Reason{}
orphans := map[string]bool{}
// hitFiles records which changed files put at least one host in the result.
hitFiles := map[string]bool{}
result := &Result{
Base: base,
Branch: branch,
ChangedFiles: uniqueSorted(files),
TotalHosts: len(a.Nodes),
}
for _, file := range result.ChangedFiles {
m := classifyPath(file, a.Index)
switch {
case m.ignored:
result.IgnoredFiles = append(result.IgnoredFiles, file)
continue
case m.unknown:
result.UnmappedFiles = append(result.UnmappedFiles, file)
m.targets = []target{allTarget(RuleUnmapped,
fmtDetail("%s matches no mapping rule; widened to the whole estate", file))}
}
for _, t := range m.targets {
for _, certname := range t.sel.Select(a.Nodes) {
reasons[certname] = append(reasons[certname],
Reason{File: file, Rule: t.rule, Detail: t.detail})
hitFiles[file] = true
}
}
for _, ref := range m.classes {
res := resolver.resolve(ref.name)
if res.orphan {
orphans[ref.name] = true
continue
}
for certname, hits := range res.hosts {
for _, h := range hits {
reasons[certname] = append(reasons[certname],
Reason{File: file, Rule: h.rule, Detail: ref.why + "; " + h.detail})
hitFiles[file] = true
}
}
}
}
// A file that mapped cleanly but reached nobody usually belongs to a role
// with no members. That is a real answer, not a failure, but it is worth
// showing rather than leaving as silence.
for _, file := range result.ChangedFiles {
if !hitFiles[file] && !slices.Contains(result.IgnoredFiles, file) {
result.EmptyFiles = append(result.EmptyFiles, file)
}
}
for name := range orphans {
result.OrphanClasses = append(result.OrphanClasses, name)
}
sort.Strings(result.OrphanClasses)
certnames := make([]string, 0, len(reasons))
for certname := range reasons {
certnames = append(certnames, certname)
}
sort.Strings(certnames)
result.Summary = Summary{
Hosts: len(certnames),
Files: len(result.ChangedFiles),
AllHostsTriggered: len(certnames) > 0 && len(certnames) == len(a.Nodes),
}
if limit > 0 && len(certnames) > limit {
certnames = certnames[:limit]
result.Truncated = true
}
for _, certname := range certnames {
result.Hosts = append(result.Hosts, Host{Certname: certname, Reasons: dedupeReasons(reasons[certname])})
}
return result
}
func dedupeReasons(in []Reason) []Reason {
sort.Slice(in, func(i, j int) bool {
if in[i].File != in[j].File {
return in[i].File < in[j].File
}
if in[i].Rule != in[j].Rule {
return in[i].Rule < in[j].Rule
}
return in[i].Detail < in[j].Detail
})
out := in[:0]
var prev Reason
for idx, r := range in {
if idx > 0 && r == prev {
continue
}
prev = r
out = append(out, r)
}
return out
}