// Command catalog-diff compiles a host's Puppet catalog in two environments // and reports what changes between them. package main import ( "context" "errors" "fmt" "io" "math/rand" "os" "sync" "time" "github.com/spf13/cobra" "git.unkin.net/unkin/puppet-catalog-tools/internal/catalog" "git.unkin.net/unkin/puppet-catalog-tools/internal/config" "git.unkin.net/unkin/puppet-catalog-tools/internal/puppetdb" "git.unkin.net/unkin/puppet-catalog-tools/internal/puppetserver" ) var version = "dev" // Exit codes, also documented in --help and the README. const ( exitSame = 0 // compiled, no differences exitCompile = 1 // catalog compilation failed exitDifferent = 2 // compiled, differences present exitTransport = 3 // transport, auth, environment-mismatch or usage error ) // maxRetry bounds --retry: past this the backoff is already pinned at its // ceiling and the run outlives any review it could inform. const maxRetry = 20 const longHelp = `Compile a host's Puppet catalog in a base environment and in the environment built from the branch under test, then diff the two. Branch names are converted to environment names the way g10k deploys them: every character outside [0-9A-Za-z_] becomes "_". Use --environment to bypass that conversion. Exit codes: 0 catalogs compiled, no differences 1 catalog compilation failed in one or both environments 2 catalogs compiled, differences present 3 transport, auth, environment-mismatch or usage error --testonly compiles the test environment only and returns 0, 1 or 3 (never 2).` type options struct { branch string environment string base string testonly bool jsonOut bool server string puppetdb string cert string key string cacert string timeout time.Duration retry int includeClasses bool ignoreResources []string ignoreAttrs []string datatypeChanges bool detailAdd bool sourceFileLine bool } // exitError carries the exit code a failure should produce. type exitError struct { code int err error } func (e *exitError) Error() string { if e == nil { return "" } if e.err == nil { return fmt.Sprintf("failed with exit code %d", e.code) } return e.err.Error() } func (e *exitError) Unwrap() error { if e == nil { return nil } return e.err } func fail(code int, format string, args ...any) *exitError { return &exitError{code: code, err: fmt.Errorf(format, args...)} } func main() { cfg, err := config.Load() if err != nil { fmt.Fprintln(os.Stderr, "config error:", err) os.Exit(exitTransport) } opts := &options{} root := &cobra.Command{ Use: "catalog-diff ", Short: "Diff a host's Puppet catalog between two environments", Long: longHelp, Args: cobra.ExactArgs(1), Version: version, SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { return run(cmd.Context(), cmd.OutOrStdout(), args[0], opts) }, } f := root.Flags() f.StringVarP(&opts.branch, "branch", "b", "", "branch under test (required unless --environment is given)") f.StringVar(&opts.environment, "environment", "", "explicit puppet environment name, bypasses branch name conversion") f.StringVar(&opts.base, "base", cfg.BaseEnvironment, "base environment to diff against") f.BoolVar(&opts.testonly, "testonly", false, "compile the test environment only; no output, result via exit code") f.BoolVar(&opts.jsonOut, "json", false, "machine-readable output") f.StringVar(&opts.server, "server", cfg.ServerURL, "puppetserver base URL") f.StringVar(&opts.puppetdb, "puppetdb", cfg.PuppetDBURL, "PuppetDB base URL") f.StringVar(&opts.cert, "cert", cfg.Cert, "client certificate PEM (CATALOG_DIFF_CERT)") f.StringVar(&opts.key, "key", cfg.Key, "client private key PEM (CATALOG_DIFF_KEY)") f.StringVar(&opts.cacert, "cacert", cfg.CACert, "CA bundle PEM used to verify puppetserver (CATALOG_DIFF_CACERT)") f.DurationVar(&opts.timeout, "timeout", 10*time.Minute, "per-compile timeout") f.IntVar(&opts.retry, "retry", 2, "retries for transient compile or transport failures") f.BoolVar(&opts.includeClasses, "include-classes", false, "diff Class resources, which manage nothing themselves") f.StringArrayVar(&opts.ignoreResources, "ignore", nil, "ignore resources matching Type[Title] (glob), repeatable") f.StringArrayVar(&opts.ignoreAttrs, "ignore-attr", nil, "ignore a parameter on every resource, repeatable") f.BoolVar(&opts.datatypeChanges, "display-datatype-changes", false, "report changes where only the datatype differs (28 vs \"28\")") f.BoolVar(&opts.detailAdd, "display-detail-add", false, "expand the parameters of added resources") f.BoolVar(&opts.sourceFileLine, "display-source-file-line", false, "print the manifest file and line each change came from") root.AddCommand(configCmd()) if err := root.Execute(); err != nil { // errSilent only carries an exit code; the diff is already printed. if !errors.Is(err, errSilent) { fmt.Fprintln(os.Stderr, "error:", err) } var ee *exitError if errors.As(err, &ee) { os.Exit(ee.code) } os.Exit(exitTransport) } } func configCmd() *cobra.Command { cmd := &cobra.Command{Use: "config", Short: "Manage configuration"} cmd.AddCommand(&cobra.Command{ Use: "init", Short: "Write a default config file to " + config.Path(), RunE: func(_ *cobra.Command, _ []string) error { path, err := config.WriteDefault() if err != nil { return err } fmt.Println("Config written to", path) return nil }, }) cmd.AddCommand(&cobra.Command{ Use: "show", Short: "Print the active configuration", RunE: func(_ *cobra.Command, _ []string) error { cfg, err := config.Load() if err != nil { return err } fmt.Printf("config file : %s\n", config.Path()) fmt.Printf("server_url : %s\n", cfg.ServerURL) fmt.Printf("puppetdb_url : %s\n", cfg.PuppetDBURL) fmt.Printf("cert : %s\n", cfg.Cert) fmt.Printf("key : %s\n", cfg.Key) fmt.Printf("cacert : %s\n", cfg.CACert) fmt.Printf("base_environment : %s\n", cfg.BaseEnvironment) return nil }, }) return cmd } // validate rejects flag values the rest of the run cannot act on. A negative // retry count would skip the compile loop entirely and a non-positive timeout // cancels every request before it is sent, so both are usage errors. func validate(opts *options) error { if opts.retry < 0 { return fail(exitTransport, "--retry must be zero or more, got %d", opts.retry) } if opts.retry > maxRetry { return fail(exitTransport, "--retry must be at most %d, got %d", maxRetry, opts.retry) } if opts.timeout <= 0 { return fail(exitTransport, "--timeout must be positive, got %s", opts.timeout) } return nil } // resolveEnvironment turns the flags into the environment to test. func resolveEnvironment(opts *options) (string, error) { if opts.environment != "" && opts.branch != "" { return "", fail(exitTransport, "--branch and --environment are mutually exclusive") } if opts.environment != "" { if !catalog.ValidEnvironmentName(opts.environment) { return "", fail(exitTransport, "%q is not a valid puppet environment name (must match [a-z0-9_]+)", opts.environment) } return opts.environment, nil } if opts.branch == "" { return "", fail(exitTransport, "one of --branch or --environment is required") } env := catalog.BranchToEnvironment(opts.branch) if !catalog.ValidEnvironmentName(env) { return "", fail(exitTransport, "branch %q deploys as environment %q, which puppet rejects (environment names must match [a-z0-9_]+). "+ "Rename the branch or pass --environment", opts.branch, env) } return env, nil } func run(ctx context.Context, out io.Writer, hostname string, opts *options) error { if ctx == nil { ctx = context.Background() } if err := validate(opts); err != nil { return err } testEnv, err := resolveEnvironment(opts) if err != nil { return err } if !catalog.ValidEnvironmentName(opts.base) { return fail(exitTransport, "%q is not a valid base environment name (must match [a-z0-9_]+)", opts.base) } if testEnv == opts.base && !opts.testonly { return fail(exitTransport, "test and base environments are both %q; there is nothing to diff", testEnv) } tlsCfg, err := puppetserver.TLSConfig(opts.cert, opts.key, opts.cacert) if err != nil { return &exitError{code: exitTransport, err: err} } server := puppetserver.New(opts.server, tlsCfg, opts.timeout) facts := puppetdb.New(opts.puppetdb, opts.timeout) facts.Warn = func(msg string, _ int) { fmt.Fprintln(os.Stderr, "warning:", msg) } healthCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() if err := server.Health(healthCtx); err != nil { return &exitError{code: exitTransport, err: err} } factValues, err := facts.Facts(ctx, hostname) if err != nil { return &exitError{code: exitTransport, err: err} } if opts.testonly { _, err := compile(ctx, server, hostname, testEnv, factValues, opts) return err } // Both compilations are independent, so run them together: one host costs // one compile's wall clock rather than two. The first failure cancels its // sibling — a quick manifest error should not wait out a full compile. compileCtx, abandon := context.WithCancel(ctx) defer abandon() var ( wg sync.WaitGroup testCat, baseCat *catalog.Catalog testErr, baseErr error ) wg.Add(2) go func() { defer wg.Done() testCat, testErr = compile(compileCtx, server, hostname, testEnv, factValues, opts) if testErr != nil { abandon() } }() go func() { defer wg.Done() baseCat, baseErr = compile(compileCtx, server, hostname, opts.base, factValues, opts) if baseErr != nil { abandon() } }() wg.Wait() // Report the test environment's failure first: it is the one under review, // unless it only stopped because the base compile failed first. switch { case testErr != nil && baseErr != nil: if abandoned(ctx, testErr) { return baseErr } return testErr case testErr != nil: return testErr case baseErr != nil: return baseErr } normOpts := catalog.Options{ IncludeClasses: opts.includeClasses, IgnoreResources: opts.ignoreResources, IgnoreAttrs: opts.ignoreAttrs, } changes := catalog.Diff( catalog.Normalize(baseCat, normOpts), catalog.Normalize(testCat, normOpts), catalog.DiffOptions{DisplayDatatypeChanges: opts.datatypeChanges}, ) report := catalog.NewReport(hostname, opts.base, testEnv, changes) if opts.jsonOut { err = catalog.RenderJSON(out, report) } else { err = catalog.RenderText(out, report, catalog.RenderOptions{ Colour: isTerminal(os.Stdout), DetailAdd: opts.detailAdd, SourceFileLine: opts.sourceFileLine, }) } if err != nil { return &exitError{code: exitTransport, err: err} } if len(changes) > 0 { return &exitError{code: exitDifferent, err: errSilent} } return nil } // errSilent marks an exit code that carries no message of its own: the diff // itself has already been printed. var errSilent = errors.New("differences present") // abandoned reports whether err is only the fallout of cancelling a compile // because its sibling failed first, rather than a failure in its own right. func abandoned(ctx context.Context, err error) bool { return ctx.Err() == nil && errors.Is(err, context.Canceled) } // compile runs one compilation, retrying transient failures, and maps the // final failure onto an exit code. func compile(ctx context.Context, server *puppetserver.Client, hostname, environment string, facts map[string]any, opts *options) (*catalog.Catalog, error) { var lastErr error for attempt := 0; attempt <= opts.retry; attempt++ { if attempt > 0 { // The cancellation, not the error that earned the retry, is why // this compile stopped. if err := sleepCtx(ctx, backoff(attempt)); err != nil { if lastErr != nil { err = fmt.Errorf("%w while retrying after: %v", err, lastErr) } return nil, &exitError{code: exitTransport, err: err} } } cat, err := server.Compile(ctx, puppetserver.CompileRequest{ Certname: hostname, Environment: environment, Facts: facts, TrustedFacts: puppetserver.TrustedFacts(hostname), }) if err == nil { return cat, nil } lastErr = err if !transient(err) { break } } if lastErr == nil { return nil, &exitError{code: exitTransport, err: fmt.Errorf("no compile was attempted for %s in %s", hostname, environment)} } var compileErr *puppetserver.CompileError if errors.As(lastErr, &compileErr) { return nil, &exitError{code: exitCompile, err: lastErr} } return nil, &exitError{code: exitTransport, err: lastErr} } // transient reports whether a failure is worth retrying: a transport error, a // server-side 5xx, or a compile the compiler's own PuppetDB query broke. A // manifest that does not compile, an environment mismatch and an unusable // response shape are all settled answers and are not retried. func transient(err error) bool { var mismatch *puppetserver.EnvironmentMismatchError if errors.As(err, &mismatch) { return false } var respErr *puppetserver.ResponseError if errors.As(err, &respErr) { return false } var compileErr *puppetserver.CompileError if errors.As(err, &compileErr) { return compileErr.Transient() } return true } // backoff grows exponentially from 2s and adds up to 50% jitter, so parallel // compiles do not retry in lockstep against an autoscaling compiler pool. The // shift is capped: an unbounded one overflows into a negative delay. func backoff(attempt int) time.Duration { const ( start = 2 * time.Second limit = 30 * time.Second ) d := limit if attempt >= 1 && attempt < 32 { if shifted := start << (attempt - 1); shifted > 0 && shifted < limit { d = shifted } } return d + time.Duration(rand.Int63n(int64(d/2))) } func sleepCtx(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func isTerminal(f *os.File) bool { info, err := f.Stat() if err != nil { return false } return info.Mode()&os.ModeCharDevice != 0 }