Add opt-in configuration overrides

This commit is contained in:
2026-11-07 04:39:34 +00:00
parent 85ae55bf42
commit aeb70e5873
22 changed files with 238 additions and 35 deletions

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -66,7 +67,7 @@ func TestFromConfigKitCompareReturnsNativeChangesAndLoadsEqualSelections(t *test
if err != nil {
t.Fatal(err)
}
if len(calls) != 2 || calls[0].selection != selection || calls[1].selection != selection {
if len(calls) != 2 || !reflect.DeepEqual(calls[0].selection, selection) || !reflect.DeepEqual(calls[1].selection, selection) {
t.Fatalf("loader calls = %#v", calls)
}
if calls[0].input.Label != "before" || calls[1].input.Label != "after" || calls[0].state != state || calls[1].state != state || *state != 2 {

View File

@@ -3,6 +3,7 @@ package ckconfig
import (
"context"
"io"
"reflect"
"strings"
"testing"
@@ -75,7 +76,7 @@ func TestComparisonPreparation(t *testing.T) {
}
if want := (Selection{
Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true},
}); input.against != want {
}); !reflect.DeepEqual(input.against, want) {
t.Fatalf("resolved right selection = %#v, want %#v", input.against, want)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"io"
"reflect"
"strconv"
"strings"
"testing"
@@ -106,7 +107,7 @@ func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T)
Config: commandkit.Supplied[string]{Value: "same.yml", Set: true},
Overlay: commandkit.Supplied[string]{Set: true},
}
if len(selections) != 2 || selections[0] != wantSelection || selections[1] != wantSelection {
if len(selections) != 2 || !reflect.DeepEqual(selections[0], wantSelection) || !reflect.DeepEqual(selections[1], wantSelection) {
t.Fatalf("side selections = %#v, want equal %#v values", selections, wantSelection)
}
}
@@ -115,7 +116,7 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
type input struct{ ID int }
type state struct{ Label string }
inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0
checked := make(map[Selection]input)
var checked []input
shared := &state{Label: "shared"}
var request ComparisonRequest[input]
app := newConfigTestApp(t, Command(Suite[input, *state]{
@@ -135,7 +136,7 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
if scope != "app" {
return errors.New("unexpected scope")
}
checked[selection] = value
checked = append(checked, value)
return nil
},
Scopes: []Scope[input, *state]{{
@@ -162,10 +163,10 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
}
left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}
right := Selection{Overlay: commandkit.Supplied[string]{Set: true}}
if request.Left.Selection != left || request.Right.Selection != right {
if !reflect.DeepEqual(request.Left.Selection, left) || !reflect.DeepEqual(request.Right.Selection, right) {
t.Fatalf("provider selections = %#v", request)
}
if len(checked) != 2 || request.Left.Input != checked[left] || request.Right.Input != checked[right] {
if len(checked) != 2 || request.Left.Input != checked[0] || request.Right.Input != checked[1] {
t.Fatalf("provider inputs = %#v, checked = %#v", request, checked)
}
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 {

View File

@@ -15,6 +15,8 @@ type Selection struct {
Config commandkit.Supplied[string]
// Overlay is the caller-supplied overlay path, if present.
Overlay commandkit.Supplied[string]
// Overrides contains ordered CLI override records, or nil when none were supplied.
Overrides []configkit.Override
}
// Operation identifies a supported standard configuration operation.

View File

@@ -92,7 +92,7 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if gotContext != ctx || gotSelection != selection || gotInput.Label != "typed" || gotState != stateValue || stateValue.Calls != 1 {
if gotContext != ctx || !reflect.DeepEqual(gotSelection, selection) || gotInput.Label != "typed" || gotState != stateValue || stateValue.Calls != 1 {
t.Fatalf("loader arguments/context/state = %v, %#v, %#v, %p, %d", gotContext, gotSelection, gotInput, gotState, stateValue.Calls)
}
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result || acquired.Value.Metadata == nil || acquired.Value.Findings == nil {

View File

@@ -63,7 +63,7 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf
}
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Explain,
Selection: plan.selection,
Selection: selected.selection,
}, selected.input, state)
queueConfigAcquisition(reporter, acquired)
if acquireErr != nil {

View File

@@ -44,7 +44,7 @@ func TestComparisonSidesKeepIndependentSelectionsAndDefaults(t *testing.T) {
}
for _, test := range tests {
result := app.Execute(context.Background(), append([]string{"config", "diff"}, test.args...), commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || pair.Left.Selection != test.left || pair.Right.Selection != test.right || pair.Left.Input.Label != test.leftLabel || pair.Right.Input.Label != test.rightLabel || !reflect.DeepEqual(pair.Left.Input.Tags, test.leftTags) || !reflect.DeepEqual(pair.Right.Input.Tags, test.rightTags) {
if result.Kind != commandkit.OutcomeSuccess || !reflect.DeepEqual(pair.Left.Selection, test.left) || !reflect.DeepEqual(pair.Right.Selection, test.right) || pair.Left.Input.Label != test.leftLabel || pair.Right.Input.Label != test.rightLabel || !reflect.DeepEqual(pair.Left.Input.Tags, test.leftTags) || !reflect.DeepEqual(pair.Right.Input.Tags, test.rightTags) {
t.Fatalf("args=%v outcome=%#v pair=%#v", test.args, result, pair)
}
}

View File

@@ -19,8 +19,9 @@ type configCommandInput[I, S any] struct {
}
type selectedScopeInput[I, S any] struct {
scope *preparedScope[I, S]
input I
scope *preparedScope[I, S]
input I
selection Selection
}
type preparedComparison[I, S any] struct {
@@ -31,7 +32,6 @@ type preparedComparison[I, S any] struct {
type configOperationPlan[I, S any] struct {
prepared *preparedSuite[I, S]
operation Operation
selection Selection
scopeName string
path configkit.Path
scopes []selectedScopeInput[I, S]
@@ -76,6 +76,18 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
}),
)
}
if supportsOverrides(prepared, operation) {
flags = append(flags, commandkit.RepeatFlag(commandkit.FlagMeta{Name: "set", Usage: "Override POINTER=JSON (pointer keys cannot contain =)."}, []configkit.Override(nil), overrideParser("--set"),
func(input **configCommandInput[I, S], value commandkit.Supplied[[]configkit.Override]) {
(*input).selection.Overrides = value.Value
}))
if operation == Diff {
flags = append(flags, commandkit.RepeatFlag(commandkit.FlagMeta{Name: "against-set", Usage: "Right-side override POINTER=JSON (pointer keys cannot contain =)."}, []configkit.Override(nil), overrideParser("--against-set"),
func(input **configCommandInput[I, S], value commandkit.Supplied[[]configkit.Override]) {
(*input).against.Overrides = value.Value
}))
}
}
if operation == Explain {
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain.", Required: true}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }))
@@ -155,6 +167,9 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
if !containsOperation(scope.scope.Operations, operation) {
return fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.scope.Name, operation, joinOperations(scope.scope.Operations))
}
if (len(input.selection.Overrides) > 0 || len(input.against.Overrides) > 0) && !scope.scope.Features.Overrides {
return fmt.Errorf("configuration scope %q does not support overrides", scope.scope.Name)
}
}
var path configkit.Path
@@ -172,13 +187,14 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
plan := &configOperationPlan[I, S]{
prepared: prepared,
operation: operation,
selection: input.selection,
scopeName: scopeName,
path: path,
scopes: make([]selectedScopeInput[I, S], 0, len(selected)),
}
if operation == Diff {
scope := selected[0]
input.selection = cloneSelection(input.selection)
input.against = cloneSelection(input.against)
left, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, input.selection, true)
if err != nil {
return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err)
@@ -198,11 +214,12 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
return nil
}
for _, scope := range selected {
value, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, input.selection, false)
selection := cloneSelection(input.selection)
value, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, selection, false)
if err != nil {
return fmt.Errorf("configuration scope %q: %w", scope.scope.Name, err)
}
plan.scopes = append(plan.scopes, selectedScopeInput[I, S]{scope: scope, input: value})
plan.scopes = append(plan.scopes, selectedScopeInput[I, S]{scope: scope, input: value, selection: selection})
}
input.plan = plan
return nil

View File

@@ -0,0 +1,65 @@
package ckconfig
import (
"fmt"
"strings"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func supportsOverrides[I, S any](prepared *preparedSuite[I, S], operation Operation) bool {
for _, scope := range prepared.scopes {
if scope.scope.Features.Overrides && containsOperation(scope.scope.Operations, operation) {
return true
}
}
return false
}
func overrideParser(flag string) func(string) (configkit.Override, error) {
return func(raw string) (configkit.Override, error) {
pointer, jsonText, ok := strings.Cut(raw, "=")
if !ok {
return configkit.Override{}, fmt.Errorf("override requires POINTER=JSON")
}
path, err := configkit.ParsePath(pointer)
if err != nil {
return configkit.Override{}, fmt.Errorf("invalid override path: %w", err)
}
value, err := commandkit.ParseCLIJSON(jsonText)
if err != nil {
return configkit.Override{}, fmt.Errorf("invalid override JSON: %w", err)
}
return configkit.Override{Path: path, Value: value, Flag: flag}, nil
}
}
func cloneSelection(selection Selection) Selection {
if selection.Overrides != nil {
selection.Overrides = append([]configkit.Override(nil), selection.Overrides...)
for index := range selection.Overrides {
selection.Overrides[index].Value = cloneCLIValue(selection.Overrides[index].Value)
}
}
return selection
}
func cloneCLIValue(value any) any {
switch value := value.(type) {
case []any:
copy := make([]any, len(value))
for index, element := range value {
copy[index] = cloneCLIValue(element)
}
return copy
case map[string]any:
copy := make(map[string]any, len(value))
for key, element := range value {
copy[key] = cloneCLIValue(element)
}
return copy
default:
return value
}
}

View File

@@ -0,0 +1,94 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"io"
"reflect"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestOverrideAssignmentsAreCopiedPerScopeAndPreservedInOrder(t *testing.T) {
var received []Selection
makeScope := func(name string) Scope[struct{}, struct{}] {
return Scope[struct{}, struct{}]{Name: name, Operations: []Operation{Validate}, Features: ScopeFeatures{Overrides: true}, Acquire: func(_ context.Context, req Request, _, _ struct{}) (commandkit.Acquired[View], error) {
received = append(received, cloneSelection(req.Selection))
if name == "app" {
req.Selection.Overrides[0].Value.(map[string]any)["numbers"].([]any)[0] = int64(999)
}
return commandkit.Acquired[View]{}, nil
}}
}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{makeScope("app"), makeScope("other")}}))
result := app.Execute(context.Background(), []string{"config", "validate", "--scope=all", "--set=/nested={\"numbers\":[1,2.5],\"v\":\"a=b\"}", "--set=/nested=null", "--set=/false=false", "--set=/zero=0", "--set=/empty=\"\""}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || len(received) != 2 || !reflect.DeepEqual(received[0].Overrides, received[1].Overrides) || len(received[0].Overrides) != 5 {
t.Fatalf("result=%#v selections=%#v", result, received)
}
first := received[1].Overrides
object, ok := first[0].Value.(map[string]any)
if !ok || !reflect.DeepEqual(object["numbers"], []any{int64(1), float64(2.5)}) || object["v"] != "a=b" || first[0].Flag != "--set" || first[1].Path.String() != "/nested" || first[1].Value != nil || first[2].Value != false || first[3].Value != int64(0) || first[4].Value != "" {
t.Fatalf("records=%#v", first)
}
}
func TestOverridePreflightAndComparisonSides(t *testing.T) {
calls, factories := 0, 0
var pair ComparisonRequest[struct{}]
supported := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Validate, Diff}, Features: ScopeFeatures{Overrides: true}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}, Compare: func(_ context.Context, value ComparisonRequest[struct{}], _ struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
calls++
pair = value
return commandkit.Acquired[[]configkit.Difference]{}, nil
}}
unsupported := Scope[struct{}, struct{}]{Name: "other", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { factories++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{supported, unsupported}}))
for _, args := range [][]string{{"config", "validate", "--scope=other", "--set=/x=1"}, {"config", "validate", "--scope=all", "--set=/x=1"}, {"config", "validate", "--set=/bad"}, {"config", "validate", "--set=/bad={"}, {"config", "validate", "--set=bad=1"}} {
result := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeUsage || calls != 0 || factories != 0 {
t.Fatalf("preflight %v: %#v, calls=%d factories=%d", args, result, calls, factories)
}
}
result := app.Execute(context.Background(), []string{"config", "diff", "--set=/x=1", "--against-set=/x=2", "--against-set=/y=[]"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || len(pair.Left.Selection.Overrides) != 1 || len(pair.Right.Selection.Overrides) != 2 || pair.Left.Selection.Overrides[0].Flag != "--set" || pair.Right.Selection.Overrides[0].Flag != "--against-set" || pair.Left.Selection.Overrides[0].Value != int64(1) || pair.Right.Selection.Overrides[0].Value != int64(2) {
t.Fatalf("pair=%#v outcome=%#v", pair, result)
}
result = app.Execute(context.Background(), []string{"config", "diff", "--set=/x=1"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || pair.Right.Selection.Overrides != nil {
t.Fatalf("omitted right = %#v", pair.Right.Selection)
}
without := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{unsupported}}))
if result := without.Execute(context.Background(), []string{"config", "validate", "--set=/x=1"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); result.Kind != commandkit.OutcomeUsage {
t.Fatalf("unavailable flag: %#v", result)
}
}
func TestOverrideNativeLoadErrorAndProvenance(t *testing.T) {
type settings struct {
Name string `yaml:"name"`
}
scope := FromConfigKit("app", "", func(_ context.Context, selection Selection, _, _ struct{}) (*configkit.Result[settings], error) {
return configkit.Load(configkit.Mapping("input", map[string]any{"name": "before"}), configkit.Options[settings]{Overrides: selection.Overrides})
})
scope.Features.Overrides = true
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
var out bytes.Buffer
result := app.Execute(context.Background(), []string{"config", "explain", "--path=/name", "--set=/name=\"after\""}, commandkit.Streams{Out: &out, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), "--set") {
t.Fatalf("provenance: %#v %q", result, out.String())
}
result = app.Execute(context.Background(), []string{"config", "validate", "--set==1"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
var loadErr *configkit.LoadError
if result.Kind != commandkit.OutcomeOperational || !errors.As(result.Cause, &loadErr) {
t.Fatalf("native invalid root override: %#v", result)
}
}

View File

@@ -23,7 +23,7 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera
if selected.scope.scope.Acquire == nil {
return commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name))
}
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Show, Selection: plan.selection}, selected.input, state)
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Show, Selection: selected.selection}, selected.input, state)
if acquireErr != nil {
return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr)
}

View File

@@ -66,7 +66,7 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
}
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Sources,
Selection: plan.selection,
Selection: selected.selection,
}, selected.input, state)
queueConfigAcquisition(reporter, acquired)
if acquireErr != nil {

View File

@@ -33,7 +33,7 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi
outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name))
break
}
acquired, err := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Validate, Selection: plan.selection}, selected.input, state)
acquired, err := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Validate, Selection: selected.selection}, selected.input, state)
if err != nil {
status := "failed"
if invocation.Context.Err() != nil {