From aa3e2342f4f1a7c8de5bd52f4294f2b7b92fe1b9 Mon Sep 17 00:00:00 2001 From: "pieter.noordhuis" Date: Wed, 4 Nov 2026 09:13:23 +0000 Subject: [PATCH 1/2] Refuse to apply a plan that keeps a modified snapshot under --force `bundle deploy --plan plan.json --force` reused the zip staged when the plan was produced. If the snapshot was untouched at plan time and the content had not changed, that zip carries the previous snapshot's name and the plan holds a skip, so the forced deploy stayed on the snapshot that was modified in the workspace, contrary to what --force promises. Detect this case and ask the user to rerun `bundle plan --force`, which moves the deployment to a fresh snapshot. Co-authored-by: Isaac --- .nextchanges/bundles/snapshot-force-plan.md | 1 + .../deploy/immutable/breakglass/output.txt | 23 ++++++++++ .../bundle/deploy/immutable/breakglass/script | 19 ++++++++ bundle/deploy/snapshot/upload.go | 46 +++++++++++-------- 4 files changed, 71 insertions(+), 18 deletions(-) create mode 100644 .nextchanges/bundles/snapshot-force-plan.md diff --git a/.nextchanges/bundles/snapshot-force-plan.md b/.nextchanges/bundles/snapshot-force-plan.md new file mode 100644 index 00000000000..65099501d21 --- /dev/null +++ b/.nextchanges/bundles/snapshot-force-plan.md @@ -0,0 +1 @@ +* `bundle deploy --plan --force` now fails instead of staying on a snapshot that was modified in the workspace after the plan was created; run `bundle plan --force` again. ([#7336](https://github.com/databricks/cli/pull/7336)) diff --git a/acceptance/bundle/deploy/immutable/breakglass/output.txt b/acceptance/bundle/deploy/immutable/breakglass/output.txt index 39f62c3b457..03c8ea5264a 100644 --- a/acceptance/bundle/deploy/immutable/breakglass/output.txt +++ b/acceptance/bundle/deploy/immutable/breakglass/output.txt @@ -54,6 +54,29 @@ Resources: 1 created, 1 changed, 1 deleted, 0 unchanged Fourth snapshot path: /Workspace/Users/[UUID]/.snapshots/[SNAPSHOT_HASH]/[SNAPSHOT_HASH]-[UUID]/snapshot/files/src/main.py OK: the saved plan moved the deployment to a new snapshot +=== Save a plan without changes, then break the glass before deploying it +>>> [CLI] bundle plan -o json + +>>> [CLI] api post /api/2.0/snapshots:breakGlass --json {"snapshot_content_path": "/Workspace/Users/[UUID]/.snapshots/[SNAPSHOT_HASH]/[SNAPSHOT_HASH]-[UUID]/snapshot"} +true + +=== Deploying the saved plan with --force fails, since it keeps the modified snapshot +>>> musterr [CLI] bundle deploy --plan plan.json --force +Error: the last deployment was modified in the workspace since the plan was created; run 'bundle plan --force' again to move to a new snapshot + +Files: 0 uploaded, 0 deleted + +=== A plan saved with --force moves to a new snapshot +>>> [CLI] bundle plan --force -o json + +>>> [CLI] bundle deploy --plan plan.json --force +Recreated internal_immutable_snapshots.immutable +Updated jobs.my_job +Files: 0 uploaded, 0 deleted +Resources: 1 created, 1 changed, 1 deleted, 0 unchanged +Fifth snapshot path: /Workspace/Users/[UUID]/.snapshots/[SNAPSHOT_HASH]/[SNAPSHOT_HASH]-[UUID]/snapshot/files/src/main.py +OK: the plan saved with --force moved the deployment to a new snapshot + >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: delete resources.jobs.my_job diff --git a/acceptance/bundle/deploy/immutable/breakglass/script b/acceptance/bundle/deploy/immutable/breakglass/script index 26768a89612..7cb00bdab00 100644 --- a/acceptance/bundle/deploy/immutable/breakglass/script +++ b/acceptance/bundle/deploy/immutable/breakglass/script @@ -60,3 +60,22 @@ if [ "$SNAPSHOT_PATH_3" = "$SNAPSHOT_PATH_4" ]; then exit 1 fi echo "OK: the saved plan moved the deployment to a new snapshot" + +title "Save a plan without changes, then break the glass before deploying it" +trace $CLI bundle plan -o json > plan.json +CONTENT_PATH_4="${SNAPSHOT_PATH_4%/files/src/main.py}" +trace $CLI api post /api/2.0/snapshots:breakGlass --json "{\"snapshot_content_path\": \"$CONTENT_PATH_4\"}" | jq .status.dirty + +title "Deploying the saved plan with --force fails, since it keeps the modified snapshot" +trace musterr $CLI bundle deploy --plan plan.json --force + +title "A plan saved with --force moves to a new snapshot" +trace $CLI bundle plan --force -o json > plan.json +trace $CLI bundle deploy --plan plan.json --force +SNAPSHOT_PATH_5=$($CLI jobs get "$JOB_ID" | jq -r '.settings.tasks[0].spark_python_task.python_file') +echo "Fifth snapshot path: $SNAPSHOT_PATH_5" +if [ "$SNAPSHOT_PATH_4" = "$SNAPSHOT_PATH_5" ]; then + echo "ERROR: snapshot path did not change after deploying the plan saved with --force" + exit 1 +fi +echo "OK: the plan saved with --force moved the deployment to a new snapshot" diff --git a/bundle/deploy/snapshot/upload.go b/bundle/deploy/snapshot/upload.go index cdfb6d538d3..16b0418d0c6 100644 --- a/bundle/deploy/snapshot/upload.go +++ b/bundle/deploy/snapshot/upload.go @@ -82,7 +82,7 @@ func (m *snapshotUpload) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagn // Check the previous snapshot before doing any work, so a deploy onto a snapshot that was // modified outside the bundle fails before building a zip. A saved plan is checked as well: // the snapshot may have been modified after the plan was produced. - prev, err := checkPrevSnapshot(ctx, b, uploader, remoteRoot) + prev, modified, err := checkPrevSnapshot(ctx, b, uploader, remoteRoot) if err != nil { return diag.FromErr(err) } @@ -90,6 +90,10 @@ func (m *snapshotUpload) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagn var zipPath string var diags diag.Diagnostics if m.clean { + // --force moves the deployment off a modified snapshot, so don't let it reuse that name. + if modified { + prev = nil + } zipPath, diags, err = stageZip(ctx, b, snap, prev) } else { zipPath, err = stagedZipPath(ctx, b) @@ -99,6 +103,11 @@ func (m *snapshotUpload) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagn } snap.ZipPath = filepath.ToSlash(zipPath) + // A plan produced while the snapshot was untouched may keep it in place. Applying it would + // leave the deployment on the modified snapshot, which --force promises to move off. + if !m.clean && modified && snap.RelativePath() == prev.RelativePath { + return diag.Errorf("the last deployment was modified in the workspace since the plan was created; run 'bundle plan --force' again to move to a new snapshot") + } b.Config.Resources.Snapshots[resources.SnapshotResourceKey] = snap return diags @@ -231,10 +240,11 @@ func BuildCanManage(b *bundle.Bundle) []snapshot.ManagePrincipal { } // checkPrevSnapshot asks the backend whether the snapshot deployed last time was modified outside -// of the bundle, and returns its state when the deployment may keep using it: +// of the bundle, and returns its state: // - the previous state while the snapshot is untouched; -// - nil when there is no previous snapshot, or it was modified and --force was given, which -// moves the deployment to a new snapshot; +// - nil when there is no previous snapshot; +// - the previous state and modified=true when it was modified and --force was given; the +// caller must then not keep the deployment on it; // - an error when it was modified and --force was not given. // // The check has to happen here rather than in the resource's DoRead: it decides the snapshot's @@ -242,32 +252,32 @@ func BuildCanManage(b *bundle.Bundle) []snapshot.ManagePrincipal { // before any resource is read (see LookupReferencePreDeploy). Erroring unless --force also has no // equivalent in the resource layer. CheckDashboardsModifiedRemotely is the same shape: read state, // ask the API, refuse unless forced. -func checkPrevSnapshot(ctx context.Context, b *bundle.Bundle, uploader *snapshot.SnapshotClient, remoteRoot string) (*dresources.SnapshotState, error) { +func checkPrevSnapshot(ctx context.Context, b *bundle.Bundle, uploader *snapshot.SnapshotClient, remoteRoot string) (prev *dresources.SnapshotState, modified bool, err error) { // The deploy/plan pipeline opens the state DB for read before this runs, but callers that // exercise PlanUpload in isolation (unit tests) may not. No open state means no previous // snapshot to check, which is the first-deploy case. if !b.DeploymentBundle.StateDB.IsOpen() { - return nil, nil + return nil, false, nil } entry, ok := b.DeploymentBundle.StateDB.GetResourceEntry(resources.SnapshotKey) if !ok || len(entry.State) == 0 { - return nil, nil + return nil, false, nil } // Decode into the state type the engine persists, so the field names cannot drift apart. - var prev dresources.SnapshotState - if err := json.Unmarshal(entry.State, &prev); err != nil { - return nil, fmt.Errorf("reading previous snapshot state: %w", err) + var state dresources.SnapshotState + if err := json.Unmarshal(entry.State, &state); err != nil { + return nil, false, fmt.Errorf("reading previous snapshot state: %w", err) } - if prev.RelativePath == "" { - return nil, nil + if state.RelativePath == "" { + return nil, false, nil } // Compose the content path from relative_path rather than reading full_path: relative_path // means the same thing in every CLI version, while full_path from a CLI that predates the // content subfolder points one level too high. - contentPath := path.Join(remoteRoot, prev.RelativePath, snapshot.ContentSubdir) + contentPath := path.Join(remoteRoot, state.RelativePath, snapshot.ContentSubdir) status, err := uploader.InspectSnapshot(ctx, contentPath) // Nothing to protect when no snapshot is there: the deployment predates the content @@ -275,16 +285,16 @@ func checkPrevSnapshot(ctx context.Context, b *bundle.Bundle, uploader *snapshot // Failing here would also be a dead end, since this runs before the --force check and so // could not be forced past. if errors.Is(err, apierr.ErrNotFound) { - return nil, nil + return nil, false, nil } if err != nil { - return nil, err + return nil, false, err } if !status.Dirty { - return &prev, nil + return &state, false, nil } if !b.Config.Bundle.Force { - return nil, errors.New("the last deployment was modified in the workspace. Changes might be overwritten by this deployment. To confirm it, use the --force flag") + return nil, false, errors.New("the last deployment was modified in the workspace. Changes might be overwritten by this deployment. To confirm it, use the --force flag") } - return nil, nil + return &state, true, nil } From 7b865e15d0f065e536c1aeaf5e24ea14548d994c Mon Sep 17 00:00:00 2001 From: "pieter.noordhuis" Date: Wed, 4 Nov 2026 16:03:18 +0000 Subject: [PATCH 2/2] Reword the stale --force plan error to say to create a new plan The error is reached when the plan was saved with a plain 'bundle plan', so "run 'bundle plan --force' again" was misleading. Co-authored-by: Isaac --- .nextchanges/bundles/snapshot-force-plan.md | 2 +- acceptance/bundle/deploy/immutable/breakglass/output.txt | 2 +- bundle/deploy/snapshot/upload.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.nextchanges/bundles/snapshot-force-plan.md b/.nextchanges/bundles/snapshot-force-plan.md index 65099501d21..c92b0cf788d 100644 --- a/.nextchanges/bundles/snapshot-force-plan.md +++ b/.nextchanges/bundles/snapshot-force-plan.md @@ -1 +1 @@ -* `bundle deploy --plan --force` now fails instead of staying on a snapshot that was modified in the workspace after the plan was created; run `bundle plan --force` again. ([#7336](https://github.com/databricks/cli/pull/7336)) +* `bundle deploy --plan --force` now fails instead of staying on a snapshot that was modified in the workspace after the plan was created; create a new plan with `bundle plan --force`. ([#7336](https://github.com/databricks/cli/pull/7336)) diff --git a/acceptance/bundle/deploy/immutable/breakglass/output.txt b/acceptance/bundle/deploy/immutable/breakglass/output.txt index 03c8ea5264a..5646552b909 100644 --- a/acceptance/bundle/deploy/immutable/breakglass/output.txt +++ b/acceptance/bundle/deploy/immutable/breakglass/output.txt @@ -62,7 +62,7 @@ true === Deploying the saved plan with --force fails, since it keeps the modified snapshot >>> musterr [CLI] bundle deploy --plan plan.json --force -Error: the last deployment was modified in the workspace since the plan was created; run 'bundle plan --force' again to move to a new snapshot +Error: the last deployment was modified in the workspace since the plan was created; create a new plan with 'bundle plan --force' to move to a new snapshot Files: 0 uploaded, 0 deleted diff --git a/bundle/deploy/snapshot/upload.go b/bundle/deploy/snapshot/upload.go index 16b0418d0c6..fcd1c49ba97 100644 --- a/bundle/deploy/snapshot/upload.go +++ b/bundle/deploy/snapshot/upload.go @@ -106,7 +106,7 @@ func (m *snapshotUpload) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagn // A plan produced while the snapshot was untouched may keep it in place. Applying it would // leave the deployment on the modified snapshot, which --force promises to move off. if !m.clean && modified && snap.RelativePath() == prev.RelativePath { - return diag.Errorf("the last deployment was modified in the workspace since the plan was created; run 'bundle plan --force' again to move to a new snapshot") + return diag.Errorf("the last deployment was modified in the workspace since the plan was created; create a new plan with 'bundle plan --force' to move to a new snapshot") } b.Config.Resources.Snapshots[resources.SnapshotResourceKey] = snap