Files
unkin-agent 04ec9e6d41
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
carry dropped fact records through the failure path and count them
Report the accumulated warnings on stderr when the estate load fails, and fold
the dropped-record tally into the error. Count records rather than warning
messages in --explain, by carrying the count on the Warn hook.
2026-09-12 20:27:55 +10:00

216 lines
6.5 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"`
// ResolutionIncomplete is set when a class reference could not be resolved
// statically, or a fact record could not be attributed to a host, so the
// answer is a best effort rather than exact.
ResolutionIncomplete bool `json:"resolution_incomplete"`
// DroppedFactRecords counts the fact records that never reached the estate
// table, which is not the number of warnings: one warning can cover many.
DroppedFactRecords int `json:"dropped_fact_records"`
}
// 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"`
// UnresolvedRefs are class references the parser could not reduce to
// names, either in a changed file or on the path from one to a host.
UnresolvedRefs []UnresolvedRef `json:"unresolved_references"`
// NodeWarnings are fact records that could not be folded into the estate
// table, so a host may be missing from it entirely.
NodeWarnings []string `json:"node_warnings"`
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
// NodeWarnings carries whatever LoadNodes could not fold into Nodes.
NodeWarnings []NodeWarning
}
// 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{}
var unresolved []UnresolvedRef
// 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),
NodeWarnings: NodeWarningMessages(a.NodeWarnings),
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
}
}
unresolved = append(unresolved, a.Index.UnresolvedIn(file)...)
for _, ref := range m.classes {
res := resolver.resolve(ref.name)
unresolved = append(unresolved, res.unresolved...)
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)
result.UnresolvedRefs = sortUnresolved(dedupeUnresolved(unresolved))
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),
ResolutionIncomplete: len(result.UnresolvedRefs) > 0 || len(result.NodeWarnings) > 0,
DroppedFactRecords: DroppedRecords(a.NodeWarnings),
}
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
}
func sortUnresolved(in []UnresolvedRef) []UnresolvedRef {
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].Form != in[j].Form {
return in[i].Form < in[j].Form
}
return in[i].Expr < in[j].Expr
})
return in
}