Execute configuration comparisons

This commit is contained in:
2026-10-11 21:51:54 +00:00
parent 783f17f274
commit 18ee987a88
4 changed files with 316 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
# Implement ConfigKit adoption and configuration comparison
Status: implementation in progress; Stages 1–7 are complete.
Status: implementation in progress; Stages 1–8 are complete.
## Scope and authority
@@ -404,12 +404,20 @@ the writer error and the existing incomplete-report indication. Diagnostic
delivery itself continues through core, including its established writer-failure
handling. Do not manually write the same diagnostics from the handler.
**Completion evidence:** default/explicit YAML, text rejection, file/overlay
selection inheritance, comparison-only scope, discovery/help availability, and
unsupported scopes with zero provider work. Cover provider diagnostics with an
error, wrapped identity, report preparation failure, cancellation, output/short
writes, and repeated invocations with fresh inputs/state. Existing four operations
and their output defaults must continue to work.
**Completion evidence — complete:** the installed `config diff` dispatches through
the prepared comparison pair and its provider once, never through ordinary
acquisition. Execution preserves provider diagnostics and causes, checks
cancellation before writing, renders the full report before delivery, and uses
the existing operational/delivery outcomes. Public core/Cobra execution tests
cover default and explicit YAML, text rejection before provider work, right-side
configuration with inherited overlay selection, comparison-only discovery and
execution, and unsupported scopes before factories or providers. They also cover
provider errors with diagnostics and wrapped identity, malformed report snapshots,
cancellation after acquisition, output errors and short writes, and repeated
invocations with distinct side inputs and fresh shared state. A real ConfigKit
consumer comparison covers concealed changes and empty results. Existing
operations remain covered by the ckconfig suite. `go test -count=1 ./...` passes
in ckconfig and ckp.
## Stage 9: Demonstrate the simple consumer comparison

View File

@@ -0,0 +1,43 @@
package ckconfig
import (
"errors"
"fmt"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
if plan.comparison == nil || plan.comparison.scope == nil {
return configOperationFailure(errors.New("configuration comparison was not prepared"), nil, false)
}
selected := plan.comparison
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, nil, false)
}
compare := selected.scope.scope.Compare
if compare == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name), nil, false)
}
acquired, err := compare(invocation.Context, selected.request, state)
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
if err != nil {
return configOperationFailure(err, diagnostics, false)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
}
report, err := renderDiffReport(acquired.Value)
if err != nil {
return configOperationFailure(err, diagnostics, false)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
}
if err := writeConfigReport(invocation.Streams.Out, report); err != nil {
return configDeliveryFailure(err, diagnostics, true)
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
}

View File

@@ -0,0 +1,256 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *testing.T) {
type settings struct {
Greeting string `yaml:"greeting"`
}
var selections []Selection
app := newConfigTestApp(t, AppCommand(func(_ context.Context, selection Selection) (*configkit.Result[settings], error) {
selections = append(selections, selection)
value := "Hello"
if selection.Config.Set && selection.Config.Value == "after.yml" {
value = "Goodbye"
}
return configkit.Load(configkit.Mapping("selected configuration", map[string]any{"greeting": value}), configkit.Options[settings]{})
}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before.yml", "--overlay", "base", "--against-config", "after.yml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("default YAML comparison outcome = %#v", outcome)
}
if len(selections) != 2 || !selections[0].Config.Set || selections[0].Config.Value != "before.yml" || !selections[1].Config.Set || selections[1].Config.Value != "after.yml" || selections[1].Overlay != selections[0].Overlay {
t.Fatalf("comparison selections = %#v", selections)
}
var document yaml.Node
if err := yaml.Unmarshal(output.Bytes(), &document); err != nil {
t.Fatalf("default report is not YAML: %q: %v", output.String(), err)
}
changes := reportField(document.Content[0], "changes")
if value := reportField(changes, "greeting"); value == nil || value.Value != "Goodbye" {
t.Fatalf("public comparison output = %#v", document.Content[0])
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "yaml", "--config", "before.yml", "--against-config", "before.yml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("explicit YAML comparison outcome = %#v", outcome)
}
var equal yaml.Node
if err := yaml.Unmarshal(output.Bytes(), &equal); err != nil {
t.Fatalf("explicit YAML report is not YAML: %q: %v", output.String(), err)
}
if len(reportField(equal.Content[0], "additions").Content) != 0 || len(reportField(equal.Content[0], "changes").Content) != 0 || len(reportField(equal.Content[0], "deletions").Content) != 0 {
t.Fatalf("equal comparison did not return empty categories: %q", output.String())
}
loadsBeforeText := len(selections)
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "text"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || len(selections) != loadsBeforeText || output.Len() != 0 {
t.Fatalf("text format outcome/work/output = %#v / %d / %q", outcome, len(selections)-loadsBeforeText, output.String())
}
}
func TestConfigDiffRunsComparisonOnlyScopeAndRejectsUnsupportedScopeBeforeWork(t *testing.T) {
var inputCalls, stateCalls, acquireCalls, compareCalls int
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputCalls++; return struct{}{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Scopes: []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
compareCalls++
return commandkit.Acquired[[]configkit.Difference]{}, nil
}},
{Name: "plain", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}},
},
}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "app: diff") {
t.Fatalf("comparison-only discovery = %#v, %q", outcome, output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "app"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || compareCalls != 1 {
t.Fatalf("comparison-only execution = %#v calls=%d", outcome, compareCalls)
}
inputsBefore, statesBefore := inputCalls, stateCalls
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "plain"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || inputCalls != inputsBefore || stateCalls != statesBefore || acquireCalls != 0 || compareCalls != 1 || output.Len() != 0 {
t.Fatalf("unsupported scope outcome/work/output = %#v / %d,%d,%d,%d / %q", outcome, inputCalls, stateCalls, acquireCalls, compareCalls, output.String())
}
}
func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) {
wantErr := errors.New("provider unavailable")
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "provider warning\n")
return err
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, fmt.Errorf("comparison setup: %w", wantErr)
},
}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityError {
t.Fatalf("provider failure outcome/output = %#v / %q", outcome, output.String())
}
}
func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{Value: []configkit.Difference{{
Path: configkit.Keys("invalid"), Right: configkit.ValueSnapshot{Present: true, YAML: "bad: ["},
}}}, nil
},
}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 {
t.Fatalf("report preparation failure outcome/output = %#v / %q", outcome, output.String())
}
}
func TestConfigDiffCancellationAfterAcquisitionRetainsDiagnosticsWithoutWriting(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(io.Writer) error { return nil }}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
cancel()
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
},
}}}))
defer cancel()
var output bytes.Buffer
outcome := app.Execute(ctx, []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || outcome.ReportIncomplete || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("cancelled comparison outcome/output = %#v / %q", outcome, output.String())
}
}
func TestConfigDiffOutputErrorsCarryIncompleteDeliveryOutcome(t *testing.T) {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}}}))
wantErr := errors.New("output failed")
for _, test := range []struct {
name string
writer io.Writer
want error
}{
{name: "writer error", writer: failingOutput{err: wantErr}, want: wantErr},
{name: "short write", writer: shortWriter{}, want: io.ErrShortWrite},
} {
t.Run(test.name, func(t *testing.T) {
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, test.want) || !errors.Is(outcome.Cause, test.want) || !outcome.ReportIncomplete {
t.Fatalf("delivery failure outcome = %#v", outcome)
}
})
}
}
func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
type input struct {
ID int
Label string
}
type state struct{ ID int }
label := commandkit.ValueFlag(commandkit.FlagMeta{Name: "label"}, (*string)(nil), parseConfigString,
func(target *input, value commandkit.Supplied[string]) { target.Label = value.Value })
var inputID, stateID int
var observed []struct {
left, right input
state *state
}
app := newConfigTestApp(t, Command(Suite[input, *state]{
NewInput: func() input { inputID++; return input{ID: inputID} },
NewState: func() *state { stateID++; return &state{ID: stateID} },
Selectors: []Selector[input]{{Binding: label, Scopes: []string{"app"}}},
Scopes: []Scope[input, *state]{{Name: "app", Operations: []Operation{Diff}, Compare: func(_ context.Context, request ComparisonRequest[input], shared *state) (commandkit.Acquired[[]configkit.Difference], error) {
observed = append(observed, struct {
left, right input
state *state
}{request.Left.Input, request.Right.Input, shared})
left, err := configkit.Load[struct {
Label string `yaml:"label"`
}](configkit.Mapping("left", map[string]any{"label": request.Left.Input.Label}))
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{}, err
}
right, err := configkit.Load[struct {
Label string `yaml:"label"`
}](configkit.Mapping("right", map[string]any{"label": request.Right.Input.Label + "-right"}))
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{}, err
}
differences, err := configkit.Diff(left, right)
return commandkit.Acquired[[]configkit.Difference]{Value: differences}, err
}}},
}))
for range 2 {
outcome := app.Execute(context.Background(), []string{"config", "diff", "--label", "current"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("comparison invocation outcome = %#v", outcome)
}
}
if len(observed) != 2 || observed[0].left.ID == observed[0].right.ID || observed[1].left.ID == observed[1].right.ID || observed[0].left.ID == observed[1].left.ID || observed[0].right.ID == observed[1].right.ID {
t.Fatalf("input instances were reused: %#v", observed)
}
if observed[0].left.Label != "current" || observed[0].right.Label != "current" || observed[1].left.Label != "current" || observed[1].right.Label != "current" {
t.Fatalf("comparison inputs missed captured flag values: %#v", observed)
}
if observed[0].state == nil || observed[1].state == nil || observed[0].state == observed[1].state || observed[0].state.ID != 1 || observed[1].state.ID != 2 {
t.Fatalf("state was not fresh per invocation: %#v", observed)
}
}
func TestConfigDiffExecutesConcealedChangesAndEmptyResults(t *testing.T) {
type settings struct {
Secret string `yaml:"secret"`
}
app := newConfigTestApp(t, AppCommand(func(_ context.Context, selection Selection) (*configkit.Result[settings], error) {
value := "same"
if selection.Config.Set && selection.Config.Value == "after" {
value = "changed-secret"
}
return configkit.Load(configkit.Mapping("selected", map[string]any{"secret": value}), configkit.Options[settings]{Definition: configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}})
}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "after"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "[REDACTED]") || strings.Contains(output.String(), "changed-secret") {
t.Fatalf("concealed comparison outcome/report = %#v / %q", outcome, output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "before"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "additions:") || !strings.Contains(output.String(), "changes:") || !strings.Contains(output.String(), "deletions:") {
t.Fatalf("empty comparison outcome/report = %#v / %q", outcome, output.String())
}
}

View File

@@ -28,6 +28,8 @@ func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Op
return runConfigSources(invocation, plan, state)
case Explain:
return runConfigExplanation(invocation, plan, state)
case Diff:
return runConfigDiff(invocation, plan, state)
default:
return commandkit.Failure(fmt.Errorf("unsupported configuration operation %q", operation))
}