diff --git a/internal/publish/plan.go b/internal/publish/plan.go index 5314df7..e17447a 100644 --- a/internal/publish/plan.go +++ b/internal/publish/plan.go @@ -194,7 +194,15 @@ func compareDestination(req Request, status state.DestinationStatus) state.Compa return state.Comparison{Outcome: state.OutcomeDestinationOlder, Reason: "fixed destination source is older than selected source"} } if destinationManifest.Created.After(req.SourceBundle.Manifest.Created) { - return state.Comparison{Outcome: state.OutcomeDestinationNewer, Reason: "fixed destination source is newer than selected source"} + return state.Comparison{ + Outcome: state.OutcomeDestinationNewer, + Reason: "fixed destination source is newer than selected source", + Detail: state.ComparisonDetail{ + Kind: state.ComparisonDetailDestinationNewer, + CurrentSourceID: req.SourceBundle.Manifest.ID, + DestinationSourceID: destinationManifest.ID, + }, + } } return comparison } @@ -207,7 +215,15 @@ func compareDestination(req Request, status state.DestinationStatus) state.Compa return state.Comparison{Outcome: state.OutcomeDestinationOlder, Reason: "fixed destination source is older than selected source"} } if destinationManifest.Created.After(req.SourceBundle.Manifest.Created) { - return state.Comparison{Outcome: state.OutcomeDestinationNewer, Reason: "fixed destination source is newer than selected source"} + return state.Comparison{ + Outcome: state.OutcomeDestinationNewer, + Reason: "fixed destination source is newer than selected source", + Detail: state.ComparisonDetail{ + Kind: state.ComparisonDetailDestinationNewer, + CurrentSourceID: req.SourceBundle.Manifest.ID, + DestinationSourceID: destinationManifest.ID, + }, + } } return comparison } diff --git a/internal/publish/plan_test.go b/internal/publish/plan_test.go new file mode 100644 index 0000000..0bb307e --- /dev/null +++ b/internal/publish/plan_test.go @@ -0,0 +1,41 @@ +package publish + +import ( + "testing" + "time" + + "gitea.maximumdirect.net/eric/distributor/internal/config" + "gitea.maximumdirect.net/eric/distributor/internal/state" + "gitea.maximumdirect.net/eric/distributor/internal/storage/fake" + "gitea.maximumdirect.net/eric/distributor/internal/testutil" +) + +func TestCompareDestinationFixedPathReportsDestinationNewerDetail(t *testing.T) { + sourceBackend := fake.New() + sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{}) + destinationState := testutil.DestinationState(sourceBundle.Manifest, testutil.DestinationStateOptions{}) + destinationState.Source.Manifest.ID = "latest.previous" + destinationState.Source.Manifest.Created = sourceBundle.Manifest.Created.Add(time.Hour) + + comparison := compareDestination(Request{ + PipelineID: "reports", + DestinationID: "archive", + SourceBundle: sourceBundle, + DestinationBundlePath: "", + PathMapping: config.PathMappingFixed, + State: config.StatePolicy{Mode: config.StateModeSingleOwner}, + }, state.DestinationStatus{State: &destinationState, HasContents: true}) + + if comparison.Outcome != state.OutcomeDestinationNewer { + t.Fatalf("comparison outcome = %s, want %s", comparison.Outcome, state.OutcomeDestinationNewer) + } + if comparison.Reason != "fixed destination source is newer than selected source" { + t.Fatalf("comparison reason = %q, want fixed-path newer reason", comparison.Reason) + } + if comparison.Detail.Kind != state.ComparisonDetailDestinationNewer { + t.Fatalf("detail kind = %q, want %q", comparison.Detail.Kind, state.ComparisonDetailDestinationNewer) + } + if comparison.Detail.CurrentSourceID != sourceBundle.Manifest.ID || comparison.Detail.DestinationSourceID != "latest.previous" { + t.Fatalf("detail = %#v, want source ids", comparison.Detail) + } +} diff --git a/internal/state/compare.go b/internal/state/compare.go index 54d76c8..475448e 100644 --- a/internal/state/compare.go +++ b/internal/state/compare.go @@ -30,37 +30,75 @@ type DestinationStatus struct { type Comparison struct { Outcome Outcome Reason string + Detail ComparisonDetail +} + +type ComparisonDetailKind string + +const ( + ComparisonDetailNone ComparisonDetailKind = "" + ComparisonDetailInvalidState ComparisonDetailKind = "invalid_state" + ComparisonDetailUnmanagedContent ComparisonDetailKind = "unmanaged_content" + ComparisonDetailPipelineIDMismatch ComparisonDetailKind = "pipeline_id_mismatch" + ComparisonDetailDestinationIDMismatch ComparisonDetailKind = "destination_id_mismatch" + ComparisonDetailDifferentSourceID ComparisonDetailKind = "different_source_id" + ComparisonDetailSameCreatedDigestConflict ComparisonDetailKind = "same_created_digest_conflict" + ComparisonDetailDestinationNewer ComparisonDetailKind = "destination_newer" + ComparisonDetailSharedRootOwnerAbsent ComparisonDetailKind = "shared_root_owner_absent" + ComparisonDetailSharedRootOutputOwner ComparisonDetailKind = "shared_root_output_owner_conflict" +) + +type ComparisonDetail struct { + Kind ComparisonDetailKind + CurrentPipelineID string + CurrentDestinationID string + DestinationPipelineID string + DestinationDestinationID string + CurrentSourceID string + DestinationSourceID string + CurrentSourceDigest string + DestinationSourceDigest string + Path string + CurrentOwner OwnerScope + ConflictingOwner OwnerScope } func CompareSharedRootOwner(source bundle.Manifest, scope OwnerScope, status DestinationStatus) Comparison { if status.StateErr != nil { - return Comparison{Outcome: OutcomeInvalidState, Reason: status.StateErr.Error()} + return Comparison{Outcome: OutcomeInvalidState, Reason: status.StateErr.Error(), Detail: ComparisonDetail{Kind: ComparisonDetailInvalidState}} } if status.SharedRoot != nil { if err := ValidateSharedRoot(*status.SharedRoot); err != nil { - return Comparison{Outcome: OutcomeInvalidState, Reason: err.Error()} + return Comparison{Outcome: OutcomeInvalidState, Reason: err.Error(), Detail: ComparisonDetail{Kind: ComparisonDetailInvalidState}} } owner, ok := status.SharedRoot.Owner(scope) if !ok { - return Comparison{Outcome: OutcomeDestinationAbsent, Reason: fmt.Sprintf("destination owner %s/%s is absent", scope.PipelineID, scope.DestinationID)} + return Comparison{ + Outcome: OutcomeDestinationAbsent, + Reason: fmt.Sprintf("destination owner %s/%s is absent", scope.PipelineID, scope.DestinationID), + Detail: ComparisonDetail{ + Kind: ComparisonDetailSharedRootOwnerAbsent, + CurrentOwner: scope, + }, + } } return compareManifests(source, owner.Source.Manifest) } if status.State != nil { destinationState := *status.State if err := Validate(destinationState); err != nil { - return Comparison{Outcome: OutcomeInvalidState, Reason: err.Error()} + return Comparison{Outcome: OutcomeInvalidState, Reason: err.Error(), Detail: ComparisonDetail{Kind: ComparisonDetailInvalidState}} } if destinationState.PipelineID != scope.PipelineID { - return Comparison{Outcome: OutcomeIdentityMismatch, Reason: fmt.Sprintf("pipeline id %q does not match %q", destinationState.PipelineID, scope.PipelineID)} + return pipelineIDMismatchComparison(destinationState.PipelineID, scope.PipelineID) } if destinationState.DestinationID != scope.DestinationID { - return Comparison{Outcome: OutcomeIdentityMismatch, Reason: fmt.Sprintf("destination id %q does not match %q", destinationState.DestinationID, scope.DestinationID)} + return destinationIDMismatchComparison(destinationState.DestinationID, scope.DestinationID) } return compareManifests(source, destinationState.Source.Manifest) } if status.HasContents { - return Comparison{Outcome: OutcomeDestinationUnmanaged, Reason: "destination has content but no distributor state"} + return Comparison{Outcome: OutcomeDestinationUnmanaged, Reason: "destination has content but no distributor state", Detail: ComparisonDetail{Kind: ComparisonDetailUnmanagedContent}} } return Comparison{Outcome: OutcomeDestinationAbsent, Reason: "destination state is absent"} } @@ -70,45 +108,95 @@ func compareManifests(source, destination bundle.Manifest) Comparison { return Comparison{Outcome: OutcomeSameSource, Reason: "destination source manifest matches source"} } if destination.ID != source.ID { - return Comparison{Outcome: OutcomeDifferentSourceConflict, Reason: "destination source id differs from source"} + return Comparison{ + Outcome: OutcomeDifferentSourceConflict, + Reason: "destination source id differs from source", + Detail: ComparisonDetail{ + Kind: ComparisonDetailDifferentSourceID, + CurrentSourceID: source.ID, + DestinationSourceID: destination.ID, + }, + } } if destination.Created.Before(source.Created) { return Comparison{Outcome: OutcomeDestinationOlder, Reason: "destination source is older than source"} } if destination.Created.After(source.Created) { - return Comparison{Outcome: OutcomeDestinationNewer, Reason: "destination source is newer than source"} + return Comparison{ + Outcome: OutcomeDestinationNewer, + Reason: "destination source is newer than source", + Detail: ComparisonDetail{ + Kind: ComparisonDetailDestinationNewer, + CurrentSourceID: source.ID, + DestinationSourceID: destination.ID, + }, + } } if destination.Digest != source.Digest { - return Comparison{Outcome: OutcomeSameCreatedConflict, Reason: "destination source has same id and created time but different digest"} + return Comparison{ + Outcome: OutcomeSameCreatedConflict, + Reason: "destination source has same id and created time but different digest", + Detail: ComparisonDetail{ + Kind: ComparisonDetailSameCreatedDigestConflict, + CurrentSourceID: source.ID, + DestinationSourceID: destination.ID, + CurrentSourceDigest: source.Digest, + DestinationSourceDigest: destination.Digest, + }, + } } - return Comparison{Outcome: OutcomeInvalidState, Reason: "destination source differs from source without a supported comparison outcome"} + return Comparison{Outcome: OutcomeInvalidState, Reason: "destination source differs from source without a supported comparison outcome", Detail: ComparisonDetail{Kind: ComparisonDetailInvalidState}} } func Compare(source bundle.Manifest, pipelineID, destinationID string, status DestinationStatus) Comparison { if status.StateErr != nil { - return Comparison{Outcome: OutcomeInvalidState, Reason: status.StateErr.Error()} + return Comparison{Outcome: OutcomeInvalidState, Reason: status.StateErr.Error(), Detail: ComparisonDetail{Kind: ComparisonDetailInvalidState}} } if status.State == nil { if status.HasContents { - return Comparison{Outcome: OutcomeDestinationUnmanaged, Reason: "destination has content but no distributor state"} + return Comparison{Outcome: OutcomeDestinationUnmanaged, Reason: "destination has content but no distributor state", Detail: ComparisonDetail{Kind: ComparisonDetailUnmanagedContent}} } return Comparison{Outcome: OutcomeDestinationAbsent, Reason: "destination state is absent"} } destinationState := *status.State if err := Validate(destinationState); err != nil { - return Comparison{Outcome: OutcomeInvalidState, Reason: err.Error()} + return Comparison{Outcome: OutcomeInvalidState, Reason: err.Error(), Detail: ComparisonDetail{Kind: ComparisonDetailInvalidState}} } if destinationState.PipelineID != pipelineID { - return Comparison{Outcome: OutcomeIdentityMismatch, Reason: fmt.Sprintf("pipeline id %q does not match %q", destinationState.PipelineID, pipelineID)} + return pipelineIDMismatchComparison(destinationState.PipelineID, pipelineID) } if destinationState.DestinationID != destinationID { - return Comparison{Outcome: OutcomeIdentityMismatch, Reason: fmt.Sprintf("destination id %q does not match %q", destinationState.DestinationID, destinationID)} + return destinationIDMismatchComparison(destinationState.DestinationID, destinationID) } return compareManifests(source, destinationState.Source.Manifest) } +func pipelineIDMismatchComparison(destinationPipelineID, currentPipelineID string) Comparison { + return Comparison{ + Outcome: OutcomeIdentityMismatch, + Reason: fmt.Sprintf("pipeline id %q does not match %q", destinationPipelineID, currentPipelineID), + Detail: ComparisonDetail{ + Kind: ComparisonDetailPipelineIDMismatch, + CurrentPipelineID: currentPipelineID, + DestinationPipelineID: destinationPipelineID, + }, + } +} + +func destinationIDMismatchComparison(destinationDestinationID, currentDestinationID string) Comparison { + return Comparison{ + Outcome: OutcomeIdentityMismatch, + Reason: fmt.Sprintf("destination id %q does not match %q", destinationDestinationID, currentDestinationID), + Detail: ComparisonDetail{ + Kind: ComparisonDetailDestinationIDMismatch, + CurrentDestinationID: currentDestinationID, + DestinationDestinationID: destinationDestinationID, + }, + } +} + func manifestsEqual(a, b bundle.Manifest) bool { if a.SchemaVersion != b.SchemaVersion || a.ID != b.ID || diff --git a/internal/state/compare_test.go b/internal/state/compare_test.go index 8514002..4c11276 100644 --- a/internal/state/compare_test.go +++ b/internal/state/compare_test.go @@ -97,6 +97,93 @@ func TestCompareOutcomes(t *testing.T) { } } +func TestCompareReportsStructuredDetails(t *testing.T) { + source := validManifest(t) + tests := []struct { + name string + status DestinationStatus + wantKind ComparisonDetailKind + assertions func(t *testing.T, detail ComparisonDetail) + }{ + { + name: "pipeline mismatch", + status: DestinationStatus{State: withState(t, source, func(s *DistributorState) { s.PipelineID = "other" })}, + wantKind: ComparisonDetailPipelineIDMismatch, + assertions: func(t *testing.T, detail ComparisonDetail) { + t.Helper() + if detail.DestinationPipelineID != "other" || detail.CurrentPipelineID != "reports" { + t.Fatalf("detail = %#v, want pipeline ids", detail) + } + }, + }, + { + name: "destination mismatch", + status: DestinationStatus{State: withState(t, source, func(s *DistributorState) { s.DestinationID = "other" })}, + wantKind: ComparisonDetailDestinationIDMismatch, + assertions: func(t *testing.T, detail ComparisonDetail) { + t.Helper() + if detail.DestinationDestinationID != "other" || detail.CurrentDestinationID != "archive" { + t.Fatalf("detail = %#v, want destination ids", detail) + } + }, + }, + { + name: "different source id", + status: DestinationStatus{State: withState(t, source, func(s *DistributorState) { s.Source.Manifest.ID = "other.source" })}, + wantKind: ComparisonDetailDifferentSourceID, + assertions: func(t *testing.T, detail ComparisonDetail) { + t.Helper() + if detail.DestinationSourceID != "other.source" || detail.CurrentSourceID != source.ID { + t.Fatalf("detail = %#v, want source ids", detail) + } + }, + }, + { + name: "same created digest conflict", + status: DestinationStatus{State: withState(t, source, func(s *DistributorState) { + s.Source.Manifest.Files[0].SHA256 = "sha256:3333333333333333333333333333333333333333333333333333333333333333" + s.Source.Manifest.Digest = bundle.BundleDigest(s.Source.Manifest.Files) + })}, + wantKind: ComparisonDetailSameCreatedDigestConflict, + assertions: func(t *testing.T, detail ComparisonDetail) { + t.Helper() + if detail.CurrentSourceDigest == "" || detail.DestinationSourceDigest == "" || detail.CurrentSourceDigest == detail.DestinationSourceDigest { + t.Fatalf("detail = %#v, want different source digests", detail) + } + }, + }, + { + name: "destination newer", + status: DestinationStatus{State: withState(t, source, func(s *DistributorState) { + s.Source.Manifest.Created = source.Created.Add(time.Hour) + })}, + wantKind: ComparisonDetailDestinationNewer, + }, + { + name: "invalid state", + status: DestinationStatus{StateErr: errors.New("invalid json")}, + wantKind: ComparisonDetailInvalidState, + }, + { + name: "unmanaged content", + status: DestinationStatus{HasContents: true}, + wantKind: ComparisonDetailUnmanagedContent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Compare(source, "reports", "archive", tt.status) + if got.Detail.Kind != tt.wantKind { + t.Fatalf("Compare() detail kind = %q, want %q; comparison=%#v", got.Detail.Kind, tt.wantKind, got) + } + if tt.assertions != nil { + tt.assertions(t, got.Detail) + } + }) + } +} + func withState(t *testing.T, source bundle.Manifest, mutate func(*DistributorState)) *DistributorState { t.Helper() stateManifest := source diff --git a/internal/state/outputs.go b/internal/state/outputs.go index a73cf92..50980cb 100644 --- a/internal/state/outputs.go +++ b/internal/state/outputs.go @@ -191,7 +191,17 @@ func (s SharedRootState) PathOwnershipConflict(scope OwnerScope, paths []string) for _, path := range paths { owner, exists := s.OutputOwner(path) if exists && owner != scope { - return PathOwnershipConflict{Path: path, Owner: owner}, true + return PathOwnershipConflict{ + Path: path, + Owner: owner, + CurrentOwner: scope, + Detail: ComparisonDetail{ + Kind: ComparisonDetailSharedRootOutputOwner, + Path: path, + CurrentOwner: scope, + ConflictingOwner: owner, + }, + }, true } } return PathOwnershipConflict{}, false diff --git a/internal/state/shared_root.go b/internal/state/shared_root.go index 0f86fae..c70ba2f 100644 --- a/internal/state/shared_root.go +++ b/internal/state/shared_root.go @@ -57,8 +57,10 @@ type SharedRootOutputFile struct { } type PathOwnershipConflict struct { - Path string - Owner OwnerScope + Path string + Owner OwnerScope + CurrentOwner OwnerScope + Detail ComparisonDetail } type rawSharedRootState struct { diff --git a/internal/state/shared_root_test.go b/internal/state/shared_root_test.go index fdbeda8..e447973 100644 --- a/internal/state/shared_root_test.go +++ b/internal/state/shared_root_test.go @@ -130,6 +130,12 @@ func TestSharedRootOutputHelpers(t *testing.T) { if !ok || conflict.Owner != html { t.Fatalf("conflict = %#v ok=%t, want html owner conflict", conflict, ok) } + if conflict.Detail.Kind != ComparisonDetailSharedRootOutputOwner { + t.Fatalf("conflict detail kind = %q, want %q", conflict.Detail.Kind, ComparisonDetailSharedRootOutputOwner) + } + if conflict.Detail.Path != "report.html" || conflict.Detail.CurrentOwner != archive || conflict.Detail.ConflictingOwner != html { + t.Fatalf("conflict detail = %#v, want path and owners", conflict.Detail) + } } func TestRemoveMissingSharedRootOwnerOutputs(t *testing.T) { @@ -241,6 +247,12 @@ func TestCompareSharedRootOwnerScopesCurrentOwner(t *testing.T) { if missing.Outcome != OutcomeDestinationAbsent { t.Fatalf("missing owner comparison = %#v, want destination absent", missing) } + if missing.Detail.Kind != ComparisonDetailSharedRootOwnerAbsent { + t.Fatalf("missing owner detail kind = %q, want %q", missing.Detail.Kind, ComparisonDetailSharedRootOwnerAbsent) + } + if missing.Detail.CurrentOwner != CurrentOwnerScope("missing", "archive") { + t.Fatalf("missing owner detail = %#v, want missing/archive", missing.Detail) + } } func TestCompareSharedRootOwnerAcceptsMatchingSingleOwnerState(t *testing.T) {