diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 5e665cd..64f57c0 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -858,6 +858,12 @@ refuses such an app like any stateful app, because both sides write the shared paths and Bort cannot fence them apart. Read-only bind mounts leave the app stateless. +Writable binds owned by a data store selected for `recreate` or `managed` +are refused before live apply: that source service stays running, so the +target must not mount the same writable host paths. Move the source data +to a supported named-volume layout and create a new run; do not hide the +mount in the manifest to bypass the refusal. + A named volume mounted by more than one compose service in the same app stages once and is handed to Dokploy as one external volume, so those services keep sharing it. A named volume mounted by more than one app cannot be staged. Bort @@ -1038,7 +1044,12 @@ recovery through that run: - To return to the source, reverse target traffic and remove every target app container attached to a transferred staging volume. Stopping a container is not enough because the stopped container remains attached. Before recreating - the target, change it to use fresh volumes or no transferred volumes. Verify + the target, change it to use fresh volumes or no transferred volumes. For + preserved bind mounts, remove every target container mounting the shared + host paths before restarting the source, and prevent queued or future target + deployments from mounting those paths again. A stopped container can restart + and write the same files. Preserve the shared data before choosing which side + will own it; source recovery does not undo target writes to those paths. Verify that the source is healthy and authoritative, then run: ```sh diff --git a/internal/cli/applied.go b/internal/cli/applied.go index 2ed960f..024daf7 100644 --- a/internal/cli/applied.go +++ b/internal/cli/applied.go @@ -446,6 +446,9 @@ func appliedAuthorityAmbiguity(applied runApplied) applyAuthorityAmbiguity { } ambiguity := applyAuthorityUnambiguous for _, recorded := range applied.Steps { + if recorded.Kind == string(dokploy.StepCreateProject) && appliedTargetMutationAmbiguous(recorded) { + return applyWriterAuthorityAmbiguous + } if recorded.Kind == string(dokploy.StepStartDokployProxy) && recorded.Ref == "dokploy-traefik" && appliedStepMayHaveRun(recorded) { ambiguity = applyTrafficAuthorityAmbiguous diff --git a/internal/cli/applied_test.go b/internal/cli/applied_test.go index a328481..60fd2d1 100644 --- a/internal/cli/applied_test.go +++ b/internal/cli/applied_test.go @@ -563,6 +563,26 @@ func TestLegacyCutoverHandoffIsAmbiguous(t *testing.T) { } } +func TestLegacyFirstProjectCreationRequiresKnownOutcome(t *testing.T) { + knownRejected, ambiguous := false, true + for _, tc := range []struct { + status dokploy.StepStatus + ambiguous *bool + wantError bool + }{ + {dokploy.StepStatusStarted, nil, true}, + {dokploy.StepStatusError, nil, true}, + {dokploy.StepStatusError, &ambiguous, true}, + {dokploy.StepStatusError, &knownRejected, false}, + {dokploy.StepStatusOK, nil, false}, + } { + applied := runApplied{PlanVersion: appliedPlanV1Alpha2, Steps: []appliedStep{{Index: 0, Kind: string(dokploy.StepCreateProject), App: "api", Status: string(tc.status), MutationAmbiguous: tc.ambiguous}}} + if err := validateApplyResumeAuthority(applied); (err != nil) != tc.wantError { + t.Fatalf("status=%s ambiguity=%v want refusal=%t, got %v", tc.status, tc.ambiguous, tc.wantError, err) + } + } +} + func TestLegacyCutoverHandoffSurvivesCurrentPlanReordering(t *testing.T) { applied := runApplied{Steps: []appliedStep{{ Index: 1, diff --git a/internal/cli/run.go b/internal/cli/run.go index e38034c..cae0e77 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -2550,6 +2550,9 @@ func nextSafeStepContextWithSourceProbe(ctx context.Context, run loadedMigration return runNextStep{Action: fmt.Sprintf("run `%s` to resume the interrupted apply", liveApplyCommand(run)), Reason: "the apply ledger contains incomplete work"} } if err := stagedTransferRefusal(run); err != nil { + if errors.Is(err, dokploy.ErrRunningStoreBind) { + return runNextStep{Action: "move the source data store's data to a supported named-volume layout, then scan a new run", Reason: err.Error()} + } return runNextStep{ Action: fmt.Sprintf("for a data store volume, choose `%s`, then %s; otherwise change the source compose and scan a new run", bortCommand("data --recreate|--managed"), reviewedMigrationBundleRecovery(run)), Reason: err.Error(), diff --git a/internal/target/dokploy/apply.go b/internal/target/dokploy/apply.go index 5902c8a..82b4573 100644 --- a/internal/target/dokploy/apply.go +++ b/internal/target/dokploy/apply.go @@ -472,6 +472,21 @@ func validatePlanReadyForLiveApply(plan Plan) error { } func ValidateStagedTransfer(plan Plan) error { + for _, step := range plan.Steps { + if step.Kind != StepPushImage || shouldSkipApplyStep(plan, step) { + continue + } + app, ok := findPrepareApp(plan.Prepare, step.App) + if !ok { + continue + } + for _, volume := range app.Resources.Volumes { + store, owned := dataStoreForVolume(app, volume) + if volume.Type == "bind" && volume.ReadWrite && owned && dataStoreMigrationKind(store) == dataStoreMigrationSkip { + return fmt.Errorf("writable bind mount %s for service %s in app %s %w; move the source data to a supported named-volume layout and create a new migration run", volume.Target, volume.Service, app.Name, ErrRunningStoreBind) + } + } + } for _, step := range plan.Steps { if step.Kind != StepSyncVolume || shouldSkipApplyStep(plan, step) || !appStateIsStaged(plan, step.App) { continue diff --git a/internal/target/dokploy/client.go b/internal/target/dokploy/client.go index 9c83e3f..2bd2ea4 100644 --- a/internal/target/dokploy/client.go +++ b/internal/target/dokploy/client.go @@ -23,7 +23,10 @@ const ( defaultTimeout = 30 * time.Second ) -var ErrNotImplemented = errors.New("dokploy live mode is not implemented") +var ( + ErrNotImplemented = errors.New("dokploy live mode is not implemented") + ErrRunningStoreBind = errors.New("cannot be preserved on the target because its source data store stays running") +) type Client struct { BaseURL string diff --git a/internal/target/dokploy/source_test.go b/internal/target/dokploy/source_test.go index 9ac9e7a..1712156 100644 --- a/internal/target/dokploy/source_test.go +++ b/internal/target/dokploy/source_test.go @@ -571,6 +571,40 @@ func TestPlanFromArtifactsPausesWritableBindMountAppBeforeTargetDeployment(t *te } } +func TestLiveApplyRefusesWritableBindsOwnedByUnpausedStores(t *testing.T) { + for _, tc := range []struct { + name string + kind string + strategy string + readWrite bool + wantRefusal bool + }{ + {"recreated Redis", "redis", "recreate", true, true}, + {"managed Redis", "redis", "managed", true, true}, + {"recreated Postgres", "postgres", "recreate", true, true}, + {"read-only bind", "redis", "recreate", false, false}, + {"paused Redis", "redis", "migrate", true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + app := preparer.AppPlan{Name: "api"} + app.Resources.DataStores = []preparer.DataStoreResource{{Service: "db", Kind: tc.kind, Strategy: tc.strategy}} + app.Resources.Volumes = []preparer.VolumeResource{{Service: "db", Type: "bind", Source: "/srv/data", Target: "/data", ReadWrite: tc.readWrite}} + plan := Plan{Prepare: preparer.Result{Apps: []preparer.AppPlan{app}}, Steps: []Step{{Kind: StepPushImage, App: "api"}}} + err := ValidateStagedTransfer(plan) + if (err != nil) != tc.wantRefusal { + t.Fatalf("want refusal=%t, got %v", tc.wantRefusal, err) + } + if !tc.wantRefusal { + return + } + client := &Client{} + if err := client.Apply(context.Background(), plan); !errors.Is(err, ErrRunningStoreBind) { + t.Fatalf("expected pre-mutation bind refusal, got %v", err) + } + }) + } +} + func TestBindMountDeploymentRefusesRestartedSource(t *testing.T) { app := preparer.AppPlan{Name: "api"} app.Resources.Volumes = []preparer.VolumeResource{{Service: "web", Type: "bind", Source: "/srv/uploads", Target: "/uploads", ReadWrite: true, SourceContainerID: "web-id"}}