From e3d801d0190807f5663b308cc5effd3df727837e Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 11 Jun 2026 11:47:57 +0200 Subject: [PATCH 01/11] direct: add named_id_fields, ignore remote-only changes on ID fields Resources fetched by a name-based ID (schemas, volumes, registered models, apps, secret scopes, postgres resources, etc.) cannot exhibit real remote drift on the fields composing that ID: a successful get-by-ID means a differing remote value can only be backend normalization (e.g. UC lowercasing), since an out-of-band rename would 404 and is already handled as resource-gone. Reacting with recreate or rename is therefore never correct for remote-only diffs on these fields. Encode this declaratively: - named_id_fields: ID components; local changes recreate, remote-only diffs are skipped. Replaces their recreate_on_changes entries. - update_id_on_local_changes (renamed from update_id_on_changes): same skip for remote-only diffs; local changes still rename via DoUpdateWithID. normalize_case is unchanged: it covers local case-only edits, which the get-by-ID argument does not, and which would otherwise recreate a resource UC considers unchanged. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + .../name-change/out.second-plan.direct.json | 2 +- .../change_table_name/out.plan.direct.json | 2 +- .../secret_scopes/basic/out.plan2.direct.txt | 2 +- bundle/direct/bundle_plan.go | 34 +++++- bundle/direct/dresources/adapter.go | 13 +- bundle/direct/dresources/all_test.go | 7 +- bundle/direct/dresources/app_test.go | 5 + bundle/direct/dresources/config.go | 29 +++-- bundle/direct/dresources/config_test.go | 5 +- bundle/direct/dresources/resources.yml | 111 ++++++++++++------ .../dresources/vector_search_index_test.go | 6 +- 12 files changed, 157 insertions(+), 60 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 0313fa50fc1..f7661bfcb2f 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -18,6 +18,7 @@ * Remove hidden, never-functional `--existing-dashboard-id`, `--existing-dashboard-path`, `--existing-alert-id`, and `--existing-genie-space-id` alias flags from `bundle generate`; use the documented `--existing-id` / `--existing-path` flags instead ([#5591](https://github.com/databricks/cli/pull/5591)). * engine/direct: Fix WAL corruption after two consecutive failed deploys ([#5606](https://github.com/databricks/cli/pull/5606)). * engine/direct: Don't open the deployment state WAL when a deploy's plan fails ([#5607](https://github.com/databricks/cli/pull/5607)). +* direct: stop reacting to remote-only changes on name fields that form the resource ID; such differences can only be backend normalization and no longer trigger recreate or rename. ### Dependency updates diff --git a/acceptance/bundle/resources/model_serving_endpoints/recreate/name-change/out.second-plan.direct.json b/acceptance/bundle/resources/model_serving_endpoints/recreate/name-change/out.second-plan.direct.json index 63445a9561d..4826a8a87bf 100644 --- a/acceptance/bundle/resources/model_serving_endpoints/recreate/name-change/out.second-plan.direct.json +++ b/acceptance/bundle/resources/model_serving_endpoints/recreate/name-change/out.second-plan.direct.json @@ -58,7 +58,7 @@ "changes": { "name": { "action": "recreate", - "reason": "immutable", + "reason": "id_field", "old": "[ORIGINAL_ENDPOINT_ID]", "new": "[NEW_ENDPOINT_ID]", "remote": "[ORIGINAL_ENDPOINT_ID]" diff --git a/acceptance/bundle/resources/quality_monitors/change_table_name/out.plan.direct.json b/acceptance/bundle/resources/quality_monitors/change_table_name/out.plan.direct.json index 8d70f68dc7e..be7a46eb1b3 100644 --- a/acceptance/bundle/resources/quality_monitors/change_table_name/out.plan.direct.json +++ b/acceptance/bundle/resources/quality_monitors/change_table_name/out.plan.direct.json @@ -31,7 +31,7 @@ "changes": { "table_name": { "action": "recreate", - "reason": "immutable", + "reason": "id_field", "old": "main.qm_test_[UNIQUE_NAME].test_table", "new": "main.qm_test_[UNIQUE_NAME].test_table_2", "remote": "main.qm_test_[UNIQUE_NAME].test_table" diff --git a/acceptance/bundle/resources/secret_scopes/basic/out.plan2.direct.txt b/acceptance/bundle/resources/secret_scopes/basic/out.plan2.direct.txt index 6eb60bdf463..49f7e9cffae 100644 --- a/acceptance/bundle/resources/secret_scopes/basic/out.plan2.direct.txt +++ b/acceptance/bundle/resources/secret_scopes/basic/out.plan2.direct.txt @@ -8,7 +8,7 @@ json.plan.resources.secret_scopes.my_scope.new_state.value.scope_backend_type = json.plan.resources.secret_scopes.my_scope.remote_state.backend_type = "DATABRICKS"; json.plan.resources.secret_scopes.my_scope.remote_state.name = "test-scope-[UNIQUE_NAME]-1"; json.plan.resources.secret_scopes.my_scope.changes.scope.action = "recreate"; -json.plan.resources.secret_scopes.my_scope.changes.scope.reason = "immutable"; +json.plan.resources.secret_scopes.my_scope.changes.scope.reason = "id_field"; json.plan.resources.secret_scopes.my_scope.changes.scope.old = "test-scope-[UNIQUE_NAME]-1"; json.plan.resources.secret_scopes.my_scope.changes.scope.new = "test-scope-[UNIQUE_NAME]-2"; json.plan.resources.secret_scopes.my_scope.changes.scope.remote = "test-scope-[UNIQUE_NAME]-1"; diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index 552d2067cc3..ba641f2104a 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -369,6 +369,12 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change } else if reason, ok := shouldSkip(generatedCfg, path, ch); ok { ch.Action = deployplan.Skip ch.Reason = reason + } else if reason, ok := shouldSkipIDField(cfg, path, ch); ok { + ch.Action = deployplan.Skip + ch.Reason = reason + } else if reason, ok := shouldSkipIDField(generatedCfg, path, ch); ok { + ch.Action = deployplan.Skip + ch.Reason = reason } else if reason, ok := shouldSkipBackendDefault(cfg, path, ch); ok { ch.Action = deployplan.Skip ch.Reason = reason @@ -440,6 +446,28 @@ func shouldSkip(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNo return "", false } +// shouldSkipIDField skips remote-only diffs on fields that compose the resource's +// name-based ID. The resource was just fetched by that ID, so a differing remote +// value can only be backend normalization (e.g. UC lowercasing) — a real +// out-of-band rename would 404 and is handled as resource-gone. Local changes +// fall through to Recreate (named_id_fields) or UpdateWithID +// (update_id_on_local_changes) in shouldUpdateOrRecreate. +func shouldSkipIDField(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNode, ch *deployplan.ChangeDesc) (string, bool) { + if cfg == nil { + return "", false + } + if !structdiff.IsEqual(ch.Old, ch.New) { + return "", false + } + if reason, ok := findMatchingRule(path, cfg.NamedIDFields); ok { + return reason, true + } + if reason, ok := findMatchingRule(path, cfg.UpdateIDOnLocalChanges); ok { + return reason, true + } + return "", false +} + // shouldSkipNormalized skips a change that is a false diff caused by UC API // normalization: the API lowercases identifier names (normalize_case) and strips // trailing slashes from storage URLs (normalize_slash). The direct engine saves @@ -470,7 +498,11 @@ func shouldUpdateOrRecreate(cfg *dresources.ResourceLifecycleConfig, path *struc if reason, ok := findMatchingRule(path, cfg.RecreateOnChanges); ok { return deployplan.Recreate, reason } - if reason, ok := findMatchingRule(path, cfg.UpdateIDOnChanges); ok { + // Local changes only: remote-only diffs on these were already skipped by shouldSkipIDField. + if reason, ok := findMatchingRule(path, cfg.NamedIDFields); ok { + return deployplan.Recreate, reason + } + if reason, ok := findMatchingRule(path, cfg.UpdateIDOnLocalChanges); ok { return deployplan.UpdateWithID, reason } return deployplan.Undefined, "" diff --git a/bundle/direct/dresources/adapter.go b/bundle/direct/dresources/adapter.go index 6891632b626..910a40644c1 100644 --- a/bundle/direct/dresources/adapter.go +++ b/bundle/direct/dresources/adapter.go @@ -346,12 +346,12 @@ func (a *Adapter) validate() error { // Validate resourceConfig consistency with DoUpdateWithID if a.overrideChangeDesc == nil { - hasUpdateWithIDTrigger := a.resourceConfig != nil && len(a.resourceConfig.UpdateIDOnChanges) > 0 + hasUpdateWithIDTrigger := a.resourceConfig != nil && len(a.resourceConfig.UpdateIDOnLocalChanges) > 0 if hasUpdateWithIDTrigger && a.doUpdateWithID == nil { - return errors.New("resourceConfig has update_id_on_changes but DoUpdateWithID is not implemented") + return errors.New("resourceConfig has update_id_on_local_changes but DoUpdateWithID is not implemented") } if a.doUpdateWithID != nil && !hasUpdateWithIDTrigger { - return errors.New("DoUpdateWithID is implemented but resourceConfig lacks update_id_on_changes") + return errors.New("DoUpdateWithID is implemented but resourceConfig lacks update_id_on_local_changes") } } @@ -384,6 +384,13 @@ func (a *Adapter) IsFieldInRecreateOnChanges(path *structpath.PathNode) bool { return true } } + // NamedIDFields also trigger recreate on local changes, so they give the same + // guarantee to callers: if the action keeps the ID, the field is unchanged. + for _, p := range a.resourceConfig.NamedIDFields { + if path.HasPatternPrefix(p.Field) { + return true + } + } return false } diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 4f8f8f5e269..00b23ff53f7 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -1003,8 +1003,11 @@ func validateResourceConfig(t *testing.T, stateType reflect.Type, cfg *ResourceL for _, p := range cfg.RecreateOnChanges { assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "RecreateOnChanges: %s", p.Field) } - for _, p := range cfg.UpdateIDOnChanges { - assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "UpdateIDOnChanges: %s", p.Field) + for _, p := range cfg.NamedIDFields { + assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "NamedIDFields: %s", p.Field) + } + for _, p := range cfg.UpdateIDOnLocalChanges { + assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "UpdateIDOnLocalChanges: %s", p.Field) } for _, p := range cfg.IgnoreRemoteChanges { assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "IgnoreRemoteChanges: %s", p.Field) diff --git a/bundle/direct/dresources/app_test.go b/bundle/direct/dresources/app_test.go index c7cbfacd1af..754a6e21f8c 100644 --- a/bundle/direct/dresources/app_test.go +++ b/bundle/direct/dresources/app_test.go @@ -147,6 +147,11 @@ func TestAppDoUpdate_UpdateMaskHasAllFields(t *testing.T) { nonUpdatableFields = append(nonUpdatableFields, field.Field.String()) } + // named_id_fields recreate on local changes, so they are not updatable either. + for _, field := range config.NamedIDFields { + nonUpdatableFields = append(nonUpdatableFields, field.Field.String()) + } + fields := reflect.TypeFor[apps.App]() var allFields []string for field := range fields.Fields() { diff --git a/bundle/direct/dresources/config.go b/bundle/direct/dresources/config.go index a425d7d7208..dc5ca113d21 100644 --- a/bundle/direct/dresources/config.go +++ b/bundle/direct/dresources/config.go @@ -56,8 +56,18 @@ type ResourceLifecycleConfig struct { // RecreateOnChanges: field patterns that trigger delete + create when changed. RecreateOnChanges []FieldRule `yaml:"recreate_on_changes,omitempty"` - // UpdateIDOnChanges: field patterns that trigger UpdateWithID when changed. - UpdateIDOnChanges []FieldRule `yaml:"update_id_on_changes,omitempty"` + // NamedIDFields: field patterns that compose the resource's name-based ID + // (DoRead fetches by a name assembled from these fields). Local changes trigger + // delete + create. Remote-only differences are skipped: a successful get-by-ID + // means the remote value can only differ by backend normalization (e.g. UC + // lowercasing) — a real out-of-band rename would 404 and is handled as + // resource-gone. + NamedIDFields []FieldRule `yaml:"named_id_fields,omitempty"` + + // UpdateIDOnLocalChanges: field patterns that trigger UpdateWithID when changed + // locally. Like NamedIDFields, these compose the name-based ID, so remote-only + // differences are skipped rather than treated as a rename. + UpdateIDOnLocalChanges []FieldRule `yaml:"update_id_on_local_changes,omitempty"` // NormalizeCase: string field patterns the UC API lowercases on write. // A change is skipped when local and remote differ only by case. @@ -84,13 +94,14 @@ var resourcesYAML []byte var resourcesGeneratedYAML []byte var empty = ResourceLifecycleConfig{ - IgnoreRemoteChanges: nil, - IgnoreLocalChanges: nil, - RecreateOnChanges: nil, - UpdateIDOnChanges: nil, - NormalizeCase: nil, - NormalizeSlash: nil, - BackendDefaults: nil, + IgnoreRemoteChanges: nil, + IgnoreLocalChanges: nil, + RecreateOnChanges: nil, + NamedIDFields: nil, + UpdateIDOnLocalChanges: nil, + NormalizeCase: nil, + NormalizeSlash: nil, + BackendDefaults: nil, } func mustParseConfig(data []byte) func() *Config { diff --git a/bundle/direct/dresources/config_test.go b/bundle/direct/dresources/config_test.go index a9a347ec747..a98b5aededd 100644 --- a/bundle/direct/dresources/config_test.go +++ b/bundle/direct/dresources/config_test.go @@ -16,7 +16,7 @@ func TestGetResourceConfig(t *testing.T) { assert.Empty(t, GetResourceConfig("nonexistent").RecreateOnChanges) } -// categoryRules projects ResourceLifecycleConfig's five categories onto a +// categoryRules projects ResourceLifecycleConfig's categories onto a // uniform [name, []FieldRule] shape so the redundancy check can iterate them. func categoryRules(c ResourceLifecycleConfig) []struct { name string @@ -33,7 +33,8 @@ func categoryRules(c ResourceLifecycleConfig) []struct { {"ignore_remote_changes", c.IgnoreRemoteChanges}, {"ignore_local_changes", c.IgnoreLocalChanges}, {"recreate_on_changes", c.RecreateOnChanges}, - {"update_id_on_changes", c.UpdateIDOnChanges}, + {"named_id_fields", c.NamedIDFields}, + {"update_id_on_local_changes", c.UpdateIDOnLocalChanges}, {"backend_defaults", backendAsFieldRules}, } } diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 9c12da9f39b..1f6cd8d3a22 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -3,7 +3,12 @@ # # Available options: # recreate_on_changes: fields that trigger delete + create -# update_id_on_changes: fields that trigger UpdateWithID (ID may change) +# named_id_fields: fields composing the name-based ID the resource is fetched by. +# Local changes trigger delete + create. Remote-only differences are skipped: +# a successful get-by-ID means a differing remote value can only be backend +# normalization; a real out-of-band rename would 404 (handled as resource-gone). +# update_id_on_local_changes: like named_id_fields, but a local change triggers +# UpdateWithID (rename; the ID changes) instead of delete + create # ignore_remote_changes: fields where remote changes are ignored # ignore_local_changes: fields where local changes are ignored (can't be updated via API) # backend_defaults: fields where the backend may set defaults (skipped when old/new are nil but remote is set) @@ -157,7 +162,7 @@ resources: - field: event_log.schema models: - recreate_on_changes: + named_id_fields: # Recreate matches current behavior of Terraform. It is possible to rename without recreate # but that would require dynamic select of the method during update since # the ml.RenameModel needs to be called instead of ml.UpdateModel. @@ -195,9 +200,10 @@ resources: # TF implementation: https://github.com/databricks/terraform-provider-databricks/blob/6c106e8e7052bb2726148d66309fd460ed444236/mlflow/resource_mlflow_experiment.go#L22 model_serving_endpoints: - recreate_on_changes: + named_id_fields: - field: name - reason: immutable + reason: id_field + recreate_on_changes: # description is immutable, can't be updated via API - field: description reason: immutable @@ -255,15 +261,16 @@ resources: reason: output_only - field: updated_by reason: output_only - recreate_on_changes: + named_id_fields: # The name can technically be updated without recreate. We recreate for now though # to match TF implementation. - field: name reason: terraform_compat - field: catalog_name - reason: immutable + reason: id_field - field: schema_name - reason: immutable + reason: id_field + recreate_on_changes: - field: storage_location reason: immutable backend_defaults: @@ -276,11 +283,12 @@ resources: - field: metastore_id quality_monitors: + named_id_fields: + - field: table_name + reason: id_field recreate_on_changes: - field: assets_dir reason: immutable - - field: table_name - reason: immutable ignore_remote_changes: - field: warehouse_id reason: input_only @@ -299,16 +307,17 @@ resources: reason: immutable - field: share_name reason: immutable - update_id_on_changes: + update_id_on_local_changes: - field: name reason: id_changes schemas: - recreate_on_changes: + named_id_fields: - field: name - reason: immutable + reason: id_field - field: catalog_name - reason: immutable + reason: id_field + recreate_on_changes: - field: storage_root reason: immutable normalize_case: @@ -329,7 +338,7 @@ resources: reason: immutable - field: file_event_queue reason: immutable - update_id_on_changes: + update_id_on_local_changes: - field: name reason: id_changes ignore_remote_changes: @@ -338,21 +347,22 @@ resources: reason: input_only volumes: - recreate_on_changes: + named_id_fields: - field: catalog_name - reason: immutable + reason: id_field - field: schema_name - reason: immutable + reason: id_field + recreate_on_changes: - field: storage_location reason: immutable - field: volume_type reason: immutable - update_id_on_changes: + update_id_on_local_changes: - field: name reason: id_changes normalize_case: - # UC lowercases identifier names. name is in update_id_on_changes, so a - # case-only diff would otherwise trigger a spurious rename (DoUpdateWithID). + # UC lowercases identifier names. name is in update_id_on_local_changes, so a + # local case-only edit would otherwise trigger a spurious rename (DoUpdateWithID). - field: catalog_name reason: uc_case - field: schema_name @@ -389,10 +399,25 @@ resources: - field: serialized_space reason: etag_based + database_instances: + named_id_fields: + - field: name + reason: id_field + + database_catalogs: + named_id_fields: + - field: name + reason: id_field + + synced_database_tables: + named_id_fields: + - field: name + reason: id_field + apps: - recreate_on_changes: + named_id_fields: - field: name - reason: immutable + reason: id_field backend_defaults: # Backend sets it "MEDIUM" when not specified in the config - field: compute_size @@ -416,9 +441,10 @@ resources: # The Secrets API defaults scope_backend_type to DATABRICKS when not specified. - field: scope_backend_type values: ["DATABRICKS"] - recreate_on_changes: + named_id_fields: - field: scope - reason: immutable + reason: id_field + recreate_on_changes: - field: scope_backend_type reason: immutable - field: backend_azure_keyvault @@ -428,7 +454,7 @@ resources: # Permissions for secret scopes use ResourceSecretScopeAcls. secret_scopes.permissions: - update_id_on_changes: + update_id_on_local_changes: # When scope name changes, we need UpdateWithID trigger. This is necessary so that subsequent # DoRead operations use the correct ID and we do not end up with a persistent drift. - field: scope_name @@ -523,10 +549,10 @@ resources: - field: enable_serverless_compute postgres_projects: - recreate_on_changes: + named_id_fields: # project_id is immutable (part of hierarchical name, not in API spec) - field: project_id - reason: immutable + reason: id_field ignore_remote_changes: # purge_on_delete is a delete-time query parameter; not returned by GET. # When the user changes it locally we still want the new value to land @@ -535,12 +561,12 @@ resources: reason: input_only postgres_branches: - recreate_on_changes: + named_id_fields: # parent and branch_id are immutable (part of hierarchical name, not in API spec) - field: parent - reason: immutable + reason: id_field - field: branch_id - reason: immutable + reason: id_field ignore_remote_changes: # replace_existing is a create-time query parameter; not returned by GET. - field: replace_existing @@ -551,12 +577,12 @@ resources: reason: "input_only; cannot be updated after create" postgres_endpoints: - recreate_on_changes: + named_id_fields: # parent and endpoint_id are immutable (part of hierarchical name, not in API spec) - field: parent - reason: immutable + reason: id_field - field: endpoint_id - reason: immutable + reason: id_field ignore_remote_changes: # replace_existing is a create-time query parameter; not returned by GET. - field: replace_existing @@ -567,10 +593,11 @@ resources: reason: "input_only; cannot be updated after create" postgres_catalogs: - recreate_on_changes: + named_id_fields: # catalog_id is part of the hierarchical name and immutable. - field: catalog_id - reason: immutable + reason: id_field + recreate_on_changes: # The Postgres SDK has no UpdateCatalog endpoint, so any local change # requires delete+create. The OpenAPI spec only marks postgres_database # as IMMUTABLE (handled by autogen); branch and create_database_if_missing @@ -588,9 +615,10 @@ resources: # drift for the same fields because the GET API does not echo back the # spec. Together they make no-op deploys idempotent while a real config # edit still triggers a recreate. Same pattern as secret_scopes. - recreate_on_changes: + named_id_fields: - field: synced_table_id - reason: immutable + reason: id_field + recreate_on_changes: - field: branch reason: immutable - field: postgres_database @@ -611,6 +639,10 @@ resources: reason: immutable vector_search_endpoints: + named_id_fields: + # The endpoint API has no rename; the endpoint is fetched by name. + - field: name + reason: id_field recreate_on_changes: - field: endpoint_type reason: immutable @@ -621,11 +653,12 @@ resources: reason: effective_vs_requested vector_search_indexes: + named_id_fields: + - field: name + reason: id_field recreate_on_changes: # The index API has no rename or update path, so every config change # has to go through delete + create. - - field: name - reason: immutable - field: endpoint_name reason: immutable - field: index_type diff --git a/bundle/direct/dresources/vector_search_index_test.go b/bundle/direct/dresources/vector_search_index_test.go index e3e2b4fcfdf..106a3c33638 100644 --- a/bundle/direct/dresources/vector_search_index_test.go +++ b/bundle/direct/dresources/vector_search_index_test.go @@ -24,6 +24,10 @@ func TestVectorSearchIndexAllSDKFieldsAreClassified(t *testing.T) { for _, field := range config.RecreateOnChanges { classified[field.Field.String()] = true } + // named_id_fields also recreate on local changes, so they are classified. + for _, field := range config.NamedIDFields { + classified[field.Field.String()] = true + } for _, field := range config.IgnoreRemoteChanges { classified[field.Field.String()] = true } @@ -38,7 +42,7 @@ func TestVectorSearchIndexAllSDKFieldsAreClassified(t *testing.T) { assert.Truef(t, classified[jsonTag], "field %q is not declared in resources.yml under vector_search_indexes; "+ "vector_search_indexes has no update API, so every SDK field must be in "+ - "recreate_on_changes or ignore_remote_changes", + "recreate_on_changes, named_id_fields or ignore_remote_changes", jsonTag, ) } From 45c5ea52538338edfe74f4b30a07fea3fda88623 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Sun, 14 Jun 2026 14:55:09 -0700 Subject: [PATCH 02/11] Keep update_id_on_changes name; document local-only semantics Revert the cosmetic update_id_on_changes -> update_id_on_local_changes rename to minimize the diff (catalogs, external_locations, volumes, secret_scopes.permissions keep their existing key). The semantics shift is explained in comments instead: update_id_on_changes now only governs local changes, since remote-only diffs on these ID fields are skipped by shouldSkipIDField. Co-authored-by: Isaac --- bundle/direct/bundle_plan.go | 6 +++--- bundle/direct/dresources/adapter.go | 6 +++--- bundle/direct/dresources/all_test.go | 4 ++-- bundle/direct/dresources/config.go | 27 ++++++++++++++----------- bundle/direct/dresources/config_test.go | 2 +- bundle/direct/dresources/resources.yml | 15 +++++++------- 6 files changed, 32 insertions(+), 28 deletions(-) diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index ba641f2104a..6341c600297 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -451,7 +451,7 @@ func shouldSkip(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNo // value can only be backend normalization (e.g. UC lowercasing) — a real // out-of-band rename would 404 and is handled as resource-gone. Local changes // fall through to Recreate (named_id_fields) or UpdateWithID -// (update_id_on_local_changes) in shouldUpdateOrRecreate. +// (update_id_on_changes) in shouldUpdateOrRecreate. func shouldSkipIDField(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNode, ch *deployplan.ChangeDesc) (string, bool) { if cfg == nil { return "", false @@ -462,7 +462,7 @@ func shouldSkipIDField(cfg *dresources.ResourceLifecycleConfig, path *structpath if reason, ok := findMatchingRule(path, cfg.NamedIDFields); ok { return reason, true } - if reason, ok := findMatchingRule(path, cfg.UpdateIDOnLocalChanges); ok { + if reason, ok := findMatchingRule(path, cfg.UpdateIDOnChanges); ok { return reason, true } return "", false @@ -502,7 +502,7 @@ func shouldUpdateOrRecreate(cfg *dresources.ResourceLifecycleConfig, path *struc if reason, ok := findMatchingRule(path, cfg.NamedIDFields); ok { return deployplan.Recreate, reason } - if reason, ok := findMatchingRule(path, cfg.UpdateIDOnLocalChanges); ok { + if reason, ok := findMatchingRule(path, cfg.UpdateIDOnChanges); ok { return deployplan.UpdateWithID, reason } return deployplan.Undefined, "" diff --git a/bundle/direct/dresources/adapter.go b/bundle/direct/dresources/adapter.go index 910a40644c1..23273cd4d92 100644 --- a/bundle/direct/dresources/adapter.go +++ b/bundle/direct/dresources/adapter.go @@ -346,12 +346,12 @@ func (a *Adapter) validate() error { // Validate resourceConfig consistency with DoUpdateWithID if a.overrideChangeDesc == nil { - hasUpdateWithIDTrigger := a.resourceConfig != nil && len(a.resourceConfig.UpdateIDOnLocalChanges) > 0 + hasUpdateWithIDTrigger := a.resourceConfig != nil && len(a.resourceConfig.UpdateIDOnChanges) > 0 if hasUpdateWithIDTrigger && a.doUpdateWithID == nil { - return errors.New("resourceConfig has update_id_on_local_changes but DoUpdateWithID is not implemented") + return errors.New("resourceConfig has update_id_on_changes but DoUpdateWithID is not implemented") } if a.doUpdateWithID != nil && !hasUpdateWithIDTrigger { - return errors.New("DoUpdateWithID is implemented but resourceConfig lacks update_id_on_local_changes") + return errors.New("DoUpdateWithID is implemented but resourceConfig lacks update_id_on_changes") } } diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 00b23ff53f7..4e2aa4c46d3 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -1006,8 +1006,8 @@ func validateResourceConfig(t *testing.T, stateType reflect.Type, cfg *ResourceL for _, p := range cfg.NamedIDFields { assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "NamedIDFields: %s", p.Field) } - for _, p := range cfg.UpdateIDOnLocalChanges { - assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "UpdateIDOnLocalChanges: %s", p.Field) + for _, p := range cfg.UpdateIDOnChanges { + assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "UpdateIDOnChanges: %s", p.Field) } for _, p := range cfg.IgnoreRemoteChanges { assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "IgnoreRemoteChanges: %s", p.Field) diff --git a/bundle/direct/dresources/config.go b/bundle/direct/dresources/config.go index dc5ca113d21..2f341aa1cec 100644 --- a/bundle/direct/dresources/config.go +++ b/bundle/direct/dresources/config.go @@ -64,10 +64,13 @@ type ResourceLifecycleConfig struct { // resource-gone. NamedIDFields []FieldRule `yaml:"named_id_fields,omitempty"` - // UpdateIDOnLocalChanges: field patterns that trigger UpdateWithID when changed - // locally. Like NamedIDFields, these compose the name-based ID, so remote-only - // differences are skipped rather than treated as a rename. - UpdateIDOnLocalChanges []FieldRule `yaml:"update_id_on_local_changes,omitempty"` + // UpdateIDOnChanges: field patterns that, when changed locally, trigger + // UpdateWithID (a rename; the ID changes). Despite the historical name this only + // governs local changes: like NamedIDFields these compose the name-based ID, so a + // remote-only difference is skipped (see shouldSkipIDField) rather than treated as + // a rename — a successful get-by-ID means the remote value can only be backend + // normalization, and a real out-of-band rename would 404. + UpdateIDOnChanges []FieldRule `yaml:"update_id_on_changes,omitempty"` // NormalizeCase: string field patterns the UC API lowercases on write. // A change is skipped when local and remote differ only by case. @@ -94,14 +97,14 @@ var resourcesYAML []byte var resourcesGeneratedYAML []byte var empty = ResourceLifecycleConfig{ - IgnoreRemoteChanges: nil, - IgnoreLocalChanges: nil, - RecreateOnChanges: nil, - NamedIDFields: nil, - UpdateIDOnLocalChanges: nil, - NormalizeCase: nil, - NormalizeSlash: nil, - BackendDefaults: nil, + IgnoreRemoteChanges: nil, + IgnoreLocalChanges: nil, + RecreateOnChanges: nil, + NamedIDFields: nil, + UpdateIDOnChanges: nil, + NormalizeCase: nil, + NormalizeSlash: nil, + BackendDefaults: nil, } func mustParseConfig(data []byte) func() *Config { diff --git a/bundle/direct/dresources/config_test.go b/bundle/direct/dresources/config_test.go index a98b5aededd..24984113f87 100644 --- a/bundle/direct/dresources/config_test.go +++ b/bundle/direct/dresources/config_test.go @@ -34,7 +34,7 @@ func categoryRules(c ResourceLifecycleConfig) []struct { {"ignore_local_changes", c.IgnoreLocalChanges}, {"recreate_on_changes", c.RecreateOnChanges}, {"named_id_fields", c.NamedIDFields}, - {"update_id_on_local_changes", c.UpdateIDOnLocalChanges}, + {"update_id_on_changes", c.UpdateIDOnChanges}, {"backend_defaults", backendAsFieldRules}, } } diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 1f6cd8d3a22..5931bf7db30 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -7,8 +7,9 @@ # Local changes trigger delete + create. Remote-only differences are skipped: # a successful get-by-ID means a differing remote value can only be backend # normalization; a real out-of-band rename would 404 (handled as resource-gone). -# update_id_on_local_changes: like named_id_fields, but a local change triggers -# UpdateWithID (rename; the ID changes) instead of delete + create +# update_id_on_changes: like named_id_fields, but a local change triggers +# UpdateWithID (rename; the ID changes) instead of delete + create. Remote-only +# differences are skipped the same way (only local changes act). # ignore_remote_changes: fields where remote changes are ignored # ignore_local_changes: fields where local changes are ignored (can't be updated via API) # backend_defaults: fields where the backend may set defaults (skipped when old/new are nil but remote is set) @@ -307,7 +308,7 @@ resources: reason: immutable - field: share_name reason: immutable - update_id_on_local_changes: + update_id_on_changes: - field: name reason: id_changes @@ -338,7 +339,7 @@ resources: reason: immutable - field: file_event_queue reason: immutable - update_id_on_local_changes: + update_id_on_changes: - field: name reason: id_changes ignore_remote_changes: @@ -357,11 +358,11 @@ resources: reason: immutable - field: volume_type reason: immutable - update_id_on_local_changes: + update_id_on_changes: - field: name reason: id_changes normalize_case: - # UC lowercases identifier names. name is in update_id_on_local_changes, so a + # UC lowercases identifier names. name is in update_id_on_changes, so a # local case-only edit would otherwise trigger a spurious rename (DoUpdateWithID). - field: catalog_name reason: uc_case @@ -454,7 +455,7 @@ resources: # Permissions for secret scopes use ResourceSecretScopeAcls. secret_scopes.permissions: - update_id_on_local_changes: + update_id_on_changes: # When scope name changes, we need UpdateWithID trigger. This is necessary so that subsequent # DoRead operations use the correct ID and we do not end up with a persistent drift. - field: scope_name From 331f8481955ed5eaf6a1658145bea664a272227f Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Sun, 14 Jun 2026 15:49:34 -0700 Subject: [PATCH 03/11] Rename named_id_fields -> provided_id_fields The fields hold a user-provided name that forms the resource ID (as opposed to a server-generated id); "provided" makes clear why a remote-only difference can only be backend normalization. Source-only rename; reason strings (id_field) are unchanged. Also regenerate volumes/uppercase-name/out.deploy.direct.txt, whose id_field reasons were reverted to the stale uc_case by the rebase auto-merge. Co-authored-by: Isaac --- .../uppercase-name/out.deploy.direct.txt | 6 +-- bundle/direct/bundle_plan.go | 6 +-- bundle/direct/dresources/adapter.go | 4 +- bundle/direct/dresources/all_test.go | 4 +- bundle/direct/dresources/app_test.go | 4 +- bundle/direct/dresources/config.go | 18 ++++----- bundle/direct/dresources/config_test.go | 2 +- bundle/direct/dresources/resources.yml | 40 +++++++++---------- .../dresources/vector_search_index_test.go | 6 +-- 9 files changed, 45 insertions(+), 45 deletions(-) diff --git a/acceptance/bundle/resources/volumes/uppercase-name/out.deploy.direct.txt b/acceptance/bundle/resources/volumes/uppercase-name/out.deploy.direct.txt index ea3d1afdc00..3f9698ea13a 100644 --- a/acceptance/bundle/resources/volumes/uppercase-name/out.deploy.direct.txt +++ b/acceptance/bundle/resources/volumes/uppercase-name/out.deploy.direct.txt @@ -32,7 +32,7 @@ Deployment complete! "changes": { "name": { "action": "skip", - "reason": "uc_case" + "reason": "id_field" } } }, @@ -41,11 +41,11 @@ Deployment complete! "changes": { "name": { "action": "skip", - "reason": "uc_case" + "reason": "id_changes" }, "schema_name": { "action": "skip", - "reason": "uc_case" + "reason": "id_field" }, "storage_location": { "action": "skip", diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index 6341c600297..1da045f41f8 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -450,7 +450,7 @@ func shouldSkip(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNo // name-based ID. The resource was just fetched by that ID, so a differing remote // value can only be backend normalization (e.g. UC lowercasing) — a real // out-of-band rename would 404 and is handled as resource-gone. Local changes -// fall through to Recreate (named_id_fields) or UpdateWithID +// fall through to Recreate (provided_id_fields) or UpdateWithID // (update_id_on_changes) in shouldUpdateOrRecreate. func shouldSkipIDField(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNode, ch *deployplan.ChangeDesc) (string, bool) { if cfg == nil { @@ -459,7 +459,7 @@ func shouldSkipIDField(cfg *dresources.ResourceLifecycleConfig, path *structpath if !structdiff.IsEqual(ch.Old, ch.New) { return "", false } - if reason, ok := findMatchingRule(path, cfg.NamedIDFields); ok { + if reason, ok := findMatchingRule(path, cfg.ProvidedIDFields); ok { return reason, true } if reason, ok := findMatchingRule(path, cfg.UpdateIDOnChanges); ok { @@ -499,7 +499,7 @@ func shouldUpdateOrRecreate(cfg *dresources.ResourceLifecycleConfig, path *struc return deployplan.Recreate, reason } // Local changes only: remote-only diffs on these were already skipped by shouldSkipIDField. - if reason, ok := findMatchingRule(path, cfg.NamedIDFields); ok { + if reason, ok := findMatchingRule(path, cfg.ProvidedIDFields); ok { return deployplan.Recreate, reason } if reason, ok := findMatchingRule(path, cfg.UpdateIDOnChanges); ok { diff --git a/bundle/direct/dresources/adapter.go b/bundle/direct/dresources/adapter.go index 23273cd4d92..49ef0feb036 100644 --- a/bundle/direct/dresources/adapter.go +++ b/bundle/direct/dresources/adapter.go @@ -384,9 +384,9 @@ func (a *Adapter) IsFieldInRecreateOnChanges(path *structpath.PathNode) bool { return true } } - // NamedIDFields also trigger recreate on local changes, so they give the same + // ProvidedIDFields also trigger recreate on local changes, so they give the same // guarantee to callers: if the action keeps the ID, the field is unchanged. - for _, p := range a.resourceConfig.NamedIDFields { + for _, p := range a.resourceConfig.ProvidedIDFields { if path.HasPatternPrefix(p.Field) { return true } diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 4e2aa4c46d3..0057444fb84 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -1003,8 +1003,8 @@ func validateResourceConfig(t *testing.T, stateType reflect.Type, cfg *ResourceL for _, p := range cfg.RecreateOnChanges { assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "RecreateOnChanges: %s", p.Field) } - for _, p := range cfg.NamedIDFields { - assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "NamedIDFields: %s", p.Field) + for _, p := range cfg.ProvidedIDFields { + assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "ProvidedIDFields: %s", p.Field) } for _, p := range cfg.UpdateIDOnChanges { assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "UpdateIDOnChanges: %s", p.Field) diff --git a/bundle/direct/dresources/app_test.go b/bundle/direct/dresources/app_test.go index 754a6e21f8c..444dfbd255e 100644 --- a/bundle/direct/dresources/app_test.go +++ b/bundle/direct/dresources/app_test.go @@ -147,8 +147,8 @@ func TestAppDoUpdate_UpdateMaskHasAllFields(t *testing.T) { nonUpdatableFields = append(nonUpdatableFields, field.Field.String()) } - // named_id_fields recreate on local changes, so they are not updatable either. - for _, field := range config.NamedIDFields { + // provided_id_fields recreate on local changes, so they are not updatable either. + for _, field := range config.ProvidedIDFields { nonUpdatableFields = append(nonUpdatableFields, field.Field.String()) } diff --git a/bundle/direct/dresources/config.go b/bundle/direct/dresources/config.go index 2f341aa1cec..9aec917d482 100644 --- a/bundle/direct/dresources/config.go +++ b/bundle/direct/dresources/config.go @@ -56,17 +56,17 @@ type ResourceLifecycleConfig struct { // RecreateOnChanges: field patterns that trigger delete + create when changed. RecreateOnChanges []FieldRule `yaml:"recreate_on_changes,omitempty"` - // NamedIDFields: field patterns that compose the resource's name-based ID - // (DoRead fetches by a name assembled from these fields). Local changes trigger - // delete + create. Remote-only differences are skipped: a successful get-by-ID - // means the remote value can only differ by backend normalization (e.g. UC - // lowercasing) — a real out-of-band rename would 404 and is handled as - // resource-gone. - NamedIDFields []FieldRule `yaml:"named_id_fields,omitempty"` + // ProvidedIDFields: field patterns that compose the resource's ID — a name the + // user provides (not a server-generated id), which DoRead fetches by. Local + // changes trigger delete + create. Remote-only differences are skipped: since the + // user supplies the value and we just fetched by it, a differing remote value can + // only be backend normalization (e.g. UC lowercasing) — a real out-of-band rename + // would 404 and is handled as resource-gone. + ProvidedIDFields []FieldRule `yaml:"provided_id_fields,omitempty"` // UpdateIDOnChanges: field patterns that, when changed locally, trigger // UpdateWithID (a rename; the ID changes). Despite the historical name this only - // governs local changes: like NamedIDFields these compose the name-based ID, so a + // governs local changes: like ProvidedIDFields these compose the name-based ID, so a // remote-only difference is skipped (see shouldSkipIDField) rather than treated as // a rename — a successful get-by-ID means the remote value can only be backend // normalization, and a real out-of-band rename would 404. @@ -100,7 +100,7 @@ var empty = ResourceLifecycleConfig{ IgnoreRemoteChanges: nil, IgnoreLocalChanges: nil, RecreateOnChanges: nil, - NamedIDFields: nil, + ProvidedIDFields: nil, UpdateIDOnChanges: nil, NormalizeCase: nil, NormalizeSlash: nil, diff --git a/bundle/direct/dresources/config_test.go b/bundle/direct/dresources/config_test.go index 24984113f87..4546fd40665 100644 --- a/bundle/direct/dresources/config_test.go +++ b/bundle/direct/dresources/config_test.go @@ -33,7 +33,7 @@ func categoryRules(c ResourceLifecycleConfig) []struct { {"ignore_remote_changes", c.IgnoreRemoteChanges}, {"ignore_local_changes", c.IgnoreLocalChanges}, {"recreate_on_changes", c.RecreateOnChanges}, - {"named_id_fields", c.NamedIDFields}, + {"provided_id_fields", c.ProvidedIDFields}, {"update_id_on_changes", c.UpdateIDOnChanges}, {"backend_defaults", backendAsFieldRules}, } diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 5931bf7db30..4b5487400be 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -3,11 +3,11 @@ # # Available options: # recreate_on_changes: fields that trigger delete + create -# named_id_fields: fields composing the name-based ID the resource is fetched by. +# provided_id_fields: fields composing the name-based ID the resource is fetched by. # Local changes trigger delete + create. Remote-only differences are skipped: # a successful get-by-ID means a differing remote value can only be backend # normalization; a real out-of-band rename would 404 (handled as resource-gone). -# update_id_on_changes: like named_id_fields, but a local change triggers +# update_id_on_changes: like provided_id_fields, but a local change triggers # UpdateWithID (rename; the ID changes) instead of delete + create. Remote-only # differences are skipped the same way (only local changes act). # ignore_remote_changes: fields where remote changes are ignored @@ -163,7 +163,7 @@ resources: - field: event_log.schema models: - named_id_fields: + provided_id_fields: # Recreate matches current behavior of Terraform. It is possible to rename without recreate # but that would require dynamic select of the method during update since # the ml.RenameModel needs to be called instead of ml.UpdateModel. @@ -201,7 +201,7 @@ resources: # TF implementation: https://github.com/databricks/terraform-provider-databricks/blob/6c106e8e7052bb2726148d66309fd460ed444236/mlflow/resource_mlflow_experiment.go#L22 model_serving_endpoints: - named_id_fields: + provided_id_fields: - field: name reason: id_field recreate_on_changes: @@ -262,7 +262,7 @@ resources: reason: output_only - field: updated_by reason: output_only - named_id_fields: + provided_id_fields: # The name can technically be updated without recreate. We recreate for now though # to match TF implementation. - field: name @@ -284,7 +284,7 @@ resources: - field: metastore_id quality_monitors: - named_id_fields: + provided_id_fields: - field: table_name reason: id_field recreate_on_changes: @@ -313,7 +313,7 @@ resources: reason: id_changes schemas: - named_id_fields: + provided_id_fields: - field: name reason: id_field - field: catalog_name @@ -348,7 +348,7 @@ resources: reason: input_only volumes: - named_id_fields: + provided_id_fields: - field: catalog_name reason: id_field - field: schema_name @@ -401,22 +401,22 @@ resources: reason: etag_based database_instances: - named_id_fields: + provided_id_fields: - field: name reason: id_field database_catalogs: - named_id_fields: + provided_id_fields: - field: name reason: id_field synced_database_tables: - named_id_fields: + provided_id_fields: - field: name reason: id_field apps: - named_id_fields: + provided_id_fields: - field: name reason: id_field backend_defaults: @@ -442,7 +442,7 @@ resources: # The Secrets API defaults scope_backend_type to DATABRICKS when not specified. - field: scope_backend_type values: ["DATABRICKS"] - named_id_fields: + provided_id_fields: - field: scope reason: id_field recreate_on_changes: @@ -550,7 +550,7 @@ resources: - field: enable_serverless_compute postgres_projects: - named_id_fields: + provided_id_fields: # project_id is immutable (part of hierarchical name, not in API spec) - field: project_id reason: id_field @@ -562,7 +562,7 @@ resources: reason: input_only postgres_branches: - named_id_fields: + provided_id_fields: # parent and branch_id are immutable (part of hierarchical name, not in API spec) - field: parent reason: id_field @@ -578,7 +578,7 @@ resources: reason: "input_only; cannot be updated after create" postgres_endpoints: - named_id_fields: + provided_id_fields: # parent and endpoint_id are immutable (part of hierarchical name, not in API spec) - field: parent reason: id_field @@ -594,7 +594,7 @@ resources: reason: "input_only; cannot be updated after create" postgres_catalogs: - named_id_fields: + provided_id_fields: # catalog_id is part of the hierarchical name and immutable. - field: catalog_id reason: id_field @@ -616,7 +616,7 @@ resources: # drift for the same fields because the GET API does not echo back the # spec. Together they make no-op deploys idempotent while a real config # edit still triggers a recreate. Same pattern as secret_scopes. - named_id_fields: + provided_id_fields: - field: synced_table_id reason: id_field recreate_on_changes: @@ -640,7 +640,7 @@ resources: reason: immutable vector_search_endpoints: - named_id_fields: + provided_id_fields: # The endpoint API has no rename; the endpoint is fetched by name. - field: name reason: id_field @@ -654,7 +654,7 @@ resources: reason: effective_vs_requested vector_search_indexes: - named_id_fields: + provided_id_fields: - field: name reason: id_field recreate_on_changes: diff --git a/bundle/direct/dresources/vector_search_index_test.go b/bundle/direct/dresources/vector_search_index_test.go index 106a3c33638..55fe35c2287 100644 --- a/bundle/direct/dresources/vector_search_index_test.go +++ b/bundle/direct/dresources/vector_search_index_test.go @@ -24,8 +24,8 @@ func TestVectorSearchIndexAllSDKFieldsAreClassified(t *testing.T) { for _, field := range config.RecreateOnChanges { classified[field.Field.String()] = true } - // named_id_fields also recreate on local changes, so they are classified. - for _, field := range config.NamedIDFields { + // provided_id_fields also recreate on local changes, so they are classified. + for _, field := range config.ProvidedIDFields { classified[field.Field.String()] = true } for _, field := range config.IgnoreRemoteChanges { @@ -42,7 +42,7 @@ func TestVectorSearchIndexAllSDKFieldsAreClassified(t *testing.T) { assert.Truef(t, classified[jsonTag], "field %q is not declared in resources.yml under vector_search_indexes; "+ "vector_search_indexes has no update API, so every SDK field must be in "+ - "recreate_on_changes, named_id_fields or ignore_remote_changes", + "recreate_on_changes, provided_id_fields or ignore_remote_changes", jsonTag, ) } From 14d1b0825870f8f8e1b27608709f6229cf77dac6 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 15 Jun 2026 08:52:44 -0700 Subject: [PATCH 04/11] Drop normalize_case; refresh model_serving_endpoints cloud golden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provided_id_fields already skips the remote-only diffs that normalize_case was added for (the no-op-redeploy bug), so normalize_case only guarded a local case-only edit — an untested corner, gated by the destructive-action prompt for schemas/volumes, and inconsistent with the other provided_id_fields resources that never had it. Remove the field, its shouldSkipNormalized branch, and the schemas/volumes yaml blocks. normalize_slash is unaffected. Also regenerate model_serving_endpoints/basic/out.second-plan.direct.json (immutable -> id_field), a cloud-only golden carrying the reason change from moving name to provided_id_fields; local task test skips it, so CI integration caught it. Co-authored-by: Isaac --- .../basic/out.second-plan.direct.json | 2 +- bundle/direct/bundle_plan.go | 11 ++++------- bundle/direct/dresources/config.go | 5 ----- bundle/direct/dresources/resources.yml | 15 --------------- 4 files changed, 5 insertions(+), 28 deletions(-) diff --git a/acceptance/bundle/resources/model_serving_endpoints/basic/out.second-plan.direct.json b/acceptance/bundle/resources/model_serving_endpoints/basic/out.second-plan.direct.json index 690eb2c000c..62806695822 100644 --- a/acceptance/bundle/resources/model_serving_endpoints/basic/out.second-plan.direct.json +++ b/acceptance/bundle/resources/model_serving_endpoints/basic/out.second-plan.direct.json @@ -36,7 +36,7 @@ }, "name": { "action": "recreate", - "reason": "immutable", + "reason": "id_field", "old": "[ENDPOINT_NAME_1]", "new": "[ENDPOINT_NAME_2]", "remote": "[ENDPOINT_NAME_1]" diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index 1da045f41f8..dc7adc7653d 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -469,10 +469,10 @@ func shouldSkipIDField(cfg *dresources.ResourceLifecycleConfig, path *structpath } // shouldSkipNormalized skips a change that is a false diff caused by UC API -// normalization: the API lowercases identifier names (normalize_case) and strips -// trailing slashes from storage URLs (normalize_slash). The direct engine saves -// local config to state, so without this the next plan sees the original value -// against the normalized remote value and triggers a spurious recreate/update. +// normalization: the API strips trailing slashes from storage URLs +// (normalize_slash). The direct engine saves local config to state, so without +// this the next plan sees the original value against the normalized remote value +// and triggers a spurious recreate/update. func shouldSkipNormalized(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNode, ch *deployplan.ChangeDesc) (string, bool) { if cfg == nil { return "", false @@ -482,9 +482,6 @@ func shouldSkipNormalized(cfg *dresources.ResourceLifecycleConfig, path *structp if !newOk || !remoteOk { return "", false } - if reason, ok := findMatchingRule(path, cfg.NormalizeCase); ok && strings.EqualFold(newStr, remoteStr) { - return reason, true - } if reason, ok := findMatchingRule(path, cfg.NormalizeSlash); ok && strings.TrimRight(newStr, "/") == strings.TrimRight(remoteStr, "/") { return reason, true } diff --git a/bundle/direct/dresources/config.go b/bundle/direct/dresources/config.go index 9aec917d482..c4f659b2e7a 100644 --- a/bundle/direct/dresources/config.go +++ b/bundle/direct/dresources/config.go @@ -72,10 +72,6 @@ type ResourceLifecycleConfig struct { // normalization, and a real out-of-band rename would 404. UpdateIDOnChanges []FieldRule `yaml:"update_id_on_changes,omitempty"` - // NormalizeCase: string field patterns the UC API lowercases on write. - // A change is skipped when local and remote differ only by case. - NormalizeCase []FieldRule `yaml:"normalize_case,omitempty"` - // NormalizeSlash: string field patterns the UC API strips trailing slashes from. // A change is skipped when local and remote differ only by trailing slashes. NormalizeSlash []FieldRule `yaml:"normalize_slash,omitempty"` @@ -102,7 +98,6 @@ var empty = ResourceLifecycleConfig{ RecreateOnChanges: nil, ProvidedIDFields: nil, UpdateIDOnChanges: nil, - NormalizeCase: nil, NormalizeSlash: nil, BackendDefaults: nil, } diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 4b5487400be..87fafd78eda 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -321,12 +321,6 @@ resources: recreate_on_changes: - field: storage_root reason: immutable - normalize_case: - # UC lowercases identifier names; remote returns "myschema" for config "MySchema". - - field: name - reason: uc_case - - field: catalog_name - reason: uc_case normalize_slash: - field: storage_root reason: uc_strips_trailing_slash @@ -361,15 +355,6 @@ resources: update_id_on_changes: - field: name reason: id_changes - normalize_case: - # UC lowercases identifier names. name is in update_id_on_changes, so a - # local case-only edit would otherwise trigger a spurious rename (DoUpdateWithID). - - field: catalog_name - reason: uc_case - - field: schema_name - reason: uc_case - - field: name - reason: uc_case normalize_slash: # UC strips trailing slashes on create; matches the Terraform provider's suppressLocationDiff. # https://github.com/databricks/terraform-provider-databricks/blob/v1.65.1/catalog/resource_volume.go#L25 From 5af74acfd77aa303fb5e63f972f053ea6a2edd3d Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 15 Jun 2026 08:58:39 -0700 Subject: [PATCH 05/11] Rename IsFieldInRecreateOnChanges -> FieldTriggersRecreate The method now also covers provided_id_fields (both categories recreate on a local change), so the recreate_on_changes-specific name was inaccurate. Co-authored-by: Isaac --- bundle/direct/bundle_plan.go | 2 +- bundle/direct/dresources/adapter.go | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index dc7adc7653d..a7c4f57ec3a 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -708,7 +708,7 @@ func (b *DeploymentBundle) LookupReferencePreDeploy(ctx context.Context, path *s return value, nil } - canReadRemoteCache := targetAction == deployplan.Skip || (targetAction.KeepsID() && adapter.IsFieldInRecreateOnChanges(fieldPath)) + canReadRemoteCache := targetAction == deployplan.Skip || (targetAction.KeepsID() && adapter.FieldTriggersRecreate(fieldPath)) if configValidErr != nil && remoteValidErr == nil { // The field is only present in remote state schema. diff --git a/bundle/direct/dresources/adapter.go b/bundle/direct/dresources/adapter.go index 49ef0feb036..d3555332a05 100644 --- a/bundle/direct/dresources/adapter.go +++ b/bundle/direct/dresources/adapter.go @@ -378,14 +378,15 @@ func (a *Adapter) GeneratedResourceConfig() *ResourceLifecycleConfig { return a.generatedResourceConfig } -func (a *Adapter) IsFieldInRecreateOnChanges(path *structpath.PathNode) bool { +// FieldTriggersRecreate reports whether a local change to the field forces a +// delete + create. Both recreate_on_changes and provided_id_fields do this, so a +// caller that knows the ID is preserved can conclude the field is unchanged. +func (a *Adapter) FieldTriggersRecreate(path *structpath.PathNode) bool { for _, p := range a.resourceConfig.RecreateOnChanges { if path.HasPatternPrefix(p.Field) { return true } } - // ProvidedIDFields also trigger recreate on local changes, so they give the same - // guarantee to callers: if the action keeps the ID, the field is unchanged. for _, p := range a.resourceConfig.ProvidedIDFields { if path.HasPatternPrefix(p.Field) { return true From 042a58e70177cdd5969e1ec67c1c048f129dd670 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 15 Jun 2026 09:28:09 -0700 Subject: [PATCH 06/11] Consolidate ID-field classification into classifyIDField The provided_id_fields / update_id_on_changes logic was split across shouldSkipIDField (remote-only -> skip) and shouldUpdateOrRecreate (local -> recreate/rename), so correctness depended on the first running before the second in the ladder. Fold both into classifyIDField, which decides skip vs recreate/UpdateWithID in one place from Old==New. The leftover shouldUpdateOrRecreate then only matched recreate_on_changes, so inline it as a direct findMatchingRule call (cfg is never nil here). Pure refactor; no behavior or golden changes. Co-authored-by: Isaac --- bundle/direct/bundle_plan.go | 69 +++++++++++++----------------- bundle/direct/dresources/config.go | 2 +- 2 files changed, 31 insertions(+), 40 deletions(-) diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index a7c4f57ec3a..f716d8e198b 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -369,11 +369,11 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change } else if reason, ok := shouldSkip(generatedCfg, path, ch); ok { ch.Action = deployplan.Skip ch.Reason = reason - } else if reason, ok := shouldSkipIDField(cfg, path, ch); ok { - ch.Action = deployplan.Skip + } else if action, reason, ok := classifyIDField(cfg, path, ch); ok { + ch.Action = action ch.Reason = reason - } else if reason, ok := shouldSkipIDField(generatedCfg, path, ch); ok { - ch.Action = deployplan.Skip + } else if action, reason, ok := classifyIDField(generatedCfg, path, ch); ok { + ch.Action = action ch.Reason = reason } else if reason, ok := shouldSkipBackendDefault(cfg, path, ch); ok { ch.Action = deployplan.Skip @@ -387,11 +387,11 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change } else if reason, ok := shouldSkipNormalized(generatedCfg, path, ch); ok { ch.Action = deployplan.Skip ch.Reason = reason - } else if action, reason := shouldUpdateOrRecreate(cfg, path); action != deployplan.Undefined { - ch.Action = action + } else if reason, ok := findMatchingRule(path, cfg.RecreateOnChanges); ok { + ch.Action = deployplan.Recreate ch.Reason = reason - } else if action, reason := shouldUpdateOrRecreate(generatedCfg, path); action != deployplan.Undefined { - ch.Action = action + } else if reason, ok := findMatchingRule(path, generatedCfg.RecreateOnChanges); ok { + ch.Action = deployplan.Recreate ch.Reason = reason } else { ch.Action = deployplan.Update @@ -446,26 +446,34 @@ func shouldSkip(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNo return "", false } -// shouldSkipIDField skips remote-only diffs on fields that compose the resource's -// name-based ID. The resource was just fetched by that ID, so a differing remote -// value can only be backend normalization (e.g. UC lowercasing) — a real -// out-of-band rename would 404 and is handled as resource-gone. Local changes -// fall through to Recreate (provided_id_fields) or UpdateWithID -// (update_id_on_changes) in shouldUpdateOrRecreate. -func shouldSkipIDField(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNode, ch *deployplan.ChangeDesc) (string, bool) { +// classifyIDField decides the action for a field that composes the resource's +// name-based ID, in one place so the remote-only-vs-local rule does not depend on +// ordering elsewhere in the ladder. Returns ok=false if the path is not such a field. +// +// - Remote-only difference (Old==New): the resource was just fetched by that ID, +// so a differing remote value can only be backend normalization (e.g. UC +// lowercasing) — a real out-of-band rename would 404 and is handled as +// resource-gone. Skip. +// - Local change: provided_id_fields recreate (delete + create); update_id_on_changes +// rename via UpdateWithID. +func classifyIDField(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNode, ch *deployplan.ChangeDesc) (deployplan.ActionType, string, bool) { if cfg == nil { - return "", false - } - if !structdiff.IsEqual(ch.Old, ch.New) { - return "", false + return deployplan.Undefined, "", false } + localChange := !structdiff.IsEqual(ch.Old, ch.New) if reason, ok := findMatchingRule(path, cfg.ProvidedIDFields); ok { - return reason, true + if localChange { + return deployplan.Recreate, reason, true + } + return deployplan.Skip, reason, true } if reason, ok := findMatchingRule(path, cfg.UpdateIDOnChanges); ok { - return reason, true + if localChange { + return deployplan.UpdateWithID, reason, true + } + return deployplan.Skip, reason, true } - return "", false + return deployplan.Undefined, "", false } // shouldSkipNormalized skips a change that is a false diff caused by UC API @@ -488,23 +496,6 @@ func shouldSkipNormalized(cfg *dresources.ResourceLifecycleConfig, path *structp return "", false } -func shouldUpdateOrRecreate(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNode) (deployplan.ActionType, string) { - if cfg == nil { - return deployplan.Undefined, "" - } - if reason, ok := findMatchingRule(path, cfg.RecreateOnChanges); ok { - return deployplan.Recreate, reason - } - // Local changes only: remote-only diffs on these were already skipped by shouldSkipIDField. - if reason, ok := findMatchingRule(path, cfg.ProvidedIDFields); ok { - return deployplan.Recreate, reason - } - if reason, ok := findMatchingRule(path, cfg.UpdateIDOnChanges); ok { - return deployplan.UpdateWithID, reason - } - return deployplan.Undefined, "" -} - // shouldSkipBackendDefault checks if a change should be skipped because the remote value // is a known backend default. Applies when old and new are nil but remote is set. // If the rule has allowed values, the remote value must match one of them. diff --git a/bundle/direct/dresources/config.go b/bundle/direct/dresources/config.go index c4f659b2e7a..8e974a249d0 100644 --- a/bundle/direct/dresources/config.go +++ b/bundle/direct/dresources/config.go @@ -67,7 +67,7 @@ type ResourceLifecycleConfig struct { // UpdateIDOnChanges: field patterns that, when changed locally, trigger // UpdateWithID (a rename; the ID changes). Despite the historical name this only // governs local changes: like ProvidedIDFields these compose the name-based ID, so a - // remote-only difference is skipped (see shouldSkipIDField) rather than treated as + // remote-only difference is skipped (see classifyIDField) rather than treated as // a rename — a successful get-by-ID means the remote value can only be backend // normalization, and a real out-of-band rename would 404. UpdateIDOnChanges []FieldRule `yaml:"update_id_on_changes,omitempty"` From 7f63af57c0d8e34cfacd8dcc36c7f5393436f239 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 15 Jun 2026 09:45:31 -0700 Subject: [PATCH 07/11] Restore UC lowercasing comment on schemas provided_id_fields Co-authored-by: Isaac --- bundle/direct/dresources/resources.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 87fafd78eda..6a32c166f08 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -314,6 +314,7 @@ resources: schemas: provided_id_fields: + # UC lowercases identifier names; remote returns "myschema" for config "MySchema". - field: name reason: id_field - field: catalog_name From 8ea6ab72ae1bc4ff339f256c8ce15a2c257fc486 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 15 Jun 2026 09:51:22 -0700 Subject: [PATCH 08/11] Enforce action categories are mutually exclusive recreate_on_changes, provided_id_fields, and update_id_on_changes each decide a field's action, and classifyIDField runs before recreate_on_changes in the ladder, so a field in more than one would have dead entries and the categories disagree on remote-only diffs. Add a test that a field appears in at most one. Co-authored-by: Isaac --- bundle/direct/dresources/config_test.go | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/bundle/direct/dresources/config_test.go b/bundle/direct/dresources/config_test.go index 4546fd40665..52281aeb12b 100644 --- a/bundle/direct/dresources/config_test.go +++ b/bundle/direct/dresources/config_test.go @@ -73,3 +73,35 @@ func TestResourcesYMLNoRedundantRules(t *testing.T) { } } } + +// TestResourcesYMLActionCategoriesExclusive guards that a field is in at most one +// of the action categories that decide a change's action. They are not +// independent: classifyIDField (provided_id_fields, update_id_on_changes) runs +// before recreate_on_changes in the ladder and short-circuits, so a field listed +// in more than one would have all but the first entry silently dead — and the +// categories disagree (e.g. provided_id_fields skips a remote-only diff that +// recreate_on_changes would recreate). +func TestResourcesYMLActionCategoriesExclusive(t *testing.T) { + cfg := MustLoadConfig() + for resourceType, rc := range cfg.Resources { + actionCats := []struct { + name string + rules []FieldRule + }{ + {"recreate_on_changes", rc.RecreateOnChanges}, + {"provided_id_fields", rc.ProvidedIDFields}, + {"update_id_on_changes", rc.UpdateIDOnChanges}, + } + firstCat := map[string]string{} + for _, c := range actionCats { + for _, r := range c.rules { + field := r.Field.String() + if prev, ok := firstCat[field]; ok { + t.Errorf("bundle/direct/dresources/resources.yml: %s lists %q in both %s and %s; a field's action belongs to exactly one category", resourceType, field, prev, c.name) + } else { + firstCat[field] = c.name + } + } + } + } +} From d309c68fc2b838b573f836597388a5451e64df2c Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 15 Jun 2026 09:55:05 -0700 Subject: [PATCH 09/11] Rename update_id_on_changes -> updatable_id_fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with provided_id_fields: both compose the resource's user-provided ID and are handled by classifyIDField (remote-only diffs skipped), differing only in the local-change action — provided_id_fields recreates, updatable_id_fields renames via UpdateWithID. The *_id_fields naming makes the family explicit and is more accurate than "update_id_on_changes", which post-refactor also governs the remote-only skip. Source-only; the per-field reason (id_changes) is unchanged so no golden churn. Co-authored-by: Isaac --- bundle/direct/bundle_plan.go | 4 ++-- bundle/direct/dresources/README.md | 2 +- bundle/direct/dresources/adapter.go | 6 +++--- bundle/direct/dresources/all_test.go | 4 ++-- bundle/direct/dresources/config.go | 15 +++++++-------- bundle/direct/dresources/config_test.go | 6 +++--- bundle/direct/dresources/resources.yml | 10 +++++----- 7 files changed, 23 insertions(+), 24 deletions(-) diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index f716d8e198b..d97185f3e9a 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -454,7 +454,7 @@ func shouldSkip(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNo // so a differing remote value can only be backend normalization (e.g. UC // lowercasing) — a real out-of-band rename would 404 and is handled as // resource-gone. Skip. -// - Local change: provided_id_fields recreate (delete + create); update_id_on_changes +// - Local change: provided_id_fields recreate (delete + create); updatable_id_fields // rename via UpdateWithID. func classifyIDField(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNode, ch *deployplan.ChangeDesc) (deployplan.ActionType, string, bool) { if cfg == nil { @@ -467,7 +467,7 @@ func classifyIDField(cfg *dresources.ResourceLifecycleConfig, path *structpath.P } return deployplan.Skip, reason, true } - if reason, ok := findMatchingRule(path, cfg.UpdateIDOnChanges); ok { + if reason, ok := findMatchingRule(path, cfg.UpdatableIDFields); ok { if localChange { return deployplan.UpdateWithID, reason, true } diff --git a/bundle/direct/dresources/README.md b/bundle/direct/dresources/README.md index a20b68c4ebd..30a2de3cd5d 100644 --- a/bundle/direct/dresources/README.md +++ b/bundle/direct/dresources/README.md @@ -24,7 +24,7 @@ Each field with special plan/deploy behavior must be declared in `resources.yml` - `managed` — managed by the cloud provider or platform, not by the user config - **`ignore_local_changes`**: Ignore changes the user makes to this field. Use for fields that cannot be updated via API — either they are immutable after creation or require a separate API that is not yet implemented. Must have a comment in resources.yml explaining why. - **`recreate_on_changes`**: Changing this field requires delete + create. Use for truly immutable fields (name, type, location). The reason should reference API docs or TF provider. - - **`update_id_on_changes`**: Changing this field changes the resource's ID. Requires `DoUpdateWithID` to be implemented. + - **`updatable_id_fields`**: Changing this field changes the resource's ID. Requires `DoUpdateWithID` to be implemented. ## Update mask diff --git a/bundle/direct/dresources/adapter.go b/bundle/direct/dresources/adapter.go index d3555332a05..e38bb611078 100644 --- a/bundle/direct/dresources/adapter.go +++ b/bundle/direct/dresources/adapter.go @@ -346,12 +346,12 @@ func (a *Adapter) validate() error { // Validate resourceConfig consistency with DoUpdateWithID if a.overrideChangeDesc == nil { - hasUpdateWithIDTrigger := a.resourceConfig != nil && len(a.resourceConfig.UpdateIDOnChanges) > 0 + hasUpdateWithIDTrigger := a.resourceConfig != nil && len(a.resourceConfig.UpdatableIDFields) > 0 if hasUpdateWithIDTrigger && a.doUpdateWithID == nil { - return errors.New("resourceConfig has update_id_on_changes but DoUpdateWithID is not implemented") + return errors.New("resourceConfig has updatable_id_fields but DoUpdateWithID is not implemented") } if a.doUpdateWithID != nil && !hasUpdateWithIDTrigger { - return errors.New("DoUpdateWithID is implemented but resourceConfig lacks update_id_on_changes") + return errors.New("DoUpdateWithID is implemented but resourceConfig lacks updatable_id_fields") } } diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 0057444fb84..68ef2434fa8 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -1006,8 +1006,8 @@ func validateResourceConfig(t *testing.T, stateType reflect.Type, cfg *ResourceL for _, p := range cfg.ProvidedIDFields { assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "ProvidedIDFields: %s", p.Field) } - for _, p := range cfg.UpdateIDOnChanges { - assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "UpdateIDOnChanges: %s", p.Field) + for _, p := range cfg.UpdatableIDFields { + assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "UpdatableIDFields: %s", p.Field) } for _, p := range cfg.IgnoreRemoteChanges { assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "IgnoreRemoteChanges: %s", p.Field) diff --git a/bundle/direct/dresources/config.go b/bundle/direct/dresources/config.go index 8e974a249d0..98bd8c85bfd 100644 --- a/bundle/direct/dresources/config.go +++ b/bundle/direct/dresources/config.go @@ -64,13 +64,12 @@ type ResourceLifecycleConfig struct { // would 404 and is handled as resource-gone. ProvidedIDFields []FieldRule `yaml:"provided_id_fields,omitempty"` - // UpdateIDOnChanges: field patterns that, when changed locally, trigger - // UpdateWithID (a rename; the ID changes). Despite the historical name this only - // governs local changes: like ProvidedIDFields these compose the name-based ID, so a - // remote-only difference is skipped (see classifyIDField) rather than treated as - // a rename — a successful get-by-ID means the remote value can only be backend - // normalization, and a real out-of-band rename would 404. - UpdateIDOnChanges []FieldRule `yaml:"update_id_on_changes,omitempty"` + // UpdatableIDFields: like ProvidedIDFields, these compose the resource's + // user-provided ID, but the backend supports renaming them in place. A local + // change triggers UpdateWithID (a rename; the ID changes) instead of delete + + // create. A remote-only difference is still skipped (see classifyIDField) — it + // can only be backend normalization, since we just fetched by this ID. + UpdatableIDFields []FieldRule `yaml:"updatable_id_fields,omitempty"` // NormalizeSlash: string field patterns the UC API strips trailing slashes from. // A change is skipped when local and remote differ only by trailing slashes. @@ -97,7 +96,7 @@ var empty = ResourceLifecycleConfig{ IgnoreLocalChanges: nil, RecreateOnChanges: nil, ProvidedIDFields: nil, - UpdateIDOnChanges: nil, + UpdatableIDFields: nil, NormalizeSlash: nil, BackendDefaults: nil, } diff --git a/bundle/direct/dresources/config_test.go b/bundle/direct/dresources/config_test.go index 52281aeb12b..5b063145e4c 100644 --- a/bundle/direct/dresources/config_test.go +++ b/bundle/direct/dresources/config_test.go @@ -34,7 +34,7 @@ func categoryRules(c ResourceLifecycleConfig) []struct { {"ignore_local_changes", c.IgnoreLocalChanges}, {"recreate_on_changes", c.RecreateOnChanges}, {"provided_id_fields", c.ProvidedIDFields}, - {"update_id_on_changes", c.UpdateIDOnChanges}, + {"updatable_id_fields", c.UpdatableIDFields}, {"backend_defaults", backendAsFieldRules}, } } @@ -76,7 +76,7 @@ func TestResourcesYMLNoRedundantRules(t *testing.T) { // TestResourcesYMLActionCategoriesExclusive guards that a field is in at most one // of the action categories that decide a change's action. They are not -// independent: classifyIDField (provided_id_fields, update_id_on_changes) runs +// independent: classifyIDField (provided_id_fields, updatable_id_fields) runs // before recreate_on_changes in the ladder and short-circuits, so a field listed // in more than one would have all but the first entry silently dead — and the // categories disagree (e.g. provided_id_fields skips a remote-only diff that @@ -90,7 +90,7 @@ func TestResourcesYMLActionCategoriesExclusive(t *testing.T) { }{ {"recreate_on_changes", rc.RecreateOnChanges}, {"provided_id_fields", rc.ProvidedIDFields}, - {"update_id_on_changes", rc.UpdateIDOnChanges}, + {"updatable_id_fields", rc.UpdatableIDFields}, } firstCat := map[string]string{} for _, c := range actionCats { diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 6a32c166f08..99e39e62e7b 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -7,7 +7,7 @@ # Local changes trigger delete + create. Remote-only differences are skipped: # a successful get-by-ID means a differing remote value can only be backend # normalization; a real out-of-band rename would 404 (handled as resource-gone). -# update_id_on_changes: like provided_id_fields, but a local change triggers +# updatable_id_fields: like provided_id_fields, but a local change triggers # UpdateWithID (rename; the ID changes) instead of delete + create. Remote-only # differences are skipped the same way (only local changes act). # ignore_remote_changes: fields where remote changes are ignored @@ -308,7 +308,7 @@ resources: reason: immutable - field: share_name reason: immutable - update_id_on_changes: + updatable_id_fields: - field: name reason: id_changes @@ -334,7 +334,7 @@ resources: reason: immutable - field: file_event_queue reason: immutable - update_id_on_changes: + updatable_id_fields: - field: name reason: id_changes ignore_remote_changes: @@ -353,7 +353,7 @@ resources: reason: immutable - field: volume_type reason: immutable - update_id_on_changes: + updatable_id_fields: - field: name reason: id_changes normalize_slash: @@ -441,7 +441,7 @@ resources: # Permissions for secret scopes use ResourceSecretScopeAcls. secret_scopes.permissions: - update_id_on_changes: + updatable_id_fields: # When scope name changes, we need UpdateWithID trigger. This is necessary so that subsequent # DoRead operations use the correct ID and we do not end up with a persistent drift. - field: scope_name From 62b7e0a5e1343cfc0cc87ca4112f7f421a3adff7 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 15 Jun 2026 11:09:31 -0700 Subject: [PATCH 10/11] Add PR link to changelog entry Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index f7661bfcb2f..ad4220ffc43 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -18,7 +18,7 @@ * Remove hidden, never-functional `--existing-dashboard-id`, `--existing-dashboard-path`, `--existing-alert-id`, and `--existing-genie-space-id` alias flags from `bundle generate`; use the documented `--existing-id` / `--existing-path` flags instead ([#5591](https://github.com/databricks/cli/pull/5591)). * engine/direct: Fix WAL corruption after two consecutive failed deploys ([#5606](https://github.com/databricks/cli/pull/5606)). * engine/direct: Don't open the deployment state WAL when a deploy's plan fails ([#5607](https://github.com/databricks/cli/pull/5607)). -* direct: stop reacting to remote-only changes on name fields that form the resource ID; such differences can only be backend normalization and no longer trigger recreate or rename. +* direct: Stop reacting to remote-only changes on the name fields that form a resource's ID (e.g. Unity Catalog lowercasing a schema or volume name); these can only be backend normalization, so they no longer trigger a spurious recreate or rename on redeploy ([#5599](https://github.com/databricks/cli/pull/5599)). ### Dependency updates From 66684c6c866de9dcd75b145c053ed6dfb781b7d3 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 15 Jun 2026 11:33:06 -0700 Subject: [PATCH 11/11] Shorten changelog entry Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index ad4220ffc43..12bbf6f7b6e 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -18,7 +18,7 @@ * Remove hidden, never-functional `--existing-dashboard-id`, `--existing-dashboard-path`, `--existing-alert-id`, and `--existing-genie-space-id` alias flags from `bundle generate`; use the documented `--existing-id` / `--existing-path` flags instead ([#5591](https://github.com/databricks/cli/pull/5591)). * engine/direct: Fix WAL corruption after two consecutive failed deploys ([#5606](https://github.com/databricks/cli/pull/5606)). * engine/direct: Don't open the deployment state WAL when a deploy's plan fails ([#5607](https://github.com/databricks/cli/pull/5607)). -* direct: Stop reacting to remote-only changes on the name fields that form a resource's ID (e.g. Unity Catalog lowercasing a schema or volume name); these can only be backend normalization, so they no longer trigger a spurious recreate or rename on redeploy ([#5599](https://github.com/databricks/cli/pull/5599)). +* direct: Stop spurious recreate/rename on redeploy when the backend normalizes a resource's name-based ID (e.g. Unity Catalog lowercasing a schema or volume name) ([#5599](https://github.com/databricks/cli/pull/5599)). ### Dependency updates