Files
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

244 lines
7.1 KiB
Go

package catalog
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"path"
"sort"
"strconv"
"strings"
)
// Catalog is the subset of a Puppet v4 catalog this tool reads.
type Catalog struct {
Name string `json:"name"`
Version json.RawMessage `json:"version"`
Environment string `json:"environment"`
CodeID string `json:"code_id"`
CatalogUUID string `json:"catalog_uuid"`
Tags []string `json:"tags"`
Classes []string `json:"classes"`
SensitiveParameters []string `json:"sensitive_parameters"`
Resources []Resource `json:"resources"`
}
// Resource is a single catalog resource. Only type, title, tags and exported
// are guaranteed: the compiler omits parameters on class, stage and anchor
// resources, and omits file and line on resources with no manifest location.
// Line is a pointer so an omitted line stays distinguishable from line 0.
type Resource struct {
Type string `json:"type"`
Title string `json:"title"`
Parameters map[string]any `json:"parameters"`
Tags []string `json:"tags"`
Exported bool `json:"exported"`
File string `json:"file"`
Line *int `json:"line"`
SensitiveParameters []string `json:"sensitive_parameters"`
}
// Key is the "Type[Title]" identity used to match resources across catalogs.
func (r Resource) Key() string { return r.Type + "[" + r.Title + "]" }
// Params returns the resource's parameters, mapping an omitted "parameters"
// key onto an empty set so absent and empty compare and render identically.
func (r Resource) Params() map[string]any {
if r.Parameters == nil {
return map[string]any{}
}
return r.Parameters
}
// HasSource reports whether the compiler supplied a manifest location.
func (r Resource) HasSource() bool { return r.File != "" && r.Line != nil }
// Options controls normalisation. The zero value is the default behaviour:
// classes ignored, exported resources skipped, ordering-only metaparameters
// dropped.
type Options struct {
// IncludeClasses keeps Class resources, which manage nothing themselves.
IncludeClasses bool
// IgnoreResources holds "Type[Title]" patterns (glob) to drop entirely.
IgnoreResources []string
// IgnoreAttrs holds parameter names to drop from every resource.
IgnoreAttrs []string
}
// noiseParameters never carry change signal. before/require are ordering only
// and their targets are rarely visible; notify/subscribe are kept because they
// cause real work, but their arrays are sorted before comparison.
var noiseParameters = map[string]bool{
"loglevel": true,
"stage": true,
"before": true,
"require": true,
}
// relationshipParameters are the kept metaparameters whose value Puppet writes
// inconsistently as either a scalar or an array.
var relationshipParameters = map[string]bool{
"notify": true,
"subscribe": true,
}
// Normalize indexes a catalog by resource key, applying every normalisation
// that stops known false positives: classes and exported resources dropped,
// noise parameters stripped, File titles keyed by path, relationship
// metaparameters sorted, sensitive values redacted.
func Normalize(c *Catalog, opts Options) map[string]Resource {
out := make(map[string]Resource, len(c.Resources))
for _, r := range c.Resources {
if r.Exported {
continue
}
if !opts.IncludeClasses && r.Type == "Class" {
continue
}
title := normalizeTitle(r)
r.Parameters = cleanParameters(r, c.SensitiveParameters, opts.IgnoreAttrs)
r.Title = title
r.Tags = sortedTags(r.Tags)
if matchesAny(r.Key(), opts.IgnoreResources) {
continue
}
out[r.Key()] = r
}
return out
}
// normalizeTitle keys a File by its path parameter, so file{'my-name':
// path=>'/etc/foo'} and file{'/etc/foo':} compare equal. cleanParameters has
// already removed the path parameter.
func normalizeTitle(r Resource) string {
if r.Type != "File" {
return r.Title
}
if p, ok := r.Params()["path"].(string); ok && p != "" {
return p
}
return r.Title
}
func cleanParameters(r Resource, catalogSensitive, ignoreAttrs []string) map[string]any {
sensitive := map[string]bool{}
for _, name := range append(append([]string(nil), catalogSensitive...), r.SensitiveParameters...) {
sensitive[name] = true
}
ignored := map[string]bool{}
for _, name := range ignoreAttrs {
ignored[name] = true
}
params := make(map[string]any, len(r.Parameters))
for k, v := range r.Params() {
if noiseParameters[k] || ignored[k] || strings.HasPrefix(k, "_") {
continue
}
if r.Type == "File" && k == "path" {
continue
}
if sensitive[k] {
params[k] = redact(v)
continue
}
if relationshipParameters[k] {
params[k] = sortedRelationship(v)
continue
}
params[k] = v
}
return params
}
// sortedRelationship turns a scalar into a one-element list and sorts the
// result, so notify => "Service[x]" and notify => ["Service[x]"] compare equal
// and list order never registers as a change.
func sortedRelationship(v any) any {
items, ok := v.([]any)
if !ok {
items = []any{v}
}
sorted := append([]any(nil), items...)
sort.SliceStable(sorted, func(i, j int) bool {
return stringRepr(sorted[i]) < stringRepr(sorted[j])
})
return sorted
}
// redact replaces a sensitive value with a digest, so a change is still
// visible but the plaintext never reaches stdout or a CI log.
func redact(v any) string {
sum := sha256.Sum256([]byte(stringRepr(v)))
return "redacted:sha256:" + hex.EncodeToString(sum[:])[:16]
}
func sortedTags(tags []string) []string {
if len(tags) == 0 {
return nil
}
out := append([]string(nil), tags...)
sort.Strings(out)
return out
}
// matchesAny reports whether a "Type[Title]" key matches one of the patterns.
// A pattern may glob in either half; a pattern without brackets matches the
// type and any title.
func matchesAny(key string, patterns []string) bool {
for _, p := range patterns {
if matchesPattern(key, p) {
return true
}
}
return false
}
func matchesPattern(key, pattern string) bool {
keyType, keyTitle := splitKey(key)
patType, patTitle := splitKey(pattern)
if patTitle == "" && !strings.Contains(pattern, "[") {
patTitle = "*"
}
typeOK, err := path.Match(patType, keyType)
if err != nil || !typeOK {
return false
}
titleOK, err := path.Match(patTitle, keyTitle)
return err == nil && titleOK
}
func splitKey(key string) (string, string) {
open := strings.Index(key, "[")
if open < 0 || !strings.HasSuffix(key, "]") {
return key, ""
}
return key[:open], key[open+1 : len(key)-1]
}
// stringRepr renders a value the way the datatype-insensitive comparison sees
// it: nil and "" are the same string, 28 and "28" are the same string.
func stringRepr(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case bool:
return fmt.Sprintf("%t", t)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case int:
return strconv.Itoa(t)
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}