diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index a05c0b8345..b7798935cf 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -12,6 +12,7 @@ * Add `ipykernel` to the `default` template to enable Databricks Connect notebooks in Cursor/VS Code ([#4164](https://github.com/databricks/cli/pull/4164)) * Add interactive SQL warehouse picker to `default-sql` and `dbt-sql` bundle templates ([#4170](https://github.com/databricks/cli/pull/4170)) * Add `name`, `target` and `mode` fields to the deployment metadata file ([#4180](https://github.com/databricks/cli/pull/4180)) +* Add Genie Space resource support for direct deploy mode ([#4191](https://github.com/databricks/cli/pull/4191)) ### Dependency updates diff --git a/acceptance/bundle/resources/genie_spaces/simple/databricks.yml.tmpl b/acceptance/bundle/resources/genie_spaces/simple/databricks.yml.tmpl new file mode 100644 index 0000000000..a9d535827c --- /dev/null +++ b/acceptance/bundle/resources/genie_spaces/simple/databricks.yml.tmpl @@ -0,0 +1,13 @@ +# +# Acceptance test for deploying genie spaces +# +bundle: + name: deploy-genie-space-test-$UNIQUE_NAME + +resources: + genie_spaces: + genie1: + title: $GENIE_TITLE + warehouse_id: $TEST_DEFAULT_WAREHOUSE_ID + parent_path: /Users/$CURRENT_USER_NAME + serialized_space: '{"version":1,"config":{"sample_questions":[{"id":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4","question":["What is the total count?"]}]},"data_sources":{"tables":[]}}' diff --git a/acceptance/bundle/resources/genie_spaces/simple/out.plan.direct.json b/acceptance/bundle/resources/genie_spaces/simple/out.plan.direct.json new file mode 100644 index 0000000000..9489e0ed40 --- /dev/null +++ b/acceptance/bundle/resources/genie_spaces/simple/out.plan.direct.json @@ -0,0 +1,33 @@ +{ + "plan_version": 1, + "cli_version": "[DEV_VERSION]", + "lineage": "[UUID]", + "serial": 1, + "plan": { + "resources.genie_spaces.genie1": { + "action": "recreate", + "new_state": { + "value": { + "parent_path": "/Workspace/Users/[USERNAME]", + "serialized_space": "{\"version\":1,\"config\":{\"sample_questions\":[{\"id\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\",\"question\":[\"What is the total count?\"]}]},\"data_sources\":{\"tables\":[]}}", + "title": "test bundle-deploy-genie-space [UUID]", + "warehouse_id": "[TEST_DEFAULT_WAREHOUSE_ID]" + } + }, + "remote_state": { + "serialized_space": "{\"version\":1,\"config\":{\"sample_questions\":[{\"id\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\",\"question\":[\"What is the total count?\"]}]},\"data_sources\":{\"tables\":[]}}", + "space_id": "[GENIE_ID]", + "title": "test bundle-deploy-genie-space [UUID]", + "warehouse_id": "[TEST_DEFAULT_WAREHOUSE_ID]" + }, + "changes": { + "remote": { + "parent_path": { + "action": "recreate", + "old": "/Workspace/Users/[USERNAME]" + } + } + } + } + } +} diff --git a/acceptance/bundle/resources/genie_spaces/simple/out.test.toml b/acceptance/bundle/resources/genie_spaces/simple/out.test.toml new file mode 100644 index 0000000000..13c04ee530 --- /dev/null +++ b/acceptance/bundle/resources/genie_spaces/simple/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresWarehouse = true + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/genie_spaces/simple/output.txt b/acceptance/bundle/resources/genie_spaces/simple/output.txt new file mode 100644 index 0000000000..ee73d02b75 --- /dev/null +++ b/acceptance/bundle/resources/genie_spaces/simple/output.txt @@ -0,0 +1,23 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-genie-space-test-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] genie get-space [GENIE_ID] +{ + "title": "test bundle-deploy-genie-space [UUID]", + "parent_path": null +} + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.genie_spaces.genie1 + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-genie-space-test-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/genie_spaces/simple/script b/acceptance/bundle/resources/genie_spaces/simple/script new file mode 100644 index 0000000000..8f6f32eec4 --- /dev/null +++ b/acceptance/bundle/resources/genie_spaces/simple/script @@ -0,0 +1,24 @@ +GENIE_TITLE="test bundle-deploy-genie-space $(uuid)" +if [ -z "$CLOUD_ENV" ]; then + export TEST_DEFAULT_WAREHOUSE_ID="warehouse-1234" + echo "warehouse-1234:TEST_DEFAULT_WAREHOUSE_ID" >> ACC_REPLS +fi + +export GENIE_TITLE +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +trace $CLI bundle deploy +GENIE_ID=$($CLI bundle summary --output json | jq -r '.resources.genie_spaces.genie1.id') + +# Capture the genie space ID as a replacement. +echo "$GENIE_ID:GENIE_ID" >> ACC_REPLS + +trace $CLI genie get-space $GENIE_ID | jq '{title, parent_path}' + +# Verify that there is no drift right after deploy. +trace $CLI bundle plan -o json > out.plan.direct.json diff --git a/acceptance/bundle/resources/genie_spaces/simple/test.toml b/acceptance/bundle/resources/genie_spaces/simple/test.toml new file mode 100644 index 0000000000..a86b47bf5f --- /dev/null +++ b/acceptance/bundle/resources/genie_spaces/simple/test.toml @@ -0,0 +1,8 @@ +Local = true +Cloud = true +RequiresWarehouse = true +RecordRequests = false + +Ignore = [ + "databricks.yml", +] diff --git a/acceptance/bundle/resources/genie_spaces/test.toml b/acceptance/bundle/resources/genie_spaces/test.toml new file mode 100644 index 0000000000..c6dcadde8c --- /dev/null +++ b/acceptance/bundle/resources/genie_spaces/test.toml @@ -0,0 +1,12 @@ +Local = true +Cloud = true +RequiresWarehouse = true + +# Genie spaces are direct mode only +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +[Env] +# MSYS2 automatically converts absolute paths like /Users/$username/$UNIQUE_NAME to +# C:/Program Files/Git/Users/$username/UNIQUE_NAME before passing it to the CLI +# Setting this environment variable prevents that conversion on windows. +MSYS_NO_PATHCONV = "1" diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go index dfb36fbca1..f404d736c4 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go @@ -45,6 +45,13 @@ var ( permissions.CAN_MANAGE: "CAN_MANAGE", permissions.CAN_VIEW: "CAN_READ", }, + "genie_spaces": { + // Genie spaces also support CAN_EDIT, but bundle-level permissions + // don't have an equivalent. Users can set CAN_EDIT directly on the resource. + permissions.CAN_MANAGE: "CAN_MANAGE", + permissions.CAN_VIEW: "CAN_VIEW", + permissions.CAN_RUN: "CAN_RUN", + }, "apps": { permissions.CAN_MANAGE: "CAN_MANAGE", permissions.CAN_VIEW: "CAN_USE", diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index 4446e7c53e..1e280998ac 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -19,12 +19,12 @@ import ( // This list exists to ensure that this mutator is updated when new resource is added. // These resources are there because they use grants, not permissions: var unsupportedResources = []string{ - "volumes", - "schemas", + "database_catalogs", "quality_monitors", "registered_models", - "database_catalogs", + "schemas", "synced_database_tables", + "volumes", } func TestApplyBundlePermissions(t *testing.T) { @@ -71,6 +71,10 @@ func TestApplyBundlePermissions(t *testing.T) { "dashboard_1": {}, "dashboard_2": {}, }, + GenieSpaces: map[string]*resources.GenieSpace{ + "geniespace_1": {}, + "geniespace_2": {}, + }, Apps: map[string]*resources.App{ "app_1": {}, "app_2": {}, @@ -132,6 +136,11 @@ func TestApplyBundlePermissions(t *testing.T) { require.Contains(t, b.Config.Resources.Dashboards["dashboard_1"].Permissions, resources.DashboardPermission{Level: "CAN_MANAGE", UserName: "TestUser"}) require.Contains(t, b.Config.Resources.Dashboards["dashboard_1"].Permissions, resources.DashboardPermission{Level: "CAN_READ", GroupName: "TestGroup"}) + require.Len(t, b.Config.Resources.GenieSpaces["geniespace_1"].Permissions, 3) + require.Contains(t, b.Config.Resources.GenieSpaces["geniespace_1"].Permissions, resources.GenieSpacePermission{Level: "CAN_MANAGE", UserName: "TestUser"}) + require.Contains(t, b.Config.Resources.GenieSpaces["geniespace_1"].Permissions, resources.GenieSpacePermission{Level: "CAN_VIEW", GroupName: "TestGroup"}) + require.Contains(t, b.Config.Resources.GenieSpaces["geniespace_1"].Permissions, resources.GenieSpacePermission{Level: "CAN_RUN", ServicePrincipalName: "TestServicePrincipal"}) + require.Len(t, b.Config.Resources.Apps["app_1"].Permissions, 2) 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"}) diff --git a/bundle/config/mutator/resourcemutator/apply_presets.go b/bundle/config/mutator/resourcemutator/apply_presets.go index 1311f3660f..9586630ab5 100644 --- a/bundle/config/mutator/resourcemutator/apply_presets.go +++ b/bundle/config/mutator/resourcemutator/apply_presets.go @@ -237,6 +237,14 @@ func (m *applyPresets) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnos dashboard.DisplayName = prefix + dashboard.DisplayName } + // Genie Spaces: Prefix + for _, genieSpace := range r.GenieSpaces { + if genieSpace == nil { + continue + } + genieSpace.Title = prefix + genieSpace.Title + } + // Apps: No presets // Alerts: Prefix diff --git a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go index ec7980348f..d4dd9e6408 100644 --- a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go +++ b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go @@ -147,6 +147,13 @@ func mockBundle(mode config.Mode) *bundle.Bundle { }, }, }, + GenieSpaces: map[string]*resources.GenieSpace{ + "geniespace1": { + GenieSpaceConfig: resources.GenieSpaceConfig{ + Title: "geniespace1", + }, + }, + }, Apps: map[string]*resources.App{ "app1": { App: apps.App{ @@ -258,6 +265,9 @@ func TestProcessTargetModeDevelopment(t *testing.T) { // Dashboards assert.Equal(t, "[dev lennart] dashboard1", b.Config.Resources.Dashboards["dashboard1"].DisplayName) + + // Genie Spaces + assert.Equal(t, "[dev lennart] geniespace1", b.Config.Resources.GenieSpaces["geniespace1"].Title) } func TestProcessTargetModeDevelopmentTagNormalizationForAws(t *testing.T) { diff --git a/bundle/config/mutator/resourcemutator/genie_space_fixups.go b/bundle/config/mutator/resourcemutator/genie_space_fixups.go new file mode 100644 index 0000000000..f3c7ebf649 --- /dev/null +++ b/bundle/config/mutator/resourcemutator/genie_space_fixups.go @@ -0,0 +1,33 @@ +package resourcemutator + +import ( + "context" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/diag" +) + +type genieSpaceFixups struct{} + +func GenieSpaceFixups() bundle.Mutator { + return &genieSpaceFixups{} +} + +func (m *genieSpaceFixups) Name() string { + return "GenieSpaceFixups" +} + +// Apply ensures the parent_path has the /Workspace prefix to match what the API returns. +// This prevents persistent recreates when comparing local config vs remote state. +func (m *genieSpaceFixups) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { + for _, genieSpace := range b.Config.Resources.GenieSpaces { + if genieSpace == nil { + continue + } + + // Reuse the ensureWorkspacePrefix function from dashboard_fixups.go + genieSpace.ParentPath = ensureWorkspacePrefix(genieSpace.ParentPath) + } + + return nil +} diff --git a/bundle/config/mutator/resourcemutator/resource_mutator.go b/bundle/config/mutator/resourcemutator/resource_mutator.go index d716de41be..c3dfb7eff0 100644 --- a/bundle/config/mutator/resourcemutator/resource_mutator.go +++ b/bundle/config/mutator/resourcemutator/resource_mutator.go @@ -52,6 +52,7 @@ func applyInitializeMutators(ctx context.Context, b *bundle.Bundle) { }{ {"resources.dashboards.*.parent_path", b.Config.Workspace.ResourcePath}, {"resources.dashboards.*.embed_credentials", false}, + {"resources.genie_spaces.*.parent_path", b.Config.Workspace.ResourcePath}, {"resources.volumes.*.volume_type", "MANAGED"}, {"resources.alerts.*.parent_path", b.Config.Workspace.ResourcePath}, @@ -114,6 +115,11 @@ func applyInitializeMutators(ctx context.Context, b *bundle.Bundle) { // Ensures dashboard parent paths have the required /Workspace prefix DashboardFixups(), + // Reads (typed): b.Config.Resources.GenieSpaces (checks genie space configurations) + // Updates (typed): b.Config.Resources.GenieSpaces[].ParentPath (ensures /Workspace prefix is present) + // Ensures genie space parent paths have the required /Workspace prefix + GenieSpaceFixups(), + // Reads (typed): b.Config.Permissions (validates permission levels) // Reads (dynamic): resources.{jobs,pipelines,experiments,models,model_serving_endpoints,dashboards,apps}.*.permissions (reads existing permissions) // Updates (dynamic): resources.{jobs,pipelines,experiments,models,model_serving_endpoints,dashboards,apps}.*.permissions (adds permissions from bundle-level configuration) diff --git a/bundle/config/mutator/resourcemutator/run_as_test.go b/bundle/config/mutator/resourcemutator/run_as_test.go index 7500374adc..a245efc5ee 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -39,6 +39,7 @@ func allResourceTypes(t *testing.T) []string { "database_catalogs", "database_instances", "experiments", + "genie_spaces", "jobs", "model_serving_endpoints", "models", @@ -149,11 +150,12 @@ var allowList = []string{ "database_catalogs", "database_instances", "synced_database_tables", + "experiments", + "genie_spaces", "jobs", - "pipelines", "models", + "pipelines", "registered_models", - "experiments", "schemas", "secret_scopes", "sql_warehouses", diff --git a/bundle/config/resources.go b/bundle/config/resources.go index 4cb5da53ce..56f21d4004 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -23,6 +23,7 @@ type Resources struct { Volumes map[string]*resources.Volume `json:"volumes,omitempty"` Clusters map[string]*resources.Cluster `json:"clusters,omitempty"` Dashboards map[string]*resources.Dashboard `json:"dashboards,omitempty"` + GenieSpaces map[string]*resources.GenieSpace `json:"genie_spaces,omitempty"` Apps map[string]*resources.App `json:"apps,omitempty"` SecretScopes map[string]*resources.SecretScope `json:"secret_scopes,omitempty"` Alerts map[string]*resources.Alert `json:"alerts,omitempty"` @@ -90,6 +91,7 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["schemas"], r.Schemas), collectResourceMap(descriptions["clusters"], r.Clusters), collectResourceMap(descriptions["dashboards"], r.Dashboards), + collectResourceMap(descriptions["genie_spaces"], r.GenieSpaces), collectResourceMap(descriptions["volumes"], r.Volumes), collectResourceMap(descriptions["apps"], r.Apps), collectResourceMap(descriptions["alerts"], r.Alerts), @@ -151,6 +153,12 @@ func (r *Resources) FindResourceByConfigKey(key string) (ConfigResource, error) } } + for k := range r.GenieSpaces { + if k == key { + found = append(found, r.GenieSpaces[k]) + } + } + for k := range r.RegisteredModels { if k == key { found = append(found, r.RegisteredModels[k]) @@ -233,6 +241,7 @@ func SupportedResources() map[string]resources.ResourceDescription { "schemas": (&resources.Schema{}).ResourceDescription(), "clusters": (&resources.Cluster{}).ResourceDescription(), "dashboards": (&resources.Dashboard{}).ResourceDescription(), + "genie_spaces": (&resources.GenieSpace{}).ResourceDescription(), "volumes": (&resources.Volume{}).ResourceDescription(), "apps": (&resources.App{}).ResourceDescription(), "secret_scopes": (&resources.SecretScope{}).ResourceDescription(), diff --git a/bundle/config/resources/genie_space.go b/bundle/config/resources/genie_space.go new file mode 100644 index 0000000000..6bd517c708 --- /dev/null +++ b/bundle/config/resources/genie_space.go @@ -0,0 +1,92 @@ +package resources + +import ( + "context" + "net/url" + + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/marshal" + "github.com/databricks/databricks-sdk-go/service/dashboards" +) + +// GenieSpaceConfig holds configuration for a Genie Space resource. +// This is persisted in the deployment state. +type GenieSpaceConfig struct { + // SpaceId is the UUID of the Genie space (output only). + SpaceId string `json:"space_id,omitempty"` + + // Title is the display title of the Genie space. + Title string `json:"title,omitempty"` + + // Description is an optional description for the Genie space. + Description string `json:"description,omitempty"` + + // ParentPath is the workspace folder path where the space is registered. + // Example: /Workspace/Users/user@example.com + ParentPath string `json:"parent_path,omitempty"` + + // WarehouseId is the SQL warehouse ID to associate with this space. + // This is required for creating a Genie space. + WarehouseId string `json:"warehouse_id,omitempty"` + + // SerializedSpace holds the contents of the Genie space in serialized JSON string form. + // The JSON structure includes sample questions and data sources. + // Example: + // {"version":1,"config":{"sample_questions":[{"id":"...","question":["Show orders by date"]}]},"data_sources":{"tables":[{"identifier":"catalog.schema.table_name"}]}} + SerializedSpace string `json:"serialized_space,omitempty"` + + ForceSendFields []string `json:"-" url:"-"` +} + +func (c *GenieSpaceConfig) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, c) +} + +func (c GenieSpaceConfig) MarshalJSON() ([]byte, error) { + return marshal.Marshal(c) +} + +// GenieSpace represents a Genie Space resource in a Databricks Asset Bundle. +type GenieSpace struct { + BaseResource + GenieSpaceConfig + + Permissions []GenieSpacePermission `json:"permissions,omitempty"` +} + +func (*GenieSpace) Exists(ctx context.Context, w *databricks.WorkspaceClient, id string) (bool, error) { + _, err := w.Genie.GetSpace(ctx, dashboards.GenieGetSpaceRequest{ + SpaceId: id, + }) + if err != nil { + log.Debugf(ctx, "genie space %s does not exist", id) + return false, err + } + return true, nil +} + +func (*GenieSpace) ResourceDescription() ResourceDescription { + return ResourceDescription{ + SingularName: "genie_space", + PluralName: "genie_spaces", + SingularTitle: "Genie Space", + PluralTitle: "Genie Spaces", + } +} + +func (r *GenieSpace) InitializeURL(baseURL url.URL) { + if r.ID == "" { + return + } + baseURL.Path = "genie/rooms/" + r.ID + r.URL = baseURL.String() +} + +func (r *GenieSpace) GetName() string { + return r.Title +} + +func (r *GenieSpace) GetURL() string { + return r.URL +} diff --git a/bundle/config/resources/permission.go b/bundle/config/resources/permission.go index d7c3f6fca9..253ebf0ffa 100644 --- a/bundle/config/resources/permission.go +++ b/bundle/config/resources/permission.go @@ -42,6 +42,7 @@ type ( ClusterPermissionLevel string DashboardPermissionLevel string DatabaseInstancePermissionLevel string + GenieSpacePermissionLevel string JobPermissionLevel string MlflowExperimentPermissionLevel string MlflowModelPermissionLevel string @@ -99,6 +100,14 @@ type DatabaseInstancePermission struct { GroupName string `json:"group_name,omitempty"` } +type GenieSpacePermission struct { + Level GenieSpacePermissionLevel `json:"level"` + + UserName string `json:"user_name,omitempty"` + ServicePrincipalName string `json:"service_principal_name,omitempty"` + GroupName string `json:"group_name,omitempty"` +} + type JobPermission struct { Level JobPermissionLevel `json:"level"` @@ -154,6 +163,7 @@ func (p AppPermission) GetAPIRequestObjectType() string { return "/ func (p ClusterPermission) GetAPIRequestObjectType() string { return "/clusters/" } func (p DashboardPermission) GetAPIRequestObjectType() string { return "/dashboards/" } func (p DatabaseInstancePermission) GetAPIRequestObjectType() string { return "/database-instances/" } +func (p GenieSpacePermission) GetAPIRequestObjectType() string { return "/genie/spaces/" } func (p JobPermission) GetAPIRequestObjectType() string { return "/jobs/" } func (p MlflowExperimentPermission) GetAPIRequestObjectType() string { return "/experiments/" } func (p MlflowModelPermission) GetAPIRequestObjectType() string { return "/registered-models/" } @@ -190,6 +200,11 @@ func (p DatabaseInstancePermission) GetUserName() string { return p. func (p DatabaseInstancePermission) GetServicePrincipalName() string { return p.ServicePrincipalName } func (p DatabaseInstancePermission) GetGroupName() string { return p.GroupName } +func (p GenieSpacePermission) GetLevel() string { return string(p.Level) } +func (p GenieSpacePermission) GetUserName() string { return p.UserName } +func (p GenieSpacePermission) GetServicePrincipalName() string { return p.ServicePrincipalName } +func (p GenieSpacePermission) GetGroupName() string { return p.GroupName } + func (p JobPermission) GetLevel() string { return string(p.Level) } func (p JobPermission) GetUserName() string { return p.UserName } func (p JobPermission) GetServicePrincipalName() string { return p.ServicePrincipalName } diff --git a/bundle/config/resources_test.go b/bundle/config/resources_test.go index 6bc61196e2..091f4dfd36 100644 --- a/bundle/config/resources_test.go +++ b/bundle/config/resources_test.go @@ -150,6 +150,9 @@ func TestResourcesBindSupport(t *testing.T) { Dashboards: map[string]*resources.Dashboard{ "my_dashboard": {}, }, + GenieSpaces: map[string]*resources.GenieSpace{ + "my_genie_space": {}, + }, Volumes: map[string]*resources.Volume{ "my_volume": { CreateVolumeRequestContent: catalog.CreateVolumeRequestContent{}, @@ -212,6 +215,7 @@ func TestResourcesBindSupport(t *testing.T) { m.GetMockSchemasAPI().EXPECT().GetByFullName(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockClustersAPI().EXPECT().GetByClusterId(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockLakeviewAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) + m.GetMockGenieAPI().EXPECT().GetSpace(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.GetMockAlertsV2API().EXPECT().GetAlertById(mock.Anything, mock.Anything).Return(nil, nil) diff --git a/bundle/deploy/terraform/lifecycle_test.go b/bundle/deploy/terraform/lifecycle_test.go index 697a2270cd..95a323be49 100644 --- a/bundle/deploy/terraform/lifecycle_test.go +++ b/bundle/deploy/terraform/lifecycle_test.go @@ -14,7 +14,15 @@ func TestConvertLifecycleForAllResources(t *testing.T) { supportedResources := config.SupportedResources() ctx := context.Background() + // Skip resources that are direct-mode only (no Terraform support) + directModeOnlyResources := map[string]bool{ + "genie_spaces": true, + } + for resourceType := range supportedResources { + if directModeOnlyResources[resourceType] { + continue + } t.Run(resourceType, func(t *testing.T) { vin := dyn.NewValue(map[string]dyn.Value{ "resources": dyn.NewValue(map[string]dyn.Value{ diff --git a/bundle/direct/dresources/all.go b/bundle/direct/dresources/all.go index 6570fcdeea..5ec0a331b2 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -22,6 +22,7 @@ var SupportedResources = map[string]any{ "clusters": (*ResourceCluster)(nil), "registered_models": (*ResourceRegisteredModel)(nil), "dashboards": (*ResourceDashboard)(nil), + "genie_spaces": (*ResourceGenieSpace)(nil), "secret_scopes": (*ResourceSecretScope)(nil), "model_serving_endpoints": (*ResourceModelServingEndpoint)(nil), diff --git a/bundle/direct/dresources/genie_space.go b/bundle/direct/dresources/genie_space.go new file mode 100644 index 0000000000..16540f14d4 --- /dev/null +++ b/bundle/direct/dresources/genie_space.go @@ -0,0 +1,165 @@ +package dresources + +import ( + "context" + "fmt" + "path" + "strings" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/bundle/deployplan" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/service/dashboards" +) + +// Genie API reference: https://docs.databricks.com/api/workspace/genie +type ResourceGenieSpace struct { + client *databricks.WorkspaceClient +} + +// ensureWorkspacePrefix adds the /Workspace prefix to the parent path if it's not already present. +// The backend removes this prefix from parent path, and thus it needs to be added back +// to match the local configuration. +func ensureGenieWorkspacePrefix(parentPath string) string { + if parentPath == "" { + return parentPath + } + if parentPath == "/Workspace" || strings.HasPrefix(parentPath, "/Workspace/") { + return parentPath + } + return path.Join("/Workspace", parentPath) +} + +func (*ResourceGenieSpace) New(client *databricks.WorkspaceClient) *ResourceGenieSpace { + return &ResourceGenieSpace{client: client} +} + +func (*ResourceGenieSpace) PrepareState(input *resources.GenieSpace) *resources.GenieSpaceConfig { + return &input.GenieSpaceConfig +} + +func (r *ResourceGenieSpace) RemapState(state *resources.GenieSpaceConfig) *resources.GenieSpaceConfig { + return &resources.GenieSpaceConfig{ + Title: state.Title, + Description: state.Description, + ParentPath: state.ParentPath, + WarehouseId: state.WarehouseId, + SerializedSpace: state.SerializedSpace, + + // Clear output-only fields. They should not show up on remote diff computation. + SpaceId: "", + ForceSendFields: nil, + } +} + +func (r *ResourceGenieSpace) DoRead(ctx context.Context, id string) (*resources.GenieSpaceConfig, error) { + space, err := r.client.Genie.GetSpace(ctx, dashboards.GenieGetSpaceRequest{ + SpaceId: id, + IncludeSerializedSpace: true, + ForceSendFields: nil, + }) + if err != nil { + return nil, err + } + + return &resources.GenieSpaceConfig{ + SpaceId: space.SpaceId, + Title: space.Title, + Description: space.Description, + ParentPath: "", + WarehouseId: space.WarehouseId, + SerializedSpace: space.SerializedSpace, + ForceSendFields: nil, + // Note: ParentPath is not returned by GetSpace API, so we can't set it here. + // This means parent_path changes won't be detected via remote drift. + // However, FieldTriggers ensures parent_path changes trigger recreate locally. + }, nil +} + +// prepareGenieSpaceRequest returns the serialized_space string from the config. +func prepareGenieSpaceRequest(config *resources.GenieSpaceConfig) string { + return config.SerializedSpace +} + +func responseToGenieState(resp *dashboards.GenieSpace, serializedSpace, parentPath string) *resources.GenieSpaceConfig { + return &resources.GenieSpaceConfig{ + SpaceId: resp.SpaceId, + Title: resp.Title, + Description: resp.Description, + ParentPath: ensureGenieWorkspacePrefix(parentPath), + WarehouseId: resp.WarehouseId, + SerializedSpace: serializedSpace, + ForceSendFields: nil, + } +} + +func (r *ResourceGenieSpace) DoCreate(ctx context.Context, config *resources.GenieSpaceConfig) (string, *resources.GenieSpaceConfig, error) { + serializedSpace := prepareGenieSpaceRequest(config) + + createReq := dashboards.GenieCreateSpaceRequest{ + WarehouseId: config.WarehouseId, + SerializedSpace: serializedSpace, + Title: config.Title, + Description: config.Description, + ParentPath: config.ParentPath, + ForceSendFields: nil, + } + + resp, err := r.client.Genie.CreateSpace(ctx, createReq) + + // The API returns 404 if the parent directory doesn't exist. + // If the parent directory doesn't exist, create it and try again. + if err != nil && apierr.IsMissing(err) && config.ParentPath != "" { + mkdirErr := r.client.Workspace.MkdirsByPath(ctx, config.ParentPath) + if mkdirErr != nil { + return "", nil, fmt.Errorf("failed to create parent directory: %w", mkdirErr) + } + resp, err = r.client.Genie.CreateSpace(ctx, createReq) + } + if err != nil { + return "", nil, err + } + + return resp.SpaceId, responseToGenieState(resp, serializedSpace, config.ParentPath), nil +} + +func (r *ResourceGenieSpace) DoUpdate(ctx context.Context, id string, config *resources.GenieSpaceConfig, _ *Changes) (*resources.GenieSpaceConfig, error) { + serializedSpace := prepareGenieSpaceRequest(config) + + resp, err := r.client.Genie.UpdateSpace(ctx, dashboards.GenieUpdateSpaceRequest{ + SpaceId: id, + SerializedSpace: serializedSpace, + Title: config.Title, + Description: config.Description, + WarehouseId: config.WarehouseId, + ForceSendFields: nil, + }) + if err != nil { + return nil, err + } + + return responseToGenieState(resp, serializedSpace, config.ParentPath), nil +} + +func (r *ResourceGenieSpace) DoDelete(ctx context.Context, id string) error { + return r.client.Genie.TrashSpace(ctx, dashboards.GenieTrashSpaceRequest{ + SpaceId: id, + }) +} + +func (*ResourceGenieSpace) FieldTriggers(isLocal bool) map[string]deployplan.ActionType { + triggers := map[string]deployplan.ActionType{ + // Change in parent_path should trigger a recreate since Genie API + // doesn't support updating parent_path. + "parent_path": deployplan.ActionTypeRecreate, + } + + if !isLocal { + // For remote diff, skip serialized_space comparison since the format + // may differ between local config and API response. + triggers["serialized_space"] = deployplan.ActionTypeSkip + } + + return triggers +} diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 2a9c78b4ac..242db48c94 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -183,6 +183,11 @@ github.com/databricks/cli/bundle/config.Resources: The experiment definitions for the bundle, where each key is the name of the experiment. "markdown_description": |- The experiment definitions for the bundle, where each key is the name of the experiment. See [\_](/dev-tools/bundles/resources.md#experiments). + "genie_spaces": + "description": |- + The genie space definitions for the bundle, where each key is the name of the genie space. + "markdown_description": |- + The genie space definitions for the bundle, where each key is the name of the genie space. See [\_](/dev-tools/bundles/resources.md#genie_spaces). "jobs": "description": |- The job definitions for the bundle, where each key is the name of the job. @@ -564,6 +569,47 @@ github.com/databricks/cli/bundle/config/resources.DatabaseInstancePermission: "user_name": "description": |- PLACEHOLDER +github.com/databricks/cli/bundle/config/resources.GenieSpace: + "_": + "description": |- + Configuration for a Genie space resource in a bundle. + "description": + "description": |- + The description of the genie space. + "lifecycle": + "description": |- + PLACEHOLDER + "parent_path": + "description": |- + Workspace path where the genie space is stored. + "permissions": + "description": |- + PLACEHOLDER + "serialized_space": + "description": |- + The serialized content of the genie space, containing instructions, sample questions, and data sources. + "space_id": + "description": |- + The unique identifier of the genie space. This is an output-only field set by the API. + "title": + "description": |- + The display title of the genie space. + "warehouse_id": + "description": |- + The ID of the SQL warehouse to use with this genie space. +github.com/databricks/cli/bundle/config/resources.GenieSpacePermission: + "group_name": + "description": |- + PLACEHOLDER + "level": + "description": |- + PLACEHOLDER + "service_principal_name": + "description": |- + PLACEHOLDER + "user_name": + "description": |- + PLACEHOLDER github.com/databricks/cli/bundle/config/resources.Grant: "principal": "description": |- diff --git a/bundle/internal/schema/annotations_openapi_overrides.yml b/bundle/internal/schema/annotations_openapi_overrides.yml index 427c184570..d2521e0fdb 100644 --- a/bundle/internal/schema/annotations_openapi_overrides.yml +++ b/bundle/internal/schema/annotations_openapi_overrides.yml @@ -218,6 +218,17 @@ github.com/databricks/cli/bundle/config/resources.DashboardPermissionLevel: CAN_EDIT - |- CAN_MANAGE +github.com/databricks/cli/bundle/config/resources.GenieSpacePermissionLevel: + "_": + "enum": + - |- + CAN_EDIT + - |- + CAN_MANAGE + - |- + CAN_RUN + - |- + CAN_VIEW github.com/databricks/cli/bundle/config/resources.DatabaseCatalog: "create_database_if_not_exists": "description": |- diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index 2cc2045b9a..f5ca623cb4 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -52,6 +52,8 @@ var RequiredFields = map[string][]string{ "resources.experiments.*": {"name"}, "resources.experiments.*.permissions[*]": {"level"}, + "resources.genie_spaces.*.permissions[*]": {"level"}, + "resources.jobs.*.deployment": {"kind"}, "resources.jobs.*.environments[*]": {"environment_key"}, "resources.jobs.*.git_source": {"git_provider", "git_url"}, diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 41cb0fa591..b35a49fc11 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -718,6 +718,97 @@ } ] }, + "resources.GenieSpace": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for a Genie space resource in a bundle.", + "properties": { + "description": { + "description": "The description of the genie space.", + "$ref": "#/$defs/string" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "parent_path": { + "description": "Workspace path where the genie space is stored.", + "$ref": "#/$defs/string" + }, + "permissions": { + "$ref": "#/$defs/slice/github.com/databricks/cli/bundle/config/resources.GenieSpacePermission" + }, + "serialized_space": { + "description": "The serialized content of the genie space, containing instructions, sample questions, and data sources.", + "$ref": "#/$defs/string" + }, + "space_id": { + "description": "The unique identifier of the genie space. This is an output-only field set by the API.", + "$ref": "#/$defs/string" + }, + "title": { + "description": "The display title of the genie space.", + "$ref": "#/$defs/string" + }, + "warehouse_id": { + "description": "The ID of the SQL warehouse to use with this genie space.", + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "resources.GenieSpacePermission": { + "oneOf": [ + { + "type": "object", + "properties": { + "group_name": { + "$ref": "#/$defs/string" + }, + "level": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.GenieSpacePermissionLevel" + }, + "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.GenieSpacePermissionLevel": { + "oneOf": [ + { + "type": "string", + "enum": [ + "CAN_EDIT", + "CAN_MANAGE", + "CAN_RUN", + "CAN_VIEW" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.Grant": { "oneOf": [ { @@ -2465,6 +2556,11 @@ "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.MlflowExperiment", "markdownDescription": "The experiment definitions for the bundle, where each key is the name of the experiment. See [experiments](https://docs.databricks.com/dev-tools/bundles/resources.html#experiments)." }, + "genie_spaces": { + "description": "The genie space definitions for the bundle, where each key is the name of the genie space.", + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.GenieSpace", + "markdownDescription": "The genie space definitions for the bundle, where each key is the name of the genie space. See [genie_spaces](https://docs.databricks.com/dev-tools/bundles/resources.html#genie_spaces)." + }, "jobs": { "description": "The job definitions for the bundle, where each key is the name of the job.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.Job", @@ -9638,6 +9734,20 @@ } ] }, + "resources.GenieSpace": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.GenieSpace" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.Job": { "oneOf": [ { @@ -9986,6 +10096,20 @@ } ] }, + "resources.GenieSpacePermission": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.GenieSpacePermission" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.Grant": { "oneOf": [ { diff --git a/bundle/statemgmt/state_load_test.go b/bundle/statemgmt/state_load_test.go index 3c0b53b0c9..afe745c276 100644 --- a/bundle/statemgmt/state_load_test.go +++ b/bundle/statemgmt/state_load_test.go @@ -36,6 +36,7 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { "resources.volumes.test_volume": {ID: "1"}, "resources.clusters.test_cluster": {ID: "1"}, "resources.dashboards.test_dashboard": {ID: "1"}, + "resources.genie_spaces.test_genie_space": {ID: "1"}, "resources.apps.test_app": {ID: "app1"}, "resources.secret_scopes.test_secret_scope": {ID: "secret_scope1"}, "resources.sql_warehouses.test_sql_warehouse": {ID: "1"}, @@ -80,6 +81,9 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { assert.Equal(t, "1", config.Resources.Dashboards["test_dashboard"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.Dashboards["test_dashboard"].ModifiedStatus) + assert.Equal(t, "1", config.Resources.GenieSpaces["test_genie_space"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.GenieSpaces["test_genie_space"].ModifiedStatus) + assert.Equal(t, "app1", config.Resources.Apps["test_app"].ID) assert.Equal(t, "", config.Resources.Apps["test_app"].Name) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.Apps["test_app"].ModifiedStatus) @@ -179,6 +183,13 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { }, }, }, + GenieSpaces: map[string]*resources.GenieSpace{ + "test_genie_space": { + GenieSpaceConfig: resources.GenieSpaceConfig{ + Title: "test_genie_space", + }, + }, + }, Apps: map[string]*resources.App{ "test_app": { App: apps.App{ @@ -265,6 +276,9 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { assert.Equal(t, "", config.Resources.Dashboards["test_dashboard"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Dashboards["test_dashboard"].ModifiedStatus) + assert.Equal(t, "", config.Resources.GenieSpaces["test_genie_space"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.GenieSpaces["test_genie_space"].ModifiedStatus) + assert.Equal(t, "", config.Resources.Apps["test_app"].Name) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Apps["test_app"].ModifiedStatus) @@ -424,6 +438,18 @@ func TestStateToBundleModifiedResources(t *testing.T) { }, }, }, + GenieSpaces: map[string]*resources.GenieSpace{ + "test_genie_space": { + GenieSpaceConfig: resources.GenieSpaceConfig{ + Title: "test_genie_space", + }, + }, + "test_genie_space_new": { + GenieSpaceConfig: resources.GenieSpaceConfig{ + Title: "test_genie_space_new", + }, + }, + }, Apps: map[string]*resources.App{ "test_app": { App: apps.App{ @@ -529,6 +555,8 @@ func TestStateToBundleModifiedResources(t *testing.T) { "resources.clusters.test_cluster_old": {ID: "2"}, "resources.dashboards.test_dashboard": {ID: "1"}, "resources.dashboards.test_dashboard_old": {ID: "2"}, + "resources.genie_spaces.test_genie_space": {ID: "1"}, + "resources.genie_spaces.test_genie_space_old": {ID: "2"}, "resources.apps.test_app": {ID: "test_app"}, "resources.apps.test_app_old": {ID: "test_app_old"}, "resources.secret_scopes.test_secret_scope": {ID: "test_secret_scope"}, @@ -620,6 +648,13 @@ func TestStateToBundleModifiedResources(t *testing.T) { assert.Equal(t, "", config.Resources.Dashboards["test_dashboard_new"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Dashboards["test_dashboard_new"].ModifiedStatus) + assert.Equal(t, "1", config.Resources.GenieSpaces["test_genie_space"].ID) + assert.Equal(t, "", config.Resources.GenieSpaces["test_genie_space"].ModifiedStatus) + assert.Equal(t, "2", config.Resources.GenieSpaces["test_genie_space_old"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.GenieSpaces["test_genie_space_old"].ModifiedStatus) + assert.Equal(t, "", config.Resources.GenieSpaces["test_genie_space_new"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.GenieSpaces["test_genie_space_new"].ModifiedStatus) + assert.Equal(t, "test_app", config.Resources.Apps["test_app"].Name) assert.Equal(t, "", config.Resources.Apps["test_app"].ModifiedStatus) assert.Equal(t, "test_app_old", config.Resources.Apps["test_app_old"].ID) diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 8cb87dc9cf..d504da9244 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -127,6 +127,7 @@ type FakeWorkspace struct { Volumes map[string]catalog.VolumeInfo Dashboards map[string]fakeDashboard PublishedDashboards map[string]dashboards.PublishedDashboard + GenieSpaces map[string]fakeGenieSpace SqlWarehouses map[string]sql.GetWarehouseResponse Alerts map[string]sql.AlertV2 Experiments map[string]ml.GetExperimentResponse @@ -233,6 +234,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { Volumes: map[string]catalog.VolumeInfo{}, Dashboards: map[string]fakeDashboard{}, PublishedDashboards: map[string]dashboards.PublishedDashboard{}, + GenieSpaces: map[string]fakeGenieSpace{}, SqlWarehouses: map[string]sql.GetWarehouseResponse{}, ServingEndpoints: map[string]serving.ServingEndpointDetailed{}, Repos: map[string]workspace.RepoInfo{}, diff --git a/libs/testserver/genie_spaces.go b/libs/testserver/genie_spaces.go new file mode 100644 index 0000000000..78cee25ca8 --- /dev/null +++ b/libs/testserver/genie_spaces.go @@ -0,0 +1,138 @@ +package testserver + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/databricks/databricks-sdk-go/service/dashboards" +) + +// fakeGenieSpace wraps the SDK GenieSpace with additional fields not in the response +type fakeGenieSpace struct { + dashboards.GenieSpace + ParentPath string `json:"parent_path,omitempty"` +} + +// Generate 32 character hex string for genie space ID +func generateGenieSpaceId() (string, error) { + randomBytes := make([]byte, 16) + _, err := rand.Read(randomBytes) + if err != nil { + return "", err + } + return hex.EncodeToString(randomBytes), nil +} + +func (s *FakeWorkspace) GenieSpaceCreate(req Request) Response { + defer s.LockUnlock()() + + var createReq dashboards.GenieCreateSpaceRequest + if err := json.Unmarshal(req.Body, &createReq); err != nil { + return Response{ + StatusCode: 400, + Body: map[string]string{ + "message": fmt.Sprintf("Failed to parse request: %s", err), + }, + } + } + + spaceId, err := generateGenieSpaceId() + if err != nil { + return Response{ + StatusCode: 500, + Body: map[string]string{ + "message": "Failed to generate genie space ID", + }, + } + } + + // Remove /Workspace prefix from parent_path. This matches the remote behavior. + parentPath := createReq.ParentPath + if strings.HasPrefix(parentPath, "/Workspace/") { + parentPath = strings.TrimPrefix(parentPath, "/Workspace") + } + + genieSpace := fakeGenieSpace{ + GenieSpace: dashboards.GenieSpace{ + SpaceId: spaceId, + Title: createReq.Title, + Description: createReq.Description, + WarehouseId: createReq.WarehouseId, + SerializedSpace: createReq.SerializedSpace, + }, + ParentPath: parentPath, + } + + s.GenieSpaces[spaceId] = genieSpace + + return Response{ + Body: genieSpace, + } +} + +func (s *FakeWorkspace) GenieSpaceUpdate(req Request, spaceId string) Response { + defer s.LockUnlock()() + + genieSpace, ok := s.GenieSpaces[spaceId] + if !ok { + return Response{ + StatusCode: 404, + Body: map[string]string{ + "message": fmt.Sprintf("Genie space with ID %s not found", spaceId), + }, + } + } + + var updateReq dashboards.GenieUpdateSpaceRequest + if err := json.Unmarshal(req.Body, &updateReq); err != nil { + return Response{ + StatusCode: 400, + Body: map[string]string{ + "message": fmt.Sprintf("Failed to parse request: %s", err), + }, + } + } + + // Update fields if provided + if updateReq.Title != "" { + genieSpace.Title = updateReq.Title + } + if updateReq.Description != "" { + genieSpace.Description = updateReq.Description + } + if updateReq.WarehouseId != "" { + genieSpace.WarehouseId = updateReq.WarehouseId + } + if updateReq.SerializedSpace != "" { + genieSpace.SerializedSpace = updateReq.SerializedSpace + } + + s.GenieSpaces[spaceId] = genieSpace + + return Response{ + Body: genieSpace, + } +} + +func (s *FakeWorkspace) GenieSpaceTrash(spaceId string) Response { + defer s.LockUnlock()() + + _, ok := s.GenieSpaces[spaceId] + if !ok { + return Response{ + StatusCode: 404, + Body: map[string]string{ + "message": fmt.Sprintf("Genie space with ID %s not found", spaceId), + }, + } + } + + delete(s.GenieSpaces, spaceId) + + return Response{ + Body: map[string]any{}, + } +} diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index c46457ba3f..f11698b852 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -244,6 +244,20 @@ func AddDefaultHandlers(server *Server) { return MapGet(req.Workspace, req.Workspace.PublishedDashboards, req.Vars["dashboard_id"]) }) + // Genie Spaces: + server.Handle("GET", "/api/2.0/genie/spaces/{space_id}", func(req Request) any { + return MapGet(req.Workspace, req.Workspace.GenieSpaces, req.Vars["space_id"]) + }) + server.Handle("POST", "/api/2.0/genie/spaces", func(req Request) any { + return req.Workspace.GenieSpaceCreate(req) + }) + server.Handle("PATCH", "/api/2.0/genie/spaces/{space_id}", func(req Request) any { + return req.Workspace.GenieSpaceUpdate(req, req.Vars["space_id"]) + }) + server.Handle("DELETE", "/api/2.0/genie/spaces/{space_id}", func(req Request) any { + return req.Workspace.GenieSpaceTrash(req.Vars["space_id"]) + }) + // Pipelines: server.Handle("GET", "/api/2.0/pipelines/{pipeline_id}", func(req Request) any {