diff --git a/docs/config.md b/docs/config.md index c919a2f..a2e8a1a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -418,6 +418,24 @@ pipelines: Every output path in a shared root belongs to exactly one `pipeline_id` and `destination_id`. A different owner planning the same path fails as a conflict. +## Takeover Policy + +```yaml +takeover: + mode: same_pipeline +``` + +- `takeover.mode`: optional. Accepted values are `same_pipeline`, `same_source`, `any_managed`, and `never`; default is `same_pipeline`. + +Takeover controls when a single-owner destination may normally replace valid distributor-managed state whose pipeline, destination, or source identity differs from the current publication. + +- `same_pipeline`: replace managed state owned by the same pipeline. The previous destination id and source id may differ. +- `same_source`: replace managed state only when the existing source manifest id matches the current source id. +- `any_managed`: replace any valid distributor-managed single-owner state at the selected destination bundle path. +- `never`: do not replace identity or source conflicts without the explicit forced replacement workflow. + +Takeover does not apply to unmanaged content, invalid destination state, same-created digest conflicts, same-source destination-newer comparisons, or shared-root output paths owned by another owner. + ## Reconciliation Policy ```yaml @@ -529,6 +547,7 @@ Defaults are applied after YAML decoding and before validation: - `links.primary: auto` when a `links` block is present and `primary` is omitted - `state.mode: single_owner` - `reconciliation.mode: replace` +- `takeover.mode: same_pipeline` - `retention.prune.enabled: false` - `transfer.on_destination_same: skip` - `transfer.on_destination_older: replace` diff --git a/docs/internal/publish.md b/docs/internal/publish.md index f5b5a62..2235849 100644 --- a/docs/internal/publish.md +++ b/docs/internal/publish.md @@ -8,7 +8,7 @@ Audience: developers and LLM coding agents changing `internal/publish`. ## Inputs And Outputs -Inputs are a source bundle, source backend, destination backend, pipeline id, destination id, destination bundle path, path mapping mode, publish policy, transform policy, optional link policy, state policy, reconciliation policy, transformer resolver, transfer policy, distributor version, and force flag. +Inputs are a source bundle, source backend, destination backend, pipeline id, destination id, destination bundle path, path mapping mode, publish policy, transform policy, optional link policy, state policy, reconciliation policy, takeover policy, transformer resolver, transfer policy, distributor version, and force flag. Output from planning is a `Plan` with action, reason, destination identity, selected outputs, state mode, owner scope, reconciliation mode, optional existing single-owner or shared-root state, optional primary URL, and force metadata. Shared-root plans also expose other-owner outputs to preserve, current-owner outputs retained by merge, current-owner outputs deleted by replace, and current-owner outputs to write. Execution writes selected source outputs, generated outputs, and `.distributor.json` for executable publish or replacement actions. @@ -20,7 +20,7 @@ External destination state semantics are documented in `docs/integrations/destin ## Config Fields Used -The package consumes already-defaulted config values for destination `publish`, `transform`, `links`, `state`, `reconciliation`, `transfer`, and path mapping mode. It uses `config.ValidatePublishTransformPolicy` for publish/transform consistency. +The package consumes already-defaulted config values for destination `publish`, `transform`, `links`, `state`, `reconciliation`, `takeover`, `transfer`, and path mapping mode. It uses `config.ValidatePublishTransformPolicy` for publish/transform consistency. ## Adapters Used @@ -28,15 +28,15 @@ The package depends on `internal/storage.Backend` for source and destination IO, ## State And Manifest Behavior -Planning inspects destination state through `internal/state`, compares it with the source manifest, and maps comparison outcomes plus transfer policy into actions: `publish_new`, `replace_older`, `force_replace`, `skip_same`, `skip_destination_newer`, `fail_conflict`, or `fail_unmanaged`. +Planning inspects destination state through `internal/state`, compares it with the source manifest, and maps comparison outcomes plus transfer and takeover policy into actions: `publish_new`, `replace_older`, `replace_takeover`, `force_replace`, `skip_same`, `skip_destination_newer`, `fail_conflict`, or `fail_unmanaged`. -Single-owner destinations compare the whole destination state against the configured pipeline and destination ids. Shared-root destinations compare only the current owner scope, keyed by pipeline id and destination id. An absent shared-root owner is publishable for that owner unless a planned output collides with unmanaged storage content. Planned writes to a path owned by another shared-root owner fail as conflicts. +Single-owner destinations compare the whole destination state against the configured pipeline and destination ids. Valid managed identity and source conflicts can become `replace_takeover` when `takeover.mode` allows them. Shared-root destinations compare only the current owner scope, keyed by pipeline id and destination id. An absent shared-root owner is publishable for that owner unless a planned output collides with unmanaged storage content. Planned writes to a path owned by another shared-root owner fail as conflicts. Execution writes destination state after selected outputs are written. Destination state includes copied source output metadata, generated output metadata, output timestamps, embedded source manifest, reconciliation metadata, link metadata when configured, pipeline id, destination id, and publication timestamps. ## Skip And Resume Behavior -`skip_same` and `skip_destination_newer` execute as no-ops. Replacement-mode single-owner updates remove managed output paths from existing state plus `.distributor.json`, verify the destination is empty, and write state whose outputs are exactly the new plan. Replacement-mode shared-root updates remove only current-owner omitted outputs and preserve unrelated owners. Merge-mode updates retain omitted managed outputs, overwrite only paths already recorded as managed, reject unmanaged destination path collisions, and write cumulative output state. Failed writes trigger cleanup where practical; merge cleanup removes only newly created outputs from the failed attempt. +`skip_same` and `skip_destination_newer` execute as no-ops. Replacement-mode single-owner updates remove managed output paths from existing state plus `.distributor.json`, verify the destination is empty, and write state whose outputs are exactly the new plan. Single-owner `replace_takeover` uses the same managed replacement mechanics and does not retain omitted outputs through merge reconciliation. Replacement-mode shared-root updates remove only current-owner omitted outputs and preserve unrelated owners. Merge-mode same-source updates retain omitted managed outputs, overwrite only paths already recorded as managed, reject unmanaged destination path collisions, and write cumulative output state. Failed writes trigger cleanup where practical; merge cleanup removes only newly created outputs from the failed attempt. Shared-root execution writes schema version `3` state. It preserves unrelated owner records and outputs, updates only the publishing owner metadata, preserves root `created_at`, and updates root `updated_at` after successful state writes. Compatible single-owner state for the same pipeline and destination is converted to shared-root state on successful publish. diff --git a/docs/operations.md b/docs/operations.md index 523dcb3..bfe8995 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -67,12 +67,15 @@ Published destination bundle paths contain `.distributor.json`. See [Destination - No destination state and no destination content: publish new outputs. - Matching destination state: skip as already published. - Older destination state for the same source id: replace if transfer policy allows it. -- Newer destination state: skip by default. -- Invalid destination state, identity mismatch, different source id, or same-created digest mismatch: fail by default. +- Newer destination state for the same source id: skip by default. +- Valid single-owner state with an identity or source mismatch: replace only when destination `takeover.mode` allows it. +- Invalid destination state, identity or source mismatches not allowed by `takeover.mode`, or same-created digest mismatch: fail by default. - Content without `.distributor.json`: fail as unmanaged content by default. When destination state is older than the source, `transfer.on_destination_older` controls whether publication may proceed and `reconciliation.mode` controls how managed outputs are updated. +For single-owner takeover replacement, `reconciliation.mode: merge` does not retain omitted outputs from the previous source identity. The destination is rewritten as a managed replacement for the current source. + `reconciliation.mode: replace` is the default. It deletes only managed output paths recorded in `.distributor.json` plus the state file, verifies the destination bundle path is empty, then writes the newly planned outputs and state. The new state `outputs` array is exactly the newly planned output set. `reconciliation.mode: merge` retains prior managed outputs that are omitted from the new plan. It overwrites planned paths only when those paths are already recorded in existing state as managed. If a newly planned path already exists in storage but is not recorded in state, publication fails as an unmanaged path collision. The new state `outputs` array is the cumulative managed output set. @@ -150,12 +153,13 @@ For single-owner state, the state owner must match the selected pipeline and des `run --dry-run` loads config, resolves credentials, discovers source bundles, opens destinations, inspects destination state, builds publish plans, and prints actions. It does not write outputs, `.distributor.json`, or SSH `known_hosts` entries. For reconciliation, dry runs report the same high-level action labels as execution; inspect the configured destination's `reconciliation.mode` to determine whether `replace_older` will replace the managed set or merge into it. -For shared-root destinations, dry runs are owner-scoped. A `replace_older` action replaces or merges only the current owner according to `reconciliation.mode`; unrelated owners remain managed by the shared-root state. +For shared-root destinations, dry runs are owner-scoped. A `replace_older` action replaces or merges only the current owner according to `reconciliation.mode`; unrelated owners remain managed by the shared-root state. Paths owned by another owner still fail as conflicts. Review these action labels before publishing: - `publish_new`: destination state is absent, or a shared-root owner is absent and planned paths are publishable. - `replace_older`: destination state is older than the source. +- `replace_takeover`: single-owner destination state is valid managed state and `takeover.mode` allows replacement across an identity or source mismatch. - `skip_same`: destination state already matches the source. - `skip_destination_newer`: destination state is newer than the source and is skipped. - `force_replace`: destructive replacement selected because `--force` is present and policy permits it. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4e48158..2f6bcf2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -205,7 +205,7 @@ Reference: [Operations](operations.md#forced-replacement-workflow). Symptom: `fail_conflict`, `destination source id differs`, `same id and created time but different digest`, `pipeline id ... does not match`, or `destination id ... does not match`. -Likely cause: `.distributor.json` belongs to a different pipeline, destination, source id, or same-created source with different content. +Likely cause: `.distributor.json` belongs to a different pipeline, destination, source id, or same-created source with different content. For single-owner state, identity and source mismatches can publish as `replace_takeover` only when destination `takeover.mode` allows them. Diagnostic: @@ -214,7 +214,7 @@ cat /.distributor.json go run ./cmd/distributor inspect ``` -Safe fix: verify the source and destination are intended to match. Use a separate destination path for unrelated content. To replace the existing state, configure `transfer.on_conflict: replace`, preview with `--dry-run --force`, then publish with `--force`. +Safe fix: verify the source and destination are intended to match. Use a separate destination path for unrelated content. For normal single-owner managed replacement, configure destination `takeover.mode` to match the intended ownership boundary. To force exceptional replacement, configure `transfer.on_conflict: replace`, preview with `--dry-run --force`, then publish with `--force`. Reference: [Operations](operations.md#destination-state-and-retry-behavior). diff --git a/internal/app/run_destination.go b/internal/app/run_destination.go index d527ccb..23a6990 100644 --- a/internal/app/run_destination.go +++ b/internal/app/run_destination.go @@ -71,6 +71,7 @@ func processDestinationSelection(ctx context.Context, request runDestinationRequ Links: request.destination.Links, State: request.destination.State, Reconciliation: request.destination.Reconciliation, + Takeover: request.destination.Takeover, Transformers: request.transforms, Transfer: request.destination.Transfer, DistributorVersion: Version, diff --git a/internal/app/run_notify.go b/internal/app/run_notify.go index 630865f..c4cc50b 100644 --- a/internal/app/run_notify.go +++ b/internal/app/run_notify.go @@ -6,7 +6,7 @@ import ( ) func shouldNotify(action publish.Action) bool { - return action == publish.ActionPublishNew || action == publish.ActionReplaceOlder || action == publish.ActionForceReplace + return action == publish.ActionPublishNew || action == publish.ActionReplaceOlder || action == publish.ActionReplaceTakeover || action == publish.ActionForceReplace } func notifyEvent(plan publish.Plan) notify.Event { diff --git a/internal/app/run_selection.go b/internal/app/run_selection.go index 7b20954..034f6eb 100644 --- a/internal/app/run_selection.go +++ b/internal/app/run_selection.go @@ -63,7 +63,7 @@ func fixedPathSelectionWarning(pipelineID, destinationID string, selections []de } func isDestructiveFixedPathAction(action publish.Action) bool { - return action == publish.ActionReplaceOlder || action == publish.ActionForceReplace + return action == publish.ActionReplaceOlder || action == publish.ActionReplaceTakeover || action == publish.ActionForceReplace } func fixedPathReplacementWarning(plan publish.Plan) OutputWarning { diff --git a/internal/app/run_test.go b/internal/app/run_test.go index fd3c459..63905b2 100644 --- a/internal/app/run_test.go +++ b/internal/app/run_test.go @@ -592,8 +592,8 @@ func TestRunFixedPathDryRunWarnsForReplacement(t *testing.T) { } output := stdout.String() for _, want := range []string{ - "Warning: pipeline=reports destination=archive path_mapping=fixed action=replace_older replaces destination root for selected_bundle=new", - "bundle=new destination=archive backend=local path_mapping=fixed target=. action=replace_older", + "Warning: pipeline=reports destination=archive path_mapping=fixed action=replace_takeover replaces destination root for selected_bundle=new", + "bundle=new destination=archive backend=local path_mapping=fixed target=. action=replace_takeover", } { if !strings.Contains(output, want) { t.Fatalf("stdout = %q, want substring %q", output, want) @@ -642,7 +642,7 @@ func TestRunFixedPathSkipsWhenDestinationStateIsNewer(t *testing.T) { sourceRoot := t.TempDir() destinationRoot := t.TempDir() newer := testutil.ValidManifest(testutil.BundleOptions{ - ID: "reports.newer", + ID: "reports.same", Created: testutil.DefaultCreated.Add(time.Hour), }) writeDestinationState(t, destinationRoot, "", newer) @@ -650,7 +650,7 @@ func TestRunFixedPathSkipsWhenDestinationStateIsNewer(t *testing.T) { t.Fatalf("write existing report: %v", err) } writeSourceBundle(t, sourceRoot, "older", testBundleOptions{ - ID: "reports.older", + ID: "reports.same", Created: testutil.DefaultCreated, }) @@ -1526,14 +1526,27 @@ func TestRunSkipsNewerDestination(t *testing.T) { testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "newer\n") } -func TestRunFailsOnConflict(t *testing.T) { +func TestRunTakeoverNeverFailsOnConflict(t *testing.T) { sourceRoot := t.TempDir() destinationRoot := t.TempDir() manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{}) manifest.ID = "other.source" writeDestinationState(t, destinationRoot, "", manifest) + configPath := writeConfigFile(t, ` +pipelines: + - id: reports + source: + backend: local + path: `+sourceRoot+` + destinations: + - id: archive + backend: local + path: `+destinationRoot+` + takeover: + mode: never +`) - err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot)}) + err := Run(context.Background(), RunOptions{ConfigPath: configPath}) if err == nil || !strings.Contains(err.Error(), "fail_conflict") { t.Fatalf("Run() error = %v, want fail_conflict", err) } diff --git a/internal/publish/execute.go b/internal/publish/execute.go index 948bed9..ead10a0 100644 --- a/internal/publish/execute.go +++ b/internal/publish/execute.go @@ -16,7 +16,7 @@ func Execute(ctx context.Context, req Request, plan Plan) error { switch plan.Action { case ActionSkipSame, ActionSkipDestinationNewer: return nil - case ActionPublishNew, ActionReplaceOlder, ActionForceReplace: + case ActionPublishNew, ActionReplaceOlder, ActionReplaceTakeover, ActionForceReplace: if usesSharedRootState(req, plan) { return executeSharedRoot(ctx, req, plan) } @@ -24,11 +24,11 @@ func Execute(ctx context.Context, req Request, plan Plan) error { return fmt.Errorf("cannot execute action %s: %s", plan.Action, plan.Reason) } - if plan.Action == ActionReplaceOlder { + if plan.Action == ActionReplaceOlder || plan.Action == ActionReplaceTakeover { if plan.ExistingState == nil { return fmt.Errorf("replace requires existing destination state") } - if plan.Reconciliation.Mode == config.ReconciliationModeReplace { + if plan.Reconciliation.Mode == config.ReconciliationModeReplace || plan.Action == ActionReplaceTakeover { if err := req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, state.ManagedOutputPaths(*plan.ExistingState), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil { return err } diff --git a/internal/publish/execute_test.go b/internal/publish/execute_test.go index 4e00c39..51170ac 100644 --- a/internal/publish/execute_test.go +++ b/internal/publish/execute_test.go @@ -189,7 +189,6 @@ func TestExecuteFixedPathSupportsReconciliationModes(t *testing.T) { }) destinationBackend := fake.New() older := sourceBundle.Manifest - older.ID = "older.source" older.Created = older.Created.Add(-time.Hour) testutil.WriteFakeDestinationState(t, destinationBackend, "", older, testutil.DestinationStateOptions{}) diff --git a/internal/publish/force_test.go b/internal/publish/force_test.go index 7e94813..6886bd6 100644 --- a/internal/publish/force_test.go +++ b/internal/publish/force_test.go @@ -166,6 +166,7 @@ func forceRequest(sourceBackend, destinationBackend *fake.Backend, sourceBundle DestinationBackend: destinationBackend, DestinationBundlePath: sourceBundle.RootRelativePath, Publish: config.PublishPolicy{Source: true}, + Takeover: config.TakeoverPolicy{Mode: config.TakeoverModeNever}, Transfer: transfer, DistributorVersion: "test", } diff --git a/internal/publish/plan.go b/internal/publish/plan.go index e17447a..93da7db 100644 --- a/internal/publish/plan.go +++ b/internal/publish/plan.go @@ -21,6 +21,7 @@ const ( ActionFailConflict Action = "fail_conflict" ActionFailUnmanaged Action = "fail_unmanaged" ActionForceReplace Action = "force_replace" + ActionReplaceTakeover Action = "replace_takeover" ) type Request struct { @@ -36,6 +37,7 @@ type Request struct { Links *config.Links State config.StatePolicy Reconciliation config.ReconciliationPolicy + Takeover config.TakeoverPolicy Transformers TransformerResolver Transfer config.TransferPolicy DistributorVersion string @@ -96,10 +98,14 @@ func Build(ctx context.Context, req Request) (Plan, error) { if err != nil { return Plan{}, err } - comparison := compareDestination(req, status) - action, reason := actionForComparison(comparison, req.Transfer, req.Force) reconciliation := normalizeReconciliation(req.Reconciliation) stateMode := normalizeState(req.State).Mode + comparison := compareDestination(req, status) + action, reason := actionForComparison(comparison, req.Transfer, req.Force) + if takeoverActionAllowed(req, status, comparison, stateMode, action) { + action = ActionReplaceTakeover + reason = comparison.Reason + } plan := Plan{ PipelineID: req.PipelineID, DestinationID: req.DestinationID, @@ -162,6 +168,11 @@ func validateRequest(req Request) error { default: return fmt.Errorf("state.mode must be %s or %s", config.StateModeSingleOwner, config.StateModeSharedRoot) } + switch normalizeTakeover(req.Takeover).Mode { + case config.TakeoverModeSamePipeline, config.TakeoverModeSameSource, config.TakeoverModeAnyManaged, config.TakeoverModeNever: + default: + return fmt.Errorf("takeover.mode must be %s, %s, %s, or %s", config.TakeoverModeSamePipeline, config.TakeoverModeSameSource, config.TakeoverModeAnyManaged, config.TakeoverModeNever) + } return nil } @@ -179,6 +190,13 @@ func normalizeState(policy config.StatePolicy) config.StatePolicy { return policy } +func normalizeTakeover(policy config.TakeoverPolicy) config.TakeoverPolicy { + if policy.Mode == "" { + policy.Mode = config.TakeoverModeSamePipeline + } + return policy +} + func compareDestination(req Request, status state.DestinationStatus) state.Comparison { if normalizeState(req.State).Mode == config.StateModeSharedRoot { scope := state.CurrentOwnerScope(req.PipelineID, req.DestinationID) @@ -207,24 +225,6 @@ func compareDestination(req Request, status state.DestinationStatus) state.Compa return comparison } comparison := state.Compare(req.SourceBundle.Manifest, req.PipelineID, req.DestinationID, status) - if req.PathMapping != config.PathMappingFixed || comparison.Outcome != state.OutcomeDifferentSourceConflict || status.State == nil { - return comparison - } - destinationManifest := status.State.Source.Manifest - if destinationManifest.Created.Before(req.SourceBundle.Manifest.Created) { - 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", - Detail: state.ComparisonDetail{ - Kind: state.ComparisonDetailDestinationNewer, - CurrentSourceID: req.SourceBundle.Manifest.ID, - DestinationSourceID: destinationManifest.ID, - }, - } - } return comparison } @@ -296,7 +296,7 @@ func planSharedRootOwner(ctx context.Context, req Request, status state.Destinat func isWriteAction(action Action) bool { switch action { - case ActionPublishNew, ActionReplaceOlder, ActionForceReplace: + case ActionPublishNew, ActionReplaceOlder, ActionReplaceTakeover, ActionForceReplace: return true default: return false @@ -439,3 +439,32 @@ func actionForComparison(comparison state.Comparison, transfer config.TransferPo return ActionFailConflict, "unsupported comparison outcome" } } + +func takeoverActionAllowed(req Request, status state.DestinationStatus, comparison state.Comparison, stateMode string, action Action) bool { + if stateMode != config.StateModeSingleOwner || status.State == nil { + return false + } + if action == ActionForceReplace { + return false + } + switch comparison.Detail.Kind { + case state.ComparisonDetailPipelineIDMismatch, + state.ComparisonDetailDestinationIDMismatch, + state.ComparisonDetailDifferentSourceID: + default: + return false + } + takeover := normalizeTakeover(req.Takeover) + switch takeover.Mode { + case config.TakeoverModeSamePipeline: + return status.State.PipelineID == req.PipelineID + case config.TakeoverModeSameSource: + return status.State.Source.Manifest.ID == req.SourceBundle.Manifest.ID + case config.TakeoverModeAnyManaged: + return true + case config.TakeoverModeNever: + return false + default: + return false + } +} diff --git a/internal/publish/plan_test.go b/internal/publish/plan_test.go index 0bb307e..7a31d4c 100644 --- a/internal/publish/plan_test.go +++ b/internal/publish/plan_test.go @@ -2,7 +2,6 @@ package publish import ( "testing" - "time" "gitea.maximumdirect.net/eric/distributor/internal/config" "gitea.maximumdirect.net/eric/distributor/internal/state" @@ -10,12 +9,11 @@ import ( "gitea.maximumdirect.net/eric/distributor/internal/testutil" ) -func TestCompareDestinationFixedPathReportsDestinationNewerDetail(t *testing.T) { +func TestCompareDestinationFixedPathPreservesDifferentSourceConflict(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", @@ -26,14 +24,11 @@ func TestCompareDestinationFixedPathReportsDestinationNewerDetail(t *testing.T) 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.Outcome != state.OutcomeDifferentSourceConflict { + t.Fatalf("comparison outcome = %s, want %s", comparison.Outcome, state.OutcomeDifferentSourceConflict) } - 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.Kind != state.ComparisonDetailDifferentSourceID { + t.Fatalf("detail kind = %q, want %q", comparison.Detail.Kind, state.ComparisonDetailDifferentSourceID) } 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/publish/takeover_test.go b/internal/publish/takeover_test.go new file mode 100644 index 0000000..d8d3f7b --- /dev/null +++ b/internal/publish/takeover_test.go @@ -0,0 +1,215 @@ +package publish + +import ( + "context" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/distributor/internal/bundle" + "gitea.maximumdirect.net/eric/distributor/internal/config" + "gitea.maximumdirect.net/eric/distributor/internal/storage" + "gitea.maximumdirect.net/eric/distributor/internal/storage/fake" + "gitea.maximumdirect.net/eric/distributor/internal/testutil" +) + +func TestBuildPlansSingleOwnerTakeoverByPolicy(t *testing.T) { + tests := []struct { + name string + takeover config.TakeoverPolicy + mutateState func(*bundle.Manifest, *testutil.DestinationStateOptions) + wantAction Action + wantErr string + }{ + { + name: "default same pipeline different source", + takeover: config.TakeoverPolicy{}, + mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) { + manifest.ID = "other.source" + }, + wantAction: ActionReplaceTakeover, + }, + { + name: "same pipeline different newer source", + takeover: config.TakeoverPolicy{Mode: config.TakeoverModeSamePipeline}, + mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) { + manifest.ID = "other.source" + manifest.Created = manifest.Created.AddDate(0, 0, 1) + }, + wantAction: ActionReplaceTakeover, + }, + { + name: "default same pipeline different destination", + takeover: config.TakeoverPolicy{}, + mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) { + opts.DestinationID = "web" + }, + wantAction: ActionReplaceTakeover, + }, + { + name: "same pipeline refuses different pipeline", + takeover: config.TakeoverPolicy{Mode: config.TakeoverModeSamePipeline}, + mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) { + opts.PipelineID = "other" + }, + wantErr: "fail_conflict", + }, + { + name: "same source allows different pipeline", + takeover: config.TakeoverPolicy{Mode: config.TakeoverModeSameSource}, + mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) { + opts.PipelineID = "other" + }, + wantAction: ActionReplaceTakeover, + }, + { + name: "same source refuses different source", + takeover: config.TakeoverPolicy{Mode: config.TakeoverModeSameSource}, + mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) { + manifest.ID = "other.source" + }, + wantErr: "fail_conflict", + }, + { + name: "any managed allows different pipeline", + takeover: config.TakeoverPolicy{Mode: config.TakeoverModeAnyManaged}, + mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) { + manifest.ID = "other.source" + opts.PipelineID = "other" + }, + wantAction: ActionReplaceTakeover, + }, + { + name: "never refuses different source", + takeover: config.TakeoverPolicy{Mode: config.TakeoverModeNever}, + mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) { + manifest.ID = "other.source" + }, + wantErr: "fail_conflict", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sourceBackend := fake.New() + sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{}) + destinationBackend := fake.New() + destinationManifest := sourceBundle.Manifest + destinationManifest.Files = append([]bundle.ManifestFile(nil), sourceBundle.Manifest.Files...) + opts := testutil.DestinationStateOptions{} + tt.mutateState(&destinationManifest, &opts) + testutil.WriteFakeDestinationState(t, destinationBackend, "bundle", destinationManifest, opts) + + req := takeoverRequest(sourceBackend, destinationBackend, sourceBundle, tt.takeover, config.ReconciliationModeReplace) + plan, err := Build(context.Background(), req) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Build() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if plan.Action != tt.wantAction { + t.Fatalf("plan action = %s, want %s", plan.Action, tt.wantAction) + } + }) + } +} + +func TestBuildDoesNotTakeOverInvalidOrUnmanagedDestination(t *testing.T) { + tests := []struct { + name string + prepare func(t *testing.T, backend *fake.Backend) + wantErr string + }{ + { + name: "invalid state", + prepare: func(t *testing.T, backend *fake.Backend) { + t.Helper() + statePath, err := storage.StatePath("bundle") + if err != nil { + t.Fatalf("state path: %v", err) + } + testutil.WriteFakeFile(t, backend, statePath, "{invalid") + }, + wantErr: "fail_conflict", + }, + { + name: "unmanaged content", + prepare: func(t *testing.T, backend *fake.Backend) { + t.Helper() + testutil.WriteFakeFile(t, backend, "bundle/old.txt", "old") + }, + wantErr: "fail_unmanaged", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sourceBackend := fake.New() + sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{}) + destinationBackend := fake.New() + tt.prepare(t, destinationBackend) + + req := takeoverRequest(sourceBackend, destinationBackend, sourceBundle, config.TakeoverPolicy{Mode: config.TakeoverModeAnyManaged}, config.ReconciliationModeReplace) + _, err := Build(context.Background(), req) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Build() error = %v, want %q", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTakeoverMergeDoesNotRetainOldSourceOutputs(t *testing.T) { + sourceBackend := fake.New() + sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{ + Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\nNew.\n"}}, + }) + destinationBackend := fake.New() + oldManifest := sourceBundle.Manifest + oldManifest.ID = "old.source" + oldManifest.Files = []bundle.ManifestFile{ + {Path: "report.md", SHA256: bundle.FileDigest([]byte("old\n")), Size: int64(len("old\n"))}, + {Path: "summary.txt", SHA256: bundle.FileDigest([]byte("old summary\n")), Size: int64(len("old summary\n"))}, + } + oldManifest.Digest = bundle.BundleDigest(oldManifest.Files) + testutil.WriteFakeDestinationState(t, destinationBackend, "bundle", oldManifest, testutil.DestinationStateOptions{}) + + req := takeoverRequest(sourceBackend, destinationBackend, sourceBundle, config.TakeoverPolicy{Mode: config.TakeoverModeSamePipeline}, config.ReconciliationModeMerge) + plan, err := Build(context.Background(), req) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if plan.Action != ActionReplaceTakeover { + t.Fatalf("plan action = %s, want %s", plan.Action, ActionReplaceTakeover) + } + if err := Execute(context.Background(), req, plan); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + testutil.AssertFakeFile(t, destinationBackend, "bundle/report.md", "# Report\nNew.\n") + testutil.AssertFakeMissing(t, destinationBackend, "bundle/summary.txt") + destinationState := readFakeState(t, destinationBackend, "bundle") + if got, want := len(destinationState.Outputs), 1; got != want { + t.Fatalf("state output count = %d, want %d", got, want) + } + if got, want := destinationState.Source.Manifest.ID, sourceBundle.Manifest.ID; got != want { + t.Fatalf("state source id = %q, want %q", got, want) + } +} + +func takeoverRequest(sourceBackend, destinationBackend *fake.Backend, sourceBundle bundle.Bundle, takeover config.TakeoverPolicy, reconciliationMode string) Request { + return Request{ + PipelineID: "reports", + DestinationID: "archive", + SourceBundle: sourceBundle, + SourceBackend: sourceBackend, + DestinationBackend: destinationBackend, + DestinationBundlePath: sourceBundle.RootRelativePath, + Publish: config.PublishPolicy{Source: true}, + Reconciliation: config.ReconciliationPolicy{Mode: reconciliationMode}, + Takeover: takeover, + Transfer: defaultTransfer(), + DistributorVersion: "test", + } +}