Files
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

124 lines
2.9 KiB
Go

package impact
import (
"fmt"
"strings"
)
// Selector is a conjunction of fact constraints. Every field left at its zero
// value is unconstrained, so an empty Selector with All unset matches nothing
// and is treated as a programming error by the callers that build them.
type Selector struct {
// All matches the whole estate (hieradata/common.yaml, repo-global files).
All bool
// Certname pins one host (hieradata/nodes/<certname>.yaml).
Certname string
// RoleTiers is a prefix match on enc_role_tier1..3, so a two-tier
// hieradata file covers every role nested under it.
RoleTiers []string
// RoleExact matches enc_role verbatim, which is how a role class maps to
// hosts.
RoleExact string
Country string
Region string
Virtual string
OSName string
OSMajor string
OSMinor string
}
// Match reports whether the node satisfies every constraint set on s.
func (s Selector) Match(n Node) bool {
if s.All {
return true
}
if s.Certname != "" && s.Certname != n.Certname {
return false
}
if s.RoleExact != "" && s.RoleExact != n.Role {
return false
}
if s.Country != "" && s.Country != n.Country {
return false
}
if s.Region != "" && s.Region != n.Region {
return false
}
if s.Virtual != "" && s.Virtual != n.Virtual {
return false
}
if s.OSName != "" && !strings.EqualFold(s.OSName, n.OSName) {
return false
}
if s.OSMajor != "" && s.OSMajor != n.OSMajor {
return false
}
if s.OSMinor != "" && s.OSMinor != n.OSMinor {
return false
}
if len(s.RoleTiers) > 0 {
tiers := n.Tiers()
if len(tiers) < len(s.RoleTiers) {
return false
}
for i, want := range s.RoleTiers {
if tiers[i] != want {
return false
}
}
}
return true
}
// Select returns the certnames of every matching node, sorted.
func (s Selector) Select(nodes []Node) []string {
var out []string
for _, n := range nodes {
if s.Match(n) {
out = append(out, n.Certname)
}
}
return out
}
// String renders the selector the way --explain prints it.
func (s Selector) String() string {
if s.All {
return "all hosts"
}
var parts []string
add := func(k, v string) {
if v != "" {
parts = append(parts, k+"="+v)
}
}
add("certname", s.Certname)
add("enc_role", s.RoleExact)
if len(s.RoleTiers) > 0 {
parts = append(parts, "role-prefix="+strings.Join(s.RoleTiers, "::"))
}
add("country", s.Country)
add("region", s.Region)
add("virtual", s.Virtual)
add("os.name", s.OSName)
add("os.release.major", s.OSMajor)
add("os.release.minor", s.OSMinor)
if len(parts) == 0 {
return "nothing"
}
return strings.Join(parts, " ")
}
// target is one resolved "this file reaches these hosts, for this reason".
type target struct {
sel Selector
rule string
detail string
}
func allTarget(rule, detail string) target {
return target{sel: Selector{All: true}, rule: rule, detail: detail}
}
func fmtDetail(format string, args ...any) string { return fmt.Sprintf(format, args...) }