Validate final registered sequence identities
This commit is contained in:
51
internal/engine/final_identity.go
Normal file
51
internal/engine/final_identity.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
func validateFinalSequenceIdentities(root *document.Node, policy sequencePolicy) []Diagnostic {
|
||||
if root == nil || policy.selectorTrie == nil {
|
||||
return nil
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
var visit func(*document.Node, Path, []*sequenceSelectorTrie)
|
||||
visit = func(node *document.Node, path Path, states []*sequenceSelectorTrie) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Kind == document.Sequence {
|
||||
var fields []string
|
||||
for _, state := range states {
|
||||
if state.terminal {
|
||||
fields = state.fields
|
||||
break
|
||||
}
|
||||
}
|
||||
if fields != nil {
|
||||
_, issues := validateNormalizedSequenceIdentityFrom(node, path, fields, sequenceIdentityDiagnosticCodes{
|
||||
invalid: CodeSequenceInvalid, conflict: CodeSequenceConflict,
|
||||
}, 0)
|
||||
diagnostics = append(diagnostics, issues...)
|
||||
}
|
||||
return
|
||||
}
|
||||
if node.Kind != document.Mapping {
|
||||
return
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
next := make([]*sequenceSelectorTrie, 0, len(states)*2)
|
||||
for _, state := range states {
|
||||
if literal := state.literals[entry.Key]; literal != nil {
|
||||
next = append(next, literal)
|
||||
}
|
||||
if state.wildcard != nil {
|
||||
next = append(next, state.wildcard)
|
||||
}
|
||||
}
|
||||
if len(next) != 0 {
|
||||
visit(entry.Value, path.Key(entry.Key), next)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(root, Path{}, []*sequenceSelectorTrie{policy.selectorTrie})
|
||||
return canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
@@ -331,6 +331,11 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
if hasErrors(secretResolution.diagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
identityDiagnostics := validateFinalSequenceIdentities(resolved.referenceInput, request.sequencePolicy)
|
||||
accumulator.add(identityDiagnostics...)
|
||||
if hasErrors(identityDiagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
if request.definition.Validate != nil {
|
||||
diagnostics = runValidation(&configuration, request.definition.Validate, schema, resolved.final, resolved.referenceInput, secretResolution.lookups)
|
||||
accumulator.add(diagnostics...)
|
||||
|
||||
@@ -388,7 +388,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replacement overlay validates identities and CLI may introduce duplicates", func(t *testing.T) {
|
||||
t.Run("replacement overlay and CLI validate final identities", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "items: [{id: base}]\n")
|
||||
writeImportFile(t, directory, "overlays/testing.yml", "items: [{id: duplicate}, {id: duplicate}]\n")
|
||||
@@ -401,8 +401,8 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
|
||||
Definition: definition,
|
||||
Overrides: []Override{{Path: Keys("items"), Flag: "--items", Value: []any{map[string]any{"id": "duplicate"}, map[string]any{"id": "duplicate"}}}},
|
||||
})
|
||||
if err != nil || len(result.Config.Items) != 2 {
|
||||
t.Fatalf("replacement CLI value = %#v, %v", result, err)
|
||||
if result != nil || loadCode(t, err) != CodeSequenceConflict {
|
||||
t.Fatalf("duplicate CLI value = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
resolved := definition
|
||||
@@ -412,31 +412,23 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
result, err = testLoad[config](Bytes("main", []byte("items: [{id: first}, {id: second}]\n")), Options[config]{Definition: resolved})
|
||||
if err != nil {
|
||||
t.Fatalf("resolver replacement error code = %s", loadCode(t, err))
|
||||
}
|
||||
if len(result.Config.Items) != 2 {
|
||||
t.Fatalf("resolver replacement = %#v, %v", result, err)
|
||||
if result != nil || loadCode(t, err) != CodeSequenceConflict {
|
||||
t.Fatalf("resolver duplicate = %#v, %v", result, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("application validation can reject replacement duplicates", func(t *testing.T) {
|
||||
t.Run("final identity validation precedes application validation", func(t *testing.T) {
|
||||
checked := definition
|
||||
validated := false
|
||||
checked.Validate = func(value *config, validation *Validation) {
|
||||
seen := make(map[string]bool)
|
||||
for index, entry := range value.Items {
|
||||
if seen[entry.ID] {
|
||||
validation.Error(Keys("items").Index(index).Key("id"), "identity must be unique")
|
||||
}
|
||||
seen[entry.ID] = true
|
||||
}
|
||||
validated = true
|
||||
}
|
||||
_, err := testLoad[config](Bytes("main", []byte("items: [{id: base}]\n")), Options[config]{
|
||||
Definition: checked,
|
||||
Overrides: []Override{{Path: Keys("items"), Flag: "--items", Value: []any{map[string]any{"id": "duplicate"}, map[string]any{"id": "duplicate"}}}},
|
||||
})
|
||||
if got := loadCode(t, err); got != CodeValidateError {
|
||||
t.Fatalf("application validation error = %s", got)
|
||||
if got := loadCode(t, err); got != CodeSequenceConflict || validated {
|
||||
t.Fatalf("duplicate identity result = %s, validation callback invoked %v", got, validated)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user