From 02b7f7ea926a422ed9d4d6b93f2ef5402f4b58fe Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 1 Sep 2026 11:35:52 +0000 Subject: [PATCH] direct: keep the state entry when an update empties a resource An update that emptied a resource dropped its state entry. The next plan then saw no state and an empty config, skipped the node, and never compared it against the remote - so `grants: []` revoked once and stopped enforcing. Add a grant out of band afterwards and it survived every later deploy. Saving the empty state keeps the node planned, so the remote is compared each time and the list stays enforced. The resource is still there: the schema keeps existing, it just grants nothing. Terraform has the same hole and is left as is, so remove_all's plan output and the new test's grants read are per-engine now. Co-authored-by: Isaac --- .../bundles/empty-list-stays-enforced.md | 2 ++ .../empty_list_enforced/databricks.yml.tmpl | 9 ++++++ .../empty_list_enforced/out.grants.direct.txt | 1 + .../out.grants.terraform.txt | 2 ++ .../schemas/empty_list_enforced/out.test.toml | 3 ++ .../schemas/empty_list_enforced/output.txt | 30 +++++++++++++++++++ .../grants/schemas/empty_list_enforced/script | 27 +++++++++++++++++ .../schemas/empty_list_enforced/update.json | 8 +++++ .../schemas/remove_all/out.plan.direct.txt | 11 +++++++ .../{out.plan.txt => out.plan.terraform.txt} | 0 .../grants/schemas/remove_all/script | 2 +- bundle/direct/apply.go | 23 ++++---------- 12 files changed, 100 insertions(+), 18 deletions(-) create mode 100644 .nextchanges/bundles/empty-list-stays-enforced.md create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list_enforced/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list_enforced/out.grants.direct.txt create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list_enforced/out.grants.terraform.txt create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list_enforced/out.test.toml create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list_enforced/output.txt create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list_enforced/script create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list_enforced/update.json create mode 100644 acceptance/bundle/resources/grants/schemas/remove_all/out.plan.direct.txt rename acceptance/bundle/resources/grants/schemas/remove_all/{out.plan.txt => out.plan.terraform.txt} (100%) diff --git a/.nextchanges/bundles/empty-list-stays-enforced.md b/.nextchanges/bundles/empty-list-stays-enforced.md new file mode 100644 index 00000000000..5ae44436caf --- /dev/null +++ b/.nextchanges/bundles/empty-list-stays-enforced.md @@ -0,0 +1,2 @@ +Emptying a resource list now keeps enforcing it. A bundle declaring `grants: []` revokes +any grant added out of band on every deploy, not just the first. diff --git a/acceptance/bundle/resources/grants/schemas/empty_list_enforced/databricks.yml.tmpl b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/databricks.yml.tmpl new file mode 100644 index 00000000000..d157f693cd7 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/databricks.yml.tmpl @@ -0,0 +1,9 @@ +bundle: + name: schema-grants-oob-after-empty-$UNIQUE_NAME + +resources: + schemas: + grants_schema: + name: schema_oob_after_empty_$UNIQUE_NAME + catalog_name: main + grants: [{ principal: deco-test-user@databricks.com, privileges: [USE_SCHEMA] }] diff --git a/acceptance/bundle/resources/grants/schemas/empty_list_enforced/out.grants.direct.txt b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/out.grants.direct.txt new file mode 100644 index 00000000000..9534b2706f9 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/out.grants.direct.txt @@ -0,0 +1 @@ +json = {}; diff --git a/acceptance/bundle/resources/grants/schemas/empty_list_enforced/out.grants.terraform.txt b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/out.grants.terraform.txt new file mode 100644 index 00000000000..6a8c10fbd58 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/out.grants.terraform.txt @@ -0,0 +1,2 @@ +json.privilege_assignments[].principal = "deco-test-user@databricks.com"; +json.privilege_assignments[].privileges[] = "USE_SCHEMA"; diff --git a/acceptance/bundle/resources/grants/schemas/empty_list_enforced/out.test.toml b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/out.test.toml new file mode 100644 index 00000000000..cef45fe553a --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/out.test.toml @@ -0,0 +1,3 @@ +Cloud = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/grants/schemas/empty_list_enforced/output.txt b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/output.txt new file mode 100644 index 00000000000..1b21b4e0c1a --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/output.txt @@ -0,0 +1,30 @@ + +=== Deploy with one grant +>>> [CLI] bundle deploy -qq + +=== Empty the list: the grant is revoked +>>> [CLI] bundle deploy -qq + +>>> [CLI] grants get schema main.schema_oob_after_empty_[UNIQUE_NAME] +json = {}; + +=== Add a grant out of band +>>> [CLI] grants update schema main.schema_oob_after_empty_[UNIQUE_NAME] --json @update.json + +>>> [CLI] grants get schema main.schema_oob_after_empty_[UNIQUE_NAME] +json.privilege_assignments[].principal = "deco-test-user@databricks.com"; +json.privilege_assignments[].privileges[] = "USE_SCHEMA"; + +=== Redeploy: the enforced empty list revokes the out-of-band grant +>>> [CLI] bundle deploy -qq + +>>> errcode [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.schemas.grants_schema + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.grants_schema + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/schema-grants-oob-after-empty-[UNIQUE_NAME]/default + +Destroy: 1 deleted diff --git a/acceptance/bundle/resources/grants/schemas/empty_list_enforced/script b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/script new file mode 100644 index 00000000000..4f202b52de8 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/script @@ -0,0 +1,27 @@ +SCHEMA_FULL_NAME=main.schema_oob_after_empty_$UNIQUE_NAME + +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace errcode $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +title "Deploy with one grant" +trace $CLI bundle deploy -qq + +title "Empty the list: the grant is revoked" +update_file.py databricks.yml 'grants: [{ principal: deco-test-user@databricks.com, privileges: [USE_SCHEMA] }]' 'grants: []' +trace $CLI bundle deploy -qq +trace $CLI grants get schema "$SCHEMA_FULL_NAME" | gron.py --noindex | sort_lines.py --repl + +title "Add a grant out of band" +trace $CLI grants update schema "$SCHEMA_FULL_NAME" --json @update.json > /dev/null +trace $CLI grants get schema "$SCHEMA_FULL_NAME" | gron.py --noindex | sort_lines.py --repl | contains.py 'deco-test-user@databricks.com' + +# The engines diverge here, so the result goes to a per-engine file: direct re-plans the +# emptied node and revokes the out-of-band grant, terraform leaves it in place. +title "Redeploy: the enforced empty list revokes the out-of-band grant" +trace $CLI bundle deploy -qq +$CLI grants get schema "$SCHEMA_FULL_NAME" | gron.py --noindex | sort_lines.py --repl > out.grants.$DATABRICKS_BUNDLE_ENGINE.txt diff --git a/acceptance/bundle/resources/grants/schemas/empty_list_enforced/update.json b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/update.json new file mode 100644 index 00000000000..36945686643 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list_enforced/update.json @@ -0,0 +1,8 @@ +{ + "changes": [ + { + "principal": "deco-test-user@databricks.com", + "add": ["USE_SCHEMA"] + } + ] +} diff --git a/acceptance/bundle/resources/grants/schemas/remove_all/out.plan.direct.txt b/acceptance/bundle/resources/grants/schemas/remove_all/out.plan.direct.txt new file mode 100644 index 00000000000..7860621b33f --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/remove_all/out.plan.direct.txt @@ -0,0 +1,11 @@ + +>>> [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/schema-grants-remove-all-[UNIQUE_NAME]/default/files... +Files: 3 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 2 unchanged + +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged diff --git a/acceptance/bundle/resources/grants/schemas/remove_all/out.plan.txt b/acceptance/bundle/resources/grants/schemas/remove_all/out.plan.terraform.txt similarity index 100% rename from acceptance/bundle/resources/grants/schemas/remove_all/out.plan.txt rename to acceptance/bundle/resources/grants/schemas/remove_all/out.plan.terraform.txt diff --git a/acceptance/bundle/resources/grants/schemas/remove_all/script b/acceptance/bundle/resources/grants/schemas/remove_all/script index 6a16dafff30..12503be67c0 100644 --- a/acceptance/bundle/resources/grants/schemas/remove_all/script +++ b/acceptance/bundle/resources/grants/schemas/remove_all/script @@ -26,4 +26,4 @@ trace $CLI grants get schema main.schema_remove_all_$UNIQUE_NAME | gron.py --noi trace $CLI bundle plan trace $CLI bundle deploy trace $CLI bundle plan -} &> out.plan.txt +} &> out.plan.$DATABRICKS_BUNDLE_ENGINE.txt diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index 6b2fffe9a16..053f8f54951 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -159,24 +159,13 @@ func (d *DeploymentUnit) Update(ctx context.Context, db *dstate.DeploymentState, return err } - empty, err := d.Adapter.IsEmptyState(newState) + // An update that empties the resource out (e.g. all grants revoked) saves the empty state + // rather than dropping the entry. The resource is still there - the schema keeps existing, + // it just grants nothing - so the node stays tracked, and a caller reading the state sees + // what the update applied instead of nothing at all. + err = d.saveState(db, id, newState, d.DependsOn) if err != nil { - return err - } - - if empty { - // The update emptied the resource out (e.g. all grants revoked). Keeping an entry - // would report the node as tracked-and-unchanged forever, while a fresh deploy of - // the same config plans no node at all; drop it so the two agree. - err = db.DeleteState(d.ResourceKey) - if err != nil { - return fmt.Errorf("deleting state id=%s: %w", id, err) - } - } else { - err = d.saveState(db, id, newState, d.DependsOn) - if err != nil { - return fmt.Errorf("saving state id=%s: %w", id, err) - } + return fmt.Errorf("saving state id=%s: %w", id, err) } waitRemoteState, err := retryOnTransient(ctx, func() (any, error) {