350 lines
16 KiB
Go
350 lines
16 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
)
|
|
|
|
func handoffFixture(t *testing.T, value codecNotes) (RunInput, PreparedPipelineStep, contracts.SerializedOutput) {
|
|
t.Helper()
|
|
prepared := preparedOrderedPipeline(t, 1,
|
|
orderedLaneSpec{id: "notes", profile: "notes"},
|
|
orderedLaneSpec{id: "score", profile: "score"},
|
|
)
|
|
consumer := &prepared.Steps[1].lanes[0]
|
|
installGeneratedTarget := func(target *ResolvedReferenceTarget, stage ModuleStage, module string) {
|
|
*target = ResolvedReferenceTarget{
|
|
Stage: stage,
|
|
StepID: consumer.resolved.StepID,
|
|
LaneID: consumer.resolved.ID,
|
|
Module: module,
|
|
Bindings: []ReferenceBinding{{
|
|
Stage: stage,
|
|
LaneID: consumer.resolved.ID,
|
|
SlotName: "producer-output",
|
|
Artifact: &ArtifactReference{Step: "step-1", Lane: "notes"},
|
|
}},
|
|
ReferenceSet: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
"producer-output": {Slot: contracts.ReferenceSlot{
|
|
Name: "producer-output",
|
|
AcceptedArtifactKinds: []contracts.ArtifactKind{"test/notes"},
|
|
AcceptedMediaTypes: []string{"application/json"},
|
|
}},
|
|
}},
|
|
}
|
|
}
|
|
installGeneratedTarget(&consumer.resolved.ExtractReferences, StageExtract, consumer.resolved.Extract.Module)
|
|
installGeneratedTarget(&consumer.resolved.MergeReferences, StageMerge, consumer.resolved.Merge.Module)
|
|
installGeneratedTarget(&consumer.resolved.NormalizeReferences, StageNormalize, consumer.resolved.Normalize.Module)
|
|
|
|
artifact, err := checkpointArtifact(prepared.Steps[0].lanes[0].typed.codec, "notes", "typed/normalize", "source", value)
|
|
if err != nil {
|
|
t.Fatalf("checkpointArtifact() error = %v", err)
|
|
}
|
|
input := RunInput{Prepared: prepared}
|
|
input.pipeline = prepared.resolved
|
|
return input, prepared.Steps[1], contracts.SerializedOutput{
|
|
StepID: "step-1",
|
|
LaneID: "notes",
|
|
NormalizerKey: "typed/normalize",
|
|
SourceID: "source",
|
|
Artifact: contracts.CloneSerializedArtifact(artifact.Artifact),
|
|
}
|
|
}
|
|
|
|
func TestBuildStepReferenceSetsCanonicalizesAndClonesFanout(t *testing.T) {
|
|
input, step, producerOutput := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
|
sets, provenance, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput})
|
|
if err != nil {
|
|
t.Fatalf("buildStepReferenceSets() error = %v", err)
|
|
}
|
|
if got, want := len(sets), 3; got != want {
|
|
t.Fatalf("reference target set count = %d, want %d", got, want)
|
|
}
|
|
if got, want := len(provenance), 3; got != want {
|
|
t.Fatalf("generated provenance count = %d, want %d", got, want)
|
|
}
|
|
|
|
var first []byte
|
|
for key, set := range sets {
|
|
item := set.Slots["producer-output"].Items[0]
|
|
if item.Origin.Type != "generated" || item.Origin.URI != "" {
|
|
t.Fatalf("target %v origin = %#v, want generated origin without URI", key, item.Origin)
|
|
}
|
|
if item.Producer.StepID != "step-1" || item.Producer.LaneID != "notes" || item.Producer.ModuleKey != "typed/normalize" {
|
|
t.Fatalf("target %v producer = %#v, want producer provenance", key, item.Producer)
|
|
}
|
|
if first == nil {
|
|
first = item.Content
|
|
continue
|
|
}
|
|
if &first[0] == &item.Content[0] {
|
|
t.Fatalf("target %v shares generated content backing storage", key)
|
|
}
|
|
}
|
|
if got := len(step.lanes[0].resolved.ExtractReferences.ReferenceSet.Slots["producer-output"].Items); got != 0 {
|
|
t.Fatalf("prepared extract reference items = %d, want zero", got)
|
|
}
|
|
|
|
encoded, err := json.Marshal(provenance)
|
|
if err != nil {
|
|
t.Fatalf("marshal generated provenance: %v", err)
|
|
}
|
|
text := string(encoded)
|
|
for _, forbidden := range []string{"first", "file://", "content_base64"} {
|
|
if strings.Contains(text, forbidden) {
|
|
t.Fatalf("generated provenance contains forbidden %q: %s", forbidden, text)
|
|
}
|
|
}
|
|
if !strings.Contains(text, "producer_pipeline_id") || !strings.Contains(text, "schema_digest") {
|
|
t.Fatalf("generated provenance = %s, want bounded producer and schema identity", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildStepReferenceSetsIndexesManyOutputsForFanout(t *testing.T) {
|
|
input, step, producerOutput := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
|
outputs := make([]contracts.SerializedOutput, 0, 65)
|
|
for index := range 64 {
|
|
outputs = append(outputs, contracts.SerializedOutput{StepID: fmt.Sprintf("unrelated-step-%d", index), LaneID: "unrelated-lane"})
|
|
}
|
|
outputs = append(outputs, producerOutput)
|
|
|
|
sets, provenance, err := buildStepReferenceSets(input, step, outputs)
|
|
if err != nil {
|
|
t.Fatalf("buildStepReferenceSets() error = %v", err)
|
|
}
|
|
if len(sets) != 3 || len(provenance) != 3 {
|
|
t.Fatalf("reference sets/provenance = %d/%d, want 3/3", len(sets), len(provenance))
|
|
}
|
|
for target, set := range sets {
|
|
item := set.Slots["producer-output"].Items[0]
|
|
if item.Producer.StepID != "step-1" || item.Producer.LaneID != "notes" || string(item.Content) == "" {
|
|
t.Fatalf("target %v item = %#v, want canonical producer artifact", target, item)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGeneratedReferenceAcceptsTypedEmptyCollection(t *testing.T) {
|
|
input, step, producerOutput := handoffFixture(t, codecNotes{})
|
|
sets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput})
|
|
if err != nil {
|
|
t.Fatalf("buildStepReferenceSets() error = %v, want nil for accepted empty collection", err)
|
|
}
|
|
if got := len(sets[keyForReferenceTarget(step.lanes[0].resolved.ExtractReferences)].Slots["producer-output"].Items); got != 1 {
|
|
t.Fatalf("generated empty collection item count = %d, want one artifact", got)
|
|
}
|
|
}
|
|
|
|
func TestGeneratedReferenceRejectsInvalidProducerOutputsDeterministically(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
outputs func(contracts.SerializedOutput) []contracts.SerializedOutput
|
|
mutate func(*ResolvedReferenceTarget, *contracts.SerializedOutput)
|
|
want string
|
|
}{
|
|
{name: "missing", outputs: func(contracts.SerializedOutput) []contracts.SerializedOutput { return nil }, want: "no accepted normalized output"},
|
|
{name: "multiple", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
|
|
return []contracts.SerializedOutput{output, output}
|
|
}, want: "exactly one is required"},
|
|
{name: "multiple after identifier normalization", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
|
|
duplicate := contracts.CloneSerializedOutput(output)
|
|
duplicate.StepID = " step-1 "
|
|
duplicate.LaneID = " notes "
|
|
return []contracts.SerializedOutput{output, duplicate}
|
|
}, want: "producer has 2 accepted normalized outputs; exactly one is required"},
|
|
{name: "kind mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
|
|
return []contracts.SerializedOutput{output}
|
|
}, mutate: func(target *ResolvedReferenceTarget, _ *contracts.SerializedOutput) {
|
|
target.ReferenceSet.Slots["producer-output"] = contracts.ResolvedReferenceSlot{Slot: contracts.ReferenceSlot{Name: "producer-output", AcceptedArtifactKinds: []contracts.ArtifactKind{"test/score"}}}
|
|
}, want: "does not accept artifact kind"},
|
|
{name: "schema mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
|
|
return []contracts.SerializedOutput{output}
|
|
}, mutate: func(_ *ResolvedReferenceTarget, output *contracts.SerializedOutput) {
|
|
output.Artifact.Schema.ID = "wrong.schema"
|
|
}, want: "schema identity"},
|
|
{name: "media mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
|
|
return []contracts.SerializedOutput{output}
|
|
}, mutate: func(_ *ResolvedReferenceTarget, output *contracts.SerializedOutput) {
|
|
output.Artifact.MediaType = "text/plain"
|
|
}, want: "media type"},
|
|
{name: "size mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
|
|
return []contracts.SerializedOutput{output}
|
|
}, mutate: func(target *ResolvedReferenceTarget, _ *contracts.SerializedOutput) {
|
|
slot := target.ReferenceSet.Slots["producer-output"]
|
|
slot.Slot.MaxBytes = 1
|
|
target.ReferenceSet.Slots["producer-output"] = slot
|
|
}, want: "limit"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
input, step, output := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
|
output = contracts.CloneSerializedOutput(output)
|
|
if test.mutate != nil {
|
|
target := &step.lanes[0].resolved.ExtractReferences
|
|
test.mutate(target, &output)
|
|
}
|
|
_, _, err := buildStepReferenceSets(input, step, test.outputs(output))
|
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("buildStepReferenceSets() error = %v, want %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func BenchmarkIndexGeneratedOutputs(b *testing.B) {
|
|
outputs := make([]contracts.SerializedOutput, 0, 1024)
|
|
for index := range 1024 {
|
|
outputs = append(outputs, contracts.SerializedOutput{StepID: fmt.Sprintf("step-%d", index), LaneID: fmt.Sprintf("lane-%d", index)})
|
|
}
|
|
b.ResetTimer()
|
|
for range b.N {
|
|
_ = indexGeneratedOutputs(outputs)
|
|
}
|
|
}
|
|
|
|
func TestGeneratedReferenceFingerprintChangesWithCanonicalContent(t *testing.T) {
|
|
input, step, first := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
|
firstSets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{first})
|
|
if err != nil {
|
|
t.Fatalf("build first reference set: %v", err)
|
|
}
|
|
_, _, second := handoffFixture(t, codecNotes{Items: []string{"second"}})
|
|
secondSets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{second})
|
|
if err != nil {
|
|
t.Fatalf("build second reference set: %v", err)
|
|
}
|
|
firstDeps := generatedReferenceDependencies(firstSets[keyForReferenceTarget(step.lanes[0].resolved.ExtractReferences)])
|
|
secondDeps := generatedReferenceDependencies(secondSets[keyForReferenceTarget(step.lanes[0].resolved.ExtractReferences)])
|
|
if reflect.DeepEqual(firstDeps, secondDeps) {
|
|
t.Fatalf("generated dependencies = %#v, want content-sensitive fingerprint", firstDeps)
|
|
}
|
|
}
|
|
|
|
func TestGeneratedReferenceFingerprintChangesWithCanonicalSize(t *testing.T) {
|
|
input, step, producerOutput := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
|
sets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput})
|
|
if err != nil {
|
|
t.Fatalf("build reference set: %v", err)
|
|
}
|
|
key := keyForReferenceTarget(step.lanes[0].resolved.ExtractReferences)
|
|
firstDeps := generatedReferenceDependencies(sets[key])
|
|
changed := CloneReferenceSet(sets[key])
|
|
item := changed.Slots["producer-output"].Items[0]
|
|
item.SizeBytes++
|
|
changed.Slots["producer-output"] = contracts.ResolvedReferenceSlot{
|
|
Slot: changed.Slots["producer-output"].Slot,
|
|
Items: []contracts.ReferenceItem{item},
|
|
}
|
|
secondDeps := generatedReferenceDependencies(changed)
|
|
if reflect.DeepEqual(firstDeps, secondDeps) {
|
|
t.Fatalf("generated dependencies = %#v, want size-sensitive fingerprint", firstDeps)
|
|
}
|
|
}
|
|
|
|
func TestRunnerHandsOffAcceptedNormalizedOutputBeforeConsumerLanes(t *testing.T) {
|
|
input, _, _ := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
|
prepared := input.Prepared
|
|
var received contracts.ReferenceSet
|
|
prepared.Steps[0].lanes[0].typed.extract = func(context.Context, any, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
|
|
return erasedTypedResult{Value: codecNotes{Items: []string{"first"}}}, nil
|
|
}
|
|
prepared.Steps[1].lanes[0].typed.extract = func(_ context.Context, _ any, request contracts.TypedExtractionRequest) (erasedTypedResult, error) {
|
|
received = CloneReferenceSet(request.References)
|
|
return erasedTypedResult{Value: codecScore{Value: 3}}, nil
|
|
}
|
|
|
|
output, err := New().Run(context.Background(), input)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
if got, want := len(output.NormalizeOutputs), 2; got != want {
|
|
t.Fatalf("normalized output count = %d, want %d", got, want)
|
|
}
|
|
if output.NormalizeOutputs[0].StepID != "step-1" || output.NormalizeOutputs[1].StepID != "step-2" {
|
|
t.Fatalf("normalized output step IDs = %#v, want step-1 and step-2", output.NormalizeOutputs)
|
|
}
|
|
item := received.Slots["producer-output"].Items[0]
|
|
if item.Origin.Type != "generated" || item.Producer.StepID != "step-1" || item.Producer.LaneID != "notes" {
|
|
t.Fatalf("consumer reference = %#v, want generated producer reference", item)
|
|
}
|
|
if len(output.Manifest.References) == 0 || output.Manifest.References[len(output.Manifest.References)-1].OriginType != "generated" {
|
|
t.Fatalf("manifest references = %#v, want generated provenance", output.Manifest.References)
|
|
}
|
|
}
|
|
|
|
type handoffDependencyLoader struct {
|
|
CheckpointLoader
|
|
extract [][]CheckpointFingerprint
|
|
merge [][]CheckpointFingerprint
|
|
normalize [][]CheckpointFingerprint
|
|
}
|
|
|
|
func (l *handoffDependencyLoader) Enabled() bool { return true }
|
|
|
|
func (l *handoffDependencyLoader) Extract(_ string, _ string, dependencies []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) {
|
|
l.extract = append(l.extract, append([]CheckpointFingerprint(nil), dependencies...))
|
|
return ExtractCheckpoint{}, CheckpointDecision{Reason: "not found"}
|
|
}
|
|
|
|
func (l *handoffDependencyLoader) Merge(_ string, _ string, dependencies []CheckpointFingerprint) (MergeCheckpoint, CheckpointDecision) {
|
|
l.merge = append(l.merge, append([]CheckpointFingerprint(nil), dependencies...))
|
|
return MergeCheckpoint{}, CheckpointDecision{Reason: "not found"}
|
|
}
|
|
|
|
func (l *handoffDependencyLoader) Normalize(_ string, _ string, dependencies []CheckpointFingerprint) (NormalizeCheckpoint, CheckpointDecision) {
|
|
l.normalize = append(l.normalize, append([]CheckpointFingerprint(nil), dependencies...))
|
|
return NormalizeCheckpoint{}, CheckpointDecision{Reason: "not found"}
|
|
}
|
|
|
|
func runHandoffWithProducerValue(t *testing.T, value codecNotes) *handoffDependencyLoader {
|
|
t.Helper()
|
|
input, _, _ := handoffFixture(t, value)
|
|
prepared := input.Prepared
|
|
prepared.Steps[0].lanes[0].typed.extract = func(context.Context, any, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
|
|
return erasedTypedResult{Value: value}, nil
|
|
}
|
|
prepared.Steps[0].lanes[0].typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
|
|
return erasedTypedResult{Value: value}, nil
|
|
}
|
|
prepared.Steps[1].lanes[0].typed.extract = func(context.Context, any, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
|
|
return erasedTypedResult{Value: codecScore{Value: 3}}, nil
|
|
}
|
|
loader := &handoffDependencyLoader{CheckpointLoader: NoopCheckpointLoader()}
|
|
if _, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Checkpoint: loader}); err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
return loader
|
|
}
|
|
|
|
func TestRunnerAddsGeneratedDependencyToEveryReceivingCheckpoint(t *testing.T) {
|
|
first := runHandoffWithProducerValue(t, codecNotes{Items: []string{"first"}})
|
|
second := runHandoffWithProducerValue(t, codecNotes{Items: []string{"second"}})
|
|
if len(first.extract) != 2 || len(first.merge) != 2 || len(first.normalize) != 2 {
|
|
t.Fatalf("checkpoint load calls = extract %d merge %d normalize %d, want two each", len(first.extract), len(first.merge), len(first.normalize))
|
|
}
|
|
for name, calls := range map[string][][]CheckpointFingerprint{"extract": first.extract, "merge": first.merge, "normalize": first.normalize} {
|
|
if got := generatedFingerprintCount(calls[1]); got != 1 {
|
|
t.Fatalf("%s consumer dependency count = %d, want one", name, got)
|
|
}
|
|
}
|
|
if reflect.DeepEqual(first.extract[1], second.extract[1]) || reflect.DeepEqual(first.merge[1], second.merge[1]) || reflect.DeepEqual(first.normalize[1], second.normalize[1]) {
|
|
t.Fatalf("consumer checkpoint dependencies did not change with producer content")
|
|
}
|
|
}
|
|
|
|
func generatedFingerprintCount(dependencies []CheckpointFingerprint) int {
|
|
count := 0
|
|
for _, dependency := range dependencies {
|
|
if strings.HasPrefix(dependency.Name, "generated-reference:") {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|