Implement catalog force replacement

This commit is contained in:
2026-06-19 16:39:01 +00:00
parent 4e673dda76
commit 9a2eaf8e5e
13 changed files with 307 additions and 35 deletions

View File

@@ -597,7 +597,6 @@ func TestRunJSONIncludesWorkflowActionAndSummary(t *testing.T) {
}
func TestRunFixedPathFailsUnmanagedWithoutForce(t *testing.T) {
t.Skip("catalog workflow protects planned path collisions rather than unrelated unplanned content")
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "bundle", testBundleOptions{})
@@ -612,7 +611,6 @@ func TestRunFixedPathFailsUnmanagedWithoutForce(t *testing.T) {
}
func TestRunFixedPathForceReplacementStaysWithinDestinationRoot(t *testing.T) {
t.Skip("force reporting and execution behavior is covered by the catalog reporting work")
sourceRoot := t.TempDir()
parent := t.TempDir()
destinationRoot := filepath.Join(parent, "latest")
@@ -639,6 +637,10 @@ func TestRunFixedPathForceReplacementStaysWithinDestinationRoot(t *testing.T) {
if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); !os.IsNotExist(err) {
t.Fatalf("unmanaged stat error = %v, want removed", err)
}
catalog := readLocalCatalogState(t, destinationRoot)
if catalog.SchemaVersion != state.CatalogSchemaVersion || catalog.State.Mode != state.StateModeCatalog {
t.Fatalf("catalog identity = schema %d mode %s", catalog.SchemaVersion, catalog.State.Mode)
}
}
func TestRunFixedPathRemoteBackendsUseBackendRoots(t *testing.T) {
@@ -1384,7 +1386,6 @@ func TestRunSkipsWhenDestinationStateMatches(t *testing.T) {
}
func TestRunFailsOnUnmanagedDestination(t *testing.T) {
t.Skip("catalog workflow protects planned path collisions rather than unrelated unplanned content")
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
@@ -1399,7 +1400,6 @@ func TestRunFailsOnUnmanagedDestination(t *testing.T) {
}
func TestRunForceReplacesUnmanagedDestination(t *testing.T) {
t.Skip("force reporting and execution behavior is covered by the catalog reporting work")
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
@@ -1419,10 +1419,50 @@ func TestRunForceReplacesUnmanagedDestination(t *testing.T) {
if !strings.Contains(stdout.String(), "action=force_replace") {
t.Fatalf("stdout = %q, want force_replace", stdout.String())
}
if !strings.Contains(stdout.String(), "Final status: ok planned=1 publish_new=0 upsert_additive=0 replace_catalog=0 skip_same=0 force_replace=1 fail_unmanaged=0 fail_conflict=0 failed=0 dry_run=false") {
t.Fatalf("stdout = %q, want force_replace counter only", stdout.String())
}
if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); !os.IsNotExist(err) {
t.Fatalf("unmanaged file stat error = %v, want not exist", err)
}
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
catalog := readLocalCatalogState(t, destinationRoot)
if catalog.SchemaVersion != state.CatalogSchemaVersion || catalog.State.Mode != state.StateModeCatalog {
t.Fatalf("catalog identity = schema %d mode %s", catalog.SchemaVersion, catalog.State.Mode)
}
}
func TestRunForceReplacementJSONOutput(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
if err := os.WriteFile(filepath.Join(destinationRoot, "unmanaged.txt"), []byte("old"), 0o600); err != nil {
t.Fatalf("write unmanaged file: %v", err)
}
var stdout bytes.Buffer
err := Run(context.Background(), RunOptions{
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
Force: true,
Stdout: &stdout,
OutputFormat: OutputFormatJSON,
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
result := decodeAppResult(t, stdout.String())
actions, ok := result["actions"].([]any)
if !ok || len(actions) != 1 {
t.Fatalf("actions = %#v, want one action", result["actions"])
}
action, ok := actions[0].(map[string]any)
if !ok || action["action"] != "force_replace" || action["workflow"] != "additive" {
t.Fatalf("action = %#v, want force_replace additive", actions[0])
}
summary, ok := result["summary"].(map[string]any)
if !ok || summary["force_replace"] != float64(1) || summary["publish_new"] != float64(0) {
t.Fatalf("summary = %#v, want force_replace only", result["summary"])
}
}
func TestRunFansOutToLocalDestinations(t *testing.T) {
@@ -1527,7 +1567,6 @@ func TestRunExercisesRemoteBackendShapesThroughCommonPath(t *testing.T) {
}
func TestRunForceReplacementStaysWithinRemoteBundlePaths(t *testing.T) {
t.Skip("force reporting and execution behavior is covered by the catalog reporting work")
localSourceRoot := t.TempDir()
writeSourceBundle(t, localSourceRoot, "bundle", testBundleOptions{})
s3Destination := fake.New()
@@ -1569,6 +1608,12 @@ func TestRunForceReplacementStaysWithinRemoteBundlePaths(t *testing.T) {
testutil.AssertFakeFile(t, sshDestination, "bundle/report.md", "# Report\nSunny.\n")
testutil.AssertFakeMissing(t, sshDestination, "bundle/old.txt")
testutil.AssertFakeFile(t, sshDestination, "bundle-sibling/keep.txt", "keep")
for name, backend := range map[string]*fake.Backend{"s3": s3Destination, "ssh": sshDestination} {
catalog := readFakeCatalogStateAt(t, backend, "bundle")
if catalog.SchemaVersion != state.CatalogSchemaVersion || catalog.State.Mode != state.StateModeCatalog {
t.Fatalf("%s catalog identity = schema %d mode %s", name, catalog.SchemaVersion, catalog.State.Mode)
}
}
}
func TestRunDryRunDoesNotWrite(t *testing.T) {
@@ -1772,6 +1817,36 @@ func readStateFile(t *testing.T, path string) testDestinationState {
return view
}
func readLocalCatalogState(t *testing.T, destinationRoot string) state.CatalogState {
t.Helper()
data, err := os.ReadFile(filepath.Join(destinationRoot, storage.StateFileName))
if err != nil {
t.Fatalf("read catalog state: %v", err)
}
catalog, err := state.ParseCatalog(data)
if err != nil {
t.Fatalf("parse catalog state: %v", err)
}
return catalog
}
func readFakeCatalogStateAt(t *testing.T, backend *fake.Backend, bundlePath string) state.CatalogState {
t.Helper()
statePath, err := storage.StatePath(bundlePath)
if err != nil {
t.Fatalf("state path: %v", err)
}
data, err := backend.ReadFile(context.Background(), statePath)
if err != nil {
t.Fatalf("read catalog state: %v", err)
}
catalog, err := state.ParseCatalog(data)
if err != nil {
t.Fatalf("parse catalog state: %v", err)
}
return catalog
}
func outputsByPath(outputs []testStateOutput) map[string]testStateOutput {
byPath := make(map[string]testStateOutput, len(outputs))
for _, output := range outputs {

View File

@@ -938,8 +938,8 @@ func TestExecuteRunForceDryRunReportsWithoutWriting(t *testing.T) {
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
if !strings.Contains(stdout.String(), "action=publish_new workflow=additive") {
t.Fatalf("stdout = %q, want additive publish", stdout.String())
if !strings.Contains(stdout.String(), "action=force_replace workflow=additive") || !strings.Contains(stdout.String(), "force_replace=1") {
t.Fatalf("stdout = %q, want forced replacement", stdout.String())
}
if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); err != nil {
t.Fatalf("unmanaged file stat error = %v", err)

View File

@@ -17,9 +17,9 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
switch plan.Action {
case ActionSkipSame, ActionSkipDestinationNewer:
return nil
case ActionPublishNew, ActionUpsertAdditive, ActionReplaceCatalog:
case ActionPublishNew, ActionUpsertAdditive, ActionReplaceCatalog, ActionForceReplace:
return executeCatalog(ctx, req, plan)
case ActionReplaceOlder, ActionReplaceConflict, ActionReplaceNewer, ActionReplaceTakeover, ActionForceReplace:
case ActionReplaceOlder, ActionReplaceConflict, ActionReplaceNewer, ActionReplaceTakeover:
if usesSharedRootState(req, plan) {
return executeSharedRoot(ctx, req, plan)
}
@@ -142,7 +142,14 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
}
func executeCatalog(ctx context.Context, req Request, plan Plan) error {
if plan.Action == ActionReplaceCatalog {
if plan.Action == ActionForceReplace {
if err := req.DestinationBackend.DeletePrefix(ctx, req.DestinationBundlePath, storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
return err
}
if err := ensureDestinationEmpty(ctx, req.DestinationBackend, req.DestinationBundlePath); err != nil {
return err
}
} else if plan.Action == ActionReplaceCatalog {
if plan.ClearDestinationRoot {
if err := req.DestinationBackend.DeletePrefix(ctx, req.DestinationBundlePath, storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
return err

View File

@@ -131,6 +131,65 @@ func TestExecuteSupersededAdditiveLeavesUnplannedFilesUnmanaged(t *testing.T) {
}
}
func TestExecuteForceReplaceClearsDestinationBundlePathOnly(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
req.DestinationBundlePath = "bundle"
req.Force = true
testutil.WriteFakeFile(t, destinationBackend, "bundle/report.md", "old report")
testutil.WriteFakeFile(t, destinationBackend, "bundle/unplanned.txt", "remove")
testutil.WriteFakeFile(t, destinationBackend, "bundle-child/keep.txt", "keep")
testutil.WriteFakeFile(t, destinationBackend, "outside.txt", "keep")
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionForceReplace {
t.Fatalf("plan action = %s, want %s", plan.Action, ActionForceReplace)
}
if err := Execute(context.Background(), req, plan); err != nil {
t.Fatalf("Execute() error = %v", err)
}
testutil.AssertFakeFile(t, destinationBackend, "bundle/report.md", "# Report\nSunny.\n")
testutil.AssertFakeFile(t, destinationBackend, "bundle/summary.txt", "Summary\n")
testutil.AssertFakeMissing(t, destinationBackend, "bundle/unplanned.txt")
testutil.AssertFakeFile(t, destinationBackend, "bundle-child/keep.txt", "keep")
testutil.AssertFakeFile(t, destinationBackend, "outside.txt", "keep")
catalog := readCatalogState(t, destinationBackend, "bundle")
if catalog.SchemaVersion != state.CatalogSchemaVersion || catalog.State.Mode != state.StateModeCatalog {
t.Fatalf("catalog identity = schema %d mode %s", catalog.SchemaVersion, catalog.State.Mode)
}
if len(catalog.Outputs) != 2 {
t.Fatalf("catalog outputs = %#v, want planned outputs only", catalog.Outputs)
}
}
func TestExecuteForceReplaceClearsFixedDestinationRoot(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
req.DestinationBundlePath = ""
req.PathMapping = config.PathMappingFixed
req.Force = true
testutil.WriteFakeFile(t, destinationBackend, "report.md", "old report")
testutil.WriteFakeFile(t, destinationBackend, "unplanned.txt", "remove")
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionForceReplace || storage.DisplayPath(plan.DestinationBundlePath) != "." {
t.Fatalf("plan action=%s destination=%s, want force_replace at root", plan.Action, storage.DisplayPath(plan.DestinationBundlePath))
}
if err := Execute(context.Background(), req, plan); err != nil {
t.Fatalf("Execute() error = %v", err)
}
testutil.AssertFakeFile(t, destinationBackend, "report.md", "# Report\nSunny.\n")
testutil.AssertFakeFile(t, destinationBackend, "summary.txt", "Summary\n")
testutil.AssertFakeMissing(t, destinationBackend, "unplanned.txt")
readCatalogState(t, destinationBackend, "")
}
func TestExecuteFailedWriteDoesNotWriteCatalogState(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
plan, err := Build(context.Background(), req)

View File

@@ -139,8 +139,15 @@ func Build(ctx context.Context, req Request) (Plan, error) {
SupersededLegacy: status.SupersededLegacy,
}
if status.StateErr != nil {
plan.Action = ActionFailConflict
plan.Reason = status.StateErr.Error()
if req.Force {
plan.Action = ActionForceReplace
plan.Force = true
plan.ClearDestinationRoot = true
plan.CatalogOutputsToWrite = catalogOutputsForPlan(req, outputs, nil, scope, now)
return plan, nil
}
plan.Action = ActionFailConflict
return plan, fmt.Errorf("%s: %s", plan.Action, plan.Reason)
}
@@ -151,7 +158,7 @@ func Build(ctx context.Context, req Request) (Plan, error) {
case status.SupersededLegacy != nil:
details = planSupersededLegacy(req, outputs, workflow, scope, now)
default:
details, err = planWithoutCatalog(ctx, req, outputs, workflow, scope, now)
details, err = planWithoutCatalog(ctx, req, outputs, workflow, scope, now, status.HasContents)
}
plan.Action = details.Action
plan.Reason = details.Reason
@@ -159,6 +166,15 @@ func Build(ctx context.Context, req Request) (Plan, error) {
plan.CatalogOutputsToRetain = details.CatalogOutputsToRetain
plan.CatalogOutputsToDelete = details.CatalogOutputsToDelete
plan.ClearDestinationRoot = details.ClearDestinationRoot
if err != nil && req.Force && forceCanReplace(details.Action) {
plan.Action = ActionForceReplace
plan.Force = true
plan.ClearDestinationRoot = true
plan.CatalogOutputsToWrite = catalogOutputsForPlan(req, outputs, nil, scope, now)
plan.CatalogOutputsToRetain = nil
plan.CatalogOutputsToDelete = nil
return plan, nil
}
if err != nil {
return plan, err
}
@@ -239,7 +255,14 @@ func planSupersededLegacy(req Request, outputs []Output, workflow string, scope
return details
}
func planWithoutCatalog(ctx context.Context, req Request, outputs []Output, workflow string, scope state.OwnerScope, now time.Time) (catalogPlanDetails, error) {
func planWithoutCatalog(ctx context.Context, req Request, outputs []Output, workflow string, scope state.OwnerScope, now time.Time, hasContents bool) (catalogPlanDetails, error) {
if hasContents {
err := fmt.Errorf("destination has content but no distributor state")
return catalogPlanDetails{
Action: ActionFailUnmanaged,
Reason: err.Error(),
}, fmt.Errorf("%s: %s", ActionFailUnmanaged, err)
}
if err := rejectCatalogUnmanagedCollisions(ctx, req.DestinationBackend, req.DestinationBundlePath, nil, outputs); err != nil {
return catalogPlanDetails{
Action: ActionFailUnmanaged,
@@ -252,6 +275,10 @@ func planWithoutCatalog(ctx context.Context, req Request, outputs []Output, work
}, nil
}
func forceCanReplace(action Action) bool {
return action == ActionFailUnmanaged || action == ActionFailConflict
}
func actionForWorkflow(workflow string) Action {
if workflow == config.WorkflowReplacement {
return ActionReplaceCatalog

View File

@@ -137,6 +137,7 @@ func TestBuildTransfersManagedPathOwnership(t *testing.T) {
func TestBuildRejectsUnmanagedPlannedPathCollision(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
writeCatalogState(t, destinationBackend, "", baseCatalog(req))
testutil.WriteFakeFile(t, destinationBackend, "report.md", "unmanaged")
plan, err := Build(context.Background(), req)
@@ -148,6 +149,55 @@ func TestBuildRejectsUnmanagedPlannedPathCollision(t *testing.T) {
}
}
func TestBuildForceReplacesUnmanagedPlannedPathCollision(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
req.Force = true
existing := baseCatalog(req)
writeCatalogState(t, destinationBackend, "", existing)
testutil.WriteFakeFile(t, destinationBackend, "report.md", "unmanaged")
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionForceReplace || !plan.Force || !plan.ClearDestinationRoot {
t.Fatalf("plan action=%s force=%t clear=%t, want forced clear", plan.Action, plan.Force, plan.ClearDestinationRoot)
}
if len(plan.CatalogOutputsToWrite) != 2 || len(plan.CatalogOutputsToRetain) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("catalog write=%d retain=%d delete=%d", len(plan.CatalogOutputsToWrite), len(plan.CatalogOutputsToRetain), len(plan.CatalogOutputsToDelete))
}
}
func TestBuildRejectsNoStateNonEmptyDestinationWithoutForce(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
testutil.WriteFakeFile(t, destinationBackend, "unplanned.txt", "unmanaged")
plan, err := Build(context.Background(), req)
if err == nil {
t.Fatal("Build() error = nil, want unmanaged destination")
}
if plan.Action != ActionFailUnmanaged || !strings.Contains(err.Error(), "destination has content but no distributor state") {
t.Fatalf("plan action=%s error=%v, want unmanaged destination", plan.Action, err)
}
}
func TestBuildForceReplacesNoStateNonEmptyDestination(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
req.Force = true
testutil.WriteFakeFile(t, destinationBackend, "unplanned.txt", "unmanaged")
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionForceReplace || !plan.Force || !plan.ClearDestinationRoot {
t.Fatalf("plan action=%s force=%t clear=%t, want forced clear", plan.Action, plan.Force, plan.ClearDestinationRoot)
}
if len(plan.CatalogOutputsToWrite) != 2 || len(plan.CatalogOutputsToRetain) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("catalog write=%d retain=%d delete=%d", len(plan.CatalogOutputsToWrite), len(plan.CatalogOutputsToRetain), len(plan.CatalogOutputsToDelete))
}
}
func TestBuildPlansSupersededLegacyAdditive(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
legacyState := testutil.DestinationState(req.SourceBundle.Manifest, testutil.DestinationStateOptions{})
@@ -204,6 +254,60 @@ func TestBuildRejectsInvalidOrFutureState(t *testing.T) {
}
}
func TestBuildForceReplacesInvalidOrFutureState(t *testing.T) {
tests := []struct {
name string
data string
}{
{name: "invalid json", data: `{"schema_version":`},
{name: "future schema", data: `{"schema_version":99}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
req.Force = true
testutil.WriteFakeFile(t, destinationBackend, storage.StateFileName, tt.data)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionForceReplace || !plan.Force || !plan.ClearDestinationRoot {
t.Fatalf("plan action=%s force=%t clear=%t, want forced clear", plan.Action, plan.Force, plan.ClearDestinationRoot)
}
if len(plan.CatalogOutputsToWrite) != 2 || len(plan.CatalogOutputsToRetain) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("catalog write=%d retain=%d delete=%d", len(plan.CatalogOutputsToWrite), len(plan.CatalogOutputsToRetain), len(plan.CatalogOutputsToDelete))
}
})
}
}
func TestBuildForceDoesNotChangeValidCatalogActions(t *testing.T) {
tests := []struct {
name string
workflow string
want Action
}{
{name: "additive", workflow: config.WorkflowAdditive, want: ActionUpsertAdditive},
{name: "replacement", workflow: config.WorkflowReplacement, want: ActionReplaceCatalog},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, tt.workflow)
req.Force = true
writeCatalogState(t, destinationBackend, "", baseCatalog(req))
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != tt.want || plan.Force || plan.ClearDestinationRoot {
t.Fatalf("plan action=%s force=%t clear=%t, want %s without forced clear", plan.Action, plan.Force, plan.ClearDestinationRoot, tt.want)
}
})
}
}
func TestValidateRequestRejectsInvalidWorkflow(t *testing.T) {
sourceBackend, destinationBackend, req := catalogPlanRequest(t, "append")
req.SourceBackend = sourceBackend