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

250 lines
7.9 KiB
Go

package impact
import (
"path"
"strings"
)
// Rules, as they appear in --json output and --explain.
const (
RuleCommon = "hieradata/common"
RuleNode = "hieradata/node"
RuleRole = "hieradata/role"
RuleCountry = "hieradata/country"
RuleCountryRegion = "hieradata/country-region"
RuleCountryRegRole = "hieradata/country-region-role"
RuleCountryRole = "hieradata/country-role"
RuleVirtual = "hieradata/virtual"
RuleOS = "hieradata/os"
RuleRepoGlobal = "repo-global"
RulePluginsync = "pluginsync"
RuleRoleClass = "role-class"
RuleHieraInclude = "hiera_include"
RuleUnmapped = "unmapped"
RuleClassReverse = "class-reverse-lookup"
)
// repoGlobalFiles change classification or module resolution for the whole
// estate. Puppetfile pins external modules whose code is not in this repo, so
// it is deliberately mapped to every host rather than guessed at.
var repoGlobalFiles = map[string]bool{
"hiera.yaml": true,
"environment.conf": true,
"Puppetfile": true,
"Puppetfile.lock": true,
}
// ignoredPrefixes hold paths outside the Puppet modulepath: CI, docs, specs
// and the bundler vendor tree. Nothing under them can reach a catalog.
var ignoredPrefixes = []string{
".woodpecker/", ".github/", ".claude/", "ci/", "doc/", "docs/", "spec/", "vendor/", "examples/",
}
var ignoredFiles = map[string]bool{
"Gemfile": true, "Gemfile.lock": true, "Rakefile": true,
".rubocop.yml": true, ".reek.yml": true, ".pre-commit-config.yaml": true,
".gitignore": true, ".fixtures.yml": true, ".ruby-version": true,
"catalog_diff.py": true, "deploy.py": true,
}
// moduleSubdirIgnored are module subtrees that are not consulted during a
// catalog compile.
var moduleSubdirIgnored = map[string]bool{"spec": true, "examples": true, "doc": true, "docs": true}
// classRef is a class a changed file defines, awaiting reverse lookup.
type classRef struct {
name string
why string
}
// mapping is what a single changed path resolves to before hosts are selected.
type mapping struct {
targets []target
classes []classRef
ignored bool
unknown bool
}
// classifyPath maps one repo-relative path onto selectors or classes. idx may
// be nil, in which case asset files fall back to their whole module.
func classifyPath(p string, idx *Index) mapping {
p = path.Clean(strings.TrimPrefix(p, "./"))
if repoGlobalFiles[p] {
return mapping{targets: []target{allTarget(RuleRepoGlobal,
fmtDetail("%s is repo-global; every catalog reads it", p))}}
}
if strings.HasPrefix(p, "manifests/") {
return mapping{targets: []target{allTarget(RuleRepoGlobal,
"the environment manifest applies to every node")}}
}
if ignoredFiles[p] || strings.HasSuffix(p, ".md") {
return mapping{ignored: true}
}
for _, prefix := range ignoredPrefixes {
if strings.HasPrefix(p, prefix) {
return mapping{ignored: true}
}
}
if rest, ok := strings.CutPrefix(p, "hieradata/"); ok {
sel, rule, ok := hieradataSelector(rest)
if !ok {
return mapping{unknown: true}
}
return mapping{targets: []target{{sel: sel, rule: rule,
detail: fmtDetail("hieradata file selects %s", sel)}}}
}
if strings.HasPrefix(p, "site/") || strings.HasPrefix(p, "modules/") {
return classifyModulePath(p, idx)
}
return mapping{unknown: true}
}
// classifyModulePath handles everything under the modulepath roots: site/ (the
// roles and profiles modules) and modules/ (in-repo modules).
func classifyModulePath(p string, idx *Index) mapping {
parts := strings.Split(p, "/")
if len(parts) < 3 {
return mapping{unknown: true}
}
module, subdir := parts[1], parts[2]
rest := parts[3:]
if moduleSubdirIgnored[subdir] {
return mapping{ignored: true}
}
if subdir == "manifests" && len(rest) > 0 && strings.HasSuffix(rest[len(rest)-1], ".pp") {
var classes []classRef
for _, name := range ManifestClasses(module, rest) {
classes = append(classes, classRef{name: name, why: fmtDetail("%s defines class %s", p, name)})
}
return mapping{classes: classes}
}
// lib/ and facts.d/ are pluginsynced to every agent, so a custom fact,
// function or type there can move any catalog.
if subdir == "lib" || subdir == "facts.d" {
return mapping{targets: []target{allTarget(RulePluginsync,
fmtDetail("%s is pluginsynced to every agent", p))}}
}
if subdir == "templates" || subdir == "files" {
asset := module + "/" + strings.Join(rest, "/")
if idx != nil {
if owners := idx.AssetOwners(asset); len(owners) > 0 {
var classes []classRef
for _, name := range owners {
classes = append(classes, classRef{name: name,
why: fmtDetail("%s is referenced by class %s", p, name)})
}
return mapping{classes: classes}
}
}
return moduleWide(p, module, idx, "no manifest references this asset")
}
return moduleWide(p, module, idx, fmtDetail("%s/ is module-wide", subdir))
}
// moduleWide attributes a file to every class its module defines. It is the
// deliberate over-include fallback for module data that cannot be traced to a
// single class.
func moduleWide(p, module string, idx *Index, why string) mapping {
if idx == nil {
return mapping{unknown: true}
}
names := idx.ModuleClasses(module)
if len(names) == 0 {
return mapping{unknown: true}
}
var classes []classRef
for _, name := range names {
classes = append(classes, classRef{name: name,
why: fmtDetail("%s: %s, attributed to class %s", p, why, name)})
}
return mapping{classes: classes}
}
// ManifestClasses returns the class names a manifest file autoloads as. An
// init.pp is reachable both as the namespace itself and as <namespace>::init,
// and this repo uses both spellings.
func ManifestClasses(module string, rest []string) []string {
segs := append([]string{module}, rest...)
segs[len(segs)-1] = strings.TrimSuffix(segs[len(segs)-1], ".pp")
full := strings.Join(segs, "::")
if segs[len(segs)-1] != "init" {
return []string{full}
}
short := strings.Join(segs[:len(segs)-1], "::")
return []string{short, full}
}
// hieradataSelector maps a path under hieradata/ onto the facts the hiera
// hierarchy interpolates into it.
func hieradataSelector(rel string) (Selector, string, bool) {
ext := path.Ext(rel)
if ext != ".yaml" && ext != ".eyaml" {
return Selector{}, "", false
}
parts := strings.Split(strings.TrimSuffix(rel, ext), "/")
switch {
case len(parts) == 1 && parts[0] == "common":
return Selector{All: true}, RuleCommon, true
case parts[0] == "nodes" && len(parts) == 2:
return Selector{Certname: parts[1]}, RuleNode, true
case parts[0] == "virtual" && len(parts) == 2:
return Selector{Virtual: parts[1]}, RuleVirtual, true
case parts[0] == "os" && len(parts) == 3:
return osSelector(parts[1], parts[2]), RuleOS, true
case parts[0] == "roles" && len(parts) >= 2 && len(parts) <= 4:
return Selector{RoleTiers: parts[1:]}, RuleRole, true
case parts[0] == "country" && len(parts) == 2:
return Selector{Country: parts[1]}, RuleCountry, true
case parts[0] == "country" && len(parts) == 4 && parts[2] == "region":
return Selector{Country: parts[1], Region: parts[3]}, RuleCountryRegion, true
case parts[0] == "country" && len(parts) >= 5 && len(parts) <= 7 && parts[2] == "region":
return Selector{Country: parts[1], Region: parts[3], RoleTiers: parts[4:]}, RuleCountryRegRole, true
case parts[0] == "country" && len(parts) >= 4 && len(parts) <= 6 && parts[2] == "roles":
return Selector{Country: parts[1], RoleTiers: parts[3:]}, RuleCountryRole, true
}
return Selector{}, "", false
}
// osSelector reads os/<name>/<name><major>[.<minor>].yaml and its
// all_releases.yaml sibling.
func osSelector(osName, file string) Selector {
sel := Selector{OSName: osName}
if file == "all_releases" {
return sel
}
version := file
if len(file) >= len(osName) && strings.EqualFold(file[:len(osName)], osName) {
version = file[len(osName):]
}
version = strings.TrimLeft(version, "-_")
if version == "" {
return sel
}
major, minor, hasMinor := strings.Cut(version, ".")
sel.OSMajor = major
if hasMinor {
sel.OSMinor = minor
}
return sel
}