diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index c196e389c0..3260efbdf7 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -5,5 +5,6 @@ ### CLI ### Bundles +* engine/direct: Added support for Databricks App Spaces (`app_spaces` resource type) ([#4982](https://github.com/databricks/cli/pull/4982)) ### Dependency updates diff --git a/acceptance/bundle/deployment/bind/app_space/databricks.yml.tmpl b/acceptance/bundle/deployment/bind/app_space/databricks.yml.tmpl new file mode 100644 index 0000000000..4c8f5016ae --- /dev/null +++ b/acceptance/bundle/deployment/bind/app_space/databricks.yml.tmpl @@ -0,0 +1,8 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + app_spaces: + foo: + name: test-space-$UNIQUE_NAME + description: This is a test app space diff --git a/acceptance/bundle/deployment/bind/app_space/out.test.toml b/acceptance/bundle/deployment/bind/app_space/out.test.toml new file mode 100644 index 0000000000..19b2c349a3 --- /dev/null +++ b/acceptance/bundle/deployment/bind/app_space/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = true + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/deployment/bind/app_space/output.txt b/acceptance/bundle/deployment/bind/app_space/output.txt new file mode 100644 index 0000000000..b51a53fb24 --- /dev/null +++ b/acceptance/bundle/deployment/bind/app_space/output.txt @@ -0,0 +1,46 @@ + +>>> [CLI] apps create-space test-space-[UNIQUE_NAME] --description Pre-existing space +{ + "name": "test-space-[UNIQUE_NAME]", + "description": "Pre-existing space" +} + +>>> [CLI] bundle deployment bind foo test-space-[UNIQUE_NAME] --auto-approve +Updating deployment state... +Successfully bound app_space with an id 'test-space-[UNIQUE_NAME]' +Run 'bundle deploy' to deploy changes to your workspace + +=== Deploy bundle +>>> [CLI] bundle deploy --force-lock --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] apps get-space test-space-[UNIQUE_NAME] +{ + "name": "test-space-[UNIQUE_NAME]", + "description": "This is a test app space" +} + +=== Unbind app space +>>> [CLI] bundle deployment unbind foo +Updating deployment state... + +=== Destroy bundle +>>> [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 + +Deleting files... +Destroy complete! + +=== Read the pre-defined app space again (expecting it still exists): +>>> [CLI] apps get-space test-space-[UNIQUE_NAME] +{ + "name": "test-space-[UNIQUE_NAME]", + "description": "This is a test app space" +} + +=== Test cleanup +>>> [CLI] apps delete-space test-space-[UNIQUE_NAME] +0 diff --git a/acceptance/bundle/deployment/bind/app_space/script b/acceptance/bundle/deployment/bind/app_space/script new file mode 100644 index 0000000000..489b5a1fdc --- /dev/null +++ b/acceptance/bundle/deployment/bind/app_space/script @@ -0,0 +1,26 @@ +cleanup() { + title "Test cleanup" + trace $CLI apps delete-space "test-space-$UNIQUE_NAME" + echo $? +} +trap cleanup EXIT + +envsubst < databricks.yml.tmpl > databricks.yml + +trace $CLI apps create-space "test-space-$UNIQUE_NAME" --description "Pre-existing space" | jq '{name, description}' + +trace $CLI bundle deployment bind foo "test-space-$UNIQUE_NAME" --auto-approve + +title "Deploy bundle" +trace $CLI bundle deploy --force-lock --auto-approve + +trace $CLI apps get-space "test-space-$UNIQUE_NAME" | jq '{name, description}' + +title "Unbind app space" +trace $CLI bundle deployment unbind foo + +title "Destroy bundle" +trace $CLI bundle destroy --auto-approve + +title "Read the pre-defined app space again (expecting it still exists): " +trace $CLI apps get-space "test-space-$UNIQUE_NAME" | jq '{name, description}' diff --git a/acceptance/bundle/deployment/bind/app_space/test.toml b/acceptance/bundle/deployment/bind/app_space/test.toml new file mode 100644 index 0000000000..bc31b13cdb --- /dev/null +++ b/acceptance/bundle/deployment/bind/app_space/test.toml @@ -0,0 +1,10 @@ +Local = true +Cloud = true + +Ignore = [ + ".databricks", + "databricks.yml", +] + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/invariant/configs/app_space.yml.tmpl b/acceptance/bundle/invariant/configs/app_space.yml.tmpl new file mode 100644 index 0000000000..26f3355ceb --- /dev/null +++ b/acceptance/bundle/invariant/configs/app_space.yml.tmpl @@ -0,0 +1,7 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + app_spaces: + foo: + name: app-space-$UNIQUE_NAME diff --git a/acceptance/bundle/invariant/continue_293/out.test.toml b/acceptance/bundle/invariant/continue_293/out.test.toml index 0622360897..b99f949814 100644 --- a/acceptance/bundle/invariant/continue_293/out.test.toml +++ b/acceptance/bundle/invariant/continue_293/out.test.toml @@ -4,4 +4,4 @@ RequiresUnityCatalog = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] - INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_depends_on.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] + INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_depends_on.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] diff --git a/acceptance/bundle/invariant/migrate/out.test.toml b/acceptance/bundle/invariant/migrate/out.test.toml index 0622360897..b99f949814 100644 --- a/acceptance/bundle/invariant/migrate/out.test.toml +++ b/acceptance/bundle/invariant/migrate/out.test.toml @@ -4,4 +4,4 @@ RequiresUnityCatalog = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] - INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_depends_on.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] + INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_depends_on.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] diff --git a/acceptance/bundle/invariant/migrate/test.toml b/acceptance/bundle/invariant/migrate/test.toml index adc49c2992..e1fe849bd9 100644 --- a/acceptance/bundle/invariant/migrate/test.toml +++ b/acceptance/bundle/invariant/migrate/test.toml @@ -4,6 +4,7 @@ EnvMatrixExclude.no_vector_search_endpoint = ["INPUT_CONFIG=vector_search_endpoi # Error: Catalog resources are only supported with direct deployment mode EnvMatrixExclude.no_catalog = ["INPUT_CONFIG=catalog.yml.tmpl"] EnvMatrixExclude.no_external_location = ["INPUT_CONFIG=external_location.yml.tmpl"] +EnvMatrixExclude.no_app_space = ["INPUT_CONFIG=app_space.yml.tmpl"] # Cross-resource permission references (e.g. ${resources.jobs.job_b.permissions[0].level}) # don't work in terraform mode: the terraform interpolator converts the path to diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index 0622360897..b99f949814 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -4,4 +4,4 @@ RequiresUnityCatalog = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] - INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_depends_on.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] + INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_depends_on.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] diff --git a/acceptance/bundle/invariant/test.toml b/acceptance/bundle/invariant/test.toml index bb66a393be..96ddfe7104 100644 --- a/acceptance/bundle/invariant/test.toml +++ b/acceptance/bundle/invariant/test.toml @@ -23,6 +23,7 @@ EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [ EnvMatrix.INPUT_CONFIG = [ "alert.yml.tmpl", "app.yml.tmpl", + "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index c79b0d3533..ca9086720c 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -56,6 +56,75 @@ resources.alerts.*.permissions[*].group_name string ALL resources.alerts.*.permissions[*].level iam.PermissionLevel ALL resources.alerts.*.permissions[*].service_principal_name string ALL resources.alerts.*.permissions[*].user_name string ALL +resources.app_spaces.*.create_time *time.Time ALL +resources.app_spaces.*.creator string ALL +resources.app_spaces.*.description string ALL +resources.app_spaces.*.effective_usage_policy_id string ALL +resources.app_spaces.*.effective_user_api_scopes []string ALL +resources.app_spaces.*.effective_user_api_scopes[*] string ALL +resources.app_spaces.*.id string ALL +resources.app_spaces.*.lifecycle resources.Lifecycle INPUT +resources.app_spaces.*.lifecycle.prevent_destroy bool INPUT +resources.app_spaces.*.modified_status string INPUT +resources.app_spaces.*.name string ALL +resources.app_spaces.*.resources []apps.AppResource ALL +resources.app_spaces.*.resources[*] apps.AppResource ALL +resources.app_spaces.*.resources[*].app *apps.AppResourceApp ALL +resources.app_spaces.*.resources[*].app.name string ALL +resources.app_spaces.*.resources[*].app.permission apps.AppResourceAppAppPermission ALL +resources.app_spaces.*.resources[*].database *apps.AppResourceDatabase ALL +resources.app_spaces.*.resources[*].database.database_name string ALL +resources.app_spaces.*.resources[*].database.instance_name string ALL +resources.app_spaces.*.resources[*].database.permission apps.AppResourceDatabaseDatabasePermission ALL +resources.app_spaces.*.resources[*].description string ALL +resources.app_spaces.*.resources[*].experiment *apps.AppResourceExperiment ALL +resources.app_spaces.*.resources[*].experiment.experiment_id string ALL +resources.app_spaces.*.resources[*].experiment.permission apps.AppResourceExperimentExperimentPermission ALL +resources.app_spaces.*.resources[*].genie_space *apps.AppResourceGenieSpace ALL +resources.app_spaces.*.resources[*].genie_space.name string ALL +resources.app_spaces.*.resources[*].genie_space.permission apps.AppResourceGenieSpaceGenieSpacePermission ALL +resources.app_spaces.*.resources[*].genie_space.space_id string ALL +resources.app_spaces.*.resources[*].job *apps.AppResourceJob ALL +resources.app_spaces.*.resources[*].job.id string ALL +resources.app_spaces.*.resources[*].job.permission apps.AppResourceJobJobPermission ALL +resources.app_spaces.*.resources[*].name string ALL +resources.app_spaces.*.resources[*].postgres *apps.AppResourcePostgres ALL +resources.app_spaces.*.resources[*].postgres.branch string ALL +resources.app_spaces.*.resources[*].postgres.database string ALL +resources.app_spaces.*.resources[*].postgres.permission apps.AppResourcePostgresPostgresPermission ALL +resources.app_spaces.*.resources[*].secret *apps.AppResourceSecret ALL +resources.app_spaces.*.resources[*].secret.key string ALL +resources.app_spaces.*.resources[*].secret.permission apps.AppResourceSecretSecretPermission ALL +resources.app_spaces.*.resources[*].secret.scope string ALL +resources.app_spaces.*.resources[*].serving_endpoint *apps.AppResourceServingEndpoint ALL +resources.app_spaces.*.resources[*].serving_endpoint.name string ALL +resources.app_spaces.*.resources[*].serving_endpoint.permission apps.AppResourceServingEndpointServingEndpointPermission ALL +resources.app_spaces.*.resources[*].sql_warehouse *apps.AppResourceSqlWarehouse ALL +resources.app_spaces.*.resources[*].sql_warehouse.id string ALL +resources.app_spaces.*.resources[*].sql_warehouse.permission apps.AppResourceSqlWarehouseSqlWarehousePermission ALL +resources.app_spaces.*.resources[*].uc_securable *apps.AppResourceUcSecurable ALL +resources.app_spaces.*.resources[*].uc_securable.permission apps.AppResourceUcSecurableUcSecurablePermission ALL +resources.app_spaces.*.resources[*].uc_securable.securable_full_name string ALL +resources.app_spaces.*.resources[*].uc_securable.securable_kind string ALL +resources.app_spaces.*.resources[*].uc_securable.securable_type apps.AppResourceUcSecurableUcSecurableType ALL +resources.app_spaces.*.service_principal_client_id string ALL +resources.app_spaces.*.service_principal_id int64 ALL +resources.app_spaces.*.service_principal_name string ALL +resources.app_spaces.*.status *apps.SpaceStatus ALL +resources.app_spaces.*.status.message string ALL +resources.app_spaces.*.status.state apps.SpaceStatusSpaceState ALL +resources.app_spaces.*.update_time *time.Time ALL +resources.app_spaces.*.updater string ALL +resources.app_spaces.*.url string INPUT +resources.app_spaces.*.usage_policy_id string ALL +resources.app_spaces.*.user_api_scopes []string ALL +resources.app_spaces.*.user_api_scopes[*] string ALL +resources.app_spaces.*.permissions.object_id string ALL +resources.app_spaces.*.permissions[*] dresources.StatePermission ALL +resources.app_spaces.*.permissions[*].group_name string ALL +resources.app_spaces.*.permissions[*].level iam.PermissionLevel ALL +resources.app_spaces.*.permissions[*].service_principal_name string ALL +resources.app_spaces.*.permissions[*].user_name string ALL resources.apps.*.active_deployment *apps.AppDeployment ALL resources.apps.*.active_deployment.command []string ALL resources.apps.*.active_deployment.command[*] string ALL diff --git a/acceptance/bundle/resources/app_spaces/basic/databricks.yml b/acceptance/bundle/resources/app_spaces/basic/databricks.yml new file mode 100644 index 0000000000..14f028d7cf --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/basic/databricks.yml @@ -0,0 +1,17 @@ +bundle: + name: test-bundle + +resources: + app_spaces: + mykey: + name: myspacename + description: my_space_description + resources: + - name: my-serving-endpoint + serving_endpoint: + name: my-endpoint + permission: CAN_QUERY + - name: my-warehouse + sql_warehouse: + id: warehouse-id-1 + permission: CAN_USE diff --git a/acceptance/bundle/resources/app_spaces/basic/out.requests.direct.json b/acceptance/bundle/resources/app_spaces/basic/out.requests.direct.json new file mode 100644 index 0000000000..0dae61a297 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/basic/out.requests.direct.json @@ -0,0 +1,28 @@ +{ + "method": "POST", + "path": "/api/2.0/app-spaces", + "body": { + "description": "my_space_description", + "name": "myspacename", + "resources": [ + { + "name": "my-serving-endpoint", + "serving_endpoint": { + "name": "my-endpoint", + "permission": "CAN_QUERY" + } + }, + { + "name": "my-warehouse", + "sql_warehouse": { + "id": "warehouse-id-1", + "permission": "CAN_USE" + } + } + ] + } +} +{ + "method": "DELETE", + "path": "/api/2.0/app-spaces/myspacename" +} diff --git a/acceptance/bundle/resources/app_spaces/basic/out.test.toml b/acceptance/bundle/resources/app_spaces/basic/out.test.toml new file mode 100644 index 0000000000..54146af564 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/basic/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/app_spaces/basic/output.txt b/acceptance/bundle/resources/app_spaces/basic/output.txt new file mode 100644 index 0000000000..420d1192f8 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/basic/output.txt @@ -0,0 +1,45 @@ + +>>> [CLI] bundle validate +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Validation OK! + +>>> [CLI] bundle plan +create app_spaces.mykey + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py //app-spaces + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + App Spaces: + mykey: + Name: myspacename + URL: (not deployed) + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.app_spaces.mykey + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Deleting files... +Destroy complete! + +>>> print_requests.py //app-spaces diff --git a/acceptance/bundle/resources/app_spaces/basic/script b/acceptance/bundle/resources/app_spaces/basic/script new file mode 100644 index 0000000000..3ce322b1db --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/basic/script @@ -0,0 +1,7 @@ +trace $CLI bundle validate +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests.py //app-spaces > out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace $CLI bundle summary +trace $CLI bundle destroy --auto-approve +trace print_requests.py //app-spaces >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/app_spaces/basic/test.toml b/acceptance/bundle/resources/app_spaces/basic/test.toml new file mode 100644 index 0000000000..54146af564 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/basic/test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/app_spaces/recreate/databricks.yml b/acceptance/bundle/resources/app_spaces/recreate/databricks.yml new file mode 100644 index 0000000000..f051ad296c --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/recreate/databricks.yml @@ -0,0 +1,8 @@ +bundle: + name: test-bundle + +resources: + app_spaces: + mykey: + name: original-space-name + description: my_space_description diff --git a/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json b/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json new file mode 100644 index 0000000000..d2d13b09bb --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json @@ -0,0 +1,24 @@ +{ + "method": "POST", + "path": "/api/2.0/app-spaces", + "body": { + "description": "my_space_description", + "name": "original-space-name" + } +} +{ + "method": "DELETE", + "path": "/api/2.0/app-spaces/original-space-name" +} +{ + "method": "POST", + "path": "/api/2.0/app-spaces", + "body": { + "description": "my_space_description", + "name": "renamed-space" + } +} +{ + "method": "DELETE", + "path": "/api/2.0/app-spaces/renamed-space" +} diff --git a/acceptance/bundle/resources/app_spaces/recreate/out.test.toml b/acceptance/bundle/resources/app_spaces/recreate/out.test.toml new file mode 100644 index 0000000000..54146af564 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/recreate/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/app_spaces/recreate/output.txt b/acceptance/bundle/resources/app_spaces/recreate/output.txt new file mode 100644 index 0000000000..0e8d16e826 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/recreate/output.txt @@ -0,0 +1,52 @@ + +>>> [CLI] bundle plan +create app_spaces.mykey + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py //app-spaces + +=== Change name (immutable field - should recreate) +>>> update_file.py databricks.yml original-space-name renamed-space + +>>> [CLI] bundle plan +recreate app_spaces.mykey + +Plan: 1 to add, 0 to change, 1 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py //app-spaces + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + App Spaces: + mykey: + Name: renamed-space + URL: (not deployed) + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.app_spaces.mykey + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Deleting files... +Destroy complete! + +>>> print_requests.py //app-spaces diff --git a/acceptance/bundle/resources/app_spaces/recreate/script b/acceptance/bundle/resources/app_spaces/recreate/script new file mode 100644 index 0000000000..54f5503dc5 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/recreate/script @@ -0,0 +1,12 @@ +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests.py //app-spaces > out.requests.$DATABRICKS_BUNDLE_ENGINE.json + +title "Change name (immutable field - should recreate)" +trace update_file.py databricks.yml original-space-name renamed-space +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests.py //app-spaces >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace $CLI bundle summary +trace $CLI bundle destroy --auto-approve +trace print_requests.py //app-spaces >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/app_spaces/recreate/test.toml b/acceptance/bundle/resources/app_spaces/recreate/test.toml new file mode 100644 index 0000000000..54146af564 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/recreate/test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/app_spaces/update/databricks.yml b/acceptance/bundle/resources/app_spaces/update/databricks.yml new file mode 100644 index 0000000000..b64753cf42 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/update/databricks.yml @@ -0,0 +1,8 @@ +bundle: + name: test-bundle + +resources: + app_spaces: + mykey: + name: myspacename + description: my_space_description diff --git a/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json b/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json new file mode 100644 index 0000000000..d1df4cd7c6 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json @@ -0,0 +1,35 @@ +{ + "method": "POST", + "path": "/api/2.0/app-spaces", + "body": { + "description": "my_space_description", + "name": "myspacename" + } +} +{ + "method": "PATCH", + "path": "/api/2.0/app-spaces/myspacename", + "q": { + "update_mask": "description,resources,user_api_scopes,usage_policy_id" + }, + "body": { + "description": "MY_SPACE_DESCRIPTION", + "name": "myspacename" + } +} +{ + "method": "DELETE", + "path": "/api/2.0/app-spaces/myspacename" +} +{ + "method": "POST", + "path": "/api/2.0/app-spaces", + "body": { + "description": "MY_SPACE_DESCRIPTION", + "name": "mynewspacename" + } +} +{ + "method": "DELETE", + "path": "/api/2.0/app-spaces/mynewspacename" +} diff --git a/acceptance/bundle/resources/app_spaces/update/out.test.toml b/acceptance/bundle/resources/app_spaces/update/out.test.toml new file mode 100644 index 0000000000..54146af564 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/update/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/app_spaces/update/output.txt b/acceptance/bundle/resources/app_spaces/update/output.txt new file mode 100644 index 0000000000..8a4cab7e74 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/update/output.txt @@ -0,0 +1,107 @@ + +>>> [CLI] bundle plan +create app_spaces.mykey + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py //app-spaces + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + App Spaces: + mykey: + Name: myspacename + URL: (not deployed) + +=== Update description and re-deploy +>>> update_file.py databricks.yml my_space_description MY_SPACE_DESCRIPTION + +>>> [CLI] bundle plan +update app_spaces.mykey + +Plan: 0 to add, 1 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py //app-spaces + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + App Spaces: + mykey: + Name: myspacename + URL: (not deployed) + +=== Update name and re-deploy +>>> update_file.py databricks.yml myspacename mynewspacename + +>>> [CLI] bundle plan +recreate app_spaces.mykey + +Plan: 1 to add, 0 to change, 1 to delete, 0 unchanged + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + App Spaces: + mykey: + Name: myspacename + URL: (not deployed) + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py //app-spaces + +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + App Spaces: + mykey: + Name: mynewspacename + URL: (not deployed) + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.app_spaces.mykey + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Deleting files... +Destroy complete! + +>>> print_requests.py //app-spaces diff --git a/acceptance/bundle/resources/app_spaces/update/script b/acceptance/bundle/resources/app_spaces/update/script new file mode 100644 index 0000000000..2d6341f7b6 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/update/script @@ -0,0 +1,23 @@ +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests.py //app-spaces > out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace $CLI bundle summary + +title "Update description and re-deploy" +trace update_file.py databricks.yml my_space_description MY_SPACE_DESCRIPTION +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests.py //app-spaces >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace $CLI bundle summary + +title "Update name and re-deploy" +trace update_file.py databricks.yml myspacename mynewspacename +trace $CLI bundle plan +trace $CLI bundle summary +trace $CLI bundle deploy +trace print_requests.py //app-spaces >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json + +trace $CLI bundle plan +trace $CLI bundle summary +trace $CLI bundle destroy --auto-approve +trace print_requests.py //app-spaces >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/app_spaces/update/test.toml b/acceptance/bundle/resources/app_spaces/update/test.toml new file mode 100644 index 0000000000..54146af564 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/update/test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go index fd019479d7..8a3ff7128a 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go @@ -51,6 +51,11 @@ var ( permissions.CAN_MANAGE: "CAN_MANAGE", permissions.CAN_VIEW: "CAN_USE", }, + "app_spaces": { + permissions.CAN_MANAGE: "CAN_MANAGE", + permissions.CAN_VIEW: "CAN_READ", + permissions.CAN_RUN: "CAN_CREATE_APP", + }, "secret_scopes": { permissions.CAN_MANAGE: "MANAGE", permissions.CAN_VIEW: "READ", diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index c347de79df..aa74875352 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -78,6 +78,10 @@ func TestApplyBundlePermissions(t *testing.T) { "app_1": {}, "app_2": {}, }, + AppSpaces: map[string]*resources.AppSpace{ + "space_1": {}, + "space_2": {}, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "vs_1": {}, "vs_2": {}, @@ -143,6 +147,11 @@ func TestApplyBundlePermissions(t *testing.T) { require.Contains(t, b.Config.Resources.Apps["app_1"].Permissions, resources.AppPermission{Level: "CAN_MANAGE", UserName: "TestUser"}) require.Contains(t, b.Config.Resources.Apps["app_1"].Permissions, resources.AppPermission{Level: "CAN_USE", GroupName: "TestGroup"}) + require.Len(t, b.Config.Resources.AppSpaces["space_1"].Permissions, 3) + require.Contains(t, b.Config.Resources.AppSpaces["space_1"].Permissions, resources.AppSpacePermission{Level: "CAN_MANAGE", UserName: "TestUser"}) + require.Contains(t, b.Config.Resources.AppSpaces["space_1"].Permissions, resources.AppSpacePermission{Level: "CAN_READ", GroupName: "TestGroup"}) + require.Contains(t, b.Config.Resources.AppSpaces["space_1"].Permissions, resources.AppSpacePermission{Level: "CAN_CREATE_APP", ServicePrincipalName: "TestServicePrincipal"}) + require.Len(t, b.Config.Resources.VectorSearchEndpoints["vs_1"].Permissions, 2) require.Contains(t, b.Config.Resources.VectorSearchEndpoints["vs_1"].Permissions, resources.Permission{Level: "CAN_MANAGE", UserName: "TestUser"}) require.Contains(t, b.Config.Resources.VectorSearchEndpoints["vs_1"].Permissions, resources.Permission{Level: "CAN_USE", GroupName: "TestGroup"}) diff --git a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go index ce299d341b..eb4631ca25 100644 --- a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go +++ b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go @@ -161,6 +161,13 @@ func mockBundle(mode config.Mode) *bundle.Bundle { }, }, }, + AppSpaces: map[string]*resources.AppSpace{ + "app_space1": { + Space: apps.Space{ + Name: "app-space-1", + }, + }, + }, SecretScopes: map[string]*resources.SecretScope{ "secretScope1": { Name: "secretScope1", @@ -442,7 +449,7 @@ func TestAllNonUcResourcesAreRenamed(t *testing.T) { // Skip resources that are not renamed (either because they don't have a user-facing Name field, // or because their Name is server-generated rather than user-specified) - if resourceType == "Apps" || resourceType == "SecretScopes" || resourceType == "DatabaseInstances" || resourceType == "DatabaseCatalogs" || resourceType == "SyncedDatabaseTables" || resourceType == "PostgresProjects" || resourceType == "PostgresBranches" || resourceType == "PostgresEndpoints" { + if resourceType == "Apps" || resourceType == "AppSpaces" || resourceType == "SecretScopes" || resourceType == "DatabaseInstances" || resourceType == "DatabaseCatalogs" || resourceType == "SyncedDatabaseTables" || resourceType == "PostgresProjects" || resourceType == "PostgresBranches" || resourceType == "PostgresEndpoints" { continue } diff --git a/bundle/config/mutator/resourcemutator/merge_app_spaces.go b/bundle/config/mutator/resourcemutator/merge_app_spaces.go new file mode 100644 index 0000000000..573ea3afa3 --- /dev/null +++ b/bundle/config/mutator/resourcemutator/merge_app_spaces.go @@ -0,0 +1,83 @@ +package resourcemutator + +import ( + "context" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/diag" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/merge" +) + +type mergeAppSpaces struct{} + +func MergeAppSpaces() bundle.Mutator { + return &mergeAppSpaces{} +} + +func (m *mergeAppSpaces) Name() string { + return "MergeAppSpaces" +} + +func (m *mergeAppSpaces) resourceName(v dyn.Value) string { + switch v.Kind() { + case dyn.KindInvalid, dyn.KindNil: + return "" + case dyn.KindString: + return v.MustString() + default: + // Validated in Apply before this is reached; unreachable under normal operation. + return "" + } +} + +// validateResourceNames walks resources.app_spaces.*.resources[*].name and returns +// diagnostics for any entries where the name is not a string. +func (m *mergeAppSpaces) validateResourceNames(root dyn.Value) diag.Diagnostics { + var diags diag.Diagnostics + + spaces := root.Get("resources").Get("app_spaces") + if spaces.Kind() != dyn.KindMap { + return nil + } + + for _, spaceKV := range spaces.MustMap().Pairs() { + resources := spaceKV.Value.Get("resources") + if resources.Kind() != dyn.KindSequence { + continue + } + for _, r := range resources.MustSequence() { + name := r.Get("name") + switch name.Kind() { + case dyn.KindInvalid, dyn.KindNil, dyn.KindString: + continue + default: + diags = diags.Extend(diag.Diagnostics{{ + Summary: "app space resource name must be a string", + Locations: []dyn.Location{name.Location()}, + Severity: diag.Error, + }}) + } + } + } + + return diags +} + +func (m *mergeAppSpaces) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { + if diags := m.validateResourceNames(b.Config.Value()); diags.HasError() { + return diags + } + + err := b.Config.Mutate(func(v dyn.Value) (dyn.Value, error) { + if v.Kind() == dyn.KindNil { + return v, nil + } + + return dyn.Map(v, "resources.app_spaces", dyn.Foreach(func(_ dyn.Path, space dyn.Value) (dyn.Value, error) { + return dyn.Map(space, "resources", merge.ElementsByKeyWithOverride("name", m.resourceName)) + })) + }) + + return diag.FromErr(err) +} diff --git a/bundle/config/mutator/resourcemutator/resource_mutator.go b/bundle/config/mutator/resourcemutator/resource_mutator.go index 2eb292cfbb..9067f84432 100644 --- a/bundle/config/mutator/resourcemutator/resource_mutator.go +++ b/bundle/config/mutator/resourcemutator/resource_mutator.go @@ -167,6 +167,10 @@ func applyNormalizeMutators(ctx context.Context, b *bundle.Bundle) { // Updates (dynamic): resources.apps.*.resources (merges app resources with the same name) MergeApps(), + // Reads (dynamic): resources.app_spaces.*.resources (reads app space resources to merge) + // Updates (dynamic): resources.app_spaces.*.resources (merges app space resources with the same name) + MergeAppSpaces(), + // Reads (dynamic): resources.{catalogs,schemas,external_locations,volumes,registered_models}.*.grants // Updates (dynamic): same paths — merges grant entries by principal and deduplicates privileges MergeGrants(), diff --git a/bundle/config/mutator/resourcemutator/run_as.go b/bundle/config/mutator/resourcemutator/run_as.go index 4f5e3ce903..35fdaf12c6 100644 --- a/bundle/config/mutator/resourcemutator/run_as.go +++ b/bundle/config/mutator/resourcemutator/run_as.go @@ -126,6 +126,16 @@ func validateRunAs(b *bundle.Bundle) diag.Diagnostics { )) } + // App spaces do not support run_as in the API. + if len(b.Config.Resources.AppSpaces) > 0 { + diags = diags.Extend(reportRunAsNotSupported( + "app_spaces", + b.Config.GetLocation("resources.app_spaces"), + b.Config.Workspace.CurrentUser.UserName, + identity, + )) + } + return diags } diff --git a/bundle/config/mutator/resourcemutator/run_as_test.go b/bundle/config/mutator/resourcemutator/run_as_test.go index 0b7003f587..2a2e096d6e 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -33,6 +33,7 @@ func allResourceTypes(t *testing.T) []string { // also update this check when adding a new resource require.Equal(t, []string{ "alerts", + "app_spaces", "apps", "catalogs", "clusters", diff --git a/bundle/config/mutator/validate_direct_only_resources.go b/bundle/config/mutator/validate_direct_only_resources.go index 5717497205..c56298ffa1 100644 --- a/bundle/config/mutator/validate_direct_only_resources.go +++ b/bundle/config/mutator/validate_direct_only_resources.go @@ -54,6 +54,18 @@ var directOnlyResources = []directOnlyResource{ return result }, }, + { + resourceType: "app_spaces", + pluralName: "App Space", + singularName: "app space", + getResources: func(b *bundle.Bundle) map[string]any { + result := make(map[string]any) + for k, v := range b.Config.Resources.AppSpaces { + result[k] = v + } + return result + }, + }, } type validateDirectOnlyResources struct { diff --git a/bundle/config/resources.go b/bundle/config/resources.go index 225ec32165..f0b26a6c3d 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -26,6 +26,7 @@ type Resources struct { Clusters map[string]*resources.Cluster `json:"clusters,omitempty"` Dashboards map[string]*resources.Dashboard `json:"dashboards,omitempty"` Apps map[string]*resources.App `json:"apps,omitempty"` + AppSpaces map[string]*resources.AppSpace `json:"app_spaces,omitempty"` SecretScopes map[string]*resources.SecretScope `json:"secret_scopes,omitempty"` Alerts map[string]*resources.Alert `json:"alerts,omitempty"` SqlWarehouses map[string]*resources.SqlWarehouse `json:"sql_warehouses,omitempty"` @@ -103,6 +104,7 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["dashboards"], r.Dashboards), collectResourceMap(descriptions["volumes"], r.Volumes), collectResourceMap(descriptions["apps"], r.Apps), + collectResourceMap(descriptions["app_spaces"], r.AppSpaces), collectResourceMap(descriptions["alerts"], r.Alerts), collectResourceMap(descriptions["secret_scopes"], r.SecretScopes), collectResourceMap(descriptions["sql_warehouses"], r.SqlWarehouses), @@ -158,6 +160,7 @@ func SupportedResources() map[string]resources.ResourceDescription { "dashboards": (&resources.Dashboard{}).ResourceDescription(), "volumes": (&resources.Volume{}).ResourceDescription(), "apps": (&resources.App{}).ResourceDescription(), + "app_spaces": (&resources.AppSpace{}).ResourceDescription(), "secret_scopes": (&resources.SecretScope{}).ResourceDescription(), "alerts": (&resources.Alert{}).ResourceDescription(), "sql_warehouses": (&resources.SqlWarehouse{}).ResourceDescription(), diff --git a/bundle/config/resources/app_spaces.go b/bundle/config/resources/app_spaces.go new file mode 100644 index 0000000000..9b286b3c07 --- /dev/null +++ b/bundle/config/resources/app_spaces.go @@ -0,0 +1,63 @@ +package resources + +import ( + "context" + "net/url" + + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/marshal" + "github.com/databricks/databricks-sdk-go/service/apps" +) + +type AppSpace struct { + BaseResource + apps.Space //nolint:govet // Space struct also defines Id field with the same json tag "id" + + Permissions []AppSpacePermission `json:"permissions,omitempty"` +} + +func (s *AppSpace) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, s) +} + +func (s AppSpace) MarshalJSON() ([]byte, error) { + return marshal.Marshal(s) +} + +func (s *AppSpace) Exists(ctx context.Context, w *databricks.WorkspaceClient, id string) (bool, error) { + _, err := w.Apps.GetSpace(ctx, apps.GetSpaceRequest{Name: id}) + if err != nil { + log.Debugf(ctx, "app space with id %s does not exist: %v", id, err) + if apierr.IsMissing(err) { + return false, nil + } + return false, err + } + return true, nil +} + +func (*AppSpace) ResourceDescription() ResourceDescription { + return ResourceDescription{ + SingularName: "app_space", + PluralName: "app_spaces", + SingularTitle: "App Space", + PluralTitle: "App Spaces", + } +} + +func (s *AppSpace) InitializeURL(_ url.URL) { + // App spaces do not currently have a stable UI URL. +} + +func (s *AppSpace) GetName() string { + if s.ID != "" { + return s.ID + } + return s.Name +} + +func (s *AppSpace) GetURL() string { + return s.URL +} diff --git a/bundle/config/resources/permission_types.go b/bundle/config/resources/permission_types.go index 3029ee40b8..076894536e 100644 --- a/bundle/config/resources/permission_types.go +++ b/bundle/config/resources/permission_types.go @@ -25,6 +25,7 @@ func (p Permission) String() string { // If the SDK exposes a resource's permission level, add it here. type ( AppPermission PermissionT[apps.AppPermissionLevel] + AppSpacePermission PermissionT[iam.PermissionLevel] ClusterPermission PermissionT[compute.ClusterPermissionLevel] JobPermission PermissionT[jobs.JobPermissionLevel] MlflowExperimentPermission PermissionT[ml.ExperimentPermissionLevel] diff --git a/bundle/config/resources_test.go b/bundle/config/resources_test.go index 576f0db6e4..aeb4b32f78 100644 --- a/bundle/config/resources_test.go +++ b/bundle/config/resources_test.go @@ -205,6 +205,11 @@ func TestResourcesBindSupport(t *testing.T) { App: apps.App{}, }, }, + AppSpaces: map[string]*resources.AppSpace{ + "my_app_space": { + Space: apps.Space{}, + }, + }, Alerts: map[string]*resources.Alert{ "my_alert": { AlertV2: sql.AlertV2{}, @@ -300,6 +305,7 @@ func TestResourcesBindSupport(t *testing.T) { m.GetMockLakeviewAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockVolumesAPI().EXPECT().Read(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockAppsAPI().EXPECT().GetByName(mock.Anything, mock.Anything).Return(nil, nil) + m.GetMockAppsAPI().EXPECT().GetSpace(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockAlertsV2API().EXPECT().GetAlertById(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockQualityMonitorsAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockServingEndpointsAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) diff --git a/bundle/deploy/terraform/lifecycle_test.go b/bundle/deploy/terraform/lifecycle_test.go index 7f56248bb4..56c3253b12 100644 --- a/bundle/deploy/terraform/lifecycle_test.go +++ b/bundle/deploy/terraform/lifecycle_test.go @@ -18,6 +18,7 @@ func TestConvertLifecycleForAllResources(t *testing.T) { "catalogs", "external_locations", "vector_search_endpoints", + "app_spaces", } for resourceType := range supportedResources { diff --git a/bundle/direct/dresources/all.go b/bundle/direct/dresources/all.go index ddc30c41f5..f4a58234b0 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -16,6 +16,7 @@ var SupportedResources = map[string]any{ "volumes": (*ResourceVolume)(nil), "models": (*ResourceMlflowModel)(nil), "apps": (*ResourceApp)(nil), + "app_spaces": (*ResourceAppSpace)(nil), "sql_warehouses": (*ResourceSqlWarehouse)(nil), "database_instances": (*ResourceDatabaseInstance)(nil), "database_catalogs": (*ResourceDatabaseCatalog)(nil), @@ -36,6 +37,7 @@ var SupportedResources = map[string]any{ "jobs.permissions": (*ResourcePermissions)(nil), "pipelines.permissions": (*ResourcePermissions)(nil), "apps.permissions": (*ResourcePermissions)(nil), + "app_spaces.permissions": (*ResourcePermissions)(nil), "alerts.permissions": (*ResourcePermissions)(nil), "clusters.permissions": (*ResourcePermissions)(nil), "database_instances.permissions": (*ResourcePermissions)(nil), diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 9f0dc07e90..4bffa3a5b1 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -40,6 +40,12 @@ var testConfig map[string]any = map[string]any{ }, }, + "app_spaces": &resources.AppSpace{ + Space: apps.Space{ + Name: "my-app-space", + }, + }, + "catalogs": &resources.Catalog{ CreateCatalog: catalog.CreateCatalog{ Name: "mycatalog", @@ -374,6 +380,29 @@ var testDeps = map[string]prepareWorkspace{ }, nil }, + "app_spaces.permissions": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { + waiter, err := client.Apps.CreateSpace(ctx, apps.CreateSpaceRequest{ + Space: apps.Space{ + Name: "space-permissions", + }, + }) + if err != nil { + return nil, err + } + space, err := waiter.Wait(ctx) + if err != nil { + return nil, err + } + + return &PermissionsState{ + ObjectID: "/app-spaces/" + space.Name, + EmbeddedSlice: []StatePermission{{ + Level: "CAN_MANAGE", + UserName: "user@example.com", + }}, + }, nil + }, + "sql_warehouses.permissions": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { return &PermissionsState{ ObjectID: "/sql/warehouses/warehouse-permissions", diff --git a/bundle/direct/dresources/apitypes.generated.yml b/bundle/direct/dresources/apitypes.generated.yml index a80b3baa69..440f1295aa 100644 --- a/bundle/direct/dresources/apitypes.generated.yml +++ b/bundle/direct/dresources/apitypes.generated.yml @@ -2,6 +2,8 @@ alerts: sql.AlertV2 +app_spaces: apps.Space + apps: apps.App catalogs: catalog.CreateCatalog diff --git a/bundle/direct/dresources/app_space.go b/bundle/direct/dresources/app_space.go new file mode 100644 index 0000000000..1ee7d51a96 --- /dev/null +++ b/bundle/direct/dresources/app_space.go @@ -0,0 +1,60 @@ +package dresources + +import ( + "context" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/common/types/fieldmask" + "github.com/databricks/databricks-sdk-go/service/apps" +) + +type ResourceAppSpace struct { + client *databricks.WorkspaceClient +} + +func (*ResourceAppSpace) New(client *databricks.WorkspaceClient) *ResourceAppSpace { + return &ResourceAppSpace{client: client} +} + +func (*ResourceAppSpace) PrepareState(input *resources.AppSpace) *apps.Space { + return &input.Space +} + +func (r *ResourceAppSpace) DoRead(ctx context.Context, id string) (*apps.Space, error) { + return r.client.Apps.GetSpace(ctx, apps.GetSpaceRequest{Name: id}) +} + +func (r *ResourceAppSpace) DoCreate(ctx context.Context, config *apps.Space) (string, *apps.Space, error) { + waiter, err := r.client.Apps.CreateSpace(ctx, apps.CreateSpaceRequest{ + Space: *config, + }) + if err != nil { + return "", nil, err + } + space, err := waiter.Wait(ctx) + if err != nil { + return "", nil, err + } + return space.Name, space, nil +} + +func (r *ResourceAppSpace) DoUpdate(ctx context.Context, id string, config *apps.Space, _ *PlanEntry) (*apps.Space, error) { + waiter, err := r.client.Apps.UpdateSpace(ctx, apps.UpdateSpaceRequest{ + Name: id, + Space: *config, + UpdateMask: fieldmask.FieldMask{Paths: []string{"description", "resources", "user_api_scopes", "usage_policy_id"}}, + }) + if err != nil { + return nil, err + } + return waiter.Wait(ctx) +} + +func (r *ResourceAppSpace) DoDelete(ctx context.Context, id string) error { + waiter, err := r.client.Apps.DeleteSpace(ctx, apps.DeleteSpaceRequest{Name: id}) + if err != nil { + return err + } + return waiter.Wait(ctx) +} diff --git a/bundle/direct/dresources/permissions.go b/bundle/direct/dresources/permissions.go index eac5e2dcdb..6ac7d1ce8e 100644 --- a/bundle/direct/dresources/permissions.go +++ b/bundle/direct/dresources/permissions.go @@ -16,6 +16,7 @@ import ( var permissionResourceToObjectType = map[string]string{ "alerts": "/alertsv2/", "apps": "/apps/", + "app_spaces": "/app-spaces/", "clusters": "/clusters/", "dashboards": "/dashboards/", "database_instances": "/database-instances/", diff --git a/bundle/direct/dresources/resources.generated.yml b/bundle/direct/dresources/resources.generated.yml index 6c3778d349..4577059836 100644 --- a/bundle/direct/dresources/resources.generated.yml +++ b/bundle/direct/dresources/resources.generated.yml @@ -24,6 +24,32 @@ resources: - field: update_time reason: spec:output_only + app_spaces: + + ignore_remote_changes: + - field: create_time + reason: spec:output_only + - field: creator + reason: spec:output_only + - field: effective_usage_policy_id + reason: spec:output_only + - field: effective_user_api_scopes + reason: spec:output_only + - field: id + reason: spec:output_only + - field: service_principal_client_id + reason: spec:output_only + - field: service_principal_id + reason: spec:output_only + - field: service_principal_name + reason: spec:output_only + - field: status + reason: spec:output_only + - field: update_time + reason: spec:output_only + - field: updater + reason: spec:output_only + apps: ignore_remote_changes: diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 569fca9ee8..963db99090 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -371,6 +371,11 @@ resources: - field: dataset_schema reason: input_only + app_spaces: + recreate_on_changes: + - field: name + reason: immutable + apps: recreate_on_changes: - field: name diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index c869a19926..b43552d19e 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -160,6 +160,9 @@ github.com/databricks/cli/bundle/config.Resources: "alerts": "description": |- PLACEHOLDER + "app_spaces": + "description": |- + PLACEHOLDER "apps": "description": |- The app resource defines a Databricks app. @@ -561,6 +564,74 @@ github.com/databricks/cli/bundle/config/resources.AppPermission: "user_name": "description": |- PLACEHOLDER +github.com/databricks/cli/bundle/config/resources.AppSpace: + "create_time": + "description": |- + PLACEHOLDER + "creator": + "description": |- + PLACEHOLDER + "description": + "description": |- + PLACEHOLDER + "effective_usage_policy_id": + "description": |- + PLACEHOLDER + "effective_user_api_scopes": + "description": |- + PLACEHOLDER + "id": + "description": |- + PLACEHOLDER + "lifecycle": + "description": |- + PLACEHOLDER + "name": + "description": |- + PLACEHOLDER + "permissions": + "description": |- + PLACEHOLDER + "resources": + "description": |- + PLACEHOLDER + "service_principal_client_id": + "description": |- + PLACEHOLDER + "service_principal_id": + "description": |- + PLACEHOLDER + "service_principal_name": + "description": |- + PLACEHOLDER + "status": + "description": |- + PLACEHOLDER + "update_time": + "description": |- + PLACEHOLDER + "updater": + "description": |- + PLACEHOLDER + "usage_policy_id": + "description": |- + PLACEHOLDER + "user_api_scopes": + "description": |- + PLACEHOLDER +github.com/databricks/cli/bundle/config/resources.AppSpacePermission: + "group_name": + "description": |- + PLACEHOLDER + "level": + "description": |- + PLACEHOLDER + "service_principal_name": + "description": |- + PLACEHOLDER + "user_name": + "description": |- + PLACEHOLDER github.com/databricks/cli/bundle/config/resources.Catalog: "comment": "description": |- diff --git a/bundle/internal/validation/generated/enum_fields.go b/bundle/internal/validation/generated/enum_fields.go index 33632c268f..f76e5acfe7 100644 --- a/bundle/internal/validation/generated/enum_fields.go +++ b/bundle/internal/validation/generated/enum_fields.go @@ -19,6 +19,20 @@ var EnumFields = map[string][]string{ "resources.alerts.*.permissions[*].level": {"CAN_ATTACH_TO", "CAN_BIND", "CAN_CREATE", "CAN_CREATE_APP", "CAN_EDIT", "CAN_EDIT_METADATA", "CAN_MANAGE", "CAN_MANAGE_PRODUCTION_VERSIONS", "CAN_MANAGE_RUN", "CAN_MANAGE_STAGING_VERSIONS", "CAN_MONITOR", "CAN_MONITOR_ONLY", "CAN_QUERY", "CAN_READ", "CAN_RESTART", "CAN_RUN", "CAN_USE", "CAN_VIEW", "CAN_VIEW_METADATA", "IS_OWNER"}, "resources.alerts.*.schedule.pause_status": {"PAUSED", "UNPAUSED"}, + "resources.app_spaces.*.permissions[*].level": {"CAN_ATTACH_TO", "CAN_BIND", "CAN_CREATE", "CAN_CREATE_APP", "CAN_EDIT", "CAN_EDIT_METADATA", "CAN_MANAGE", "CAN_MANAGE_PRODUCTION_VERSIONS", "CAN_MANAGE_RUN", "CAN_MANAGE_STAGING_VERSIONS", "CAN_MONITOR", "CAN_MONITOR_ONLY", "CAN_QUERY", "CAN_READ", "CAN_RESTART", "CAN_RUN", "CAN_USE", "CAN_VIEW", "CAN_VIEW_METADATA", "IS_OWNER"}, + "resources.app_spaces.*.resources[*].app.permission": {"CAN_USE"}, + "resources.app_spaces.*.resources[*].database.permission": {"CAN_CONNECT_AND_CREATE"}, + "resources.app_spaces.*.resources[*].experiment.permission": {"CAN_EDIT", "CAN_MANAGE", "CAN_READ"}, + "resources.app_spaces.*.resources[*].genie_space.permission": {"CAN_EDIT", "CAN_MANAGE", "CAN_RUN", "CAN_VIEW"}, + "resources.app_spaces.*.resources[*].job.permission": {"CAN_MANAGE", "CAN_MANAGE_RUN", "CAN_VIEW", "IS_OWNER"}, + "resources.app_spaces.*.resources[*].postgres.permission": {"CAN_CONNECT_AND_CREATE"}, + "resources.app_spaces.*.resources[*].secret.permission": {"MANAGE", "READ", "WRITE"}, + "resources.app_spaces.*.resources[*].serving_endpoint.permission": {"CAN_MANAGE", "CAN_QUERY", "CAN_VIEW"}, + "resources.app_spaces.*.resources[*].sql_warehouse.permission": {"CAN_MANAGE", "CAN_USE", "IS_OWNER"}, + "resources.app_spaces.*.resources[*].uc_securable.permission": {"EXECUTE", "MODIFY", "READ_VOLUME", "SELECT", "USE_CONNECTION", "WRITE_VOLUME"}, + "resources.app_spaces.*.resources[*].uc_securable.securable_type": {"CONNECTION", "FUNCTION", "TABLE", "VOLUME"}, + "resources.app_spaces.*.status.state": {"SPACE_ACTIVE", "SPACE_CREATING", "SPACE_DELETED", "SPACE_DELETING", "SPACE_ERROR", "SPACE_UPDATING"}, + "resources.apps.*.active_deployment.mode": {"AUTO_SYNC", "SNAPSHOT"}, "resources.apps.*.active_deployment.status.state": {"CANCELLED", "FAILED", "IN_PROGRESS", "SUCCEEDED"}, "resources.apps.*.app_status.state": {"CRASHED", "DEPLOYING", "RUNNING", "UNAVAILABLE"}, diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index db86398acc..e52cedf3a8 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -18,6 +18,18 @@ var RequiredFields = map[string][]string{ "resources.alerts.*.permissions[*]": {"level"}, "resources.alerts.*.schedule": {"quartz_cron_schedule", "timezone_id"}, + "resources.app_spaces.*": {"name"}, + "resources.app_spaces.*.permissions[*]": {"level"}, + "resources.app_spaces.*.resources[*]": {"name"}, + "resources.app_spaces.*.resources[*].database": {"database_name", "instance_name", "permission"}, + "resources.app_spaces.*.resources[*].experiment": {"experiment_id", "permission"}, + "resources.app_spaces.*.resources[*].genie_space": {"name", "permission", "space_id"}, + "resources.app_spaces.*.resources[*].job": {"id", "permission"}, + "resources.app_spaces.*.resources[*].secret": {"key", "permission", "scope"}, + "resources.app_spaces.*.resources[*].serving_endpoint": {"name", "permission"}, + "resources.app_spaces.*.resources[*].sql_warehouse": {"id", "permission"}, + "resources.app_spaces.*.resources[*].uc_securable": {"permission", "securable_full_name", "securable_type"}, + "resources.apps.*": {"name"}, "resources.apps.*.active_deployment.git_source.git_repository": {"provider", "url"}, "resources.apps.*.config.env[*]": {"name"}, diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 03654f1f63..6819b9ce1d 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -284,6 +284,106 @@ } ] }, + "resources.AppSpace": { + "oneOf": [ + { + "type": "object", + "properties": { + "create_time": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/common/types/time.Time" + }, + "creator": { + "$ref": "#/$defs/string" + }, + "description": { + "$ref": "#/$defs/string" + }, + "effective_usage_policy_id": { + "$ref": "#/$defs/string" + }, + "effective_user_api_scopes": { + "$ref": "#/$defs/slice/string" + }, + "id": { + "$ref": "#/$defs/string" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "name": { + "$ref": "#/$defs/string" + }, + "permissions": { + "$ref": "#/$defs/slice/github.com/databricks/cli/bundle/config/resources.AppSpacePermission" + }, + "resources": { + "$ref": "#/$defs/slice/github.com/databricks/databricks-sdk-go/service/apps.AppResource" + }, + "service_principal_client_id": { + "$ref": "#/$defs/string" + }, + "service_principal_id": { + "$ref": "#/$defs/int64" + }, + "service_principal_name": { + "$ref": "#/$defs/string" + }, + "status": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/apps.SpaceStatus" + }, + "update_time": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/common/types/time.Time" + }, + "updater": { + "$ref": "#/$defs/string" + }, + "usage_policy_id": { + "$ref": "#/$defs/string" + }, + "user_api_scopes": { + "$ref": "#/$defs/slice/string" + } + }, + "additionalProperties": false, + "required": [ + "name" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "resources.AppSpacePermission": { + "oneOf": [ + { + "type": "object", + "properties": { + "group_name": { + "$ref": "#/$defs/string" + }, + "level": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/iam.PermissionLevel" + }, + "service_principal_name": { + "$ref": "#/$defs/string" + }, + "user_name": { + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "level" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.Catalog": { "oneOf": [ { @@ -2471,6 +2571,9 @@ "alerts": { "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.Alert" }, + "app_spaces": { + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.AppSpace" + }, "apps": { "description": "The app resource defines a Databricks app.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.App", @@ -3572,6 +3675,29 @@ } ] }, + "apps.SpaceStatus": { + "oneOf": [ + { + "type": "object", + "properties": { + "message": { + "$ref": "#/$defs/string" + }, + "state": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/apps.SpaceStatusSpaceState" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "apps.SpaceStatusSpaceState": { + "type": "string" + }, "apps.TelemetryExportDestination": { "oneOf": [ { @@ -11201,6 +11327,20 @@ } ] }, + "resources.AppSpace": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.AppSpace" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.Catalog": { "oneOf": [ { @@ -11647,6 +11787,20 @@ } ] }, + "resources.AppSpacePermission": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.AppSpacePermission" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.ClusterPermission": { "oneOf": [ { diff --git a/bundle/statemgmt/state_load_test.go b/bundle/statemgmt/state_load_test.go index 34c4fa4f5a..8e7e863867 100644 --- a/bundle/statemgmt/state_load_test.go +++ b/bundle/statemgmt/state_load_test.go @@ -40,6 +40,7 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { "resources.clusters.test_cluster": {ID: "1"}, "resources.dashboards.test_dashboard": {ID: "1"}, "resources.apps.test_app": {ID: "app1"}, + "resources.app_spaces.test_app_space": {ID: "app-space-1"}, "resources.secret_scopes.test_secret_scope": {ID: "secret_scope1"}, "resources.sql_warehouses.test_sql_warehouse": {ID: "1"}, "resources.database_instances.test_database_instance": {ID: "1"}, @@ -97,6 +98,10 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { assert.Equal(t, "", config.Resources.Apps["test_app"].Name) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.Apps["test_app"].ModifiedStatus) + assert.Equal(t, "app-space-1", config.Resources.AppSpaces["test_app_space"].ID) + assert.Equal(t, "", config.Resources.AppSpaces["test_app_space"].Name) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.AppSpaces["test_app_space"].ModifiedStatus) + assert.Equal(t, "secret_scope1", config.Resources.SecretScopes["test_secret_scope"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.SecretScopes["test_secret_scope"].ModifiedStatus) @@ -226,6 +231,13 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { }, }, }, + AppSpaces: map[string]*resources.AppSpace{ + "test_app_space": { + Space: apps.Space{ + Description: "test_app_space", + }, + }, + }, SecretScopes: map[string]*resources.SecretScope{ "test_secret_scope": { Name: "test_secret_scope", @@ -347,6 +359,9 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { assert.Equal(t, "", config.Resources.Apps["test_app"].Name) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Apps["test_app"].ModifiedStatus) + assert.Equal(t, "", config.Resources.AppSpaces["test_app_space"].Name) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.AppSpaces["test_app_space"].ModifiedStatus) + assert.Equal(t, "", config.Resources.SecretScopes["test_secret_scope"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.SecretScopes["test_secret_scope"].ModifiedStatus) @@ -547,6 +562,18 @@ func TestStateToBundleModifiedResources(t *testing.T) { }, }, }, + AppSpaces: map[string]*resources.AppSpace{ + "test_app_space": { + Space: apps.Space{ + Name: "test_app_space", + }, + }, + "test_app_space_new": { + Space: apps.Space{ + Name: "test_app_space_new", + }, + }, + }, SecretScopes: map[string]*resources.SecretScope{ "test_secret_scope": { Name: "test_secret_scope", @@ -702,6 +729,8 @@ func TestStateToBundleModifiedResources(t *testing.T) { "resources.dashboards.test_dashboard_old": {ID: "2"}, "resources.apps.test_app": {ID: "test_app"}, "resources.apps.test_app_old": {ID: "test_app_old"}, + "resources.app_spaces.test_app_space": {ID: "test_app_space"}, + "resources.app_spaces.test_app_space_old": {ID: "test_app_space_old"}, "resources.secret_scopes.test_secret_scope": {ID: "test_secret_scope"}, "resources.secret_scopes.test_secret_scope_old": {ID: "test_secret_scope_old"}, "resources.sql_warehouses.test_sql_warehouse": {ID: "1"}, @@ -814,6 +843,14 @@ func TestStateToBundleModifiedResources(t *testing.T) { assert.Equal(t, "test_app_new", config.Resources.Apps["test_app_new"].Name) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Apps["test_app_new"].ModifiedStatus) + assert.Equal(t, "test_app_space", config.Resources.AppSpaces["test_app_space"].Name) + assert.Equal(t, "", config.Resources.AppSpaces["test_app_space"].ModifiedStatus) + assert.Equal(t, "test_app_space_old", config.Resources.AppSpaces["test_app_space_old"].ID) + assert.Equal(t, "", config.Resources.AppSpaces["test_app_space_old"].Name) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.AppSpaces["test_app_space_old"].ModifiedStatus) + assert.Equal(t, "test_app_space_new", config.Resources.AppSpaces["test_app_space_new"].Name) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.AppSpaces["test_app_space_new"].ModifiedStatus) + assert.Equal(t, "test_secret_scope", config.Resources.SecretScopes["test_secret_scope"].Name) assert.Equal(t, "", config.Resources.SecretScopes["test_secret_scope"].ModifiedStatus) assert.Equal(t, "test_secret_scope_old", config.Resources.SecretScopes["test_secret_scope_old"].ID) diff --git a/libs/testserver/app_spaces.go b/libs/testserver/app_spaces.go new file mode 100644 index 0000000000..88d33a55f9 --- /dev/null +++ b/libs/testserver/app_spaces.go @@ -0,0 +1,103 @@ +package testserver + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/databricks/databricks-sdk-go/service/apps" +) + +func (s *FakeWorkspace) AppSpaceUpsert(req Request, name string) Response { + var space apps.Space + if err := json.Unmarshal(req.Body, &space); err != nil { + return Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: http.StatusInternalServerError, + } + } + + defer s.LockUnlock()() + + if name != "" { + // Update path + existing, ok := s.AppSpaces[name] + if !ok { + return Response{StatusCode: 404} + } + if space.Description != "" { + existing.Description = space.Description + } + if space.Resources != nil { + existing.Resources = space.Resources + } + if space.UserApiScopes != nil { + existing.UserApiScopes = space.UserApiScopes + } + if space.UsagePolicyId != "" { + existing.UsagePolicyId = space.UsagePolicyId + } + s.AppSpaces[name] = existing + space = existing + } else { + // Create path + name = space.Name + if name == "" { + return Response{StatusCode: 400, Body: "name is required"} + } + if _, exists := s.AppSpaces[name]; exists { + return Response{ + StatusCode: 409, + Body: map[string]string{ + "error_code": "RESOURCE_ALREADY_EXISTS", + "message": "A space with the same name already exists: " + name, + }, + } + } + space.Id = strconv.Itoa(len(s.AppSpaces) + 2000) + space.Status = &apps.SpaceStatus{ + State: apps.SpaceStatusSpaceStateSpaceActive, + } + space.ServicePrincipalClientId = nextUUID() + space.ServicePrincipalId = nextID() + space.ServicePrincipalName = "space-" + name + s.AppSpaces[name] = space + } + + spaceJSON, _ := json.Marshal(space) + return Response{ + Body: apps.Operation{ + Done: true, + Name: name, + Response: spaceJSON, + }, + } +} + +func (s *FakeWorkspace) AppSpaceGetOperation(_ Request, name string) Response { + defer s.LockUnlock()() + + // Return a completed operation regardless of whether the space exists. + // This supports polling after delete operations. + space, ok := s.AppSpaces[name] + if ok { + spaceJSON, _ := json.Marshal(space) + return Response{ + Body: apps.Operation{ + Done: true, + Name: name, + Response: spaceJSON, + }, + } + } + + emptyJSON, _ := json.Marshal(map[string]any{}) + return Response{ + Body: apps.Operation{ + Done: true, + Name: name, + Response: emptyJSON, + }, + } +} diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 5430c68cbc..4eed46017a 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -168,6 +168,8 @@ type FakeWorkspace struct { DatabaseCatalogs map[string]database.DatabaseCatalog SyncedDatabaseTables map[string]database.SyncedDatabaseTable + AppSpaces map[string]apps.Space + PostgresProjects map[string]postgres.Project PostgresBranches map[string]postgres.Branch PostgresEndpoints map[string]postgres.Endpoint @@ -271,6 +273,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { PipelineUpdates: map[string]bool{}, Monitors: map[string]catalog.MonitorInfo{}, Apps: map[string]apps.App{}, + AppSpaces: map[string]apps.Space{}, Catalogs: map[string]catalog.CatalogInfo{}, ExternalLocations: map[string]catalog.ExternalLocationInfo{}, Schemas: map[string]catalog.SchemaInfo{}, diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index 8bd5339184..198867ad4f 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -8,6 +8,7 @@ import ( "path" "strings" + "github.com/databricks/databricks-sdk-go/service/apps" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/jobs" @@ -419,6 +420,32 @@ func AddDefaultHandlers(server *Server) { return MapDelete(req.Workspace, req.Workspace.Apps, req.Vars["name"]) }) + // App Spaces: + + server.Handle("GET", "/api/2.0/app-spaces/{name}", func(req Request) any { + return MapGet(req.Workspace, req.Workspace.AppSpaces, req.Vars["name"]) + }) + + server.Handle("POST", "/api/2.0/app-spaces", func(req Request) any { + return req.Workspace.AppSpaceUpsert(req, "") + }) + + server.Handle("PATCH", "/api/2.0/app-spaces/{name}", func(req Request) any { + return req.Workspace.AppSpaceUpsert(req, req.Vars["name"]) + }) + + server.Handle("DELETE", "/api/2.0/app-spaces/{name}", func(req Request) any { + delete(req.Workspace.AppSpaces, req.Vars["name"]) + return apps.Operation{ + Done: true, + Name: req.Vars["name"], + } + }) + + server.Handle("GET", "/api/2.0/app-spaces/{name}/operation", func(req Request) any { + return req.Workspace.AppSpaceGetOperation(req, req.Vars["name"]) + }) + // Schemas: server.Handle("GET", "/api/2.1/unity-catalog/schemas/{full_name}", func(req Request) any { diff --git a/libs/testserver/permissions.go b/libs/testserver/permissions.go index 3589b9d704..acc9ff9dba 100644 --- a/libs/testserver/permissions.go +++ b/libs/testserver/permissions.go @@ -30,6 +30,7 @@ var requestObjectTypeToObjectType = map[string]string{ "serving-endpoints": "serving-endpoint", "vector-search-endpoints": "vector-search-endpoints", "apps": "apps", + "app-spaces": "app-spaces", "database-instances": "database-instances", "database-projects": "database-projects", "alertsv2": "alertv2",