From 834aafcd291a560221693504bdc393f3b3792eca Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 10:20:30 +1100 Subject: [PATCH 01/15] Add GenieSpace resource type with permission support - Add GenieSpace struct with config fields (title, description, parent_path, warehouse_id, serialized_space, file_path) - Add GenieSpacePermission type and IPermission interface implementation - Register genie_spaces in Resources struct --- bundle/config/resources.go | 9 ++ bundle/config/resources/genie_space.go | 112 +++++++++++++++++++++++++ bundle/config/resources/permission.go | 15 ++++ 3 files changed, 136 insertions(+) create mode 100644 bundle/config/resources/genie_space.go 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..c633aee431 --- /dev/null +++ b/bundle/config/resources/genie_space.go @@ -0,0 +1,112 @@ +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 form. + // Even though the SDK represents this as a string, we override it as any to allow + // for inlining as YAML. If the value is a string, it is used as is. + // If it is not a string, its contents is marshalled as JSON. + // + // The JSON structure includes instructions, sample questions, and data sources. + // Example: + // { + // "version": 1, + // "config": { + // "sample_questions": [{"id": "...", "question": ["Show orders by date"]}], + // "instructions": [{"id": "...", "content": "Use MM/DD/YYYY date format"}] + // }, + // "data_sources": { + // "tables": [{"identifier": "catalog.schema.table_name"}] + // } + // } + SerializedSpace any `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 + + // FilePath points to the local JSON file containing the Genie space definition. + // This is inlined into serialized_space during deployment. + // The file should contain the JSON structure with instructions, sample questions, + // and data sources that define the Genie space. + // This is not part of GenieSpaceConfig because we don't need to store this in state. + FilePath string `json:"file_path,omitempty"` + + 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 } From b822236353568dfa930d65b72eb7e391652467bd Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 10:24:16 +1100 Subject: [PATCH 02/15] Add direct mode CRUD operations for genie spaces - Implement Create, Read, Update, Delete operations using Genie SDK API - Handle parent_path workspace prefix stripping to match API behavior - Register genie space resource in direct mode registry --- bundle/direct/dresources/all.go | 1 + bundle/direct/dresources/genie_space.go | 185 ++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 bundle/direct/dresources/genie_space.go 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..292e18b825 --- /dev/null +++ b/bundle/direct/dresources/genie_space.go @@ -0,0 +1,185 @@ +package dresources + +import ( + "context" + "encoding/json" + "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 converts the config to API request format. +// It handles serialized_space which can be either a string or any type that needs JSON marshaling. +func prepareGenieSpaceRequest(config *resources.GenieSpaceConfig) (string, error) { + v := config.SerializedSpace + if serializedSpace, ok := v.(string); ok { + // If serialized space is already a string, use it directly. + return serializedSpace, nil + } else if v != nil { + // If it's inlined in the bundle config as a map, marshal it to a string. + b, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("failed to marshal serialized_space: %w", err) + } + return string(b), nil + } + return "", nil +} + +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, err := prepareGenieSpaceRequest(config) + if err != nil { + return "", nil, err + } + + 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, err := prepareGenieSpaceRequest(config) + if err != nil { + return nil, err + } + + 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 +} From 5f4d9a74c18c4aee68f5853688de9d4b5d673535 Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 10:25:50 +1100 Subject: [PATCH 03/15] Add mutators for genie space serialization and fixups - Add ConfigureGenieSpaceSerializedSpace to load file_path content into serialized_space - Emit warning when both file_path and serialized_space are set - Add GenieSpaceFixups to strip /Workspace prefix from parent_path - Register mutators in resource mutator pipeline --- ...configure_genie_spaces_serialized_space.go | 65 +++++++++++++++++++ .../resourcemutator/genie_space_fixups.go | 33 ++++++++++ .../resourcemutator/resource_mutator.go | 11 ++++ 3 files changed, 109 insertions(+) create mode 100644 bundle/config/mutator/resourcemutator/configure_genie_spaces_serialized_space.go create mode 100644 bundle/config/mutator/resourcemutator/genie_space_fixups.go diff --git a/bundle/config/mutator/resourcemutator/configure_genie_spaces_serialized_space.go b/bundle/config/mutator/resourcemutator/configure_genie_spaces_serialized_space.go new file mode 100644 index 0000000000..f04a823017 --- /dev/null +++ b/bundle/config/mutator/resourcemutator/configure_genie_spaces_serialized_space.go @@ -0,0 +1,65 @@ +package resourcemutator + +import ( + "context" + "fmt" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/diag" + "github.com/databricks/cli/libs/dyn" +) + +const ( + serializedSpaceFieldName = "serialized_space" +) + +type configureGenieSpaceSerializedSpace struct{} + +func ConfigureGenieSpaceSerializedSpace() bundle.Mutator { + return &configureGenieSpaceSerializedSpace{} +} + +func (c configureGenieSpaceSerializedSpace) Name() string { + return "ConfigureGenieSpaceSerializedSpace" +} + +func (c configureGenieSpaceSerializedSpace) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics { + var diags diag.Diagnostics + + pattern := dyn.NewPattern( + dyn.Key("resources"), + dyn.Key("genie_spaces"), + dyn.AnyKey(), + ) + + // Configure serialized_space field for all genie spaces. + err := b.Config.Mutate(func(v dyn.Value) (dyn.Value, error) { + return dyn.MapByPattern(v, pattern, func(p dyn.Path, v dyn.Value) (dyn.Value, error) { + // Include "serialized_space" field if "file_path" is set. + path, ok := v.Get(filePathFieldName).AsString() + if !ok { + return v, nil + } + + // Warn if both file_path and serialized_space are set. + existingSpace := v.Get(serializedSpaceFieldName) + if existingSpace.Kind() != dyn.KindInvalid && existingSpace.Kind() != dyn.KindNil { + resourceName := p[len(p)-1].Key() + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Warning, + Summary: fmt.Sprintf("genie space %q has both file_path and serialized_space set; file_path takes precedence and serialized_space will be overwritten", resourceName), + }) + } + + contents, err := b.SyncRoot.ReadFile(path) + if err != nil { + return dyn.InvalidValue, fmt.Errorf("failed to read serialized genie space from file_path %s: %w", path, err) + } + + return dyn.Set(v, serializedSpaceFieldName, dyn.V(string(contents))) + }) + }) + + diags = diags.Extend(diag.FromErr(err)) + return diags +} 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..e836f3f8ba 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) @@ -176,6 +182,11 @@ func applyNormalizeMutators(ctx context.Context, b *bundle.Bundle) { // Drops (dynamic): resources.dashboards.*.file_path ConfigureDashboardSerializedDashboard(), + // Reads (dynamic): resources.genie_spaces.*.file_path + // Updates (dynamic): resources.genie_spaces.*.serialized_space + // Reads file contents and inlines them into serialized_space + ConfigureGenieSpaceSerializedSpace(), + // Reads and updates (typed): resources.jobs.*.** JobClustersFixups(), ClusterFixups(), From 01eaee762797e95f1dd4de458017cf4b1db7bbcb Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 10:28:09 +1100 Subject: [PATCH 04/15] Add bundle permissions and dev prefix for genie spaces - Add genie_spaces to levelsMap with CAN_MANAGE, CAN_VIEW, CAN_RUN mappings. Don't think CAN_EDIT is available in API - Add Title prefixing in development mode (e.g. [dev username] title) --- .../mutator/resourcemutator/apply_bundle_permissions.go | 7 +++++++ bundle/config/mutator/resourcemutator/apply_presets.go | 8 ++++++++ 2 files changed, 15 insertions(+) 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_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 From a8ed8113cac2176f5995320f591cd476c86afddc Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 10:29:17 +1100 Subject: [PATCH 05/15] Add genie space handlers to test server - Add fake genie space CRUD handlers for acceptance tests - Add GenieSpaces map to FakeWorkspace state - Register GET/POST/PATCH/DELETE routes for /api/2.0/genie/spaces --- libs/testserver/fake_workspace.go | 2 + libs/testserver/genie_spaces.go | 138 ++++++++++++++++++++++++++++++ libs/testserver/handlers.go | 14 +++ 3 files changed, 154 insertions(+) create mode 100644 libs/testserver/genie_spaces.go 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 { From c0b94609cbbb9d4c1bcc6edf40118c087521acc8 Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 10:30:04 +1100 Subject: [PATCH 06/15] Update unit tests for genie space resource - Add genie_spaces to mockBundle and test assertions - Add permission test coverage for genie spaces - Skip genie_spaces in terraform lifecycle test (direct-mode only) --- .../apply_bundle_permissions_test.go | 15 ++++++-- .../resourcemutator/apply_target_mode_test.go | 10 ++++++ .../mutator/resourcemutator/run_as_test.go | 6 ++-- bundle/config/resources_test.go | 4 +++ bundle/deploy/terraform/lifecycle_test.go | 8 +++++ bundle/statemgmt/state_load_test.go | 35 +++++++++++++++++++ 6 files changed, 73 insertions(+), 5 deletions(-) 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_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/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_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/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) From 710959baae745b6a8a1112bb02a62cf39938df15 Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 10:30:38 +1100 Subject: [PATCH 07/15] Add schema annotations for genie space - Add descriptions for all GenieSpace fields - Document file_path and serialized_space precedence behavior so user can interpret easily and decide which one they should pick --- bundle/internal/schema/annotations.yml | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 2a9c78b4ac..4240b048da 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,34 @@ 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. + "file_path": + "description": |- + Path to a file containing the serialized space content. When specified, the file content is loaded and used as the serialized_space value. If both file_path and serialized_space are set, file_path takes precedence. + "lifecycle": + "description": |- + PLACEHOLDER + "parent_path": + "description": |- + Workspace path where the genie space is stored. + "serialized_space": + "description": |- + The serialized content of the genie space, containing the layout and components. If file_path is also set, this value will be overwritten with the file contents. + "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.Grant: "principal": "description": |- From 1a9a0cc6eadeae700ce4e2c8c62d7c21975429d2 Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 10:31:06 +1100 Subject: [PATCH 08/15] Add acceptance tests for genie spaces --- .../genie_spaces/simple/databricks.yml.tmpl | 15 +++++++++ .../genie_spaces/simple/out.plan.direct.json | 33 +++++++++++++++++++ .../genie_spaces/simple/out.test.toml | 6 ++++ .../resources/genie_spaces/simple/output.txt | 23 +++++++++++++ .../genie_spaces/simple/sample-genie.json | 20 +++++++++++ .../resources/genie_spaces/simple/script | 24 ++++++++++++++ .../resources/genie_spaces/simple/test.toml | 8 +++++ .../bundle/resources/genie_spaces/test.toml | 12 +++++++ 8 files changed, 141 insertions(+) create mode 100644 acceptance/bundle/resources/genie_spaces/simple/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/genie_spaces/simple/out.plan.direct.json create mode 100644 acceptance/bundle/resources/genie_spaces/simple/out.test.toml create mode 100644 acceptance/bundle/resources/genie_spaces/simple/output.txt create mode 100644 acceptance/bundle/resources/genie_spaces/simple/sample-genie.json create mode 100644 acceptance/bundle/resources/genie_spaces/simple/script create mode 100644 acceptance/bundle/resources/genie_spaces/simple/test.toml create mode 100644 acceptance/bundle/resources/genie_spaces/test.toml 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..16ecbf69e8 --- /dev/null +++ b/acceptance/bundle/resources/genie_spaces/simple/databricks.yml.tmpl @@ -0,0 +1,15 @@ +# +# Acceptance test for deploying genie spaces with the following setup: +# 1. genie space file is inside the bundle root +# 2. sync root is the same as the bundle root +# +bundle: + name: deploy-genie-space-test-$UNIQUE_NAME + +resources: + genie_spaces: + genie1: + title: $GENIE_TITLE + warehouse_id: $TEST_DEFAULT_WAREHOUSE_ID + file_path: "sample-genie.json" + parent_path: /Users/$CURRENT_USER_NAME 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..641e6ee951 --- /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": "{\n \"version\": 1,\n \"config\": {\n \"sample_questions\": [\n {\n \"id\": \"q1\",\n \"question\": [\"What is the total count?\"]\n }\n ],\n \"instructions\": [\n {\n \"id\": \"i1\",\n \"content\": \"This is a test genie space for acceptance testing.\"\n }\n ]\n },\n \"data_sources\": {\n \"tables\": []\n }\n}\n", + "title": "test bundle-deploy-genie-space [UUID]", + "warehouse_id": "[TEST_DEFAULT_WAREHOUSE_ID]" + } + }, + "remote_state": { + "serialized_space": "{\n \"version\": 1,\n \"config\": {\n \"sample_questions\": [\n {\n \"id\": \"q1\",\n \"question\": [\"What is the total count?\"]\n }\n ],\n \"instructions\": [\n {\n \"id\": \"i1\",\n \"content\": \"This is a test genie space for acceptance testing.\"\n }\n ]\n },\n \"data_sources\": {\n \"tables\": []\n }\n}\n", + "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/sample-genie.json b/acceptance/bundle/resources/genie_spaces/simple/sample-genie.json new file mode 100644 index 0000000000..1d6a7368cf --- /dev/null +++ b/acceptance/bundle/resources/genie_spaces/simple/sample-genie.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "config": { + "sample_questions": [ + { + "id": "q1", + "question": ["What is the total count?"] + } + ], + "instructions": [ + { + "id": "i1", + "content": "This is a test genie space for acceptance testing." + } + ] + }, + "data_sources": { + "tables": [] + } +} 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" From 98a658db3bc4c5d445ddbab1f76fc44c1db6f57c Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 10:53:34 +1100 Subject: [PATCH 09/15] Add acceptance tests for genie spaces - Add simple genie space deployment test with file_path - Use 32-char hex UUIDs for IDs (API requirement) --- .../bundle/resources/genie_spaces/simple/sample-genie.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/acceptance/bundle/resources/genie_spaces/simple/sample-genie.json b/acceptance/bundle/resources/genie_spaces/simple/sample-genie.json index 1d6a7368cf..a330e9c520 100644 --- a/acceptance/bundle/resources/genie_spaces/simple/sample-genie.json +++ b/acceptance/bundle/resources/genie_spaces/simple/sample-genie.json @@ -1,15 +1,16 @@ { + "_comment": "IDs must be lowercase 32-character hex UUIDs without hyphens. The API returns INVALID_PARAMETER_VALUE if short IDs like 'q1' are used.", "version": 1, "config": { "sample_questions": [ { - "id": "q1", + "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "question": ["What is the total count?"] } ], "instructions": [ { - "id": "i1", + "id": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", "content": "This is a test genie space for acceptance testing." } ] From 07fb5511aa5ea1ac709c4a955c82b2e5336caa6d Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 12:28:11 +1100 Subject: [PATCH 10/15] Remove file_path field and serialization mutator for genie spaces Simplifies implementation by removing the file_path indirection. Users specify serialized_space directly as a JSON string --- ...configure_genie_spaces_serialized_space.go | 65 ------------------- .../resourcemutator/resource_mutator.go | 5 -- 2 files changed, 70 deletions(-) delete mode 100644 bundle/config/mutator/resourcemutator/configure_genie_spaces_serialized_space.go diff --git a/bundle/config/mutator/resourcemutator/configure_genie_spaces_serialized_space.go b/bundle/config/mutator/resourcemutator/configure_genie_spaces_serialized_space.go deleted file mode 100644 index f04a823017..0000000000 --- a/bundle/config/mutator/resourcemutator/configure_genie_spaces_serialized_space.go +++ /dev/null @@ -1,65 +0,0 @@ -package resourcemutator - -import ( - "context" - "fmt" - - "github.com/databricks/cli/bundle" - "github.com/databricks/cli/libs/diag" - "github.com/databricks/cli/libs/dyn" -) - -const ( - serializedSpaceFieldName = "serialized_space" -) - -type configureGenieSpaceSerializedSpace struct{} - -func ConfigureGenieSpaceSerializedSpace() bundle.Mutator { - return &configureGenieSpaceSerializedSpace{} -} - -func (c configureGenieSpaceSerializedSpace) Name() string { - return "ConfigureGenieSpaceSerializedSpace" -} - -func (c configureGenieSpaceSerializedSpace) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics { - var diags diag.Diagnostics - - pattern := dyn.NewPattern( - dyn.Key("resources"), - dyn.Key("genie_spaces"), - dyn.AnyKey(), - ) - - // Configure serialized_space field for all genie spaces. - err := b.Config.Mutate(func(v dyn.Value) (dyn.Value, error) { - return dyn.MapByPattern(v, pattern, func(p dyn.Path, v dyn.Value) (dyn.Value, error) { - // Include "serialized_space" field if "file_path" is set. - path, ok := v.Get(filePathFieldName).AsString() - if !ok { - return v, nil - } - - // Warn if both file_path and serialized_space are set. - existingSpace := v.Get(serializedSpaceFieldName) - if existingSpace.Kind() != dyn.KindInvalid && existingSpace.Kind() != dyn.KindNil { - resourceName := p[len(p)-1].Key() - diags = diags.Append(diag.Diagnostic{ - Severity: diag.Warning, - Summary: fmt.Sprintf("genie space %q has both file_path and serialized_space set; file_path takes precedence and serialized_space will be overwritten", resourceName), - }) - } - - contents, err := b.SyncRoot.ReadFile(path) - if err != nil { - return dyn.InvalidValue, fmt.Errorf("failed to read serialized genie space from file_path %s: %w", path, err) - } - - return dyn.Set(v, serializedSpaceFieldName, dyn.V(string(contents))) - }) - }) - - diags = diags.Extend(diag.FromErr(err)) - return diags -} diff --git a/bundle/config/mutator/resourcemutator/resource_mutator.go b/bundle/config/mutator/resourcemutator/resource_mutator.go index e836f3f8ba..c3dfb7eff0 100644 --- a/bundle/config/mutator/resourcemutator/resource_mutator.go +++ b/bundle/config/mutator/resourcemutator/resource_mutator.go @@ -182,11 +182,6 @@ func applyNormalizeMutators(ctx context.Context, b *bundle.Bundle) { // Drops (dynamic): resources.dashboards.*.file_path ConfigureDashboardSerializedDashboard(), - // Reads (dynamic): resources.genie_spaces.*.file_path - // Updates (dynamic): resources.genie_spaces.*.serialized_space - // Reads file contents and inlines them into serialized_space - ConfigureGenieSpaceSerializedSpace(), - // Reads and updates (typed): resources.jobs.*.** JobClustersFixups(), ClusterFixups(), From e7a299cc88a0f7366fc691455883a8e0869fdb58 Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 12:28:27 +1100 Subject: [PATCH 11/15] Change genie space SerializedSpace field type from any to string Aligns with API expectations. Simplifies direct deploy code by removing json marshaling logic. --- bundle/config/resources/genie_space.go | 28 ++++------------------- bundle/direct/dresources/genie_space.go | 30 +++++-------------------- 2 files changed, 9 insertions(+), 49 deletions(-) diff --git a/bundle/config/resources/genie_space.go b/bundle/config/resources/genie_space.go index c633aee431..6bd517c708 100644 --- a/bundle/config/resources/genie_space.go +++ b/bundle/config/resources/genie_space.go @@ -30,24 +30,11 @@ type GenieSpaceConfig struct { // 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 form. - // Even though the SDK represents this as a string, we override it as any to allow - // for inlining as YAML. If the value is a string, it is used as is. - // If it is not a string, its contents is marshalled as JSON. - // - // The JSON structure includes instructions, sample questions, and data sources. + // 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"]}], - // "instructions": [{"id": "...", "content": "Use MM/DD/YYYY date format"}] - // }, - // "data_sources": { - // "tables": [{"identifier": "catalog.schema.table_name"}] - // } - // } - SerializedSpace any `json:"serialized_space,omitempty"` + // {"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:"-"` } @@ -65,13 +52,6 @@ type GenieSpace struct { BaseResource GenieSpaceConfig - // FilePath points to the local JSON file containing the Genie space definition. - // This is inlined into serialized_space during deployment. - // The file should contain the JSON structure with instructions, sample questions, - // and data sources that define the Genie space. - // This is not part of GenieSpaceConfig because we don't need to store this in state. - FilePath string `json:"file_path,omitempty"` - Permissions []GenieSpacePermission `json:"permissions,omitempty"` } diff --git a/bundle/direct/dresources/genie_space.go b/bundle/direct/dresources/genie_space.go index 292e18b825..16540f14d4 100644 --- a/bundle/direct/dresources/genie_space.go +++ b/bundle/direct/dresources/genie_space.go @@ -2,7 +2,6 @@ package dresources import ( "context" - "encoding/json" "fmt" "path" "strings" @@ -78,22 +77,9 @@ func (r *ResourceGenieSpace) DoRead(ctx context.Context, id string) (*resources. }, nil } -// prepareGenieSpaceRequest converts the config to API request format. -// It handles serialized_space which can be either a string or any type that needs JSON marshaling. -func prepareGenieSpaceRequest(config *resources.GenieSpaceConfig) (string, error) { - v := config.SerializedSpace - if serializedSpace, ok := v.(string); ok { - // If serialized space is already a string, use it directly. - return serializedSpace, nil - } else if v != nil { - // If it's inlined in the bundle config as a map, marshal it to a string. - b, err := json.Marshal(v) - if err != nil { - return "", fmt.Errorf("failed to marshal serialized_space: %w", err) - } - return string(b), nil - } - return "", 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 { @@ -109,10 +95,7 @@ func responseToGenieState(resp *dashboards.GenieSpace, serializedSpace, parentPa } func (r *ResourceGenieSpace) DoCreate(ctx context.Context, config *resources.GenieSpaceConfig) (string, *resources.GenieSpaceConfig, error) { - serializedSpace, err := prepareGenieSpaceRequest(config) - if err != nil { - return "", nil, err - } + serializedSpace := prepareGenieSpaceRequest(config) createReq := dashboards.GenieCreateSpaceRequest{ WarehouseId: config.WarehouseId, @@ -142,10 +125,7 @@ func (r *ResourceGenieSpace) DoCreate(ctx context.Context, config *resources.Gen } func (r *ResourceGenieSpace) DoUpdate(ctx context.Context, id string, config *resources.GenieSpaceConfig, _ *Changes) (*resources.GenieSpaceConfig, error) { - serializedSpace, err := prepareGenieSpaceRequest(config) - if err != nil { - return nil, err - } + serializedSpace := prepareGenieSpaceRequest(config) resp, err := r.client.Genie.UpdateSpace(ctx, dashboards.GenieUpdateSpaceRequest{ SpaceId: id, From b89f98d8d5bda032163817806279c5a565043e6a Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 12:28:47 +1100 Subject: [PATCH 12/15] Update genie space schema annotations Remove file_path references, update serialized_space description. --- bundle/internal/schema/annotations.yml | 21 ++++- bundle/schema/jsonschema.json | 110 +++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 4240b048da..242db48c94 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -576,18 +576,18 @@ github.com/databricks/cli/bundle/config/resources.GenieSpace: "description": "description": |- The description of the genie space. - "file_path": - "description": |- - Path to a file containing the serialized space content. When specified, the file content is loaded and used as the serialized_space value. If both file_path and serialized_space are set, file_path takes precedence. "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 the layout and components. If file_path is also set, this value will be overwritten with the file contents. + 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. @@ -597,6 +597,19 @@ github.com/databricks/cli/bundle/config/resources.GenieSpace: "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/schema/jsonschema.json b/bundle/schema/jsonschema.json index 41cb0fa591..eeeb7889a2 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -718,6 +718,83 @@ } ] }, + "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": { + "type": "string" + }, "resources.Grant": { "oneOf": [ { @@ -2465,6 +2542,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 +9720,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 +10082,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": [ { From 6a651edf78e78653072e9a7a459c8629c58fb368 Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 12:29:00 +1100 Subject: [PATCH 13/15] Update genie space acceptance tests to use JSON string format Remove sample-genie.json, inline serialized_space as JSON string. --- .../genie_spaces/simple/databricks.yml.tmpl | 6 ++---- .../genie_spaces/simple/out.plan.direct.json | 4 ++-- .../genie_spaces/simple/sample-genie.json | 21 ------------------- 3 files changed, 4 insertions(+), 27 deletions(-) delete mode 100644 acceptance/bundle/resources/genie_spaces/simple/sample-genie.json diff --git a/acceptance/bundle/resources/genie_spaces/simple/databricks.yml.tmpl b/acceptance/bundle/resources/genie_spaces/simple/databricks.yml.tmpl index 16ecbf69e8..a9d535827c 100644 --- a/acceptance/bundle/resources/genie_spaces/simple/databricks.yml.tmpl +++ b/acceptance/bundle/resources/genie_spaces/simple/databricks.yml.tmpl @@ -1,7 +1,5 @@ # -# Acceptance test for deploying genie spaces with the following setup: -# 1. genie space file is inside the bundle root -# 2. sync root is the same as the bundle root +# Acceptance test for deploying genie spaces # bundle: name: deploy-genie-space-test-$UNIQUE_NAME @@ -11,5 +9,5 @@ resources: genie1: title: $GENIE_TITLE warehouse_id: $TEST_DEFAULT_WAREHOUSE_ID - file_path: "sample-genie.json" 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 index 641e6ee951..9489e0ed40 100644 --- a/acceptance/bundle/resources/genie_spaces/simple/out.plan.direct.json +++ b/acceptance/bundle/resources/genie_spaces/simple/out.plan.direct.json @@ -9,13 +9,13 @@ "new_state": { "value": { "parent_path": "/Workspace/Users/[USERNAME]", - "serialized_space": "{\n \"version\": 1,\n \"config\": {\n \"sample_questions\": [\n {\n \"id\": \"q1\",\n \"question\": [\"What is the total count?\"]\n }\n ],\n \"instructions\": [\n {\n \"id\": \"i1\",\n \"content\": \"This is a test genie space for acceptance testing.\"\n }\n ]\n },\n \"data_sources\": {\n \"tables\": []\n }\n}\n", + "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": "{\n \"version\": 1,\n \"config\": {\n \"sample_questions\": [\n {\n \"id\": \"q1\",\n \"question\": [\"What is the total count?\"]\n }\n ],\n \"instructions\": [\n {\n \"id\": \"i1\",\n \"content\": \"This is a test genie space for acceptance testing.\"\n }\n ]\n },\n \"data_sources\": {\n \"tables\": []\n }\n}\n", + "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]" diff --git a/acceptance/bundle/resources/genie_spaces/simple/sample-genie.json b/acceptance/bundle/resources/genie_spaces/simple/sample-genie.json deleted file mode 100644 index a330e9c520..0000000000 --- a/acceptance/bundle/resources/genie_spaces/simple/sample-genie.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "_comment": "IDs must be lowercase 32-character hex UUIDs without hyphens. The API returns INVALID_PARAMETER_VALUE if short IDs like 'q1' are used.", - "version": 1, - "config": { - "sample_questions": [ - { - "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", - "question": ["What is the total count?"] - } - ], - "instructions": [ - { - "id": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", - "content": "This is a test genie space for acceptance testing." - } - ] - }, - "data_sources": { - "tables": [] - } -} From 272637fb08131dc01f6bb631bf3502f8aec8498b Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Fri, 2 Jan 2026 12:40:31 +1100 Subject: [PATCH 14/15] Add changelog entry for genie space resource support --- NEXT_CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From eabf9d93085717535ce596e0eb77ccb4176e8eb0 Mon Sep 17 00:00:00 2001 From: Niju Vijayakumar Date: Sun, 4 Jan 2026 22:32:01 +1100 Subject: [PATCH 15/15] fix: add GenieSpacePermissionLevel enum to schema The CI checks started failing for the PR 4191. To resolve those issues and cascading failures due to the root causes, this commit adds enum values for `GenieSpacePermissionLevel` in the annotations file, following the existing DashboardPermissionLevel pattern (as I relied on dashboard implementation to create the GenieSpace implementation) This fixes two CI failures: - validate-python-codegen: schema now has enum for Python codegen to parse - validate-generated-is-up-to-date: regenerated required_fields.go --- .../schema/annotations_openapi_overrides.yml | 11 +++++++++++ .../validation/generated/required_fields.go | 2 ++ bundle/schema/jsonschema.json | 16 +++++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) 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 eeeb7889a2..b35a49fc11 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -793,7 +793,21 @@ ] }, "resources.GenieSpacePermissionLevel": { - "type": "string" + "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": [