From a5166e07b79ac3eb0cda4412ca324fbb4a90bb26 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Sat, 17 Jan 2026 20:53:17 +0100 Subject: [PATCH 1/5] Skip empty slices and maps in bundle plan drift detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty slices (e.g., `on_failure: []`) and empty maps (e.g., `tags: {}`) should not cause drift. Added reasons `empty_slice` and `empty_map` to indicate why these changes are skipped. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../out.plan.direct.json | 3 ++- .../jobs/tags_empty_map/databricks.yml | 13 ++++++++++ .../jobs/tags_empty_map/out.plan.direct.json | 17 +++++++++++++ .../tags_empty_map/out.plan.terraform.json | 9 +++++++ .../jobs/tags_empty_map/out.test.toml | 5 ++++ .../resources/jobs/tags_empty_map/output.txt | 13 ++++++++++ .../resources/jobs/tags_empty_map/script | 3 +++ .../resources/jobs/tags_empty_map/test.toml | 1 + bundle/deployplan/plan.go | 2 ++ bundle/direct/bundle_plan.go | 24 +++++++++++++++++++ 10 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 acceptance/bundle/resources/jobs/tags_empty_map/databricks.yml create mode 100644 acceptance/bundle/resources/jobs/tags_empty_map/out.plan.direct.json create mode 100644 acceptance/bundle/resources/jobs/tags_empty_map/out.plan.terraform.json create mode 100644 acceptance/bundle/resources/jobs/tags_empty_map/out.test.toml create mode 100644 acceptance/bundle/resources/jobs/tags_empty_map/output.txt create mode 100644 acceptance/bundle/resources/jobs/tags_empty_map/script create mode 100644 acceptance/bundle/resources/jobs/tags_empty_map/test.toml diff --git a/acceptance/bundle/resources/jobs/on_failure_empty_slice/out.plan.direct.json b/acceptance/bundle/resources/jobs/on_failure_empty_slice/out.plan.direct.json index 319a62d25d..d7ff65646a 100644 --- a/acceptance/bundle/resources/jobs/on_failure_empty_slice/out.plan.direct.json +++ b/acceptance/bundle/resources/jobs/on_failure_empty_slice/out.plan.direct.json @@ -1,6 +1,7 @@ { "email_notifications.on_failure": { - "action": "update", + "action": "skip", + "reason": "empty_slice", "new": [] }, "tasks[task_key='usage_logs_grants'].notebook_task.source": { diff --git a/acceptance/bundle/resources/jobs/tags_empty_map/databricks.yml b/acceptance/bundle/resources/jobs/tags_empty_map/databricks.yml new file mode 100644 index 0000000000..282c85f662 --- /dev/null +++ b/acceptance/bundle/resources/jobs/tags_empty_map/databricks.yml @@ -0,0 +1,13 @@ +bundle: + name: tags_empty_map + +resources: + jobs: + test_job: + name: Test Job with empty tags + tags: {} + +targets: + dev: + default: true + mode: development diff --git a/acceptance/bundle/resources/jobs/tags_empty_map/out.plan.direct.json b/acceptance/bundle/resources/jobs/tags_empty_map/out.plan.direct.json new file mode 100644 index 0000000000..a30158c762 --- /dev/null +++ b/acceptance/bundle/resources/jobs/tags_empty_map/out.plan.direct.json @@ -0,0 +1,17 @@ +{ + "email_notifications": { + "action": "skip", + "reason": "server_side_default", + "remote": {} + }, + "timeout_seconds": { + "action": "skip", + "reason": "server_side_default", + "remote": 0 + }, + "webhook_notifications": { + "action": "skip", + "reason": "server_side_default", + "remote": {} + } +} diff --git a/acceptance/bundle/resources/jobs/tags_empty_map/out.plan.terraform.json b/acceptance/bundle/resources/jobs/tags_empty_map/out.plan.terraform.json new file mode 100644 index 0000000000..dfcbe561e5 --- /dev/null +++ b/acceptance/bundle/resources/jobs/tags_empty_map/out.plan.terraform.json @@ -0,0 +1,9 @@ +{ + "plan_version": 2, + "cli_version": "[DEV_VERSION]", + "plan": { + "resources.jobs.test_job": { + "action": "skip" + } + } +} diff --git a/acceptance/bundle/resources/jobs/tags_empty_map/out.test.toml b/acceptance/bundle/resources/jobs/tags_empty_map/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/bundle/resources/jobs/tags_empty_map/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/resources/jobs/tags_empty_map/output.txt b/acceptance/bundle/resources/jobs/tags_empty_map/output.txt new file mode 100644 index 0000000000..c7d9cfa6f7 --- /dev/null +++ b/acceptance/bundle/resources/jobs/tags_empty_map/output.txt @@ -0,0 +1,13 @@ + +>>> [CLI] bundle plan +create jobs.test_job + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/tags_empty_map/dev/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle plan -o json diff --git a/acceptance/bundle/resources/jobs/tags_empty_map/script b/acceptance/bundle/resources/jobs/tags_empty_map/script new file mode 100644 index 0000000000..61ebb0a913 --- /dev/null +++ b/acceptance/bundle/resources/jobs/tags_empty_map/script @@ -0,0 +1,3 @@ +trace $CLI bundle plan +trace $CLI bundle deploy +trace $CLI bundle plan -o json | jq '.plan."resources.jobs.test_job".changes // .' > out.plan.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/jobs/tags_empty_map/test.toml b/acceptance/bundle/resources/jobs/tags_empty_map/test.toml new file mode 100644 index 0000000000..a030353d57 --- /dev/null +++ b/acceptance/bundle/resources/jobs/tags_empty_map/test.toml @@ -0,0 +1 @@ +RecordRequests = false diff --git a/bundle/deployplan/plan.go b/bundle/deployplan/plan.go index b819b81686..0aa50742e9 100644 --- a/bundle/deployplan/plan.go +++ b/bundle/deployplan/plan.go @@ -93,6 +93,8 @@ const ( ReasonRemoteAlreadySet = "remote_already_set" ReasonBuiltinRule = "builtin_rule" ReasonConfigOnly = "config_only" + ReasonEmptySlice = "empty_slice" + ReasonEmptyMap = "empty_map" ) // HasChange checks if there are any changes for fields with the given prefix. diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index e5d0277ec1..0dc8884a47 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -378,6 +378,14 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change } else if structdiff.IsEqual(ch.Remote, ch.New) { ch.Action = deployplan.Skip ch.Reason = deployplan.ReasonRemoteAlreadySet + } else if isEmptySlice(ch.New) { + // Empty slice in config should not cause drift when remote has values + ch.Action = deployplan.Skip + ch.Reason = deployplan.ReasonEmptySlice + } else if isEmptyMap(ch.New) { + // Empty map in config should not cause drift when remote has values + ch.Action = deployplan.Skip + ch.Reason = deployplan.ReasonEmptyMap } else if action := getActionFromConfig(cfg, pathString); action != deployplan.Undefined { ch.Action = action ch.Reason = deployplan.ReasonBuiltinRule @@ -418,6 +426,22 @@ func getActionFromConfig(cfg *dresources.ResourceLifecycleConfig, pathString str return deployplan.Undefined } +func isEmptySlice(v any) bool { + if v == nil { + return false + } + rv := reflect.ValueOf(v) + return rv.Kind() == reflect.Slice && rv.Len() == 0 +} + +func isEmptyMap(v any) bool { + if v == nil { + return false + } + rv := reflect.ValueOf(v) + return rv.Kind() == reflect.Map && rv.Len() == 0 +} + // TODO: calling this "Local" is not right, it can resolve "id" and remote refrences for "skip" targets func (b *DeploymentBundle) LookupReferenceLocal(ctx context.Context, path *structpath.PathNode) (any, error) { // TODO: Prefix(3) assumes resources.jobs.foo but not resources.jobs.foo.permissions From 490f949cde4dea1b8ef6e35cfcfad75c3695b5af Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 19 Jan 2026 10:35:29 +0100 Subject: [PATCH 2/5] better check --- bundle/direct/bundle_plan.go | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index 0dc8884a47..529b0c391b 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -378,11 +378,11 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change } else if structdiff.IsEqual(ch.Remote, ch.New) { ch.Action = deployplan.Skip ch.Reason = deployplan.ReasonRemoteAlreadySet - } else if isEmptySlice(ch.New) { + } else if isEmptySlice(ch.Old, ch.New, ch.Remote) { // Empty slice in config should not cause drift when remote has values ch.Action = deployplan.Skip ch.Reason = deployplan.ReasonEmptySlice - } else if isEmptyMap(ch.New) { + } else if isEmptyMap(ch.Old, ch.New, ch.Remote) { // Empty map in config should not cause drift when remote has values ch.Action = deployplan.Skip ch.Reason = deployplan.ReasonEmptyMap @@ -426,20 +426,30 @@ func getActionFromConfig(cfg *dresources.ResourceLifecycleConfig, pathString str return deployplan.Undefined } -func isEmptySlice(v any) bool { - if v == nil { - return false +func isEmptySlice(values ...any) bool { + for _, v := range values { + if v == nil { + continue + } + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Slice || rv.Len() != 0 { + return false + } } - rv := reflect.ValueOf(v) - return rv.Kind() == reflect.Slice && rv.Len() == 0 + return true } -func isEmptyMap(v any) bool { - if v == nil { - return false +func isEmptyMap(values ...any) bool { + for _, v := range values { + if v == nil { + continue + } + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Map || rv.Len() != 0 { + return false + } } - rv := reflect.ValueOf(v) - return rv.Kind() == reflect.Map && rv.Len() == 0 + return true } // TODO: calling this "Local" is not right, it can resolve "id" and remote refrences for "skip" targets From bb713507f2865d7365fb3c1b8a649e4ba148798b Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 19 Jan 2026 10:51:36 +0100 Subject: [PATCH 3/5] move server_Side_default_last --- .../resources/jobs/tags_empty_map/databricks.yml | 5 ----- .../jobs/tags_empty_map/out.plan.direct.json | 5 +++++ .../resources/jobs/tags_empty_map/output.txt | 2 +- bundle/direct/bundle_plan.go | 16 ++++++++-------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/acceptance/bundle/resources/jobs/tags_empty_map/databricks.yml b/acceptance/bundle/resources/jobs/tags_empty_map/databricks.yml index 282c85f662..e527f031bf 100644 --- a/acceptance/bundle/resources/jobs/tags_empty_map/databricks.yml +++ b/acceptance/bundle/resources/jobs/tags_empty_map/databricks.yml @@ -6,8 +6,3 @@ resources: test_job: name: Test Job with empty tags tags: {} - -targets: - dev: - default: true - mode: development diff --git a/acceptance/bundle/resources/jobs/tags_empty_map/out.plan.direct.json b/acceptance/bundle/resources/jobs/tags_empty_map/out.plan.direct.json index a30158c762..f79692c1db 100644 --- a/acceptance/bundle/resources/jobs/tags_empty_map/out.plan.direct.json +++ b/acceptance/bundle/resources/jobs/tags_empty_map/out.plan.direct.json @@ -4,6 +4,11 @@ "reason": "server_side_default", "remote": {} }, + "tags": { + "action": "skip", + "reason": "empty_map", + "new": {} + }, "timeout_seconds": { "action": "skip", "reason": "server_side_default", diff --git a/acceptance/bundle/resources/jobs/tags_empty_map/output.txt b/acceptance/bundle/resources/jobs/tags_empty_map/output.txt index c7d9cfa6f7..8ad5b06021 100644 --- a/acceptance/bundle/resources/jobs/tags_empty_map/output.txt +++ b/acceptance/bundle/resources/jobs/tags_empty_map/output.txt @@ -5,7 +5,7 @@ create jobs.test_job Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged >>> [CLI] bundle deploy -Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/tags_empty_map/dev/files... +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/tags_empty_map/default/files... Deploying resources... Updating deployment state... Deployment complete! diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index 529b0c391b..d2824714bb 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -368,14 +368,7 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change return err } - if ch.New == nil && ch.Old == nil && ch.Remote != nil && path.IsDotString() { - // The field was not set by us, but comes from the remote state. - // This could either be server-side default or a policy. - // In any case, this is not a change we should react to. - // Note, we only consider struct fields here. Adding/removing elements to/from maps and slices should trigger updates. - ch.Action = deployplan.Skip - ch.Reason = deployplan.ReasonServerSideDefault - } else if structdiff.IsEqual(ch.Remote, ch.New) { + if structdiff.IsEqual(ch.Remote, ch.New) { ch.Action = deployplan.Skip ch.Reason = deployplan.ReasonRemoteAlreadySet } else if isEmptySlice(ch.Old, ch.New, ch.Remote) { @@ -389,6 +382,13 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change } else if action := getActionFromConfig(cfg, pathString); action != deployplan.Undefined { ch.Action = action ch.Reason = deployplan.ReasonBuiltinRule + } else if ch.New == nil && ch.Old == nil && ch.Remote != nil && path.IsDotString() { + // The field was not set by us, but comes from the remote state. + // This could either be server-side default or a policy. + // In any case, this is not a change we should react to. + // Note, we only consider struct fields here. Adding/removing elements to/from maps and slices should trigger updates. + ch.Action = deployplan.Skip + ch.Reason = deployplan.ReasonServerSideDefault } else { ch.Action = deployplan.Update } From 0b4f5e227449519936ae25100f1e5d0f4dbeeb04 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 19 Jan 2026 11:00:49 +0100 Subject: [PATCH 4/5] split config in two parts --- bundle/direct/bundle_plan.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index d2824714bb..24687ca1d2 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -379,7 +379,7 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change // Empty map in config should not cause drift when remote has values ch.Action = deployplan.Skip ch.Reason = deployplan.ReasonEmptyMap - } else if action := getActionFromConfig(cfg, pathString); action != deployplan.Undefined { + } else if action := shouldIgnore(cfg, pathString); action != deployplan.Undefined { ch.Action = action ch.Reason = deployplan.ReasonBuiltinRule } else if ch.New == nil && ch.Old == nil && ch.Remote != nil && path.IsDotString() { @@ -389,6 +389,9 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change // Note, we only consider struct fields here. Adding/removing elements to/from maps and slices should trigger updates. ch.Action = deployplan.Skip ch.Reason = deployplan.ReasonServerSideDefault + } else if action := shouldUpdateOrRecreate(cfg, pathString); action != deployplan.Undefined { + ch.Action = action + ch.Reason = deployplan.ReasonBuiltinRule } else { ch.Action = deployplan.Update } @@ -402,9 +405,7 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change return nil } -// getActionFromConfig returns the action for a field path based on resource config. -// Returns Undefined if no config applies. -func getActionFromConfig(cfg *dresources.ResourceLifecycleConfig, pathString string) deployplan.ActionType { +func shouldIgnore(cfg *dresources.ResourceLifecycleConfig, pathString string) deployplan.ActionType { if cfg == nil { return deployplan.Undefined } @@ -413,6 +414,13 @@ func getActionFromConfig(cfg *dresources.ResourceLifecycleConfig, pathString str return deployplan.Skip } } + return deployplan.Undefined +} + +func shouldUpdateOrRecreate(cfg *dresources.ResourceLifecycleConfig, pathString string) deployplan.ActionType { + if cfg == nil { + return deployplan.Undefined + } for _, p := range cfg.RecreateOnChanges { if structpath.HasPrefix(pathString, p.String()) { return deployplan.Recreate From 7264c24147a49c280f951151a3e739d1ba471cbb Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 19 Jan 2026 11:20:59 +0100 Subject: [PATCH 5/5] add a comment --- bundle/direct/bundle_plan.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index 24687ca1d2..d24d938c0d 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -434,6 +434,10 @@ func shouldUpdateOrRecreate(cfg *dresources.ResourceLifecycleConfig, pathString return deployplan.Undefined } +// Empty slices and maps cannot be represented in proto and because of that they cannot be represented +// by SDK's JSON encoder. However, they can be provided by users in the config and can be represented in +// Bundle struct (currently libs/structs and libs/dyn use ForceSendFields for maps and slices, unlike SDK). +// Thus we get permanent drift because we see that new config is [] but in the state it is omitted. func isEmptySlice(values ...any) bool { for _, v := range values { if v == nil {