Files
puppet-catalog-tools/cmd/catalog-impact/main.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

153 lines
4.5 KiB
Go

// Command catalog-impact maps a change in the Puppet control repo onto the
// hosts whose catalogs it can alter.
package main
import (
"context"
"fmt"
"io"
"os"
"time"
"github.com/spf13/cobra"
"git.unkin.net/unkin/puppet-catalog-tools/internal/config"
"git.unkin.net/unkin/puppet-catalog-tools/internal/impact"
"git.unkin.net/unkin/puppet-catalog-tools/internal/pdbmux"
)
var version = "dev"
// Exit codes, also documented in --help and the README.
const (
exitOK = 0 // analysis completed
exitUsage = 1 // transport, git or usage error
)
const longHelp = `Work out which hosts a change to the Puppet control repo can affect, and print
their hostnames one per line.
Changed files are mapped onto hosts two ways. A hieradata path is read as its
position in the hiera hierarchy and turned into a fact selector. A manifest,
template or file is turned into the classes it defines, and those classes are
traced backwards — through include, contain and class {} in manifests, and
through hiera_include in hieradata — until only role classes and hierarchy
positions remain.
The bias is to over-include: an unrecognised path widens to the whole estate
rather than silently returning nothing.`
type options struct {
repo string
branch string
base string
files []string
pdb string
jsonOut bool
explain bool
limit int
timeout time.Duration
}
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
os.Exit(exitUsage)
}
opts := &options{}
root := &cobra.Command{
Use: "catalog-impact",
Short: "List the hosts a Puppet control repo change affects",
Long: longHelp,
Args: cobra.NoArgs,
Version: version,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return run(cmd.Context(), cmd.OutOrStdout(), opts)
},
}
f := root.Flags()
f.StringVarP(&opts.repo, "repo", "r", ".", "path to a puppet-prod checkout")
f.StringVarP(&opts.branch, "branch", "b", "", "branch under test (default: the repo's current branch)")
f.StringVar(&opts.base, "base", "origin/develop", "base ref to diff against")
f.StringSliceVar(&opts.files, "files", nil, "explicit changed-file list, bypassing git")
f.StringVar(&opts.pdb, "pdb", cfg.PDBURL, "pdbmux base URL")
f.BoolVar(&opts.jsonOut, "json", false, "emit structured output with per-host reasons")
f.BoolVar(&opts.explain, "explain", false, "human-readable: for each host, which changed files hit it and why")
f.IntVar(&opts.limit, "limit", 0, "cap the number of hosts emitted (0 = no cap)")
f.DurationVar(&opts.timeout, "timeout", time.Minute, "pdbmux request timeout")
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(exitUsage)
}
os.Exit(exitOK)
}
func run(ctx context.Context, out io.Writer, opts *options) error {
if opts.jsonOut && opts.explain {
return fmt.Errorf("--json and --explain are mutually exclusive")
}
if opts.limit < 0 {
return fmt.Errorf("--limit must not be negative")
}
branch := opts.branch
files := opts.files
if len(files) == 0 {
var err error
if branch == "" {
if branch, err = impact.CurrentBranch(opts.repo); err != nil {
return err
}
}
if files, err = impact.ChangedFiles(opts.repo, opts.base, branch); err != nil {
return err
}
}
if branch == "" {
branch = "(explicit file list)"
}
idx, err := impact.BuildIndex(opts.repo)
if err != nil {
return fmt.Errorf("indexing %s: %w", opts.repo, err)
}
nodes, err := impact.LoadNodes(ctx, pdbmux.New(opts.pdb, opts.timeout))
if err != nil {
return err
}
analyzer := &impact.Analyzer{Index: idx, Nodes: nodes}
result := analyzer.Analyze(opts.base, branch, files, opts.limit)
switch {
case opts.jsonOut:
return impact.RenderJSON(out, result)
case opts.explain:
return impact.RenderExplain(out, result)
}
warn(result)
return impact.RenderPlain(out, result)
}
// warn surfaces the widenings and dead ends on stderr so a piped host list
// stays clean.
func warn(r *impact.Result) {
for _, file := range r.UnmappedFiles {
fmt.Fprintf(os.Stderr, "warning: %s matches no mapping rule; every host is included\n", file)
}
for _, class := range r.OrphanClasses {
fmt.Fprintf(os.Stderr, "warning: class %s is declared by nothing and listed in no hiera_include; it reaches no hosts\n", class)
}
if r.Truncated {
fmt.Fprintf(os.Stderr, "warning: %d hosts affected, output capped at %d by --limit\n", r.Summary.Hosts, len(r.Hosts))
}
}