Separate config normalization from validation

This commit is contained in:
2026-09-07 03:32:50 +00:00
parent f8e3e651e6
commit 10a2e89965
6 changed files with 202 additions and 71 deletions

View File

@@ -95,6 +95,9 @@ func LoadPipelineProfilePair(path, leftProfile, rightProfile string) (*PipelineC
func finalizeLoadedPipeline(path string, cfg *PipelineConfig) (*PipelineConfig, error) {
cfg.resolution.publishDeclared = cfg.Publish != nil
applyPipelineDefaults(cfg)
if err := NormalizePipelineConfig(cfg); err != nil {
return nil, fmt.Errorf("load pipeline config: %w", err)
}
captureLogicalNotariusPaths(cfg)
if err := resolveNotariusPaths(cfg, path); err != nil {
return nil, fmt.Errorf("load pipeline config: %w", err)

View File

@@ -169,8 +169,11 @@ func TestNotariusReferenceValidationAndNormalization(t *testing.T) {
"spells": "narratio.input.spell_catalog",
}
if err := NormalizePipelineConfig(&PipelineConfig{Notarius: cfg}); err != nil {
t.Fatalf("NormalizePipelineConfig() error = %v", err)
}
if err := validateNotarius(cfg, nil); err != nil {
t.Fatalf("validateNotarius() error = %v", err)
t.Fatalf("validateNotarius() after normalization error = %v", err)
}
want := map[string]string{
"party": "narratio.input.party",
@@ -207,7 +210,7 @@ func TestNotariusReferenceValidationRejectsInvalidBindings(t *testing.T) {
" chunk . party ": "narratio.input.players",
},
wantField: "pipeline.notarius.references",
wantReason: "normalize to",
wantReason: "must be normalized",
},
}
@@ -264,19 +267,17 @@ func TestNotariusValidationFailureDoesNotMutateConfig(t *testing.T) {
{
name: "reference",
mutate: func(cfg *NotariusConfig) {
cfg.Binary = " notarius "
cfg.References = map[string]string{" party ": "unsupported"}
},
want: "pipeline.notarius.references.party",
want: "pipeline.notarius.references selector",
},
{
name: "output",
mutate: func(cfg *NotariusConfig) {
cfg.Binary = " notarius "
cfg.References = map[string]string{" party ": " narratio.input.party "}
cfg.References = map[string]string{"party": "narratio.input.party"}
cfg.Outputs["npc_registry"] = NotariusOutputConfig{LaneID: " missing-schema "}
},
want: "pipeline.notarius.outputs.npc_registry.media_type",
want: "pipeline.notarius.outputs.npc_registry.lane_id",
},
}
@@ -314,8 +315,11 @@ func TestNotariusNormalizationProducesCanonicalValues(t *testing.T) {
},
}
if err := NormalizePipelineConfig(&PipelineConfig{Notarius: cfg}); err != nil {
t.Fatalf("NormalizePipelineConfig() error = %v", err)
}
if err := validateNotarius(cfg, nil); err != nil {
t.Fatalf("validateNotarius() error = %v", err)
t.Fatalf("validateNotarius() after normalization error = %v", err)
}
wantReferences := map[string]string{
"party": "narratio.input.party",
@@ -330,6 +334,24 @@ func TestNotariusNormalizationProducesCanonicalValues(t *testing.T) {
if got := cfg.Outputs["npc_registry"]; got.LaneID != "npc-registry" || got.MediaType != "application/json" || got.SchemaID != "notarius.dnd.npc_registry" || got.SchemaVersion != "v1" {
t.Fatalf("normalized npc_registry output = %#v", got)
}
beforeSecondPass := cloneNotariusConfigForTest(cfg)
if err := NormalizePipelineConfig(&PipelineConfig{Notarius: cfg}); err != nil {
t.Fatalf("second NormalizePipelineConfig() error = %v", err)
}
if !reflect.DeepEqual(cfg, beforeSecondPass) {
t.Fatalf("normalization is not deterministic:\n got: %#v\nwant: %#v", cfg, beforeSecondPass)
}
}
func TestNotariusValidationDoesNotMutateAcceptedConfig(t *testing.T) {
cfg := validNotariusConfig()
before := cloneNotariusConfigForTest(cfg)
if err := validateNotarius(cfg, nil); err != nil {
t.Fatalf("validateNotarius() error = %v", err)
}
if !reflect.DeepEqual(cfg, before) {
t.Fatalf("accepted config mutated during validation:\n got: %#v\nwant: %#v", cfg, before)
}
}
func TestNotariusDiagnosticsUseSortedMapKeys(t *testing.T) {
@@ -421,7 +443,7 @@ func validNotariusConfig() *NotariusConfig {
return &NotariusConfig{
Enabled: true,
Binary: "notarius",
ConfigPath: "./notarius.yml",
ConfigPath: "notarius.yml",
PipelineID: "dnd-session",
Timeout: "45m",
WorkingDirectory: ".",
@@ -497,6 +519,14 @@ func TestNotariusEnabledValidation(t *testing.T) {
pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML+"\n"+tt.section, testSessionBaseYAML)
cfg, err := Load(pipelinePath, sessionPath)
if err != nil {
if len(tt.wantErr) > 0 {
for _, fragment := range tt.wantErr {
if !strings.Contains(err.Error(), fragment) {
t.Fatalf("Load() error = %v, want containing %q", err, fragment)
}
}
return
}
t.Fatalf("Load() error = %v", err)
}
err = Validate(cfg)

View File

@@ -0,0 +1,42 @@
package config
import (
"path/filepath"
"strings"
)
// NormalizePipelineConfig applies reusable domain canonicalization before
// validation. It is deterministic and commits no partial Notarius changes when
// canonicalization fails.
func NormalizePipelineConfig(cfg *PipelineConfig) error {
if cfg == nil || cfg.Notarius == nil || !cfg.Notarius.Enabled {
return nil
}
notarius := *cfg.Notarius
notarius.Binary = strings.TrimSpace(notarius.Binary)
notarius.ConfigPath = cleanNotariusPath(notarius.ConfigPath)
notarius.PipelineID = strings.TrimSpace(notarius.PipelineID)
notarius.Timeout = strings.TrimSpace(notarius.Timeout)
notarius.WorkingDirectory = cleanNotariusPath(notarius.WorkingDirectory)
references, err := normalizeNotariusReferences(notarius.References)
if err != nil {
return err
}
outputs, err := normalizeNotariusOutputs(notarius.Outputs)
if err != nil {
return err
}
notarius.References = references
notarius.Outputs = outputs
*cfg.Notarius = notarius
return nil
}
func cleanNotariusPath(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return filepath.Clean(value)
}

View File

@@ -2,7 +2,6 @@ package config
import (
"fmt"
"path/filepath"
"sort"
"strings"
@@ -16,24 +15,19 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
return nil
}
binary := strings.TrimSpace(cfg.Binary)
configPath := filepath.Clean(cfg.ConfigPath)
pipelineID := strings.TrimSpace(cfg.PipelineID)
timeout := strings.TrimSpace(cfg.Timeout)
workingDirectory := filepath.Clean(cfg.WorkingDirectory)
if binary == "" {
if strings.TrimSpace(cfg.Binary) == "" {
return fmt.Errorf("pipeline.notarius.binary is required when pipeline.notarius.enabled is true")
}
if strings.TrimSpace(cfg.ConfigPath) == "" {
return fmt.Errorf("pipeline.notarius.config_path is required when pipeline.notarius.enabled is true")
}
if pipelineID == "" {
if strings.TrimSpace(cfg.PipelineID) == "" {
return fmt.Errorf("pipeline.notarius.pipeline_id is required when pipeline.notarius.enabled is true")
}
if timeout == "" {
if strings.TrimSpace(cfg.Timeout) == "" {
return fmt.Errorf("pipeline.notarius.timeout must be a valid duration")
}
if err := validateDuration("pipeline.notarius.timeout", timeout); err != nil {
if err := validateDuration("pipeline.notarius.timeout", cfg.Timeout); err != nil {
return err
}
if strings.TrimSpace(cfg.WorkingDirectory) == "" {
@@ -46,22 +40,32 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
return fmt.Errorf("pipeline.notarius.references must contain at most %d bindings", MaxNotariusReferenceBindings)
}
references, err := normalizeNotariusReferences(cfg.References)
if err != nil {
if err := validateNotariusCanonicalScalars(cfg); err != nil {
return err
}
outputs, err := normalizeNotariusOutputs(cfg.Outputs, scriptorium)
if err != nil {
if err := validateNotariusReferences(cfg.References); err != nil {
return err
}
return validateNotariusOutputs(cfg.Outputs, scriptorium)
}
cfg.Binary = binary
cfg.ConfigPath = configPath
cfg.PipelineID = pipelineID
cfg.Timeout = timeout
cfg.WorkingDirectory = workingDirectory
cfg.References = references
cfg.Outputs = outputs
func validateNotariusCanonicalScalars(cfg *NotariusConfig) error {
fields := []struct {
name string
got string
want string
}{
{name: "binary", got: cfg.Binary, want: strings.TrimSpace(cfg.Binary)},
{name: "config_path", got: cfg.ConfigPath, want: cleanNotariusPath(cfg.ConfigPath)},
{name: "pipeline_id", got: cfg.PipelineID, want: strings.TrimSpace(cfg.PipelineID)},
{name: "timeout", got: cfg.Timeout, want: strings.TrimSpace(cfg.Timeout)},
{name: "working_directory", got: cfg.WorkingDirectory, want: cleanNotariusPath(cfg.WorkingDirectory)},
}
for _, field := range fields {
if field.got != field.want {
return fmt.Errorf("pipeline.notarius.%s must be normalized", field.name)
}
}
return nil
}
@@ -88,19 +92,12 @@ func normalizeNotariusReferences(references map[string]string) (map[string]strin
owners[selector] = rawSelector
source := strings.TrimSpace(references[rawSelector])
if source == "" {
return nil, fmt.Errorf("pipeline.notarius.references.%s source is required", selector)
}
if _, ok := artifactpolicy.DescribePreparedInputSource(source); !ok {
return nil, fmt.Errorf("pipeline.notarius.references.%s source %q is not a supported prepared input source", selector, source)
}
normalized[selector] = source
}
return normalized, nil
}
func normalizeNotariusOutputs(outputs map[string]NotariusOutputConfig, scriptorium *ScriptoriumConfig) (map[string]NotariusOutputConfig, error) {
reservedSources := notariusReservedSources(scriptorium)
func normalizeNotariusOutputs(outputs map[string]NotariusOutputConfig) (map[string]NotariusOutputConfig, error) {
keys := make([]string, 0, len(outputs))
for key := range outputs {
keys = append(keys, key)
@@ -109,20 +106,13 @@ func normalizeNotariusOutputs(outputs map[string]NotariusOutputConfig, scriptori
normalized := make(map[string]NotariusOutputConfig, len(outputs))
sourceOwners := make(map[string]string, len(outputs))
laneOwners := make(map[string]string, len(outputs))
for _, rawKey := range keys {
output := outputs[rawKey]
key := strings.TrimSpace(rawKey)
if !artifactpolicy.IsConfiguredKey(key) {
return nil, fmt.Errorf("pipeline.notarius.outputs keys must match ^[a-z][a-z0-9_]*$")
}
sourceID := artifactpolicy.ExtractionSourceID(key)
if previousKey, ok := sourceOwners[sourceID]; ok {
return nil, fmt.Errorf("pipeline.notarius.outputs keys %q and %q normalize to duplicate source %q", previousKey, rawKey, sourceID)
}
if owner, ok := reservedSources[sourceID]; ok {
return nil, fmt.Errorf("pipeline.notarius.outputs.%s source %q collides with %s", key, sourceID, owner)
}
sourceOwners[sourceID] = rawKey
output.LaneID = strings.TrimSpace(output.LaneID)
@@ -130,28 +120,89 @@ func normalizeNotariusOutputs(outputs map[string]NotariusOutputConfig, scriptori
output.SchemaID = strings.TrimSpace(output.SchemaID)
output.SchemaVersion = strings.TrimSpace(output.SchemaVersion)
output.ModuleKey = strings.TrimSpace(output.ModuleKey)
prefix := "pipeline.notarius.outputs." + key
if output.LaneID == "" {
return nil, fmt.Errorf("%s.lane_id is required", prefix)
}
if previousKey, ok := laneOwners[output.LaneID]; ok {
return nil, fmt.Errorf("%s.lane_id %q duplicates pipeline.notarius.outputs.%s.lane_id", prefix, output.LaneID, previousKey)
}
laneOwners[output.LaneID] = key
if output.MediaType == "" {
return nil, fmt.Errorf("%s.media_type is required", prefix)
}
if output.SchemaID == "" {
return nil, fmt.Errorf("%s.schema_id is required", prefix)
}
if output.SchemaVersion == "" {
return nil, fmt.Errorf("%s.schema_version is required", prefix)
}
normalized[key] = output
}
return normalized, nil
}
func validateNotariusReferences(references map[string]string) error {
selectors := make([]string, 0, len(references))
for selector := range references {
selectors = append(selectors, selector)
}
sort.Strings(selectors)
for _, selector := range selectors {
canonical, err := notariusref.NormalizeSelector(selector)
if err != nil {
return fmt.Errorf("pipeline.notarius.references selector %q is invalid: %w", selector, err)
}
if canonical != selector {
return fmt.Errorf("pipeline.notarius.references selector %q must be normalized", selector)
}
source := references[selector]
if strings.TrimSpace(source) == "" {
return fmt.Errorf("pipeline.notarius.references.%s source is required", selector)
}
if source != strings.TrimSpace(source) {
return fmt.Errorf("pipeline.notarius.references.%s source must be normalized", selector)
}
if _, ok := artifactpolicy.DescribePreparedInputSource(source); !ok {
return fmt.Errorf("pipeline.notarius.references.%s source %q is not a supported prepared input source", selector, source)
}
}
return nil
}
func validateNotariusOutputs(outputs map[string]NotariusOutputConfig, scriptorium *ScriptoriumConfig) error {
reservedSources := notariusReservedSources(scriptorium)
keys := make([]string, 0, len(outputs))
for key := range outputs {
keys = append(keys, key)
}
sort.Strings(keys)
laneOwners := make(map[string]string, len(outputs))
for _, key := range keys {
output := outputs[key]
if strings.TrimSpace(key) != key || !artifactpolicy.IsConfiguredKey(key) {
return fmt.Errorf("pipeline.notarius.outputs keys must match ^[a-z][a-z0-9_]*$")
}
sourceID := artifactpolicy.ExtractionSourceID(key)
if owner, ok := reservedSources[sourceID]; ok {
return fmt.Errorf("pipeline.notarius.outputs.%s source %q collides with %s", key, sourceID, owner)
}
prefix := "pipeline.notarius.outputs." + key
fields := []struct{ name, value string }{
{name: "lane_id", value: output.LaneID},
{name: "media_type", value: output.MediaType},
{name: "schema_id", value: output.SchemaID},
{name: "schema_version", value: output.SchemaVersion},
{name: "module_key", value: output.ModuleKey},
}
for _, field := range fields {
if field.value != strings.TrimSpace(field.value) {
return fmt.Errorf("%s.%s must be normalized", prefix, field.name)
}
}
if output.LaneID == "" {
return fmt.Errorf("%s.lane_id is required", prefix)
}
if previousKey, ok := laneOwners[output.LaneID]; ok {
return fmt.Errorf("%s.lane_id %q duplicates pipeline.notarius.outputs.%s.lane_id", prefix, output.LaneID, previousKey)
}
laneOwners[output.LaneID] = key
if output.MediaType == "" {
return fmt.Errorf("%s.media_type is required", prefix)
}
if output.SchemaID == "" {
return fmt.Errorf("%s.schema_id is required", prefix)
}
if output.SchemaVersion == "" {
return fmt.Errorf("%s.schema_version is required", prefix)
}
}
return nil
}
func notariusReservedSources(scriptorium *ScriptoriumConfig) map[string]string {
reserved := make(map[string]string)
for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() {