From e98ccc99886e86965252d03cd9813ebfd2b23aa7 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 26 Mar 2026 22:35:30 +0100 Subject: [PATCH 01/22] Fix secret scope permissions migration from Terraform to Direct engine During migration, Terraform's databricks_secret_acl resources are not tracked in the migration state. The direct engine manages secret scope permissions as a sub-resource (secret_scopes.*.permissions), so without a state entry, the post-migration plan shows a "create" action. Add state entries for secret_scopes.*.permissions after migration Apply to prevent phantom drift. Co-authored-by: Isaac --- acceptance/bundle/invariant/migrate/test.toml | 4 --- cmd/bundle/deployment/migrate.go | 31 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/acceptance/bundle/invariant/migrate/test.toml b/acceptance/bundle/invariant/migrate/test.toml index d73fd65904..5ffa32ae13 100644 --- a/acceptance/bundle/invariant/migrate/test.toml +++ b/acceptance/bundle/invariant/migrate/test.toml @@ -2,10 +2,6 @@ EnvMatrixExclude.no_catalog = ["INPUT_CONFIG=catalog.yml.tmpl"] EnvMatrixExclude.no_external_location = ["INPUT_CONFIG=external_location.yml.tmpl"] -# Unexpected action='create' for resources.secret_scopes.foo.permissions -EnvMatrixExclude.no_secret_scope = ["INPUT_CONFIG=secret_scope.yml.tmpl"] -EnvMatrixExclude.no_secret_scope2 = ["INPUT_CONFIG=secret_scope_default_backend_type.yml.tmpl"] - # Cross-resource permission references (e.g. ${resources.jobs.job_b.permissions[0].level}) # don't work in terraform mode: the terraform interpolator converts the path to # ${databricks_job.job_b.permissions[0].level}, but Terraform's databricks_job resource diff --git a/cmd/bundle/deployment/migrate.go b/cmd/bundle/deployment/migrate.go index 88e44e024e..922c74a4c1 100644 --- a/cmd/bundle/deployment/migrate.go +++ b/cmd/bundle/deployment/migrate.go @@ -281,6 +281,37 @@ To start using direct engine, set "engine: direct" under bundle in your databric return root.ErrAlreadyPrinted } + // Terraform manages secret scope permissions via databricks_secret_acl resources, + // which are not included in the migration state. The direct engine manages them as + // a sub-resource (secret_scopes.*.permissions). Add state entries so that the + // direct engine doesn't plan a "create" action for these after migration. + // This runs after Apply+Finalize, so we modify the in-memory state and re-save. + needsResave := false + for key := range deploymentBundle.StateDB.Data.State { + if !strings.HasPrefix(key, "resources.secret_scopes.") || strings.HasSuffix(key, ".permissions") { + continue + } + permKey := key + ".permissions" + if _, exists := deploymentBundle.StateDB.Data.State[permKey]; exists { + continue + } + entry := deploymentBundle.StateDB.Data.State[key] + deploymentBundle.StateDB.Data.State[permKey] = dstate.ResourceEntry{ + ID: entry.ID, + State: json.RawMessage("{}"), + } + needsResave = true + } + if needsResave { + data, err := json.MarshalIndent(deploymentBundle.StateDB.Data, "", " ") + if err != nil { + return fmt.Errorf("failed to serialize state: %w", err) + } + if err := os.WriteFile(tempStatePath, data, 0o600); err != nil { + return fmt.Errorf("failed to write state file: %w", err) + } + } + if err := os.Rename(tempStatePath, localPath); err != nil { return fmt.Errorf("renaming %s to %s: %w", tempStatePath, localPath, err) } From e7909e1bc8ba629feade13e947a372885fb62e18 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 27 Mar 2026 14:56:22 +0100 Subject: [PATCH 02/22] Handle databricks_secret_acl in ParseResourcesState Add databricks_secret_acl to TerraformToGroupName mapping and handle it in parseResourcesState, similar to how databricks_permissions and databricks_grants are handled. Multiple ACL resources per scope map to a single .permissions entry with the scope name as ID. Co-authored-by: Isaac --- bundle/deploy/terraform/showplanfile.go | 22 +++++++ bundle/deploy/terraform/showplanfile_test.go | 21 +++++++ bundle/deploy/terraform/util.go | 62 ++++++++++++------- bundle/deploy/terraform/util_test.go | 63 ++++++++++++++++++++ 4 files changed, 146 insertions(+), 22 deletions(-) diff --git a/bundle/deploy/terraform/showplanfile.go b/bundle/deploy/terraform/showplanfile.go index 23e9436b34..f88ca47975 100644 --- a/bundle/deploy/terraform/showplanfile.go +++ b/bundle/deploy/terraform/showplanfile.go @@ -63,6 +63,28 @@ func convertGrantsResourceNameToKey(terraformName string) string { return "" } +// convertSecretAclResourceNameToKey converts terraform secret ACL resource names back to hierarchical resource keys. +// Terraform creates N separate databricks_secret_acl resources per scope (one per principal), +// named "secret_acl__". They all map to a single permissions sub-resource. +// e.g., "secret_acl_my_scope_0" -> "resources.secret_scopes.my_scope.permissions" +func convertSecretAclResourceNameToKey(terraformName string) string { + name, found := strings.CutPrefix(terraformName, "secret_acl_") + if !found { + return "" + } + lastUnderscore := strings.LastIndex(name, "_") + if lastUnderscore <= 0 { + return "" + } + // Verify suffix is the ACL index (digits only). + for _, c := range name[lastUnderscore+1:] { + if c < '0' || c > '9' { + return "" + } + } + return "resources.secret_scopes." + name[:lastUnderscore] + ".permissions" +} + // populatePlan populates a deployplan.Plan from Terraform resource changes. func populatePlan(ctx context.Context, plan *deployplan.Plan, changes []*tfjson.ResourceChange) { for _, rc := range changes { diff --git a/bundle/deploy/terraform/showplanfile_test.go b/bundle/deploy/terraform/showplanfile_test.go index 75a2cc6f43..cff9a241d0 100644 --- a/bundle/deploy/terraform/showplanfile_test.go +++ b/bundle/deploy/terraform/showplanfile_test.go @@ -76,3 +76,24 @@ func TestPopulatePlan(t *testing.T) { // Unknown resource type should not be in the plan assert.NotContains(t, plan.Plan, "resources.recreate whatever") } + +func TestConvertSecretAclResourceNameToKey(t *testing.T) { + tests := []struct { + name string + expected string + }{ + {"secret_acl_my_scope_0", "resources.secret_scopes.my_scope.permissions"}, + {"secret_acl_my_scope_1", "resources.secret_scopes.my_scope.permissions"}, + {"secret_acl_scope_with_underscores_42", "resources.secret_scopes.scope_with_underscores.permissions"}, + {"secret_acl__0", ""}, // empty scope key + {"not_a_secret_acl", ""}, // wrong prefix + {"secret_acl_", ""}, // no key or index + {"secret_acl_foo", ""}, // no index suffix + {"secret_acl_foo_abc", ""}, // non-numeric index + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, convertSecretAclResourceNameToKey(tt.name)) + }) + } +} diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index ff083995cf..a551bea26e 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/statemgmt/resourcestate" @@ -75,32 +76,37 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap continue } for _, instance := range resource.Instances { - groupName, ok := TerraformToGroupName[resource.Type] - - if !ok { - // secret_acls - continue - } - var resourceKey string var resourceState ResourceState - switch groupName { - case "apps", "secret_scopes", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_endpoints": - resourceKey = "resources." + groupName + "." + resource.Name - resourceState = ResourceState{ID: instance.Attributes.Name} - case "dashboards": - resourceKey = "resources." + groupName + "." + resource.Name - resourceState = ResourceState{ID: instance.Attributes.ID, ETag: instance.Attributes.ETag} - case "permissions": - resourceKey = convertPermissionsResourceNameToKey(resource.Name) - resourceState = ResourceState{ID: instance.Attributes.ID} - case "grants": - resourceKey = convertGrantsResourceNameToKey(resource.Name) - resourceState = ResourceState{ID: instance.Attributes.ID} - default: - resourceKey = "resources." + groupName + "." + resource.Name + if resource.Type == "databricks_secret_acl" { + // Multiple databricks_secret_acl resources map to a single .permissions entry. + // The ID is resolved to the scope name in the post-processing step below. + resourceKey = convertSecretAclResourceNameToKey(resource.Name) resourceState = ResourceState{ID: instance.Attributes.ID} + } else { + groupName, ok := TerraformToGroupName[resource.Type] + if !ok { + continue + } + + switch groupName { + case "apps", "secret_scopes", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_endpoints": + resourceKey = "resources." + groupName + "." + resource.Name + resourceState = ResourceState{ID: instance.Attributes.Name} + case "dashboards": + resourceKey = "resources." + groupName + "." + resource.Name + resourceState = ResourceState{ID: instance.Attributes.ID, ETag: instance.Attributes.ETag} + case "permissions": + resourceKey = convertPermissionsResourceNameToKey(resource.Name) + resourceState = ResourceState{ID: instance.Attributes.ID} + case "grants": + resourceKey = convertGrantsResourceNameToKey(resource.Name) + resourceState = ResourceState{ID: instance.Attributes.ID} + default: + resourceKey = "resources." + groupName + "." + resource.Name + resourceState = ResourceState{ID: instance.Attributes.ID} + } } if resourceKey == "" { @@ -111,6 +117,18 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap } } + // Resolve secret scope permission IDs: use the scope name (which is the scope's ID) + // instead of the Terraform ACL ID. The direct engine expects the scope name. + for key := range result { + if !strings.HasPrefix(key, "resources.secret_scopes.") || !strings.HasSuffix(key, ".permissions") { + continue + } + scopeKey := strings.TrimSuffix(key, ".permissions") + if scopeEntry, ok := result[scopeKey]; ok { + result[key] = ResourceState{ID: scopeEntry.ID} + } + } + return result, nil } diff --git a/bundle/deploy/terraform/util_test.go b/bundle/deploy/terraform/util_test.go index 3e4e6eb636..b3729ce175 100644 --- a/bundle/deploy/terraform/util_test.go +++ b/bundle/deploy/terraform/util_test.go @@ -8,6 +8,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParseResourcesStateWithNoFile(t *testing.T) { @@ -94,3 +95,65 @@ func TestParseResourcesStateWithExistingStateFile(t *testing.T) { } assert.Equal(t, expected, state) } + +func TestParseResourcesStateSecretScopeWithAcls(t *testing.T) { + ctx := t.Context() + data := []byte(`{ + "version": 4, + "resources": [ + { + "mode": "managed", + "type": "databricks_secret_scope", + "name": "my_scope", + "instances": [{"attributes": {"id": "123", "name": "actual-scope-name"}}] + }, + { + "mode": "managed", + "type": "databricks_secret_acl", + "name": "secret_acl_my_scope_0", + "instances": [{"attributes": {"id": "actual-scope-name|||user@example.com"}}] + }, + { + "mode": "managed", + "type": "databricks_secret_acl", + "name": "secret_acl_my_scope_1", + "instances": [{"attributes": {"id": "actual-scope-name|||data-team"}}] + } + ] + }`) + path := filepath.Join(t.TempDir(), "state.json") + require.NoError(t, os.WriteFile(path, data, 0o600)) + + state, err := parseResourcesState(ctx, path) + require.NoError(t, err) + + assert.Equal(t, ExportedResourcesMap{ + "resources.secret_scopes.my_scope": {ID: "actual-scope-name"}, + "resources.secret_scopes.my_scope.permissions": {ID: "actual-scope-name"}, + }, state) +} + +func TestParseResourcesStateSecretScopeWithoutAcls(t *testing.T) { + ctx := t.Context() + data := []byte(`{ + "version": 4, + "resources": [ + { + "mode": "managed", + "type": "databricks_secret_scope", + "name": "my_scope", + "instances": [{"attributes": {"id": "123", "name": "my-scope-name"}}] + } + ] + }`) + path := filepath.Join(t.TempDir(), "state.json") + require.NoError(t, os.WriteFile(path, data, 0o600)) + + state, err := parseResourcesState(ctx, path) + require.NoError(t, err) + + // No ACLs → no permissions entry; migrate.go fixup handles this case. + assert.Equal(t, ExportedResourcesMap{ + "resources.secret_scopes.my_scope": {ID: "my-scope-name"}, + }, state) +} From e60883d7074aab4da960dc3b2222ad4d3eda2d1d Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 27 Mar 2026 15:20:38 +0100 Subject: [PATCH 03/22] Move secret scope .permissions handling from migrate.go to ParseResourcesState ParseResourcesState now creates .permissions entries for ALL secret scopes (not just those with databricks_secret_acl), so the post-Apply fixup in migrate.go is no longer needed. Co-authored-by: Isaac --- bundle/deploy/terraform/util.go | 19 ++++++++++------- bundle/deploy/terraform/util_test.go | 6 ++++-- cmd/bundle/deployment/migrate.go | 31 ---------------------------- 3 files changed, 16 insertions(+), 40 deletions(-) diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index a551bea26e..ff0ffa29c7 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -117,15 +117,20 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap } } - // Resolve secret scope permission IDs: use the scope name (which is the scope's ID) - // instead of the Terraform ACL ID. The direct engine expects the scope name. - for key := range result { - if !strings.HasPrefix(key, "resources.secret_scopes.") || !strings.HasSuffix(key, ".permissions") { + // Ensure every secret scope has a .permissions entry. The direct engine manages + // permissions as a sub-resource (SecretScopeFixups adds MANAGE for the current user). + // For scopes with databricks_secret_acl in state, resolve the ACL ID to the scope name. + // For scopes without ACLs, create a .permissions entry with the scope name as ID. + for key, entry := range result { + if !strings.HasPrefix(key, "resources.secret_scopes.") || strings.Contains(key, ".permissions") { continue } - scopeKey := strings.TrimSuffix(key, ".permissions") - if scopeEntry, ok := result[scopeKey]; ok { - result[key] = ResourceState{ID: scopeEntry.ID} + permKey := key + ".permissions" + if _, exists := result[permKey]; exists { + // Resolve ACL ID → scope name (the direct engine expects scope name as ID). + result[permKey] = ResourceState{ID: entry.ID} + } else { + result[permKey] = ResourceState{ID: entry.ID} } } diff --git a/bundle/deploy/terraform/util_test.go b/bundle/deploy/terraform/util_test.go index b3729ce175..59b0f03635 100644 --- a/bundle/deploy/terraform/util_test.go +++ b/bundle/deploy/terraform/util_test.go @@ -152,8 +152,10 @@ func TestParseResourcesStateSecretScopeWithoutAcls(t *testing.T) { state, err := parseResourcesState(ctx, path) require.NoError(t, err) - // No ACLs → no permissions entry; migrate.go fixup handles this case. + // Even without ACLs, a .permissions entry is created for every secret scope, + // so the direct engine doesn't plan a phantom "create" after migration. assert.Equal(t, ExportedResourcesMap{ - "resources.secret_scopes.my_scope": {ID: "my-scope-name"}, + "resources.secret_scopes.my_scope": {ID: "my-scope-name"}, + "resources.secret_scopes.my_scope.permissions": {ID: "my-scope-name"}, }, state) } diff --git a/cmd/bundle/deployment/migrate.go b/cmd/bundle/deployment/migrate.go index 922c74a4c1..88e44e024e 100644 --- a/cmd/bundle/deployment/migrate.go +++ b/cmd/bundle/deployment/migrate.go @@ -281,37 +281,6 @@ To start using direct engine, set "engine: direct" under bundle in your databric return root.ErrAlreadyPrinted } - // Terraform manages secret scope permissions via databricks_secret_acl resources, - // which are not included in the migration state. The direct engine manages them as - // a sub-resource (secret_scopes.*.permissions). Add state entries so that the - // direct engine doesn't plan a "create" action for these after migration. - // This runs after Apply+Finalize, so we modify the in-memory state and re-save. - needsResave := false - for key := range deploymentBundle.StateDB.Data.State { - if !strings.HasPrefix(key, "resources.secret_scopes.") || strings.HasSuffix(key, ".permissions") { - continue - } - permKey := key + ".permissions" - if _, exists := deploymentBundle.StateDB.Data.State[permKey]; exists { - continue - } - entry := deploymentBundle.StateDB.Data.State[key] - deploymentBundle.StateDB.Data.State[permKey] = dstate.ResourceEntry{ - ID: entry.ID, - State: json.RawMessage("{}"), - } - needsResave = true - } - if needsResave { - data, err := json.MarshalIndent(deploymentBundle.StateDB.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to serialize state: %w", err) - } - if err := os.WriteFile(tempStatePath, data, 0o600); err != nil { - return fmt.Errorf("failed to write state file: %w", err) - } - } - if err := os.Rename(tempStatePath, localPath); err != nil { return fmt.Errorf("renaming %s to %s: %w", tempStatePath, localPath, err) } From 185d66c8f9c26b87e51d68912f6beda202c8d2fc Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 30 Mar 2026 11:15:42 +0200 Subject: [PATCH 04/22] Add secret_scope_with_permissions invariant test config Add a test config with a secret scope that has two explicit ACL permissions (READ for users, WRITE for account users). This exercises the multi-ACL migration path in parseResourcesState. Co-authored-by: Isaac --- .../configs/secret_scope_with_permissions.yml.tmpl | 13 +++++++++++++ .../bundle/invariant/continue_293/out.test.toml | 2 +- acceptance/bundle/invariant/migrate/out.test.toml | 2 +- acceptance/bundle/invariant/no_drift/out.test.toml | 2 +- acceptance/bundle/invariant/test.toml | 1 + 5 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 acceptance/bundle/invariant/configs/secret_scope_with_permissions.yml.tmpl diff --git a/acceptance/bundle/invariant/configs/secret_scope_with_permissions.yml.tmpl b/acceptance/bundle/invariant/configs/secret_scope_with_permissions.yml.tmpl new file mode 100644 index 0000000000..6bc83ee970 --- /dev/null +++ b/acceptance/bundle/invariant/configs/secret_scope_with_permissions.yml.tmpl @@ -0,0 +1,13 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + secret_scopes: + foo: + name: test-scope-$UNIQUE_NAME + backend_type: DATABRICKS + permissions: + - level: READ + group_name: users + - level: WRITE + group_name: account users diff --git a/acceptance/bundle/invariant/continue_293/out.test.toml b/acceptance/bundle/invariant/continue_293/out.test.toml index 711e04631f..199859bd7c 100644 --- a/acceptance/bundle/invariant/continue_293/out.test.toml +++ b/acceptance/bundle/invariant/continue_293/out.test.toml @@ -4,4 +4,4 @@ RequiresUnityCatalog = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] - INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "synced_database_table.yml.tmpl", "volume.yml.tmpl"] + INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "synced_database_table.yml.tmpl", "volume.yml.tmpl"] diff --git a/acceptance/bundle/invariant/migrate/out.test.toml b/acceptance/bundle/invariant/migrate/out.test.toml index c12dd02d1f..7da28d9c55 100644 --- a/acceptance/bundle/invariant/migrate/out.test.toml +++ b/acceptance/bundle/invariant/migrate/out.test.toml @@ -4,4 +4,4 @@ RequiresUnityCatalog = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] - INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "synced_database_table.yml.tmpl", "volume.yml.tmpl"] + INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "synced_database_table.yml.tmpl", "volume.yml.tmpl"] diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index c12dd02d1f..7da28d9c55 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -4,4 +4,4 @@ RequiresUnityCatalog = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] - INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "synced_database_table.yml.tmpl", "volume.yml.tmpl"] + INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "synced_database_table.yml.tmpl", "volume.yml.tmpl"] diff --git a/acceptance/bundle/invariant/test.toml b/acceptance/bundle/invariant/test.toml index 00b826c9cf..cf7cd12c30 100644 --- a/acceptance/bundle/invariant/test.toml +++ b/acceptance/bundle/invariant/test.toml @@ -49,6 +49,7 @@ EnvMatrix.INPUT_CONFIG = [ "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", + "secret_scope_with_permissions.yml.tmpl", "synced_database_table.yml.tmpl", "volume.yml.tmpl", ] From 8250be5593ecb815f3d355773f8549445a9b97e0 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 30 Mar 2026 15:07:57 +0200 Subject: [PATCH 05/22] Apply SecretScopeFixups before CalculatePlan in migrate flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During migration, SecretScopeFixups wasn't running (it's part of PreDeployChecks which migration skips). For scopes without explicit ACLs, parseResourcesState synthesizes .permissions entries in the state, but the config didn't have them, causing a Delete→forced Update→no StateCache entry error chain. Fix: apply SecretScopeFixups(EngineDirect) before CalculatePlan so the config and state agree on .permissions entries. Also simplify the identical if/else branches in the safety net. Co-authored-by: Isaac --- bundle/deploy/terraform/util.go | 9 +++------ cmd/bundle/deployment/migrate.go | 10 +++++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index ff0ffa29c7..68b6d88884 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -126,12 +126,9 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap continue } permKey := key + ".permissions" - if _, exists := result[permKey]; exists { - // Resolve ACL ID → scope name (the direct engine expects scope name as ID). - result[permKey] = ResourceState{ID: entry.ID} - } else { - result[permKey] = ResourceState{ID: entry.ID} - } + // Use scope name as ID (the direct engine expects it). + // This overwrites the ACL compound ID if ACLs were present. + result[permKey] = ResourceState{ID: entry.ID} } return result, nil diff --git a/cmd/bundle/deployment/migrate.go b/cmd/bundle/deployment/migrate.go index 88e44e024e..07951f6bf6 100644 --- a/cmd/bundle/deployment/migrate.go +++ b/cmd/bundle/deployment/migrate.go @@ -12,7 +12,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config/engine" - + "github.com/databricks/cli/bundle/config/mutator/resourcemutator" "github.com/databricks/cli/bundle/deploy/terraform" "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct" @@ -244,6 +244,14 @@ To start using direct engine, set "engine: direct" under bundle in your databric } }() + // Apply SecretScopeFixups so the config matches what the direct engine expects. + // This adds MANAGE ACL for the current user to all secret scopes, ensuring + // the migrated state and config agree on .permissions entries. + bundle.ApplyContext(ctx, b, resourcemutator.SecretScopeFixups(engine.EngineDirect)) + if logdiag.HasError(ctx) { + return root.ErrAlreadyPrinted + } + plan, err := deploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(), &b.Config, tempStatePath) if err != nil { return err From 702ac36443f459df2dbe2efc5c837da7be997e36 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 30 Mar 2026 15:24:15 +0200 Subject: [PATCH 06/22] Fix secret_scope_with_permissions test to use 'admins' group The 'account users' group doesn't exist on all test workspaces, causing cloud test failures. Co-authored-by: Isaac --- .../invariant/configs/secret_scope_with_permissions.yml.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/bundle/invariant/configs/secret_scope_with_permissions.yml.tmpl b/acceptance/bundle/invariant/configs/secret_scope_with_permissions.yml.tmpl index 6bc83ee970..daa61aaaaa 100644 --- a/acceptance/bundle/invariant/configs/secret_scope_with_permissions.yml.tmpl +++ b/acceptance/bundle/invariant/configs/secret_scope_with_permissions.yml.tmpl @@ -10,4 +10,4 @@ resources: - level: READ group_name: users - level: WRITE - group_name: account users + group_name: admins From 18e54429fab30fc96fe81e254935cc97f41582df Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 30 Mar 2026 21:40:47 +0200 Subject: [PATCH 07/22] Remove dead secret_acl code from showplanfile.go and util.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The convertSecretAclResourceNameToKey function and its dedicated databricks_secret_acl branch in parseResourcesState are no longer needed — all secret scope permissions are now handled uniformly through the post-processing loop. Co-authored-by: Isaac --- bundle/deploy/terraform/showplanfile.go | 22 --------- bundle/deploy/terraform/showplanfile_test.go | 21 -------- bundle/deploy/terraform/util.go | 51 ++++++++------------ 3 files changed, 20 insertions(+), 74 deletions(-) diff --git a/bundle/deploy/terraform/showplanfile.go b/bundle/deploy/terraform/showplanfile.go index f88ca47975..23e9436b34 100644 --- a/bundle/deploy/terraform/showplanfile.go +++ b/bundle/deploy/terraform/showplanfile.go @@ -63,28 +63,6 @@ func convertGrantsResourceNameToKey(terraformName string) string { return "" } -// convertSecretAclResourceNameToKey converts terraform secret ACL resource names back to hierarchical resource keys. -// Terraform creates N separate databricks_secret_acl resources per scope (one per principal), -// named "secret_acl__". They all map to a single permissions sub-resource. -// e.g., "secret_acl_my_scope_0" -> "resources.secret_scopes.my_scope.permissions" -func convertSecretAclResourceNameToKey(terraformName string) string { - name, found := strings.CutPrefix(terraformName, "secret_acl_") - if !found { - return "" - } - lastUnderscore := strings.LastIndex(name, "_") - if lastUnderscore <= 0 { - return "" - } - // Verify suffix is the ACL index (digits only). - for _, c := range name[lastUnderscore+1:] { - if c < '0' || c > '9' { - return "" - } - } - return "resources.secret_scopes." + name[:lastUnderscore] + ".permissions" -} - // populatePlan populates a deployplan.Plan from Terraform resource changes. func populatePlan(ctx context.Context, plan *deployplan.Plan, changes []*tfjson.ResourceChange) { for _, rc := range changes { diff --git a/bundle/deploy/terraform/showplanfile_test.go b/bundle/deploy/terraform/showplanfile_test.go index cff9a241d0..75a2cc6f43 100644 --- a/bundle/deploy/terraform/showplanfile_test.go +++ b/bundle/deploy/terraform/showplanfile_test.go @@ -76,24 +76,3 @@ func TestPopulatePlan(t *testing.T) { // Unknown resource type should not be in the plan assert.NotContains(t, plan.Plan, "resources.recreate whatever") } - -func TestConvertSecretAclResourceNameToKey(t *testing.T) { - tests := []struct { - name string - expected string - }{ - {"secret_acl_my_scope_0", "resources.secret_scopes.my_scope.permissions"}, - {"secret_acl_my_scope_1", "resources.secret_scopes.my_scope.permissions"}, - {"secret_acl_scope_with_underscores_42", "resources.secret_scopes.scope_with_underscores.permissions"}, - {"secret_acl__0", ""}, // empty scope key - {"not_a_secret_acl", ""}, // wrong prefix - {"secret_acl_", ""}, // no key or index - {"secret_acl_foo", ""}, // no index suffix - {"secret_acl_foo_abc", ""}, // non-numeric index - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, convertSecretAclResourceNameToKey(tt.name)) - }) - } -} diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index 68b6d88884..ce01ed55a8 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -79,34 +79,27 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap var resourceKey string var resourceState ResourceState - if resource.Type == "databricks_secret_acl" { - // Multiple databricks_secret_acl resources map to a single .permissions entry. - // The ID is resolved to the scope name in the post-processing step below. - resourceKey = convertSecretAclResourceNameToKey(resource.Name) + groupName, ok := TerraformToGroupName[resource.Type] + if !ok { + continue + } + + switch groupName { + case "apps", "secret_scopes", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_endpoints": + resourceKey = "resources." + groupName + "." + resource.Name + resourceState = ResourceState{ID: instance.Attributes.Name} + case "dashboards": + resourceKey = "resources." + groupName + "." + resource.Name + resourceState = ResourceState{ID: instance.Attributes.ID, ETag: instance.Attributes.ETag} + case "permissions": + resourceKey = convertPermissionsResourceNameToKey(resource.Name) + resourceState = ResourceState{ID: instance.Attributes.ID} + case "grants": + resourceKey = convertGrantsResourceNameToKey(resource.Name) + resourceState = ResourceState{ID: instance.Attributes.ID} + default: + resourceKey = "resources." + groupName + "." + resource.Name resourceState = ResourceState{ID: instance.Attributes.ID} - } else { - groupName, ok := TerraformToGroupName[resource.Type] - if !ok { - continue - } - - switch groupName { - case "apps", "secret_scopes", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_endpoints": - resourceKey = "resources." + groupName + "." + resource.Name - resourceState = ResourceState{ID: instance.Attributes.Name} - case "dashboards": - resourceKey = "resources." + groupName + "." + resource.Name - resourceState = ResourceState{ID: instance.Attributes.ID, ETag: instance.Attributes.ETag} - case "permissions": - resourceKey = convertPermissionsResourceNameToKey(resource.Name) - resourceState = ResourceState{ID: instance.Attributes.ID} - case "grants": - resourceKey = convertGrantsResourceNameToKey(resource.Name) - resourceState = ResourceState{ID: instance.Attributes.ID} - default: - resourceKey = "resources." + groupName + "." + resource.Name - resourceState = ResourceState{ID: instance.Attributes.ID} - } } if resourceKey == "" { @@ -119,15 +112,11 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap // Ensure every secret scope has a .permissions entry. The direct engine manages // permissions as a sub-resource (SecretScopeFixups adds MANAGE for the current user). - // For scopes with databricks_secret_acl in state, resolve the ACL ID to the scope name. - // For scopes without ACLs, create a .permissions entry with the scope name as ID. for key, entry := range result { if !strings.HasPrefix(key, "resources.secret_scopes.") || strings.Contains(key, ".permissions") { continue } permKey := key + ".permissions" - // Use scope name as ID (the direct engine expects it). - // This overwrites the ACL compound ID if ACLs were present. result[permKey] = ResourceState{ID: entry.ID} } From 127ca5caaf59425c6601e5a969ead2fe595853f7 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 30 Mar 2026 21:49:32 +0200 Subject: [PATCH 08/22] Apply SecretScopeFixups in upload_state_for_yaml_sync.go Add SecretScopeFixups(EngineDirect) before reading b.Config.Value() so the config includes .permissions entries for secret scopes. Without this, CalculatePlan sees .permissions in state but not in config and produces incorrect plan entries during YAML sync conversion. Co-authored-by: Isaac --- bundle/statemgmt/upload_state_for_yaml_sync.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bundle/statemgmt/upload_state_for_yaml_sync.go b/bundle/statemgmt/upload_state_for_yaml_sync.go index 7d7c766743..425cf67574 100644 --- a/bundle/statemgmt/upload_state_for_yaml_sync.go +++ b/bundle/statemgmt/upload_state_for_yaml_sync.go @@ -11,6 +11,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config" "github.com/databricks/cli/bundle/config/engine" + "github.com/databricks/cli/bundle/config/mutator/resourcemutator" "github.com/databricks/cli/bundle/deploy" "github.com/databricks/cli/bundle/deploy/terraform" "github.com/databricks/cli/bundle/deployplan" @@ -22,6 +23,7 @@ import ( "github.com/databricks/cli/libs/dyn/dynvar" "github.com/databricks/cli/libs/filer" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/logdiag" "github.com/databricks/cli/libs/structs/structaccess" "github.com/databricks/cli/libs/structs/structpath" ) @@ -135,6 +137,14 @@ func (m *uploadStateForYamlSync) convertState(ctx context.Context, b *bundle.Bun }, } + // Apply SecretScopeFixups so the config matches what the direct engine expects. + // This adds MANAGE ACL for the current user to all secret scopes, ensuring + // the migrated state and config agree on .permissions entries. + bundle.ApplyContext(ctx, b, resourcemutator.SecretScopeFixups(engine.EngineDirect)) + if logdiag.HasError(ctx) { + return diag.Errorf("failed to apply secret scope fixups") + } + // Get the dynamic value from b.Config and reverse the interpolation // b.Config has been modified by terraform.Interpolate which converts bundle-style // references (${resources.pipelines.x.id}) to terraform-style (${databricks_pipeline.x.id}) From 4708e2d0a2f8bb5384655b2ecba9f75efcbb907e Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 31 Mar 2026 14:47:30 +0200 Subject: [PATCH 09/22] Add warning log for unknown Terraform resource types in parseResourcesState Co-authored-by: Isaac --- bundle/deploy/terraform/util.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index ce01ed55a8..0a9f4e024b 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -11,6 +11,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/statemgmt/resourcestate" + "github.com/databricks/cli/libs/log" tfjson "github.com/hashicorp/terraform-json" ) @@ -81,6 +82,7 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap groupName, ok := TerraformToGroupName[resource.Type] if !ok { + log.Warnf(ctx, "Unknown Terraform resource type: %s", resource.Type) continue } From 0ba35ddf6626515b6f8de623c8e6d21e38d20db7 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 31 Mar 2026 14:59:57 +0200 Subject: [PATCH 10/22] Skip warning for silently updated resource types in parseResourcesState Check silentlyUpdatedResources before logging unknown resource type warning, matching the pattern used in showplanfile.go. This prevents misleading "Unknown Terraform resource type: databricks_secret_acl" warnings during normal secret scope migrations. Co-authored-by: Isaac --- bundle/deploy/terraform/util.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index 0a9f4e024b..761f438342 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -82,7 +82,9 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap groupName, ok := TerraformToGroupName[resource.Type] if !ok { - log.Warnf(ctx, "Unknown Terraform resource type: %s", resource.Type) + if !silentlyUpdatedResources[resource.Type] { + log.Warnf(ctx, "Unknown Terraform resource type: %s", resource.Type) + } continue } From b2aac29ac44050d953ab579995585bdfedd63bea Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 31 Mar 2026 15:23:26 +0200 Subject: [PATCH 11/22] Remove silentlyUpdatedResources map, inline the check Replace the single-entry map with a direct type check for databricks_secret_acl in both showplanfile.go and util.go. Co-authored-by: Isaac --- bundle/deploy/terraform/showplanfile.go | 9 ++------- bundle/deploy/terraform/util.go | 3 ++- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/bundle/deploy/terraform/showplanfile.go b/bundle/deploy/terraform/showplanfile.go index 23e9436b34..81ef3282cf 100644 --- a/bundle/deploy/terraform/showplanfile.go +++ b/bundle/deploy/terraform/showplanfile.go @@ -10,12 +10,6 @@ import ( tfjson "github.com/hashicorp/terraform-json" ) -// silentlyUpdatedResources contains resource types that are automatically created by DABs, -// no need to show them in the plan -var silentlyUpdatedResources = map[string]bool{ - "databricks_secret_acl": true, -} - var prefixToGroup = []struct{ prefix, group string }{ {"job_", "jobs"}, {"pipeline_", "pipelines"}, @@ -88,7 +82,8 @@ func populatePlan(ctx context.Context, plan *deployplan.Plan, changes []*tfjson. group, ok := TerraformToGroupName[rc.Type] if !ok { - if !silentlyUpdatedResources[rc.Type] { + // databricks_secret_acl is managed automatically by DABs as part of secret scope deployment. + if rc.Type != "databricks_secret_acl" { log.Warnf(ctx, "unknown resource type '%s'", rc.Type) } continue diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index 761f438342..870d2bd5b8 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -82,7 +82,8 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap groupName, ok := TerraformToGroupName[resource.Type] if !ok { - if !silentlyUpdatedResources[resource.Type] { + // databricks_secret_acl is managed automatically by DABs as part of secret scope deployment. + if resource.Type != "databricks_secret_acl" { log.Warnf(ctx, "Unknown Terraform resource type: %s", resource.Type) } continue From 84fc0b9d8d91c25425b5f1c2e4bb432a1ebaa848 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 31 Mar 2026 15:54:24 +0200 Subject: [PATCH 12/22] Include databricks_secret_acl in TerraformToGroupName Add "secret_acls" mapping for databricks_secret_acl instead of special-casing it in the unknown-type check. In parseResourcesState, secret_acls are skipped via the switch (post-processing creates .permissions entries). In populatePlan, secret ACL changes are mapped to resources.secret_scopes..permissions with GetHigherAction for merging multiple ACL changes per scope. Task: 017.md Co-authored-by: Isaac --- bundle/deploy/terraform/pkg.go | 5 ++-- bundle/deploy/terraform/showplanfile.go | 26 ++++++++++++----- bundle/deploy/terraform/showplanfile_test.go | 30 ++++++++++++++++++++ bundle/deploy/terraform/util.go | 9 +++--- 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/bundle/deploy/terraform/pkg.go b/bundle/deploy/terraform/pkg.go index 83ea796024..845fff08c3 100644 --- a/bundle/deploy/terraform/pkg.go +++ b/bundle/deploy/terraform/pkg.go @@ -131,8 +131,9 @@ var GroupToTerraformName = map[string]string{ "postgres_endpoints": "databricks_postgres_endpoint", // 3 level groups: resources.*.GROUP - "permissions": "databricks_permissions", - "grants": "databricks_grants", + "permissions": "databricks_permissions", + "grants": "databricks_grants", + "secret_acls": "databricks_secret_acl", } var TerraformToGroupName = func() map[string]string { diff --git a/bundle/deploy/terraform/showplanfile.go b/bundle/deploy/terraform/showplanfile.go index 81ef3282cf..5b9843f9d4 100644 --- a/bundle/deploy/terraform/showplanfile.go +++ b/bundle/deploy/terraform/showplanfile.go @@ -57,6 +57,17 @@ func convertGrantsResourceNameToKey(terraformName string) string { return "" } +// convertSecretAclNameToScopeKey converts terraform secret ACL resource names to scope permission keys. +// ACL names have format "secret_acl__" (see convert_secret_scope.go). +// e.g., "secret_acl_my_scope_0" -> "resources.secret_scopes.my_scope.permissions" +func convertSecretAclNameToScopeKey(name string) string { + name, _ = strings.CutPrefix(name, "secret_acl_") + if i := strings.LastIndex(name, "_"); i >= 0 { + name = name[:i] + } + return "resources.secret_scopes." + name + ".permissions" +} + // populatePlan populates a deployplan.Plan from Terraform resource changes. func populatePlan(ctx context.Context, plan *deployplan.Plan, changes []*tfjson.ResourceChange) { for _, rc := range changes { @@ -82,26 +93,27 @@ func populatePlan(ctx context.Context, plan *deployplan.Plan, changes []*tfjson. group, ok := TerraformToGroupName[rc.Type] if !ok { - // databricks_secret_acl is managed automatically by DABs as part of secret scope deployment. - if rc.Type != "databricks_secret_acl" { - log.Warnf(ctx, "unknown resource type '%s'", rc.Type) - } + log.Warnf(ctx, "unknown resource type '%s'", rc.Type) continue } var key string switch group { case "permissions": - // Convert terraform permission resource name back to hierarchical resource key key = convertPermissionsResourceNameToKey(rc.Name) case "grants": - // Convert terraform grants resource name back to hierarchical resource key key = convertGrantsResourceNameToKey(rc.Name) + case "secret_acls": + key = convertSecretAclNameToScopeKey(rc.Name) default: key = "resources." + group + "." + rc.Name } - plan.Plan[key] = &deployplan.PlanEntry{Action: actionType} + if existing, ok := plan.Plan[key]; ok { + existing.Action = deployplan.GetHigherAction(existing.Action, actionType) + } else { + plan.Plan[key] = &deployplan.PlanEntry{Action: actionType} + } } } diff --git a/bundle/deploy/terraform/showplanfile_test.go b/bundle/deploy/terraform/showplanfile_test.go index 75a2cc6f43..08d55c00f1 100644 --- a/bundle/deploy/terraform/showplanfile_test.go +++ b/bundle/deploy/terraform/showplanfile_test.go @@ -76,3 +76,33 @@ func TestPopulatePlan(t *testing.T) { // Unknown resource type should not be in the plan assert.NotContains(t, plan.Plan, "resources.recreate whatever") } + +func TestPopulatePlanSecretAcl(t *testing.T) { + ctx := t.Context() + changes := []*tfjson.ResourceChange{ + { + Type: "databricks_secret_acl", + Change: &tfjson.Change{Actions: tfjson.Actions{tfjson.ActionCreate}}, + Name: "secret_acl_my_scope_0", + }, + { + Type: "databricks_secret_acl", + Change: &tfjson.Change{Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}}, + Name: "secret_acl_my_scope_1", + }, + } + + plan := deployplan.NewPlanTerraform() + populatePlan(ctx, plan, changes) + + // Multiple ACL changes for the same scope are merged with highest severity. + assert.Equal(t, map[string]*deployplan.PlanEntry{ + "resources.secret_scopes.my_scope.permissions": {Action: deployplan.Recreate}, + }, plan.Plan) +} + +func TestConvertSecretAclNameToScopeKey(t *testing.T) { + assert.Equal(t, "resources.secret_scopes.my_scope.permissions", convertSecretAclNameToScopeKey("secret_acl_my_scope_0")) + assert.Equal(t, "resources.secret_scopes.my_scope.permissions", convertSecretAclNameToScopeKey("secret_acl_my_scope_1")) + assert.Equal(t, "resources.secret_scopes.scope_123.permissions", convertSecretAclNameToScopeKey("secret_acl_scope_123_2")) +} diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index 870d2bd5b8..36174eb729 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -82,14 +82,15 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap groupName, ok := TerraformToGroupName[resource.Type] if !ok { - // databricks_secret_acl is managed automatically by DABs as part of secret scope deployment. - if resource.Type != "databricks_secret_acl" { - log.Warnf(ctx, "Unknown Terraform resource type: %s", resource.Type) - } + log.Warnf(ctx, "Unknown Terraform resource type: %s", resource.Type) continue } switch groupName { + case "secret_acls": + // Secret ACLs are handled by the post-processing loop below that creates + // .permissions entries for all secret scopes. + continue case "apps", "secret_scopes", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_endpoints": resourceKey = "resources." + groupName + "." + resource.Name resourceState = ResourceState{ID: instance.Attributes.Name} From 449ebd62b04b8f434fa41e9ee445c27d2b2b820a Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 31 Mar 2026 15:55:44 +0200 Subject: [PATCH 13/22] Fix formatting in GroupToTerraformName Co-authored-by: Isaac --- bundle/deploy/terraform/pkg.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bundle/deploy/terraform/pkg.go b/bundle/deploy/terraform/pkg.go index 845fff08c3..a66e5cb6a0 100644 --- a/bundle/deploy/terraform/pkg.go +++ b/bundle/deploy/terraform/pkg.go @@ -131,9 +131,9 @@ var GroupToTerraformName = map[string]string{ "postgres_endpoints": "databricks_postgres_endpoint", // 3 level groups: resources.*.GROUP - "permissions": "databricks_permissions", - "grants": "databricks_grants", - "secret_acls": "databricks_secret_acl", + "permissions": "databricks_permissions", + "grants": "databricks_grants", + "secret_acls": "databricks_secret_acl", } var TerraformToGroupName = func() map[string]string { From 6883a981aa5386382591a52e7eaa325bb998b2da Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 31 Mar 2026 16:10:17 +0200 Subject: [PATCH 14/22] Update acceptance test outputs for secret ACL plan entries Secret ACL changes now appear as .permissions entries in the Terraform plan output, reflecting the new TerraformToGroupName mapping. Task: 017.md Co-authored-by: Isaac --- .../resources/secret_scopes/basic/out.plan1.terraform.json | 3 +++ .../resources/secret_scopes/basic/out.plan2.terraform.json | 3 +++ .../basic/out.plan_verify_no_drift.terraform.json | 3 +++ .../secret_scopes/delete_scope/out.plan.terraform.txt | 3 ++- .../secret_scopes/permissions/out.plan.create.terraform.txt | 3 ++- .../secret_scopes/permissions/out.plan.recreate.terraform.txt | 3 ++- .../secret_scopes/permissions/out.plan.update.terraform.txt | 4 +++- 7 files changed, 18 insertions(+), 4 deletions(-) diff --git a/acceptance/bundle/resources/secret_scopes/basic/out.plan1.terraform.json b/acceptance/bundle/resources/secret_scopes/basic/out.plan1.terraform.json index f10683b207..0266fceefc 100644 --- a/acceptance/bundle/resources/secret_scopes/basic/out.plan1.terraform.json +++ b/acceptance/bundle/resources/secret_scopes/basic/out.plan1.terraform.json @@ -3,6 +3,9 @@ "plan": { "resources.secret_scopes.my_scope": { "action": "create" + }, + "resources.secret_scopes.my_scope.permissions": { + "action": "create" } } } diff --git a/acceptance/bundle/resources/secret_scopes/basic/out.plan2.terraform.json b/acceptance/bundle/resources/secret_scopes/basic/out.plan2.terraform.json index 299892e6dd..d61ac7b77a 100644 --- a/acceptance/bundle/resources/secret_scopes/basic/out.plan2.terraform.json +++ b/acceptance/bundle/resources/secret_scopes/basic/out.plan2.terraform.json @@ -3,6 +3,9 @@ "plan": { "resources.secret_scopes.my_scope": { "action": "recreate" + }, + "resources.secret_scopes.my_scope.permissions": { + "action": "recreate" } } } diff --git a/acceptance/bundle/resources/secret_scopes/basic/out.plan_verify_no_drift.terraform.json b/acceptance/bundle/resources/secret_scopes/basic/out.plan_verify_no_drift.terraform.json index a5a4c6840f..2bf6a06f48 100644 --- a/acceptance/bundle/resources/secret_scopes/basic/out.plan_verify_no_drift.terraform.json +++ b/acceptance/bundle/resources/secret_scopes/basic/out.plan_verify_no_drift.terraform.json @@ -3,6 +3,9 @@ "plan": { "resources.secret_scopes.my_scope": { "action": "skip" + }, + "resources.secret_scopes.my_scope.permissions": { + "action": "skip" } } } diff --git a/acceptance/bundle/resources/secret_scopes/delete_scope/out.plan.terraform.txt b/acceptance/bundle/resources/secret_scopes/delete_scope/out.plan.terraform.txt index 989400461f..1307df4e1a 100644 --- a/acceptance/bundle/resources/secret_scopes/delete_scope/out.plan.terraform.txt +++ b/acceptance/bundle/resources/secret_scopes/delete_scope/out.plan.terraform.txt @@ -1,5 +1,6 @@ >>> [CLI] bundle plan delete secret_scopes.second +delete secret_scopes.second.permissions -Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged +Plan: 0 to add, 0 to change, 2 to delete, 1 unchanged diff --git a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.terraform.txt b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.terraform.txt index 9057d991b2..d76c15f89c 100644 --- a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.terraform.txt +++ b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.terraform.txt @@ -1,3 +1,4 @@ create secret_scopes.my_scope +create secret_scopes.my_scope.permissions -Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged +Plan: 2 to add, 0 to change, 0 to delete, 0 unchanged diff --git a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.recreate.terraform.txt b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.recreate.terraform.txt index a619e377e8..6126244cbe 100644 --- a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.recreate.terraform.txt +++ b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.recreate.terraform.txt @@ -1,3 +1,4 @@ recreate secret_scopes.my_scope +recreate secret_scopes.my_scope.permissions -Plan: 1 to add, 0 to change, 1 to delete, 0 unchanged +Plan: 2 to add, 0 to change, 2 to delete, 0 unchanged diff --git a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.terraform.txt b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.terraform.txt index c54c9d511c..ffe933e26b 100644 --- a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.terraform.txt +++ b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.terraform.txt @@ -1 +1,3 @@ -Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged +delete secret_scopes.my_scope.permissions + +Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged From 99e5ddc0fca9cc61a5a3f77210ceaf2866e47415 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 31 Mar 2026 16:44:31 +0200 Subject: [PATCH 15/22] Move secret scope .permissions creation into switch case Instead of a post-processing loop that adds .permissions for every secret scope, create the entry directly in the "secret_scopes" case. This keeps all resource types handled inside the switch. Task: 018.md --- bundle/deploy/terraform/util.go | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index 36174eb729..47d1be1cdf 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -7,8 +7,6 @@ import ( "fmt" "os" "path/filepath" - "strings" - "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/statemgmt/resourcestate" "github.com/databricks/cli/libs/log" @@ -88,10 +86,16 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap switch groupName { case "secret_acls": - // Secret ACLs are handled by the post-processing loop below that creates - // .permissions entries for all secret scopes. + // Secret ACLs don't have their own state entries; permissions are + // created alongside the scope in the "secret_scopes" case below. continue - case "apps", "secret_scopes", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_endpoints": + case "secret_scopes": + resourceKey = "resources." + groupName + "." + resource.Name + resourceState = ResourceState{ID: instance.Attributes.Name} + // The direct engine manages permissions as a sub-resource + // (SecretScopeFixups adds MANAGE ACL for the current user). + result[resourceKey+".permissions"] = ResourceState{ID: instance.Attributes.Name} + case "apps", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_endpoints": resourceKey = "resources." + groupName + "." + resource.Name resourceState = ResourceState{ID: instance.Attributes.Name} case "dashboards": @@ -116,16 +120,6 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap } } - // Ensure every secret scope has a .permissions entry. The direct engine manages - // permissions as a sub-resource (SecretScopeFixups adds MANAGE for the current user). - for key, entry := range result { - if !strings.HasPrefix(key, "resources.secret_scopes.") || strings.Contains(key, ".permissions") { - continue - } - permKey := key + ".permissions" - result[permKey] = ResourceState{ID: entry.ID} - } - return result, nil } From 88f3bfa6907b9e58d4c9a617b2cb73b6dd6b59a1 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 31 Mar 2026 17:16:23 +0200 Subject: [PATCH 16/22] Fix secret_acl plan action aggregation for mixed create+delete When multiple secret ACL changes for the same scope include both creates and deletes, report the net action as "update" instead of "delete". GetHigherAction picks the highest severity (delete > create), but mixed ACL changes represent a permissions update, not a deletion. Task: 019.md Co-authored-by: Isaac --- bundle/deploy/terraform/showplanfile.go | 12 +++++++++- bundle/deploy/terraform/showplanfile_test.go | 23 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/bundle/deploy/terraform/showplanfile.go b/bundle/deploy/terraform/showplanfile.go index 5b9843f9d4..c237705255 100644 --- a/bundle/deploy/terraform/showplanfile.go +++ b/bundle/deploy/terraform/showplanfile.go @@ -68,6 +68,11 @@ func convertSecretAclNameToScopeKey(name string) string { return "resources.secret_scopes." + name + ".permissions" } +// isCreateDeleteMix returns true if one action is Create and the other is Delete (in either order). +func isCreateDeleteMix(a, b deployplan.ActionType) bool { + return (a == deployplan.Create && b == deployplan.Delete) || (a == deployplan.Delete && b == deployplan.Create) +} + // populatePlan populates a deployplan.Plan from Terraform resource changes. func populatePlan(ctx context.Context, plan *deployplan.Plan, changes []*tfjson.ResourceChange) { for _, rc := range changes { @@ -110,7 +115,12 @@ func populatePlan(ctx context.Context, plan *deployplan.Plan, changes []*tfjson. } if existing, ok := plan.Plan[key]; ok { - existing.Action = deployplan.GetHigherAction(existing.Action, actionType) + // For secret ACLs, mixed create+delete means the permissions are being updated, not deleted. + if group == "secret_acls" && isCreateDeleteMix(existing.Action, actionType) { + existing.Action = deployplan.Update + } else { + existing.Action = deployplan.GetHigherAction(existing.Action, actionType) + } } else { plan.Plan[key] = &deployplan.PlanEntry{Action: actionType} } diff --git a/bundle/deploy/terraform/showplanfile_test.go b/bundle/deploy/terraform/showplanfile_test.go index 08d55c00f1..f91fd802ee 100644 --- a/bundle/deploy/terraform/showplanfile_test.go +++ b/bundle/deploy/terraform/showplanfile_test.go @@ -101,6 +101,29 @@ func TestPopulatePlanSecretAcl(t *testing.T) { }, plan.Plan) } +func TestPopulatePlanSecretAclMixedCreateDelete(t *testing.T) { + ctx := t.Context() + changes := []*tfjson.ResourceChange{ + { + Type: "databricks_secret_acl", + Change: &tfjson.Change{Actions: tfjson.Actions{tfjson.ActionDelete}}, + Name: "secret_acl_my_scope_0", + }, + { + Type: "databricks_secret_acl", + Change: &tfjson.Change{Actions: tfjson.Actions{tfjson.ActionCreate}}, + Name: "secret_acl_my_scope_1", + }, + } + + plan := deployplan.NewPlanTerraform() + populatePlan(ctx, plan, changes) + + assert.Equal(t, map[string]*deployplan.PlanEntry{ + "resources.secret_scopes.my_scope.permissions": {Action: deployplan.Update}, + }, plan.Plan) +} + func TestConvertSecretAclNameToScopeKey(t *testing.T) { assert.Equal(t, "resources.secret_scopes.my_scope.permissions", convertSecretAclNameToScopeKey("secret_acl_my_scope_0")) assert.Equal(t, "resources.secret_scopes.my_scope.permissions", convertSecretAclNameToScopeKey("secret_acl_my_scope_1")) From 60c4321052e55030cc6d8b0de1acb20ef3d623c0 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 31 Mar 2026 18:20:10 +0200 Subject: [PATCH 17/22] Fix secret ACL plan aggregation for mixed recreate+delete actions The previous isCreateDeleteMix helper only handled the narrow case of separate Create and Delete actions. In practice, Terraform produces Recreate (delete+create pair) for ACLs with changed principals, mixed with Delete for removed principals. GetHigherAction(Recreate, Delete) returned Delete (severity 7>6), incorrectly reporting permissions as deleted rather than updated. Simplify the logic: for secret ACLs, any mix of different action types means permissions are being updated. Only same-action merges (e.g., all Recreate when scope is recreated) keep the original action. Task-review: /Users/denis.bilenko/work/prompts/features/fix-secrets-migration/021.SUMMARY.md Co-authored-by: Isaac --- .../permissions/out.plan.update.terraform.txt | 4 +- bundle/deploy/terraform/showplanfile.go | 12 +++--- bundle/deploy/terraform/showplanfile_test.go | 37 ++++++++++++++++++- bundle/deploy/terraform/util.go | 1 + 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.terraform.txt b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.terraform.txt index ffe933e26b..753764000d 100644 --- a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.terraform.txt +++ b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.terraform.txt @@ -1,3 +1,3 @@ -delete secret_scopes.my_scope.permissions +update secret_scopes.my_scope.permissions -Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged +Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged diff --git a/bundle/deploy/terraform/showplanfile.go b/bundle/deploy/terraform/showplanfile.go index c237705255..c0cc7a4e0c 100644 --- a/bundle/deploy/terraform/showplanfile.go +++ b/bundle/deploy/terraform/showplanfile.go @@ -68,11 +68,6 @@ func convertSecretAclNameToScopeKey(name string) string { return "resources.secret_scopes." + name + ".permissions" } -// isCreateDeleteMix returns true if one action is Create and the other is Delete (in either order). -func isCreateDeleteMix(a, b deployplan.ActionType) bool { - return (a == deployplan.Create && b == deployplan.Delete) || (a == deployplan.Delete && b == deployplan.Create) -} - // populatePlan populates a deployplan.Plan from Terraform resource changes. func populatePlan(ctx context.Context, plan *deployplan.Plan, changes []*tfjson.ResourceChange) { for _, rc := range changes { @@ -115,8 +110,11 @@ func populatePlan(ctx context.Context, plan *deployplan.Plan, changes []*tfjson. } if existing, ok := plan.Plan[key]; ok { - // For secret ACLs, mixed create+delete means the permissions are being updated, not deleted. - if group == "secret_acls" && isCreateDeleteMix(existing.Action, actionType) { + // For secret ACLs, multiple individual ACL changes are merged into a single + // scope-level permissions entry. When the actions differ (e.g., some ACLs are + // recreated while others are deleted), it means permissions are being updated, + // not deleted entirely. + if group == "secret_acls" && existing.Action != actionType { existing.Action = deployplan.Update } else { existing.Action = deployplan.GetHigherAction(existing.Action, actionType) diff --git a/bundle/deploy/terraform/showplanfile_test.go b/bundle/deploy/terraform/showplanfile_test.go index f91fd802ee..5c4426f665 100644 --- a/bundle/deploy/terraform/showplanfile_test.go +++ b/bundle/deploy/terraform/showplanfile_test.go @@ -95,9 +95,9 @@ func TestPopulatePlanSecretAcl(t *testing.T) { plan := deployplan.NewPlanTerraform() populatePlan(ctx, plan, changes) - // Multiple ACL changes for the same scope are merged with highest severity. + // Multiple ACL changes for the same scope with different actions are merged as Update. assert.Equal(t, map[string]*deployplan.PlanEntry{ - "resources.secret_scopes.my_scope.permissions": {Action: deployplan.Recreate}, + "resources.secret_scopes.my_scope.permissions": {Action: deployplan.Update}, }, plan.Plan) } @@ -124,6 +124,39 @@ func TestPopulatePlanSecretAclMixedCreateDelete(t *testing.T) { }, plan.Plan) } +func TestPopulatePlanSecretAclMixedRecreateDelete(t *testing.T) { + ctx := t.Context() + // Simulates a permission update where some ACLs are recreated (principal changed) + // and some are deleted (principal removed). This is the typical Terraform plan shape + // when updating secret scope permissions. + changes := []*tfjson.ResourceChange{ + { + Type: "databricks_secret_acl", + Change: &tfjson.Change{Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}}, + Name: "secret_acl_my_scope_0", + }, + { + Type: "databricks_secret_acl", + Change: &tfjson.Change{Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}}, + Name: "secret_acl_my_scope_1", + }, + { + Type: "databricks_secret_acl", + Change: &tfjson.Change{Actions: tfjson.Actions{tfjson.ActionDelete}}, + Name: "secret_acl_my_scope_2", + }, + } + + plan := deployplan.NewPlanTerraform() + populatePlan(ctx, plan, changes) + + // When permissions are being updated (some ACLs recreated, some deleted), + // the aggregated action should be Update, not Delete. + assert.Equal(t, map[string]*deployplan.PlanEntry{ + "resources.secret_scopes.my_scope.permissions": {Action: deployplan.Update}, + }, plan.Plan) +} + func TestConvertSecretAclNameToScopeKey(t *testing.T) { assert.Equal(t, "resources.secret_scopes.my_scope.permissions", convertSecretAclNameToScopeKey("secret_acl_my_scope_0")) assert.Equal(t, "resources.secret_scopes.my_scope.permissions", convertSecretAclNameToScopeKey("secret_acl_my_scope_1")) diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index 47d1be1cdf..632d32bca1 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/statemgmt/resourcestate" "github.com/databricks/cli/libs/log" From b7abfb402768cf5b115a4176aed8e4490c485788 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 1 Apr 2026 08:59:40 +0200 Subject: [PATCH 18/22] update changelog --- NEXT_CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 55159800e5..2c13750d4b 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -14,6 +14,7 @@ * engine/direct: Fix unwanted recreation of secret scopes when scope_backend_type is not set ([#4834](https://github.com/databricks/cli/pull/4834)) * engine/direct: Fix bind and unbind for non-Terraform resources ([#4850](https://github.com/databricks/cli/pull/4850)) * engine/direct: Fix deploying removed principals ([#4824](https://github.com/databricks/cli/pull/4824)) +* engine/direct: Fix secret scope permissions migration from Terraform to Direct engine ([#4866](https://github.com/databricks/cli/pull/4866)) ### Dependency updates From 896b4b9f7b4e246da0bccf286dc527863d0b98c9 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 1 Apr 2026 16:20:24 +0200 Subject: [PATCH 19/22] update --- .../secret_scopes/permissions/out.plan.create.direct.txt | 4 ---- .../secret_scopes/permissions/out.plan.create.terraform.txt | 4 ---- .../secret_scopes/permissions/out.plan.update.direct.txt | 3 --- .../secret_scopes/permissions/out.plan.update.terraform.txt | 3 --- acceptance/bundle/resources/secret_scopes/permissions/script | 4 ++-- 5 files changed, 2 insertions(+), 16 deletions(-) delete mode 100644 acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.direct.txt delete mode 100644 acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.terraform.txt delete mode 100644 acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.direct.txt delete mode 100644 acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.terraform.txt diff --git a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.direct.txt b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.direct.txt deleted file mode 100644 index d76c15f89c..0000000000 --- a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.direct.txt +++ /dev/null @@ -1,4 +0,0 @@ -create secret_scopes.my_scope -create secret_scopes.my_scope.permissions - -Plan: 2 to add, 0 to change, 0 to delete, 0 unchanged diff --git a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.terraform.txt b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.terraform.txt deleted file mode 100644 index d76c15f89c..0000000000 --- a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.terraform.txt +++ /dev/null @@ -1,4 +0,0 @@ -create secret_scopes.my_scope -create secret_scopes.my_scope.permissions - -Plan: 2 to add, 0 to change, 0 to delete, 0 unchanged diff --git a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.direct.txt b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.direct.txt deleted file mode 100644 index 753764000d..0000000000 --- a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.direct.txt +++ /dev/null @@ -1,3 +0,0 @@ -update secret_scopes.my_scope.permissions - -Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged diff --git a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.terraform.txt b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.terraform.txt deleted file mode 100644 index 753764000d..0000000000 --- a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.terraform.txt +++ /dev/null @@ -1,3 +0,0 @@ -update secret_scopes.my_scope.permissions - -Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged diff --git a/acceptance/bundle/resources/secret_scopes/permissions/script b/acceptance/bundle/resources/secret_scopes/permissions/script index 7c261749a1..2b866baa75 100755 --- a/acceptance/bundle/resources/secret_scopes/permissions/script +++ b/acceptance/bundle/resources/secret_scopes/permissions/script @@ -36,7 +36,7 @@ cleanup() { trap cleanup EXIT title "create secret scope with permissions" -trace $CLI bundle plan > out.plan.create.$DATABRICKS_BUNDLE_ENGINE.txt +trace $CLI bundle plan > out.plan.create.txt trace $CLI bundle deploy scope_name=$($CLI bundle summary --output json | jq -r '.resources.secret_scopes.my_scope.name') @@ -60,7 +60,7 @@ resources: level: MANAGE EOF envsubst < databricks.yml.tmpl > databricks.yml -trace $CLI bundle plan > out.plan.update.$DATABRICKS_BUNDLE_ENGINE.txt +trace $CLI bundle plan > out.plan.update.txt trace $CLI bundle deploy trace $CLI secrets list-acls $scope_name | jq -c '.[]' | sort From f27be5636fa6918c7f29f1f67837de8ee8d18fba Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 1 Apr 2026 16:22:02 +0200 Subject: [PATCH 20/22] update --- .../delete_scope/{out.plan.direct.txt => out.plan.txt} | 0 acceptance/bundle/resources/secret_scopes/delete_scope/script | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename acceptance/bundle/resources/secret_scopes/delete_scope/{out.plan.direct.txt => out.plan.txt} (100%) diff --git a/acceptance/bundle/resources/secret_scopes/delete_scope/out.plan.direct.txt b/acceptance/bundle/resources/secret_scopes/delete_scope/out.plan.txt similarity index 100% rename from acceptance/bundle/resources/secret_scopes/delete_scope/out.plan.direct.txt rename to acceptance/bundle/resources/secret_scopes/delete_scope/out.plan.txt diff --git a/acceptance/bundle/resources/secret_scopes/delete_scope/script b/acceptance/bundle/resources/secret_scopes/delete_scope/script index b12e98775a..661150f947 100755 --- a/acceptance/bundle/resources/secret_scopes/delete_scope/script +++ b/acceptance/bundle/resources/secret_scopes/delete_scope/script @@ -11,7 +11,7 @@ trace $CLI bundle deploy grep -v DELETE < databricks.yml > databricks.yml.tmp && mv databricks.yml.tmp databricks.yml -trace $CLI bundle plan &> out.plan.$DATABRICKS_BUNDLE_ENGINE.txt +trace $CLI bundle plan &> out.plan.txt rm out.requests.txt trace $CLI bundle deploy From c8f3b0e609b0d5adef3b0cd969767af1d25505ad Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 1 Apr 2026 16:22:47 +0200 Subject: [PATCH 21/22] update --- .../resources/secret_scopes/permissions/out.plan.create.txt | 4 ++++ .../resources/secret_scopes/permissions/out.plan.update.txt | 3 +++ 2 files changed, 7 insertions(+) create mode 100644 acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.txt create mode 100644 acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.txt diff --git a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.txt b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.txt new file mode 100644 index 0000000000..d76c15f89c --- /dev/null +++ b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.create.txt @@ -0,0 +1,4 @@ +create secret_scopes.my_scope +create secret_scopes.my_scope.permissions + +Plan: 2 to add, 0 to change, 0 to delete, 0 unchanged diff --git a/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.txt b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.txt new file mode 100644 index 0000000000..753764000d --- /dev/null +++ b/acceptance/bundle/resources/secret_scopes/permissions/out.plan.update.txt @@ -0,0 +1,3 @@ +update secret_scopes.my_scope.permissions + +Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged From 758109d3a5f5cfa50066f10e8a78eff18d756449 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 2 Apr 2026 14:58:40 +0200 Subject: [PATCH 22/22] fix test on terraform --- .../delete_scope/{out.plan.txt => out.plan.direct.txt} | 0 acceptance/bundle/resources/secret_scopes/delete_scope/script | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename acceptance/bundle/resources/secret_scopes/delete_scope/{out.plan.txt => out.plan.direct.txt} (100%) diff --git a/acceptance/bundle/resources/secret_scopes/delete_scope/out.plan.txt b/acceptance/bundle/resources/secret_scopes/delete_scope/out.plan.direct.txt similarity index 100% rename from acceptance/bundle/resources/secret_scopes/delete_scope/out.plan.txt rename to acceptance/bundle/resources/secret_scopes/delete_scope/out.plan.direct.txt diff --git a/acceptance/bundle/resources/secret_scopes/delete_scope/script b/acceptance/bundle/resources/secret_scopes/delete_scope/script index 661150f947..b12e98775a 100755 --- a/acceptance/bundle/resources/secret_scopes/delete_scope/script +++ b/acceptance/bundle/resources/secret_scopes/delete_scope/script @@ -11,7 +11,7 @@ trace $CLI bundle deploy grep -v DELETE < databricks.yml > databricks.yml.tmp && mv databricks.yml.tmp databricks.yml -trace $CLI bundle plan &> out.plan.txt +trace $CLI bundle plan &> out.plan.$DATABRICKS_BUNDLE_ENGINE.txt rm out.requests.txt trace $CLI bundle deploy