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.
182 lines
4.4 KiB
Go
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))
|
|
}
|
|
}
|