diff --git a/.nextchanges/bundles/schema-rename.md b/.nextchanges/bundles/schema-rename.md new file mode 100644 index 00000000000..2ac01106dbe --- /dev/null +++ b/.nextchanges/bundles/schema-rename.md @@ -0,0 +1 @@ +* Rename a schema in place when its `name` changes, instead of deleting and recreating it. Schemas and volumes inside a renamed catalog or schema now follow it to the new name; recreating them failed because they already exist there. ([#7343](https://github.com/databricks/cli/pull/7343)) diff --git a/acceptance/bundle/dms/declined-deploy/output.txt b/acceptance/bundle/dms/declined-deploy/output.txt index 02b3d5450f9..dacc77db623 100644 --- a/acceptance/bundle/dms/declined-deploy/output.txt +++ b/acceptance/bundle/dms/declined-deploy/output.txt @@ -47,7 +47,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged "error_message": "", "resource_id": "main.dms_declined_deploy_schema_[UNIQUE_NAME]", "sequence_id": 0, - "state": "{\"state\":{\"catalog_name\":\"main\",\"name\":\"dms_declined_deploy_schema_[UNIQUE_NAME]\"}}", + "state": "{\"state\":{\"catalog_name\":\"main\",\"name\":\"dms_declined_deploy_schema_[UNIQUE_NAME]\",\"schema_id\":\"[UUID]\"}}", "status": "OPERATION_STATUS_SUCCEEDED" } } @@ -98,7 +98,8 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "__id__": "main.dms_declined_deploy_schema_[UNIQUE_NAME]", "state": { "catalog_name": "main", - "name": "dms_declined_deploy_schema_[UNIQUE_NAME]" + "name": "dms_declined_deploy_schema_[UNIQUE_NAME]", + "schema_id": "[UUID]" } } } diff --git a/acceptance/bundle/dms/deployment-metadata-change/output.txt b/acceptance/bundle/dms/deployment-metadata-change/output.txt index 94f0a68a122..02cbb2e5705 100644 --- a/acceptance/bundle/dms/deployment-metadata-change/output.txt +++ b/acceptance/bundle/dms/deployment-metadata-change/output.txt @@ -47,7 +47,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged "error_message": "", "resource_id": "main.dms_metadata_schema_[UNIQUE_NAME]", "sequence_id": 0, - "state": "{\"state\":{\"catalog_name\":\"main\",\"name\":\"dms_metadata_schema_[UNIQUE_NAME]\"}}", + "state": "{\"state\":{\"catalog_name\":\"main\",\"name\":\"dms_metadata_schema_[UNIQUE_NAME]\",\"schema_id\":\"[UUID]\"}}", "status": "OPERATION_STATUS_SUCCEEDED" } } diff --git a/acceptance/bundle/dms/empty-grants/output.txt b/acceptance/bundle/dms/empty-grants/output.txt index c12e022ced2..5e169e86ebd 100644 --- a/acceptance/bundle/dms/empty-grants/output.txt +++ b/acceptance/bundle/dms/empty-grants/output.txt @@ -52,7 +52,7 @@ Resources: 2 created, 0 changed, 0 deleted, 0 unchanged "error_message": "", "resource_id": "main.dms_empty_grants_[UNIQUE_NAME]", "sequence_id": 0, - "state": "{\"state\":{\"catalog_name\":\"main\",\"name\":\"dms_empty_grants_[UNIQUE_NAME]\"}}", + "state": "{\"state\":{\"catalog_name\":\"main\",\"name\":\"dms_empty_grants_[UNIQUE_NAME]\",\"schema_id\":\"[UUID]\"}}", "status": "OPERATION_STATUS_SUCCEEDED" } } @@ -170,7 +170,8 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "__id__": "main.dms_empty_grants_[UNIQUE_NAME]", "state": { "catalog_name": "main", - "name": "dms_empty_grants_[UNIQUE_NAME]" + "name": "dms_empty_grants_[UNIQUE_NAME]", + "schema_id": "[UUID]" } } } diff --git a/acceptance/bundle/dms/failed-recreate/output.txt b/acceptance/bundle/dms/failed-recreate/output.txt index 2e599afbde7..0d930eff2c0 100644 --- a/acceptance/bundle/dms/failed-recreate/output.txt +++ b/acceptance/bundle/dms/failed-recreate/output.txt @@ -47,7 +47,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged "error_message": "", "resource_id": "main.dms_failed_recreate_schema_[UNIQUE_NAME]", "sequence_id": 0, - "state": "{\"state\":{\"catalog_name\":\"main\",\"comment\":\"v1\",\"name\":\"dms_failed_recreate_schema_[UNIQUE_NAME]\"}}", + "state": "{\"state\":{\"catalog_name\":\"main\",\"comment\":\"v1\",\"name\":\"dms_failed_recreate_schema_[UNIQUE_NAME]\",\"schema_id\":\"[UUID]\"}}", "status": "OPERATION_STATUS_SUCCEEDED" } } @@ -100,7 +100,8 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "state": { "catalog_name": "main", "comment": "v1", - "name": "dms_failed_recreate_schema_[UNIQUE_NAME]" + "name": "dms_failed_recreate_schema_[UNIQUE_NAME]", + "schema_id": "[UUID]" } } } diff --git a/acceptance/bundle/dms/operation-upload-fails-once/output.txt b/acceptance/bundle/dms/operation-upload-fails-once/output.txt index 042e9d0669e..6dbce4b3834 100644 --- a/acceptance/bundle/dms/operation-upload-fails-once/output.txt +++ b/acceptance/bundle/dms/operation-upload-fails-once/output.txt @@ -47,7 +47,8 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "state": { "catalog_name": "main", "comment": "v1", - "name": "dms_upload_fails_once_schema_[UNIQUE_NAME]" + "name": "dms_upload_fails_once_schema_[UNIQUE_NAME]", + "schema_id": "[UUID]" } } } @@ -94,7 +95,8 @@ Resources: 1 created, 0 changed, 1 deleted, 0 unchanged "state": { "catalog_name": "dms_other_[UNIQUE_NAME]", "comment": "v1", - "name": "dms_upload_fails_once_schema_[UNIQUE_NAME]" + "name": "dms_upload_fails_once_schema_[UNIQUE_NAME]", + "schema_id": "[UUID]" } } } @@ -141,7 +143,7 @@ Resources: 1 created, 0 changed, 1 deleted, 0 unchanged "error_message": "", "resource_id": "dms_other_[UNIQUE_NAME].dms_upload_fails_once_schema_[UNIQUE_NAME]", "sequence_id": 0, - "state": "{\"state\":{\"catalog_name\":\"dms_other_[UNIQUE_NAME]\",\"comment\":\"v1\",\"name\":\"dms_upload_fails_once_schema_[UNIQUE_NAME]\"}}", + "state": "{\"state\":{\"catalog_name\":\"dms_other_[UNIQUE_NAME]\",\"comment\":\"v1\",\"name\":\"dms_upload_fails_once_schema_[UNIQUE_NAME]\",\"schema_id\":\"[UUID]\"}}", "status": "OPERATION_STATUS_SUCCEEDED" } } diff --git a/acceptance/bundle/dms/successful-recreate/output.txt b/acceptance/bundle/dms/successful-recreate/output.txt index 41c9d29e8e2..f6776520bc3 100644 --- a/acceptance/bundle/dms/successful-recreate/output.txt +++ b/acceptance/bundle/dms/successful-recreate/output.txt @@ -47,7 +47,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged "error_message": "", "resource_id": "main.dms_successful_recreate_schema_[UNIQUE_NAME]", "sequence_id": 0, - "state": "{\"state\":{\"catalog_name\":\"main\",\"comment\":\"v1\",\"name\":\"dms_successful_recreate_schema_[UNIQUE_NAME]\"}}", + "state": "{\"state\":{\"catalog_name\":\"main\",\"comment\":\"v1\",\"name\":\"dms_successful_recreate_schema_[UNIQUE_NAME]\",\"schema_id\":\"[UUID]\"}}", "status": "OPERATION_STATUS_SUCCEEDED" } } @@ -100,7 +100,8 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "state": { "catalog_name": "main", "comment": "v1", - "name": "dms_successful_recreate_schema_[UNIQUE_NAME]" + "name": "dms_successful_recreate_schema_[UNIQUE_NAME]", + "schema_id": "[UUID]" } } } @@ -159,7 +160,7 @@ Resources: 1 created, 0 changed, 1 deleted, 0 unchanged "error_message": "", "resource_id": "dms_other_[UNIQUE_NAME].dms_successful_recreate_schema_[UNIQUE_NAME]", "sequence_id": 1, - "state": "{\"state\":{\"catalog_name\":\"dms_other_[UNIQUE_NAME]\",\"comment\":\"v1\",\"name\":\"dms_successful_recreate_schema_[UNIQUE_NAME]\"}}", + "state": "{\"state\":{\"catalog_name\":\"dms_other_[UNIQUE_NAME]\",\"comment\":\"v1\",\"name\":\"dms_successful_recreate_schema_[UNIQUE_NAME]\",\"schema_id\":\"[UUID]\"}}", "status": "OPERATION_STATUS_SUCCEEDED" } } @@ -212,7 +213,8 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "state": { "catalog_name": "dms_other_[UNIQUE_NAME]", "comment": "v1", - "name": "dms_successful_recreate_schema_[UNIQUE_NAME]" + "name": "dms_successful_recreate_schema_[UNIQUE_NAME]", + "schema_id": "[UUID]" } } } diff --git a/acceptance/bundle/lifecycle/prevent-destroy/script b/acceptance/bundle/lifecycle/prevent-destroy/script index a2c90cea5b5..94c074cfc0b 100644 --- a/acceptance/bundle/lifecycle/prevent-destroy/script +++ b/acceptance/bundle/lifecycle/prevent-destroy/script @@ -12,8 +12,8 @@ update_file.py resources/pipeline.yml ' catalog: main' ' storage: "dbf trace errcode $CLI bundle plan >>out.txt 2>&1 trace musterr $CLI bundle deploy -qq >>out.txt 2>&1 -# Changing the schema name, deploy must fail because schema will be recreated -update_file.py resources/schema.yml 'name: test-schema' 'name: test-schema-new' +# Changing the schema catalog, deploy must fail because schema will be recreated +update_file.py resources/schema.yml 'catalog_name: "test-catalog"' 'catalog_name: "test-catalog-new"' trace errcode $CLI bundle plan >>out.txt 2>&1 trace musterr $CLI bundle deploy -qq >>out.txt 2>&1 diff --git a/acceptance/bundle/migrate/auto/registered-model-catalog-change/terraform-requests.json b/acceptance/bundle/migrate/auto/registered-model-catalog-change/terraform-requests.json index 6f4620d81f5..6c27b1fc729 100644 --- a/acceptance/bundle/migrate/auto/registered-model-catalog-change/terraform-requests.json +++ b/acceptance/bundle/migrate/auto/registered-model-catalog-change/terraform-requests.json @@ -1,11 +1,4 @@ [ - { - "method": "POST", - "path": "/api/2.1/unity-catalog/catalogs", - "body": { - "name": "othercat_[UNIQUE_NAME]" - } - }, { "method": "POST", "path": "/api/2.1/unity-catalog/models", diff --git a/acceptance/bundle/migrate/auto/schema-catalog-change/terraform-requests.json b/acceptance/bundle/migrate/auto/schema-catalog-change/terraform-requests.json index eda99b84d42..e7072606fd3 100644 --- a/acceptance/bundle/migrate/auto/schema-catalog-change/terraform-requests.json +++ b/acceptance/bundle/migrate/auto/schema-catalog-change/terraform-requests.json @@ -1,11 +1,4 @@ [ - { - "method": "POST", - "path": "/api/2.1/unity-catalog/catalogs", - "body": { - "name": "othercat_[UNIQUE_NAME]" - } - }, { "method": "POST", "path": "/api/2.1/unity-catalog/schemas", diff --git a/acceptance/bundle/migrate/auto/schema-rename/output.txt b/acceptance/bundle/migrate/auto/schema-rename/output.txt index 65e060f08f9..6985c121152 100644 --- a/acceptance/bundle/migrate/auto/schema-rename/output.txt +++ b/acceptance/bundle/migrate/auto/schema-rename/output.txt @@ -2,38 +2,15 @@ === Replay the prior terraform deployment (captured from a real terraform run) >>> replay_tfstate.py terraform-requests.json terraform.tfstate -=== Rename the schema (an immutable id field). Without --auto-approve the recreate is refused ->>> DATABRICKS_BUNDLE_ENGINE=direct errcode [CLI] bundle deploy -Warn: migration to direct: schemas.my_schema: name differs between config ("myschema-[UNIQUE_NAME]-renamed") and terraform state ("myschema-[UNIQUE_NAME]"); this resource will be recreated. -Notice: migrating your bundle to direct deployment engine (https://github.com/databricks/cli/issues/6765). -Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/[UNIQUE_NAME]/files... - -This action will result in the deletion or recreation of the following UC schemas. Any underlying data may be lost: - recreate resources.schemas.my_schema -Warn: Migration not committed, keeping Terraform state -Error: the deployment requires destructive actions, but the current console does not support prompting. -Deleting data assets such as schemas, pipelines, or volumes may cause permanent data loss and should be carefully reviewed. -To proceed, use --auto-approve after reviewing the plan above. - -Files: 2 uploaded, 0 deleted - -Exit code: 1 - ->>> print_migration_telemetry -direct_migrate_recreate_planned true - -=== With --auto-approve the recreate is applied on direct under the new name ->>> DATABRICKS_BUNDLE_ENGINE=direct [CLI] bundle deploy --auto-approve -Warn: migration to direct: schemas.my_schema: name differs between config ("myschema-[UNIQUE_NAME]-renamed") and terraform state ("myschema-[UNIQUE_NAME]"); this resource will be recreated. +=== Rename the schema (an updatable id field) and deploy on direct +>>> DATABRICKS_BUNDLE_ENGINE=direct [CLI] bundle deploy +Warn: migration to direct: schemas.my_schema: name differs between config ("myschema-[UNIQUE_NAME]-renamed") and terraform state ("myschema-[UNIQUE_NAME]"); this resource will be renamed. Notice: migrating your bundle to direct deployment engine (https://github.com/databricks/cli/issues/6765). Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/[UNIQUE_NAME]/files... - -This action will result in the deletion or recreation of the following UC schemas. Any underlying data may be lost: - recreate resources.schemas.my_schema Migrated 1 resource to direct deployment engine. -Recreated schemas.my_schema -Files: 0 uploaded, 0 deleted -Resources: 1 created, 0 changed, 1 deleted, 0 unchanged +Updated schemas.my_schema +Files: 2 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 0 unchanged === Redeploy on direct: the schema now matches config, so the plan is a clean no-op >>> DATABRICKS_BUNDLE_ENGINE=direct [CLI] bundle deploy diff --git a/acceptance/bundle/migrate/auto/schema-rename/script b/acceptance/bundle/migrate/auto/schema-rename/script index 2286ccca1b2..273c43e1917 100644 --- a/acceptance/bundle/migrate/auto/schema-rename/script +++ b/acceptance/bundle/migrate/auto/schema-rename/script @@ -10,17 +10,12 @@ title "Replay the prior terraform deployment (captured from a real terraform run trace replay_tfstate.py terraform-requests.json terraform.tfstate rm -f "$OUT_REQUESTS" -title "Rename the schema (an immutable id field). Without --auto-approve the recreate is refused" -# name is a provided id field, so a rename recreates. reconcileIDFields seeds the deployed -# name into the converted state, so the migration's plan sees a real recreate. Schemas are in -# the destructive-approval group, so without --auto-approve the deploy refuses the recreate -# (exit 1) and the migration is not committed - the run stays on terraform. +title "Rename the schema (an updatable id field) and deploy on direct" +# name is an updatable id field: the schema is renamed in place (UpdateWithID), so the deploy +# needs no --auto-approve. reconcileIDFields seeds the deployed name into the converted state, +# so the migration's plan sees a rename rather than snapshotting the new name as already applied. update_file.py databricks.yml "myschema-$UNIQUE_NAME" "myschema-$UNIQUE_NAME-renamed" -trace DATABRICKS_BUNDLE_ENGINE=direct errcode $CLI bundle deploy -trace print_migration_telemetry - -title "With --auto-approve the recreate is applied on direct under the new name" -trace DATABRICKS_BUNDLE_ENGINE=direct $CLI bundle deploy --auto-approve +trace DATABRICKS_BUNDLE_ENGINE=direct $CLI bundle deploy title "Redeploy on direct: the schema now matches config, so the plan is a clean no-op" trace DATABRICKS_BUNDLE_ENGINE=direct $CLI bundle deploy diff --git a/acceptance/bundle/migrate/auto/schema-storage-change/script b/acceptance/bundle/migrate/auto/schema-storage-change/script index c0ab15856de..ef97ca99a82 100644 --- a/acceptance/bundle/migrate/auto/schema-storage-change/script +++ b/acceptance/bundle/migrate/auto/schema-storage-change/script @@ -2,7 +2,7 @@ export DATABRICKS_BUNDLE_ENGINE= # Testserver-only: an explicit storage_root needs a registered external location on a real # workspace, so this is not Cloud-enabled. The recreate/refusal class it covers is verified -# on cloud by schema-rename and pipeline-storage-change. +# on cloud by schema-catalog-change and pipeline-storage-change. title "Replay the prior terraform deployment (captured from a real terraform run)" trace replay_tfstate.py terraform-requests.json terraform.tfstate diff --git a/acceptance/bundle/migrate/auto/volume-rename/script b/acceptance/bundle/migrate/auto/volume-rename/script index 5ef772365b7..b4d3abb6b3b 100644 --- a/acceptance/bundle/migrate/auto/volume-rename/script +++ b/acceptance/bundle/migrate/auto/volume-rename/script @@ -10,7 +10,7 @@ title "Replay the prior terraform deployment (captured from a real terraform run trace replay_tfstate.py terraform-requests.json terraform.tfstate title "Rename the volume (an updatable id field) and deploy on direct" -# Unlike a schema (whose name recreates), a volume name is an updatable id field: it is +# A volume name is an updatable id field: it is # renamed in place (UpdateWithID). The migration seeds the deployed name into the converted # state, so its plan sees a rename and the migration succeeds; the deploy then renames the # volume rather than the migration snapshotting the new name as already applied. Verifies the diff --git a/acceptance/bundle/migrate/auto/volume-type-change/script b/acceptance/bundle/migrate/auto/volume-type-change/script index 6b02a55f247..ea5ebdb1499 100644 --- a/acceptance/bundle/migrate/auto/volume-type-change/script +++ b/acceptance/bundle/migrate/auto/volume-type-change/script @@ -2,7 +2,7 @@ export DATABRICKS_BUNDLE_ENGINE= # Testserver-only: changing volume_type to EXTERNAL needs a storage_location backed by a # registered external location on a real workspace, so this is not Cloud-enabled. The -# recreate class it covers is verified on cloud by schema-rename / registered-model-rename. +# recreate class it covers is verified on cloud by schema-catalog-change / registered-model-rename. # volume_type is both a provided id field and a recreate_on_changes field. reconcileIDFields # records the deployed value, so the migration's plan sees a real change and recreates. Volumes diff --git a/acceptance/bundle/refschema/output/schemas.txt b/acceptance/bundle/refschema/output/schemas.txt index 694d59c6211..566999fd664 100644 --- a/acceptance/bundle/refschema/output/schemas.txt +++ b/acceptance/bundle/refschema/output/schemas.txt @@ -23,7 +23,7 @@ resources.schemas.*.name string ALL resources.schemas.*.owner string REMOTE resources.schemas.*.properties map[string]string ALL resources.schemas.*.properties.* string ALL -resources.schemas.*.schema_id string REMOTE +resources.schemas.*.schema_id string REMOTE STATE resources.schemas.*.storage_location string REMOTE resources.schemas.*.storage_root string ALL resources.schemas.*.updated_at int64 REMOTE diff --git a/acceptance/bundle/refschema/output/volumes.txt b/acceptance/bundle/refschema/output/volumes.txt index 1c573ed1931..52e07594399 100644 --- a/acceptance/bundle/refschema/output/volumes.txt +++ b/acceptance/bundle/refschema/output/volumes.txt @@ -24,7 +24,7 @@ resources.volumes.*.storage_location string ALL resources.volumes.*.updated_at int64 REMOTE resources.volumes.*.updated_by string REMOTE resources.volumes.*.url string INPUT -resources.volumes.*.volume_id string REMOTE +resources.volumes.*.volume_id string REMOTE STATE resources.volumes.*.volume_path string INPUT resources.volumes.*.volume_type catalog.VolumeType ALL resources.volumes.*.grants.full_name string ALL diff --git a/acceptance/bundle/resource_deps/non_existent_field/out.deploy.txt b/acceptance/bundle/resource_deps/non_existent_field/out.deploy.txt index 8da224f9751..d546194dba3 100644 --- a/acceptance/bundle/resource_deps/non_existent_field/out.deploy.txt +++ b/acceptance/bundle/resource_deps/non_existent_field/out.deploy.txt @@ -1,5 +1,5 @@ Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... -Error: cannot plan resources.volumes.foo: cannot resolve "${resources.volumes.bar.non_existent}": schema mismatch: non_existent: field "non_existent" not found in catalog.CreateVolumeRequestContent; non_existent: field "non_existent" not found in catalog.VolumeInfo +Error: cannot plan resources.volumes.foo: cannot resolve "${resources.volumes.bar.non_existent}": schema mismatch: non_existent: field "non_existent" not found in dresources.VolumeState; non_existent: field "non_existent" not found in catalog.VolumeInfo Error: planning failed diff --git a/acceptance/bundle/resource_deps/non_existent_field/out.plan.txt b/acceptance/bundle/resource_deps/non_existent_field/out.plan.txt index 6a2b5d1157c..232d9f0b3d4 100644 --- a/acceptance/bundle/resource_deps/non_existent_field/out.plan.txt +++ b/acceptance/bundle/resource_deps/non_existent_field/out.plan.txt @@ -1,4 +1,4 @@ -Error: cannot plan resources.volumes.foo: cannot resolve "${resources.volumes.bar.non_existent}": schema mismatch: non_existent: field "non_existent" not found in catalog.CreateVolumeRequestContent; non_existent: field "non_existent" not found in catalog.VolumeInfo +Error: cannot plan resources.volumes.foo: cannot resolve "${resources.volumes.bar.non_existent}": schema mismatch: non_existent: field "non_existent" not found in dresources.VolumeState; non_existent: field "non_existent" not found in catalog.VolumeInfo Error: planning failed diff --git a/acceptance/bundle/resources/catalogs/rename/databricks.yml.tmpl b/acceptance/bundle/resources/catalogs/rename/databricks.yml.tmpl new file mode 100644 index 00000000000..e45eccddc44 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename/databricks.yml.tmpl @@ -0,0 +1,16 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + catalogs: + catalog1: + name: test_catalog_$UNIQUE_NAME + schemas: + schema1: + catalog_name: ${resources.catalogs.catalog1.name} + name: myschema + volumes: + volume1: + catalog_name: ${resources.catalogs.catalog1.name} + schema_name: ${resources.schemas.schema1.name} + name: myvolume diff --git a/acceptance/bundle/resources/catalogs/rename/out.test.toml b/acceptance/bundle/resources/catalogs/rename/out.test.toml new file mode 100644 index 00000000000..0f167effef1 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/catalogs/rename/output.txt b/acceptance/bundle/resources/catalogs/rename/output.txt new file mode 100644 index 00000000000..07843301bcc --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename/output.txt @@ -0,0 +1,225 @@ + +=== Deploy a catalog with a schema and a volume in it +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created catalogs.catalog1 +Created schemas.schema1 +Created volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 3 created, 0 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/catalogs", + "body": { + "name": "test_catalog_[UNIQUE_NAME]" + } +} +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/schemas", + "body": { + "catalog_name": "test_catalog_[UNIQUE_NAME]", + "name": "myschema" + } +} +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/volumes", + "body": { + "catalog_name": "test_catalog_[UNIQUE_NAME]", + "name": "myvolume", + "schema_name": "myschema", + "volume_type": "MANAGED" + } +} + +=== Rename the catalog +>>> update_file.py databricks.yml test_catalog_ test_catalog_renamed_ + +=== UC moves the schema and the volume with the catalog, so the plan re-keys them instead of recreating them +>>> [CLI] bundle plan --output json +{ + "resources.catalogs.catalog1": { + "action": "update_id", + "new_state": { + "value": { + "name": "test_catalog_renamed_[UNIQUE_NAME]" + } + }, + "remote_state": { + "catalog_type": "MANAGED_CATALOG", + "created_at": [UNIX_TIME_MILLIS][0], + "created_by": "[USERNAME]", + "full_name": "test_catalog_[UNIQUE_NAME]", + "name": "test_catalog_[UNIQUE_NAME]", + "owner": "[USERNAME]" + }, + "changes": { + "name": { + "action": "update_id", + "reason": "id_changes", + "old": "test_catalog_[UNIQUE_NAME]", + "new": "test_catalog_renamed_[UNIQUE_NAME]", + "remote": "test_catalog_[UNIQUE_NAME]" + } + } + }, + "resources.schemas.schema1": { + "depends_on": [ + { + "node": "resources.catalogs.catalog1", + "label": "${resources.catalogs.catalog1.name}" + } + ], + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "test_catalog_renamed_[UNIQUE_NAME]", + "name": "myschema" + } + }, + "remote_state": { + "catalog_name": "test_catalog_[UNIQUE_NAME]", + "catalog_type": "MANAGED_CATALOG", + "created_at": [UNIX_TIME_MILLIS][1], + "created_by": "[USERNAME]", + "full_name": "test_catalog_[UNIQUE_NAME].myschema", + "name": "myschema", + "owner": "[USERNAME]", + "schema_id": "[SCHEMA_ID]" + }, + "changes": { + "catalog_name": { + "action": "update_id", + "reason": "parent_renamed", + "old": "test_catalog_[UNIQUE_NAME]", + "new": "test_catalog_renamed_[UNIQUE_NAME]", + "remote": "test_catalog_[UNIQUE_NAME]" + } + } + }, + "resources.volumes.volume1": { + "depends_on": [ + { + "node": "resources.catalogs.catalog1", + "label": "${resources.catalogs.catalog1.name}" + }, + { + "node": "resources.schemas.schema1", + "label": "${resources.schemas.schema1.name}" + } + ], + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "test_catalog_renamed_[UNIQUE_NAME]", + "name": "myvolume", + "schema_name": "myschema", + "volume_type": "MANAGED" + } + }, + "remote_state": { + "catalog_name": "test_catalog_[UNIQUE_NAME]", + "created_at": [UNIX_TIME_MILLIS][2], + "created_by": "[USERNAME]", + "full_name": "test_catalog_[UNIQUE_NAME].myschema.myvolume", + "name": "myvolume", + "owner": "[USERNAME]", + "schema_name": "myschema", + "volume_id": "[VOLUME_ID]", + "volume_type": "MANAGED" + }, + "changes": { + "catalog_name": { + "action": "update_id", + "reason": "parent_renamed", + "old": "test_catalog_[UNIQUE_NAME]", + "new": "test_catalog_renamed_[UNIQUE_NAME]", + "remote": "test_catalog_[UNIQUE_NAME]" + } + } + } +} + +=== Deploy renames the catalog. UC moves the schema and the volume with it, so they get no request +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated catalogs.catalog1 +Updated schemas.schema1 +Updated volumes.volume1 +Files: 1 uploaded, 0 deleted +Resources: 0 created, 3 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/catalogs/test_catalog_[UNIQUE_NAME]", + "body": { + "comment": "", + "custom_max_retention_hours": 0, + "new_name": "test_catalog_renamed_[UNIQUE_NAME]" + } +} + +=== The old catalog is gone and the schema and the volume are under the new one, with their original ids +old catalog is gone + +>>> [CLI] schemas get test_catalog_renamed_[UNIQUE_NAME].myschema +{ + "full_name": "test_catalog_renamed_[UNIQUE_NAME].myschema", + "catalog_name": "test_catalog_renamed_[UNIQUE_NAME]", + "schema_id": "[SCHEMA_ID]" +} + +>>> [CLI] volumes read test_catalog_renamed_[UNIQUE_NAME].myschema.myvolume +{ + "full_name": "test_catalog_renamed_[UNIQUE_NAME].myschema.myvolume", + "catalog_name": "test_catalog_renamed_[UNIQUE_NAME]", + "volume_id": "[VOLUME_ID]" +} + +=== The state tracks the new paths +>>> [CLI] bundle summary --output json +{ + "catalogs": { + "catalog1": "test_catalog_renamed_[UNIQUE_NAME]" + }, + "schemas": { + "schema1": "test_catalog_renamed_[UNIQUE_NAME].myschema" + }, + "volumes": { + "volume1": "test_catalog_renamed_[UNIQUE_NAME].myschema.myvolume" + } +} + +=== Redeploy is a no-op +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 3 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 3 unchanged + +>>> print_requests.py //unity-catalog + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.catalogs.catalog1 + delete resources.schemas.schema1 + delete resources.volumes.volume1 + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.schema1 + +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 + +Destroy: 3 deleted diff --git a/acceptance/bundle/resources/catalogs/rename/script b/acceptance/bundle/resources/catalogs/rename/script new file mode 100644 index 00000000000..e84ef070fc8 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename/script @@ -0,0 +1,43 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +OLD_CATALOG="test_catalog_${UNIQUE_NAME}" +NEW_CATALOG="test_catalog_renamed_${UNIQUE_NAME}" + +cleanup() { + trace $CLI bundle destroy --auto-approve + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +title "Deploy a catalog with a schema and a volume in it" +trace $CLI bundle deploy +trace print_requests.py //unity-catalog +add_repl "$($CLI schemas get "$OLD_CATALOG.myschema" | jq -r .schema_id)" SCHEMA_ID +add_repl "$($CLI volumes read "$OLD_CATALOG.myschema.myvolume" | jq -r .volume_id)" VOLUME_ID + +title "Rename the catalog" +trace update_file.py databricks.yml test_catalog_ test_catalog_renamed_ + +title "UC moves the schema and the volume with the catalog, so the plan re-keys them instead of recreating them" +# Drop the remote_state fields that differ between clouds or from the test server. +trace $CLI bundle plan --output json | jq .plan | normalize_uc_payload.py updated_at updated_by metastore_id browse_only effective_predictive_optimization_flag enable_predictive_optimization isolation_mode securable_type storage_location + +title "Deploy renames the catalog. UC moves the schema and the volume with it, so they get no request" +trace $CLI bundle deploy +trace print_requests.py //unity-catalog + +title "The old catalog is gone and the schema and the volume are under the new one, with their original ids\n" +# Not traced: the "not found" message differs between UC and the test server. +$CLI catalogs get "$OLD_CATALOG" &> /dev/null && echo "old catalog still exists" || echo "old catalog is gone" +trace $CLI schemas get "$NEW_CATALOG.myschema" | jq '{full_name, catalog_name, schema_id}' +trace $CLI volumes read "$NEW_CATALOG.myschema.myvolume" | jq '{full_name, catalog_name, volume_id}' + +title "The state tracks the new paths" +trace $CLI bundle summary --output json | jq '.resources | map_values(map_values(.id))' + +title "Redeploy is a no-op" +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests.py //unity-catalog diff --git a/acceptance/bundle/resources/catalogs/rename/test.toml b/acceptance/bundle/resources/catalogs/rename/test.toml new file mode 100644 index 00000000000..4272a80cf61 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename/test.toml @@ -0,0 +1,7 @@ +Cloud = true + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] diff --git a/acceptance/bundle/resources/catalogs/rename_all_out_of_band/databricks.yml.tmpl b/acceptance/bundle/resources/catalogs/rename_all_out_of_band/databricks.yml.tmpl new file mode 100644 index 00000000000..e45eccddc44 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename_all_out_of_band/databricks.yml.tmpl @@ -0,0 +1,16 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + catalogs: + catalog1: + name: test_catalog_$UNIQUE_NAME + schemas: + schema1: + catalog_name: ${resources.catalogs.catalog1.name} + name: myschema + volumes: + volume1: + catalog_name: ${resources.catalogs.catalog1.name} + schema_name: ${resources.schemas.schema1.name} + name: myvolume diff --git a/acceptance/bundle/resources/catalogs/rename_all_out_of_band/out.test.toml b/acceptance/bundle/resources/catalogs/rename_all_out_of_band/out.test.toml new file mode 100644 index 00000000000..0f167effef1 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename_all_out_of_band/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/catalogs/rename_all_out_of_band/output.txt b/acceptance/bundle/resources/catalogs/rename_all_out_of_band/output.txt new file mode 100644 index 00000000000..7b1f43ba6ce --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename_all_out_of_band/output.txt @@ -0,0 +1,174 @@ + +=== Deploy a catalog with a schema and a volume in it +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created catalogs.catalog1 +Created schemas.schema1 +Created volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 3 created, 0 changed, 0 deleted, 0 unchanged + +=== Rename the volume, the schema and the catalog out of band +>>> [CLI] volumes update test_catalog_[UNIQUE_NAME].myschema.myvolume --new-name myvolume2 +{ + "full_name": "test_catalog_[UNIQUE_NAME].myschema.myvolume2" +} + +>>> [CLI] schemas update test_catalog_[UNIQUE_NAME].myschema --new-name myschema2 +{ + "full_name": "test_catalog_[UNIQUE_NAME].myschema2" +} + +>>> [CLI] catalogs update test_catalog_[UNIQUE_NAME] --new-name test_catalog_renamed_[UNIQUE_NAME] +{ + "name": "test_catalog_renamed_[UNIQUE_NAME]" +} + +=== Rename all three in the config to match +>>> update_file.py databricks.yml test_catalog_ test_catalog_renamed_ + +>>> update_file.py databricks.yml name: myschema name: myschema2 + +>>> update_file.py databricks.yml name: myvolume name: myvolume2 + +=== The plan finds the schema and the volume at their new names by their ids. A catalog has no such id, so it is created again +>>> [CLI] bundle plan --output json +{ + "resources.catalogs.catalog1": { + "action": "create", + "new_state": { + "value": { + "name": "test_catalog_renamed_[UNIQUE_NAME]" + } + }, + "changes": { + "name": { + "action": "update_id", + "reason": "id_changes", + "old": "test_catalog_[UNIQUE_NAME]", + "new": "test_catalog_renamed_[UNIQUE_NAME]" + } + } + }, + "resources.schemas.schema1": { + "id": "test_catalog_renamed_[UNIQUE_NAME].myschema2", + "depends_on": [ + { + "node": "resources.catalogs.catalog1", + "label": "${resources.catalogs.catalog1.name}" + } + ], + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "test_catalog_renamed_[UNIQUE_NAME]", + "name": "myschema2" + } + }, + "remote_state": { + "catalog_name": "test_catalog_renamed_[UNIQUE_NAME]", + "catalog_type": "MANAGED_CATALOG", + "created_at": [UNIX_TIME_MILLIS][0], + "created_by": "[USERNAME]", + "full_name": "test_catalog_renamed_[UNIQUE_NAME].myschema2", + "name": "myschema2", + "owner": "[USERNAME]", + "schema_id": "[SCHEMA_ID]" + }, + "changes": { + "catalog_name": { + "action": "skip", + "reason": "remote_already_set", + "old": "test_catalog_[UNIQUE_NAME]", + "new": "test_catalog_renamed_[UNIQUE_NAME]", + "remote": "test_catalog_renamed_[UNIQUE_NAME]" + }, + "name": { + "action": "skip", + "reason": "remote_already_set", + "old": "myschema", + "new": "myschema2", + "remote": "myschema2" + } + } + }, + "resources.volumes.volume1": { + "id": "test_catalog_renamed_[UNIQUE_NAME].myschema2.myvolume2", + "depends_on": [ + { + "node": "resources.catalogs.catalog1", + "label": "${resources.catalogs.catalog1.name}" + }, + { + "node": "resources.schemas.schema1", + "label": "${resources.schemas.schema1.name}" + } + ], + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "test_catalog_renamed_[UNIQUE_NAME]", + "name": "myvolume2", + "schema_name": "myschema2", + "volume_type": "MANAGED" + } + }, + "remote_state": { + "catalog_name": "test_catalog_renamed_[UNIQUE_NAME]", + "created_at": [UNIX_TIME_MILLIS][1], + "created_by": "[USERNAME]", + "full_name": "test_catalog_renamed_[UNIQUE_NAME].myschema2.myvolume2", + "name": "myvolume2", + "owner": "[USERNAME]", + "schema_name": "myschema2", + "volume_id": "[VOLUME_ID]", + "volume_type": "MANAGED" + }, + "changes": { + "catalog_name": { + "action": "skip", + "reason": "remote_already_set", + "old": "test_catalog_[UNIQUE_NAME]", + "new": "test_catalog_renamed_[UNIQUE_NAME]", + "remote": "test_catalog_renamed_[UNIQUE_NAME]" + }, + "name": { + "action": "skip", + "reason": "remote_already_set", + "old": "myvolume", + "new": "myvolume2", + "remote": "myvolume2" + }, + "schema_name": { + "action": "skip", + "reason": "remote_already_set", + "old": "myschema", + "new": "myschema2", + "remote": "myschema2" + } + } + } +} + +=== The catalog's create fails because the renamed catalog has its name +>>> musterr [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Error: cannot create resources.catalogs.catalog1: Catalog 'test_catalog_renamed_[UNIQUE_NAME]' already exists (400 CATALOG_ALREADY_EXISTS) + +Endpoint: POST [DATABRICKS_URL]/api/2.1/unity-catalog/catalogs +HTTP Status: 400 Bad Request +API error_code: CATALOG_ALREADY_EXISTS +API message: Catalog 'test_catalog_renamed_[UNIQUE_NAME]' already exists + +Error: cannot update_id resources.schemas.schema1: dependency failed: resources.catalogs.catalog1 + +Error: cannot update_id resources.volumes.volume1: dependency failed: resources.catalogs.catalog1 + +Files: 3 uploaded, 0 deleted + +>>> errcode [CLI] bundle destroy --auto-approve +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default + +Destroy: 0 deleted + +>>> errcode [CLI] catalogs delete test_catalog_renamed_[UNIQUE_NAME] --force diff --git a/acceptance/bundle/resources/catalogs/rename_all_out_of_band/script b/acceptance/bundle/resources/catalogs/rename_all_out_of_band/script new file mode 100644 index 00000000000..9645fc0dc02 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename_all_out_of_band/script @@ -0,0 +1,44 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +OLD_CATALOG="test_catalog_${UNIQUE_NAME}" +NEW_CATALOG="test_catalog_renamed_${UNIQUE_NAME}" + +cleanup() { + trace errcode $CLI bundle destroy --auto-approve + # The renamed catalog is not where the state looks for it, so destroy cannot remove it. + trace errcode $CLI catalogs delete "$NEW_CATALOG" --force + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +# Drop the remote_state fields that differ between clouds or from the test server. +plan() { + trace $CLI bundle plan --output json | jq .plan | normalize_uc_payload.py updated_at updated_by metastore_id browse_only effective_predictive_optimization_flag enable_predictive_optimization isolation_mode securable_type storage_location +} + +title "Deploy a catalog with a schema and a volume in it" +trace $CLI bundle deploy +print_requests.py //unity-catalog &> LOG.deploy_requests +add_repl "$($CLI schemas get "$OLD_CATALOG.myschema" | jq -r .schema_id)" SCHEMA_ID +add_repl "$($CLI volumes read "$OLD_CATALOG.myschema.myvolume" | jq -r .volume_id)" VOLUME_ID + +title "Rename the volume, the schema and the catalog out of band" +trace $CLI volumes update "$OLD_CATALOG.myschema.myvolume" --new-name myvolume2 | jq "{full_name}" +trace $CLI schemas update "$OLD_CATALOG.myschema" --new-name myschema2 | jq "{full_name}" +trace $CLI catalogs update "$OLD_CATALOG" --new-name "$NEW_CATALOG" | jq "{name}" +print_requests.py //unity-catalog &> LOG.rename_requests + +title "Rename all three in the config to match" +trace update_file.py databricks.yml test_catalog_ test_catalog_renamed_ +trace update_file.py databricks.yml "name: myschema" "name: myschema2" +trace update_file.py databricks.yml "name: myvolume" "name: myvolume2" +NEW_SCHEMA="$NEW_CATALOG.myschema2" +NEW_VOLUME="$NEW_SCHEMA.myvolume2" + +title "The plan finds the schema and the volume at their new names by their ids. A catalog has no such id, so it is created again" +plan + +title "The catalog's create fails because the renamed catalog has its name" +trace musterr $CLI bundle deploy diff --git a/acceptance/bundle/resources/catalogs/rename_all_out_of_band/test.toml b/acceptance/bundle/resources/catalogs/rename_all_out_of_band/test.toml new file mode 100644 index 00000000000..4272a80cf61 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename_all_out_of_band/test.toml @@ -0,0 +1,7 @@ +Cloud = true + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] diff --git a/acceptance/bundle/resources/catalogs/rename_out_of_band/databricks.yml.tmpl b/acceptance/bundle/resources/catalogs/rename_out_of_band/databricks.yml.tmpl new file mode 100644 index 00000000000..e45eccddc44 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename_out_of_band/databricks.yml.tmpl @@ -0,0 +1,16 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + catalogs: + catalog1: + name: test_catalog_$UNIQUE_NAME + schemas: + schema1: + catalog_name: ${resources.catalogs.catalog1.name} + name: myschema + volumes: + volume1: + catalog_name: ${resources.catalogs.catalog1.name} + schema_name: ${resources.schemas.schema1.name} + name: myvolume diff --git a/acceptance/bundle/resources/catalogs/rename_out_of_band/out.test.toml b/acceptance/bundle/resources/catalogs/rename_out_of_band/out.test.toml new file mode 100644 index 00000000000..0f167effef1 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename_out_of_band/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/catalogs/rename_out_of_band/output.txt b/acceptance/bundle/resources/catalogs/rename_out_of_band/output.txt new file mode 100644 index 00000000000..09bf7199065 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename_out_of_band/output.txt @@ -0,0 +1,139 @@ + +=== Deploy a catalog with a schema and a volume in it +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created catalogs.catalog1 +Created schemas.schema1 +Created volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 3 created, 0 changed, 0 deleted, 0 unchanged + +=== Rename the catalog out of band. UC moves the schema and the volume with it +>>> [CLI] catalogs update test_catalog_[UNIQUE_NAME] --new-name test_catalog_renamed_[UNIQUE_NAME] +{ + "name": "test_catalog_renamed_[UNIQUE_NAME]" +} + +=== Rename the catalog in the config to match +>>> update_file.py databricks.yml test_catalog_ test_catalog_renamed_ + +=== The plan finds the schema and the volume at their new names by their ids. A catalog has no such id, so it is created again +>>> [CLI] bundle plan --output json +{ + "resources.catalogs.catalog1": { + "action": "create", + "new_state": { + "value": { + "name": "test_catalog_renamed_[UNIQUE_NAME]" + } + }, + "changes": { + "name": { + "action": "update_id", + "reason": "id_changes", + "old": "test_catalog_[UNIQUE_NAME]", + "new": "test_catalog_renamed_[UNIQUE_NAME]" + } + } + }, + "resources.schemas.schema1": { + "id": "test_catalog_renamed_[UNIQUE_NAME].myschema", + "depends_on": [ + { + "node": "resources.catalogs.catalog1", + "label": "${resources.catalogs.catalog1.name}" + } + ], + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "test_catalog_renamed_[UNIQUE_NAME]", + "name": "myschema" + } + }, + "remote_state": { + "catalog_name": "test_catalog_renamed_[UNIQUE_NAME]", + "catalog_type": "MANAGED_CATALOG", + "created_at": [UNIX_TIME_MILLIS][0], + "created_by": "[USERNAME]", + "full_name": "test_catalog_renamed_[UNIQUE_NAME].myschema", + "name": "myschema", + "owner": "[USERNAME]", + "schema_id": "[SCHEMA_ID]" + }, + "changes": { + "catalog_name": { + "action": "skip", + "reason": "remote_already_set", + "old": "test_catalog_[UNIQUE_NAME]", + "new": "test_catalog_renamed_[UNIQUE_NAME]", + "remote": "test_catalog_renamed_[UNIQUE_NAME]" + } + } + }, + "resources.volumes.volume1": { + "id": "test_catalog_renamed_[UNIQUE_NAME].myschema.myvolume", + "depends_on": [ + { + "node": "resources.catalogs.catalog1", + "label": "${resources.catalogs.catalog1.name}" + }, + { + "node": "resources.schemas.schema1", + "label": "${resources.schemas.schema1.name}" + } + ], + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "test_catalog_renamed_[UNIQUE_NAME]", + "name": "myvolume", + "schema_name": "myschema", + "volume_type": "MANAGED" + } + }, + "remote_state": { + "catalog_name": "test_catalog_renamed_[UNIQUE_NAME]", + "created_at": [UNIX_TIME_MILLIS][1], + "created_by": "[USERNAME]", + "full_name": "test_catalog_renamed_[UNIQUE_NAME].myschema.myvolume", + "name": "myvolume", + "owner": "[USERNAME]", + "schema_name": "myschema", + "volume_id": "[VOLUME_ID]", + "volume_type": "MANAGED" + }, + "changes": { + "catalog_name": { + "action": "skip", + "reason": "remote_already_set", + "old": "test_catalog_[UNIQUE_NAME]", + "new": "test_catalog_renamed_[UNIQUE_NAME]", + "remote": "test_catalog_renamed_[UNIQUE_NAME]" + } + } + } +} + +=== The catalog's create fails because the renamed catalog has its name +>>> musterr [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Error: cannot create resources.catalogs.catalog1: Catalog 'test_catalog_renamed_[UNIQUE_NAME]' already exists (400 CATALOG_ALREADY_EXISTS) + +Endpoint: POST [DATABRICKS_URL]/api/2.1/unity-catalog/catalogs +HTTP Status: 400 Bad Request +API error_code: CATALOG_ALREADY_EXISTS +API message: Catalog 'test_catalog_renamed_[UNIQUE_NAME]' already exists + +Error: cannot update_id resources.schemas.schema1: dependency failed: resources.catalogs.catalog1 + +Error: cannot update_id resources.volumes.volume1: dependency failed: resources.catalogs.catalog1 + +Files: 3 uploaded, 0 deleted + +>>> errcode [CLI] bundle destroy --auto-approve +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default + +Destroy: 0 deleted + +>>> errcode [CLI] catalogs delete test_catalog_renamed_[UNIQUE_NAME] --force diff --git a/acceptance/bundle/resources/catalogs/rename_out_of_band/script b/acceptance/bundle/resources/catalogs/rename_out_of_band/script new file mode 100644 index 00000000000..8b161d76a37 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename_out_of_band/script @@ -0,0 +1,40 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +OLD_CATALOG="test_catalog_${UNIQUE_NAME}" +NEW_CATALOG="test_catalog_renamed_${UNIQUE_NAME}" + +cleanup() { + trace errcode $CLI bundle destroy --auto-approve + # The renamed catalog is not where the state looks for it, so destroy cannot remove it. + trace errcode $CLI catalogs delete "$NEW_CATALOG" --force + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +# Drop the remote_state fields that differ between clouds or from the test server. +plan() { + trace $CLI bundle plan --output json | jq .plan | normalize_uc_payload.py updated_at updated_by metastore_id browse_only effective_predictive_optimization_flag enable_predictive_optimization isolation_mode securable_type storage_location +} + +title "Deploy a catalog with a schema and a volume in it" +trace $CLI bundle deploy +print_requests.py //unity-catalog &> LOG.deploy_requests +add_repl "$($CLI schemas get "$OLD_CATALOG.myschema" | jq -r .schema_id)" SCHEMA_ID +add_repl "$($CLI volumes read "$OLD_CATALOG.myschema.myvolume" | jq -r .volume_id)" VOLUME_ID + +title "Rename the catalog out of band. UC moves the schema and the volume with it" +trace $CLI catalogs update "$OLD_CATALOG" --new-name "$NEW_CATALOG" | jq "{name}" +print_requests.py //unity-catalog &> LOG.rename_requests + +title "Rename the catalog in the config to match" +trace update_file.py databricks.yml test_catalog_ test_catalog_renamed_ +NEW_SCHEMA="$NEW_CATALOG.myschema" +NEW_VOLUME="$NEW_SCHEMA.myvolume" + +title "The plan finds the schema and the volume at their new names by their ids. A catalog has no such id, so it is created again" +plan + +title "The catalog's create fails because the renamed catalog has its name" +trace musterr $CLI bundle deploy diff --git a/acceptance/bundle/resources/catalogs/rename_out_of_band/test.toml b/acceptance/bundle/resources/catalogs/rename_out_of_band/test.toml new file mode 100644 index 00000000000..4272a80cf61 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/rename_out_of_band/test.toml @@ -0,0 +1,7 @@ +Cloud = true + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] diff --git a/acceptance/bundle/resources/grants/schemas/change_name/output.txt b/acceptance/bundle/resources/grants/schemas/change_name/output.txt index 6c4e069ee0c..b7009427f4b 100644 --- a/acceptance/bundle/resources/grants/schemas/change_name/output.txt +++ b/acceptance/bundle/resources/grants/schemas/change_name/output.txt @@ -54,28 +54,19 @@ schemas schema1.grants id='schema/main.test-schema-[UNIQUE_NAME]' === Deploy moves the schema to the new name and applies the grants there >>> [CLI] bundle deploy --auto-approve Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... - -This action will result in the deletion or recreation of the following UC schemas. Any underlying data may be lost: - recreate resources.schemas.schema1 -Recreated schemas.schema1 +Updated schemas.schema1 Updated schemas.schema1.grants Files: 1 uploaded, 0 deleted -Resources: 1 created, 1 changed, 1 deleted, 0 unchanged +Resources: 0 created, 2 changed, 0 deleted, 0 unchanged >>> print_requests.py //unity-catalog { - "method": "DELETE", + "method": "PATCH", "path": "/api/2.1/unity-catalog/schemas/main.test-schema-[UNIQUE_NAME]", - "q": { - "force": "true" - } -} -{ - "method": "POST", - "path": "/api/2.1/unity-catalog/schemas", "body": { - "catalog_name": "main", - "name": "test-schema-renamed-[UNIQUE_NAME]" + "comment": "", + "custom_max_retention_hours": 0, + "new_name": "test-schema-renamed-[UNIQUE_NAME]" } } { diff --git a/acceptance/bundle/resources/schemas/recreate_interrupted/databricks.yml.tmpl b/acceptance/bundle/resources/schemas/recreate_interrupted/databricks.yml.tmpl new file mode 100644 index 00000000000..a965908256c --- /dev/null +++ b/acceptance/bundle/resources/schemas/recreate_interrupted/databricks.yml.tmpl @@ -0,0 +1,14 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + schemas: + schema1: + catalog_name: main + name: test-schema-$UNIQUE_NAME + storage_root: s3://mybucket/a + volumes: + volume1: + catalog_name: main + schema_name: ${resources.schemas.schema1.name} + name: myvolume diff --git a/acceptance/bundle/resources/schemas/recreate_interrupted/out.test.toml b/acceptance/bundle/resources/schemas/recreate_interrupted/out.test.toml new file mode 100644 index 00000000000..19b5b15d11f --- /dev/null +++ b/acceptance/bundle/resources/schemas/recreate_interrupted/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = [""] diff --git a/acceptance/bundle/resources/schemas/recreate_interrupted/output.txt b/acceptance/bundle/resources/schemas/recreate_interrupted/output.txt new file mode 100644 index 00000000000..69ff54088bd --- /dev/null +++ b/acceptance/bundle/resources/schemas/recreate_interrupted/output.txt @@ -0,0 +1,121 @@ + +=== Deploy a schema with a volume in it +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created schemas.schema1 +Created volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged + +=== Rename the schema and change its storage_root, which recreates it. The CLI is killed on the volume's recreate, after the schema's +>>> update_file.py databricks.yml test-schema- test-schema-renamed- + +>>> update_file.py databricks.yml s3://mybucket/a s3://mybucket/b + +>>> errcode [CLI] bundle deploy -q --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... + +This action will result in the deletion or recreation of the following UC schemas. Any underlying data may be lost: + recreate resources.schemas.schema1 + +This action will result in the deletion or recreation 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: + recreate resources.volumes.volume1 +[PROCESS_KILLED] + +Exit code: [KILLED] + +>>> print_requests.py //unity-catalog +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/schemas/main.test-schema-[UNIQUE_NAME]", + "q": { + "force": "true" + } +} +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/schemas", + "body": { + "catalog_name": "main", + "name": "test-schema-renamed-[UNIQUE_NAME]", + "storage_root": "s3://mybucket/b" + } +} + +=== The volume went with the old schema. Nothing with its volume_id is at its new path, so the plan creates it +>>> [CLI] bundle plan --output json +{ + "depends_on": [ + { + "node": "resources.schemas.schema1", + "label": "${resources.schemas.schema1.name}" + } + ], + "action": "create", + "new_state": { + "value": { + "catalog_name": "main", + "name": "myvolume", + "schema_name": "test-schema-renamed-[UNIQUE_NAME]", + "volume_type": "MANAGED" + } + }, + "changes": { + "schema_name": { + "action": "recreate", + "reason": "id_field", + "old": "test-schema-[UNIQUE_NAME]", + "new": "test-schema-renamed-[UNIQUE_NAME]" + } + } +} + +=== The next deploy creates a new volume +>>> [CLI] bundle deploy --force-lock +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created volumes.volume1 +Files: 0 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 1 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/volumes", + "body": { + "catalog_name": "main", + "name": "myvolume", + "schema_name": "test-schema-renamed-[UNIQUE_NAME]", + "volume_type": "MANAGED" + } +} + +>>> [CLI] volumes read main.test-schema-renamed-[UNIQUE_NAME].myvolume +{ + "full_name": "main.test-schema-renamed-[UNIQUE_NAME].myvolume", + "volume_id": "[UUID]" +} + +=== Redeploy is a no-op +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle destroy --auto-approve --force-lock +The following resources will be deleted: + delete resources.schemas.schema1 + delete resources.volumes.volume1 + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.schema1 + +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 + +Destroy: 2 deleted diff --git a/acceptance/bundle/resources/schemas/recreate_interrupted/script b/acceptance/bundle/resources/schemas/recreate_interrupted/script new file mode 100644 index 00000000000..df501ef6124 --- /dev/null +++ b/acceptance/bundle/resources/schemas/recreate_interrupted/script @@ -0,0 +1,36 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +OLD_VOLUME="main.test-schema-${UNIQUE_NAME}.myvolume" +NEW_VOLUME="main.test-schema-renamed-${UNIQUE_NAME}.myvolume" + +cleanup() { + trace $CLI bundle destroy --auto-approve --force-lock + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +title "Deploy a schema with a volume in it" +trace $CLI bundle deploy +print_requests.py //unity-catalog &> LOG.deploy_requests +add_repl "$($CLI volumes read "$OLD_VOLUME" | jq -r .volume_id)" VOLUME_ID + +title "Rename the schema and change its storage_root, which recreates it. The CLI is killed on the volume's recreate, after the schema's" +trace update_file.py databricks.yml test-schema- test-schema-renamed- +trace update_file.py databricks.yml s3://mybucket/a s3://mybucket/b +kill_after.py "DELETE /api/2.1/unity-catalog/volumes/$OLD_VOLUME" 0 1 +# -q: the per-resource lines race the kill signal. +trace errcode $CLI bundle deploy -q --auto-approve +trace print_requests.py //unity-catalog + +title "The volume went with the old schema. Nothing with its volume_id is at its new path, so the plan creates it" +trace $CLI bundle plan --output json | jq '.plan["resources.volumes.volume1"]' + +title "The next deploy creates a new volume" +trace $CLI bundle deploy --force-lock +trace print_requests.py //unity-catalog +trace $CLI volumes read "$NEW_VOLUME" | jq '{full_name, volume_id}' + +title "Redeploy is a no-op" +trace $CLI bundle plan diff --git a/acceptance/bundle/resources/schemas/recreate_interrupted/test.toml b/acceptance/bundle/resources/schemas/recreate_interrupted/test.toml new file mode 100644 index 00000000000..e149a3f7289 --- /dev/null +++ b/acceptance/bundle/resources/schemas/recreate_interrupted/test.toml @@ -0,0 +1,36 @@ +# Local-only: the test server kills the CLI mid-deploy (kill_after.py), which a real workspace +# cannot, and an explicit storage_root needs an external location on a real workspace. +Cloud = false + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] + +# KillCaller needs the CLI's pid. A kill leaves no state in DMS to recover from, so DMS is not run. +Env.DATABRICKS_CLI_TEST_PID = "1" +EnvMatrix.DMS = [""] + +# Normalize how a killed process shows up on each OS, as in bundle/deploy/wal. +[[Repls]] +Old = 'script: line \d+:\s+\d+ Killed(: 9)?\s+"\$@"' +New = '[PROCESS_KILLED]' + +[[Repls]] +Old = '(\n>>> errcode [^\n]+\n)\nExit code:' +New = "${1}[PROCESS_KILLED]\n\nExit code:" + +[[Repls]] +Old = 'Exit code: 137' +New = 'Exit code: [KILLED]' + +[[Repls]] +Old = '(\[PROCESS_KILLED\]\n\nExit code: )1' +New = '${1}[KILLED]' + +# On Windows a killed CLI leaves no bash "Killed" line. The kill lands right after the +# approval text, so its last line is followed directly by "Exit code: 1". +[[Repls]] +Old = '( recreate resources\.volumes\.volume1)\n\nExit code: 1' +New = "${1}\n[PROCESS_KILLED]\n\nExit code: [KILLED]" diff --git a/acceptance/bundle/resources/schemas/recreate_out_of_band/databricks.yml.tmpl b/acceptance/bundle/resources/schemas/recreate_out_of_band/databricks.yml.tmpl new file mode 100644 index 00000000000..2234fdf2e39 --- /dev/null +++ b/acceptance/bundle/resources/schemas/recreate_out_of_band/databricks.yml.tmpl @@ -0,0 +1,13 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + schemas: + schema1: + catalog_name: main + name: test-schema-$UNIQUE_NAME + volumes: + volume1: + catalog_name: main + schema_name: ${resources.schemas.schema1.name} + name: myvolume diff --git a/acceptance/bundle/resources/schemas/recreate_out_of_band/out.test.toml b/acceptance/bundle/resources/schemas/recreate_out_of_band/out.test.toml new file mode 100644 index 00000000000..0f167effef1 --- /dev/null +++ b/acceptance/bundle/resources/schemas/recreate_out_of_band/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/schemas/recreate_out_of_band/output.txt b/acceptance/bundle/resources/schemas/recreate_out_of_band/output.txt new file mode 100644 index 00000000000..f85cf6c5f62 --- /dev/null +++ b/acceptance/bundle/resources/schemas/recreate_out_of_band/output.txt @@ -0,0 +1,91 @@ + +=== Deploy a schema with a volume in it +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created schemas.schema1 +Created volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged + +=== Out of band, drop the schema and create another one under a new name, with a volume of the same name +>>> [CLI] schemas delete main.test-schema-[UNIQUE_NAME] --force + +=== With the config renamed to match, the plan finds a schema and a volume at the new names, but their ids differ, so it plans creates +>>> update_file.py databricks.yml test-schema- test-schema-renamed- + +>>> print_state.py +{ + "__id__": "main.test-schema-[UNIQUE_NAME]", + "schema_id": "[SCHEMA_ID]" +} + +>>> print_state.py +{ + "__id__": "main.test-schema-[UNIQUE_NAME].myvolume", + "volume_id": "[VOLUME_ID]" +} + +>>> [CLI] schemas get main.test-schema-renamed-[UNIQUE_NAME] +{ + "full_name": "main.test-schema-renamed-[UNIQUE_NAME]", + "schema_id": "[NEW_SCHEMA_ID]" +} + +>>> [CLI] volumes read main.test-schema-renamed-[UNIQUE_NAME].myvolume +{ + "full_name": "main.test-schema-renamed-[UNIQUE_NAME].myvolume", + "volume_id": "[NEW_VOLUME_ID]" +} + +>>> [CLI] bundle plan --output json +{ + "resources.schemas.schema1": { + "action": "create", + "new_state": { + "value": { + "catalog_name": "main", + "name": "test-schema-renamed-[UNIQUE_NAME]" + } + }, + "changes": { + "name": { + "action": "update_id", + "reason": "id_changes", + "old": "test-schema-[UNIQUE_NAME]", + "new": "test-schema-renamed-[UNIQUE_NAME]" + } + } + }, + "resources.volumes.volume1": { + "depends_on": [ + { + "node": "resources.schemas.schema1", + "label": "${resources.schemas.schema1.name}" + } + ], + "action": "create", + "new_state": { + "value": { + "catalog_name": "main", + "name": "myvolume", + "schema_name": "test-schema-renamed-[UNIQUE_NAME]", + "volume_type": "MANAGED" + } + }, + "changes": { + "schema_name": { + "action": "recreate", + "reason": "id_field", + "old": "test-schema-[UNIQUE_NAME]", + "new": "test-schema-renamed-[UNIQUE_NAME]" + } + } + } +} + +>>> [CLI] bundle destroy --auto-approve +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default + +Destroy: 0 deleted + +>>> [CLI] schemas delete main.test-schema-renamed-[UNIQUE_NAME] --force diff --git a/acceptance/bundle/resources/schemas/recreate_out_of_band/script b/acceptance/bundle/resources/schemas/recreate_out_of_band/script new file mode 100644 index 00000000000..cadca8544af --- /dev/null +++ b/acceptance/bundle/resources/schemas/recreate_out_of_band/script @@ -0,0 +1,39 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +OLD_SCHEMA="main.test-schema-${UNIQUE_NAME}" +NEW_SCHEMA="main.test-schema-renamed-${UNIQUE_NAME}" + +cleanup() { + trace $CLI bundle destroy --auto-approve + # The schema created out of band is not in the state, so destroy does not remove it. + trace $CLI schemas delete "$NEW_SCHEMA" --force + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +# Drop the remote_state fields that differ between clouds or from the test server. +plan() { + trace $CLI bundle plan --output json | jq .plan | normalize_uc_payload.py updated_at updated_by metastore_id effective_predictive_optimization_flag storage_location +} + +title "Deploy a schema with a volume in it" +trace $CLI bundle deploy +print_requests.py //unity-catalog &> LOG.deploy_requests +add_repl "$($CLI schemas get "$OLD_SCHEMA" | jq -r .schema_id)" SCHEMA_ID +add_repl "$($CLI volumes read "$OLD_SCHEMA.myvolume" | jq -r .volume_id)" VOLUME_ID + +title "Out of band, drop the schema and create another one under a new name, with a volume of the same name" +trace $CLI schemas delete "$OLD_SCHEMA" --force +add_repl "$($CLI schemas create "test-schema-renamed-${UNIQUE_NAME}" main | jq -r .schema_id)" NEW_SCHEMA_ID +add_repl "$($CLI volumes create main "test-schema-renamed-${UNIQUE_NAME}" myvolume MANAGED | jq -r .volume_id)" NEW_VOLUME_ID +print_requests.py //unity-catalog &> LOG.recreate_requests + +title "With the config renamed to match, the plan finds a schema and a volume at the new names, but their ids differ, so it plans creates" +trace update_file.py databricks.yml test-schema- test-schema-renamed- +trace print_state.py | jq '.state["resources.schemas.schema1"] | {__id__, schema_id: .state.schema_id}' +trace print_state.py | jq '.state["resources.volumes.volume1"] | {__id__, volume_id: .state.volume_id}' +trace $CLI schemas get "$NEW_SCHEMA" | jq '{full_name, schema_id}' +trace $CLI volumes read "$NEW_SCHEMA.myvolume" | jq '{full_name, volume_id}' +plan diff --git a/acceptance/bundle/resources/schemas/recreate_out_of_band/test.toml b/acceptance/bundle/resources/schemas/recreate_out_of_band/test.toml new file mode 100644 index 00000000000..4272a80cf61 --- /dev/null +++ b/acceptance/bundle/resources/schemas/recreate_out_of_band/test.toml @@ -0,0 +1,7 @@ +Cloud = true + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] diff --git a/acceptance/bundle/resources/schemas/rename/databricks.yml.tmpl b/acceptance/bundle/resources/schemas/rename/databricks.yml.tmpl new file mode 100644 index 00000000000..080c703a351 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename/databricks.yml.tmpl @@ -0,0 +1,9 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + schemas: + schema1: + catalog_name: main + name: test-schema-$UNIQUE_NAME + comment: COMMENT1 diff --git a/acceptance/bundle/resources/schemas/rename/out.test.toml b/acceptance/bundle/resources/schemas/rename/out.test.toml new file mode 100644 index 00000000000..0f167effef1 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/schemas/rename/output.txt b/acceptance/bundle/resources/schemas/rename/output.txt new file mode 100644 index 00000000000..275d84698ac --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename/output.txt @@ -0,0 +1,118 @@ + +=== Deploy the schema +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created schemas.schema1 +Files: 2 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/schemas", + "body": { + "catalog_name": "main", + "comment": "COMMENT1", + "name": "test-schema-[UNIQUE_NAME]" + } +} + +=== Rename the schema +>>> update_file.py databricks.yml test-schema- test-schema-renamed- + +=== The plan renames the schema in place instead of recreating it +>>> [CLI] bundle plan --output json +{ + "resources.schemas.schema1": { + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "main", + "comment": "COMMENT1", + "name": "test-schema-renamed-[UNIQUE_NAME]" + } + }, + "remote_state": { + "browse_only": false, + "catalog_name": "main", + "catalog_type": "MANAGED_CATALOG", + "comment": "COMMENT1", + "created_at": [UNIX_TIME_MILLIS], + "created_by": "[USERNAME]", + "enable_predictive_optimization": "INHERIT", + "full_name": "main.test-schema-[UNIQUE_NAME]", + "metastore_id": "[UUID]", + "name": "test-schema-[UNIQUE_NAME]", + "owner": "[USERNAME]", + "schema_id": "[SCHEMA_ID]" + }, + "changes": { + "name": { + "action": "update_id", + "reason": "id_changes", + "old": "test-schema-[UNIQUE_NAME]", + "new": "test-schema-renamed-[UNIQUE_NAME]", + "remote": "test-schema-[UNIQUE_NAME]" + } + } + } +} + +=== Deploy renames the schema without a deletion prompt +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated schemas.schema1 +Files: 1 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/schemas/main.test-schema-[UNIQUE_NAME]", + "body": { + "comment": "COMMENT1", + "custom_max_retention_hours": 0, + "new_name": "test-schema-renamed-[UNIQUE_NAME]" + } +} + +=== The schema is only reachable under the new name, with its original schema_id +>>> musterr [CLI] schemas get main.test-schema-[UNIQUE_NAME] +Error: Schema 'main.test-schema-[UNIQUE_NAME]' does not exist. + +>>> [CLI] schemas get main.test-schema-renamed-[UNIQUE_NAME] +{ + "full_name": "main.test-schema-renamed-[UNIQUE_NAME]", + "name": "test-schema-renamed-[UNIQUE_NAME]", + "comment": "COMMENT1", + "schema_id": "[SCHEMA_ID]" +} + +=== The state and summary track the new name +>>> [CLI] bundle summary --output json +{ + "id": "main.test-schema-renamed-[UNIQUE_NAME]", + "name": "test-schema-renamed-[UNIQUE_NAME]" +} + +=== Redeploy is a no-op +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 1 unchanged + +>>> print_requests.py //unity-catalog + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.schemas.schema1 + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.schema1 + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default + +Destroy: 1 deleted diff --git a/acceptance/bundle/resources/schemas/rename/script b/acceptance/bundle/resources/schemas/rename/script new file mode 100644 index 00000000000..c8617676747 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename/script @@ -0,0 +1,40 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +OLD_SCHEMA="main.test-schema-${UNIQUE_NAME}" +NEW_SCHEMA="main.test-schema-renamed-${UNIQUE_NAME}" + +cleanup() { + trace $CLI bundle destroy --auto-approve + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +title "Deploy the schema" +trace $CLI bundle deploy +trace print_requests.py //unity-catalog +add_repl "$($CLI schemas get "$OLD_SCHEMA" | jq -r .schema_id)" SCHEMA_ID + +title "Rename the schema" +trace update_file.py databricks.yml test-schema- test-schema-renamed- + +title "The plan renames the schema in place instead of recreating it" +# Drop the remote_state fields that differ between clouds or from the test server. +trace $CLI bundle plan --output json | jq .plan | normalize_uc_payload.py updated_at updated_by effective_predictive_optimization_flag + +title "Deploy renames the schema without a deletion prompt" +trace $CLI bundle deploy +trace print_requests.py //unity-catalog + +title "The schema is only reachable under the new name, with its original schema_id" +trace musterr $CLI schemas get "$OLD_SCHEMA" +trace $CLI schemas get "$NEW_SCHEMA" | jq '{full_name, name, comment, schema_id}' + +title "The state and summary track the new name" +trace $CLI bundle summary --output json | jq '.resources.schemas.schema1 | {id, name}' + +title "Redeploy is a no-op" +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests.py //unity-catalog diff --git a/acceptance/bundle/resources/schemas/rename/test.toml b/acceptance/bundle/resources/schemas/rename/test.toml new file mode 100644 index 00000000000..4272a80cf61 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename/test.toml @@ -0,0 +1,7 @@ +Cloud = true + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] diff --git a/acceptance/bundle/resources/schemas/rename_interrupted/databricks.yml.tmpl b/acceptance/bundle/resources/schemas/rename_interrupted/databricks.yml.tmpl new file mode 100644 index 00000000000..2964856d08e --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_interrupted/databricks.yml.tmpl @@ -0,0 +1,14 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + schemas: + schema1: + catalog_name: main + name: test-schema-$UNIQUE_NAME + volumes: + volume1: + catalog_name: main + schema_name: ${resources.schemas.schema1.name} + name: myvolume + comment: c1 diff --git a/acceptance/bundle/resources/schemas/rename_interrupted/out.test.toml b/acceptance/bundle/resources/schemas/rename_interrupted/out.test.toml new file mode 100644 index 00000000000..19b5b15d11f --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_interrupted/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = [""] diff --git a/acceptance/bundle/resources/schemas/rename_interrupted/output.txt b/acceptance/bundle/resources/schemas/rename_interrupted/output.txt new file mode 100644 index 00000000000..f2b97b7ae27 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_interrupted/output.txt @@ -0,0 +1,137 @@ + +=== Deploy a schema with a volume in it +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created schemas.schema1 +Created volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged + +=== Rename the schema and change the volume's comment. The CLI is killed on the volume's update, after the schema's rename is saved +>>> update_file.py databricks.yml test-schema- test-schema-renamed- + +>>> update_file.py databricks.yml comment: c1 comment: c2 + +>>> errcode [CLI] bundle deploy -q +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +[PROCESS_KILLED] + +Exit code: [KILLED] + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/schemas/main.test-schema-[UNIQUE_NAME]", + "body": { + "comment": "", + "custom_max_retention_hours": 0, + "new_name": "test-schema-renamed-[UNIQUE_NAME]" + } +} + +=== The volume is gone from its recorded path, so the plan looks for it at its path in config +>>> print_state.py +{ + "__id__": "main.test-schema-[UNIQUE_NAME].myvolume", + "volume_id": "[VOLUME_ID]" +} + +>>> [CLI] bundle plan --output json +{ + "id": "main.test-schema-renamed-[UNIQUE_NAME].myvolume", + "depends_on": [ + { + "node": "resources.schemas.schema1", + "label": "${resources.schemas.schema1.name}" + } + ], + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "main", + "comment": "c2", + "name": "myvolume", + "schema_name": "test-schema-renamed-[UNIQUE_NAME]", + "volume_type": "MANAGED" + } + }, + "remote_state": { + "catalog_name": "main", + "comment": "c1", + "created_at": [UNIX_TIME_MILLIS][0], + "created_by": "[USERNAME]", + "full_name": "main.test-schema-renamed-[UNIQUE_NAME].myvolume", + "name": "myvolume", + "owner": "[USERNAME]", + "schema_name": "test-schema-renamed-[UNIQUE_NAME]", + "storage_location": "s3://[METASTORE_NAME]/metastore/[UUID]/volumes/[VOLUME_ID]", + "updated_at": [UNIX_TIME_MILLIS][0], + "volume_id": "[VOLUME_ID]", + "volume_type": "MANAGED" + }, + "changes": { + "comment": { + "action": "update", + "old": "c1", + "new": "c2", + "remote": "c1" + }, + "schema_name": { + "action": "skip", + "reason": "remote_already_set", + "old": "test-schema-[UNIQUE_NAME]", + "new": "test-schema-renamed-[UNIQUE_NAME]", + "remote": "test-schema-renamed-[UNIQUE_NAME]" + }, + "storage_location": { + "action": "skip", + "reason": "immutable_backend_value", + "remote": "s3://[METASTORE_NAME]/metastore/[UUID]/volumes/[VOLUME_ID]" + } + } +} + +=== The next deploy updates the volume at its new path +>>> [CLI] bundle deploy --force-lock +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated volumes.volume1 +Files: 0 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 1 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/volumes/main.test-schema-renamed-[UNIQUE_NAME].myvolume", + "body": { + "comment": "c2" + } +} + +>>> [CLI] volumes read main.test-schema-renamed-[UNIQUE_NAME].myvolume +{ + "full_name": "main.test-schema-renamed-[UNIQUE_NAME].myvolume", + "comment": "c2", + "volume_id": "[VOLUME_ID]" +} + +=== Redeploy is a no-op +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle destroy --auto-approve --force-lock +The following resources will be deleted: + delete resources.schemas.schema1 + delete resources.volumes.volume1 + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.schema1 + +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 + +Destroy: 2 deleted diff --git a/acceptance/bundle/resources/schemas/rename_interrupted/script b/acceptance/bundle/resources/schemas/rename_interrupted/script new file mode 100644 index 00000000000..76ee184840a --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_interrupted/script @@ -0,0 +1,36 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +NEW_VOLUME="main.test-schema-renamed-${UNIQUE_NAME}.myvolume" + +cleanup() { + trace $CLI bundle destroy --auto-approve --force-lock + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +title "Deploy a schema with a volume in it" +trace $CLI bundle deploy +print_requests.py //unity-catalog &> LOG.deploy_requests +add_repl "$($CLI volumes read "main.test-schema-${UNIQUE_NAME}.myvolume" | jq -r .volume_id)" VOLUME_ID + +title "Rename the schema and change the volume's comment. The CLI is killed on the volume's update, after the schema's rename is saved" +trace update_file.py databricks.yml test-schema- test-schema-renamed- +trace update_file.py databricks.yml "comment: c1" "comment: c2" +kill_after.py "PATCH /api/2.1/unity-catalog/volumes/$NEW_VOLUME" 0 1 +# -q: the per-resource lines race the kill signal. +trace errcode $CLI bundle deploy -q +trace print_requests.py //unity-catalog + +title "The volume is gone from its recorded path, so the plan looks for it at its path in config" +trace print_state.py | jq '.state["resources.volumes.volume1"] | {__id__, volume_id: .state.volume_id}' +trace $CLI bundle plan --output json | jq '.plan["resources.volumes.volume1"]' + +title "The next deploy updates the volume at its new path" +trace $CLI bundle deploy --force-lock +trace print_requests.py //unity-catalog +trace $CLI volumes read "$NEW_VOLUME" | jq '{full_name, comment, volume_id}' + +title "Redeploy is a no-op" +trace $CLI bundle plan diff --git a/acceptance/bundle/resources/schemas/rename_interrupted/test.toml b/acceptance/bundle/resources/schemas/rename_interrupted/test.toml new file mode 100644 index 00000000000..443e541f555 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_interrupted/test.toml @@ -0,0 +1,35 @@ +# Local-only: the test server kills the CLI mid-deploy (kill_after.py), which a real workspace cannot. +Cloud = false + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] + +# KillCaller needs the CLI's pid. A kill leaves no state in DMS to recover from, so DMS is not run. +Env.DATABRICKS_CLI_TEST_PID = "1" +EnvMatrix.DMS = [""] + +# Normalize how a killed process shows up on each OS, as in bundle/deploy/wal. +[[Repls]] +Old = 'script: line \d+:\s+\d+ Killed(: 9)?\s+"\$@"' +New = '[PROCESS_KILLED]' + +[[Repls]] +Old = '(\n>>> errcode [^\n]+\n)\nExit code:' +New = "${1}[PROCESS_KILLED]\n\nExit code:" + +[[Repls]] +Old = 'Exit code: 137' +New = 'Exit code: [KILLED]' + +[[Repls]] +Old = '(\[PROCESS_KILLED\]\n\nExit code: )1' +New = '${1}[KILLED]' + +# On Windows a killed CLI leaves no bash "Killed" line. The kill lands right after the +# "Uploading bundle files" line, so it is followed directly by "Exit code: 1". +[[Repls]] +Old = '(Uploading bundle files to [^\n]+)\n\nExit code: 1' +New = "${1}\n[PROCESS_KILLED]\n\nExit code: [KILLED]" diff --git a/acceptance/bundle/resources/schemas/rename_out_of_band/databricks.yml.tmpl b/acceptance/bundle/resources/schemas/rename_out_of_band/databricks.yml.tmpl new file mode 100644 index 00000000000..2234fdf2e39 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_out_of_band/databricks.yml.tmpl @@ -0,0 +1,13 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + schemas: + schema1: + catalog_name: main + name: test-schema-$UNIQUE_NAME + volumes: + volume1: + catalog_name: main + schema_name: ${resources.schemas.schema1.name} + name: myvolume diff --git a/acceptance/bundle/resources/schemas/rename_out_of_band/out.test.toml b/acceptance/bundle/resources/schemas/rename_out_of_band/out.test.toml new file mode 100644 index 00000000000..0f167effef1 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_out_of_band/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/schemas/rename_out_of_band/output.txt b/acceptance/bundle/resources/schemas/rename_out_of_band/output.txt new file mode 100644 index 00000000000..0b951a5e912 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_out_of_band/output.txt @@ -0,0 +1,152 @@ + +=== Deploy a schema with a volume in it +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created schemas.schema1 +Created volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged + +=== Rename the schema out of band. UC moves the volume with it +>>> [CLI] schemas update main.test-schema-[UNIQUE_NAME] --new-name test-schema-renamed-[UNIQUE_NAME] +{ + "full_name": "main.test-schema-renamed-[UNIQUE_NAME]", + "schema_id": "[SCHEMA_ID]" +} + +=== With the config unchanged, the plan creates the schema and the volume again under their configured names +>>> [CLI] bundle plan +create schemas.schema1 +create volumes.volume1 + +Plan: 2 to add, 0 to change, 0 to delete, 0 unchanged + +=== With the config renamed to match, the plan finds both at their new names, with the same schema_id and volume_id +>>> update_file.py databricks.yml test-schema- test-schema-renamed- + +>>> [CLI] bundle plan --output json +{ + "resources.schemas.schema1": { + "id": "main.test-schema-renamed-[UNIQUE_NAME]", + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "main", + "name": "test-schema-renamed-[UNIQUE_NAME]" + } + }, + "remote_state": { + "browse_only": false, + "catalog_name": "main", + "catalog_type": "MANAGED_CATALOG", + "created_at": [UNIX_TIME_MILLIS][0], + "created_by": "[USERNAME]", + "enable_predictive_optimization": "INHERIT", + "full_name": "main.test-schema-renamed-[UNIQUE_NAME]", + "name": "test-schema-renamed-[UNIQUE_NAME]", + "owner": "[USERNAME]", + "schema_id": "[SCHEMA_ID]" + }, + "changes": { + "name": { + "action": "skip", + "reason": "remote_already_set", + "old": "test-schema-[UNIQUE_NAME]", + "new": "test-schema-renamed-[UNIQUE_NAME]", + "remote": "test-schema-renamed-[UNIQUE_NAME]" + } + } + }, + "resources.volumes.volume1": { + "id": "main.test-schema-renamed-[UNIQUE_NAME].myvolume", + "depends_on": [ + { + "node": "resources.schemas.schema1", + "label": "${resources.schemas.schema1.name}" + } + ], + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "main", + "name": "myvolume", + "schema_name": "test-schema-renamed-[UNIQUE_NAME]", + "volume_type": "MANAGED" + } + }, + "remote_state": { + "catalog_name": "main", + "created_at": [UNIX_TIME_MILLIS][1], + "created_by": "[USERNAME]", + "full_name": "main.test-schema-renamed-[UNIQUE_NAME].myvolume", + "name": "myvolume", + "owner": "[USERNAME]", + "schema_name": "test-schema-renamed-[UNIQUE_NAME]", + "volume_id": "[VOLUME_ID]", + "volume_type": "MANAGED" + }, + "changes": { + "schema_name": { + "action": "skip", + "reason": "remote_already_set", + "old": "test-schema-[UNIQUE_NAME]", + "new": "test-schema-renamed-[UNIQUE_NAME]", + "remote": "test-schema-renamed-[UNIQUE_NAME]" + } + } + } +} + +=== Deploy only records the new names in the state +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated schemas.schema1 +Updated volumes.volume1 +Files: 3 uploaded, 0 deleted +Resources: 0 created, 2 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity-catalog + +>>> [CLI] schemas get main.test-schema-renamed-[UNIQUE_NAME] +{ + "full_name": "main.test-schema-renamed-[UNIQUE_NAME]", + "schema_id": "[SCHEMA_ID]" +} + +>>> [CLI] volumes read main.test-schema-renamed-[UNIQUE_NAME].myvolume +{ + "full_name": "main.test-schema-renamed-[UNIQUE_NAME].myvolume", + "volume_id": "[VOLUME_ID]" +} + +>>> [CLI] bundle summary --output json +{ + "schemas": { + "schema1": "main.test-schema-renamed-[UNIQUE_NAME]" + }, + "volumes": { + "volume1": "main.test-schema-renamed-[UNIQUE_NAME].myvolume" + } +} + +=== Redeploy is a no-op +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.schemas.schema1 + delete resources.volumes.volume1 + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.schema1 + +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 + +Destroy: 2 deleted diff --git a/acceptance/bundle/resources/schemas/rename_out_of_band/script b/acceptance/bundle/resources/schemas/rename_out_of_band/script new file mode 100644 index 00000000000..48f43a5ebc4 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_out_of_band/script @@ -0,0 +1,44 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +OLD_SCHEMA="main.test-schema-${UNIQUE_NAME}" +NEW_SCHEMA="main.test-schema-renamed-${UNIQUE_NAME}" + +cleanup() { + trace $CLI bundle destroy --auto-approve + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +# Drop the remote_state fields that differ between clouds or from the test server. +plan() { + trace $CLI bundle plan --output json | jq .plan | normalize_uc_payload.py updated_at updated_by metastore_id effective_predictive_optimization_flag storage_location +} + +title "Deploy a schema with a volume in it" +trace $CLI bundle deploy +print_requests.py //unity-catalog &> LOG.deploy_requests +add_repl "$($CLI schemas get "$OLD_SCHEMA" | jq -r .schema_id)" SCHEMA_ID +add_repl "$($CLI volumes read "$OLD_SCHEMA.myvolume" | jq -r .volume_id)" VOLUME_ID + +title "Rename the schema out of band. UC moves the volume with it" +trace $CLI schemas update "$OLD_SCHEMA" --new-name "test-schema-renamed-${UNIQUE_NAME}" | jq '{full_name, schema_id}' +print_requests.py //unity-catalog &> LOG.rename_requests + +title "With the config unchanged, the plan creates the schema and the volume again under their configured names" +trace $CLI bundle plan + +title "With the config renamed to match, the plan finds both at their new names, with the same schema_id and volume_id" +trace update_file.py databricks.yml test-schema- test-schema-renamed- +plan + +title "Deploy only records the new names in the state" +trace $CLI bundle deploy +trace print_requests.py //unity-catalog +trace $CLI schemas get "$NEW_SCHEMA" | jq '{full_name, schema_id}' +trace $CLI volumes read "$NEW_SCHEMA.myvolume" | jq '{full_name, volume_id}' +trace $CLI bundle summary --output json | jq '.resources | map_values(map_values(.id))' + +title "Redeploy is a no-op" +trace $CLI bundle plan diff --git a/acceptance/bundle/resources/schemas/rename_out_of_band/test.toml b/acceptance/bundle/resources/schemas/rename_out_of_band/test.toml new file mode 100644 index 00000000000..4272a80cf61 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_out_of_band/test.toml @@ -0,0 +1,7 @@ +Cloud = true + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] diff --git a/acceptance/bundle/resources/schemas/rename_then_change/databricks.yml.tmpl b/acceptance/bundle/resources/schemas/rename_then_change/databricks.yml.tmpl new file mode 100644 index 00000000000..663acec3be0 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_then_change/databricks.yml.tmpl @@ -0,0 +1,17 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + schemas: + schema1: + catalog_name: main + name: test-schema1-$UNIQUE_NAME + schema2: + catalog_name: main + name: test-schema2-$UNIQUE_NAME + volumes: + volume1: + catalog_name: main + schema_name: ${resources.schemas.schema1.name} + name: myvolume + comment: c1 diff --git a/acceptance/bundle/resources/schemas/rename_then_change/out.test.toml b/acceptance/bundle/resources/schemas/rename_then_change/out.test.toml new file mode 100644 index 00000000000..0f167effef1 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_then_change/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/schemas/rename_then_change/output.txt b/acceptance/bundle/resources/schemas/rename_then_change/output.txt new file mode 100644 index 00000000000..ed430978177 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_then_change/output.txt @@ -0,0 +1,276 @@ + +=== Deploy two schemas and a volume in the first one +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created schemas.schema1 +Created schemas.schema2 +Created volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 3 created, 0 changed, 0 deleted, 0 unchanged + +>>> print_requests.py --sort //unity-catalog +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/schemas", + "body": { + "catalog_name": "main", + "name": "test-schema1-[UNIQUE_NAME]" + } +} +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/schemas", + "body": { + "catalog_name": "main", + "name": "test-schema2-[UNIQUE_NAME]" + } +} +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/volumes", + "body": { + "catalog_name": "main", + "comment": "c1", + "name": "myvolume", + "schema_name": "test-schema1-[UNIQUE_NAME]", + "volume_type": "MANAGED" + } +} + +=== Rename the volume's schema: the volume is re-keyed without a request +>>> update_file.py databricks.yml test-schema1- test-schema1-renamed- + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated schemas.schema1 +Updated volumes.volume1 +Files: 1 uploaded, 0 deleted +Resources: 0 created, 2 changed, 0 deleted, 1 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/schemas/main.test-schema1-[UNIQUE_NAME]", + "body": { + "comment": "", + "custom_max_retention_hours": 0, + "new_name": "test-schema1-renamed-[UNIQUE_NAME]" + } +} + +=== After the rename, update, update_id and recreate of the volume all reach it at its new path +>>> update_file.py databricks.yml comment: c1 comment: c2 + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated volumes.volume1 +Files: 1 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 2 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/volumes/main.test-schema1-renamed-[UNIQUE_NAME].myvolume", + "body": { + "comment": "c2" + } +} + +>>> update_file.py databricks.yml name: myvolume name: myvolume2 + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated volumes.volume1 +Files: 1 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 2 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/volumes/main.test-schema1-renamed-[UNIQUE_NAME].myvolume", + "body": { + "comment": "c2", + "new_name": "myvolume2" + } +} + +>>> update_file.py databricks.yml schemas.schema1.name schemas.schema2.name + +>>> [CLI] bundle deploy --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... + +This action will result in the deletion or recreation 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: + recreate resources.volumes.volume1 +Recreated volumes.volume1 +Files: 1 uploaded, 0 deleted +Resources: 1 created, 0 changed, 1 deleted, 2 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/volumes/main.test-schema1-renamed-[UNIQUE_NAME].myvolume2" +} +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/volumes", + "body": { + "catalog_name": "main", + "comment": "c2", + "name": "myvolume2", + "schema_name": "test-schema2-[UNIQUE_NAME]", + "volume_type": "MANAGED" + } +} + +=== Rename the volume's new schema: the volume is re-keyed again +>>> update_file.py databricks.yml test-schema2- test-schema2-renamed- + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated schemas.schema2 +Updated volumes.volume1 +Files: 1 uploaded, 0 deleted +Resources: 0 created, 2 changed, 0 deleted, 1 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/schemas/main.test-schema2-[UNIQUE_NAME]", + "body": { + "comment": "", + "custom_max_retention_hours": 0, + "new_name": "test-schema2-renamed-[UNIQUE_NAME]" + } +} + +=== Update, update_id and recreate work again, moving the volume back to the first schema +>>> update_file.py databricks.yml comment: c2 comment: c3 + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated volumes.volume1 +Files: 1 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 2 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/volumes/main.test-schema2-renamed-[UNIQUE_NAME].myvolume2", + "body": { + "comment": "c3" + } +} + +>>> update_file.py databricks.yml name: myvolume2 name: myvolume + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated volumes.volume1 +Files: 1 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 2 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/volumes/main.test-schema2-renamed-[UNIQUE_NAME].myvolume2", + "body": { + "comment": "c3", + "new_name": "myvolume" + } +} + +>>> update_file.py databricks.yml schemas.schema2.name schemas.schema1.name + +>>> [CLI] bundle deploy --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... + +This action will result in the deletion or recreation 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: + recreate resources.volumes.volume1 +Recreated volumes.volume1 +Files: 1 uploaded, 0 deleted +Resources: 1 created, 0 changed, 1 deleted, 2 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/volumes/main.test-schema2-renamed-[UNIQUE_NAME].myvolume" +} +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/volumes", + "body": { + "catalog_name": "main", + "comment": "c3", + "name": "myvolume", + "schema_name": "test-schema1-renamed-[UNIQUE_NAME]", + "volume_type": "MANAGED" + } +} + +=== Rename the first schema back: the volume is re-keyed to its original path +>>> update_file.py databricks.yml test-schema1-renamed- test-schema1- + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated schemas.schema1 +Updated volumes.volume1 +Files: 1 uploaded, 0 deleted +Resources: 0 created, 2 changed, 0 deleted, 1 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/schemas/main.test-schema1-renamed-[UNIQUE_NAME]", + "body": { + "comment": "", + "custom_max_retention_hours": 0, + "new_name": "test-schema1-[UNIQUE_NAME]" + } +} + +=== The state tracks the current paths and the plan is a no-op +>>> [CLI] bundle summary --output json +{ + "schemas": { + "schema1": "main.test-schema1-[UNIQUE_NAME]", + "schema2": "main.test-schema2-renamed-[UNIQUE_NAME]" + }, + "volumes": { + "volume1": "main.test-schema1-[UNIQUE_NAME].myvolume" + } +} + +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 3 unchanged + +>>> [CLI] volumes read main.test-schema1-[UNIQUE_NAME].myvolume +{ + "full_name": "main.test-schema1-[UNIQUE_NAME].myvolume", + "comment": "c3" +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.schemas.schema1 + delete resources.schemas.schema2 + delete resources.volumes.volume1 + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.schema1 + delete resources.schemas.schema2 + +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 + +Destroy: 3 deleted diff --git a/acceptance/bundle/resources/schemas/rename_then_change/script b/acceptance/bundle/resources/schemas/rename_then_change/script new file mode 100644 index 00000000000..452af92148c --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_then_change/script @@ -0,0 +1,52 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +deploy() { + trace $CLI bundle deploy "$@" + trace print_requests.py //unity-catalog +} + +title "Deploy two schemas and a volume in the first one" +trace $CLI bundle deploy +# The two schemas are created in parallel. +trace print_requests.py --sort //unity-catalog + +title "Rename the volume's schema: the volume is re-keyed without a request" +trace update_file.py databricks.yml test-schema1- test-schema1-renamed- +deploy + +title "After the rename, update, update_id and recreate of the volume all reach it at its new path" +trace update_file.py databricks.yml "comment: c1" "comment: c2" +deploy +trace update_file.py databricks.yml "name: myvolume" "name: myvolume2" +deploy +trace update_file.py databricks.yml schemas.schema1.name schemas.schema2.name +deploy --auto-approve + +title "Rename the volume's new schema: the volume is re-keyed again" +trace update_file.py databricks.yml test-schema2- test-schema2-renamed- +deploy + +title "Update, update_id and recreate work again, moving the volume back to the first schema" +trace update_file.py databricks.yml "comment: c2" "comment: c3" +deploy +trace update_file.py databricks.yml "name: myvolume2" "name: myvolume" +deploy +trace update_file.py databricks.yml schemas.schema2.name schemas.schema1.name +deploy --auto-approve + +title "Rename the first schema back: the volume is re-keyed to its original path" +trace update_file.py databricks.yml test-schema1-renamed- test-schema1- +deploy + +title "The state tracks the current paths and the plan is a no-op" +trace $CLI bundle summary --output json | jq '.resources | map_values(map_values(.id))' +trace $CLI bundle plan +trace $CLI volumes read "main.test-schema1-${UNIQUE_NAME}.myvolume" | jq '{full_name, comment}' diff --git a/acceptance/bundle/resources/schemas/rename_then_change/test.toml b/acceptance/bundle/resources/schemas/rename_then_change/test.toml new file mode 100644 index 00000000000..4272a80cf61 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_then_change/test.toml @@ -0,0 +1,7 @@ +Cloud = true + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] diff --git a/acceptance/bundle/resources/schemas/rename_with_volume/databricks.yml.tmpl b/acceptance/bundle/resources/schemas/rename_with_volume/databricks.yml.tmpl new file mode 100644 index 00000000000..2234fdf2e39 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_with_volume/databricks.yml.tmpl @@ -0,0 +1,13 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + schemas: + schema1: + catalog_name: main + name: test-schema-$UNIQUE_NAME + volumes: + volume1: + catalog_name: main + schema_name: ${resources.schemas.schema1.name} + name: myvolume diff --git a/acceptance/bundle/resources/schemas/rename_with_volume/out.test.toml b/acceptance/bundle/resources/schemas/rename_with_volume/out.test.toml new file mode 100644 index 00000000000..0f167effef1 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_with_volume/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/schemas/rename_with_volume/output.txt b/acceptance/bundle/resources/schemas/rename_with_volume/output.txt new file mode 100644 index 00000000000..37458a8f7bf --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_with_volume/output.txt @@ -0,0 +1,166 @@ + +=== Deploy a schema with a volume in it +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created schemas.schema1 +Created volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/schemas", + "body": { + "catalog_name": "main", + "name": "test-schema-[UNIQUE_NAME]" + } +} +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/volumes", + "body": { + "catalog_name": "main", + "name": "myvolume", + "schema_name": "test-schema-[UNIQUE_NAME]", + "volume_type": "MANAGED" + } +} + +=== Rename the schema +>>> update_file.py databricks.yml test-schema- test-schema-renamed- + +=== UC moves the volume with the schema, so the plan re-keys the volume instead of recreating it +>>> [CLI] bundle plan --output json +{ + "resources.schemas.schema1": { + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "main", + "name": "test-schema-renamed-[UNIQUE_NAME]" + } + }, + "remote_state": { + "browse_only": false, + "catalog_name": "main", + "catalog_type": "MANAGED_CATALOG", + "created_at": [UNIX_TIME_MILLIS][0], + "created_by": "[USERNAME]", + "enable_predictive_optimization": "INHERIT", + "full_name": "main.test-schema-[UNIQUE_NAME]", + "name": "test-schema-[UNIQUE_NAME]", + "owner": "[USERNAME]", + "schema_id": "[UUID]" + }, + "changes": { + "name": { + "action": "update_id", + "reason": "id_changes", + "old": "test-schema-[UNIQUE_NAME]", + "new": "test-schema-renamed-[UNIQUE_NAME]", + "remote": "test-schema-[UNIQUE_NAME]" + } + } + }, + "resources.volumes.volume1": { + "depends_on": [ + { + "node": "resources.schemas.schema1", + "label": "${resources.schemas.schema1.name}" + } + ], + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "main", + "name": "myvolume", + "schema_name": "test-schema-renamed-[UNIQUE_NAME]", + "volume_type": "MANAGED" + } + }, + "remote_state": { + "catalog_name": "main", + "created_at": [UNIX_TIME_MILLIS][1], + "created_by": "[USERNAME]", + "full_name": "main.test-schema-[UNIQUE_NAME].myvolume", + "name": "myvolume", + "owner": "[USERNAME]", + "schema_name": "test-schema-[UNIQUE_NAME]", + "volume_id": "[VOLUME_ID]", + "volume_type": "MANAGED" + }, + "changes": { + "schema_name": { + "action": "update_id", + "reason": "parent_renamed", + "old": "test-schema-[UNIQUE_NAME]", + "new": "test-schema-renamed-[UNIQUE_NAME]", + "remote": "test-schema-[UNIQUE_NAME]" + } + } + } +} + +=== Deploy renames the schema. UC moves the volume with it, so the volume gets no request +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated schemas.schema1 +Updated volumes.volume1 +Files: 1 uploaded, 0 deleted +Resources: 0 created, 2 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/schemas/main.test-schema-[UNIQUE_NAME]", + "body": { + "comment": "", + "custom_max_retention_hours": 0, + "new_name": "test-schema-renamed-[UNIQUE_NAME]" + } +} + +=== The old schema is gone and the volume is under the new one, with its original volume_id +>>> musterr [CLI] schemas get main.test-schema-[UNIQUE_NAME] +Error: Schema 'main.test-schema-[UNIQUE_NAME]' does not exist. + +>>> [CLI] volumes read main.test-schema-renamed-[UNIQUE_NAME].myvolume +{ + "full_name": "main.test-schema-renamed-[UNIQUE_NAME].myvolume", + "schema_name": "test-schema-renamed-[UNIQUE_NAME]", + "volume_id": "[VOLUME_ID]" +} + +=== The state tracks the new path +>>> [CLI] bundle summary --output json +"main.test-schema-renamed-[UNIQUE_NAME].myvolume" + +=== Redeploy is a no-op +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 2 unchanged + +>>> print_requests.py //unity-catalog + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.schemas.schema1 + delete resources.volumes.volume1 + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.schema1 + +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 + +Destroy: 2 deleted diff --git a/acceptance/bundle/resources/schemas/rename_with_volume/script b/acceptance/bundle/resources/schemas/rename_with_volume/script new file mode 100644 index 00000000000..fd5830bf18a --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_with_volume/script @@ -0,0 +1,41 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +OLD_SCHEMA="main.test-schema-${UNIQUE_NAME}" +OLD_VOLUME="$OLD_SCHEMA.myvolume" +NEW_VOLUME="main.test-schema-renamed-${UNIQUE_NAME}.myvolume" + +cleanup() { + trace $CLI bundle destroy --auto-approve + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +title "Deploy a schema with a volume in it" +trace $CLI bundle deploy +trace print_requests.py //unity-catalog +add_repl "$($CLI volumes read "$OLD_VOLUME" | jq -r .volume_id)" VOLUME_ID + +title "Rename the schema" +trace update_file.py databricks.yml test-schema- test-schema-renamed- + +title "UC moves the volume with the schema, so the plan re-keys the volume instead of recreating it" +# Drop the remote_state fields that differ between clouds or from the test server. +trace $CLI bundle plan --output json | jq .plan | normalize_uc_payload.py updated_at updated_by metastore_id effective_predictive_optimization_flag storage_location + +title "Deploy renames the schema. UC moves the volume with it, so the volume gets no request" +trace $CLI bundle deploy +trace print_requests.py //unity-catalog + +title "The old schema is gone and the volume is under the new one, with its original volume_id" +trace musterr $CLI schemas get "$OLD_SCHEMA" +trace $CLI volumes read "$NEW_VOLUME" | jq '{full_name, schema_name, volume_id}' + +title "The state tracks the new path" +trace $CLI bundle summary --output json | jq '.resources.volumes.volume1.id' + +title "Redeploy is a no-op" +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests.py //unity-catalog diff --git a/acceptance/bundle/resources/schemas/rename_with_volume/test.toml b/acceptance/bundle/resources/schemas/rename_with_volume/test.toml new file mode 100644 index 00000000000..4272a80cf61 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_with_volume/test.toml @@ -0,0 +1,7 @@ +Cloud = true + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] diff --git a/acceptance/bundle/resources/schemas/rename_with_volume_ref/databricks.yml.tmpl b/acceptance/bundle/resources/schemas/rename_with_volume_ref/databricks.yml.tmpl new file mode 100644 index 00000000000..bddda4baff0 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_with_volume_ref/databricks.yml.tmpl @@ -0,0 +1,23 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + schemas: + schema1: + catalog_name: main + name: test-schema-$UNIQUE_NAME + volumes: + volume1: + catalog_name: main + schema_name: ${resources.schemas.schema1.name} + name: myvolume + jobs: + job1: + name: test-job-$UNIQUE_NAME + parameters: + - name: volume_full_name + default: ${resources.volumes.volume1.full_name} + - name: volume_id + default: ${resources.volumes.volume1.id} + - name: volume_uuid + default: ${resources.volumes.volume1.volume_id} diff --git a/acceptance/bundle/resources/schemas/rename_with_volume_ref/out.test.toml b/acceptance/bundle/resources/schemas/rename_with_volume_ref/out.test.toml new file mode 100644 index 00000000000..0f167effef1 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_with_volume_ref/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/schemas/rename_with_volume_ref/output.txt b/acceptance/bundle/resources/schemas/rename_with_volume_ref/output.txt new file mode 100644 index 00000000000..ceb77a34e65 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_with_volume_ref/output.txt @@ -0,0 +1,95 @@ + +=== Deploy a schema, a volume in it and a job that reads the volume's full_name and id +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created jobs.job1 +Created schemas.schema1 +Created volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 3 created, 0 changed, 0 deleted, 0 unchanged + +>>> [CLI] jobs get [JOB_ID] +[ + { + "default": "main.test-schema-[UNIQUE_NAME].myvolume", + "name": "volume_full_name" + }, + { + "default": "main.test-schema-[UNIQUE_NAME].myvolume", + "name": "volume_id" + }, + { + "default": "[VOLUME_ID]", + "name": "volume_uuid" + } +] + +=== Rename the schema +>>> update_file.py databricks.yml test-schema- test-schema-renamed- + +=== The volume's full_name and id change with the schema, so the job is updated too +>>> [CLI] bundle plan +update jobs.job1 +update schemas.schema1 +update volumes.volume1 + +Plan: 0 to add, 3 to change, 0 to delete, 0 unchanged + +=== Deploy passes the volume's new full_name and id to the job +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated jobs.job1 +Updated schemas.schema1 +Updated volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 0 created, 3 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity-catalog +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/schemas/main.test-schema-[UNIQUE_NAME]", + "body": { + "comment": "", + "custom_max_retention_hours": 0, + "new_name": "test-schema-renamed-[UNIQUE_NAME]" + } +} + +>>> [CLI] jobs get [JOB_ID] +[ + { + "default": "main.test-schema-renamed-[UNIQUE_NAME].myvolume", + "name": "volume_full_name" + }, + { + "default": "main.test-schema-renamed-[UNIQUE_NAME].myvolume", + "name": "volume_id" + }, + { + "default": "[VOLUME_ID]", + "name": "volume_uuid" + } +] + +=== Redeploy is a no-op +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 3 unchanged + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.jobs.job1 + delete resources.schemas.schema1 + delete resources.volumes.volume1 + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.schema1 + +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 + +Destroy: 3 deleted diff --git a/acceptance/bundle/resources/schemas/rename_with_volume_ref/script b/acceptance/bundle/resources/schemas/rename_with_volume_ref/script new file mode 100644 index 00000000000..6418f29011d --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_with_volume_ref/script @@ -0,0 +1,31 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +title "Deploy a schema, a volume in it and a job that reads the volume's full_name and id" +trace $CLI bundle deploy +print_requests.py //unity-catalog &> LOG.deploy_requests +JOB_ID=$($CLI bundle summary --output json | jq -r .resources.jobs.job1.id) +add_repl "$JOB_ID" JOB_ID +add_repl "$($CLI volumes read "main.test-schema-${UNIQUE_NAME}.myvolume" | jq -r .volume_id)" VOLUME_ID +trace $CLI jobs get "$JOB_ID" | jq .settings.parameters + +title "Rename the schema" +trace update_file.py databricks.yml test-schema- test-schema-renamed- + +title "The volume's full_name and id change with the schema, so the job is updated too" +trace $CLI bundle plan + +title "Deploy passes the volume's new full_name and id to the job" +trace $CLI bundle deploy +trace print_requests.py //unity-catalog +trace $CLI jobs get "$JOB_ID" | jq .settings.parameters + +title "Redeploy is a no-op" +trace $CLI bundle plan diff --git a/acceptance/bundle/resources/schemas/rename_with_volume_ref/test.toml b/acceptance/bundle/resources/schemas/rename_with_volume_ref/test.toml new file mode 100644 index 00000000000..4272a80cf61 --- /dev/null +++ b/acceptance/bundle/resources/schemas/rename_with_volume_ref/test.toml @@ -0,0 +1,7 @@ +Cloud = true + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] diff --git a/acceptance/bundle/resources/volumes/recreate_out_of_band/databricks.yml.tmpl b/acceptance/bundle/resources/volumes/recreate_out_of_band/databricks.yml.tmpl new file mode 100644 index 00000000000..2234fdf2e39 --- /dev/null +++ b/acceptance/bundle/resources/volumes/recreate_out_of_band/databricks.yml.tmpl @@ -0,0 +1,13 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + schemas: + schema1: + catalog_name: main + name: test-schema-$UNIQUE_NAME + volumes: + volume1: + catalog_name: main + schema_name: ${resources.schemas.schema1.name} + name: myvolume diff --git a/acceptance/bundle/resources/volumes/recreate_out_of_band/out.test.toml b/acceptance/bundle/resources/volumes/recreate_out_of_band/out.test.toml new file mode 100644 index 00000000000..0f167effef1 --- /dev/null +++ b/acceptance/bundle/resources/volumes/recreate_out_of_band/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/volumes/recreate_out_of_band/output.txt b/acceptance/bundle/resources/volumes/recreate_out_of_band/output.txt new file mode 100644 index 00000000000..22819d47ef8 --- /dev/null +++ b/acceptance/bundle/resources/volumes/recreate_out_of_band/output.txt @@ -0,0 +1,81 @@ + +=== Deploy a schema with a volume in it +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created schemas.schema1 +Created volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged + +=== Out of band, delete the volume and create another one under a new name +>>> [CLI] volumes delete main.test-schema-[UNIQUE_NAME].myvolume + +=== With the config renamed to match, the plan finds a volume at the new name, but its volume_id differs, so it plans a create +>>> update_file.py databricks.yml name: myvolume name: myvolume2 + +>>> print_state.py +{ + "__id__": "main.test-schema-[UNIQUE_NAME].myvolume", + "volume_id": "[VOLUME_ID]" +} + +>>> [CLI] volumes read main.test-schema-[UNIQUE_NAME].myvolume2 +{ + "full_name": "main.test-schema-[UNIQUE_NAME].myvolume2", + "volume_id": "[NEW_VOLUME_ID]" +} + +>>> [CLI] bundle plan --output json +{ + "resources.schemas.schema1": { + "action": "skip", + "remote_state": { + "browse_only": false, + "catalog_name": "main", + "catalog_type": "MANAGED_CATALOG", + "created_at": [UNIX_TIME_MILLIS], + "created_by": "[USERNAME]", + "enable_predictive_optimization": "INHERIT", + "full_name": "main.test-schema-[UNIQUE_NAME]", + "name": "test-schema-[UNIQUE_NAME]", + "owner": "[USERNAME]", + "schema_id": "[UUID]" + } + }, + "resources.volumes.volume1": { + "depends_on": [ + { + "node": "resources.schemas.schema1", + "label": "${resources.schemas.schema1.name}" + } + ], + "action": "create", + "new_state": { + "value": { + "catalog_name": "main", + "name": "myvolume2", + "schema_name": "test-schema-[UNIQUE_NAME]", + "volume_type": "MANAGED" + } + }, + "changes": { + "name": { + "action": "update_id", + "reason": "id_changes", + "old": "myvolume", + "new": "myvolume2" + } + } + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.schemas.schema1 + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.schema1 + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default + +Destroy: 1 deleted diff --git a/acceptance/bundle/resources/volumes/recreate_out_of_band/script b/acceptance/bundle/resources/volumes/recreate_out_of_band/script new file mode 100644 index 00000000000..eaaa5d0268a --- /dev/null +++ b/acceptance/bundle/resources/volumes/recreate_out_of_band/script @@ -0,0 +1,34 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +SCHEMA="main.test-schema-${UNIQUE_NAME}" + +cleanup() { + # Deleting the schema deletes the volumes in it, the one created out of band too. + trace $CLI bundle destroy --auto-approve + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +# Drop the remote_state fields that differ between clouds or from the test server. +plan() { + trace $CLI bundle plan --output json | jq .plan | normalize_uc_payload.py updated_at updated_by metastore_id effective_predictive_optimization_flag storage_location +} + +title "Deploy a schema with a volume in it" +trace $CLI bundle deploy +print_requests.py //unity-catalog &> LOG.deploy_requests +add_repl "$($CLI volumes read "$SCHEMA.myvolume" | jq -r .volume_id)" VOLUME_ID + +title "Out of band, delete the volume and create another one under a new name" +trace $CLI volumes delete "$SCHEMA.myvolume" +NEW_VOLUME_ID=$($CLI volumes create main "test-schema-${UNIQUE_NAME}" myvolume2 MANAGED | jq -r .volume_id) +add_repl "$NEW_VOLUME_ID" NEW_VOLUME_ID +print_requests.py //unity-catalog &> LOG.recreate_requests + +title "With the config renamed to match, the plan finds a volume at the new name, but its volume_id differs, so it plans a create" +trace update_file.py databricks.yml "name: myvolume" "name: myvolume2" +trace print_state.py | jq '.state["resources.volumes.volume1"] | {__id__, volume_id: .state.volume_id}' +trace $CLI volumes read "$SCHEMA.myvolume2" | jq '{full_name, volume_id}' +plan diff --git a/acceptance/bundle/resources/volumes/recreate_out_of_band/test.toml b/acceptance/bundle/resources/volumes/recreate_out_of_band/test.toml new file mode 100644 index 00000000000..4272a80cf61 --- /dev/null +++ b/acceptance/bundle/resources/volumes/recreate_out_of_band/test.toml @@ -0,0 +1,7 @@ +Cloud = true + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] diff --git a/acceptance/bundle/resources/volumes/rename_out_of_band/databricks.yml.tmpl b/acceptance/bundle/resources/volumes/rename_out_of_band/databricks.yml.tmpl new file mode 100644 index 00000000000..2234fdf2e39 --- /dev/null +++ b/acceptance/bundle/resources/volumes/rename_out_of_band/databricks.yml.tmpl @@ -0,0 +1,13 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + schemas: + schema1: + catalog_name: main + name: test-schema-$UNIQUE_NAME + volumes: + volume1: + catalog_name: main + schema_name: ${resources.schemas.schema1.name} + name: myvolume diff --git a/acceptance/bundle/resources/volumes/rename_out_of_band/out.test.toml b/acceptance/bundle/resources/volumes/rename_out_of_band/out.test.toml new file mode 100644 index 00000000000..0f167effef1 --- /dev/null +++ b/acceptance/bundle/resources/volumes/rename_out_of_band/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/volumes/rename_out_of_band/output.txt b/acceptance/bundle/resources/volumes/rename_out_of_band/output.txt new file mode 100644 index 00000000000..80687ae93cc --- /dev/null +++ b/acceptance/bundle/resources/volumes/rename_out_of_band/output.txt @@ -0,0 +1,121 @@ + +=== Deploy a schema with a volume in it +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created schemas.schema1 +Created volumes.volume1 +Files: 2 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged + +=== Rename the volume out of band +>>> [CLI] volumes update main.test-schema-[UNIQUE_NAME].myvolume --new-name myvolume2 +{ + "full_name": "main.test-schema-[UNIQUE_NAME].myvolume2", + "volume_id": "[VOLUME_ID]" +} + +=== With the config unchanged, the plan creates the volume again under its configured name +>>> [CLI] bundle plan +create volumes.volume1 + +Plan: 1 to add, 0 to change, 0 to delete, 1 unchanged + +=== With the config renamed to match, the plan finds the volume at its new name, with the same volume_id +>>> update_file.py databricks.yml name: myvolume name: myvolume2 + +>>> [CLI] bundle plan --output json +{ + "resources.schemas.schema1": { + "action": "skip", + "remote_state": { + "browse_only": false, + "catalog_name": "main", + "catalog_type": "MANAGED_CATALOG", + "created_at": [UNIX_TIME_MILLIS][0], + "created_by": "[USERNAME]", + "enable_predictive_optimization": "INHERIT", + "full_name": "main.test-schema-[UNIQUE_NAME]", + "name": "test-schema-[UNIQUE_NAME]", + "owner": "[USERNAME]", + "schema_id": "[UUID]" + } + }, + "resources.volumes.volume1": { + "id": "main.test-schema-[UNIQUE_NAME].myvolume2", + "depends_on": [ + { + "node": "resources.schemas.schema1", + "label": "${resources.schemas.schema1.name}" + } + ], + "action": "update_id", + "new_state": { + "value": { + "catalog_name": "main", + "name": "myvolume2", + "schema_name": "test-schema-[UNIQUE_NAME]", + "volume_type": "MANAGED" + } + }, + "remote_state": { + "catalog_name": "main", + "created_at": [UNIX_TIME_MILLIS][1], + "created_by": "[USERNAME]", + "full_name": "main.test-schema-[UNIQUE_NAME].myvolume2", + "name": "myvolume2", + "owner": "[USERNAME]", + "schema_name": "test-schema-[UNIQUE_NAME]", + "volume_id": "[VOLUME_ID]", + "volume_type": "MANAGED" + }, + "changes": { + "name": { + "action": "skip", + "reason": "remote_already_set", + "old": "myvolume", + "new": "myvolume2", + "remote": "myvolume2" + } + } + } +} + +=== Deploy only records the new name in the state +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated volumes.volume1 +Files: 3 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 1 unchanged + +>>> print_requests.py //unity-catalog + +>>> [CLI] volumes read main.test-schema-[UNIQUE_NAME].myvolume2 +{ + "full_name": "main.test-schema-[UNIQUE_NAME].myvolume2", + "volume_id": "[VOLUME_ID]" +} + +>>> [CLI] bundle summary --output json +"main.test-schema-[UNIQUE_NAME].myvolume2" + +=== Redeploy is a no-op +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.schemas.schema1 + delete resources.volumes.volume1 + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.schema1 + +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 + +Destroy: 2 deleted diff --git a/acceptance/bundle/resources/volumes/rename_out_of_band/script b/acceptance/bundle/resources/volumes/rename_out_of_band/script new file mode 100644 index 00000000000..c16fde22dbc --- /dev/null +++ b/acceptance/bundle/resources/volumes/rename_out_of_band/script @@ -0,0 +1,41 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +SCHEMA="main.test-schema-${UNIQUE_NAME}" + +cleanup() { + trace $CLI bundle destroy --auto-approve + + # Consume the destroy's requests so none are left recorded. + print_requests.py //unity-catalog &> LOG.destroy_requests +} +trap cleanup EXIT + +# Drop the remote_state fields that differ between clouds or from the test server. +plan() { + trace $CLI bundle plan --output json | jq .plan | normalize_uc_payload.py updated_at updated_by metastore_id effective_predictive_optimization_flag storage_location +} + +title "Deploy a schema with a volume in it" +trace $CLI bundle deploy +print_requests.py //unity-catalog &> LOG.deploy_requests +add_repl "$($CLI volumes read "$SCHEMA.myvolume" | jq -r .volume_id)" VOLUME_ID + +title "Rename the volume out of band" +trace $CLI volumes update "$SCHEMA.myvolume" --new-name myvolume2 | jq '{full_name, volume_id}' +print_requests.py //unity-catalog &> LOG.rename_requests + +title "With the config unchanged, the plan creates the volume again under its configured name" +trace $CLI bundle plan + +title "With the config renamed to match, the plan finds the volume at its new name, with the same volume_id" +trace update_file.py databricks.yml "name: myvolume" "name: myvolume2" +plan + +title "Deploy only records the new name in the state" +trace $CLI bundle deploy +trace print_requests.py //unity-catalog +trace $CLI volumes read "$SCHEMA.myvolume2" | jq '{full_name, volume_id}' +trace $CLI bundle summary --output json | jq '.resources.volumes.volume1.id' + +title "Redeploy is a no-op" +trace $CLI bundle plan diff --git a/acceptance/bundle/resources/volumes/rename_out_of_band/test.toml b/acceptance/bundle/resources/volumes/rename_out_of_band/test.toml new file mode 100644 index 00000000000..4272a80cf61 --- /dev/null +++ b/acceptance/bundle/resources/volumes/rename_out_of_band/test.toml @@ -0,0 +1,7 @@ +Cloud = true + +Ignore = [ + ".databricks", + ".gitignore", + "databricks.yml", +] diff --git a/acceptance/bundle/resources/volumes/uppercase-name/out.deploy.txt b/acceptance/bundle/resources/volumes/uppercase-name/out.deploy.txt index bba6a759059..2b50dcb8cfa 100644 --- a/acceptance/bundle/resources/volumes/uppercase-name/out.deploy.txt +++ b/acceptance/bundle/resources/volumes/uppercase-name/out.deploy.txt @@ -33,7 +33,7 @@ Resources: 2 created, 0 changed, 0 deleted, 0 unchanged "changes": { "name": { "action": "skip", - "reason": "id_field" + "reason": "id_changes" } } }, diff --git a/acceptance/bundle/user_agent/simple/out.requests.deploy.json b/acceptance/bundle/user_agent/simple/out.requests.deploy.json index 31bc7b0724a..e31832f7f4a 100644 --- a/acceptance/bundle/user_agent/simple/out.requests.deploy.json +++ b/acceptance/bundle/user_agent/simple/out.requests.deploy.json @@ -249,7 +249,8 @@ "__id__": "mycatalog.myschema", "state": { "catalog_name": "mycatalog", - "name": "myschema" + "name": "myschema", + "schema_id": "[UUID]" } } } diff --git a/bundle/deployplan/plan.go b/bundle/deployplan/plan.go index 8446b200b12..99d70f7b80b 100644 --- a/bundle/deployplan/plan.go +++ b/bundle/deployplan/plan.go @@ -119,6 +119,8 @@ func LoadPlanFromFile(path string) (*Plan, error) { } type PlanEntry struct { + // ID is set when the resource was found at another ID than the one in state: a rename + // moved it there (see findMoved). Applying the entry addresses the resource at ID. ID string `json:"id,omitempty"` DependsOn []DependsOnEntry `json:"depends_on,omitempty"` Action ActionType `json:"action,omitempty"` @@ -184,6 +186,10 @@ const ( // it and whatever the remote reports is not drift (e.g. cluster libraries without a libraries // section, where job runs install their task libraries cluster-wide). ReasonUnmanaged = "unmanaged" + // ReasonParentRenamed: an ID field that references the name of a parent being renamed in + // place. The backend moves the resource along with its parent, so it is re-keyed rather + // than recreated. + ReasonParentRenamed = "parent_renamed" // Special reason that results in removing this change from the plan ReasonDrop = "!drop" @@ -231,6 +237,17 @@ func (c *Changes) HasChangeExcept(prefixes ...string) bool { return false } +// OnlyParentRenamed reports whether every actionable change follows a parent's rename +// (ReasonParentRenamed). The backend has already moved such a resource, so only its ID changes. +func (c *Changes) OnlyParentRenamed() bool { + for _, change := range *c { + if change.Action != Skip && change.Reason != ReasonParentRenamed { + return false + } + } + return true +} + func (p *Plan) GetActions() []Action { actions := make([]Action, 0, len(p.Plan)) for key, entry := range p.Plan { diff --git a/bundle/deployplan/plan_test.go b/bundle/deployplan/plan_test.go index cee00f42c44..dad1020fcfb 100644 --- a/bundle/deployplan/plan_test.go +++ b/bundle/deployplan/plan_test.go @@ -65,3 +65,44 @@ func TestHasChange(t *testing.T) { }) } } + +func TestOnlyParentRenamed(t *testing.T) { + parentRenamed := &deployplan.ChangeDesc{Action: deployplan.UpdateWithID, Reason: deployplan.ReasonParentRenamed} + tests := []struct { + name string + changes deployplan.Changes + want bool + }{ + { + name: "parent renamed", + changes: deployplan.Changes{"schema_name": parentRenamed}, + want: true, + }, + { + name: "skip change is ignored", + changes: deployplan.Changes{ + "schema_name": parentRenamed, + "storage_location": {Action: deployplan.Skip, Reason: "immutable_backend_value"}, + }, + want: true, + }, + { + name: "own field also changes", + changes: deployplan.Changes{ + "schema_name": parentRenamed, + "comment": {Action: deployplan.Update}, + }, + want: false, + }, + { + name: "own rename", + changes: deployplan.Changes{"name": {Action: deployplan.UpdateWithID, Reason: "id_changes"}}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.changes.OnlyParentRenamed()) + }) + } +} diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index 9cdd5a4f505..7c9f12da677 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -44,6 +44,10 @@ func (d *DeploymentUnit) Deploy(ctx context.Context, db *dstate.DeploymentState, if oldID == "" { return errors.New("state entry not found") } + if planEntry.ID != "" { + // The plan found the resource at the ID its config gives it: a rename moved it. + oldID = planEntry.ID + } switch actionType { case deployplan.Recreate: diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index 8f8336beaa5..64b88bd7205 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -263,6 +263,13 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return true } + // Collected before resolveReferences, which drops the references it resolves. + parentRenames, err := b.parentRenames(resourceKey) + if err != nil { + logdiag.LogError(ctx, fmt.Errorf("%s: %w", errorPrefix, err)) + return false + } + // Process all references in the resource using Refs map // Refs maps path inside resource to references e.g. "${resources.jobs.foo.id} ${resources.jobs.foo.name}" if !b.resolveReferences(ctx, resourceKey, entry, errorPrefix, true) { @@ -327,6 +334,13 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return false } } + if remoteState == nil { + entry.ID, remoteState, err = findMoved(ctx, adapter, sv, dbentry.ID, savedState) + if err != nil { + logdiag.LogError(ctx, fmt.Errorf("%s: %w", errorPrefix, err)) + return false + } + } // We have a choice whether to include remoteState or remoteStateComparable from below. // Including remoteState because in the near future remoteState is expected to become a superset struct of remoteStateComparable @@ -370,6 +384,12 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return false } + err = followParentRenames(adapter, entry.Changes, parentRenames) + if err != nil { + logdiag.LogError(ctx, fmt.Errorf("%s: classifying changes: %w", errorPrefix, err)) + return false + } + if remoteState == nil { // Even if local action is "recreate" which is higher than "create", we should still pick "create" here // because we know remote does not exist. @@ -377,6 +397,10 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks } else { action = getMaxAction(entry.Changes) } + if entry.ID != "" { + // Found at a new ID: the state has to follow it there. + action = deployplan.GetHigherAction(action, deployplan.UpdateWithID) + } // Note, this unconditionally stores remoteState. However, it may updated post-deploy, so whether // it can be used for variable resolution depends on several factors, see canReadRemoteCache in LookupReferencePreDeploy @@ -477,6 +501,13 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change return err } + if cfg != nil { + if _, ok := findMatchingRule(path, cfg.DropChanges); ok { + toDrop = append(toDrop, pathString) + continue + } + } + // RemoteAlreadySet only holds when ch.Remote is a real remote value we can compare // against ch.New. Skip it for fields whose remote value is fabricated and thus // meaningless: declared ignore_remote_changes (present in RemoteType but read back @@ -666,6 +697,140 @@ func classifyIDField(cfg *dresources.ResourceLifecycleConfig, path *structpath.P return deployplan.Undefined, "", false } +// parentRenames returns the fields of resourceKey whose whole value is a reference to a +// field that the referenced resource renames in this plan (an update_id change), keyed by +// field path, with the referenced change as the value. +func (b *DeploymentBundle) parentRenames(resourceKey string) (map[string]*deployplan.ChangeDesc, error) { + sv, ok := b.StateCache.Load(resourceKey) + if !ok { + return nil, fmt.Errorf("internal error: no state cache entry found for %q", resourceKey) + } + + result := make(map[string]*deployplan.ChangeDesc) + for fieldPath, refString := range sv.Refs { + ref, ok := dynvar.NewRef(dyn.V(refString)) + if !ok || !ref.IsPure() { + continue + } + targetPath, err := structpath.ParsePath(ref.References()[0]) + if err != nil { + return nil, fmt.Errorf("cannot parse reference %q: %w", refString, err) + } + targetKey, targetField := splitResourcePath(targetPath) + change, err := b.renameChange(targetKey, targetField.String()) + if err != nil { + return nil, err + } + if change != nil { + result[fieldPath] = change + } + } + return result, nil +} + +// renameChange returns the change to field of resourceKey if it is part of an update_id, nil otherwise. +func (b *DeploymentBundle) renameChange(resourceKey, field string) (*deployplan.ChangeDesc, error) { + entry, err := b.Plan.ReadLockEntry(resourceKey) + if err != nil { + return nil, err + } + defer b.Plan.ReadUnlockEntry(resourceKey) + + // A missing entry is reported by resolveReferences. + if entry == nil || entry.Action != deployplan.UpdateWithID { + return nil, nil + } + change := entry.Changes[field] + if change == nil || change.Action != deployplan.UpdateWithID { + return nil, nil + } + return change, nil +} + +// findMoved looks for a resource missing from its recorded ID at the ID its config gives it, where a +// rename can have moved it: the resource or its parent renamed out of band, or by a deploy that +// stopped before re-keying it. What is found there is taken only if its internal ID matches the one +// in savedState, so an unrelated object with the same name is never adopted; the create then fails +// on it instead. +func findMoved(ctx context.Context, adapter *dresources.Adapter, sv *structvar.StructVar, storedID string, savedState any) (string, any, error) { + if !adapter.HasInternalID() || !adapter.HasDoUpdateWithID() || hasUnresolvedIDField(adapter, sv) { + return "", nil, nil + } + savedInternalID, err := adapter.InternalID(savedState) + if err != nil || savedInternalID == "" { + return "", nil, err + } + id, err := adapter.IDFromState(sv.Value) + if err != nil || id == storedID { + return "", nil, err + } + remote, err := retry.With(ctx, retry.IsTransientRead, func() (any, error) { + return adapter.DoRead(ctx, id) + }) + if apierr.IsMissing(err) { + return "", nil, nil + } + if err != nil { + return "", nil, fmt.Errorf("reading id=%q: %w", id, err) + } + found, err := adapter.RemapState(remote) + if err != nil { + return "", nil, err + } + foundInternalID, err := adapter.InternalID(found) + if err != nil || foundInternalID != savedInternalID { + return "", nil, err + } + return id, remote, nil +} + +// hasUnresolvedIDField reports whether a field the ID is built from is still an unresolved reference. +func hasUnresolvedIDField(adapter *dresources.Adapter, sv *structvar.StructVar) bool { + cfg := adapter.ResourceConfig() + for pathString := range sv.Refs { + path, err := structpath.ParsePath(pathString) + if err != nil { + return true + } + if _, ok := findMatchingRule(path, cfg.ProvidedIDFields); ok { + return true + } + if _, ok := findMatchingRule(path, cfg.UpdatableIDFields); ok { + return true + } + } + return false +} + +// followParentRenames plans an update_id instead of a recreate for an ID field that changes +// only because it references the name of a parent that is renamed in place. UC moves the +// children of a renamed catalog or schema along with it, so recreating such a child fails +// with "already exists"; its DoUpdateWithID addresses it under the new parent instead. +// +// The old values must match too: a child moved to the renamed parent from somewhere else +// still has to be recreated. +func followParentRenames(adapter *dresources.Adapter, changes deployplan.Changes, parentRenames map[string]*deployplan.ChangeDesc) error { + if !adapter.HasDoUpdateWithID() { + return nil + } + for pathString, parentChange := range parentRenames { + ch := changes[pathString] + if ch == nil || ch.Action != deployplan.Recreate || !structdiff.IsEqual(ch.Old, parentChange.Old) { + continue + } + path, err := structpath.ParsePath(pathString) + if err != nil { + return err + } + if _, ok := findMatchingRule(path, adapter.ResourceConfig().ProvidedIDFields); !ok { + continue + } + ch.Action = deployplan.UpdateWithID + ch.Reason = deployplan.ReasonParentRenamed + } + return nil +} + // shouldSkipNormalized skips a change that is a false diff caused by UC API // normalization: the API strips trailing slashes from storage URLs // (normalize_slash). The direct engine saves local config to state, so without diff --git a/bundle/direct/bundle_plan_test.go b/bundle/direct/bundle_plan_test.go index 2d051dc21b9..1780a13134e 100644 --- a/bundle/direct/bundle_plan_test.go +++ b/bundle/direct/bundle_plan_test.go @@ -55,8 +55,8 @@ func TestDynPathToStructPath(t *testing.T) { func TestExtractReferences_ExcludesReadonlyFields(t *testing.T) { adapters, err := dresources.InitAll(nil) require.NoError(t, err) - // The volume state type is catalog.CreateVolumeRequestContent, which has - // comment but not volume_path. + // The volume state type is VolumeState, which has comment but not + // volume_path. stateType := adapters["volumes"].StateType() const yml = ` @@ -271,6 +271,64 @@ func TestRemoteAlreadySetGuards(t *testing.T) { } } +// TestFollowParentRenames pins when an ID field that references a renamed parent is +// re-keyed (update_id) instead of recreated: only a provided_id_field of a resource with +// DoUpdateWithID, and only if it was under that parent before the rename. +func TestFollowParentRenames(t *testing.T) { + adapters, err := dresources.InitAll(nil) + require.NoError(t, err) + + tests := []struct { + name string + resource string + field string + old string + expectedAction deployplan.ActionType + }{ + { + name: "child of the renamed parent follows it", + resource: "volumes", + field: "schema_name", + old: "s_old", + expectedAction: deployplan.UpdateWithID, + }, + { + name: "child moved from another parent is recreated", + resource: "volumes", + field: "schema_name", + old: "other", + expectedAction: deployplan.Recreate, + }, + { + name: "resource without DoUpdateWithID is recreated", + resource: "registered_models", + field: "schema_name", + old: "s_old", + expectedAction: deployplan.Recreate, + }, + { + name: "recreate_on_changes field is recreated", + resource: "volumes", + field: "storage_location", + old: "s_old", + expectedAction: deployplan.Recreate, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + adapter, ok := adapters[tt.resource] + require.True(t, ok) + ch := &deployplan.ChangeDesc{Action: deployplan.Recreate, Reason: "id_field", Old: tt.old, New: "s_new", Remote: tt.old} + parentRenames := map[string]*deployplan.ChangeDesc{ + tt.field: {Action: deployplan.UpdateWithID, Reason: "id_changes", Old: "s_old", New: "s_new", Remote: "s_old"}, + } + require.NoError(t, followParentRenames(adapter, deployplan.Changes{tt.field: ch}, parentRenames)) + assert.Equal(t, tt.expectedAction, ch.Action) + }) + } +} + // TestRecreateBackendDefault pins the implicit backend-default behavior for // recreate_on_changes fields (recreateOrBackendDefault): an immutable field the // config never set (old and new nil) but the backend populated (remote set) is left @@ -795,3 +853,18 @@ func TestLadderBackendDefaultBeforeRemoteAddition(t *testing.T) { assert.Equal(t, deployplan.Skip, changes["enable_elastic_disk"].Action) assert.Equal(t, deployplan.ReasonBackendDefault, changes["enable_elastic_disk"].Reason) } + +// TestDropChanges: a drop_changes field (volumes' volume_id, which config never sets) leaves the +// plan entirely, while the volume's other changes stay. +func TestDropChanges(t *testing.T) { + adapters, err := dresources.InitAll(nil) + require.NoError(t, err) + + changes := deployplan.Changes{ + "volume_id": {Old: "uuid-1", New: nil, Remote: "uuid-1"}, + "comment": {Old: "a", New: "b", Remote: "a"}, + } + require.NoError(t, addPerFieldActions(t.Context(), adapters["volumes"], changes, nil, nil)) + assert.NotContains(t, changes, "volume_id") + assert.Equal(t, deployplan.Update, changes["comment"].Action) +} diff --git a/bundle/direct/dresources/README.md b/bundle/direct/dresources/README.md index 78952537943..b29498ca69b 100644 --- a/bundle/direct/dresources/README.md +++ b/bundle/direct/dresources/README.md @@ -27,6 +27,7 @@ Choose the right category: - `output_only` — the field is computed by the backend; the user never sets it - `input_only` — accepted on create/update but not returned by GET (e.g., write-only tokens, flags) - `managed` — managed by the cloud provider or platform, not by the user config + - **`drop_changes`**: Leave changes to this field out of the plan entirely. Use for fields that config never sets but the state records, like an internal ID recorded from the backend (see `InternalID`). - **`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 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. - **`updatable_id_fields`**: Changing this field changes the resource's ID. Requires `DoUpdateWithID` to be implemented. @@ -47,6 +48,10 @@ For resources whose create or update is asynchronous (the resource is not immedi If the API may return a slice's elements in a different order between calls (e.g., `depends_on` in job tasks, `privileges` in grants), implement `KeyedSlices` to compare elements by a natural key rather than by index. Without this, every deploy after any reordering shows phantom diffs. +## Moved resources: IDFromState / InternalID + +If the ID is built from the resource's own fields (a UC full name) and the backend has its own ID that a rename keeps (a volume's `volume_id`), record that ID in the state type, set it from the API responses (and from `RemapState`), and implement `IDFromState` and `InternalID` together, along with `DoUpdateWithID`. When nothing is at the recorded ID, the plan reads at the ID the config gives the resource and takes what it finds only if its internal ID matches the one saved in state. This covers a rename done out of band, or by a deploy that stopped before re-keying the resource. + ## Empty states: IsEmptyState If a desired state describes no resource at all (e.g. an empty grants list), implement `IsEmptyState`. The planner omits such a node instead of planning a create, and apply drops its state entry instead of persisting one. diff --git a/bundle/direct/dresources/adapter.go b/bundle/direct/dresources/adapter.go index 80ece24b2be..64a32906462 100644 --- a/bundle/direct/dresources/adapter.go +++ b/bundle/direct/dresources/adapter.go @@ -118,6 +118,17 @@ type IResource interface { // (returned by GET, not 404) that rejects a second DELETE. // Example: func (*ResourceApp) IsGone(remote *AppRemote) bool IsGone(remoteState any) bool + + // [Optional] IDFromState returns the ID the state gives the resource, for resource types whose ID + // is built from their own fields (a UC full name). Implemented together with InternalID. + // Example: func (*ResourceVolume) IDFromState(state *VolumeState) string + IDFromState(state any) string + + // [Optional] InternalID returns the backend's own ID recorded in the state, which a rename keeps + // (a volume's volume_id). When the resource is not at its recorded ID, the plan looks for it at the + // ID IDFromState gives it, and takes what it finds only if the internal IDs match. + // Example: func (*ResourceVolume) InternalID(state *VolumeState) string + InternalID(state any) string } // Adapter wraps resource implementation, validates signatures and type consistency across methods @@ -141,6 +152,8 @@ type Adapter struct { overrideChangeDesc *calladapt.BoundCaller doResize *calladapt.BoundCaller isGone *calladapt.BoundCaller + idFromState *calladapt.BoundCaller + internalID *calladapt.BoundCaller resourceConfig *ResourceLifecycleConfig generatedResourceConfig *ResourceLifecycleConfig @@ -182,6 +195,8 @@ func NewAdapter(typedNil any, resourceType string, client *databricks.WorkspaceC waitAfterDelete: nil, overrideChangeDesc: nil, isGone: nil, + idFromState: nil, + internalID: nil, resourceConfig: GetResourceConfig(resourceType), generatedResourceConfig: GetGeneratedResourceConfig(resourceType), keyedSlices: nil, @@ -308,6 +323,16 @@ func (a *Adapter) initMethods(resource any) error { return err } + a.idFromState, err = calladapt.PrepareCall(resource, reflect.TypeFor[IResource](), "IDFromState") + if err != nil { + return err + } + + a.internalID, err = calladapt.PrepareCall(resource, reflect.TypeFor[IResource](), "InternalID") + if err != nil { + return err + } + keyedSlicesCall, err := calladapt.PrepareCall(resource, reflect.TypeFor[IResource](), "KeyedSlices") if err != nil { return err @@ -402,6 +427,14 @@ func (a *Adapter) validate() error { validations = append(validations, "IsGone remoteState", a.isGone.InTypes[0], remoteType) } + if (a.idFromState == nil) != (a.internalID == nil) { + return errors.New("IDFromState and InternalID must be implemented together") + } + if a.idFromState != nil { + validations = append(validations, "IDFromState state", a.idFromState.InTypes[0], stateType) + validations = append(validations, "InternalID state", a.internalID.InTypes[0], stateType) + } + if a.doUpdateWithID != nil { validations = append(validations, "DoUpdateWithID newState", a.doUpdateWithID.InTypes[2], stateType) // DoUpdateWithID must return (string, remoteType, error) @@ -727,6 +760,29 @@ func (a *Adapter) IsGone(remoteState any) bool { return outs[0].(bool) } +// HasInternalID reports whether the resource implements IDFromState and InternalID. +func (a *Adapter) HasInternalID() bool { + return a.internalID != nil +} + +// IDFromState returns the ID the state gives the resource; see IResource.IDFromState. +func (a *Adapter) IDFromState(state any) (string, error) { + outs, err := a.idFromState.Call(state) + if err != nil { + return "", err + } + return outs[0].(string), nil +} + +// InternalID returns the backend's own ID recorded in state; see IResource.InternalID. +func (a *Adapter) InternalID(state any) (string, error) { + outs, err := a.internalID.Call(state) + if err != nil { + return "", err + } + return outs[0].(string), nil +} + // prepareCallRequired prepares a call and ensures the method is found. func prepareCallRequired(resource any, methodName string) (*calladapt.BoundCaller, error) { caller, err := calladapt.PrepareCall(resource, reflect.TypeFor[IResource](), methodName) diff --git a/bundle/direct/dresources/config.go b/bundle/direct/dresources/config.go index 9b3ae9080b8..1cf0f41bb0f 100644 --- a/bundle/direct/dresources/config.go +++ b/bundle/direct/dresources/config.go @@ -68,6 +68,10 @@ type ResourceLifecycleConfig struct { // IgnoreLocalChanges: field patterns where local changes are ignored (can't be updated via API). IgnoreLocalChanges []FieldRule `yaml:"ignore_local_changes,omitempty"` + // DropChanges: field patterns whose changes are left out of the plan: fields that config never + // sets but the state records, such as an internal ID recorded from the backend. + DropChanges []FieldRule `yaml:"drop_changes,omitempty"` + // RecreateOnChanges: field patterns that trigger delete + create when changed. RecreateOnChanges []FieldRule `yaml:"recreate_on_changes,omitempty"` @@ -155,6 +159,7 @@ const ( var empty = ResourceLifecycleConfig{ IgnoreRemoteChanges: nil, IgnoreLocalChanges: nil, + DropChanges: nil, RecreateOnChanges: nil, ProvidedIDFields: nil, UpdatableIDFields: nil, diff --git a/bundle/direct/dresources/config_test.go b/bundle/direct/dresources/config_test.go index 53279eed6c6..d653d1ec695 100644 --- a/bundle/direct/dresources/config_test.go +++ b/bundle/direct/dresources/config_test.go @@ -91,6 +91,7 @@ func categoryRules(c ResourceLifecycleConfig) []struct { }{ {"ignore_remote_changes", c.IgnoreRemoteChanges}, {"ignore_local_changes", c.IgnoreLocalChanges}, + {"drop_changes", c.DropChanges}, {"recreate_on_changes", c.RecreateOnChanges}, {"provided_id_fields", c.ProvidedIDFields}, {"updatable_id_fields", c.UpdatableIDFields}, diff --git a/bundle/direct/dresources/configs/schemas.yml b/bundle/direct/dresources/configs/schemas.yml index 8ef27e71d15..c50e8d12b62 100644 --- a/bundle/direct/dresources/configs/schemas.yml +++ b/bundle/direct/dresources/configs/schemas.yml @@ -1,9 +1,10 @@ provided_id_fields: - # UC lowercases identifier names; remote returns "myschema" for config "MySchema". - - field: name - reason: id_field - field: catalog_name reason: id_field +updatable_id_fields: + # UC lowercases identifier names; remote returns "myschema" for config "MySchema". + - field: name + reason: id_changes recreate_on_changes: - field: storage_root reason: immutable @@ -15,3 +16,7 @@ backend_defaults: # Without this, every subsequent plan produces an Update whose payload is empty, # and UC rejects it with "UpdateSchema Nothing to update". - field: properties['unity.catalog.managed.*.defaults.*'] +drop_changes: + # Recorded from the backend (see InternalID); config never sets it. + - field: schema_id + reason: internal_id diff --git a/bundle/direct/dresources/configs/volumes.yml b/bundle/direct/dresources/configs/volumes.yml index d4b68e95875..9a6a9906d5e 100644 --- a/bundle/direct/dresources/configs/volumes.yml +++ b/bundle/direct/dresources/configs/volumes.yml @@ -16,3 +16,7 @@ normalize_slash: # https://github.com/databricks/terraform-provider-databricks/blob/v1.65.1/catalog/resource_volume.go#L25 - field: storage_location reason: uc_strips_trailing_slash +drop_changes: + # Recorded from the backend (see InternalID); config never sets it. + - field: volume_id + reason: internal_id diff --git a/bundle/direct/dresources/schema.go b/bundle/direct/dresources/schema.go index d80dac584bc..250d44fb975 100644 --- a/bundle/direct/dresources/schema.go +++ b/bundle/direct/dresources/schema.go @@ -2,15 +2,33 @@ package dresources import ( "context" + "fmt" "slices" "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/utils" "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/catalog" ) +// SchemaState is the schema's config plus its schema_id. The backend assigns schema_id and a +// rename keeps it, so recording it lets the plan recognize the schema under a new name (see +// InternalID). +type SchemaState struct { + catalog.CreateSchema + SchemaId string `json:"schema_id,omitempty"` +} + +func (s *SchemaState) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, s) +} + +func (s SchemaState) MarshalJSON() ([]byte, error) { + return marshal.Marshal(s) +} + type ResourceSchema struct { client *databricks.WorkspaceClient } @@ -19,12 +37,15 @@ func (*ResourceSchema) New(client *databricks.WorkspaceClient) *ResourceSchema { return &ResourceSchema{client: client} } -func (*ResourceSchema) PrepareState(input *resources.Schema) *catalog.CreateSchema { - return &input.CreateSchema +func (*ResourceSchema) PrepareState(input *resources.Schema) *SchemaState { + return &SchemaState{ + CreateSchema: input.CreateSchema, + SchemaId: "", // Recorded from the backend when the schema is created or updated. + } } -func (*ResourceSchema) RemapState(info *catalog.SchemaInfo) *catalog.CreateSchema { - return &catalog.CreateSchema{ +func (*ResourceSchema) RemapState(info *catalog.SchemaInfo) *SchemaState { + return &SchemaState{ CatalogName: info.CatalogName, Comment: info.Comment, CustomMaxRetentionHours: info.CustomMaxRetentionHours, @@ -32,6 +53,7 @@ func (*ResourceSchema) RemapState(info *catalog.SchemaInfo) *catalog.CreateSchem Properties: info.Properties, StorageRoot: info.StorageRoot, ForceSendFields: utils.FilterFields[catalog.CreateSchema](info.ForceSendFields), + SchemaId: info.SchemaId, } } @@ -39,11 +61,12 @@ func (r *ResourceSchema) DoRead(ctx context.Context, id string) (*catalog.Schema return r.client.Schemas.GetByFullName(ctx, id) } -func (r *ResourceSchema) DoCreate(ctx context.Context, config *catalog.CreateSchema) (string, *catalog.SchemaInfo, error) { - response, err := r.client.Schemas.Create(ctx, *config) +func (r *ResourceSchema) DoCreate(ctx context.Context, config *SchemaState) (string, *catalog.SchemaInfo, error) { + response, err := r.client.Schemas.Create(ctx, config.CreateSchema) if err != nil || response == nil { return "", nil, err } + config.SchemaId = response.SchemaId return response.FullName, response, nil } @@ -64,32 +87,80 @@ func (r *ResourceSchema) DoCreate(ctx context.Context, config *catalog.CreateSch // migrate invariant (schema.yml is excluded there, and schema_tf_compat.yml covers migrate instead). var schemaForceSend = []string{"Comment", "CustomMaxRetentionHours"} -// DoUpdate updates the schema in place and returns remote state. -func (r *ResourceSchema) DoUpdate(ctx context.Context, id string, config *catalog.CreateSchema, _ *PlanEntry) (*catalog.SchemaInfo, error) { - updateRequest := catalog.UpdateSchema{ +func makeUpdateSchema(id, newName string, config *catalog.CreateSchema) catalog.UpdateSchema { + return catalog.UpdateSchema{ Comment: config.Comment, CustomMaxRetentionHours: config.CustomMaxRetentionHours, EnablePredictiveOptimization: "", // Not supported by DABs FullName: id, - NewName: "", // We recreate schemas on name change intentionally. + NewName: newName, Owner: "", // Not supported by DABs Properties: config.Properties, ForceSendFields: utils.FilterFields[catalog.UpdateSchema](append(slices.Clone(schemaForceSend), config.ForceSendFields...), "EnablePredictiveOptimization", "NewName", "Owner"), } +} - response, err := r.client.Schemas.Update(ctx, updateRequest) - if err != nil { +// DoUpdate updates the schema in place and returns remote state. +func (r *ResourceSchema) DoUpdate(ctx context.Context, id string, config *SchemaState, _ *PlanEntry) (*catalog.SchemaInfo, error) { + response, err := r.client.Schemas.Update(ctx, makeUpdateSchema(id, "", &config.CreateSchema)) + if err != nil || response == nil { return nil, err } - if response != nil && response.FullName != id { + if response.FullName != id { log.Warnf(ctx, "schemas: response contains unexpected full_name=%#v (expected %#v)", response.FullName, id) } + config.SchemaId = response.SchemaId return response, nil } -func (r *ResourceSchema) DoDelete(ctx context.Context, id string, _ *catalog.CreateSchema) error { +// DoUpdateWithID renames the schema in place when the name changes; the ID is the full name, so it changes too. +func (r *ResourceSchema) DoUpdateWithID(ctx context.Context, id string, config *SchemaState, entry *PlanEntry) (string, *catalog.SchemaInfo, error) { + nameFromID, err := getNameFromID(id) + if err != nil { + return "", nil, err + } + + newName := "" + if config.Name != nameFromID { + newName = config.Name + } + + // The catalog comes from config: a renamed catalog moves the schema with it, leaving id + // stale (see followParentRenames). + fullName := config.CatalogName + "." + nameFromID + + if entry.Changes.OnlyParentRenamed() { + // UC already moved the schema with its catalog; only the ID changes. + remote, ok := entry.RemoteState.(*catalog.SchemaInfo) + if !ok { + return "", nil, fmt.Errorf("internal error: unexpected remote state type %T", entry.RemoteState) + } + config.SchemaId = remote.SchemaId + return fullName, nil, nil + } + + response, err := r.client.Schemas.Update(ctx, makeUpdateSchema(fullName, newName, &config.CreateSchema)) + if err != nil || response == nil { + return "", nil, err + } + + config.SchemaId = response.SchemaId + return response.FullName, response, nil +} + +// IDFromState returns the full name, which is the ID. +func (*ResourceSchema) IDFromState(state *SchemaState) string { + return state.CatalogName + "." + state.Name +} + +// InternalID returns the schema_id. +func (*ResourceSchema) InternalID(state *SchemaState) string { + return state.SchemaId +} + +func (r *ResourceSchema) DoDelete(ctx context.Context, id string, _ *SchemaState) error { return r.client.Schemas.Delete(ctx, catalog.DeleteSchemaRequest{ FullName: id, Force: true, diff --git a/bundle/direct/dresources/schema_test.go b/bundle/direct/dresources/schema_test.go index ee6397afc6e..5c486cab1f1 100644 --- a/bundle/direct/dresources/schema_test.go +++ b/bundle/direct/dresources/schema_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "testing" - "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -15,13 +14,14 @@ func TestResourceSchema_DoUpdate_WithUnsupportedForceSendFields(t *testing.T) { adapter := (*ResourceSchema)(nil).New(client) ctx := t.Context() - config := &catalog.CreateSchema{ + config := &SchemaState{ CatalogName: "main", Name: "test_schema", Comment: "original comment", Properties: map[string]string{"key": "value"}, StorageRoot: "", ForceSendFields: nil, + SchemaId: "", } id, _, err := adapter.DoCreate(ctx, config) diff --git a/bundle/direct/dresources/volume.go b/bundle/direct/dresources/volume.go index 7cd1eaba1ce..5f9f9ccefca 100644 --- a/bundle/direct/dresources/volume.go +++ b/bundle/direct/dresources/volume.go @@ -10,9 +10,26 @@ import ( "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/utils" "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/catalog" ) +// VolumeState is the volume's config plus its volume_id. The backend assigns volume_id and a +// rename keeps it, so recording it lets the plan recognize the volume under a new name (see +// InternalID). +type VolumeState struct { + catalog.CreateVolumeRequestContent + VolumeId string `json:"volume_id,omitempty"` +} + +func (s *VolumeState) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, s) +} + +func (s VolumeState) MarshalJSON() ([]byte, error) { + return marshal.Marshal(s) +} + type ResourceVolume struct { client *databricks.WorkspaceClient } @@ -21,12 +38,15 @@ func (*ResourceVolume) New(client *databricks.WorkspaceClient) *ResourceVolume { return &ResourceVolume{client: client} } -func (*ResourceVolume) PrepareState(input *resources.Volume) *catalog.CreateVolumeRequestContent { - return &input.CreateVolumeRequestContent +func (*ResourceVolume) PrepareState(input *resources.Volume) *VolumeState { + return &VolumeState{ + CreateVolumeRequestContent: input.CreateVolumeRequestContent, + VolumeId: "", // Recorded from the backend when the volume is created or updated. + } } -func (*ResourceVolume) RemapState(info *catalog.VolumeInfo) *catalog.CreateVolumeRequestContent { - return &catalog.CreateVolumeRequestContent{ +func (*ResourceVolume) RemapState(info *catalog.VolumeInfo) *VolumeState { + return &VolumeState{ CatalogName: info.CatalogName, Comment: info.Comment, Name: info.Name, @@ -34,6 +54,7 @@ func (*ResourceVolume) RemapState(info *catalog.VolumeInfo) *catalog.CreateVolum StorageLocation: info.StorageLocation, VolumeType: info.VolumeType, ForceSendFields: utils.FilterFields[catalog.CreateVolumeRequestContent](info.ForceSendFields), + VolumeId: info.VolumeId, } } @@ -41,18 +62,19 @@ func (r *ResourceVolume) DoRead(ctx context.Context, id string) (*catalog.Volume return r.client.Volumes.ReadByName(ctx, id) } -func (r *ResourceVolume) DoCreate(ctx context.Context, config *catalog.CreateVolumeRequestContent) (string, *catalog.VolumeInfo, error) { - response, err := r.client.Volumes.Create(ctx, *config) +func (r *ResourceVolume) DoCreate(ctx context.Context, config *VolumeState) (string, *catalog.VolumeInfo, error) { + response, err := r.client.Volumes.Create(ctx, config.CreateVolumeRequestContent) if err != nil { return "", nil, err } + config.VolumeId = response.VolumeId return response.FullName, response, nil } // See schemaForceSend. Verified against a real workspace: {"comment": ""} clears it. var volumeForceSend = []string{"Comment"} -func (r *ResourceVolume) DoUpdate(ctx context.Context, id string, config *catalog.CreateVolumeRequestContent, _ *PlanEntry) (*catalog.VolumeInfo, error) { +func (r *ResourceVolume) DoUpdate(ctx context.Context, id string, config *VolumeState, _ *PlanEntry) (*catalog.VolumeInfo, error) { updateRequest := catalog.UpdateVolumeRequestContent{ Comment: config.Comment, Name: id, @@ -80,13 +102,14 @@ func (r *ResourceVolume) DoUpdate(ctx context.Context, id string, config *catalo log.Warnf(ctx, "volumes: response contains unexpected full_name=%#v (expected %#v)", response.FullName, id) } + config.VolumeId = response.VolumeId return response, err } -func (r *ResourceVolume) DoUpdateWithID(ctx context.Context, id string, config *catalog.CreateVolumeRequestContent, _ *PlanEntry) (string, *catalog.VolumeInfo, error) { +func (r *ResourceVolume) DoUpdateWithID(ctx context.Context, id string, config *VolumeState, entry *PlanEntry) (string, *catalog.VolumeInfo, error) { updateRequest := catalog.UpdateVolumeRequestContent{ Comment: config.Comment, - Name: id, + Name: "", // Initialized below NewName: "", // Initialized below if needed Owner: "", // Not supported by DABs @@ -100,6 +123,20 @@ func (r *ResourceVolume) DoUpdateWithID(ctx context.Context, id string, config * } nameFromID := items[len(items)-1] + // The parents come from config: a renamed catalog or schema moves the volume with it, + // leaving id stale (see followParentRenames). + updateRequest.Name = config.CatalogName + "." + config.SchemaName + "." + nameFromID + + if entry.Changes.OnlyParentRenamed() { + // UC already moved the volume with its parent; only the ID changes. + remote, ok := entry.RemoteState.(*catalog.VolumeInfo) + if !ok { + return "", nil, fmt.Errorf("internal error: unexpected remote state type %T", entry.RemoteState) + } + config.VolumeId = remote.VolumeId + return updateRequest.Name, nil, nil + } + if config.Name != nameFromID { updateRequest.NewName = config.Name } @@ -109,10 +146,21 @@ func (r *ResourceVolume) DoUpdateWithID(ctx context.Context, id string, config * return "", nil, err } + config.VolumeId = response.VolumeId return response.FullName, response, nil } -func (r *ResourceVolume) DoDelete(ctx context.Context, id string, _ *catalog.CreateVolumeRequestContent) error { +// IDFromState returns the full name, which is the ID. +func (*ResourceVolume) IDFromState(state *VolumeState) string { + return state.CatalogName + "." + state.SchemaName + "." + state.Name +} + +// InternalID returns the volume_id. +func (*ResourceVolume) InternalID(state *VolumeState) string { + return state.VolumeId +} + +func (r *ResourceVolume) DoDelete(ctx context.Context, id string, _ *VolumeState) error { return r.client.Volumes.DeleteByName(ctx, id) } diff --git a/libs/testserver/catalogs.go b/libs/testserver/catalogs.go index 9759b4b1a19..ff62b2e6959 100644 --- a/libs/testserver/catalogs.go +++ b/libs/testserver/catalogs.go @@ -37,6 +37,16 @@ func (s *FakeWorkspace) CatalogsCreate(req Request) Response { } } + if _, exists := s.Catalogs[createRequest.Name]; exists { + return Response{ + StatusCode: http.StatusBadRequest, + Body: map[string]string{ + "error_code": "CATALOG_ALREADY_EXISTS", + "message": fmt.Sprintf("Catalog '%s' already exists", createRequest.Name), + }, + } + } + // Echo back every field create accepts: a dropped one makes the next plan see a // phantom change. catalogInfo := catalog.CatalogInfo{ @@ -110,6 +120,7 @@ func (s *FakeWorkspace) CatalogsUpdate(req Request, name string) Response { // Delete the old entry and create with new name delete(s.Catalogs, name) + s.moveCatalogChildren(name, updateRequest.NewName) name = updateRequest.NewName } @@ -121,3 +132,48 @@ func (s *FakeWorkspace) CatalogsUpdate(req Request, name string) Response { Body: existing, } } + +// moveCatalogChildren mirrors UC, where the schemas and volumes of a renamed catalog move with it. +func (s *FakeWorkspace) moveCatalogChildren(oldCatalogName, newCatalogName string) { + for key, schema := range s.Schemas { + if schema.CatalogName != oldCatalogName { + continue + } + delete(s.Schemas, key) + schema.CatalogName = newCatalogName + schema.FullName = newCatalogName + "." + schema.Name + s.Schemas[schema.FullName] = schema + } + for key, volume := range s.Volumes { + if volume.CatalogName != oldCatalogName { + continue + } + delete(s.Volumes, key) + volume.CatalogName = newCatalogName + volume.FullName = newCatalogName + "." + volume.SchemaName + "." + volume.Name + s.Volumes[volume.FullName] = volume + } +} + +// CatalogsDelete mirrors UC, where deleting a catalog with force deletes its schemas and volumes too. +func (s *FakeWorkspace) CatalogsDelete(req Request, name string) Response { + defer s.LockUnlock()() + + if _, ok := s.Catalogs[name]; !ok { + return Response{StatusCode: http.StatusNotFound} + } + delete(s.Catalogs, name) + if req.URL.Query().Get("force") == "true" { + for key, schema := range s.Schemas { + if schema.CatalogName == name { + delete(s.Schemas, key) + } + } + for key, volume := range s.Volumes { + if volume.CatalogName == name { + delete(s.Volumes, key) + } + } + } + return Response{} +} diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index e5f4c37a16b..1fa448f4743 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -798,7 +798,7 @@ func AddDefaultHandlers(server *Server) { }) server.Handle("DELETE", "/api/2.1/unity-catalog/schemas/{full_name}", func(req Request) any { - return MapDelete(req.Workspace, req.Workspace.Schemas, req.Vars["full_name"]) + return req.Workspace.SchemasDelete(req, req.Vars["full_name"]) }) // Grants: @@ -826,7 +826,7 @@ func AddDefaultHandlers(server *Server) { }) server.Handle("DELETE", "/api/2.1/unity-catalog/catalogs/{name}", func(req Request) any { - return MapDelete(req.Workspace, req.Workspace.Catalogs, req.Vars["name"]) + return req.Workspace.CatalogsDelete(req, req.Vars["name"]) }) // External Locations: diff --git a/libs/testserver/schemas.go b/libs/testserver/schemas.go index 8094c93acc6..8b3403a0204 100644 --- a/libs/testserver/schemas.go +++ b/libs/testserver/schemas.go @@ -86,7 +86,7 @@ func (s *FakeWorkspace) SchemasUpdate(req Request, name string) Response { return *errResponse } - var schemaUpdate catalog.SchemaInfo + var schemaUpdate catalog.UpdateSchema if err := json.Unmarshal(req.Body, &schemaUpdate); err != nil { return Response{ @@ -97,12 +97,53 @@ func (s *FakeWorkspace) SchemasUpdate(req Request, name string) Response { applyUpdatedFields(&existing, schemaUpdate, fields) + if schemaUpdate.NewName != "" { + delete(s.Schemas, name) + oldName := existing.Name + // UC normalizes schema names to lowercase. + existing.Name = strings.ToLower(schemaUpdate.NewName) + existing.FullName = existing.CatalogName + "." + existing.Name + s.moveSchemaVolumes(existing.CatalogName, oldName, existing.Name) + } + existing.UpdatedAt = nowMilli() existing.UpdatedBy = s.CurrentUser().UserName - s.Schemas[name] = existing + s.Schemas[existing.FullName] = existing return Response{ Body: existing, } } + +// moveSchemaVolumes mirrors UC, where the volumes of a renamed schema move with it. +func (s *FakeWorkspace) moveSchemaVolumes(catalogName, oldSchemaName, newSchemaName string) { + for key, volume := range s.Volumes { + if volume.CatalogName != catalogName || volume.SchemaName != oldSchemaName { + continue + } + delete(s.Volumes, key) + volume.SchemaName = newSchemaName + volume.FullName = catalogName + "." + newSchemaName + "." + volume.Name + s.Volumes[volume.FullName] = volume + } +} + +// SchemasDelete mirrors UC, where deleting a schema with force deletes the volumes in it too. +func (s *FakeWorkspace) SchemasDelete(req Request, fullName string) Response { + defer s.LockUnlock()() + + schema, ok := s.Schemas[fullName] + if !ok { + return Response{StatusCode: http.StatusNotFound} + } + delete(s.Schemas, fullName) + if req.URL.Query().Get("force") == "true" { + for key, volume := range s.Volumes { + if volume.CatalogName == schema.CatalogName && volume.SchemaName == schema.Name { + delete(s.Volumes, key) + } + } + } + return Response{} +}