8093933500
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
250 lines
7.9 KiB
Go
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
|
|
}
|