577 lines
17 KiB
Go
577 lines
17 KiB
Go
package configkit
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
|
)
|
|
|
|
const historyNodeLimit = 4_000_000
|
|
|
|
var (
|
|
errExplanationUnavailable = errors.New("configkit: configuration explanation is unavailable")
|
|
errExplanationUnknownPath = errors.New("configkit: configuration explanation path is unknown")
|
|
)
|
|
|
|
// ValueSnapshot is a detached, deterministic YAML representation of one value.
|
|
// Present distinguishes absence or deletion from an explicit null value.
|
|
type ValueSnapshot struct {
|
|
Present bool
|
|
Redacted bool
|
|
YAML string
|
|
}
|
|
|
|
// ValueChange describes one explicit definition or audited domain operation.
|
|
type ValueChange struct {
|
|
Before ValueSnapshot
|
|
After ValueSnapshot
|
|
Origin Origin
|
|
Reasons []string
|
|
Dependencies []Path
|
|
}
|
|
|
|
// SecretReferenceInfo describes a successfully resolved effective reference.
|
|
// Name is the environment-variable name, never its value.
|
|
type SecretReferenceInfo struct {
|
|
Name string
|
|
}
|
|
|
|
// Explanation is a detached safe account of one effective or historical path.
|
|
type Explanation struct {
|
|
Path Path
|
|
Effective ValueSnapshot
|
|
Origin Origin
|
|
History []ValueChange
|
|
SecretReference *SecretReferenceInfo
|
|
}
|
|
|
|
type historyApplication struct {
|
|
layer Layer
|
|
path Path
|
|
value *document.Node
|
|
origin Origin
|
|
}
|
|
|
|
func cloneHistoryApplications(values []historyApplication) []historyApplication {
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
cloned := make([]historyApplication, len(values))
|
|
for index, value := range values {
|
|
cloned[index] = historyApplication{
|
|
layer: value.layer, path: value.path.clone(), value: value.value,
|
|
origin: cloneOrigin(value.origin),
|
|
}
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func cloneSecretReferences(values map[string]string) map[string]string {
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
cloned := make(map[string]string, len(values))
|
|
for path, name := range values {
|
|
cloned[path] = name
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
// Explain returns safe effective and chronological values for a known final or
|
|
// historical path. Unknown and malformed paths return false.
|
|
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
|
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
|
|
return Explanation{}, false
|
|
}
|
|
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
|
|
if info, ok := r.metadata.Lookup(path); ok {
|
|
explanation.Origin = cloneOrigin(info.Origin)
|
|
}
|
|
|
|
working := document.Clone(r.baseline)
|
|
if _, present := documentAtPath(working, path); present {
|
|
explanation.History = append(explanation.History, ValueChange{
|
|
After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault},
|
|
})
|
|
}
|
|
for _, application := range r.applications {
|
|
operationPath, relevant := applicationOperationPath(application, path)
|
|
var before ValueSnapshot
|
|
if relevant {
|
|
before = valueSnapshot(working, path, r.secrets)
|
|
}
|
|
applyHistoryApplication(&working, application)
|
|
if !relevant {
|
|
continue
|
|
}
|
|
after := valueSnapshot(working, path, r.secrets)
|
|
if !before.Present && !after.Present {
|
|
continue
|
|
}
|
|
explanation.History = append(explanation.History, ValueChange{
|
|
Before: before,
|
|
After: after,
|
|
Origin: historyApplicationOrigin(application, operationPath),
|
|
})
|
|
}
|
|
|
|
// Intermediate input replacements do not permanently suppress defaults:
|
|
// decoding applies them to the final input tree. Show that reconciliation
|
|
// explicitly, including canonical scalar conversions, before domain changes.
|
|
replayed, _ := documentAtPath(working, path)
|
|
decoded, _ := documentAtPath(r.input, path)
|
|
if !configurationValuesEqual(replayed, decoded) {
|
|
origin := cloneOrigin(r.inputOrigins[path.String()])
|
|
origin.Operation = ChangeDecoding
|
|
explanation.History = append(explanation.History, ValueChange{
|
|
Before: valueSnapshot(working, path, r.secrets),
|
|
After: valueSnapshot(r.input, path, r.secrets),
|
|
Origin: origin,
|
|
Reasons: []string{"applied retained defaults and canonical typed decoding"},
|
|
})
|
|
}
|
|
|
|
working = document.Clone(r.input)
|
|
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) {
|
|
relevant := pathsIntersect(snapshot.change.Path, path)
|
|
var before ValueSnapshot
|
|
if relevant {
|
|
before = valueSnapshot(working, path, r.secrets)
|
|
}
|
|
replaceHistoryPath(&working, snapshot.change.Path, snapshot.after)
|
|
if !relevant {
|
|
continue
|
|
}
|
|
after := valueSnapshot(working, path, r.secrets)
|
|
if !before.Present && !after.Present {
|
|
continue
|
|
}
|
|
explanation.History = append(explanation.History, ValueChange{
|
|
Before: before,
|
|
After: after,
|
|
Origin: cloneOrigin(snapshot.change.Origin),
|
|
Reasons: append([]string(nil), snapshot.change.Reasons...),
|
|
Dependencies: clonePaths(snapshot.change.Dependencies),
|
|
})
|
|
}
|
|
|
|
if !explanation.Effective.Present && len(explanation.History) == 0 {
|
|
return Explanation{}, false
|
|
}
|
|
if name, ok := r.secretReferences[path.String()]; ok && !pathIsSecret(r.secrets, path) {
|
|
explanation.SecretReference = &SecretReferenceInfo{Name: name}
|
|
}
|
|
return explanation, true
|
|
}
|
|
|
|
func applicationOperationPath(application historyApplication, query Path) (Path, bool) {
|
|
if !application.path.valid() || application.value == nil {
|
|
return Path{}, false
|
|
}
|
|
if application.path.String() != "" {
|
|
if pathsIntersect(application.path, query) {
|
|
return application.path.clone(), true
|
|
}
|
|
return Path{}, false
|
|
}
|
|
if len(query.segments) == 0 {
|
|
return Path{}, true
|
|
}
|
|
for length := 1; length <= len(query.segments); length++ {
|
|
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
|
node, present := documentAtPath(application.value, prefix)
|
|
if !present {
|
|
return Path{}, false
|
|
}
|
|
if length == len(query.segments) || node.Kind != document.Mapping {
|
|
return prefix, true
|
|
}
|
|
}
|
|
return Path{}, false
|
|
}
|
|
|
|
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
|
|
if application.origin.Layer != "" {
|
|
return cloneOrigin(application.origin)
|
|
}
|
|
origin := Origin{Layer: application.layer}
|
|
relative, ok := relativeHistoryPath(application.path, operationPath)
|
|
if !ok {
|
|
return origin
|
|
}
|
|
if node, present := documentAtPath(application.value, relative); present {
|
|
origin.Locations = []Location{publicLocation(node.Location)}
|
|
}
|
|
return origin
|
|
}
|
|
|
|
func relativeHistoryPath(root, path Path) (Path, bool) {
|
|
if !pathContains(root, path) {
|
|
return Path{}, false
|
|
}
|
|
return Path{segments: clonePathSegments(path.segments[len(root.segments):], 0)}, true
|
|
}
|
|
|
|
func pathsIntersect(left, right Path) bool {
|
|
return pathContains(left, right) || pathContains(right, left)
|
|
}
|
|
|
|
func applyHistoryApplication(root **document.Node, application historyApplication) {
|
|
if application.path.String() == "" {
|
|
mergeHistoryDocument(root, application.value)
|
|
return
|
|
}
|
|
mergeHistoryAtPath(root, application.path, application.value)
|
|
}
|
|
|
|
func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
|
|
if incoming == nil {
|
|
return
|
|
}
|
|
if *target == nil || (*target).Kind != document.Mapping || incoming.Kind != document.Mapping {
|
|
*target = document.Clone(incoming)
|
|
return
|
|
}
|
|
for _, entry := range incoming.Mapping {
|
|
position := documentEntryIndex((*target).Mapping, entry.Key)
|
|
if position < 0 {
|
|
cloned := entry
|
|
cloned.Value = document.Clone(entry.Value)
|
|
(*target).Mapping = append((*target).Mapping, cloned)
|
|
continue
|
|
}
|
|
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value)
|
|
}
|
|
}
|
|
|
|
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) {
|
|
if root == nil || *root == nil || len(path.segments) == 0 {
|
|
mergeHistoryDocument(root, incoming)
|
|
return
|
|
}
|
|
current := *root
|
|
for index, segment := range path.segments {
|
|
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
|
|
return
|
|
}
|
|
position := documentEntryIndex(current.Mapping, segment.key)
|
|
if index == len(path.segments)-1 {
|
|
if position < 0 {
|
|
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
|
|
} else {
|
|
mergeHistoryDocument(¤t.Mapping[position].Value, incoming)
|
|
}
|
|
return
|
|
}
|
|
if position < 0 {
|
|
child := &document.Node{Kind: document.Mapping}
|
|
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
|
current = child
|
|
} else {
|
|
current = current.Mapping[position].Value
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolution records describe one before/final transformation, not assignment
|
|
// order. A parent already carries its descendants' final values; merge overlapping
|
|
// intent into that event rather than replay a child assignment a second time.
|
|
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
|
groups := make([]resolutionSnapshot, 0, len(snapshots))
|
|
for _, snapshot := range snapshots {
|
|
merged := false
|
|
for i := range groups {
|
|
if !pathContains(groups[i].change.Path, snapshot.change.Path) {
|
|
continue
|
|
}
|
|
for _, reason := range snapshot.change.Reasons {
|
|
groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason)
|
|
}
|
|
for _, dependency := range snapshot.change.Dependencies {
|
|
groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency)
|
|
}
|
|
if snapshot.change.Origin.Operation == ChangeNormalization {
|
|
groups[i].change.Origin.Operation = ChangeNormalization
|
|
}
|
|
merged = true
|
|
break
|
|
}
|
|
if !merged {
|
|
snapshot.change = cloneChange(snapshot.change)
|
|
groups = append(groups, snapshot)
|
|
}
|
|
}
|
|
return groups
|
|
}
|
|
|
|
func replaceHistoryPath(root **document.Node, path Path, replacement *document.Node) {
|
|
if root == nil || *root == nil {
|
|
return
|
|
}
|
|
if len(path.segments) == 0 {
|
|
*root = document.Clone(replacement)
|
|
return
|
|
}
|
|
current := *root
|
|
for index, segment := range path.segments {
|
|
last := index == len(path.segments)-1
|
|
switch current.Kind {
|
|
case document.Mapping:
|
|
if segment.kind == pathSegmentIndex {
|
|
return
|
|
}
|
|
position := documentEntryIndex(current.Mapping, segment.key)
|
|
if position < 0 {
|
|
if replacement == nil {
|
|
return
|
|
}
|
|
child := &document.Node{Kind: document.Mapping}
|
|
if last {
|
|
child = document.Clone(replacement)
|
|
}
|
|
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
|
if last {
|
|
return
|
|
}
|
|
current = child
|
|
continue
|
|
}
|
|
if last {
|
|
if replacement == nil {
|
|
current.Mapping = append(current.Mapping[:position], current.Mapping[position+1:]...)
|
|
} else {
|
|
current.Mapping[position].Value = document.Clone(replacement)
|
|
}
|
|
return
|
|
}
|
|
current = current.Mapping[position].Value
|
|
case document.Sequence:
|
|
position, ok := schemaIndex(segment)
|
|
if !ok || position >= len(current.Sequence) {
|
|
return
|
|
}
|
|
if last {
|
|
if replacement != nil {
|
|
current.Sequence[position] = document.Clone(replacement)
|
|
}
|
|
return
|
|
}
|
|
current = current.Sequence[position]
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot {
|
|
node, present := documentAtPath(root, path)
|
|
if !present {
|
|
return ValueSnapshot{}
|
|
}
|
|
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
|
var output bytes.Buffer
|
|
if err := yamladapter.Write(&output, safe); err != nil {
|
|
return ValueSnapshot{Present: true, Redacted: true, YAML: "'[REDACTED]'\n"}
|
|
}
|
|
return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()}
|
|
}
|
|
|
|
func safeSnapshotDocument(node *document.Node, secrets map[string]bool, path Path) (*document.Node, bool) {
|
|
if node == nil {
|
|
return nil, false
|
|
}
|
|
if pathIsSecret(secrets, path) {
|
|
return &document.Node{Kind: document.String, Text: "[REDACTED]"}, true
|
|
}
|
|
result := *node
|
|
redacted := false
|
|
switch node.Kind {
|
|
case document.Mapping:
|
|
result.Mapping = make([]document.Entry, len(node.Mapping))
|
|
copy(result.Mapping, node.Mapping)
|
|
sortDocumentMapping(result.Mapping)
|
|
for index := range result.Mapping {
|
|
entry := &result.Mapping[index]
|
|
entry.Value, redacted = safeSnapshotChild(entry.Value, secrets, path.Key(entry.Key), redacted)
|
|
}
|
|
case document.Sequence:
|
|
result.Sequence = make([]*document.Node, len(node.Sequence))
|
|
for index, child := range node.Sequence {
|
|
result.Sequence[index], redacted = safeSnapshotChild(child, secrets, path.Index(index), redacted)
|
|
}
|
|
}
|
|
return &result, redacted
|
|
}
|
|
|
|
func safeSnapshotChild(node *document.Node, secrets map[string]bool, path Path, prior bool) (*document.Node, bool) {
|
|
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
|
return safe, prior || redacted
|
|
}
|
|
|
|
func sortDocumentMapping(entries []document.Entry) {
|
|
slices.SortFunc(entries, func(left, right document.Entry) int {
|
|
return strings.Compare(left.Key, right.Key)
|
|
})
|
|
}
|
|
|
|
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
|
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions)
|
|
}
|
|
|
|
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
|
seen := make(map[*document.Node]bool)
|
|
count := 0
|
|
var visit func(*document.Node) bool
|
|
visit = func(node *document.Node) bool {
|
|
if node == nil || seen[node] {
|
|
return true
|
|
}
|
|
seen[node] = true
|
|
count++
|
|
if count > limit {
|
|
return false
|
|
}
|
|
for _, entry := range node.Mapping {
|
|
if !visit(entry.Value) {
|
|
return false
|
|
}
|
|
}
|
|
for _, child := range node.Sequence {
|
|
if !visit(child) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
if !visit(baseline) || !visit(input) || !visit(final) {
|
|
return false
|
|
}
|
|
for _, application := range applications {
|
|
if !visit(application.value) {
|
|
return false
|
|
}
|
|
}
|
|
for _, resolution := range resolutions {
|
|
if !visit(resolution.before) || !visit(resolution.after) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// WriteExplanation writes a deterministic human-readable rendering of Explain.
|
|
// Unknown paths produce no output. Writer failures may leave partial output.
|
|
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
|
|
if r == nil || output == nil {
|
|
return errExplanationUnavailable
|
|
}
|
|
explanation, ok := r.Explain(path)
|
|
if !ok {
|
|
return errExplanationUnknownPath
|
|
}
|
|
var rendered strings.Builder
|
|
rendered.WriteString("path: ")
|
|
rendered.WriteString(strconv.Quote(explanation.Path.String()))
|
|
rendered.WriteByte('\n')
|
|
writeExplanationOrigin(&rendered, "effective source", explanation.Origin, r.sources)
|
|
writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0)
|
|
if explanation.SecretReference != nil {
|
|
rendered.WriteString("secret reference: ")
|
|
rendered.WriteString(strconv.Quote(explanation.SecretReference.Name))
|
|
rendered.WriteString(" (resolved from process environment)\n")
|
|
}
|
|
rendered.WriteString("history:\n")
|
|
for _, change := range explanation.History {
|
|
rendered.WriteString(" - representation: ")
|
|
if change.Origin.Operation == ChangeDecoding {
|
|
rendered.WriteString("input to canonical typed\n")
|
|
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI {
|
|
rendered.WriteString("policy-validated input\n")
|
|
} else {
|
|
rendered.WriteString("canonical typed\n")
|
|
}
|
|
writeExplanationOrigin(&rendered, " source", change.Origin, r.sources)
|
|
writeExplanationSnapshot(&rendered, " before", change.Before, 4)
|
|
writeExplanationSnapshot(&rendered, " after", change.After, 4)
|
|
for _, reason := range change.Reasons {
|
|
rendered.WriteString(" reason: ")
|
|
rendered.WriteString(strconv.Quote(reason))
|
|
rendered.WriteByte('\n')
|
|
}
|
|
for _, dependency := range change.Dependencies {
|
|
rendered.WriteString(" dependency: ")
|
|
rendered.WriteString(strconv.Quote(dependency.String()))
|
|
rendered.WriteByte('\n')
|
|
}
|
|
}
|
|
data := []byte(rendered.String())
|
|
written, err := output.Write(data)
|
|
if err == nil && written != len(data) {
|
|
return io.ErrShortWrite
|
|
}
|
|
return err
|
|
}
|
|
|
|
func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, sources []SourceInfo) {
|
|
output.WriteString(label)
|
|
output.WriteString(": layer=")
|
|
output.WriteString(strconv.Quote(string(origin.Layer)))
|
|
if origin.Operation != "" {
|
|
output.WriteString(" operation=")
|
|
output.WriteString(strconv.Quote(string(origin.Operation)))
|
|
}
|
|
output.WriteByte('\n')
|
|
for _, location := range origin.Locations {
|
|
output.WriteString(" location: source=")
|
|
output.WriteString(strconv.Quote(location.SourceID))
|
|
if label := sourceLabel(sources, location.SourceID); label != "" {
|
|
output.WriteString(" label=")
|
|
output.WriteString(strconv.Quote(label))
|
|
}
|
|
output.WriteString(" line=")
|
|
output.WriteString(strconv.Itoa(location.Line))
|
|
output.WriteString(" column=")
|
|
output.WriteString(strconv.Itoa(location.Column))
|
|
output.WriteByte('\n')
|
|
}
|
|
}
|
|
|
|
func sourceLabel(sources []SourceInfo, sourceID string) string {
|
|
for _, source := range sources {
|
|
if source.ID == sourceID {
|
|
return source.Label
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) {
|
|
if !snapshot.Present {
|
|
output.WriteString(label)
|
|
output.WriteString(": absent\n")
|
|
return
|
|
}
|
|
output.WriteString(label)
|
|
if snapshot.Redacted {
|
|
output.WriteString(" (redacted)")
|
|
}
|
|
output.WriteString(":\n")
|
|
prefix := strings.Repeat(" ", indent+2)
|
|
for _, line := range strings.SplitAfter(snapshot.YAML, "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
output.WriteString(prefix)
|
|
output.WriteString(line)
|
|
if !strings.HasSuffix(line, "\n") {
|
|
output.WriteByte('\n')
|
|
}
|
|
}
|
|
}
|