Files
unkin-agent 04ec9e6d41
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
carry dropped fact records through the failure path and count them
Report the accumulated warnings on stderr when the estate load fails, and fold
the dropped-record tally into the error. Count records rather than warning
messages in --explain, by carrying the count on the Warn hook.
2026-09-12 20:27:55 +10:00

185 lines
6.0 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/puppetdb"
)
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,
and a class reference that is not a literal name — a selector, an interpolated
name, a variable — is reported rather than silently dropped.`
type options struct {
repo string
branch string
base string
files []string
puppetdb 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(), cmd.ErrOrStderr(), 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.puppetdb, "puppetdb", cfg.PuppetDBURL, "PuppetDB 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, "PuppetDB 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, errOut 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)
}
// A dropped fact record can take a whole host out of the estate table, so
// it rides the same NodeWarnings channel as an unattributable one, through
// every output path and through a failed load.
facts := puppetdb.New(opts.puppetdb, opts.timeout)
var dropped []impact.NodeWarning
facts.Warn = func(msg string, records int) {
dropped = append(dropped, impact.NodeWarning{Message: msg, Records: records})
}
nodes, nodeWarnings, err := impact.LoadNodes(ctx, facts)
warnings := append(dropped, nodeWarnings...)
if err != nil {
return loadFailed(errOut, warnings, err)
}
analyzer := &impact.Analyzer{Index: idx, Nodes: nodes, NodeWarnings: warnings}
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(errOut, result)
return impact.RenderPlain(out, result)
}
// warn surfaces the widenings and dead ends on stderr so a piped host list
// stays clean.
func warn(w io.Writer, r *impact.Result) {
for _, file := range r.UnmappedFiles {
_, _ = fmt.Fprintf(w, "warning: %s matches no mapping rule; every host is included\n", file)
}
for _, line := range impact.UnresolvedLines(r.UnresolvedRefs) {
_, _ = fmt.Fprintf(w, "warning: %s did not resolve to a class name; the host list may be incomplete\n", line)
}
warnNodes(w, r.NodeWarnings)
for _, class := range r.OrphanClasses {
_, _ = fmt.Fprintf(w, "warning: class %s is declared by nothing and listed in no hiera_include; it reaches no hosts\n", class)
}
if r.Truncated {
_, _ = fmt.Fprintf(w, "warning: %d hosts affected, output capped at %d by --limit\n", r.Summary.Hosts, len(r.Hosts))
}
}
func warnNodes(w io.Writer, lines []string) {
for _, line := range lines {
_, _ = fmt.Fprintf(w, "warning: %s; the estate table may be missing hosts\n", line)
}
}
// loadFailed keeps the warnings that explain a failed load from dying with it:
// no report is rendered once the run stops here, so they go to stderr in every
// output mode and the tally rides on the error.
func loadFailed(w io.Writer, warnings []impact.NodeWarning, err error) error {
warnNodes(w, impact.NodeWarningMessages(warnings))
if n := impact.DroppedRecords(warnings); n > 0 {
return fmt.Errorf("%w (%d fact record(s) dropped)", err, n)
}
return err
}