diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 858549dca40..a810689f8de 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -17,6 +17,7 @@ * direct: Fix deploy bug when a `postgres_projects`, `postgres_branches`, or `postgres_endpoints` field is set to its zero value (e.g. `enable_pg_native_login: false`, `replace_existing: false`) ([#5782](https://github.com/databricks/cli/pull/5782)). * `bundle run --only` help now documents the `+` modifier syntax: prefix a task key with `+` to also run its upstream tasks, or suffix it with `+` for downstream tasks ([#5760](https://github.com/databricks/cli/pull/5760)). * direct: Recognize UC-managed catalog and schema property defaults to avoid unnecessary drift ([#5865](https://github.com/databricks/cli/pull/5865) & [#5870](https://github.com/databricks/cli/pull/5870)). +* Fix `bundle deploy --select ` skipping the resource's grants and permissions; they are now applied as part of the selected resource ([#5852](https://github.com/databricks/cli/pull/5852)). ### Dependency updates diff --git a/acceptance/bundle/select/grants_permissions/databricks.yml.tmpl b/acceptance/bundle/select/grants_permissions/databricks.yml.tmpl new file mode 100644 index 00000000000..c31a0df1c6e --- /dev/null +++ b/acceptance/bundle/select/grants_permissions/databricks.yml.tmpl @@ -0,0 +1,19 @@ +bundle: + name: select-subnodes-$UNIQUE_NAME + +resources: + jobs: + my_job: + name: my_job-$UNIQUE_NAME + permissions: + - level: CAN_VIEW + user_name: viewer@example.com + + schemas: + my_schema: + catalog_name: main + name: my_schema_$UNIQUE_NAME + grants: + - principal: deco-test-user@databricks.com + privileges: + - ALL_PRIVILEGES diff --git a/acceptance/bundle/select/grants_permissions/out.test.toml b/acceptance/bundle/select/grants_permissions/out.test.toml new file mode 100644 index 00000000000..55ed5ee6619 --- /dev/null +++ b/acceptance/bundle/select/grants_permissions/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false +RequiresUnityCatalog = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.READPLAN = ["", "1"] diff --git a/acceptance/bundle/select/grants_permissions/output.txt b/acceptance/bundle/select/grants_permissions/output.txt new file mode 100644 index 00000000000..0575ed2a26c --- /dev/null +++ b/acceptance/bundle/select/grants_permissions/output.txt @@ -0,0 +1,78 @@ + +>>> [CLI] bundle plan --select jobs.my_job +create jobs.my_job +create jobs.my_job.permissions + +Plan: 2 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle plan --select schemas.my_schema +create schemas.my_schema +create schemas.my_schema.grants + +Plan: 2 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle plan --select jobs.my_job.permissions +Error: no such resource: jobs.my_job.permissions + + +>>> [CLI] bundle plan --select schemas.my_schema.grants +Error: no such resource: schemas.my_schema.grants + + +=== bundle deploy --select jobs.my_job +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/select-subnodes-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== bundle deploy --select schemas.my_schema +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/select-subnodes-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --sort //permissions +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.my_schema_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "add": [ + "ALL_PRIVILEGES" + ], + "principal": "deco-test-user@databricks.com" + } + ] + } +} +{ + "method": "PUT", + "path": "/api/2.0/permissions/jobs/[NUMID]", + "body": { + "access_control_list": [ + { + "permission_level": "CAN_VIEW", + "user_name": "viewer@example.com" + }, + { + "permission_level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + } +} + +=== Destroy +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.jobs.my_job + delete resources.schemas.my_schema + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.my_schema + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/select-subnodes-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/select/grants_permissions/script b/acceptance/bundle/select/grants_permissions/script new file mode 100644 index 00000000000..1aab6a33fa4 --- /dev/null +++ b/acceptance/bundle/select/grants_permissions/script @@ -0,0 +1,30 @@ +envsubst '$UNIQUE_NAME' < databricks.yml.tmpl > databricks.yml + +cleanup() { + title "Destroy" + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +# Selecting a resource pulls in its grants/permissions sub-nodes even though the +# dependency edge runs sub-node -> parent, so the plan would never reach them from +# the parent otherwise (issue #5794). +trace $CLI bundle plan --select jobs.my_job +trace $CLI bundle plan --select schemas.my_schema + +# Grants and permissions sub-nodes are not addressable via --select. +musterr trace $CLI bundle plan --select jobs.my_job.permissions +musterr trace $CLI bundle plan --select schemas.my_schema.grants + +# Deploying a selected resource applies its permissions/grants too. +$CLI bundle plan --select jobs.my_job -o json > plan-job.json +title "bundle deploy --select jobs.my_job\n" +$CLI bundle deploy --select jobs.my_job $(readplanarg plan-job.json) + +$CLI bundle plan --select schemas.my_schema -o json > plan-schema.json +title "bundle deploy --select schemas.my_schema\n" +$CLI bundle deploy --select schemas.my_schema $(readplanarg plan-schema.json) + +# Assert the permissions PUT and grants PATCH were emitted for the selected resources. +trace print_requests.py --sort //permissions diff --git a/acceptance/bundle/select/grants_permissions/test.toml b/acceptance/bundle/select/grants_permissions/test.toml new file mode 100644 index 00000000000..9f8b5c0ddc9 --- /dev/null +++ b/acceptance/bundle/select/grants_permissions/test.toml @@ -0,0 +1,15 @@ +# Local-only: exercises --select (issue #5794) end to end against the testserver. +# It deploys a schema, so it needs a UC metastore; --select is direct-engine only. +# The bundle name deliberately avoids the "permissions" token used in the +# print_requests.py filter: on Windows, Git Bash mangles the leading "//" of +# "//permissions" so the filter degrades to the bare substring "permissions", which +# would otherwise also match the bundle's own file-upload paths. (We can't set +# MSYS_NO_PATHCONV to keep the "//" intact, because that breaks the python helper's +# own path resolution -- see the env -u workaround in acceptance/script.prepare.) +Local = true +Cloud = false +RecordRequests = true +RequiresUnityCatalog = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.READPLAN = ["", "1"] +Ignore = [".databricks", ".gitignore", "databricks.yml", "plan-job.json", "plan-schema.json"] diff --git a/bundle/deployplan/plan.go b/bundle/deployplan/plan.go index bc5ea03f6c7..bb1b0d77bff 100644 --- a/bundle/deployplan/plan.go +++ b/bundle/deployplan/plan.go @@ -216,10 +216,14 @@ func (p *Plan) FilterToSelected(selected []string) { reachable := make(map[string]struct{}, len(selected)) for _, s := range selected { key := "resources." + s - if _, ok := p.Plan[key]; ok { - reachable[key] = struct{}{} - queue = append(queue, key) - } + p.enqueueReachable(reachable, &queue, key) + // Grants and permissions are modeled as separate plan nodes for internal + // reasons, but the user cannot address them via --select. Pull them in as + // part of the parent resource so selecting a resource applies its grants + // and permissions too. The dependency edge runs sub-node → parent, so the + // BFS below would never reach them from the parent otherwise. + p.enqueueReachable(reachable, &queue, key+".grants") + p.enqueueReachable(reachable, &queue, key+".permissions") } // BFS following DependsOn edges to include transitive dependencies. @@ -227,12 +231,7 @@ func (p *Plan) FilterToSelected(selected []string) { key := queue[0] queue = queue[1:] for _, dep := range p.Plan[key].DependsOn { - if _, seen := reachable[dep.Node]; !seen { - if _, ok := p.Plan[dep.Node]; ok { - reachable[dep.Node] = struct{}{} - queue = append(queue, dep.Node) - } - } + p.enqueueReachable(reachable, &queue, dep.Node) } } @@ -243,6 +242,18 @@ func (p *Plan) FilterToSelected(selected []string) { } } +// enqueueReachable marks key as reachable and appends it to queue, if key exists +// in the plan and has not been seen before. Missing or already-seen keys are ignored. +func (p *Plan) enqueueReachable(reachable map[string]struct{}, queue *[]string, key string) { + if _, seen := reachable[key]; seen { + return + } + if _, ok := p.Plan[key]; ok { + reachable[key] = struct{}{} + *queue = append(*queue, key) + } +} + type lockmap struct { state map[string]int } diff --git a/bundle/deployplan/plan_filter_test.go b/bundle/deployplan/plan_filter_test.go index b16d78eb43d..18767e826d8 100644 --- a/bundle/deployplan/plan_filter_test.go +++ b/bundle/deployplan/plan_filter_test.go @@ -20,6 +20,23 @@ func planWithDeps() *deployplan.Plan { return p } +func planWithGrants() *deployplan.Plan { + p := deployplan.NewPlanDirect() + p.Plan["resources.schemas.bronze"] = &deployplan.PlanEntry{} + // Sub-nodes depend on the parent (to resolve full_name), not the other way around. + p.Plan["resources.schemas.bronze.grants"] = &deployplan.PlanEntry{ + DependsOn: []deployplan.DependsOnEntry{{Node: "resources.schemas.bronze"}}, + } + p.Plan["resources.schemas.bronze.permissions"] = &deployplan.PlanEntry{ + DependsOn: []deployplan.DependsOnEntry{{Node: "resources.schemas.bronze"}}, + } + p.Plan["resources.schemas.silver"] = &deployplan.PlanEntry{} + p.Plan["resources.schemas.silver.grants"] = &deployplan.PlanEntry{ + DependsOn: []deployplan.DependsOnEntry{{Node: "resources.schemas.silver"}}, + } + return p +} + func TestFilterToSelected_Direct(t *testing.T) { p := planWithDeps() p.FilterToSelected([]string{"jobs.foo"}) @@ -46,3 +63,23 @@ func TestFilterToSelected_Multiple(t *testing.T) { assert.NotContains(t, p.Plan, "resources.jobs.foo") assert.NotContains(t, p.Plan, "resources.jobs.bar") } + +func TestFilterToSelected_IncludesGrantsAndPermissions(t *testing.T) { + p := planWithGrants() + p.FilterToSelected([]string{"schemas.bronze"}) + assert.Contains(t, p.Plan, "resources.schemas.bronze") + assert.Contains(t, p.Plan, "resources.schemas.bronze.grants") + assert.Contains(t, p.Plan, "resources.schemas.bronze.permissions") + assert.NotContains(t, p.Plan, "resources.schemas.silver") + assert.NotContains(t, p.Plan, "resources.schemas.silver.grants") +} + +func TestFilterToSelected_MissingSubNodesAreSkipped(t *testing.T) { + p := planWithGrants() + // silver has grants but no permissions node; selecting it must not fail. + p.FilterToSelected([]string{"schemas.silver"}) + assert.Contains(t, p.Plan, "resources.schemas.silver") + assert.Contains(t, p.Plan, "resources.schemas.silver.grants") + assert.NotContains(t, p.Plan, "resources.schemas.silver.permissions") + assert.NotContains(t, p.Plan, "resources.schemas.bronze") +}