Render native configuration differences as YAML
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Implement ConfigKit adoption and configuration comparison
|
||||
|
||||
Status: implementation in progress; Stages 1–6 are complete.
|
||||
Status: implementation in progress; Stages 1–7 are complete.
|
||||
|
||||
## Scope and authority
|
||||
|
||||
@@ -355,11 +355,16 @@ Stage 6's helpers, ready for delivery in Stage 8.
|
||||
handling rather than adding a general provider
|
||||
schema-validation framework or new restrictions on legitimate native records.
|
||||
|
||||
**Completion evidence:** semantic YAML checks for all three categories and the
|
||||
empty document; deletion versus null and mapping-to-empty deletions. Cover
|
||||
atomic sequences, structural replacements, native masks, literal
|
||||
marker-looking public values, and concealed-only changes. Protect ordered root
|
||||
keys and types, without freezing incidental emitter spelling or mapping styles.
|
||||
**Completion evidence — complete:** `renderDiffReport` now classifies native
|
||||
records into ordered additions, changes, and deletions and encodes the complete
|
||||
three-category YAML document before returning bytes. Semantic node checks cover
|
||||
all categories, category order, the empty document, root values, explicit null
|
||||
versus deletion, mapping-to-empty deletions, atomic sequences, and structural
|
||||
values. Real ConfigKit results cover native redaction, a public string matching
|
||||
the upstream redaction marker, and a concealed-only change; the report retains
|
||||
safe values without exposing secret contents or native mask metadata. Malformed
|
||||
present snapshots return an error without report bytes. `go test -count=1 ./...`
|
||||
passes in ckconfig.
|
||||
|
||||
## Stage 8: Execute comparison through existing outcomes and delivery
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -118,3 +120,55 @@ func pointerFromTokens(tokens []string) string {
|
||||
}
|
||||
return pointer.String()
|
||||
}
|
||||
|
||||
func renderDiffReport(differences []configkit.Difference) ([]byte, error) {
|
||||
additions := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
changes := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
deletions := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
||||
|
||||
for _, difference := range differences {
|
||||
path := difference.Path.String()
|
||||
switch {
|
||||
case !difference.Left.Present && difference.Right.Present:
|
||||
tokens, err := decodeJSONPointer(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
||||
}
|
||||
value, err := decodeSafeSnapshot(difference.Right.YAML)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
||||
}
|
||||
additions, err = placeYAMLValue(additions, tokens, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
||||
}
|
||||
case difference.Left.Present && difference.Right.Present:
|
||||
tokens, err := decodeJSONPointer(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
||||
}
|
||||
value, err := decodeSafeSnapshot(difference.Right.YAML)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
||||
}
|
||||
changes, err = placeYAMLValue(changes, tokens, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
||||
}
|
||||
case difference.Left.Present && !difference.Right.Present:
|
||||
deletions.Content = append(deletions.Content, stringKey(path))
|
||||
}
|
||||
}
|
||||
|
||||
root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
|
||||
stringKey("additions"), additions,
|
||||
stringKey("changes"), changes,
|
||||
stringKey("deletions"), deletions,
|
||||
}}
|
||||
document := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}
|
||||
var output bytes.Buffer
|
||||
if err := encodeYAML(&output, document); err != nil {
|
||||
return nil, fmt.Errorf("encode configuration diff report: %w", err)
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
173
integrations/ckconfig/diff_report_render_test.go
Normal file
173
integrations/ckconfig/diff_report_render_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func renderAndDecode(t *testing.T, differences []configkit.Difference) (*yaml.Node, []byte) {
|
||||
t.Helper()
|
||||
report, err := renderDiffReport(differences)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document yaml.Node
|
||||
if err := yaml.Unmarshal(report, &document); err != nil {
|
||||
t.Fatalf("decode rendered report %q: %v", report, err)
|
||||
}
|
||||
return document.Content[0], report
|
||||
}
|
||||
|
||||
func reportField(mapping *yaml.Node, key string) *yaml.Node {
|
||||
for index := 0; index+1 < len(mapping.Content); index += 2 {
|
||||
if mapping.Content[index].Value == key {
|
||||
return mapping.Content[index+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRenderDiffReportClassifiesAndOrdersNativeRecords(t *testing.T) {
|
||||
differences := []configkit.Difference{
|
||||
{Path: configkit.Keys("z-added"), Right: configkit.ValueSnapshot{Present: true, YAML: "false\n"}},
|
||||
{Path: configkit.Keys("count"), Left: configkit.ValueSnapshot{Present: true, YAML: "1\n"}, Right: configkit.ValueSnapshot{Present: true, YAML: "9007199254740993\n"}},
|
||||
{Path: configkit.Keys("removed"), Left: configkit.ValueSnapshot{Present: true, YAML: "unused"}},
|
||||
{Path: configkit.Keys("a-added"), Right: configkit.ValueSnapshot{Present: true, YAML: "'123'\n"}},
|
||||
}
|
||||
root, _ := renderAndDecode(t, differences)
|
||||
if root.Kind != yaml.MappingNode || len(root.Content) != 6 {
|
||||
t.Fatalf("report root = %#v", root)
|
||||
}
|
||||
if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) {
|
||||
t.Fatalf("report category order = %#v", got)
|
||||
}
|
||||
additions := reportField(root, "additions")
|
||||
if additions.Kind != yaml.MappingNode || additions.Content[0].Value != "z-added" || additions.Content[2].Value != "a-added" {
|
||||
t.Fatalf("addition order/value = %#v", additions)
|
||||
}
|
||||
if additions.Content[1].Kind != yaml.ScalarNode || additions.Content[1].Tag != "!!bool" || additions.Content[1].Value != "false" {
|
||||
t.Fatalf("boolean addition lost its type: %#v", additions.Content[1])
|
||||
}
|
||||
if additions.Content[3].Tag != "!!str" || additions.Content[3].Value != "123" {
|
||||
t.Fatalf("numeric-looking string lost its type: %#v", additions.Content[3])
|
||||
}
|
||||
changes := reportField(root, "changes")
|
||||
if changes.Kind != yaml.MappingNode || changes.Content[1].Tag != "!!int" || changes.Content[1].Value != "9007199254740993" {
|
||||
t.Fatalf("change value/type = %#v", changes)
|
||||
}
|
||||
deletions := reportField(root, "deletions")
|
||||
if deletions.Kind != yaml.SequenceNode || len(deletions.Content) != 1 || deletions.Content[0].Tag != "!!str" || deletions.Content[0].Value != "/removed" {
|
||||
t.Fatalf("deletion records = %#v", deletions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDiffReportEmitsEmptyCategoriesAndRootValue(t *testing.T) {
|
||||
root, _ := renderAndDecode(t, nil)
|
||||
if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) {
|
||||
t.Fatalf("empty report keys = %#v", got)
|
||||
}
|
||||
if reportField(root, "additions").Kind != yaml.MappingNode || len(reportField(root, "additions").Content) != 0 || reportField(root, "changes").Kind != yaml.MappingNode || len(reportField(root, "changes").Content) != 0 || reportField(root, "deletions").Kind != yaml.SequenceNode || len(reportField(root, "deletions").Content) != 0 {
|
||||
t.Fatalf("empty report categories = %#v", root)
|
||||
}
|
||||
|
||||
root, _ = renderAndDecode(t, []configkit.Difference{{Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}}})
|
||||
additions := reportField(root, "additions")
|
||||
if additions.Tag != "!!null" || additions.Value != "null" {
|
||||
t.Fatalf("whole-root addition was wrapped: %#v", additions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDiffReportDistinguishesDeletionFromNullAndEmptyMappings(t *testing.T) {
|
||||
differences := []configkit.Difference{
|
||||
{Path: configkit.Keys("nullable"), Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}},
|
||||
{Path: configkit.Keys("settings", "first"), Left: configkit.ValueSnapshot{Present: true, YAML: "intentionally not parsed"}},
|
||||
{Path: configkit.Keys("settings", "second"), Left: configkit.ValueSnapshot{Present: true, YAML: "also not parsed"}},
|
||||
}
|
||||
root, _ := renderAndDecode(t, differences)
|
||||
if got := reportField(root, "additions").Content; len(got) != 2 || got[0].Value != "nullable" || got[1].Tag != "!!null" {
|
||||
t.Fatalf("explicit null addition = %#v", got)
|
||||
}
|
||||
deletions := reportField(root, "deletions")
|
||||
if len(deletions.Content) != 2 || deletions.Content[0].Value != "/settings/first" || deletions.Content[1].Value != "/settings/second" {
|
||||
t.Fatalf("mapping-to-empty deletion records = %#v", deletions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDiffReportKeepsAtomicSequencesAndStructuralValues(t *testing.T) {
|
||||
differences := []configkit.Difference{
|
||||
{Path: configkit.Keys("items"), Left: configkit.ValueSnapshot{Present: true}, Right: configkit.ValueSnapshot{Present: true, YAML: "- one\n- two\n"}},
|
||||
{Path: configkit.Keys("settings"), Left: configkit.ValueSnapshot{Present: true}, Right: configkit.ValueSnapshot{Present: true, YAML: "nested:\n enabled: true\n"}},
|
||||
}
|
||||
root, _ := renderAndDecode(t, differences)
|
||||
changes := reportField(root, "changes")
|
||||
if changes.Content[1].Kind != yaml.SequenceNode || len(changes.Content[1].Content) != 2 {
|
||||
t.Fatalf("sequence change was expanded or altered: %#v", changes.Content[1])
|
||||
}
|
||||
settings := reportField(changes, "settings")
|
||||
if settings.Kind != yaml.MappingNode || reportField(settings, "nested").Kind != yaml.MappingNode || reportField(reportField(settings, "nested"), "enabled").Tag != "!!bool" {
|
||||
t.Fatalf("structural replacement was altered: %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDiffReportPreservesNativeMasksAndMarkerLookingStrings(t *testing.T) {
|
||||
type settings struct {
|
||||
Public string `yaml:"public"`
|
||||
Secret string `yaml:"secret"`
|
||||
}
|
||||
definition := configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}
|
||||
load := func(public, secret string) *configkit.Result[settings] {
|
||||
result, err := configkit.Load(configkit.Mapping("configuration", map[string]any{"public": public, "secret": secret}), configkit.Options[settings]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
left := load("visible", "old-secret")
|
||||
right := load("[REDACTED]", "new-secret")
|
||||
differences, err := configkit.Diff(left, right)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(differences) != 2 || differences[0].Path.String() != "/public" || differences[1].Path.String() != "/secret" || !differences[1].HiddenChanged || !differences[1].Right.Redacted {
|
||||
t.Fatalf("native changes/mask metadata = %#v", differences)
|
||||
}
|
||||
root, report := renderAndDecode(t, differences)
|
||||
changes := reportField(root, "changes")
|
||||
for _, key := range []string{"public", "secret"} {
|
||||
value := reportField(changes, key)
|
||||
if value == nil || value.Tag != "!!str" || value.Value != "[REDACTED]" {
|
||||
t.Fatalf("safe value for %q = %#v", key, value)
|
||||
}
|
||||
}
|
||||
if bytes.Contains(report, []byte("HiddenChanged")) || bytes.Contains(report, []byte("Redacted")) || strings.Contains(string(report), "old-secret") || strings.Contains(string(report), "new-secret") {
|
||||
t.Fatalf("report exposed native metadata or secret contents: %q", report)
|
||||
}
|
||||
|
||||
concealedOnly := load("[REDACTED]", "later-secret")
|
||||
concealedDifferences, err := configkit.Diff(right, concealedOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(concealedDifferences) != 1 || concealedDifferences[0].Path.String() != "/secret" || !concealedDifferences[0].HiddenChanged {
|
||||
t.Fatalf("concealed-only native records = %#v", concealedDifferences)
|
||||
}
|
||||
concealedRoot, _ := renderAndDecode(t, concealedDifferences)
|
||||
if value := reportField(reportField(concealedRoot, "changes"), "secret"); value == nil || value.Tag != "!!str" || value.Value != "[REDACTED]" {
|
||||
t.Fatalf("concealed-only safe value = %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDiffReportReportsMalformedPresentSnapshot(t *testing.T) {
|
||||
report, err := renderDiffReport([]configkit.Difference{{
|
||||
Path: configkit.Keys("setting"),
|
||||
Right: configkit.ValueSnapshot{Present: true, YAML: "setting: [unterminated\n"},
|
||||
}})
|
||||
if err == nil || report != nil || !strings.Contains(err.Error(), "/setting") {
|
||||
t.Fatalf("malformed snapshot report/error = %q / %v", report, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user