diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 0313fa50fc1..12bbf6f7b6e 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 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 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/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/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 552d2067cc3..d97185f3e9a 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 action, reason, ok := classifyIDField(cfg, path, ch); ok { + ch.Action = action + ch.Reason = reason + } 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 ch.Reason = reason @@ -381,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 @@ -440,11 +446,41 @@ func shouldSkip(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNo return "", false } +// 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); updatable_id_fields +// rename via UpdateWithID. +func classifyIDField(cfg *dresources.ResourceLifecycleConfig, path *structpath.PathNode, ch *deployplan.ChangeDesc) (deployplan.ActionType, string, bool) { + if cfg == nil { + return deployplan.Undefined, "", false + } + localChange := !structdiff.IsEqual(ch.Old, ch.New) + if reason, ok := findMatchingRule(path, cfg.ProvidedIDFields); ok { + if localChange { + return deployplan.Recreate, reason, true + } + return deployplan.Skip, reason, true + } + if reason, ok := findMatchingRule(path, cfg.UpdatableIDFields); ok { + if localChange { + return deployplan.UpdateWithID, reason, true + } + return deployplan.Skip, reason, true + } + return deployplan.Undefined, "", 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 -// 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 @@ -454,28 +490,12 @@ 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 } 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 - } - 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. @@ -679,7 +699,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/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 6891632b626..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") } } @@ -378,12 +378,20 @@ 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 } } + for _, p := range a.resourceConfig.ProvidedIDFields { + 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..68ef2434fa8 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.ProvidedIDFields { + assert.NoError(t, structaccess.ValidatePattern(stateType, p.Field), "ProvidedIDFields: %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/app_test.go b/bundle/direct/dresources/app_test.go index c7cbfacd1af..444dfbd255e 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()) } + // provided_id_fields recreate on local changes, so they are not updatable either. + for _, field := range config.ProvidedIDFields { + 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..98bd8c85bfd 100644 --- a/bundle/direct/dresources/config.go +++ b/bundle/direct/dresources/config.go @@ -56,12 +56,20 @@ 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"` - - // 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"` + // 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"` + + // 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. @@ -87,8 +95,8 @@ var empty = ResourceLifecycleConfig{ IgnoreRemoteChanges: nil, IgnoreLocalChanges: nil, RecreateOnChanges: nil, - UpdateIDOnChanges: nil, - NormalizeCase: nil, + ProvidedIDFields: nil, + UpdatableIDFields: nil, NormalizeSlash: nil, BackendDefaults: nil, } diff --git a/bundle/direct/dresources/config_test.go b/bundle/direct/dresources/config_test.go index a9a347ec747..5b063145e4c 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}, + {"provided_id_fields", c.ProvidedIDFields}, + {"updatable_id_fields", c.UpdatableIDFields}, {"backend_defaults", backendAsFieldRules}, } } @@ -72,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, 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 +// 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}, + {"updatable_id_fields", rc.UpdatableIDFields}, + } + 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 + } + } + } + } +} diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 9c12da9f39b..99e39e62e7b 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -3,7 +3,13 @@ # # Available options: # recreate_on_changes: fields that trigger delete + create -# update_id_on_changes: fields that trigger UpdateWithID (ID may change) +# 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). +# 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 # 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 +163,7 @@ resources: - field: event_log.schema models: - recreate_on_changes: + 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. @@ -195,9 +201,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: + provided_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 +262,16 @@ resources: reason: output_only - field: updated_by reason: output_only - recreate_on_changes: + provided_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 +284,12 @@ resources: - field: metastore_id quality_monitors: + provided_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,24 +308,20 @@ resources: reason: immutable - field: share_name reason: immutable - update_id_on_changes: + updatable_id_fields: - field: name reason: id_changes schemas: - recreate_on_changes: + provided_id_fields: + # UC lowercases identifier names; remote returns "myschema" for config "MySchema". - 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: - # 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 @@ -329,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: @@ -338,27 +343,19 @@ resources: reason: input_only volumes: - recreate_on_changes: + provided_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: + updatable_id_fields: - 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). - - 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 @@ -389,10 +386,25 @@ resources: - field: serialized_space reason: etag_based + database_instances: + provided_id_fields: + - field: name + reason: id_field + + database_catalogs: + provided_id_fields: + - field: name + reason: id_field + + synced_database_tables: + provided_id_fields: + - field: name + reason: id_field + apps: - recreate_on_changes: + provided_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 +428,10 @@ resources: # The Secrets API defaults scope_backend_type to DATABRICKS when not specified. - field: scope_backend_type values: ["DATABRICKS"] - recreate_on_changes: + provided_id_fields: - field: scope - reason: immutable + reason: id_field + recreate_on_changes: - field: scope_backend_type reason: immutable - field: backend_azure_keyvault @@ -428,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 @@ -523,10 +536,10 @@ resources: - field: enable_serverless_compute postgres_projects: - recreate_on_changes: + provided_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 +548,12 @@ resources: reason: input_only postgres_branches: - recreate_on_changes: + provided_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 +564,12 @@ resources: reason: "input_only; cannot be updated after create" postgres_endpoints: - recreate_on_changes: + provided_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 +580,11 @@ resources: reason: "input_only; cannot be updated after create" postgres_catalogs: - recreate_on_changes: + provided_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 +602,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: + provided_id_fields: - field: synced_table_id - reason: immutable + reason: id_field + recreate_on_changes: - field: branch reason: immutable - field: postgres_database @@ -611,6 +626,10 @@ resources: reason: immutable vector_search_endpoints: + provided_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 +640,12 @@ resources: reason: effective_vs_requested vector_search_indexes: + provided_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..55fe35c2287 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 } + // 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 { 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, provided_id_fields or ignore_remote_changes", jsonTag, ) }