Files
puppet-catalog-tools/internal/impact/node.go
T
unkin-agent 8093933500
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
add the catalog-impact binary
Work out which hosts a puppet-prod change can affect, so a catalog test can
fan out over exactly those hosts instead of the whole estate.

- add cmd/catalog-impact and internal/impact
- map hieradata paths onto fact selectors from the hiera hierarchy
- trace manifest classes back to role classes through include, contain,
  class {} and hiera_include, to a fixed point
- fetch the estate fact table from pdbmux in one query via FactsByName
- widen to all hosts, loudly, for any path that matches no rule
- ship the binary and its completions in the RPM
2026-09-12 16:15:00 +10:00

133 lines
2.8 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/pdbmux"
)
// 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) ([]pdbmux.FactRecord, error)
}
// 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.
func LoadNodes(ctx context.Context, src FactSource) ([]Node, error) {
records, err := src.FactsByName(ctx, FactNames)
if err != nil {
return nil, err
}
byName := map[string]*Node{}
for _, r := range records {
if r.Certname == "" {
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, fmt.Errorf("pdbmux returned no nodes")
}
return nodes, nil
}
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))
}
}