Implement single-owner managed takeover

This commit is contained in:
2026-06-18 15:18:38 +00:00
parent 598b665307
commit c02106987f
14 changed files with 329 additions and 53 deletions

View File

@@ -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`

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 <destination-path>/.distributor.json
go run ./cmd/distributor inspect <source-root>
```
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).

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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{})

View File

@@ -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",
}

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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",
}
}