// 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)) } }