Files
puppet-catalog-tools/cmd/catalog-diff/main.go
T
unkin-agent dcbd810c34
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
keep stderr quiet when the diff exits with differences
2026-09-12 15:55:34 +10:00

370 lines
12 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/pdbmux"
"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
)
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
pdb 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 { return e.err.Error() }
func (e *exitError) Unwrap() error { 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.pdb, "pdb", cfg.PDBURL, "pdbmux 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("pdb_url : %s\n", cfg.PDBURL)
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
}
// 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()
}
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 := pdbmux.New(opts.pdb, opts.timeout)
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.
var (
wg sync.WaitGroup
testCat, baseCat *catalog.Catalog
testErr, baseErr error
)
wg.Add(2)
go func() {
defer wg.Done()
testCat, testErr = compile(ctx, server, hostname, testEnv, factValues, opts)
}()
go func() {
defer wg.Done()
baseCat, baseErr = compile(ctx, server, hostname, opts.base, factValues, opts)
}()
wg.Wait()
// Report the test environment's failure first: it is the one under review.
if testErr != nil {
return testErr
}
if 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")
// 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 {
if err := sleepCtx(ctx, backoff(attempt)); err != nil {
break
}
}
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
}
}
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, or
// a server-side 5xx. A manifest that does not compile is not retried.
func transient(err error) bool {
var mismatch *puppetserver.EnvironmentMismatchError
if errors.As(err, &mismatch) {
return false
}
var compileErr *puppetserver.CompileError
if errors.As(err, &compileErr) {
return compileErr.Status >= 500
}
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.
func backoff(attempt int) time.Duration {
base := 2 * time.Second << (attempt - 1)
if base > 30*time.Second {
base = 30 * time.Second
}
return base + time.Duration(rand.Int63n(int64(base/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
}