a76480d454
- 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
244 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|