Files
puppet-catalog-tools/internal/catalog/report.go
T
unkin-agent 53007a9257
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
ci/woodpecker/pr/test Pipeline was canceled
add repo scaffold and the catalog-diff CLI
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
2026-09-12 15:54:25 +10:00

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)
}