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

182 lines
4.4 KiB
Go

// Package impact maps a change in the Puppet control repo onto the hosts whose
// catalogs that change can alter.
package impact
import (
"context"
"fmt"
"sort"
"strings"
"git.unkin.net/unkin/puppet-catalog-tools/internal/puppetdb"
)
// FactNames are the facts every selector is evaluated against. They are
// fetched for the whole estate in one query.
var FactNames = []string{
"enc_role",
"enc_role_tier1",
"enc_role_tier2",
"enc_role_tier3",
"country",
"region",
"virtual",
"os",
}
// Node is the slice of a node's facts the hiera hierarchy keys off.
type Node struct {
Certname string
Role string
Tier1 string
Tier2 string
Tier3 string
Country string
Region string
Virtual string
OSName string
OSMajor string
OSMinor string
}
// Tiers returns the role tiers as a slice, truncated at the first empty one.
func (n Node) Tiers() []string {
tiers := make([]string, 0, 3)
for _, t := range []string{n.Tier1, n.Tier2, n.Tier3} {
if t == "" {
break
}
tiers = append(tiers, t)
}
return tiers
}
// FactSource supplies the estate-wide fact table.
type FactSource interface {
FactsByName(ctx context.Context, names []string) ([]puppetdb.FactRecord, error)
}
// NodeWarning is one report of fact records that never reached the estate
// table, with the number of records it accounts for. One warning can stand for
// many records, so the count travels with the message rather than being
// inferred from it.
type NodeWarning struct {
Message string
Records int
}
// NodeWarningMessages renders warnings one line each.
func NodeWarningMessages(warnings []NodeWarning) []string {
out := make([]string, 0, len(warnings))
for _, w := range warnings {
out = append(out, w.Message)
}
return out
}
// DroppedRecords totals the records the warnings account for.
func DroppedRecords(warnings []NodeWarning) int {
total := 0
for _, w := range warnings {
total += w.Records
}
return total
}
// LoadNodes fetches FactNames for every node and folds them into a sorted
// table. Nodes without an enc_role are kept: they still match country, os and
// virtual selectors. A record with no certname belongs to no host and cannot
// be kept, so it is returned as a warning rather than dropped in silence.
// Warnings are returned alongside a failure too: they are usually what
// explains it.
func LoadNodes(ctx context.Context, src FactSource) ([]Node, []NodeWarning, error) {
records, err := src.FactsByName(ctx, FactNames)
if err != nil {
return nil, nil, err
}
byName := map[string]*Node{}
anonymous := map[string]int{}
for _, r := range records {
if r.Certname == "" {
anonymous[r.Name]++
continue
}
n, ok := byName[r.Certname]
if !ok {
n = &Node{Certname: r.Certname}
byName[r.Certname] = n
}
applyFact(n, r.Name, r.Value)
}
nodes := make([]Node, 0, len(byName))
for _, n := range byName {
nodes = append(nodes, *n)
}
sort.Slice(nodes, func(i, j int) bool { return nodes[i].Certname < nodes[j].Certname })
if len(nodes) == 0 {
return nil, anonymousWarnings(anonymous), fmt.Errorf("puppetdb returned no nodes")
}
return nodes, anonymousWarnings(anonymous), nil
}
// anonymousWarnings renders the dropped-record tally one line per fact.
func anonymousWarnings(counts map[string]int) []NodeWarning {
names := make([]string, 0, len(counts))
for name := range counts {
names = append(names, name)
}
sort.Strings(names)
out := make([]NodeWarning, 0, len(names))
for _, name := range names {
out = append(out, NodeWarning{
Message: fmt.Sprintf("fact %q: %d record(s) had no certname and were dropped", name, counts[name]),
Records: counts[name],
})
}
return out
}
func applyFact(n *Node, name string, value any) {
switch name {
case "enc_role":
n.Role = str(value)
case "enc_role_tier1":
n.Tier1 = str(value)
case "enc_role_tier2":
n.Tier2 = str(value)
case "enc_role_tier3":
n.Tier3 = str(value)
case "country":
n.Country = str(value)
case "region":
n.Region = str(value)
case "virtual":
n.Virtual = str(value)
case "os":
os, ok := value.(map[string]any)
if !ok {
return
}
n.OSName = str(os["name"])
release, ok := os["release"].(map[string]any)
if !ok {
return
}
n.OSMajor = str(release["major"])
n.OSMinor = str(release["minor"])
}
}
func str(v any) string {
switch t := v.(type) {
case string:
return t
case nil:
return ""
default:
return strings.TrimSpace(fmt.Sprint(t))
}
}