04ec9e6d41
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.
452 lines
14 KiB
Go
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
|
|
}
|