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

452 lines
14 KiB
Go

// 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 "<nil>"
}
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 <hostname>",
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
}