Files
puppet-catalog-tools/internal/catalog/diff.go
T
unkin-agent a76480d454
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
unwrap the catalog response and classify failures correctly
- unwrap the {"catalog": ...} envelope, still accepting a bare catalog
- report an environment-less response as a protocol mismatch, not an ENC problem
- make Resource.Line a pointer and Resource.Params() map absent onto empty
- render a missing manifest location as "(source unknown)"
- retry compile failures caused by the compiler's PuppetDB query
- stop retrying manifest errors the compiler returns as HTTP 500
- add a trimmed real-server response fixture and tests for each shape
2026-09-12 16:58:59 +10:00

274 lines
7.2 KiB
Go

package catalog
import (
"encoding/json"
"sort"
"strconv"
)
// Diff types, mirroring octocatalog-diff's taxonomy.
const (
DiffAdded = "+" // whole resource added
DiffRemoved = "-" // whole resource removed
DiffChanged = "~" // attribute changed
DiffElemented = "!" // element changed inside a nested array or hash
)
// Severities.
const (
SeverityCritical = "critical"
SeverityNormal = "normal"
)
// criticalTypes are the resource types whose changes can break a host.
var criticalTypes = map[string]bool{
"Service": true,
"Package": true,
"File": true,
"Exec": true,
"Mount": true,
"User": true,
"Group": true,
"Cron": true,
"Systemd::Unit_file": true,
"Systemd::Service": true,
"Docker::Image": true,
"Docker::Container": true,
}
// IsCritical reports whether a resource type is in the critical set.
func IsCritical(resourceType string) bool { return criticalTypes[resourceType] }
// Severity buckets a resource type.
func Severity(resourceType string) string {
if IsCritical(resourceType) {
return SeverityCritical
}
return SeverityNormal
}
// Change is one difference between the base and test catalogs. Structure is
// the path to the changed value, e.g. ["parameters","device"].
type Change struct {
DiffType string `json:"diff_type"`
Type string `json:"type"`
Title string `json:"title"`
Structure []string `json:"structure"`
OldValue any `json:"old_value"`
NewValue any `json:"new_value"`
OldFile string `json:"old_file,omitempty"`
OldLine *int `json:"old_line,omitempty"`
NewFile string `json:"new_file,omitempty"`
NewLine *int `json:"new_line,omitempty"`
Severity string `json:"severity"`
// Datatype marks a change where only the datatype differs (28 vs "28").
Datatype bool `json:"datatype,omitempty"`
}
// Key is the "Type[Title]" identity of the resource the change belongs to.
func (c Change) Key() string { return c.Type + "[" + c.Title + "]" }
// Summary counts a change set.
type Summary struct {
Added int `json:"added"`
Removed int `json:"removed"`
Changed int `json:"changed"`
Critical int `json:"critical"`
}
// DiffOptions controls what the comparison reports.
type DiffOptions struct {
// DisplayDatatypeChanges keeps changes where only the datatype differs.
DisplayDatatypeChanges bool
}
// Diff compares two normalised catalogs. Whole-resource additions and removals
// are determined from the key sets first, so a new resource is never confused
// with a new element inside an existing one. file and line are reported for
// context but never drive equality, so a resource moving line is not a change.
// The result is sorted, so the same inputs always render identically.
func Diff(base, test map[string]Resource, opts DiffOptions) []Change {
var changes []Change
for key, baseRes := range base {
if _, ok := test[key]; ok {
continue
}
changes = append(changes, Change{
DiffType: DiffRemoved,
Type: baseRes.Type,
Title: baseRes.Title,
OldValue: baseRes.Params(),
OldFile: baseRes.File,
OldLine: baseRes.Line,
Severity: Severity(baseRes.Type),
})
}
for key, testRes := range test {
if _, ok := base[key]; ok {
continue
}
changes = append(changes, Change{
DiffType: DiffAdded,
Type: testRes.Type,
Title: testRes.Title,
NewValue: testRes.Params(),
NewFile: testRes.File,
NewLine: testRes.Line,
Severity: Severity(testRes.Type),
})
}
for key, baseRes := range base {
testRes, ok := test[key]
if !ok {
continue
}
changes = append(changes, diffParameters(baseRes, testRes, opts)...)
}
sort.SliceStable(changes, func(i, j int) bool {
a, b := changes[i], changes[j]
if a.Key() != b.Key() {
return a.Key() < b.Key()
}
if a.DiffType != b.DiffType {
return a.DiffType < b.DiffType
}
return structureKey(a.Structure) < structureKey(b.Structure)
})
return changes
}
func structureKey(s []string) string {
out := ""
for _, part := range s {
out += "\x00" + part
}
return out
}
func diffParameters(baseRes, testRes Resource, opts DiffOptions) []Change {
proto := Change{
Type: testRes.Type,
Title: testRes.Title,
OldFile: baseRes.File,
OldLine: baseRes.Line,
NewFile: testRes.File,
NewLine: testRes.Line,
Severity: Severity(testRes.Type),
}
return walk(proto, []string{"parameters"},
anyMap(baseRes.Params()), anyMap(testRes.Params()), opts)
}
func anyMap(m map[string]any) any {
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = v
}
return out
}
// walk compares two values at a path, recursing into matching containers so a
// change deep inside a hash or array is reported as an element change ("!")
// against the smallest structure that actually differs.
func walk(proto Change, structure []string, oldVal, newVal any, opts DiffOptions) []Change {
if deepEqual(oldVal, newVal) {
return nil
}
oldMap, oldIsMap := oldVal.(map[string]any)
newMap, newIsMap := newVal.(map[string]any)
if oldIsMap && newIsMap {
var changes []Change
for _, k := range unionKeys(oldMap, newMap) {
changes = append(changes, walk(proto, child(structure, k), oldMap[k], newMap[k], opts)...)
}
return changes
}
oldList, oldIsList := oldVal.([]any)
newList, newIsList := newVal.([]any)
if oldIsList && newIsList && len(oldList) == len(newList) {
var changes []Change
for i := range oldList {
changes = append(changes, walk(proto, child(structure, strconv.Itoa(i)), oldList[i], newList[i], opts)...)
}
return changes
}
datatypeOnly := stringRepr(oldVal) == stringRepr(newVal)
if datatypeOnly && !opts.DisplayDatatypeChanges {
return nil
}
change := proto
change.Structure = append([]string(nil), structure...)
change.OldValue = oldVal
change.NewValue = newVal
change.Datatype = datatypeOnly
// A difference directly on a parameter is an attribute change; anything
// deeper is an element change inside that attribute.
if len(structure) <= 2 {
change.DiffType = DiffChanged
} else {
change.DiffType = DiffElemented
}
return []Change{change}
}
func unionKeys(a, b map[string]any) []string {
seen := make(map[string]bool, len(a)+len(b))
keys := make([]string, 0, len(a)+len(b))
for k := range a {
seen[k] = true
keys = append(keys, k)
}
for k := range b {
if !seen[k] {
keys = append(keys, k)
}
}
sort.Strings(keys)
return keys
}
// child extends a structure path without aliasing the parent's backing array.
func child(structure []string, part string) []string {
out := make([]string, 0, len(structure)+1)
return append(append(out, structure...), part)
}
// deepEqual compares two decoded JSON values structurally. Encoding both sides
// sorts map keys, so map ordering never registers as a change.
func deepEqual(a, b any) bool {
ab, errA := json.Marshal(a)
bb, errB := json.Marshal(b)
if errA != nil || errB != nil {
return false
}
return string(ab) == string(bb)
}
// Summarise counts a change set.
func Summarise(changes []Change) Summary {
var s Summary
for _, c := range changes {
switch c.DiffType {
case DiffAdded:
s.Added++
case DiffRemoved:
s.Removed++
default:
s.Changed++
}
if c.Severity == SeverityCritical {
s.Critical++
}
}
return s
}