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 ::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//[.].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 }