797 lines
25 KiB
Go
797 lines
25 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// compositionDocument is the presence-aware representation used while
|
|
// assembling pipeline configuration sources. It deliberately models YAML
|
|
// mechanics rather than duplicating PipelineConfig's field schema.
|
|
type compositionDocument struct {
|
|
root *compositionNode
|
|
sources []string
|
|
}
|
|
|
|
type compositionNode struct {
|
|
kind yaml.Kind
|
|
tag string
|
|
value string
|
|
path string
|
|
sources []string
|
|
line int
|
|
column int
|
|
fields []compositionField
|
|
items []*compositionNode
|
|
}
|
|
|
|
type compositionField struct {
|
|
key string
|
|
value *compositionNode
|
|
order int
|
|
line int
|
|
column int
|
|
}
|
|
|
|
// compositionValueRecord is a deterministic semantic leaf projection. Lists
|
|
// are atomic configuration values, while mappings are traversed recursively.
|
|
type compositionValueRecord struct {
|
|
Path string
|
|
Kind yaml.Kind
|
|
Value string
|
|
Sources []string
|
|
}
|
|
|
|
// parseCompositionDocument parses exactly one YAML mapping while retaining
|
|
// source ownership, explicit zero values, and declaration order.
|
|
func parseCompositionDocument(source string, reader io.Reader) (*compositionDocument, error) {
|
|
if strings.TrimSpace(source) == "" {
|
|
return nil, fmt.Errorf("configuration source identity is required")
|
|
}
|
|
if reader == nil {
|
|
return nil, fmt.Errorf("configuration source %q: reader is nil", source)
|
|
}
|
|
|
|
decoder := yaml.NewDecoder(reader)
|
|
var document yaml.Node
|
|
if err := decoder.Decode(&document); err != nil {
|
|
if err == io.EOF {
|
|
return nil, fmt.Errorf("configuration source %q: document is empty", source)
|
|
}
|
|
return nil, fmt.Errorf("configuration source %q: decode YAML: %w", source, err)
|
|
}
|
|
var trailing yaml.Node
|
|
if err := decoder.Decode(&trailing); err == nil {
|
|
return nil, fmt.Errorf("configuration source %q: must contain exactly one YAML document", source)
|
|
} else if err != io.EOF {
|
|
return nil, fmt.Errorf("configuration source %q: decode trailing YAML: %w", source, err)
|
|
}
|
|
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 {
|
|
return nil, fmt.Errorf("configuration source %q: must contain exactly one YAML document", source)
|
|
}
|
|
if document.Content[0].Kind != yaml.MappingNode {
|
|
return nil, fmt.Errorf(
|
|
"configuration source %q: top-level document must be a mapping, got %s",
|
|
source,
|
|
yamlKindName(document.Content[0].Kind),
|
|
)
|
|
}
|
|
|
|
root, err := buildCompositionNode(document.Content[0], source, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &compositionDocument{root: root, sources: []string{source}}, nil
|
|
}
|
|
|
|
func parseCompositionBytes(source string, data []byte) (*compositionDocument, error) {
|
|
return parseCompositionDocument(source, bytes.NewReader(data))
|
|
}
|
|
|
|
func buildCompositionNode(node *yaml.Node, source, path string) (*compositionNode, error) {
|
|
if node == nil {
|
|
return nil, fmt.Errorf("configuration source %q at %s: YAML node is nil", source, displayCompositionPath(path))
|
|
}
|
|
if node.Kind == yaml.AliasNode {
|
|
return nil, compositionNodeError(source, path, node, "YAML aliases are not supported because source ownership would be ambiguous")
|
|
}
|
|
result := &compositionNode{
|
|
kind: node.Kind, tag: node.Tag, value: node.Value, path: path,
|
|
sources: []string{source}, line: node.Line, column: node.Column,
|
|
}
|
|
|
|
switch node.Kind {
|
|
case yaml.MappingNode:
|
|
if len(node.Content)%2 != 0 {
|
|
return nil, compositionNodeError(source, path, node, "mapping has an incomplete key/value pair")
|
|
}
|
|
seen := make(map[string]*yaml.Node, len(node.Content)/2)
|
|
for index := 0; index < len(node.Content); index += 2 {
|
|
keyNode := node.Content[index]
|
|
valueNode := node.Content[index+1]
|
|
if keyNode.Kind == yaml.AliasNode {
|
|
return nil, compositionNodeError(source, path, keyNode, "YAML aliases are not supported because source ownership would be ambiguous")
|
|
}
|
|
if valueNode.Kind == yaml.AliasNode {
|
|
return nil, compositionNodeError(source, appendCompositionPath(path, keyNode.Value), valueNode, "YAML aliases are not supported because source ownership would be ambiguous")
|
|
}
|
|
if keyNode.Kind != yaml.ScalarNode || keyNode.Tag != "!!str" {
|
|
return nil, compositionNodeError(source, path, keyNode, "mapping keys must be strings")
|
|
}
|
|
key := keyNode.Value
|
|
fieldPath := appendCompositionPath(path, key)
|
|
if prior, duplicate := seen[key]; duplicate {
|
|
return nil, fmt.Errorf(
|
|
"configuration source %q at %s: duplicate YAML key %q (first declared at line %d, column %d; repeated at line %d, column %d)",
|
|
source, displayCompositionPath(fieldPath), key,
|
|
prior.Line, prior.Column, keyNode.Line, keyNode.Column,
|
|
)
|
|
}
|
|
seen[key] = keyNode
|
|
child, err := buildCompositionNode(valueNode, source, fieldPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.fields = append(result.fields, compositionField{
|
|
key: key, value: child, order: len(result.fields),
|
|
line: keyNode.Line, column: keyNode.Column,
|
|
})
|
|
}
|
|
case yaml.SequenceNode:
|
|
for index, childNode := range node.Content {
|
|
childPath := fmt.Sprintf("%s[%d]", path, index)
|
|
child, err := buildCompositionNode(childNode, source, childPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.items = append(result.items, child)
|
|
}
|
|
case yaml.ScalarNode:
|
|
// Scalar tag and lexical value retain distinctions such as explicit
|
|
// false, zero, an empty string, and null until final strict decoding.
|
|
default:
|
|
return nil, compositionNodeError(source, path, node, fmt.Sprintf("unsupported YAML node kind %s", yamlKindName(node.Kind)))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// mergeAdditiveComposition recursively combines disjoint mappings. A scalar,
|
|
// list, or final keyed value may have only one base owner, regardless of
|
|
// whether duplicate values happen to be equal.
|
|
func mergeAdditiveComposition(base, incoming *compositionDocument) (*compositionDocument, error) {
|
|
return mergeAdditiveCompositions(base, incoming)
|
|
}
|
|
|
|
// mergeAdditiveCompositions validates the complete base source set before
|
|
// merging so a conflict names every source that claims the same final path.
|
|
func mergeAdditiveCompositions(documents ...*compositionDocument) (*compositionDocument, error) {
|
|
if len(documents) == 0 {
|
|
return nil, fmt.Errorf("configuration additive base merge requires at least one document")
|
|
}
|
|
for index, document := range documents {
|
|
if err := validateCompositionDocument(document, fmt.Sprintf("base[%d]", index)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := validateAdditiveClaims(documents); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &compositionDocument{
|
|
root: cloneCompositionNode(documents[0].root),
|
|
sources: append([]string(nil), documents[0].sources...),
|
|
}
|
|
for _, incoming := range documents[1:] {
|
|
merged, err := mergeAdditiveNodes(result.root, incoming.root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.root = merged
|
|
result.sources = appendUniqueStrings(result.sources, incoming.sources...)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validateAdditiveClaims(documents []*compositionDocument) error {
|
|
claims := make(map[string][]*compositionNode)
|
|
for _, document := range documents {
|
|
appendCompositionClaims(document.root, claims)
|
|
}
|
|
paths := make([]string, 0, len(claims))
|
|
for path := range claims {
|
|
paths = append(paths, path)
|
|
}
|
|
sort.Slice(paths, func(i, j int) bool {
|
|
leftDepth := compositionPathDepth(paths[i])
|
|
rightDepth := compositionPathDepth(paths[j])
|
|
if leftDepth != rightDepth {
|
|
return leftDepth < rightDepth
|
|
}
|
|
return paths[i] < paths[j]
|
|
})
|
|
for _, path := range paths {
|
|
values := claims[path]
|
|
if len(values) < 2 {
|
|
continue
|
|
}
|
|
allPopulatedMappings := true
|
|
for _, value := range values {
|
|
if value.kind != yaml.MappingNode || len(value.fields) == 0 {
|
|
allPopulatedMappings = false
|
|
break
|
|
}
|
|
}
|
|
if !allPopulatedMappings {
|
|
return newCompositionConflict("additive base merge", path, values...)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func appendCompositionClaims(node *compositionNode, claims map[string][]*compositionNode) {
|
|
if node == nil {
|
|
return
|
|
}
|
|
if node.path != "" {
|
|
claims[node.path] = append(claims[node.path], node)
|
|
}
|
|
for _, field := range node.fields {
|
|
appendCompositionClaims(field.value, claims)
|
|
}
|
|
}
|
|
|
|
func compositionPathDepth(path string) int {
|
|
if path == "" {
|
|
return 0
|
|
}
|
|
return strings.Count(path, ".") + strings.Count(path, "[") + 1
|
|
}
|
|
|
|
func mergeAdditiveNodes(base, incoming *compositionNode) (*compositionNode, error) {
|
|
if base.kind != yaml.MappingNode || incoming.kind != yaml.MappingNode {
|
|
return nil, newCompositionConflict("additive base merge", base.path, base, incoming)
|
|
}
|
|
base.sources = appendUniqueStrings(base.sources, incoming.sources...)
|
|
for _, incomingField := range incoming.fields {
|
|
index := compositionFieldIndex(base.fields, incomingField.key)
|
|
if index < 0 {
|
|
field := cloneCompositionField(incomingField)
|
|
field.order = len(base.fields)
|
|
base.fields = append(base.fields, field)
|
|
continue
|
|
}
|
|
baseValue := base.fields[index].value
|
|
incomingValue := incomingField.value
|
|
if baseValue.kind == yaml.MappingNode && incomingValue.kind == yaml.MappingNode {
|
|
if len(baseValue.fields) == 0 || len(incomingValue.fields) == 0 {
|
|
return nil, newCompositionConflict("additive base merge", incomingValue.path, baseValue, incomingValue)
|
|
}
|
|
merged, err := mergeAdditiveNodes(baseValue, incomingValue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
base.fields[index].value = merged
|
|
continue
|
|
}
|
|
return nil, newCompositionConflict("additive base merge", incomingValue.path, baseValue, incomingValue)
|
|
}
|
|
return base, nil
|
|
}
|
|
|
|
// mergeOverlayComposition applies the sole overwrite layer. Mappings merge
|
|
// recursively; same-kind scalars and lists replace; null and kind changes are
|
|
// rejected.
|
|
func mergeOverlayComposition(base, overlay *compositionDocument) (*compositionDocument, error) {
|
|
if err := validateCompositionDocument(base, "base"); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateCompositionDocument(overlay, "overlay"); err != nil {
|
|
return nil, err
|
|
}
|
|
if null := firstNullCompositionNode(overlay.root); null != nil {
|
|
return nil, fmt.Errorf(
|
|
"configuration overlay at %s from %s: null cannot delete an effective value",
|
|
displayCompositionPath(null.path), formatCompositionSources(null.sources),
|
|
)
|
|
}
|
|
merged, err := mergeOverlayNodes(cloneCompositionNode(base.root), overlay.root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &compositionDocument{
|
|
root: merged,
|
|
sources: appendUniqueStrings(
|
|
append([]string(nil), base.sources...), overlay.sources...,
|
|
),
|
|
}, nil
|
|
}
|
|
|
|
func mergeOverlayNodes(base, overlay *compositionNode) (*compositionNode, error) {
|
|
if base.kind != yaml.MappingNode || overlay.kind != yaml.MappingNode {
|
|
return nil, newCompositionConflict("profile overlay", base.path, base, overlay)
|
|
}
|
|
base.sources = appendUniqueStrings(base.sources, overlay.sources...)
|
|
for _, overlayField := range overlay.fields {
|
|
index := compositionFieldIndex(base.fields, overlayField.key)
|
|
if index < 0 {
|
|
field := cloneCompositionField(overlayField)
|
|
field.order = len(base.fields)
|
|
base.fields = append(base.fields, field)
|
|
continue
|
|
}
|
|
baseValue := base.fields[index].value
|
|
overlayValue := overlayField.value
|
|
if baseValue.kind != overlayValue.kind {
|
|
return nil, newCompositionConflict("profile overlay kind change", overlayValue.path, baseValue, overlayValue)
|
|
}
|
|
if baseValue.kind == yaml.MappingNode {
|
|
merged, err := mergeOverlayNodes(baseValue, overlayValue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
base.fields[index].value = merged
|
|
continue
|
|
}
|
|
base.fields[index].value = cloneCompositionNode(overlayValue)
|
|
}
|
|
return base, nil
|
|
}
|
|
|
|
// canonicalYAML renders the effective mapping with sorted keys and normalized
|
|
// presentation while retaining sequence order and scalar YAML types.
|
|
func (document *compositionDocument) canonicalYAML() ([]byte, error) {
|
|
if err := validateCompositionDocument(document, "document"); err != nil {
|
|
return nil, err
|
|
}
|
|
root, err := compositionYAMLNode(document.root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var buffer bytes.Buffer
|
|
encoder := yaml.NewEncoder(&buffer)
|
|
encoder.SetIndent(2)
|
|
if err := encoder.Encode(root); err != nil {
|
|
return nil, fmt.Errorf("render effective configuration YAML: %w", err)
|
|
}
|
|
if err := encoder.Close(); err != nil {
|
|
return nil, fmt.Errorf("finish effective configuration YAML: %w", err)
|
|
}
|
|
return buffer.Bytes(), nil
|
|
}
|
|
|
|
// canonicalDigestInput provides a deterministic, formatting-independent byte
|
|
// representation for later secret-free effective configuration digesting.
|
|
func (document *compositionDocument) canonicalDigestInput() ([]byte, error) {
|
|
if err := validateCompositionDocument(document, "document"); err != nil {
|
|
return nil, err
|
|
}
|
|
value, err := canonicalCompositionValue(document.root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serialize canonical configuration digest input: %w", err)
|
|
}
|
|
return append(data, '\n'), nil
|
|
}
|
|
|
|
// semanticRecords returns sorted atomic values for future effective diff and
|
|
// source-report projections. A caller receives copies of all source slices.
|
|
func (document *compositionDocument) semanticRecords() ([]compositionValueRecord, error) {
|
|
if err := validateCompositionDocument(document, "document"); err != nil {
|
|
return nil, err
|
|
}
|
|
var records []compositionValueRecord
|
|
if err := appendCompositionRecords(document.root, &records); err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(records, func(i, j int) bool { return records[i].Path < records[j].Path })
|
|
return records, nil
|
|
}
|
|
|
|
// compactSemanticRecords returns the same logical atomic paths as
|
|
// semanticRecords, but represents values as compact JSON-compatible YAML
|
|
// values rather than the typed structural form used for digesting. It is the
|
|
// stable human-facing projection for semantic comparisons.
|
|
func (document *compositionDocument) compactSemanticRecords() ([]compositionValueRecord, error) {
|
|
if err := validateCompositionDocument(document, "document"); err != nil {
|
|
return nil, err
|
|
}
|
|
var records []compositionValueRecord
|
|
if err := appendCompactCompositionRecords(document.root, &records); err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(records, func(i, j int) bool { return records[i].Path < records[j].Path })
|
|
return records, nil
|
|
}
|
|
|
|
func appendCompositionRecords(node *compositionNode, records *[]compositionValueRecord) error {
|
|
if node.kind == yaml.MappingNode && len(node.fields) > 0 {
|
|
for _, field := range node.fields {
|
|
if err := appendCompositionRecords(field.value, records); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
value, err := canonicalCompositionValue(node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return fmt.Errorf("serialize configuration value at %s: %w", displayCompositionPath(node.path), err)
|
|
}
|
|
*records = append(*records, compositionValueRecord{
|
|
Path: node.path, Kind: node.kind, Value: string(encoded),
|
|
Sources: append([]string(nil), node.sources...),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func appendCompactCompositionRecords(node *compositionNode, records *[]compositionValueRecord) error {
|
|
if node.kind == yaml.MappingNode && len(node.fields) > 0 {
|
|
for _, field := range node.fields {
|
|
if err := appendCompactCompositionRecords(field.value, records); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
value, err := compactCompositionValue(node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return fmt.Errorf("serialize configuration value at %s: %w", displayCompositionPath(node.path), err)
|
|
}
|
|
*records = append(*records, compositionValueRecord{
|
|
Path: node.path, Kind: node.kind, Value: string(encoded),
|
|
Sources: append([]string(nil), node.sources...),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func compactCompositionValue(node *compositionNode) (any, error) {
|
|
switch node.kind {
|
|
case yaml.MappingNode:
|
|
values := make(map[string]any, len(node.fields))
|
|
for _, field := range node.fields {
|
|
value, err := compactCompositionValue(field.value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values[field.key] = value
|
|
}
|
|
return values, nil
|
|
case yaml.SequenceNode:
|
|
values := make([]any, 0, len(node.items))
|
|
for _, item := range node.items {
|
|
value, err := compactCompositionValue(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values = append(values, value)
|
|
}
|
|
return values, nil
|
|
case yaml.ScalarNode:
|
|
return canonicalScalarValue(node)
|
|
default:
|
|
return nil, fmt.Errorf("configuration at %s has unsupported YAML kind %s", displayCompositionPath(node.path), yamlKindName(node.kind))
|
|
}
|
|
}
|
|
|
|
type canonicalCompositionField struct {
|
|
Key string `json:"key"`
|
|
Value any `json:"value"`
|
|
}
|
|
|
|
type canonicalCompositionNode struct {
|
|
Kind string `json:"kind"`
|
|
Tag string `json:"tag,omitempty"`
|
|
Value any `json:"value,omitempty"`
|
|
Fields []canonicalCompositionField `json:"fields,omitempty"`
|
|
Items []any `json:"items,omitempty"`
|
|
}
|
|
|
|
func canonicalCompositionValue(node *compositionNode) (any, error) {
|
|
switch node.kind {
|
|
case yaml.MappingNode:
|
|
fields := append([]compositionField(nil), node.fields...)
|
|
sort.Slice(fields, func(i, j int) bool { return fields[i].key < fields[j].key })
|
|
result := canonicalCompositionNode{Kind: "mapping"}
|
|
if len(fields) == 0 {
|
|
result.Fields = []canonicalCompositionField{}
|
|
}
|
|
for _, field := range fields {
|
|
value, err := canonicalCompositionValue(field.value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.Fields = append(result.Fields, canonicalCompositionField{Key: field.key, Value: value})
|
|
}
|
|
return result, nil
|
|
case yaml.SequenceNode:
|
|
result := canonicalCompositionNode{Kind: "sequence", Items: make([]any, 0, len(node.items))}
|
|
for _, item := range node.items {
|
|
value, err := canonicalCompositionValue(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.Items = append(result.Items, value)
|
|
}
|
|
return result, nil
|
|
case yaml.ScalarNode:
|
|
value, err := canonicalScalarValue(node)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return canonicalCompositionNode{Kind: "scalar", Tag: node.tag, Value: value}, nil
|
|
default:
|
|
return nil, fmt.Errorf("configuration at %s has unsupported YAML kind %s", displayCompositionPath(node.path), yamlKindName(node.kind))
|
|
}
|
|
}
|
|
|
|
func canonicalScalarValue(node *compositionNode) (any, error) {
|
|
raw := &yaml.Node{Kind: yaml.ScalarNode, Tag: node.tag, Value: node.value}
|
|
var value any
|
|
if err := raw.Decode(&value); err != nil {
|
|
return nil, fmt.Errorf("decode scalar at %s: %w", displayCompositionPath(node.path), err)
|
|
}
|
|
switch typed := value.(type) {
|
|
case nil, bool, string, int, int64, uint64, float64:
|
|
return typed, nil
|
|
default:
|
|
// yaml.v3 may decode timestamps or uncommon scalar tags into types that
|
|
// encoding/json can serialize deterministically. Preserve the resolved
|
|
// tag alongside the value in the containing canonical node.
|
|
return typed, nil
|
|
}
|
|
}
|
|
|
|
func compositionYAMLNode(node *compositionNode) (*yaml.Node, error) {
|
|
switch node.kind {
|
|
case yaml.MappingNode:
|
|
result := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
fields := append([]compositionField(nil), node.fields...)
|
|
sort.Slice(fields, func(i, j int) bool { return fields[i].key < fields[j].key })
|
|
for _, field := range fields {
|
|
value, err := compositionYAMLNode(field.value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.Content = append(result.Content,
|
|
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: field.key},
|
|
value,
|
|
)
|
|
}
|
|
return result, nil
|
|
case yaml.SequenceNode:
|
|
result := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
|
for _, item := range node.items {
|
|
value, err := compositionYAMLNode(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.Content = append(result.Content, value)
|
|
}
|
|
return result, nil
|
|
case yaml.ScalarNode:
|
|
return normalizedCompositionScalarNode(node)
|
|
default:
|
|
return nil, fmt.Errorf("configuration at %s has unsupported YAML kind %s", displayCompositionPath(node.path), yamlKindName(node.kind))
|
|
}
|
|
}
|
|
|
|
func normalizedCompositionScalarNode(node *compositionNode) (*yaml.Node, error) {
|
|
raw := &yaml.Node{Kind: yaml.ScalarNode, Tag: node.tag, Value: node.value}
|
|
var value any
|
|
if err := raw.Decode(&value); err != nil {
|
|
return nil, fmt.Errorf("normalize scalar at %s: %w", displayCompositionPath(node.path), err)
|
|
}
|
|
normalized := &yaml.Node{}
|
|
if err := normalized.Encode(value); err != nil {
|
|
return nil, fmt.Errorf("encode normalized scalar at %s: %w", displayCompositionPath(node.path), err)
|
|
}
|
|
if normalized.Kind != yaml.ScalarNode {
|
|
return nil, fmt.Errorf("normalize scalar at %s produced YAML kind %s", displayCompositionPath(node.path), yamlKindName(normalized.Kind))
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func validateCompositionDocument(document *compositionDocument, role string) error {
|
|
if document == nil || document.root == nil {
|
|
return fmt.Errorf("configuration composition %s document is nil", role)
|
|
}
|
|
if document.root.kind != yaml.MappingNode {
|
|
return fmt.Errorf("configuration composition %s root must be a mapping", role)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstNullCompositionNode(node *compositionNode) *compositionNode {
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
if node.kind == yaml.ScalarNode && node.tag == "!!null" {
|
|
return node
|
|
}
|
|
for _, field := range node.fields {
|
|
if found := firstNullCompositionNode(field.value); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
for _, item := range node.items {
|
|
if found := firstNullCompositionNode(item); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cloneCompositionNode(node *compositionNode) *compositionNode {
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
clone := &compositionNode{
|
|
kind: node.kind, tag: node.tag, value: node.value, path: node.path,
|
|
sources: append([]string(nil), node.sources...), line: node.line, column: node.column,
|
|
}
|
|
for _, field := range node.fields {
|
|
clone.fields = append(clone.fields, cloneCompositionField(field))
|
|
}
|
|
for _, item := range node.items {
|
|
clone.items = append(clone.items, cloneCompositionNode(item))
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func cloneCompositionField(field compositionField) compositionField {
|
|
return compositionField{
|
|
key: field.key, value: cloneCompositionNode(field.value), order: field.order,
|
|
line: field.line, column: field.column,
|
|
}
|
|
}
|
|
|
|
func compositionFieldIndex(fields []compositionField, key string) int {
|
|
for index := range fields {
|
|
if fields[index].key == key {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func appendCompositionPath(parent, key string) string {
|
|
if isSimpleCompositionPathSegment(key) {
|
|
if parent == "" {
|
|
return key
|
|
}
|
|
return parent + "." + key
|
|
}
|
|
if parent == "" {
|
|
return "[" + strconv.Quote(key) + "]"
|
|
}
|
|
return parent + "[" + strconv.Quote(key) + "]"
|
|
}
|
|
|
|
func isSimpleCompositionPathSegment(value string) bool {
|
|
if value == "" {
|
|
return false
|
|
}
|
|
for index, char := range value {
|
|
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char == '_' || (index > 0 && char >= '0' && char <= '9') || (index > 0 && char == '-') {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func displayCompositionPath(path string) string {
|
|
if path == "" {
|
|
return "<root>"
|
|
}
|
|
return path
|
|
}
|
|
|
|
func compositionNodeError(source, path string, node *yaml.Node, message string) error {
|
|
line, column := 0, 0
|
|
if node != nil {
|
|
line, column = node.Line, node.Column
|
|
}
|
|
return fmt.Errorf(
|
|
"configuration source %q at %s (line %d, column %d): %s",
|
|
source, displayCompositionPath(path), line, column, message,
|
|
)
|
|
}
|
|
|
|
func newCompositionConflict(operation, path string, values ...*compositionNode) error {
|
|
var sources []string
|
|
var kinds []string
|
|
for _, value := range values {
|
|
if value == nil {
|
|
continue
|
|
}
|
|
sources = appendUniqueStrings(sources, compositionClaimSources(value)...)
|
|
kind := yamlKindName(value.kind)
|
|
if !containsString(kinds, kind) {
|
|
kinds = append(kinds, kind)
|
|
}
|
|
}
|
|
return fmt.Errorf(
|
|
"configuration %s conflict at %s: claimed by %s (YAML kinds: %s)",
|
|
operation, displayCompositionPath(path), formatCompositionSources(sources), strings.Join(kinds, ", "),
|
|
)
|
|
}
|
|
|
|
func compositionClaimSources(node *compositionNode) []string {
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
sources := append([]string(nil), node.sources...)
|
|
for _, field := range node.fields {
|
|
sources = appendUniqueStrings(sources, compositionClaimSources(field.value)...)
|
|
}
|
|
for _, item := range node.items {
|
|
sources = appendUniqueStrings(sources, compositionClaimSources(item)...)
|
|
}
|
|
return sources
|
|
}
|
|
|
|
func appendUniqueStrings(values []string, additions ...string) []string {
|
|
seen := make(map[string]struct{}, len(values)+len(additions))
|
|
result := make([]string, 0, len(values)+len(additions))
|
|
for _, value := range append(append([]string(nil), values...), additions...) {
|
|
if _, exists := seen[value]; exists {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
result = append(result, value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func containsString(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func formatCompositionSources(sources []string) string {
|
|
quoted := make([]string, 0, len(sources))
|
|
for _, source := range sources {
|
|
quoted = append(quoted, strconv.Quote(source))
|
|
}
|
|
return strings.Join(quoted, ", ")
|
|
}
|
|
|
|
func yamlKindName(kind yaml.Kind) string {
|
|
switch kind {
|
|
case yaml.DocumentNode:
|
|
return "document"
|
|
case yaml.MappingNode:
|
|
return "mapping"
|
|
case yaml.SequenceNode:
|
|
return "sequence"
|
|
case yaml.ScalarNode:
|
|
return "scalar"
|
|
case yaml.AliasNode:
|
|
return "alias"
|
|
default:
|
|
return fmt.Sprintf("kind(%d)", kind)
|
|
}
|
|
}
|