Add effective diff consumer examples
This commit is contained in:
@@ -352,7 +352,7 @@ and the existing nil-result/`LoadError` load contract.
|
||||
|
||||
## Stage 9 — Demonstrate both consumer workflows
|
||||
|
||||
Status: planned. Scope: maintained examples and their behavior tests.
|
||||
Status: complete. Scope: maintained examples and their behavior tests.
|
||||
|
||||
1. Add a small complete example under `examples/consumers` that loads two caller-
|
||||
selected files with one definition, handles each load error, calls `Diff`, and
|
||||
@@ -374,6 +374,15 @@ Validate: `go test ./examples/consumers`, `go test . -run '^ExampleDiff$'` when
|
||||
added, and the everyday check. Done: applications can implement the motivating
|
||||
workflows without internal imports or their own structural comparison.
|
||||
|
||||
Evidence: `go test ./examples/consumers`, `go test . -run '^ExampleDiff$'`,
|
||||
and `./scripts/check.sh` passed. Temporary-file coverage exercises both
|
||||
caller-selected loads and base-versus-selected-overlay comparison. The overlay
|
||||
case verifies a recorded derived URL after resolution, a retained lower-priority
|
||||
port, bilateral redaction and `HiddenChanged` for a nested secret-reference
|
||||
change, deterministic consumer formatting, and no credential disclosure. The
|
||||
presenter uses snapshot presence and record membership without interpreting the
|
||||
redaction marker; no full-context rendering supplements the diff records.
|
||||
|
||||
## Stage 10 — Reconcile documentation and audit the complete feature
|
||||
|
||||
Status: planned. Scope: current-behavior documentation, integration audit, and
|
||||
|
||||
@@ -30,6 +30,32 @@ func ExampleNoSource() {
|
||||
// Output: 4 0 <nil>
|
||||
}
|
||||
|
||||
func ExampleDiff() {
|
||||
type Config struct {
|
||||
Workers int `yaml:"workers"`
|
||||
}
|
||||
before, err := configkit.Load[Config](configkit.Mapping("before", map[string]any{"workers": 2}))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
after, err := configkit.Load[Config](configkit.Mapping("after", map[string]any{"workers": 4}))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
differences, err := configkit.Diff(before, after)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, difference := range differences {
|
||||
fmt.Printf("%s: %s -> %s\n", difference.Path.String(),
|
||||
strings.TrimSpace(difference.Left.YAML), strings.TrimSpace(difference.Right.YAML))
|
||||
}
|
||||
// Output: /workers: 2 -> 4
|
||||
}
|
||||
|
||||
func ExampleValidation_Error() {
|
||||
type Config struct {
|
||||
First string `yaml:"first"`
|
||||
|
||||
136
examples/consumers/diff.go
Normal file
136
examples/consumers/diff.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// FileComparisonConfig is a small application-owned shape for comparing
|
||||
// caller-selected configuration files.
|
||||
type FileComparisonConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Entries map[string]any `yaml:"entries"`
|
||||
}
|
||||
|
||||
// CompareFiles loads two caller-selected files with the same application
|
||||
// definition, then formats only the safe records returned by configkit.Diff.
|
||||
func CompareFiles(leftPath, rightPath string) (string, error) {
|
||||
options := configkit.Options[FileComparisonConfig]{
|
||||
Definition: configkit.Definition[FileComparisonConfig]{
|
||||
Defaults: func() FileComparisonConfig {
|
||||
return FileComparisonConfig{Entries: map[string]any{}}
|
||||
},
|
||||
},
|
||||
}
|
||||
left, err := configkit.Load(configkit.File(leftPath), options)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load left configuration: %w", err)
|
||||
}
|
||||
right, err := configkit.Load(configkit.File(rightPath), options)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load right configuration: %w", err)
|
||||
}
|
||||
differences, err := configkit.Diff(left, right)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return FormatDifferences(differences), nil
|
||||
}
|
||||
|
||||
// RolloutConfig models a service endpoint whose URL is derived by the
|
||||
// application resolver and whose credential reference is classified.
|
||||
type RolloutConfig struct {
|
||||
Service RolloutService `yaml:"service"`
|
||||
}
|
||||
|
||||
// RolloutService contains endpoint settings and a classified credential.
|
||||
type RolloutService struct {
|
||||
Endpoint RolloutEndpoint `yaml:"endpoint"`
|
||||
Credential RolloutCredential `yaml:"credential"`
|
||||
}
|
||||
|
||||
// RolloutEndpoint is the effective destination used by the application.
|
||||
type RolloutEndpoint struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
// RolloutCredential holds only a typed secret reference in configuration.
|
||||
type RolloutCredential struct {
|
||||
Token configkit.Secret `yaml:"token" configkit:"secret"`
|
||||
}
|
||||
|
||||
// LoadRollout loads a base file, optionally with one selected configuration
|
||||
// overlay. Resolution derives the effective URL after the overlay is applied.
|
||||
func LoadRollout(basePath, overlayName string) (*configkit.Result[RolloutConfig], error) {
|
||||
return configkit.Load(configkit.File(basePath), configkit.Options[RolloutConfig]{
|
||||
Overlay: overlayName,
|
||||
Definition: configkit.Definition[RolloutConfig]{
|
||||
Resolve: resolveRollout,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// CompareRolloutOverlay compares the effective base configuration with that
|
||||
// same file plus one selected overlay. It presents only safe diff records.
|
||||
func CompareRolloutOverlay(basePath, overlayName string) (string, error) {
|
||||
base, err := LoadRollout(basePath, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load base configuration: %w", err)
|
||||
}
|
||||
withOverlay, err := LoadRollout(basePath, overlayName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load overlay configuration: %w", err)
|
||||
}
|
||||
differences, err := configkit.Diff(base, withOverlay)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return FormatDifferences(differences), nil
|
||||
}
|
||||
|
||||
func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resolution) error {
|
||||
endpoint := &configuration.Service.Endpoint
|
||||
if endpoint.Host == "" || endpoint.Port <= 0 {
|
||||
return nil
|
||||
}
|
||||
url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port)
|
||||
if endpoint.URL != url {
|
||||
endpoint.URL = url
|
||||
resolution.Record(configkit.Keys("service", "endpoint", "url"),
|
||||
configkit.ChangeNormalization, "derived endpoint URL",
|
||||
configkit.Keys("service", "endpoint", "host"),
|
||||
configkit.Keys("service", "endpoint", "port"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatDifferences is consumer-owned presentation. It distinguishes absence
|
||||
// through Present and reports withheld changes through HiddenChanged without
|
||||
// interpreting redaction marker text.
|
||||
func FormatDifferences(differences []configkit.Difference) string {
|
||||
if len(differences) == 0 {
|
||||
return "No effective changes.\n"
|
||||
}
|
||||
var output strings.Builder
|
||||
for _, difference := range differences {
|
||||
fmt.Fprintf(&output, "%s: left=%s right=%s", difference.Path.String(),
|
||||
formatSnapshot(difference.Left), formatSnapshot(difference.Right))
|
||||
if difference.HiddenChanged {
|
||||
output.WriteString(" hidden-change")
|
||||
}
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func formatSnapshot(snapshot configkit.ValueSnapshot) string {
|
||||
if !snapshot.Present {
|
||||
return "<absent>"
|
||||
}
|
||||
return strconv.Quote(snapshot.YAML)
|
||||
}
|
||||
187
examples/consumers/diff_test.go
Normal file
187
examples/consumers/diff_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||
)
|
||||
|
||||
func ExampleCompareFiles() {
|
||||
directory, err := os.MkdirTemp("", "configkit-diff-files-")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(directory)
|
||||
leftPath := filepath.Join(directory, "before.yml")
|
||||
rightPath := filepath.Join(directory, "after.yml")
|
||||
if err := os.WriteFile(leftPath, []byte("name: before\nentries: {}\n"), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(rightPath, []byte("name: after\nentries:\n added: null\n"), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
output, err := consumers.CompareFiles(leftPath, rightPath)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Print(output)
|
||||
// Output:
|
||||
// /entries/added: left=<absent> right="null\n"
|
||||
// /name: left="before\n" right="after\n"
|
||||
}
|
||||
|
||||
func ExampleCompareRolloutOverlay() {
|
||||
const baseToken = "CONFIGKIT_DIFF_EXAMPLE_BASE_TOKEN"
|
||||
const overlayToken = "CONFIGKIT_DIFF_EXAMPLE_OVERLAY_TOKEN"
|
||||
defer preserveEnvironment(baseToken)()
|
||||
defer preserveEnvironment(overlayToken)()
|
||||
_ = os.Setenv(baseToken, "disposable-example-base-credential")
|
||||
_ = os.Setenv(overlayToken, "disposable-example-overlay-credential")
|
||||
|
||||
directory, err := os.MkdirTemp("", "configkit-diff-overlay-")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(directory)
|
||||
basePath := filepath.Join(directory, "config.yml")
|
||||
overlayPath := filepath.Join(directory, "overlays", "canary.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
base := "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: " + baseToken + "\n"
|
||||
overlay := "service:\n endpoint:\n host: canary.example\n credential:\n token: " + overlayToken + "\n"
|
||||
if err := os.WriteFile(basePath, []byte(base), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(overlayPath, []byte(overlay), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
output, err := consumers.CompareRolloutOverlay(basePath, "canary")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Print(output)
|
||||
// Output:
|
||||
// /service/credential/token: left="'[REDACTED]'\n" right="'[REDACTED]'\n" hidden-change
|
||||
// /service/endpoint/host: left="base.example\n" right="canary.example\n"
|
||||
// /service/endpoint/url: left="https://base.example:8443\n" right="https://canary.example:8443\n"
|
||||
}
|
||||
|
||||
func TestCompareFilesUsesCallerSelectedSourcesAndSafePresence(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
leftPath := filepath.Join(directory, "before.yml")
|
||||
rightPath := filepath.Join(directory, "after.yml")
|
||||
writeConfiguration(t, leftPath, "name: before\nentries: {}\n")
|
||||
writeConfiguration(t, rightPath, "name: after\nentries:\n added: null\n")
|
||||
|
||||
output, err := consumers.CompareFiles(leftPath, rightPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "/entries/added: left=<absent> right=\"null\\n\"\n" +
|
||||
"/name: left=\"before\\n\" right=\"after\\n\"\n"
|
||||
if output != want {
|
||||
t.Fatalf("comparison output = %q, want %q", output, want)
|
||||
}
|
||||
if again, err := consumers.CompareFiles(leftPath, rightPath); err != nil || again != output {
|
||||
t.Fatalf("repeat output = %q, %v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareFilesReturnsEachLoadError(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
leftPath := filepath.Join(directory, "before.yml")
|
||||
rightPath := filepath.Join(directory, "after.yml")
|
||||
writeConfiguration(t, leftPath, "name: before\n")
|
||||
writeConfiguration(t, rightPath, "unknown: invalid\n")
|
||||
|
||||
output, err := consumers.CompareFiles(leftPath, rightPath)
|
||||
if err == nil || output != "" || !strings.Contains(err.Error(), "load right configuration") {
|
||||
t.Fatalf("right load failure = %q, %v", output, err)
|
||||
}
|
||||
if _, err := consumers.CompareFiles(filepath.Join(directory, "missing.yml"), rightPath); err == nil || !strings.Contains(err.Error(), "load left configuration") {
|
||||
t.Fatalf("left load failure = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareRolloutOverlayShowsResolvedSafeChangesAndRetainsSiblings(t *testing.T) {
|
||||
t.Setenv("CONFIGKIT_DIFF_BASE_TOKEN", "disposable-base-credential")
|
||||
t.Setenv("CONFIGKIT_DIFF_OVERLAY_TOKEN", "disposable-overlay-credential")
|
||||
directory := t.TempDir()
|
||||
basePath := filepath.Join(directory, "config.yml")
|
||||
overlayPath := filepath.Join(directory, "overlays", "canary.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeConfiguration(t, basePath, "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: CONFIGKIT_DIFF_BASE_TOKEN\n")
|
||||
writeConfiguration(t, overlayPath, "service:\n endpoint:\n host: canary.example\n credential:\n token: CONFIGKIT_DIFF_OVERLAY_TOKEN\n")
|
||||
|
||||
base, err := consumers.LoadRollout(basePath, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withOverlay, err := consumers.LoadRollout(basePath, "canary")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if base.Config.Service.Endpoint.Port != 8443 || withOverlay.Config.Service.Endpoint.Port != 8443 {
|
||||
t.Fatalf("lower-priority port was not retained: base=%#v overlay=%#v", base.Config.Service.Endpoint, withOverlay.Config.Service.Endpoint)
|
||||
}
|
||||
urlInfo, found := withOverlay.Metadata().Lookup(configkit.Keys("service", "endpoint", "url"))
|
||||
if !found || urlInfo.Origin.Layer != configkit.LayerResolution || withOverlay.Config.Service.Endpoint.URL != "https://canary.example:8443" {
|
||||
t.Fatalf("resolved endpoint = %#v, metadata=%#v, %v", withOverlay.Config.Service.Endpoint, urlInfo, found)
|
||||
}
|
||||
|
||||
differences, err := configkit.Diff(base, withOverlay)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(differences) != 3 {
|
||||
t.Fatalf("differences = %#v", differences)
|
||||
}
|
||||
wantPaths := []string{"/service/credential/token", "/service/endpoint/host", "/service/endpoint/url"}
|
||||
for index, difference := range differences {
|
||||
if got := difference.Path.String(); got != wantPaths[index] {
|
||||
t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index])
|
||||
}
|
||||
}
|
||||
secret := differences[0]
|
||||
if !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged || secret.Left.YAML != "'[REDACTED]'\n" || secret.Right.YAML != "'[REDACTED]'\n" {
|
||||
t.Fatalf("classified credential difference = %#v", secret)
|
||||
}
|
||||
if strings.Contains(consumers.FormatDifferences(differences), "disposable-") {
|
||||
t.Fatal("formatted differences exposed credential contents")
|
||||
}
|
||||
|
||||
output, err := consumers.CompareRolloutOverlay(basePath, "canary")
|
||||
if err != nil || output != consumers.FormatDifferences(differences) {
|
||||
t.Fatalf("formatted base/overlay comparison = %q, %v", output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDifferencesUsesRecordMembershipForNoChange(t *testing.T) {
|
||||
if got := consumers.FormatDifferences(nil); got != "No effective changes.\n" {
|
||||
t.Fatalf("no-change output = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfiguration(t *testing.T, filename, contents string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user