Implement shared-root takeover policy

This commit is contained in:
2026-06-18 15:27:50 +00:00
parent c02106987f
commit 11d1eabe2a
7 changed files with 386 additions and 44 deletions

View File

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