From f6229e86b43a39fc2802bb43173efe3b15ccaa77 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 30 Apr 2026 13:03:26 +0200 Subject: [PATCH 1/6] Fix spurious recreate of external volumes with trailing-slash storage_location The UC API strips trailing slashes from storage_location on create, causing the direct engine to detect drift on every subsequent plan and trigger a full delete+create cycle. Fix: add OverrideChangeDesc to ResourceVolume that treats storage_location values differing only by trailing slashes as equivalent (alias), matching the Terraform provider's suppressLocationDiff behavior. Also update the testserver to accept storage_location on EXTERNAL volumes and strip the trailing slash, mirroring UC API behavior so the bug and fix are reproducible locally. Add an invariant no_drift test for external volumes. Co-authored-by: Isaac --- .../configs/external_volume.yml.tmpl | 11 +++++++ acceptance/bundle/invariant/test.toml | 4 +++ bundle/direct/dresources/volume.go | 31 +++++++++++++++++++ libs/testserver/volumes.go | 12 +++++-- 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 acceptance/bundle/invariant/configs/external_volume.yml.tmpl diff --git a/acceptance/bundle/invariant/configs/external_volume.yml.tmpl b/acceptance/bundle/invariant/configs/external_volume.yml.tmpl new file mode 100644 index 00000000000..9bb123550f5 --- /dev/null +++ b/acceptance/bundle/invariant/configs/external_volume.yml.tmpl @@ -0,0 +1,11 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + volumes: + foo: + name: test-volume-$UNIQUE_NAME + catalog_name: main + schema_name: default + volume_type: EXTERNAL + storage_location: s3://test-bucket/path/ diff --git a/acceptance/bundle/invariant/test.toml b/acceptance/bundle/invariant/test.toml index bb66a393bef..87a6aed8d51 100644 --- a/acceptance/bundle/invariant/test.toml +++ b/acceptance/bundle/invariant/test.toml @@ -57,6 +57,7 @@ EnvMatrix.INPUT_CONFIG = [ "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl", + "external_volume.yml.tmpl", ] [EnvMatrixExclude] @@ -71,6 +72,9 @@ no_postgres_endpoint_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_end # which are environment-specific, so we only test locally with the mock server no_external_location_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=external_location.yml.tmpl"] +# External volumes reference external locations; excluded from cloud for the same reason +no_external_volume_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=external_volume.yml.tmpl"] + # Fake SQL endpoint for local tests [[Server]] Pattern = "POST /api/2.0/sql/statements/" diff --git a/bundle/direct/dresources/volume.go b/bundle/direct/dresources/volume.go index 62a08987eea..7bb798f2d07 100644 --- a/bundle/direct/dresources/volume.go +++ b/bundle/direct/dresources/volume.go @@ -6,7 +6,9 @@ import ( "strings" "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/structs/structpath" "github.com/databricks/cli/libs/utils" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/service/catalog" @@ -112,6 +114,35 @@ func (r *ResourceVolume) DoDelete(ctx context.Context, id string) error { return r.client.Volumes.DeleteByName(ctx, id) } +// OverrideChangeDesc suppresses drift for storage_location when the only difference +// is a trailing slash. The UC API strips trailing slashes on create, so remote returns +// "s3://bucket/path" while the config may have "s3://bucket/path/". +// +// This matches the Terraform provider's suppressLocationDiff behavior. +// https://github.com/databricks/terraform-provider-databricks/blob/v1.65.1/catalog/resource_volume.go#L25 +func (*ResourceVolume) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, _ *catalog.VolumeInfo) error { + if change.Action == deployplan.Skip { + return nil + } + + if path.String() != "storage_location" { + return nil + } + + newStr, newOk := change.New.(string) + remoteStr, remoteOk := change.Remote.(string) + if !newOk || !remoteOk { + return nil + } + + if newStr != remoteStr && strings.TrimRight(newStr, "/") == strings.TrimRight(remoteStr, "/") { + change.Action = deployplan.Skip + change.Reason = deployplan.ReasonAlias + } + + return nil +} + func getNameFromID(id string) (string, error) { items := strings.Split(id, ".") if len(items) == 0 { diff --git a/libs/testserver/volumes.go b/libs/testserver/volumes.go index 66e5d047ab8..76b5fdf3e5f 100644 --- a/libs/testserver/volumes.go +++ b/libs/testserver/volumes.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/databricks/databricks-sdk-go/service/workspace" @@ -21,7 +22,7 @@ func (s *FakeWorkspace) VolumesCreate(req Request) Response { volume.FullName = volume.CatalogName + "." + volume.SchemaName + "." + volume.Name - if volume.StorageLocation != "" { + if volume.StorageLocation != "" && volume.VolumeType != catalog.VolumeTypeExternal { return Response{ StatusCode: 400, Body: map[string]string{ @@ -30,8 +31,15 @@ func (s *FakeWorkspace) VolumesCreate(req Request) Response { }, } } + volume.VolumeId = nextUUID() - volume.StorageLocation = fmt.Sprintf("s3://%s/metastore/%s/volumes/%s", testMetastoreName, TestMetastore.MetastoreId, volume.VolumeId) + + if volume.StorageLocation != "" { + // Strip trailing slash to mimic UC API normalization behavior. + volume.StorageLocation = strings.TrimRight(volume.StorageLocation, "/") + } else { + volume.StorageLocation = fmt.Sprintf("s3://%s/metastore/%s/volumes/%s", testMetastoreName, TestMetastore.MetastoreId, volume.VolumeId) + } volume.CreatedAt = nowMilli() volume.UpdatedAt = volume.CreatedAt From b6e814eaf47b2fbaa9e444e06b2447a17dae6df8 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 30 Apr 2026 13:05:21 +0200 Subject: [PATCH 2/6] Rename external_volume.yml.tmpl to volume_external.yml.tmpl Co-authored-by: Isaac --- .../{external_volume.yml.tmpl => volume_external.yml.tmpl} | 0 acceptance/bundle/invariant/test.toml | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename acceptance/bundle/invariant/configs/{external_volume.yml.tmpl => volume_external.yml.tmpl} (100%) diff --git a/acceptance/bundle/invariant/configs/external_volume.yml.tmpl b/acceptance/bundle/invariant/configs/volume_external.yml.tmpl similarity index 100% rename from acceptance/bundle/invariant/configs/external_volume.yml.tmpl rename to acceptance/bundle/invariant/configs/volume_external.yml.tmpl diff --git a/acceptance/bundle/invariant/test.toml b/acceptance/bundle/invariant/test.toml index 87a6aed8d51..beabef5ef1e 100644 --- a/acceptance/bundle/invariant/test.toml +++ b/acceptance/bundle/invariant/test.toml @@ -57,7 +57,7 @@ EnvMatrix.INPUT_CONFIG = [ "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl", - "external_volume.yml.tmpl", + "volume_external.yml.tmpl", ] [EnvMatrixExclude] @@ -73,7 +73,7 @@ no_postgres_endpoint_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_end no_external_location_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=external_location.yml.tmpl"] # External volumes reference external locations; excluded from cloud for the same reason -no_external_volume_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=external_volume.yml.tmpl"] +no_external_volume_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=volume_external.yml.tmpl"] # Fake SQL endpoint for local tests [[Server]] From b4f00e8eda67923a691cd5df29dd80fb440102cd Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 30 Apr 2026 13:06:17 +0200 Subject: [PATCH 3/6] Add ReasonURLNormalization, use it in volume storage_location Co-authored-by: Isaac --- bundle/deployplan/plan.go | 1 + bundle/direct/dresources/volume.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bundle/deployplan/plan.go b/bundle/deployplan/plan.go index e0dcd9b2886..b35357c7c28 100644 --- a/bundle/deployplan/plan.go +++ b/bundle/deployplan/plan.go @@ -100,6 +100,7 @@ type ChangeDesc struct { const ( ReasonBackendDefault = "backend_default" ReasonAlias = "alias" + ReasonURLNormalization = "url_normalization" ReasonRemoteAlreadySet = "remote_already_set" ReasonEmpty = "empty" ReasonCustom = "custom" diff --git a/bundle/direct/dresources/volume.go b/bundle/direct/dresources/volume.go index 7bb798f2d07..35196bdb388 100644 --- a/bundle/direct/dresources/volume.go +++ b/bundle/direct/dresources/volume.go @@ -137,7 +137,7 @@ func (*ResourceVolume) OverrideChangeDesc(_ context.Context, path *structpath.Pa if newStr != remoteStr && strings.TrimRight(newStr, "/") == strings.TrimRight(remoteStr, "/") { change.Action = deployplan.Skip - change.Reason = deployplan.ReasonAlias + change.Reason = deployplan.ReasonURLNormalization } return nil From 0ded0ac9b81d370e1727b4837dbdeb4e0b48809a Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 30 Apr 2026 13:20:33 +0200 Subject: [PATCH 4/6] Add dedicated cloud test for external volume trailing-slash storage_location Co-authored-by: Isaac --- .../out.test.toml | 10 +++++ .../output.txt | 25 +++++++++++++ .../trailing-slash-storage-location/script | 37 +++++++++++++++++++ .../trailing-slash-storage-location/test.toml | 16 ++++++++ 4 files changed, 88 insertions(+) create mode 100644 acceptance/bundle/resources/volumes/trailing-slash-storage-location/out.test.toml create mode 100644 acceptance/bundle/resources/volumes/trailing-slash-storage-location/output.txt create mode 100644 acceptance/bundle/resources/volumes/trailing-slash-storage-location/script create mode 100644 acceptance/bundle/resources/volumes/trailing-slash-storage-location/test.toml diff --git a/acceptance/bundle/resources/volumes/trailing-slash-storage-location/out.test.toml b/acceptance/bundle/resources/volumes/trailing-slash-storage-location/out.test.toml new file mode 100644 index 00000000000..7035e08badb --- /dev/null +++ b/acceptance/bundle/resources/volumes/trailing-slash-storage-location/out.test.toml @@ -0,0 +1,10 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true + +[CloudEnvs] + azure = false + gcp = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/volumes/trailing-slash-storage-location/output.txt b/acceptance/bundle/resources/volumes/trailing-slash-storage-location/output.txt new file mode 100644 index 00000000000..50cd0496f41 --- /dev/null +++ b/acceptance/bundle/resources/volumes/trailing-slash-storage-location/output.txt @@ -0,0 +1,25 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== Plan after deploy should show no changes +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.volumes.volume1 + +This action will result in the deletion of the following volumes. +For managed volumes, the files stored in the volume are also deleted from your +cloud tenant within 30 days. For external volumes, the metadata about the volume +is removed from the catalog, but the underlying files are not deleted: + delete resources.volumes.volume1 + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/volumes/trailing-slash-storage-location/script b/acceptance/bundle/resources/volumes/trailing-slash-storage-location/script new file mode 100644 index 00000000000..d6f1c673d34 --- /dev/null +++ b/acceptance/bundle/resources/volumes/trailing-slash-storage-location/script @@ -0,0 +1,37 @@ +if [ -n "${CLOUD_ENV}" ]; then + # On cloud: find an external location to use as storage base + EXT_LOC_URL=$($CLI external-locations list --output json 2>/dev/null | jq -r 'first(.[] | select(.url != null) | .url) // empty') + if [ -z "$EXT_LOC_URL" ]; then + echo "No external locations available; skipping" + exit 0 + fi + STORAGE_LOCATION="${EXT_LOC_URL%/}/dabs-test-trailing-slash-${UNIQUE_NAME}/" +else + STORAGE_LOCATION="s3://test-bucket/path/" +fi + +add_repl.py "$STORAGE_LOCATION" "STORAGE_LOCATION" + +cat > databricks.yml << EOF +bundle: + name: test-bundle-${UNIQUE_NAME} + +resources: + volumes: + volume1: + catalog_name: main + schema_name: default + name: test-volume-${UNIQUE_NAME} + volume_type: EXTERNAL + storage_location: "$STORAGE_LOCATION" +EOF + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +trace $CLI bundle deploy + +title "Plan after deploy should show no changes" +trace $CLI bundle plan diff --git a/acceptance/bundle/resources/volumes/trailing-slash-storage-location/test.toml b/acceptance/bundle/resources/volumes/trailing-slash-storage-location/test.toml new file mode 100644 index 00000000000..726067fe318 --- /dev/null +++ b/acceptance/bundle/resources/volumes/trailing-slash-storage-location/test.toml @@ -0,0 +1,16 @@ +Local = true +Cloud = true +RecordRequests = false +RequiresUnityCatalog = true + +# External volumes require an external location backed by cloud IAM; only AWS has one set up +CloudEnvs.gcp = false +CloudEnvs.azure = false + +Ignore = [ + ".databricks", + "databricks.yml", +] + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] From 22517b32bc1faf3f7f6a2ab24a03de38ceab4bba Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 30 Apr 2026 14:02:43 +0200 Subject: [PATCH 5/6] Remove dedicated trailing-slash test; invariant test covers it Co-authored-by: Isaac --- .../out.test.toml | 10 ----- .../output.txt | 25 ------------- .../trailing-slash-storage-location/script | 37 ------------------- .../trailing-slash-storage-location/test.toml | 16 -------- 4 files changed, 88 deletions(-) delete mode 100644 acceptance/bundle/resources/volumes/trailing-slash-storage-location/out.test.toml delete mode 100644 acceptance/bundle/resources/volumes/trailing-slash-storage-location/output.txt delete mode 100644 acceptance/bundle/resources/volumes/trailing-slash-storage-location/script delete mode 100644 acceptance/bundle/resources/volumes/trailing-slash-storage-location/test.toml diff --git a/acceptance/bundle/resources/volumes/trailing-slash-storage-location/out.test.toml b/acceptance/bundle/resources/volumes/trailing-slash-storage-location/out.test.toml deleted file mode 100644 index 7035e08badb..00000000000 --- a/acceptance/bundle/resources/volumes/trailing-slash-storage-location/out.test.toml +++ /dev/null @@ -1,10 +0,0 @@ -Local = true -Cloud = true -RequiresUnityCatalog = true - -[CloudEnvs] - azure = false - gcp = false - -[EnvMatrix] - DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/volumes/trailing-slash-storage-location/output.txt b/acceptance/bundle/resources/volumes/trailing-slash-storage-location/output.txt deleted file mode 100644 index 50cd0496f41..00000000000 --- a/acceptance/bundle/resources/volumes/trailing-slash-storage-location/output.txt +++ /dev/null @@ -1,25 +0,0 @@ - ->>> [CLI] bundle deploy -Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... -Deploying resources... -Updating deployment state... -Deployment complete! - -=== Plan after deploy should show no changes ->>> [CLI] bundle plan -Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged - ->>> [CLI] bundle destroy --auto-approve -The following resources will be deleted: - delete resources.volumes.volume1 - -This action will result in the deletion of the following volumes. -For managed volumes, the files stored in the volume are also deleted from your -cloud tenant within 30 days. For external volumes, the metadata about the volume -is removed from the catalog, but the underlying files are not deleted: - delete resources.volumes.volume1 - -All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default - -Deleting files... -Destroy complete! diff --git a/acceptance/bundle/resources/volumes/trailing-slash-storage-location/script b/acceptance/bundle/resources/volumes/trailing-slash-storage-location/script deleted file mode 100644 index d6f1c673d34..00000000000 --- a/acceptance/bundle/resources/volumes/trailing-slash-storage-location/script +++ /dev/null @@ -1,37 +0,0 @@ -if [ -n "${CLOUD_ENV}" ]; then - # On cloud: find an external location to use as storage base - EXT_LOC_URL=$($CLI external-locations list --output json 2>/dev/null | jq -r 'first(.[] | select(.url != null) | .url) // empty') - if [ -z "$EXT_LOC_URL" ]; then - echo "No external locations available; skipping" - exit 0 - fi - STORAGE_LOCATION="${EXT_LOC_URL%/}/dabs-test-trailing-slash-${UNIQUE_NAME}/" -else - STORAGE_LOCATION="s3://test-bucket/path/" -fi - -add_repl.py "$STORAGE_LOCATION" "STORAGE_LOCATION" - -cat > databricks.yml << EOF -bundle: - name: test-bundle-${UNIQUE_NAME} - -resources: - volumes: - volume1: - catalog_name: main - schema_name: default - name: test-volume-${UNIQUE_NAME} - volume_type: EXTERNAL - storage_location: "$STORAGE_LOCATION" -EOF - -cleanup() { - trace $CLI bundle destroy --auto-approve -} -trap cleanup EXIT - -trace $CLI bundle deploy - -title "Plan after deploy should show no changes" -trace $CLI bundle plan diff --git a/acceptance/bundle/resources/volumes/trailing-slash-storage-location/test.toml b/acceptance/bundle/resources/volumes/trailing-slash-storage-location/test.toml deleted file mode 100644 index 726067fe318..00000000000 --- a/acceptance/bundle/resources/volumes/trailing-slash-storage-location/test.toml +++ /dev/null @@ -1,16 +0,0 @@ -Local = true -Cloud = true -RecordRequests = false -RequiresUnityCatalog = true - -# External volumes require an external location backed by cloud IAM; only AWS has one set up -CloudEnvs.gcp = false -CloudEnvs.azure = false - -Ignore = [ - ".databricks", - "databricks.yml", -] - -[EnvMatrix] - DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] From 2598f17a37572aef7369f336f7144b33eba66c2f Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 30 Apr 2026 14:05:45 +0200 Subject: [PATCH 6/6] Update out.test.toml files after adding volume_external.yml.tmpl Co-authored-by: Isaac --- acceptance/bundle/invariant/continue_293/out.test.toml | 3 ++- acceptance/bundle/invariant/migrate/out.test.toml | 3 ++- acceptance/bundle/invariant/no_drift/out.test.toml | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/acceptance/bundle/invariant/continue_293/out.test.toml b/acceptance/bundle/invariant/continue_293/out.test.toml index 22d9d4dff31..11aaf584918 100644 --- a/acceptance/bundle/invariant/continue_293/out.test.toml +++ b/acceptance/bundle/invariant/continue_293/out.test.toml @@ -38,5 +38,6 @@ EnvMatrix.INPUT_CONFIG = [ "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", - "volume.yml.tmpl" + "volume.yml.tmpl", + "volume_external.yml.tmpl" ] diff --git a/acceptance/bundle/invariant/migrate/out.test.toml b/acceptance/bundle/invariant/migrate/out.test.toml index 22d9d4dff31..11aaf584918 100644 --- a/acceptance/bundle/invariant/migrate/out.test.toml +++ b/acceptance/bundle/invariant/migrate/out.test.toml @@ -38,5 +38,6 @@ EnvMatrix.INPUT_CONFIG = [ "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", - "volume.yml.tmpl" + "volume.yml.tmpl", + "volume_external.yml.tmpl" ] diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index 22d9d4dff31..11aaf584918 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -38,5 +38,6 @@ EnvMatrix.INPUT_CONFIG = [ "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", - "volume.yml.tmpl" + "volume.yml.tmpl", + "volume_external.yml.tmpl" ]