From 40611fe231082ed1975895334a6017d35aec9c1f Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 14 Sep 2026 16:39:51 +0200 Subject: [PATCH] Treat already-gone deletes as state-only cleanup in plan/deploy output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Delete entry marked Gone (the resource is already missing remotely) only drops the stale state entry — no backend call — just like a StateOnly delete. Report it that way consistently: omit it from the `bundle plan` and `bundle deploy` action listings and from the resource counts, matching what `bundle destroy` already does. Previously plan/deploy listed and counted such deletes while destroy did not. Co-authored-by: Isaac --- .nextchanges/bundles/6675.md | 1 + .../out.deploy_removed.direct.txt | 4 +--- .../removed_from_config/out.plan_removed.direct.txt | 5 +---- bundle/deployplan/action.go | 9 +++++++++ bundle/deployplan/plan.go | 13 +++++++++++-- bundle/direct/bundle_apply.go | 10 +++++----- bundle/phases/deploy.go | 10 ++++------ bundle/phases/destroy.go | 12 ++++++------ cmd/bundle/plan.go | 2 +- 9 files changed, 39 insertions(+), 27 deletions(-) create mode 100644 .nextchanges/bundles/6675.md diff --git a/.nextchanges/bundles/6675.md b/.nextchanges/bundles/6675.md new file mode 100644 index 00000000000..7365f5e1ce9 --- /dev/null +++ b/.nextchanges/bundles/6675.md @@ -0,0 +1 @@ +* `bundle plan` and `deploy` no longer list or count a resource that was already deleted remotely as a deletion, matching `bundle destroy`; applying still cleans up its stale state entry. ([#6675](https://github.com/databricks/cli/pull/6675)) diff --git a/acceptance/bundle/resources/jobs/remote_delete/removed_from_config/out.deploy_removed.direct.txt b/acceptance/bundle/resources/jobs/remote_delete/removed_from_config/out.deploy_removed.direct.txt index 345b16e2ebe..be8b0698ea5 100644 --- a/acceptance/bundle/resources/jobs/remote_delete/removed_from_config/out.deploy_removed.direct.txt +++ b/acceptance/bundle/resources/jobs/remote_delete/removed_from_config/out.deploy_removed.direct.txt @@ -1,7 +1,5 @@ >>> [CLI] bundle deploy --auto-approve Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... -Deleted jobs.foo -Deleted pipelines.bar Files: 4 uploaded, 0 deleted -Resources: 0 created, 0 changed, 2 deleted, 0 unchanged +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged diff --git a/acceptance/bundle/resources/jobs/remote_delete/removed_from_config/out.plan_removed.direct.txt b/acceptance/bundle/resources/jobs/remote_delete/removed_from_config/out.plan_removed.direct.txt index acce6c2fee5..ed49fd178e0 100644 --- a/acceptance/bundle/resources/jobs/remote_delete/removed_from_config/out.plan_removed.direct.txt +++ b/acceptance/bundle/resources/jobs/remote_delete/removed_from_config/out.plan_removed.direct.txt @@ -1,6 +1,3 @@ >>> [CLI] bundle plan -delete jobs.foo -delete pipelines.bar - -Plan: 0 to add, 0 to change, 2 to delete, 0 unchanged +Plan: 0 to add, 0 to change, 0 to delete, 0 unchanged diff --git a/bundle/deployplan/action.go b/bundle/deployplan/action.go index c6696bf1e88..e53ef0d84bd 100644 --- a/bundle/deployplan/action.go +++ b/bundle/deployplan/action.go @@ -21,6 +21,15 @@ func (a Action) String() string { return fmt.Sprintf(" %s %s", a.ActionType.StringShort(), a.ResourceKey) } +// IsStateOnlyDelete reports whether applying this delete only drops the state entry +// without any backend call: the resource is already gone remotely (Gone) or has no +// delete operation (StateOnly). Such deletes are omitted from human output, excluded +// from the resource counts, and need no destructive-action approval. See the same +// method on PlanEntry. +func (a Action) IsStateOnlyDelete() bool { + return a.Gone || a.StateOnly +} + func (a Action) IsChildResource() bool { // Note, strictly speaking ResourceKey could be resources.jobs["my.job"] but // we have an assumption in many other places that it's always looks like "resources.jobs.my_job" diff --git a/bundle/deployplan/plan.go b/bundle/deployplan/plan.go index 906d08542ea..87654c7d69b 100644 --- a/bundle/deployplan/plan.go +++ b/bundle/deployplan/plan.go @@ -59,8 +59,9 @@ func (p *Plan) CountActions() ActionCounts { case Delete: // A state-only delete touches nothing in the backend and only drops the // state entry, so it is not a real action: leave it out of the tally - // entirely rather than misreport it as deleted or unchanged. - if entry.StateOnly { + // entirely rather than misreport it as deleted or unchanged. This matches + // how `bundle destroy` counts its own deletions. + if entry.IsStateOnlyDelete() { continue } c.Delete++ @@ -141,6 +142,14 @@ type PlanEntry struct { Changes Changes `json:"changes,omitempty"` } +// IsStateOnlyDelete reports whether applying this delete only drops the state entry +// without any backend call: the resource is already gone remotely (Gone) or has no +// delete operation (StateOnly). Such deletes are omitted from human output, excluded +// from the resource counts, and need no destructive-action approval. +func (e *PlanEntry) IsStateOnlyDelete() bool { + return e.Gone || e.StateOnly +} + type DependsOnEntry struct { Node string `json:"node"` Label string `json:"label,omitempty"` diff --git a/bundle/direct/bundle_apply.go b/bundle/direct/bundle_apply.go index ea90425ee8f..a673a3239c8 100644 --- a/bundle/direct/bundle_apply.go +++ b/bundle/direct/bundle_apply.go @@ -114,10 +114,10 @@ func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.Workspa } if action == deployplan.Delete { - if entry.Gone || entry.StateOnly { - // Either planning confirmed the resource is already deleted remotely - // (Gone), or the resource has no delete operation (StateOnly). Both - // cases only remove it from the state, without calling the delete API. + if entry.IsStateOnlyDelete() { + // The resource is already deleted remotely (Gone) or has no delete + // operation (StateOnly); either way only remove it from the state, + // without calling the delete API. err = b.StateDB.DeleteState(ctx, resourceKey, false) } else { err = d.Destroy(ctx, &b.StateDB) @@ -129,7 +129,7 @@ func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.Workspa // A state-only delete performs no backend operation, so don't report it, // consistent with the summary (CountActions excludes it) and the terraform // path in logDeploySummary. - if reportApplied && !entry.StateOnly { + if reportApplied && !entry.IsStateOnlyDelete() { cmdio.LogString(ctx, deployplan.AppliedLine(resourceKey, action)) } return true diff --git a/bundle/phases/deploy.go b/bundle/phases/deploy.go index 5f9b67748c4..92c6f5c076d 100644 --- a/bundle/phases/deploy.go +++ b/bundle/phases/deploy.go @@ -48,10 +48,9 @@ var deployApprovalGroups = []approvalGroup{ func approvalForDeploy(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan) (bool, error) { actions := plan.GetActions() - // Deletes that only clean up the state — the resource is already gone remotely - // (Gone) or has no delete operation (StateOnly) — are not destructive and need - // no approval. - actions = slices.DeleteFunc(actions, func(a deployplan.Action) bool { return a.Gone || a.StateOnly }) + // Deletes that only clean up the state (already gone remotely, or no delete + // operation) are not destructive and need no approval. + actions = slices.DeleteFunc(actions, func(a deployplan.Action) bool { return a.IsStateOnlyDelete() }) err := checkForPreventDestroy(b, actions) if err != nil { @@ -150,8 +149,7 @@ func logDeploySummary(ctx context.Context, b *bundle.Bundle, plan *deployplan.Pl if action.ActionType == deployplan.Skip || action.ActionType == deployplan.Undefined { continue } - // A state-only delete performs no backend operation, so don't report it. - if action.StateOnly { + if action.IsStateOnlyDelete() { continue } cmdio.LogString(ctx, deployplan.AppliedLine(action.ResourceKey, action.ActionType)) diff --git a/bundle/phases/destroy.go b/bundle/phases/destroy.go index 970de4e5914..5e30c04f5e0 100644 --- a/bundle/phases/destroy.go +++ b/bundle/phases/destroy.go @@ -88,11 +88,11 @@ func logPipelineDeleteApproval(ctx context.Context, b *bundle.Bundle, actions [] func approvalForDestroy(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan, engine engine.EngineType) (bool, error) { deleteActions := plan.GetActions() - // Deletes that only clean up the state — the resource is already gone remotely - // (Gone) or has no delete operation (StateOnly) — are not destructive, so they - // are not listed as deletions and need no approval. In particular this makes - // prevent_destroy inert for state-only resources: nothing is destroyed. - deleteActions = slices.DeleteFunc(deleteActions, func(a deployplan.Action) bool { return a.Gone || a.StateOnly }) + // Deletes that only clean up the state (already gone remotely, or no delete + // operation) are not destructive, so they are not listed as deletions and need no + // approval. In particular this makes prevent_destroy inert for state-only + // resources: nothing is destroyed. + deleteActions = slices.DeleteFunc(deleteActions, func(a deployplan.Action) bool { return a.IsStateOnlyDelete() }) err := checkForPreventDestroy(b, deleteActions) if err != nil { @@ -190,7 +190,7 @@ func destroyCore(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan, e // a destruction to report. deleted := 0 for _, a := range plan.GetActions() { - if a.ActionType == deployplan.Delete && !a.IsChildResource() && !a.Gone && !a.StateOnly { + if a.ActionType == deployplan.Delete && !a.IsChildResource() && !a.IsStateOnlyDelete() { deleted++ } } diff --git a/cmd/bundle/plan.go b/cmd/bundle/plan.go index 5c1d790173d..c5d01c6a98d 100644 --- a/cmd/bundle/plan.go +++ b/cmd/bundle/plan.go @@ -91,7 +91,7 @@ It is useful for previewing changes before running 'bundle deploy'.`, } // A state-only delete has no backend effect; keep it in the JSON // plan but omit it from the human-readable action list. - if action.StateOnly { + if action.IsStateOnlyDelete() { continue } key := strings.TrimPrefix(action.ResourceKey, "resources.")