53007a9257
Compile a host's catalog in a base and a test environment over the puppetserver API, then diff the two. - add catalog-diff CLI with cobra flags, config precedence and exit codes - add puppetserver mTLS client for POST /puppet/v4/catalog - add pdbmux fact client - add catalog normalisation, diff engine and text/JSON renderers - add Makefile, nfpm RPM packaging and woodpecker pipelines
215 lines
5.6 KiB
Go
215 lines
5.6 KiB
Go
package catalog
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Report is the full result of a catalog-diff run and the shape of --json.
|
|
type Report struct {
|
|
Hostname string `json:"hostname"`
|
|
BaseEnvironment string `json:"base_environment"`
|
|
TestEnvironment string `json:"test_environment"`
|
|
Compiled map[string]bool `json:"compiled"`
|
|
Changes []Change `json:"changes"`
|
|
Summary Summary `json:"summary"`
|
|
}
|
|
|
|
// NewReport builds a report from a change set.
|
|
func NewReport(hostname, baseEnv, testEnv string, changes []Change) Report {
|
|
if changes == nil {
|
|
changes = []Change{}
|
|
}
|
|
return Report{
|
|
Hostname: hostname,
|
|
BaseEnvironment: baseEnv,
|
|
TestEnvironment: testEnv,
|
|
Compiled: map[string]bool{baseEnv: true, testEnv: true},
|
|
Changes: changes,
|
|
Summary: Summarise(changes),
|
|
}
|
|
}
|
|
|
|
// RenderJSON writes the report as indented JSON.
|
|
func RenderJSON(w io.Writer, r Report) error {
|
|
enc := json.NewEncoder(w)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(r)
|
|
}
|
|
|
|
// RenderOptions controls the text renderer.
|
|
type RenderOptions struct {
|
|
// Colour emits ANSI escapes; callers set it only for a TTY.
|
|
Colour bool
|
|
// DetailAdd expands the parameters of added resources.
|
|
DetailAdd bool
|
|
// SourceFileLine appends the originating manifest file and line.
|
|
SourceFileLine bool
|
|
}
|
|
|
|
const (
|
|
ansiReset = "\033[0m"
|
|
ansiBold = "\033[1m"
|
|
ansiRed = "\033[31m"
|
|
)
|
|
|
|
// RenderText writes the human/agent readable report. Changes are grouped
|
|
// critical first, then ordered by resource key, so CI logs diff cleanly
|
|
// between runs.
|
|
func RenderText(w io.Writer, r Report, opts RenderOptions) error {
|
|
var b strings.Builder
|
|
|
|
bold := func(s string) string {
|
|
if !opts.Colour {
|
|
return s
|
|
}
|
|
return ansiBold + s + ansiReset
|
|
}
|
|
red := func(s string) string {
|
|
if !opts.Colour {
|
|
return s
|
|
}
|
|
return ansiRed + s + ansiReset
|
|
}
|
|
|
|
b.WriteString(bold("Puppet Catalog Diff") + "\n")
|
|
b.WriteString(strings.Repeat("=", 50) + "\n")
|
|
fmt.Fprintf(&b, "Host : %s\n", r.Hostname)
|
|
fmt.Fprintf(&b, "Base environment : %s\n", r.BaseEnvironment)
|
|
fmt.Fprintf(&b, "Test environment : %s\n", r.TestEnvironment)
|
|
fmt.Fprintf(&b, "Compiled : %s\n", compiledLine(r.Compiled))
|
|
fmt.Fprintf(&b, "Changes : %d critical, %d added, %d removed, %d changed\n\n",
|
|
r.Summary.Critical, r.Summary.Added, r.Summary.Removed, r.Summary.Changed)
|
|
|
|
if len(r.Changes) == 0 {
|
|
b.WriteString("No differences between environments.\n")
|
|
_, err := io.WriteString(w, b.String())
|
|
return err
|
|
}
|
|
|
|
var critical, other []Change
|
|
for _, c := range r.Changes {
|
|
if c.Severity == SeverityCritical {
|
|
critical = append(critical, c)
|
|
} else {
|
|
other = append(other, c)
|
|
}
|
|
}
|
|
|
|
writeGroup := func(title string, changes []Change) {
|
|
if len(changes) == 0 {
|
|
return
|
|
}
|
|
b.WriteString(title + "\n")
|
|
b.WriteString(strings.Repeat("-", 50) + "\n")
|
|
for _, c := range changes {
|
|
for _, line := range describe(c, opts) {
|
|
b.WriteString(" " + line + "\n")
|
|
}
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
writeGroup(red(bold("CRITICAL")), critical)
|
|
writeGroup(bold("OTHER"), other)
|
|
|
|
_, err := io.WriteString(w, strings.TrimRight(b.String(), "\n")+"\n")
|
|
return err
|
|
}
|
|
|
|
func compiledLine(compiled map[string]bool) string {
|
|
envs := make([]string, 0, len(compiled))
|
|
for env := range compiled {
|
|
envs = append(envs, env)
|
|
}
|
|
sort.Strings(envs)
|
|
parts := make([]string, 0, len(envs))
|
|
for _, env := range envs {
|
|
status := "failed"
|
|
if compiled[env] {
|
|
status = "ok"
|
|
}
|
|
parts = append(parts, env+"="+status)
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// describe renders one change as one or more lines. Added resources collapse
|
|
// to a single line unless DetailAdd is set; changed resources always expand.
|
|
func describe(c Change, opts RenderOptions) []string {
|
|
switch c.DiffType {
|
|
case DiffAdded:
|
|
lines := []string{fmt.Sprintf("+ %s added%s", c.Key(), location(c.NewFile, c.NewLine, opts))}
|
|
if opts.DetailAdd {
|
|
lines = append(lines, parameterLines(" ", c.NewValue)...)
|
|
}
|
|
return lines
|
|
case DiffRemoved:
|
|
return []string{fmt.Sprintf("- %s removed%s", c.Key(), location(c.OldFile, c.OldLine, opts))}
|
|
default:
|
|
note := ""
|
|
if c.Datatype {
|
|
note = " (datatype only)"
|
|
}
|
|
return []string{fmt.Sprintf("%s %s %s: %s -> %s%s%s",
|
|
c.DiffType, c.Key(), structurePath(c.Structure),
|
|
renderValue(c.OldValue), renderValue(c.NewValue), note,
|
|
location(c.NewFile, c.NewLine, opts))}
|
|
}
|
|
}
|
|
|
|
// structurePath drops the leading "parameters" element, which every attribute
|
|
// change shares, and joins the rest.
|
|
func structurePath(structure []string) string {
|
|
if len(structure) > 0 && structure[0] == "parameters" {
|
|
structure = structure[1:]
|
|
}
|
|
if len(structure) == 0 {
|
|
return "(resource)"
|
|
}
|
|
return strings.Join(structure, ".")
|
|
}
|
|
|
|
func location(file string, line int, opts RenderOptions) string {
|
|
if !opts.SourceFileLine || file == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf(" (%s:%d)", file, line)
|
|
}
|
|
|
|
func parameterLines(indent string, value any) []string {
|
|
params, ok := value.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
names := make([]string, 0, len(params))
|
|
for name := range params {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
lines := make([]string, 0, len(names))
|
|
for _, name := range names {
|
|
lines = append(lines, fmt.Sprintf("%s%s: %s", indent, name, renderValue(params[name])))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// renderValue prints a value compactly: strings bare, everything else as
|
|
// compact JSON, and an absent value as "<absent>".
|
|
func renderValue(v any) string {
|
|
if v == nil {
|
|
return "<absent>"
|
|
}
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
return string(b)
|
|
}
|