Implement shared-root takeover policy
This commit is contained in:
@@ -148,7 +148,7 @@ func executeSharedRoot(ctx context.Context, req Request, plan Plan) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if plan.Action == ActionReplaceOlder && plan.Reconciliation.Mode == config.ReconciliationModeReplace {
|
||||
if plan.Action == ActionReplaceTakeover || (plan.Action == ActionReplaceOlder && 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.Reconciliation.Mode == config.ReconciliationModeMerge {
|
||||
if plan.Action == ActionReplaceOlder && plan.Reconciliation.Mode == config.ReconciliationModeMerge {
|
||||
outputs = newOutputs
|
||||
}
|
||||
_ = req.DestinationBackend.DeleteManagedOutputs(ctx, req.DestinationBundlePath, ManagedOutputPaths(outputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true})
|
||||
@@ -257,6 +257,11 @@ func outputManagedBySharedRootPlan(output Output, plan Plan) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, existing := range plan.TakenOverOwnerOutputs {
|
||||
if existing.Path == output.DestinationPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -283,6 +288,7 @@ func sharedRootStateForPlan(req Request, plan Plan, now time.Time) (state.Shared
|
||||
scope = state.CurrentOwnerScope(req.PipelineID, req.DestinationID)
|
||||
}
|
||||
base := sharedRootBaseState(req, plan, now)
|
||||
base = removeTakenOverSharedRootOutputs(base, plan.TakenOverOwnerOutputs)
|
||||
owner := state.OwnerRecord{
|
||||
Scope: scope,
|
||||
Reconciliation: state.ReconciliationPolicy{Mode: plan.Reconciliation.Mode},
|
||||
@@ -319,6 +325,22 @@ func sharedRootBaseState(req Request, plan Plan, now time.Time) state.SharedRoot
|
||||
return newSharedRootState(req, now)
|
||||
}
|
||||
|
||||
func removeTakenOverSharedRootOutputs(sharedRoot state.SharedRootState, takenOver []state.SharedRootOutputFile) state.SharedRootState {
|
||||
if len(takenOver) == 0 {
|
||||
return sharedRoot
|
||||
}
|
||||
paths := sharedRootOutputPathSet(takenOver)
|
||||
next := sharedRoot
|
||||
next.Outputs = make([]state.SharedRootOutputFile, 0, len(sharedRoot.Outputs))
|
||||
for _, output := range sharedRoot.Outputs {
|
||||
if _, remove := paths[output.Path]; remove {
|
||||
continue
|
||||
}
|
||||
next.Outputs = append(next.Outputs, output)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func newSharedRootState(req Request, now time.Time) state.SharedRootState {
|
||||
return state.SharedRootState{
|
||||
SchemaVersion: state.SharedRootSchemaVersion,
|
||||
|
||||
@@ -66,6 +66,7 @@ type Plan struct {
|
||||
ExistingState *state.DistributorState
|
||||
ExistingSharedRoot *state.SharedRootState
|
||||
OtherOwnerOutputs []state.SharedRootOutputFile
|
||||
TakenOverOwnerOutputs []state.SharedRootOutputFile
|
||||
RetainedOwnerOutputs []state.SharedRootOutputFile
|
||||
OwnerOutputsToDelete []state.SharedRootOutputFile
|
||||
OwnerOutputsToWrite []Output
|
||||
@@ -126,18 +127,21 @@ func Build(ctx context.Context, req Request) (Plan, error) {
|
||||
}
|
||||
if stateMode == config.StateModeSharedRoot {
|
||||
sharedDetails, err := planSharedRootOwner(ctx, req, status, action, reconciliation, outputs)
|
||||
plan.Action = sharedDetails.Action
|
||||
if sharedDetails.Reason != "" {
|
||||
plan.Reason = sharedDetails.Reason
|
||||
}
|
||||
plan.OtherOwnerOutputs = sharedDetails.OtherOwnerOutputs
|
||||
plan.TakenOverOwnerOutputs = sharedDetails.TakenOverOwnerOutputs
|
||||
plan.RetainedOwnerOutputs = sharedDetails.RetainedOwnerOutputs
|
||||
plan.OwnerOutputsToDelete = sharedDetails.OwnerOutputsToDelete
|
||||
plan.OwnerOutputsToWrite = sharedDetails.OwnerOutputsToWrite
|
||||
if err != nil {
|
||||
plan.Action = sharedDetails.Action
|
||||
plan.Reason = sharedDetails.Reason
|
||||
return plan, err
|
||||
}
|
||||
}
|
||||
if action == ActionFailConflict || action == ActionFailUnmanaged {
|
||||
return plan, fmt.Errorf("%s: %s", action, reason)
|
||||
if plan.Action == ActionFailConflict || plan.Action == ActionFailUnmanaged {
|
||||
return plan, fmt.Errorf("%s: %s", plan.Action, plan.Reason)
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
@@ -239,12 +243,13 @@ func sharedRootComparisonManifest(status state.DestinationStatus, scope state.Ow
|
||||
}
|
||||
|
||||
type sharedRootPlanDetails struct {
|
||||
Action Action
|
||||
Reason string
|
||||
OtherOwnerOutputs []state.SharedRootOutputFile
|
||||
RetainedOwnerOutputs []state.SharedRootOutputFile
|
||||
OwnerOutputsToDelete []state.SharedRootOutputFile
|
||||
OwnerOutputsToWrite []Output
|
||||
Action Action
|
||||
Reason string
|
||||
OtherOwnerOutputs []state.SharedRootOutputFile
|
||||
TakenOverOwnerOutputs []state.SharedRootOutputFile
|
||||
RetainedOwnerOutputs []state.SharedRootOutputFile
|
||||
OwnerOutputsToDelete []state.SharedRootOutputFile
|
||||
OwnerOutputsToWrite []Output
|
||||
}
|
||||
|
||||
func planSharedRootOwner(ctx context.Context, req Request, status state.DestinationStatus, action Action, reconciliation config.ReconciliationPolicy, outputs []Output) (sharedRootPlanDetails, error) {
|
||||
@@ -260,11 +265,20 @@ func planSharedRootOwner(ctx context.Context, req Request, status state.Destinat
|
||||
details.OwnerOutputsToWrite = append([]Output(nil), outputs...)
|
||||
return details, nil
|
||||
}
|
||||
if conflict, ok := sharedRootPathOwnershipConflict(status, scope, plannedPaths); ok {
|
||||
reason := fmt.Sprintf("destination output path %s is owned by %s/%s", conflict.Path, conflict.Owner.PipelineID, conflict.Owner.DestinationID)
|
||||
details.Action = ActionFailConflict
|
||||
details.Reason = reason
|
||||
return details, fmt.Errorf("%s: %s", ActionFailConflict, reason)
|
||||
conflicts := sharedRootPathOwnershipConflicts(status, scope, plannedPaths)
|
||||
if len(conflicts) > 0 {
|
||||
for _, conflict := range conflicts {
|
||||
if sharedRootTakeoverAllowed(req, status, conflict) {
|
||||
continue
|
||||
}
|
||||
reason := sharedRootOwnershipConflictReason(conflict)
|
||||
details.Action = ActionFailConflict
|
||||
details.Reason = reason
|
||||
return details, fmt.Errorf("%s: %s", ActionFailConflict, reason)
|
||||
}
|
||||
details.Action = ActionReplaceTakeover
|
||||
details.Reason = sharedRootOwnershipConflictReason(conflicts[0])
|
||||
details.TakenOverOwnerOutputs = sharedRootConflictOutputs(status.SharedRoot, conflicts)
|
||||
}
|
||||
if err := rejectSharedRootUnmanagedCollisions(ctx, req.DestinationBackend, req.DestinationBundlePath, status, scope, plannedPaths); err != nil {
|
||||
details.Action = ActionFailUnmanaged
|
||||
@@ -272,7 +286,8 @@ func planSharedRootOwner(ctx context.Context, req Request, status state.Destinat
|
||||
return details, fmt.Errorf("%s: %s", ActionFailUnmanaged, err)
|
||||
}
|
||||
|
||||
details.OtherOwnerOutputs = otherOwnerOutputs(status, scope)
|
||||
takenOverPaths := sharedRootOutputPathSet(details.TakenOverOwnerOutputs)
|
||||
details.OtherOwnerOutputs = otherOwnerOutputsExcept(status, scope, takenOverPaths)
|
||||
ownerOutputs := currentOwnerOutputs(status, scope)
|
||||
planned := make(map[string]struct{}, len(plannedPaths))
|
||||
for _, path := range plannedPaths {
|
||||
@@ -282,11 +297,11 @@ func planSharedRootOwner(ctx context.Context, req Request, status state.Destinat
|
||||
if _, exists := planned[output.Path]; exists {
|
||||
continue
|
||||
}
|
||||
if action == ActionReplaceOlder && reconciliation.Mode == config.ReconciliationModeReplace {
|
||||
if details.Action == ActionReplaceTakeover || (details.Action == ActionReplaceOlder && reconciliation.Mode == config.ReconciliationModeReplace) {
|
||||
details.OwnerOutputsToDelete = append(details.OwnerOutputsToDelete, output)
|
||||
continue
|
||||
}
|
||||
if action == ActionReplaceOlder && reconciliation.Mode == config.ReconciliationModeMerge {
|
||||
if details.Action == ActionReplaceOlder && reconciliation.Mode == config.ReconciliationModeMerge {
|
||||
details.RetainedOwnerOutputs = append(details.RetainedOwnerOutputs, output)
|
||||
}
|
||||
}
|
||||
@@ -311,11 +326,76 @@ func outputPaths(outputs []Output) []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
func sharedRootPathOwnershipConflict(status state.DestinationStatus, scope state.OwnerScope, paths []string) (state.PathOwnershipConflict, bool) {
|
||||
if status.SharedRoot != nil {
|
||||
return status.SharedRoot.PathOwnershipConflict(scope, paths)
|
||||
func sharedRootOwnershipConflictReason(conflict state.PathOwnershipConflict) string {
|
||||
return fmt.Sprintf("destination output path %s is owned by %s/%s", conflict.Path, conflict.Owner.PipelineID, conflict.Owner.DestinationID)
|
||||
}
|
||||
|
||||
func sharedRootPathOwnershipConflicts(status state.DestinationStatus, scope state.OwnerScope, paths []string) []state.PathOwnershipConflict {
|
||||
if status.SharedRoot == nil {
|
||||
return nil
|
||||
}
|
||||
conflicts := make([]state.PathOwnershipConflict, 0)
|
||||
seen := make(map[string]struct{}, len(paths))
|
||||
for _, path := range paths {
|
||||
if _, exists := seen[path]; exists {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
owner, exists := status.SharedRoot.OutputOwner(path)
|
||||
if !exists || owner == scope {
|
||||
continue
|
||||
}
|
||||
conflicts = append(conflicts, state.PathOwnershipConflict{
|
||||
Path: path,
|
||||
Owner: owner,
|
||||
CurrentOwner: scope,
|
||||
Detail: state.ComparisonDetail{
|
||||
Kind: state.ComparisonDetailSharedRootOutputOwner,
|
||||
Path: path,
|
||||
CurrentOwner: scope,
|
||||
ConflictingOwner: owner,
|
||||
},
|
||||
})
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
|
||||
func sharedRootConflictOutputs(sharedRoot *state.SharedRootState, conflicts []state.PathOwnershipConflict) []state.SharedRootOutputFile {
|
||||
if sharedRoot == nil || len(conflicts) == 0 {
|
||||
return nil
|
||||
}
|
||||
paths := make(map[string]struct{}, len(conflicts))
|
||||
for _, conflict := range conflicts {
|
||||
paths[conflict.Path] = struct{}{}
|
||||
}
|
||||
outputs := make([]state.SharedRootOutputFile, 0, len(conflicts))
|
||||
for _, output := range sharedRoot.Outputs {
|
||||
if _, exists := paths[output.Path]; exists {
|
||||
outputs = append(outputs, output)
|
||||
}
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
func sharedRootTakeoverAllowed(req Request, status state.DestinationStatus, conflict state.PathOwnershipConflict) bool {
|
||||
if status.SharedRoot == nil {
|
||||
return false
|
||||
}
|
||||
takeover := normalizeTakeover(req.Takeover)
|
||||
switch takeover.Mode {
|
||||
case config.TakeoverModeSamePipeline:
|
||||
return conflict.Owner.PipelineID == req.PipelineID
|
||||
case config.TakeoverModeSameSource:
|
||||
owner, ok := status.SharedRoot.Owner(conflict.Owner)
|
||||
return ok && owner.Source.Manifest.ID == req.SourceBundle.Manifest.ID
|
||||
case config.TakeoverModeAnyManaged:
|
||||
_, ok := status.SharedRoot.Owner(conflict.Owner)
|
||||
return ok
|
||||
case config.TakeoverModeNever:
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return state.PathOwnershipConflict{}, false
|
||||
}
|
||||
|
||||
func rejectSharedRootUnmanagedCollisions(ctx context.Context, backend storage.Backend, bundlePath string, status state.DestinationStatus, scope state.OwnerScope, paths []string) error {
|
||||
@@ -349,18 +429,34 @@ func pathManagedBySharedRootStatus(status state.DestinationStatus, scope state.O
|
||||
}
|
||||
|
||||
func otherOwnerOutputs(status state.DestinationStatus, scope state.OwnerScope) []state.SharedRootOutputFile {
|
||||
return otherOwnerOutputsExcept(status, scope, nil)
|
||||
}
|
||||
|
||||
func otherOwnerOutputsExcept(status state.DestinationStatus, scope state.OwnerScope, exclude map[string]struct{}) []state.SharedRootOutputFile {
|
||||
if status.SharedRoot == nil {
|
||||
return nil
|
||||
}
|
||||
outputs := make([]state.SharedRootOutputFile, 0, len(status.SharedRoot.Outputs))
|
||||
for _, output := range status.SharedRoot.Outputs {
|
||||
if output.Owner != scope {
|
||||
outputs = append(outputs, output)
|
||||
if output.Owner == scope {
|
||||
continue
|
||||
}
|
||||
if _, skip := exclude[output.Path]; skip {
|
||||
continue
|
||||
}
|
||||
outputs = append(outputs, output)
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
func sharedRootOutputPathSet(outputs []state.SharedRootOutputFile) map[string]struct{} {
|
||||
paths := make(map[string]struct{}, len(outputs))
|
||||
for _, output := range outputs {
|
||||
paths[output.Path] = struct{}{}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func currentOwnerOutputs(status state.DestinationStatus, scope state.OwnerScope) []state.SharedRootOutputFile {
|
||||
if status.SharedRoot != nil {
|
||||
outputs := make([]state.SharedRootOutputFile, 0, len(status.SharedRoot.Outputs))
|
||||
|
||||
@@ -113,6 +113,94 @@ func TestBuildSharedRootRejectsOtherOwnerPathConflict(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSharedRootPlansOutputTakeoverByPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
takeover config.TakeoverPolicy
|
||||
ownerScope state.OwnerScope
|
||||
sameSource bool
|
||||
wantAction Action
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "default same pipeline allows different destination",
|
||||
takeover: config.TakeoverPolicy{},
|
||||
ownerScope: state.CurrentOwnerScope("reports", "web"),
|
||||
wantAction: ActionReplaceTakeover,
|
||||
},
|
||||
{
|
||||
name: "same pipeline refuses different pipeline",
|
||||
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeSamePipeline},
|
||||
ownerScope: state.CurrentOwnerScope("other", "archive"),
|
||||
wantErr: "fail_conflict",
|
||||
},
|
||||
{
|
||||
name: "same source allows different pipeline",
|
||||
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeSameSource},
|
||||
ownerScope: state.CurrentOwnerScope("other", "archive"),
|
||||
sameSource: true,
|
||||
wantAction: ActionReplaceTakeover,
|
||||
},
|
||||
{
|
||||
name: "same source refuses different source",
|
||||
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeSameSource},
|
||||
ownerScope: state.CurrentOwnerScope("reports", "web"),
|
||||
wantErr: "fail_conflict",
|
||||
},
|
||||
{
|
||||
name: "any managed allows different pipeline",
|
||||
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeAnyManaged},
|
||||
ownerScope: state.CurrentOwnerScope("other", "archive"),
|
||||
wantAction: ActionReplaceTakeover,
|
||||
},
|
||||
{
|
||||
name: "never refuses same pipeline",
|
||||
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeNever},
|
||||
ownerScope: state.CurrentOwnerScope("reports", "web"),
|
||||
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{
|
||||
Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\n"}},
|
||||
})
|
||||
destinationBackend := fake.New()
|
||||
sharedRoot := sharedRootStateWithOwners(t, sourceBundle.Manifest, false)
|
||||
ownerManifest := sharedRoot.Owners[0].Source.Manifest
|
||||
if tt.sameSource {
|
||||
ownerManifest = sourceBundle.Manifest
|
||||
}
|
||||
setSharedRootOwnerOutput(t, &sharedRoot, 0, tt.ownerScope, ownerManifest, "report.md")
|
||||
writeFakeSharedRootState(t, destinationBackend, "bundle", sharedRoot)
|
||||
|
||||
req := sharedRootRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeReplace)
|
||||
req.Takeover = tt.takeover
|
||||
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)
|
||||
}
|
||||
if plan.Action != ActionFailConflict {
|
||||
t.Fatalf("plan action = %s, want fail_conflict", plan.Action)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if got, want := sharedRootOutputPathList(plan.TakenOverOwnerOutputs), "report.md"; got != want {
|
||||
t.Fatalf("taken over outputs = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSharedRootRejectsUnmanagedPathCollision(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{
|
||||
@@ -203,6 +291,120 @@ func TestExecuteSharedRootReplaceDeletesOnlyCurrentOwnerOmittedOutputs(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSharedRootTakeoverReassignsPathAndPreservesUnrelatedOutputs(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()
|
||||
sharedRoot := sharedRootStateWithOwners(t, sourceBundle.Manifest, false)
|
||||
previousScope := state.CurrentOwnerScope("reports", "web")
|
||||
setSharedRootOwnerOutput(t, &sharedRoot, 0, previousScope, sharedRoot.Owners[0].Source.Manifest, "report.md")
|
||||
keepOutput := sharedRoot.Outputs[0]
|
||||
keepOutput.Path = "web/keep.md"
|
||||
keepOutput.SourcePath = "web/keep.md"
|
||||
sharedRoot.Outputs = append(sharedRoot.Outputs, keepOutput)
|
||||
writeFakeSharedRootState(t, destinationBackend, "bundle", sharedRoot)
|
||||
|
||||
req := sharedRootRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeReplace)
|
||||
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.AssertFakeFile(t, destinationBackend, "bundle/web/keep.md", "old")
|
||||
destinationState := readFakeSharedRootState(t, destinationBackend, "bundle")
|
||||
reportOutput, ok := findSharedRootOutputForTest(destinationState.Outputs, "report.md")
|
||||
if !ok {
|
||||
t.Fatal("report.md missing from shared-root outputs")
|
||||
}
|
||||
if reportOutput.Owner != state.CurrentOwnerScope("reports", "archive") {
|
||||
t.Fatalf("report.md owner = %#v, want reports/archive", reportOutput.Owner)
|
||||
}
|
||||
keep, ok := findSharedRootOutputForTest(destinationState.Outputs, "web/keep.md")
|
||||
if !ok {
|
||||
t.Fatal("web/keep.md missing from shared-root outputs")
|
||||
}
|
||||
if keep.Owner != previousScope {
|
||||
t.Fatalf("web/keep.md owner = %#v, want reports/web", keep.Owner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSharedRootTakeoverMergeDoesNotRetainOldSourceOutputs(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()
|
||||
sharedRoot := sharedRootStateWithOwners(t, sourceBundle.Manifest, false)
|
||||
previousScope := state.CurrentOwnerScope("reports", "web")
|
||||
setSharedRootOwnerOutput(t, &sharedRoot, 0, previousScope, sharedRoot.Owners[0].Source.Manifest, "report.md")
|
||||
|
||||
createdAt := sharedRoot.CreatedAt
|
||||
oldManifest := sourceBundle.Manifest
|
||||
oldManifest.ID = "old.source"
|
||||
oldManifest.Created = oldManifest.Created.Add(-time.Hour)
|
||||
oldManifest.Files = []bundle.ManifestFile{{
|
||||
Path: "old.md",
|
||||
SHA256: bundle.FileDigest([]byte("old\n")),
|
||||
Size: int64(len("old\n")),
|
||||
}}
|
||||
oldManifest.Digest = bundle.BundleDigest(oldManifest.Files)
|
||||
currentScope := state.CurrentOwnerScope("reports", "archive")
|
||||
sharedRoot.Owners = append(sharedRoot.Owners, state.OwnerRecord{
|
||||
Scope: currentScope,
|
||||
Reconciliation: state.ReconciliationPolicy{Mode: config.ReconciliationModeMerge},
|
||||
Source: state.SourceState{Manifest: oldManifest},
|
||||
})
|
||||
sharedRoot.Outputs = append(sharedRoot.Outputs, state.SharedRootOutputFile{
|
||||
Path: "old.md",
|
||||
Kind: state.OutputKindSource,
|
||||
SourcePath: "old.md",
|
||||
SHA256: oldManifest.Files[0].SHA256,
|
||||
Size: oldManifest.Files[0].Size,
|
||||
Owner: currentScope,
|
||||
SourceID: oldManifest.ID,
|
||||
SourceDigest: oldManifest.Digest,
|
||||
SourceCreated: oldManifest.Created,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
})
|
||||
writeFakeSharedRootState(t, destinationBackend, "bundle", sharedRoot)
|
||||
|
||||
req := sharedRootRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeMerge)
|
||||
req.PathMapping = config.PathMappingFixed
|
||||
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 got, want := sharedRootOutputPathList(plan.OwnerOutputsToDelete), "old.md"; got != want {
|
||||
t.Fatalf("owner outputs to delete = %q, want %q", got, want)
|
||||
}
|
||||
if len(plan.RetainedOwnerOutputs) != 0 {
|
||||
t.Fatalf("retained owner outputs = %#v, want none", plan.RetainedOwnerOutputs)
|
||||
}
|
||||
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/old.md")
|
||||
destinationState := readFakeSharedRootState(t, destinationBackend, "bundle")
|
||||
if got, want := strings.Join(destinationState.AllManagedOutputPaths(), ","), "report.md"; got != want {
|
||||
t.Fatalf("managed paths = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSharedRootMergeRetainsCurrentOwnerOmittedOutputs(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{
|
||||
@@ -322,6 +524,26 @@ func findSharedRootOutputForTest(outputs []state.SharedRootOutputFile, path stri
|
||||
return state.SharedRootOutputFile{}, false
|
||||
}
|
||||
|
||||
func setSharedRootOwnerOutput(t *testing.T, sharedRoot *state.SharedRootState, index int, scope state.OwnerScope, manifest bundle.Manifest, path string) {
|
||||
t.Helper()
|
||||
sharedRoot.Owners[index].Scope = scope
|
||||
sharedRoot.Owners[index].Source = state.SourceState{Manifest: manifest}
|
||||
sourcePath := path
|
||||
if len(manifest.Files) > 0 {
|
||||
sourcePath = manifest.Files[0].Path
|
||||
}
|
||||
sharedRoot.Outputs[index].Path = path
|
||||
sharedRoot.Outputs[index].SourcePath = sourcePath
|
||||
sharedRoot.Outputs[index].Owner = scope
|
||||
sharedRoot.Outputs[index].SourceID = manifest.ID
|
||||
sharedRoot.Outputs[index].SourceDigest = manifest.Digest
|
||||
sharedRoot.Outputs[index].SourceCreated = manifest.Created
|
||||
if len(manifest.Files) > 0 {
|
||||
sharedRoot.Outputs[index].SHA256 = manifest.Files[0].SHA256
|
||||
sharedRoot.Outputs[index].Size = manifest.Files[0].Size
|
||||
}
|
||||
}
|
||||
|
||||
func sharedRootStateWithOwners(t *testing.T, current bundle.Manifest, includeCurrent bool) state.SharedRootState {
|
||||
t.Helper()
|
||||
createdAt := time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC)
|
||||
|
||||
Reference in New Issue
Block a user