Update policy for replacement of managed files

This commit is contained in:
2026-06-18 13:38:44 -05:00
parent 8b0ce4d134
commit 69043801d0
18 changed files with 330 additions and 107 deletions

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, ActionReplaceTakeover, ActionForceReplace:
case ActionPublishNew, ActionReplaceOlder, ActionReplaceConflict, ActionReplaceNewer, 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 || plan.Action == ActionReplaceTakeover {
if plan.Action == ActionReplaceOlder || plan.Action == ActionReplaceConflict || plan.Action == ActionReplaceNewer || plan.Action == ActionReplaceTakeover {
if plan.ExistingState == nil {
return fmt.Errorf("replace requires existing destination state")
}
if plan.Reconciliation.Mode == config.ReconciliationModeReplace || plan.Action == ActionReplaceTakeover {
if plan.Reconciliation.Mode == config.ReconciliationModeReplace || plan.Action == ActionReplaceConflict || 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
}
@@ -148,7 +148,7 @@ func executeSharedRoot(ctx context.Context, req Request, plan Plan) error {
return err
}
}
if plan.Action == ActionReplaceTakeover || (plan.Action == ActionReplaceOlder && plan.Reconciliation.Mode == config.ReconciliationModeReplace) {
if plan.Action == ActionReplaceTakeover || plan.Action == ActionReplaceConflict || (isReconciliationReplacementAction(plan.Action) && plan.Reconciliation.Mode == config.ReconciliationModeReplace) {
if err := req.DestinationBackend.DeleteManagedOutputs(ctx, req.DestinationBundlePath, sharedRootOutputPaths(plan.OwnerOutputsToDelete), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
return err
}
@@ -158,7 +158,7 @@ func executeSharedRoot(ctx context.Context, req Request, plan Plan) error {
newOutputs := make([]Output, 0, len(plan.Outputs))
cleanup := func() {
outputs := writtenOutputs
if plan.Action == ActionReplaceOlder && plan.Reconciliation.Mode == config.ReconciliationModeMerge {
if isReconciliationReplacementAction(plan.Action) && plan.Reconciliation.Mode == config.ReconciliationModeMerge {
outputs = newOutputs
}
_ = req.DestinationBackend.DeleteManagedOutputs(ctx, req.DestinationBundlePath, ManagedOutputPaths(outputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true})
@@ -278,7 +278,7 @@ func stateOutputsForPlan(plan Plan, now time.Time) ([]state.OutputFile, error) {
}
func usesMergeRetention(plan Plan) bool {
return plan.Reconciliation.Mode == config.ReconciliationModeMerge && plan.Action == ActionReplaceOlder
return plan.Reconciliation.Mode == config.ReconciliationModeMerge && isReconciliationReplacementAction(plan.Action)
}
func sharedRootStateForPlan(req Request, plan Plan, now time.Time) (state.SharedRootState, error) {
@@ -298,7 +298,7 @@ func sharedRootStateForPlan(req Request, plan Plan, now time.Time) (state.Shared
owner.Links = &state.LinkState{PrimaryURL: plan.PrimaryURL}
}
planned := state.ProjectSharedRootOutputs(StateOutputProjections(plan.Outputs), currentOwnerSharedRootOutputs(plan), scope, req.SourceBundle.Manifest, now)
if plan.Action == ActionReplaceOlder && plan.Reconciliation.Mode == config.ReconciliationModeMerge {
if isReconciliationReplacementAction(plan.Action) && plan.Reconciliation.Mode == config.ReconciliationModeMerge {
return state.MergeOwnerOutputs(base, scope, owner, planned)
}
return state.ReplaceOwnerOutputs(base, scope, owner, planned)

View File

@@ -120,6 +120,71 @@ func TestExecuteMergeRetainsOmittedAndOverwritesManagedOutputs(t *testing.T) {
}
}
func TestExecuteReplaceConflictDeletesOmittedManagedOutputs(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{
Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\nNew.\n"}},
})
destinationBackend := fake.New()
conflict := sourceBundle.Manifest
conflict.ID = "other.source"
conflict.Files = append([]bundle.ManifestFile(nil), testutil.ValidManifest(testutil.BundleOptions{}).Files...)
conflict.Digest = bundle.BundleDigest(conflict.Files)
testutil.WriteFakeDestinationState(t, destinationBackend, "", conflict, testutil.DestinationStateOptions{})
req := testRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeMerge)
req.Takeover = config.TakeoverPolicy{Mode: config.TakeoverModeNever}
req.Transfer.OnConflict = config.TransferActionReplace
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionReplaceConflict {
t.Fatalf("plan action = %s, want replace_conflict", plan.Action)
}
if err := Execute(context.Background(), req, plan); err != nil {
t.Fatalf("Execute() error = %v", err)
}
testutil.AssertFakeFile(t, destinationBackend, "report.md", "# Report\nNew.\n")
testutil.AssertFakeMissing(t, destinationBackend, "summary.txt")
destinationState := readFakeState(t, destinationBackend, "")
outputs := outputsByPath(destinationState.Outputs)
if got, want := len(outputs), 1; got != want {
t.Fatalf("state output count = %d, want %d", got, want)
}
if _, ok := outputs["summary.txt"]; ok {
t.Fatalf("state retained summary.txt after replace_conflict: %#v", destinationState.Outputs)
}
}
func TestExecuteReplaceNewerUsesManagedReplacement(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{
Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\nNew.\n"}},
})
destinationBackend := fake.New()
newer := sourceBundle.Manifest
newer.Created = newer.Created.Add(time.Hour)
newer.Files = append([]bundle.ManifestFile(nil), testutil.ValidManifest(testutil.BundleOptions{}).Files...)
newer.Digest = bundle.BundleDigest(newer.Files)
testutil.WriteFakeDestinationState(t, destinationBackend, "", newer, testutil.DestinationStateOptions{})
req := testRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeReplace)
req.Transfer.OnDestinationNewer = config.TransferActionReplace
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionReplaceNewer {
t.Fatalf("plan action = %s, want replace_newer", plan.Action)
}
if err := Execute(context.Background(), req, plan); err != nil {
t.Fatalf("Execute() error = %v", err)
}
testutil.AssertFakeFile(t, destinationBackend, "report.md", "# Report\nNew.\n")
testutil.AssertFakeMissing(t, destinationBackend, "summary.txt")
}
func TestExecuteMergeFailsOnUnmanagedDestinationPathCollision(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{

View File

@@ -29,61 +29,6 @@ func TestBuildPlansForcedReplacementOnlyWhenExplicit(t *testing.T) {
wantReason: "fail_unmanaged",
forceAction: true,
},
{
name: "different source id",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
conflict := source
conflict.ID = "other.source"
testutil.WriteFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{})
},
transfer: conflictReplaceTransfer(),
wantReason: "requires --force",
forceAction: true,
},
{
name: "same created digest conflict",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
conflict := testutil.ValidManifest(testutil.BundleOptions{Files: []testutil.SourceFile{{Path: "report.md", Data: "# Different\n"}}})
testutil.WriteFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{})
},
transfer: conflictReplaceTransfer(),
wantReason: "requires --force",
forceAction: true,
},
{
name: "pipeline mismatch",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
testutil.WriteFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{PipelineID: "other-pipeline"})
},
transfer: conflictReplaceTransfer(),
wantReason: "requires --force",
forceAction: true,
},
{
name: "destination mismatch",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
testutil.WriteFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{DestinationID: "other-destination"})
},
transfer: conflictReplaceTransfer(),
wantReason: "requires --force",
forceAction: true,
},
{
name: "newer destination",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
newer := source
newer.Created = newer.Created.AddDate(0, 0, 1)
testutil.WriteFakeDestinationState(t, backend, "bundle", newer, testutil.DestinationStateOptions{})
},
transfer: newerReplaceTransfer(),
wantReason: "requires --force",
forceAction: true,
},
}
for _, tt := range tests {
@@ -113,6 +58,89 @@ func TestBuildPlansForcedReplacementOnlyWhenExplicit(t *testing.T) {
}
}
func TestBuildPlansConflictReplacementWithoutForce(t *testing.T) {
tests := []struct {
name string
prepare func(t *testing.T, backend *fake.Backend, source bundle.Manifest)
wantReason string
}{
{
name: "different source id",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
conflict := source
conflict.ID = "other.source"
testutil.WriteFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{})
},
wantReason: "destination source id differs",
},
{
name: "same created digest conflict",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
conflict := testutil.ValidManifest(testutil.BundleOptions{Files: []testutil.SourceFile{{Path: "report.md", Data: "# Different\n"}}})
testutil.WriteFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{})
},
wantReason: "same id and created time but different digest",
},
{
name: "pipeline mismatch",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
testutil.WriteFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{PipelineID: "other-pipeline"})
},
wantReason: "pipeline id",
},
{
name: "destination mismatch",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
testutil.WriteFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{DestinationID: "other-destination"})
},
wantReason: "destination id",
},
}
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, sourceBundle.Manifest)
req := forceRequest(sourceBackend, destinationBackend, sourceBundle, conflictReplaceTransfer())
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionReplaceConflict || plan.Force {
t.Fatalf("plan action = %s force=%t, want replace_conflict without force", plan.Action, plan.Force)
}
if !strings.Contains(plan.Reason, tt.wantReason) {
t.Fatalf("plan reason = %q, want %q", plan.Reason, tt.wantReason)
}
})
}
}
func TestBuildPlansNewerReplacementWithoutForce(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{})
destinationBackend := fake.New()
newer := sourceBundle.Manifest
newer.Created = newer.Created.AddDate(0, 0, 1)
testutil.WriteFakeDestinationState(t, destinationBackend, "bundle", newer, testutil.DestinationStateOptions{})
req := forceRequest(sourceBackend, destinationBackend, sourceBundle, newerReplaceTransfer())
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionReplaceNewer || plan.Force {
t.Fatalf("plan action = %s force=%t, want replace_newer without force", plan.Action, plan.Force)
}
}
func TestBuildRequiresConflictPolicyForStateConflicts(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{})

View File

@@ -16,6 +16,8 @@ type Action string
const (
ActionPublishNew Action = "publish_new"
ActionReplaceOlder Action = "replace_older"
ActionReplaceConflict Action = "replace_conflict"
ActionReplaceNewer Action = "replace_newer"
ActionSkipSame Action = "skip_same"
ActionSkipDestinationNewer Action = "skip_destination_newer"
ActionFailConflict Action = "fail_conflict"
@@ -269,16 +271,21 @@ func planSharedRootOwner(ctx context.Context, req Request, status state.Destinat
}
conflicts := sharedRootPathOwnershipConflicts(status, scope, plannedPaths)
if len(conflicts) > 0 {
conflictAction := ActionReplaceTakeover
for _, conflict := range conflicts {
if sharedRootTakeoverAllowed(req, status, conflict) {
continue
}
if req.Transfer.OnConflict == config.TransferActionReplace {
conflictAction = ActionReplaceConflict
continue
}
reason := sharedRootOwnershipConflictReason(conflict)
details.Action = ActionFailConflict
details.Reason = reason
return details, fmt.Errorf("%s: %s", ActionFailConflict, reason)
}
details.Action = ActionReplaceTakeover
details.Action = conflictAction
details.Reason = sharedRootOwnershipConflictReason(conflicts[0])
details.TakenOverOwnerOutputs = sharedRootConflictOutputs(status.SharedRoot, conflicts)
}
@@ -299,11 +306,11 @@ func planSharedRootOwner(ctx context.Context, req Request, status state.Destinat
if _, exists := planned[output.Path]; exists {
continue
}
if details.Action == ActionReplaceTakeover || (details.Action == ActionReplaceOlder && reconciliation.Mode == config.ReconciliationModeReplace) {
if details.Action == ActionReplaceTakeover || details.Action == ActionReplaceConflict || (isReconciliationReplacementAction(details.Action) && reconciliation.Mode == config.ReconciliationModeReplace) {
details.OwnerOutputsToDelete = append(details.OwnerOutputsToDelete, output)
continue
}
if details.Action == ActionReplaceOlder && reconciliation.Mode == config.ReconciliationModeMerge {
if isReconciliationReplacementAction(details.Action) && reconciliation.Mode == config.ReconciliationModeMerge {
details.RetainedOwnerOutputs = append(details.RetainedOwnerOutputs, output)
}
}
@@ -313,7 +320,7 @@ func planSharedRootOwner(ctx context.Context, req Request, status state.Destinat
func isWriteAction(action Action) bool {
switch action {
case ActionPublishNew, ActionReplaceOlder, ActionReplaceTakeover, ActionForceReplace:
case ActionPublishNew, ActionReplaceOlder, ActionReplaceConflict, ActionReplaceNewer, ActionReplaceTakeover, ActionForceReplace:
return true
default:
return false
@@ -506,10 +513,7 @@ func actionForComparison(comparison state.Comparison, transfer config.TransferPo
return ActionFailConflict, comparison.Reason
case state.OutcomeIdentityMismatch, state.OutcomeSameCreatedConflict, state.OutcomeDifferentSourceConflict:
if transfer.OnConflict == config.TransferActionReplace {
if force {
return ActionForceReplace, "forced replacement of conflicting destination state: " + comparison.Reason
}
return ActionFailConflict, "destination conflict replacement requires --force"
return ActionReplaceConflict, comparison.Reason
}
return ActionFailConflict, comparison.Reason
case state.OutcomeSameSource:
@@ -524,10 +528,7 @@ func actionForComparison(comparison state.Comparison, transfer config.TransferPo
return ActionReplaceOlder, comparison.Reason
case state.OutcomeDestinationNewer:
if transfer.OnDestinationNewer == config.TransferActionReplace {
if force {
return ActionForceReplace, "forced replacement of newer destination state"
}
return ActionFailConflict, "destination is newer and replacement requires --force"
return ActionReplaceNewer, comparison.Reason
}
if transfer.OnDestinationNewer == config.TransferActionFail {
return ActionFailConflict, "destination is newer and transfer policy requires failure"
@@ -538,6 +539,10 @@ func actionForComparison(comparison state.Comparison, transfer config.TransferPo
}
}
func isReconciliationReplacementAction(action Action) bool {
return action == ActionReplaceOlder || action == ActionReplaceNewer
}
func takeoverActionAllowed(req Request, status state.DestinationStatus, comparison state.Comparison, stateMode string, action Action) bool {
if stateMode != config.StateModeSingleOwner || status.State == nil {
return false

View File

@@ -117,6 +117,7 @@ func TestBuildSharedRootPlansOutputTakeoverByPolicy(t *testing.T) {
tests := []struct {
name string
takeover config.TakeoverPolicy
transfer config.TransferPolicy
ownerScope state.OwnerScope
sameSource bool
wantAction Action
@@ -159,6 +160,13 @@ func TestBuildSharedRootPlansOutputTakeoverByPolicy(t *testing.T) {
ownerScope: state.CurrentOwnerScope("reports", "web"),
wantErr: "fail_conflict",
},
{
name: "transfer conflict replacement allows managed owner conflict",
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeNever},
transfer: config.TransferPolicy{OnConflict: config.TransferActionReplace},
ownerScope: state.CurrentOwnerScope("other", "archive"),
wantAction: ActionReplaceConflict,
},
}
for _, tt := range tests {
@@ -178,6 +186,9 @@ func TestBuildSharedRootPlansOutputTakeoverByPolicy(t *testing.T) {
req := sharedRootRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeReplace)
req.Takeover = tt.takeover
if tt.transfer.OnConflict != "" {
req.Transfer.OnConflict = tt.transfer.OnConflict
}
plan, err := Build(context.Background(), req)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {