Skip to content

Commit b9c7eb9

Browse files
denikclaude
andauthored
direct: Ignore changes between nulls and empty slices/maps (#4313)
## Why Although these changes can appear in configs, they cannot be represented by SDK's structs, so they cannot be persisted or appear in remote reply, thus causing a permanent drift. Fixes #4121 ## Tests Existing and new acceptance tests. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 644a4fb commit b9c7eb9

File tree

10 files changed

+119
-9
lines changed

10 files changed

+119
-9
lines changed

acceptance/bundle/resources/jobs/on_failure_empty_slice/out.plan.direct.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"email_notifications.on_failure": {
3-
"action": "update",
3+
"action": "skip",
4+
"reason": "empty_slice",
45
"new": []
56
},
67
"tasks[task_key='usage_logs_grants'].notebook_task.source": {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
bundle:
2+
name: tags_empty_map
3+
4+
resources:
5+
jobs:
6+
test_job:
7+
name: Test Job with empty tags
8+
tags: {}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"email_notifications": {
3+
"action": "skip",
4+
"reason": "server_side_default",
5+
"remote": {}
6+
},
7+
"tags": {
8+
"action": "skip",
9+
"reason": "empty_map",
10+
"new": {}
11+
},
12+
"timeout_seconds": {
13+
"action": "skip",
14+
"reason": "server_side_default",
15+
"remote": 0
16+
},
17+
"webhook_notifications": {
18+
"action": "skip",
19+
"reason": "server_side_default",
20+
"remote": {}
21+
}
22+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"plan_version": 2,
3+
"cli_version": "[DEV_VERSION]",
4+
"plan": {
5+
"resources.jobs.test_job": {
6+
"action": "skip"
7+
}
8+
}
9+
}

acceptance/bundle/resources/jobs/tags_empty_map/out.test.toml

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
2+
>>> [CLI] bundle plan
3+
create jobs.test_job
4+
5+
Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged
6+
7+
>>> [CLI] bundle deploy
8+
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/tags_empty_map/default/files...
9+
Deploying resources...
10+
Updating deployment state...
11+
Deployment complete!
12+
13+
>>> [CLI] bundle plan -o json
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
trace $CLI bundle plan
2+
trace $CLI bundle deploy
3+
trace $CLI bundle plan -o json | jq '.plan."resources.jobs.test_job".changes // .' > out.plan.$DATABRICKS_BUNDLE_ENGINE.json
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
RecordRequests = false

bundle/deployplan/plan.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ const (
9393
ReasonRemoteAlreadySet = "remote_already_set"
9494
ReasonBuiltinRule = "builtin_rule"
9595
ReasonConfigOnly = "config_only"
96+
ReasonEmptySlice = "empty_slice"
97+
ReasonEmptyMap = "empty_map"
9698
)
9799

98100
// HasChange checks if there are any changes for fields with the given prefix.

bundle/direct/bundle_plan.go

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -368,17 +368,28 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change
368368
return err
369369
}
370370

371-
if ch.New == nil && ch.Old == nil && ch.Remote != nil && path.IsDotString() {
371+
if structdiff.IsEqual(ch.Remote, ch.New) {
372+
ch.Action = deployplan.Skip
373+
ch.Reason = deployplan.ReasonRemoteAlreadySet
374+
} else if isEmptySlice(ch.Old, ch.New, ch.Remote) {
375+
// Empty slice in config should not cause drift when remote has values
376+
ch.Action = deployplan.Skip
377+
ch.Reason = deployplan.ReasonEmptySlice
378+
} else if isEmptyMap(ch.Old, ch.New, ch.Remote) {
379+
// Empty map in config should not cause drift when remote has values
380+
ch.Action = deployplan.Skip
381+
ch.Reason = deployplan.ReasonEmptyMap
382+
} else if action := shouldIgnore(cfg, pathString); action != deployplan.Undefined {
383+
ch.Action = action
384+
ch.Reason = deployplan.ReasonBuiltinRule
385+
} else if ch.New == nil && ch.Old == nil && ch.Remote != nil && path.IsDotString() {
372386
// The field was not set by us, but comes from the remote state.
373387
// This could either be server-side default or a policy.
374388
// In any case, this is not a change we should react to.
375389
// Note, we only consider struct fields here. Adding/removing elements to/from maps and slices should trigger updates.
376390
ch.Action = deployplan.Skip
377391
ch.Reason = deployplan.ReasonServerSideDefault
378-
} else if structdiff.IsEqual(ch.Remote, ch.New) {
379-
ch.Action = deployplan.Skip
380-
ch.Reason = deployplan.ReasonRemoteAlreadySet
381-
} else if action := getActionFromConfig(cfg, pathString); action != deployplan.Undefined {
392+
} else if action := shouldUpdateOrRecreate(cfg, pathString); action != deployplan.Undefined {
382393
ch.Action = action
383394
ch.Reason = deployplan.ReasonBuiltinRule
384395
} else {
@@ -394,9 +405,7 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change
394405
return nil
395406
}
396407

397-
// getActionFromConfig returns the action for a field path based on resource config.
398-
// Returns Undefined if no config applies.
399-
func getActionFromConfig(cfg *dresources.ResourceLifecycleConfig, pathString string) deployplan.ActionType {
408+
func shouldIgnore(cfg *dresources.ResourceLifecycleConfig, pathString string) deployplan.ActionType {
400409
if cfg == nil {
401410
return deployplan.Undefined
402411
}
@@ -405,6 +414,13 @@ func getActionFromConfig(cfg *dresources.ResourceLifecycleConfig, pathString str
405414
return deployplan.Skip
406415
}
407416
}
417+
return deployplan.Undefined
418+
}
419+
420+
func shouldUpdateOrRecreate(cfg *dresources.ResourceLifecycleConfig, pathString string) deployplan.ActionType {
421+
if cfg == nil {
422+
return deployplan.Undefined
423+
}
408424
for _, p := range cfg.RecreateOnChanges {
409425
if structpath.HasPrefix(pathString, p.String()) {
410426
return deployplan.Recreate
@@ -418,6 +434,36 @@ func getActionFromConfig(cfg *dresources.ResourceLifecycleConfig, pathString str
418434
return deployplan.Undefined
419435
}
420436

437+
// Empty slices and maps cannot be represented in proto and because of that they cannot be represented
438+
// by SDK's JSON encoder. However, they can be provided by users in the config and can be represented in
439+
// Bundle struct (currently libs/structs and libs/dyn use ForceSendFields for maps and slices, unlike SDK).
440+
// Thus we get permanent drift because we see that new config is [] but in the state it is omitted.
441+
func isEmptySlice(values ...any) bool {
442+
for _, v := range values {
443+
if v == nil {
444+
continue
445+
}
446+
rv := reflect.ValueOf(v)
447+
if rv.Kind() != reflect.Slice || rv.Len() != 0 {
448+
return false
449+
}
450+
}
451+
return true
452+
}
453+
454+
func isEmptyMap(values ...any) bool {
455+
for _, v := range values {
456+
if v == nil {
457+
continue
458+
}
459+
rv := reflect.ValueOf(v)
460+
if rv.Kind() != reflect.Map || rv.Len() != 0 {
461+
return false
462+
}
463+
}
464+
return true
465+
}
466+
421467
// TODO: calling this "Local" is not right, it can resolve "id" and remote refrences for "skip" targets
422468
func (b *DeploymentBundle) LookupReferenceLocal(ctx context.Context, path *structpath.PathNode) (any, error) {
423469
// TODO: Prefix(3) assumes resources.jobs.foo but not resources.jobs.foo.permissions

0 commit comments

Comments
 (0)