Add evidence context policy preparation
This commit is contained in:
148
internal/framework/pipeline/artifact_evidence_registry.go
Normal file
148
internal/framework/pipeline/artifact_evidence_registry.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
// ArtifactEvidenceProjector returns the direct source references represented
|
||||
// by one normalized artifact value.
|
||||
type ArtifactEvidenceProjector[T any] func(T) []source.SourceRef
|
||||
|
||||
// ArtifactEvidenceRegistry keeps the typed projection boundary private while
|
||||
// allowing preparation and execution to discover registered capabilities.
|
||||
type ArtifactEvidenceRegistry struct {
|
||||
entries map[contracts.ArtifactKind]artifactEvidenceEntry
|
||||
}
|
||||
|
||||
type artifactEvidenceEntry struct {
|
||||
valueType reflect.Type
|
||||
project func(any) ([]source.SourceRef, error)
|
||||
}
|
||||
|
||||
func NewArtifactEvidenceRegistry() *ArtifactEvidenceRegistry {
|
||||
return &ArtifactEvidenceRegistry{entries: make(map[contracts.ArtifactKind]artifactEvidenceEntry)}
|
||||
}
|
||||
|
||||
// RegisterArtifactEvidence registers one evidence projector for an artifact
|
||||
// kind. Projectors are invoked only after an exact Go-type check.
|
||||
func RegisterArtifactEvidence[T any](registry *ArtifactEvidenceRegistry, kind contracts.ArtifactKind, projector ArtifactEvidenceProjector[T]) error {
|
||||
if registry == nil {
|
||||
return fmt.Errorf("artifact evidence registry must not be nil")
|
||||
}
|
||||
if projector == nil {
|
||||
return fmt.Errorf("artifact evidence projector must not be nil")
|
||||
}
|
||||
kind = normalizeArtifactKind(kind)
|
||||
if kind == "" {
|
||||
return fmt.Errorf("artifact evidence kind must not be empty")
|
||||
}
|
||||
if _, ok := registry.entries[kind]; ok {
|
||||
return fmt.Errorf("artifact evidence %q is already registered", kind)
|
||||
}
|
||||
valueType := reflect.TypeFor[T]()
|
||||
if registry.entries == nil {
|
||||
registry.entries = make(map[contracts.ArtifactKind]artifactEvidenceEntry)
|
||||
}
|
||||
registry.entries[kind] = artifactEvidenceEntry{
|
||||
valueType: valueType,
|
||||
project: func(value any) ([]source.SourceRef, error) {
|
||||
actualType := reflect.TypeOf(value)
|
||||
if actualType != valueType {
|
||||
return nil, newArtifactEvidenceTypeError(kind, valueType, actualType)
|
||||
}
|
||||
typed, ok := value.(T)
|
||||
if !ok {
|
||||
return nil, newArtifactEvidenceTypeError(kind, valueType, actualType)
|
||||
}
|
||||
return append([]source.SourceRef(nil), projector(typed)...), nil
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ArtifactEvidenceRegistry) RegisteredKinds() []contracts.ArtifactKind {
|
||||
if r == nil || len(r.entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
kinds := make([]contracts.ArtifactKind, 0, len(r.entries))
|
||||
for kind := range r.entries {
|
||||
kinds = append(kinds, kind)
|
||||
}
|
||||
sort.Slice(kinds, func(i, j int) bool { return kinds[i] < kinds[j] })
|
||||
return kinds
|
||||
}
|
||||
|
||||
// Project returns independently owned direct references for a registered
|
||||
// artifact value.
|
||||
func (r *ArtifactEvidenceRegistry) Project(kind contracts.ArtifactKind, value any) ([]source.SourceRef, error) {
|
||||
entry, normalizedKind, err := r.entry(kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs, err := entry.project(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("project artifact evidence %q: %w", normalizedKind, err)
|
||||
}
|
||||
return append([]source.SourceRef(nil), refs...), nil
|
||||
}
|
||||
|
||||
func (r *ArtifactEvidenceRegistry) entry(kind contracts.ArtifactKind) (artifactEvidenceEntry, contracts.ArtifactKind, error) {
|
||||
if r == nil {
|
||||
return artifactEvidenceEntry{}, "", fmt.Errorf("artifact evidence registry must not be nil")
|
||||
}
|
||||
kind = normalizeArtifactKind(kind)
|
||||
if kind == "" {
|
||||
return artifactEvidenceEntry{}, "", fmt.Errorf("artifact evidence kind must not be empty")
|
||||
}
|
||||
entry, ok := r.entries[kind]
|
||||
if !ok {
|
||||
return artifactEvidenceEntry{}, kind, fmt.Errorf("artifact evidence %q is not registered", kind)
|
||||
}
|
||||
return entry, kind, nil
|
||||
}
|
||||
|
||||
func (r *ArtifactEvidenceRegistry) valueType(kind contracts.ArtifactKind) (reflect.Type, bool) {
|
||||
if r == nil {
|
||||
return nil, false
|
||||
}
|
||||
entry, ok := r.entries[normalizeArtifactKind(kind)]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return entry.valueType, true
|
||||
}
|
||||
|
||||
func newArtifactEvidenceTypeError(kind contracts.ArtifactKind, expected, actual reflect.Type) error {
|
||||
actualName := "<nil>"
|
||||
if actual != nil {
|
||||
actualName = actual.String()
|
||||
}
|
||||
return fmt.Errorf("project artifact evidence %q: expected exact Go type %s, got %s", kind, expected, actualName)
|
||||
}
|
||||
|
||||
func normalizeEvidenceLaneIDs(values []string) ([]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, fmt.Errorf("evidence lane ids must not be empty")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
lanes := make([]string, 0, len(values))
|
||||
for _, raw := range values {
|
||||
lane := strings.TrimSpace(raw)
|
||||
if lane == "" {
|
||||
return nil, fmt.Errorf("evidence lane id must not be empty")
|
||||
}
|
||||
if _, ok := seen[lane]; ok {
|
||||
return nil, fmt.Errorf("evidence lane id %q is duplicated", lane)
|
||||
}
|
||||
seen[lane] = struct{}{}
|
||||
lanes = append(lanes, lane)
|
||||
}
|
||||
sort.Strings(lanes)
|
||||
return lanes, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestArtifactEvidenceRegistryProjectsExactRegisteredTypesWithOwnedReferences(t *testing.T) {
|
||||
registry := NewArtifactEvidenceRegistry()
|
||||
input := codecNotes{Items: []string{"one"}}
|
||||
refs := []source.SourceRef{{SourceID: "source", StartUnitID: 2, EndUnitID: 2}}
|
||||
if err := RegisterArtifactEvidence(registry, "test/notes", func(codecNotes) []source.SourceRef { return refs }); err != nil {
|
||||
t.Fatalf("RegisterArtifactEvidence() error = %v, want nil", err)
|
||||
}
|
||||
if err := RegisterArtifactEvidence(registry, "test/score", func(codecScore) []source.SourceRef { return nil }); err != nil {
|
||||
t.Fatalf("RegisterArtifactEvidence() second kind error = %v, want nil", err)
|
||||
}
|
||||
if got, want := registry.RegisteredKinds(), []contracts.ArtifactKind{"test/notes", "test/score"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredKinds() = %#v, want %#v", got, want)
|
||||
}
|
||||
projected, err := registry.Project(" test/notes ", input)
|
||||
if err != nil {
|
||||
t.Fatalf("Project() error = %v, want nil", err)
|
||||
}
|
||||
projected[0].StartUnitID = 99
|
||||
if refs[0].StartUnitID != 2 {
|
||||
t.Fatal("Project() retained projector reference storage")
|
||||
}
|
||||
if _, err := registry.Project("test/notes", codecNotesAlias(input)); err == nil || !strings.Contains(err.Error(), "expected exact Go type") {
|
||||
t.Fatalf("Project() exact type error = %v, want exact type failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactEvidenceRegistryRejectsInvalidRegistration(t *testing.T) {
|
||||
registry := NewArtifactEvidenceRegistry()
|
||||
if err := RegisterArtifactEvidence[codecNotes](nil, "test/notes", func(codecNotes) []source.SourceRef { return nil }); err == nil || !strings.Contains(err.Error(), "must not be nil") {
|
||||
t.Fatalf("nil registry error = %v, want failure", err)
|
||||
}
|
||||
if err := RegisterArtifactEvidence(registry, " ", func(codecNotes) []source.SourceRef { return nil }); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("blank kind error = %v, want failure", err)
|
||||
}
|
||||
if err := RegisterArtifactEvidence(registry, "test/notes", ArtifactEvidenceProjector[codecNotes](nil)); err == nil || !strings.Contains(err.Error(), "must not be nil") {
|
||||
t.Fatalf("nil projector error = %v, want failure", err)
|
||||
}
|
||||
if err := RegisterArtifactEvidence(registry, "test/notes", func(codecNotes) []source.SourceRef { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RegisterArtifactEvidence(registry, "test/notes", func(codecNotes) []source.SourceRef { return nil }); err == nil || !strings.Contains(err.Error(), "already registered") {
|
||||
t.Fatalf("duplicate kind error = %v, want failure", err)
|
||||
}
|
||||
}
|
||||
20
internal/framework/pipeline/evidence_policy.go
Normal file
20
internal/framework/pipeline/evidence_policy.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package pipeline
|
||||
|
||||
// EvidenceContextPolicy controls optional source-context publication by an
|
||||
// output encoder.
|
||||
type EvidenceContextPolicy struct {
|
||||
Enabled bool
|
||||
WindowUnits int
|
||||
LaneIDs []string
|
||||
}
|
||||
|
||||
// EvidenceContextPolicyProvider is implemented by output encoders that opt in
|
||||
// to evidence-context publication.
|
||||
type EvidenceContextPolicyProvider interface {
|
||||
EvidenceContextPolicy() EvidenceContextPolicy
|
||||
}
|
||||
|
||||
func cloneEvidenceContextPolicy(policy EvidenceContextPolicy) EvidenceContextPolicy {
|
||||
policy.LaneIDs = append([]string(nil), policy.LaneIDs...)
|
||||
return policy
|
||||
}
|
||||
98
internal/framework/pipeline/evidence_preparation_test.go
Normal file
98
internal/framework/pipeline/evidence_preparation_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
type testEvidenceOutput struct {
|
||||
policy EvidenceContextPolicy
|
||||
}
|
||||
|
||||
func (output testEvidenceOutput) Key() string { return "output" }
|
||||
func (output testEvidenceOutput) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
|
||||
return contracts.OutputResult{}, nil
|
||||
}
|
||||
func (output testEvidenceOutput) EvidenceContextPolicy() EvidenceContextPolicy {
|
||||
return cloneEvidenceContextPolicy(output.policy)
|
||||
}
|
||||
|
||||
func TestPrepareEvidencePlanSelectsActiveLanesAndOwnsPolicy(t *testing.T) {
|
||||
registries, _ := constructionRegistries(t, nil, nil)
|
||||
registries.ArtifactEvidence = NewArtifactEvidenceRegistry()
|
||||
registerTestEvidenceOutput(t, ®istries, EvidenceContextPolicy{Enabled: true, WindowUnits: 2, LaneIDs: []string{"artifact", "inactive"}})
|
||||
if err := RegisterArtifactEvidence(registries.ArtifactEvidence, "test/notes", func(codecNotes) []source.SourceRef { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := constructionProfile()
|
||||
profile.Output.Options = map[string]any{"known": true}
|
||||
resolved, err := ResolvePipeline(profile, ResolveOptions{}, registries.catalog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error = %v, want nil", err)
|
||||
}
|
||||
if prepared.evidencePlan == nil || !reflect.DeepEqual(prepared.evidencePlan.policy.LaneIDs, []string{"artifact", "inactive"}) {
|
||||
t.Fatalf("evidence plan = %#v, want complete configured policy", prepared.evidencePlan)
|
||||
}
|
||||
if got := prepared.evidencePlan.lanes; len(got) != 1 || got[0].laneID != "artifact" {
|
||||
t.Fatalf("active evidence lanes = %#v, want artifact only", got)
|
||||
}
|
||||
prepared.evidencePlan.policy.LaneIDs[0] = "mutated"
|
||||
if policy := prepared.output.(EvidenceContextPolicyProvider).EvidenceContextPolicy(); policy.LaneIDs[0] != "artifact" {
|
||||
t.Fatalf("prepared plan mutated provider policy: %#v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareEvidencePlanRejectsMissingAndMismatchedCapabilities(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
configure func(*Registries)
|
||||
want string
|
||||
}{
|
||||
{name: "missing registry", configure: func(registries *Registries) { registries.ArtifactEvidence = nil }, want: "artifact evidence registry"},
|
||||
{name: "unsupported kind", configure: func(registries *Registries) {}, want: "artifact evidence \"test/notes\" is not registered"},
|
||||
{name: "mismatched type", configure: func(registries *Registries) {
|
||||
if err := RegisterArtifactEvidence(registries.ArtifactEvidence, "test/notes", func(codecScore) []source.SourceRef { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, want: "requires Go type"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
registries, _ := constructionRegistries(t, nil, nil)
|
||||
registries.ArtifactEvidence = NewArtifactEvidenceRegistry()
|
||||
registerTestEvidenceOutput(t, ®istries, EvidenceContextPolicy{Enabled: true, LaneIDs: []string{"artifact"}})
|
||||
test.configure(®istries)
|
||||
profile := constructionProfile()
|
||||
profile.Output.Options = map[string]any{"known": true}
|
||||
resolved, err := ResolvePipeline(profile, ResolveOptions{}, registries.catalog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = Prepare(resolved, registries, ModuleDependencies{})
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Prepare() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func registerTestEvidenceOutput(t *testing.T, registries *Registries, policy EvidenceContextPolicy) {
|
||||
t.Helper()
|
||||
registry := NewOutputEncoderRegistry()
|
||||
if err := registry.RegisterBuilderWithSpec(defaultModuleSpec("output", StageOutput), func(options map[string]any) error {
|
||||
return RejectUnknownOptions(options, "known")
|
||||
}, func(BuildRequest) (contracts.OutputEncoder, error) {
|
||||
return testEvidenceOutput{policy: cloneEvidenceContextPolicy(policy)}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registries.Outputs = registry
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package pipeline
|
||||
|
||||
import "fmt"
|
||||
|
||||
func validateResolvedOptions(resolved ResolvedPipeline, catalog ModuleCatalog) error {
|
||||
func validateResolvedOptions(resolved ResolvedPipeline, catalog ModuleCatalog, configuredLaneIDs []string) error {
|
||||
if err := catalog.Inputs.ValidateOptions(resolved.Input.Module, resolved.Input.Options); err != nil {
|
||||
return moduleOptionsError(resolved.ID, "", StageInput, resolved.Input.Module, err)
|
||||
}
|
||||
@@ -42,6 +42,9 @@ func validateResolvedOptions(resolved ResolvedPipeline, catalog ModuleCatalog) e
|
||||
if err := catalog.Outputs.ValidateOptions(resolved.Output.Module, resolved.Output.Options); err != nil {
|
||||
return moduleOptionsError(resolved.ID, "", StageOutput, resolved.Output.Module, err)
|
||||
}
|
||||
if err := catalog.Outputs.ValidateProfileOptions(resolved.Output.Module, OutputProfileOptionContext{LaneIDs: configuredLaneIDs}, resolved.Output.Options); err != nil {
|
||||
return moduleOptionsError(resolved.ID, "", StageOutput, resolved.Output.Module, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,17 +10,25 @@ import (
|
||||
type OutputEncoderConstructor func() (contracts.OutputEncoder, error)
|
||||
type OutputEncoderBuilder func(BuildRequest) (contracts.OutputEncoder, error)
|
||||
|
||||
type OutputProfileOptionContext struct {
|
||||
LaneIDs []string
|
||||
}
|
||||
|
||||
type OutputProfileOptionValidator func(OutputProfileOptionContext, map[string]any) error
|
||||
|
||||
type OutputEncoderRegistry struct {
|
||||
builders map[string]OutputEncoderBuilder
|
||||
optionValidators map[string]OptionValidator
|
||||
specs map[string]ModuleSpec
|
||||
builders map[string]OutputEncoderBuilder
|
||||
optionValidators map[string]OptionValidator
|
||||
profileValidators map[string]OutputProfileOptionValidator
|
||||
specs map[string]ModuleSpec
|
||||
}
|
||||
|
||||
func NewOutputEncoderRegistry() *OutputEncoderRegistry {
|
||||
return &OutputEncoderRegistry{
|
||||
builders: make(map[string]OutputEncoderBuilder),
|
||||
optionValidators: make(map[string]OptionValidator),
|
||||
specs: make(map[string]ModuleSpec),
|
||||
builders: make(map[string]OutputEncoderBuilder),
|
||||
optionValidators: make(map[string]OptionValidator),
|
||||
profileValidators: make(map[string]OutputProfileOptionValidator),
|
||||
specs: make(map[string]ModuleSpec),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +46,12 @@ func (r *OutputEncoderRegistry) RegisterWithSpec(spec ModuleSpec, constructor Ou
|
||||
}
|
||||
|
||||
func (r *OutputEncoderRegistry) RegisterBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder OutputEncoderBuilder) error {
|
||||
return r.RegisterBuilderWithProfileValidation(spec, validateOptions, nil, builder)
|
||||
}
|
||||
|
||||
// RegisterBuilderWithProfileValidation registers an output builder with an
|
||||
// optional validator that can inspect all configured lane identities.
|
||||
func (r *OutputEncoderRegistry) RegisterBuilderWithProfileValidation(spec ModuleSpec, validateOptions OptionValidator, validateProfile OutputProfileOptionValidator, builder OutputEncoderBuilder) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("output encoder registry must not be nil")
|
||||
}
|
||||
@@ -62,11 +76,15 @@ func (r *OutputEncoderRegistry) RegisterBuilderWithSpec(spec ModuleSpec, validat
|
||||
if r.optionValidators == nil {
|
||||
r.optionValidators = make(map[string]OptionValidator)
|
||||
}
|
||||
if r.profileValidators == nil {
|
||||
r.profileValidators = make(map[string]OutputProfileOptionValidator)
|
||||
}
|
||||
if r.specs == nil {
|
||||
r.specs = make(map[string]ModuleSpec)
|
||||
}
|
||||
r.builders[normalizedSpec.Key] = builder
|
||||
r.optionValidators[normalizedSpec.Key] = validateOptions
|
||||
r.profileValidators[normalizedSpec.Key] = validateProfile
|
||||
r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec)
|
||||
return nil
|
||||
}
|
||||
@@ -116,6 +134,21 @@ func (r *OutputEncoderRegistry) ValidateOptions(key string, options map[string]a
|
||||
return validateRegisteredOptions(validator, options)
|
||||
}
|
||||
|
||||
func (r *OutputEncoderRegistry) ValidateProfileOptions(key string, context OutputProfileOptionContext, options map[string]any) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("output encoder registry must not be nil")
|
||||
}
|
||||
normalizedKey := strings.TrimSpace(key)
|
||||
validator, ok := r.profileValidators[normalizedKey]
|
||||
if !ok {
|
||||
return fmt.Errorf("output encoder %q is not registered", normalizedKey)
|
||||
}
|
||||
if validator == nil {
|
||||
return nil
|
||||
}
|
||||
return validator(OutputProfileOptionContext{LaneIDs: append([]string(nil), context.LaneIDs...)}, cloneOptions(options))
|
||||
}
|
||||
|
||||
func (r *OutputEncoderRegistry) Spec(key string) (ModuleSpec, bool) {
|
||||
if r == nil {
|
||||
return ModuleSpec{}, false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
@@ -56,3 +57,33 @@ func TestOutputEncoderRegistryBehavior(t *testing.T) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestOutputProfileValidationReceivesOwnedOptionsAndLaneIDs(t *testing.T) {
|
||||
registry := NewOutputEncoderRegistry()
|
||||
if err := registry.RegisterBuilderWithProfileValidation(defaultModuleSpec("profile-output", StageOutput), func(options map[string]any) error {
|
||||
options["nested"].(map[string]any)["value"] = "changed"
|
||||
return nil
|
||||
}, func(context OutputProfileOptionContext, options map[string]any) error {
|
||||
context.LaneIDs[0] = "changed"
|
||||
options["nested"].(map[string]any)["value"] = "changed-again"
|
||||
return nil
|
||||
}, func(BuildRequest) (contracts.OutputEncoder, error) {
|
||||
return registryOutputEncoder{key: "profile-output"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options := map[string]any{"nested": map[string]any{"value": "original"}}
|
||||
if err := registry.ValidateOptions("profile-output", options); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
context := OutputProfileOptionContext{LaneIDs: []string{"artifact"}}
|
||||
if err := registry.ValidateProfileOptions("profile-output", context, options); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := options["nested"].(map[string]any)["value"]; got != "original" {
|
||||
t.Fatalf("options mutated by validator: %q", got)
|
||||
}
|
||||
if want := []string{"artifact"}; !reflect.DeepEqual(context.LaneIDs, want) {
|
||||
t.Fatalf("lane ids mutated by validator: %#v, want %#v", context.LaneIDs, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
@@ -24,6 +25,7 @@ type PreparedPipeline struct {
|
||||
chunkValidators preparedValidatorChain
|
||||
output contracts.OutputEncoder
|
||||
artifactCodecs *ArtifactCodecRegistry
|
||||
evidencePlan *preparedEvidencePlan
|
||||
checkpointFingerprints []CheckpointFingerprint
|
||||
}
|
||||
|
||||
@@ -55,6 +57,17 @@ type preparedTypedLane struct {
|
||||
codec artifactCodecEntry
|
||||
}
|
||||
|
||||
type preparedEvidencePlan struct {
|
||||
policy EvidenceContextPolicy
|
||||
lanes []preparedEvidenceLane
|
||||
}
|
||||
|
||||
type preparedEvidenceLane struct {
|
||||
laneID string
|
||||
kind contracts.ArtifactKind
|
||||
project func(any) ([]source.SourceRef, error)
|
||||
}
|
||||
|
||||
type preparedValidatorChain struct {
|
||||
resolved ResolvedValidatorChain
|
||||
validators []preparedValidator
|
||||
@@ -129,6 +142,10 @@ func Prepare(resolved ResolvedPipeline, registries Registries, deps ModuleDepend
|
||||
return nil, constructionError(stable.ID, "", StageOutput, stable.Output.Module, "", err)
|
||||
}
|
||||
prepared.output = output
|
||||
prepared.evidencePlan, err = prepareEvidencePlan(stable, registries, output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prepared.checkpointFingerprints, err = collectPreparedCheckpointFingerprints(prepared)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -136,6 +153,52 @@ func Prepare(resolved ResolvedPipeline, registries Registries, deps ModuleDepend
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func prepareEvidencePlan(resolved ResolvedPipeline, registries Registries, output contracts.OutputEncoder) (*preparedEvidencePlan, error) {
|
||||
provider, ok := output.(EvidenceContextPolicyProvider)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
policy := cloneEvidenceContextPolicy(provider.EvidenceContextPolicy())
|
||||
if !policy.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
if policy.WindowUnits < 0 {
|
||||
return nil, constructionError(resolved.ID, "", StageOutput, resolved.Output.Module, "", fmt.Errorf("evidence window units must not be negative"))
|
||||
}
|
||||
lanes, err := normalizeEvidenceLaneIDs(policy.LaneIDs)
|
||||
if err != nil {
|
||||
return nil, constructionError(resolved.ID, "", StageOutput, resolved.Output.Module, "", err)
|
||||
}
|
||||
policy.LaneIDs = lanes
|
||||
active := make(map[string]ResolvedArtifactLane)
|
||||
for _, lane := range resolved.AllArtifactLanes() {
|
||||
active[lane.ID] = lane
|
||||
}
|
||||
plan := &preparedEvidencePlan{policy: cloneEvidenceContextPolicy(policy)}
|
||||
for _, laneID := range policy.LaneIDs {
|
||||
lane, ok := active[laneID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if registries.ArtifactEvidence == nil {
|
||||
return nil, constructionError(resolved.ID, laneID, StageOutput, resolved.Output.Module, "", fmt.Errorf("artifact evidence registry must not be nil for active evidence lane"))
|
||||
}
|
||||
evidence, _, evidenceErr := registries.ArtifactEvidence.entry(lane.ArtifactKind)
|
||||
if evidenceErr != nil {
|
||||
return nil, constructionError(resolved.ID, laneID, StageOutput, resolved.Output.Module, "", evidenceErr)
|
||||
}
|
||||
codecType, ok := registries.ArtifactCodecs.valueType(lane.ArtifactKind)
|
||||
if !ok {
|
||||
return nil, constructionError(resolved.ID, laneID, StageOutput, resolved.Output.Module, "", fmt.Errorf("artifact codec %q has no Go type", lane.ArtifactKind))
|
||||
}
|
||||
if evidence.valueType != codecType {
|
||||
return nil, constructionError(resolved.ID, laneID, StageOutput, resolved.Output.Module, "", fmt.Errorf("artifact evidence kind %q requires Go type %s, but active artifact codec uses %s", lane.ArtifactKind, typeName(evidence.valueType), typeName(codecType)))
|
||||
}
|
||||
plan.lanes = append(plan.lanes, preparedEvidenceLane{laneID: laneID, kind: lane.ArtifactKind, project: evidence.project})
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func prepareLane(pipeline ResolvedPipeline, lane ResolvedArtifactLane, registries Registries, deps ModuleDependencies) (preparedLaneExecutor, error) {
|
||||
executor := preparedLaneExecutor{resolved: cloneResolvedArtifactLane(lane)}
|
||||
request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest {
|
||||
@@ -303,7 +366,7 @@ func constructionError(pipelineID, laneID string, stage ModuleStage, moduleKey,
|
||||
|
||||
func (registries Registries) catalog() ModuleCatalog {
|
||||
return ModuleCatalog{
|
||||
Inputs: registries.Inputs, Chunkers: registries.Chunkers, ArtifactCodecs: registries.ArtifactCodecs,
|
||||
Inputs: registries.Inputs, Chunkers: registries.Chunkers, ArtifactCodecs: registries.ArtifactCodecs, ArtifactEvidence: registries.ArtifactEvidence,
|
||||
Extractors: registries.Extractors, Mergers: registries.Mergers, Normalizers: registries.Normalizers,
|
||||
Validators: registries.Validators, ValidatorChains: registries.ValidatorChains, Outputs: registries.Outputs,
|
||||
}
|
||||
|
||||
@@ -213,15 +213,16 @@ func (resolved ResolvedPipeline) AllArtifactLanes() []ResolvedArtifactLane {
|
||||
}
|
||||
|
||||
type ModuleCatalog struct {
|
||||
Inputs *InputAdapterRegistry
|
||||
Chunkers *ChunkerRegistry
|
||||
ArtifactCodecs *ArtifactCodecRegistry
|
||||
Extractors *ExtractorRegistry
|
||||
Mergers *MergerRegistry
|
||||
Normalizers *NormalizerRegistry
|
||||
Validators *ValidatorRegistry
|
||||
ValidatorChains *ValidatorChainRegistry
|
||||
Outputs *OutputEncoderRegistry
|
||||
Inputs *InputAdapterRegistry
|
||||
Chunkers *ChunkerRegistry
|
||||
ArtifactCodecs *ArtifactCodecRegistry
|
||||
ArtifactEvidence *ArtifactEvidenceRegistry
|
||||
Extractors *ExtractorRegistry
|
||||
Mergers *MergerRegistry
|
||||
Normalizers *NormalizerRegistry
|
||||
Validators *ValidatorRegistry
|
||||
ValidatorChains *ValidatorChainRegistry
|
||||
Outputs *OutputEncoderRegistry
|
||||
}
|
||||
|
||||
func Binding(module string) ModuleBinding {
|
||||
@@ -251,6 +252,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
|
||||
if len(steps) == 0 {
|
||||
return ResolvedPipeline{}, fmt.Errorf("pipeline %q must declare at least one artifact lane", pipelineID)
|
||||
}
|
||||
configuredLaneIDs, err := configuredProfileLaneIDs(pipelineID, steps)
|
||||
if err != nil {
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
|
||||
input := resolveBinding(profile.Input, "")
|
||||
if input.Module == "" {
|
||||
@@ -381,7 +386,7 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
|
||||
if missing, ok := outputCapabilities.missing(outputSpec.Requires); ok {
|
||||
return ResolvedPipeline{}, capabilityError(pipelineID, "", StageOutput, resolved.Output.Module, missing)
|
||||
}
|
||||
if err := validateResolvedOptions(resolved, catalog); err != nil {
|
||||
if err := validateResolvedOptions(resolved, catalog, configuredLaneIDs); err != nil {
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
|
||||
@@ -393,6 +398,29 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// configuredProfileLaneIDs validates the complete configured lane identity
|
||||
// set before invocation-level selection removes legacy-profile lanes.
|
||||
func configuredProfileLaneIDs(pipelineID string, steps []PipelineStepProfile) ([]string, error) {
|
||||
seen := make(map[string]string)
|
||||
var laneIDs []string
|
||||
for _, step := range steps {
|
||||
_, ids, err := selectedArtifactLanes(pipelineID, step.Artifacts, ResolveOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stepID := strings.TrimSpace(step.ID)
|
||||
for _, laneID := range ids {
|
||||
if previous, ok := seen[laneID]; ok {
|
||||
return nil, fmt.Errorf("pipeline %q artifact lane id %q is duplicated across steps %q and %q", pipelineID, laneID, previous, stepID)
|
||||
}
|
||||
seen[laneID] = stepID
|
||||
laneIDs = append(laneIDs, laneID)
|
||||
}
|
||||
}
|
||||
sort.Strings(laneIDs)
|
||||
return laneIDs, nil
|
||||
}
|
||||
|
||||
func resolveArtifactLane(
|
||||
pipelineID string,
|
||||
stepID string,
|
||||
@@ -1190,11 +1218,32 @@ func cloneOptions(options map[string]any) map[string]any {
|
||||
|
||||
copied := make(map[string]any, len(options))
|
||||
for key, value := range options {
|
||||
copied[key] = value
|
||||
copied[key] = cloneOptionValue(value)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func cloneOptionValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return cloneOptions(typed)
|
||||
case []any:
|
||||
out := make([]any, len(typed))
|
||||
for i := range typed {
|
||||
out[i] = cloneOptionValue(typed[i])
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return append([]string(nil), typed...)
|
||||
case []byte:
|
||||
return append([]byte(nil), typed...)
|
||||
case json.RawMessage:
|
||||
return append(json.RawMessage(nil), typed...)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeReferenceMap(values map[string]ReferenceSource) map[string]ReferenceSource {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -21,15 +21,16 @@ import (
|
||||
)
|
||||
|
||||
type Registries struct {
|
||||
Inputs *InputAdapterRegistry
|
||||
Chunkers *ChunkerRegistry
|
||||
ArtifactCodecs *ArtifactCodecRegistry
|
||||
Extractors *ExtractorRegistry
|
||||
Mergers *MergerRegistry
|
||||
Normalizers *NormalizerRegistry
|
||||
Validators *ValidatorRegistry
|
||||
ValidatorChains *ValidatorChainRegistry
|
||||
Outputs *OutputEncoderRegistry
|
||||
Inputs *InputAdapterRegistry
|
||||
Chunkers *ChunkerRegistry
|
||||
ArtifactCodecs *ArtifactCodecRegistry
|
||||
ArtifactEvidence *ArtifactEvidenceRegistry
|
||||
Extractors *ExtractorRegistry
|
||||
Mergers *MergerRegistry
|
||||
Normalizers *NormalizerRegistry
|
||||
Validators *ValidatorRegistry
|
||||
ValidatorChains *ValidatorChainRegistry
|
||||
Outputs *OutputEncoderRegistry
|
||||
}
|
||||
|
||||
type Runner struct{}
|
||||
|
||||
Reference in New Issue
Block a user