Execute configuration comparisons
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
43
integrations/ckconfig/diff.go
Normal file
43
integrations/ckconfig/diff.go
Normal 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
|
||||
}
|
||||
256
integrations/ckconfig/diff_execution_test.go
Normal file
256
integrations/ckconfig/diff_execution_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user