04ec9e6d41
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.
216 lines
6.5 KiB
Go
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
|
|
}
|