Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Updating deployment state...
Deployment complete!

>>> [CLI] bundle plan
Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged

=== Delete one permission and deploy again

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ trace $CLI bundle deploy
# although they are semantically the same. We're not doing the same transformation in direct, because permissions get endpoint uses a different order.
print_requests | sort_acl_requests > out.requests_create.json

# Badness: because of remote reordering we have drift in direct there
trace $CLI bundle plan > out.plan.$DATABRICKS_BUNDLE_ENGINE.txt
trace $CLI bundle plan

job_id=$($CLI bundle summary --output json | jq -r '.resources.jobs.job_with_permissions.id')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
},
"changes": {
"local": {
"permissions": {
"permissions[user_name='[email protected]']": {
"action": "update"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@
},
"changes": {
"remote": {
"permissions": {
"permissions[group_name='data-team']": {
"action": "update"
},
"permissions[user_name='[email protected]']": {
"action": "update"
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
bundle:
name: test-bundle

resources:
jobs:
foo:
name: job1
tasks:
- task_key: main
notebook_task:
notebook_path: /Workspace/Users/[email protected]/notebook
source: WORKSPACE
permissions:
- {"level": "CAN_VIEW", "user_name": "[email protected]"}
- level: CAN_MANAGE
group_name: data-team
- level: CAN_MANAGE
service_principal_name: f37d18cd-98a8-4db5-8112-12dd0a6bfe38
# PLACEHOLDER

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@

>>> [CLI] bundle deploy
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files...
Deploying resources...
Updating deployment state...
Deployment complete!

>>> [CLI] bundle plan
Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged

>>> [CLI] bundle deploy
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files...
Deploying resources...
Updating deployment state...
Deployment complete!

>>> [CLI] bundle plan
Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
trace $CLI bundle deploy
update_file.py databricks.yml '- {"level": "CAN_VIEW", "user_name": "[email protected]"}' ''
update_file.py databricks.yml ' # PLACEHOLDER' '- {"level": "CAN_VIEW", "user_name": "[email protected]"}'

trace $CLI bundle plan
trace $CLI bundle deploy
trace $CLI bundle plan
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
RecordRequests = false
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
bundle:
name: test-bundle

resources:
jobs:
foo:
name: job1
tasks:
- task_key: main
notebook_task:
notebook_path: /Workspace/Users/[email protected]/notebook
source: WORKSPACE
permissions:
- {"level": "CAN_VIEW", "user_name": "[email protected]"}
- level: CAN_MANAGE
group_name: data-team
- level: CAN_MANAGE
service_principal_name: f37d18cd-98a8-4db5-8112-12dd0a6bfe38

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@

>>> [CLI] bundle deploy
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files...
Deploying resources...
Updating deployment state...
Deployment complete!

>>> [CLI] bundle plan
Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged

>>> [CLI] permissions set jobs [NUMID] --json @reorder.json

>>> [CLI] bundle plan
Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"access_control_list": [
{"permission_level": "CAN_MANAGE", "group_name": "data-team"},
{"permission_level": "CAN_VIEW", "user_name": "[email protected]"},
{"permission_level": "IS_OWNER", "user_name": "[email protected]"},
{"permission_level": "CAN_MANAGE", "service_principal_name": "f37d18cd-98a8-4db5-8112-12dd0a6bfe38"}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
trace $CLI bundle deploy
trace $CLI bundle plan | contains.py "2 unchanged"

job_id=$($CLI bundle summary --output json | jq -r '.resources.jobs.foo.id')

trace $CLI permissions set jobs $job_id --json @reorder.json > /dev/null

trace $CLI bundle plan | contains.py "2 unchanged"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
RecordRequests = false
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
},
"changes": {
"local": {
"permissions": {
"permissions[group_name='data-team']": {
"action": "update"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
},
"changes": {
"local": {
"permissions[0].permission_level": {
"permissions[user_name='[email protected]'].permission_level": {
"action": "update"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
},
"changes": {
"local": {
"permissions": {
"permissions[group_name='data-team']": {
"action": "update"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
},
"changes": {
"local": {
"permissions[0].permission_level": {
"permissions[user_name='[email protected]'].permission_level": {
"action": "update"
}
}
Expand Down
4 changes: 2 additions & 2 deletions bundle/direct/bundle_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks
// for integers: compare 0 with actual object ID. As long as real object IDs are never 0 we're good.
// Once we add non-id fields or add per-field details to "bundle plan", we must read dynamic data and deal with references as first class citizen.
// This means distinguishing between 0 that are actually object ids and 0 that are there because typed struct integer cannot contain ${...} string.
localDiff, err := structdiff.GetStructDiff(savedState, entry.NewState.Value)
localDiff, err := structdiff.GetStructDiff(savedState, entry.NewState.Value, adapter.KeyedSlices())
if err != nil {
logdiag.LogError(ctx, fmt.Errorf("%s: diffing local state: %w", errorPrefix, err))
return false
Expand Down Expand Up @@ -187,7 +187,7 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks
return false
}

remoteDiff, err := structdiff.GetStructDiff(savedState, remoteStateComparable)
remoteDiff, err := structdiff.GetStructDiff(savedState, remoteStateComparable, adapter.KeyedSlices())
if err != nil {
logdiag.LogError(ctx, fmt.Errorf("%s: diffing remote state: %w", errorPrefix, err))
return false
Expand Down
44 changes: 44 additions & 0 deletions bundle/direct/dresources/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ type IResource interface {

// [Optional] WaitAfterUpdate waits for the resource to become ready after update. Returns optionally updated remote state.
WaitAfterUpdate(ctx context.Context, newState any) (remoteState any, e error)

// [Optional] KeyedSlices returns a map from path patterns to KeyFunc for comparing slices by key instead of by index.
// Example: func (*ResourcePermissions) KeyedSlices(state *PermissionsState) map[string]any
KeyedSlices(state any) map[string]any
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the implementation does not use the state parameter; shall it be removed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, cleaned up 9cb6261

}

// Adapter wraps resource implementation, validates signatures and type consistency across methods
Expand All @@ -106,6 +110,7 @@ type Adapter struct {

fieldTriggersLocal map[string]deployplan.ActionType
fieldTriggersRemote map[string]deployplan.ActionType
keyedSlices map[string]any
}

func NewAdapter(typedNil any, client *databricks.WorkspaceClient) (*Adapter, error) {
Expand Down Expand Up @@ -136,6 +141,7 @@ func NewAdapter(typedNil any, client *databricks.WorkspaceClient) (*Adapter, err
classifyChange: nil,
fieldTriggersLocal: map[string]deployplan.ActionType{},
fieldTriggersRemote: map[string]deployplan.ActionType{},
keyedSlices: nil,
}

err = adapter.initMethods(impl)
Expand Down Expand Up @@ -194,6 +200,27 @@ func loadFieldTriggers(triggerCall *calladapt.BoundCaller, isLocal bool) (map[st
return result, nil
}

// loadKeyedSlices validates and calls KeyedSlices method, returning the resulting map.
func loadKeyedSlices(call *calladapt.BoundCaller) (map[string]any, error) {
// Validate signature: func(stateType) map[string]any
if len(call.InTypes) != 1 {
return nil, fmt.Errorf("KeyedSlices must take exactly 1 parameter, got %d", len(call.InTypes))
}
expectedReturnType := reflect.TypeOf(map[string]any{})
if len(call.OutTypes) != 1 || call.OutTypes[0] != expectedReturnType {
return nil, fmt.Errorf("KeyedSlices must return map[string]any, got %v", call.OutTypes)
}

// Create a nil pointer of the expected type (KeyedSlices implementations should not depend on the state value)
nilArg := reflect.Zero(call.InTypes[0]).Interface()
outs, err := call.Call(nilArg)
if err != nil {
return nil, fmt.Errorf("failed to call KeyedSlices: %w", err)
}
result := outs[0].(map[string]any)
return result, nil
}

func (a *Adapter) initMethods(resource any) error {
err := calladapt.EnsureNoExtraMethods(resource, calladapt.TypeOf[IResource]())
if err != nil {
Expand Down Expand Up @@ -262,6 +289,17 @@ func (a *Adapter) initMethods(resource any) error {
return err
}

keyedSlicesCall, err := calladapt.PrepareCall(resource, calladapt.TypeOf[IResource](), "KeyedSlices")
if err != nil {
return err
}
if keyedSlicesCall != nil {
a.keyedSlices, err = loadKeyedSlices(keyedSlicesCall)
if err != nil {
return err
}
}

return nil
}

Expand Down Expand Up @@ -610,6 +648,12 @@ func (a *Adapter) ClassifyChange(change structdiff.Change, remoteState any, isLo
return actionType, nil
}

// KeyedSlices returns a map from path patterns to KeyFunc for comparing slices by key.
// If the resource doesn't implement KeyedSlices, returns nil.
func (a *Adapter) KeyedSlices() map[string]any {
return a.keyedSlices
}

// prepareCallRequired prepares a call and ensures the method is found.
func prepareCallRequired(resource any, methodName string) (*calladapt.BoundCaller, error) {
caller, err := calladapt.PrepareCall(resource, calladapt.TypeOf[IResource](), methodName)
Expand Down
2 changes: 1 addition & 1 deletion bundle/direct/dresources/all_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W
if remoteStateFromUpdate != nil {
remappedStateFromUpdate, err := adapter.RemapState(remoteStateFromUpdate)
require.NoError(t, err)
changes, err := structdiff.GetStructDiff(remappedState, remappedStateFromUpdate)
changes, err := structdiff.GetStructDiff(remappedState, remappedStateFromUpdate, nil)
require.NoError(t, err)
// Filter out timestamp fields that are expected to differ in value
var relevantChanges []structdiff.Change
Expand Down
19 changes: 19 additions & 0 deletions bundle/direct/dresources/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ func (*ResourcePermissions) PrepareState(s *PermissionsState) *PermissionsState
return s
}

func accessControlRequestKey(x iam.AccessControlRequest) (string, string) {
if x.UserName != "" {
return "user_name", x.UserName
}
if x.ServicePrincipalName != "" {
return "service_principal_name", x.ServicePrincipalName
}
if x.GroupName != "" {
return "group_name", x.GroupName
}
return "", ""
}

func (*ResourcePermissions) KeyedSlices(s *PermissionsState) map[string]any {
return map[string]any{
"permissions": accessControlRequestKey,
}
}

// parsePermissionsID extracts the object type and ID from a permissions ID string.
// Handles both 3-part IDs ("/jobs/123") and 4-part IDs ("/sql/warehouses/uuid").
func parsePermissionsID(id string) (extractedType, extractedID string, err error) {
Expand Down
2 changes: 1 addition & 1 deletion libs/structs/structaccess/set_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ func TestSet(t *testing.T) {
require.NoError(t, err)

// Compare the actual changes using structdiff
changes, err := structdiff.GetStructDiff(original, target)
changes, err := structdiff.GetStructDiff(original, target, nil)
require.NoError(t, err)
assert.Equal(t, tt.expectedChanges, changes)
})
Expand Down
2 changes: 1 addition & 1 deletion libs/structs/structdiff/bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func bench(b *testing.B, job1, job2 string) {

b.ResetTimer()
for range b.N {
changes, err := GetStructDiff(&x, &y)
changes, err := GetStructDiff(&x, &y, nil)
if err != nil {
b.Fatalf("error: %s", err)
}
Expand Down
Loading