Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/applied.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions internal/cli/applied_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app> <store> --recreate|--managed"), reviewedMigrationBundleRecovery(run)),
Reason: err.Error(),
Expand Down
15 changes: 15 additions & 0 deletions internal/target/dokploy/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion internal/target/dokploy/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions internal/target/dokploy/source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand Down
Loading