diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index ba12113034..2198ac3226 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -18,6 +18,7 @@ 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{ + "app_spaces", "catalogs", "external_locations", "volumes", diff --git a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go index 804552f56a..27dfab5162 100644 --- a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go +++ b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go @@ -160,6 +160,13 @@ func mockBundle(mode config.Mode) *bundle.Bundle { }, }, }, + AppSpaces: map[string]*resources.AppSpace{ + "app_space1": { + Space: apps.Space{ + Name: "app-space-1", + }, + }, + }, SecretScopes: map[string]*resources.SecretScope{ "secretScope1": { Name: "secretScope1", @@ -430,7 +437,7 @@ func TestAllNonUcResourcesAreRenamed(t *testing.T) { // Skip resources that are not renamed (either because they don't have a user-facing Name field, // or because their Name is server-generated rather than user-specified) - if resourceType == "Apps" || resourceType == "SecretScopes" || resourceType == "DatabaseInstances" || resourceType == "DatabaseCatalogs" || resourceType == "SyncedDatabaseTables" || resourceType == "PostgresProjects" || resourceType == "PostgresBranches" || resourceType == "PostgresEndpoints" { + if resourceType == "Apps" || resourceType == "AppSpaces" || resourceType == "SecretScopes" || resourceType == "DatabaseInstances" || resourceType == "DatabaseCatalogs" || resourceType == "SyncedDatabaseTables" || resourceType == "PostgresProjects" || resourceType == "PostgresBranches" || resourceType == "PostgresEndpoints" { continue } diff --git a/bundle/config/mutator/resourcemutator/merge_app_spaces.go b/bundle/config/mutator/resourcemutator/merge_app_spaces.go new file mode 100644 index 0000000000..cb05238adf --- /dev/null +++ b/bundle/config/mutator/resourcemutator/merge_app_spaces.go @@ -0,0 +1,45 @@ +package resourcemutator + +import ( + "context" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/diag" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/merge" +) + +type mergeAppSpaces struct{} + +func MergeAppSpaces() bundle.Mutator { + return &mergeAppSpaces{} +} + +func (m *mergeAppSpaces) Name() string { + return "MergeAppSpaces" +} + +func (m *mergeAppSpaces) resourceName(v dyn.Value) string { + switch v.Kind() { + case dyn.KindInvalid, dyn.KindNil: + return "" + case dyn.KindString: + return v.MustString() + default: + panic("app space resource name must be a string") + } +} + +func (m *mergeAppSpaces) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { + err := b.Config.Mutate(func(v dyn.Value) (dyn.Value, error) { + if v.Kind() == dyn.KindNil { + return v, nil + } + + return dyn.Map(v, "resources.app_spaces", dyn.Foreach(func(_ dyn.Path, space dyn.Value) (dyn.Value, error) { + return dyn.Map(space, "resources", merge.ElementsByKeyWithOverride("name", m.resourceName)) + })) + }) + + return diag.FromErr(err) +} diff --git a/bundle/config/mutator/resourcemutator/resource_mutator.go b/bundle/config/mutator/resourcemutator/resource_mutator.go index 9616de202a..5930e7c332 100644 --- a/bundle/config/mutator/resourcemutator/resource_mutator.go +++ b/bundle/config/mutator/resourcemutator/resource_mutator.go @@ -167,6 +167,10 @@ func applyNormalizeMutators(ctx context.Context, b *bundle.Bundle) { // Updates (dynamic): resources.apps.*.resources (merges app resources with the same name) MergeApps(), + // Reads (dynamic): resources.app_spaces.*.resources (reads app space resources to merge) + // Updates (dynamic): resources.app_spaces.*.resources (merges app space resources with the same name) + MergeAppSpaces(), + // Reads (dynamic): resources.{catalogs,schemas,external_locations,volumes,registered_models}.*.grants // Updates (dynamic): same paths — merges grant entries by principal and deduplicates privileges MergeGrants(), diff --git a/bundle/config/mutator/resourcemutator/run_as.go b/bundle/config/mutator/resourcemutator/run_as.go index 15decbfee2..619877e9d2 100644 --- a/bundle/config/mutator/resourcemutator/run_as.go +++ b/bundle/config/mutator/resourcemutator/run_as.go @@ -126,6 +126,16 @@ func validateRunAs(b *bundle.Bundle) diag.Diagnostics { )) } + // App spaces do not support run_as in the API. + if len(b.Config.Resources.AppSpaces) > 0 { + diags = diags.Extend(reportRunAsNotSupported( + "app_spaces", + b.Config.GetLocation("resources.app_spaces"), + b.Config.Workspace.CurrentUser.UserName, + identity, + )) + } + return diags } diff --git a/bundle/config/mutator/resourcemutator/run_as_test.go b/bundle/config/mutator/resourcemutator/run_as_test.go index 5ed9edad54..e24f75132b 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -33,6 +33,7 @@ func allResourceTypes(t *testing.T) []string { // also update this check when adding a new resource require.Equal(t, []string{ "alerts", + "app_spaces", "apps", "catalogs", "clusters", diff --git a/bundle/config/resources.go b/bundle/config/resources.go index 4131f68695..4f52be7622 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -26,6 +26,7 @@ type Resources struct { Clusters map[string]*resources.Cluster `json:"clusters,omitempty"` Dashboards map[string]*resources.Dashboard `json:"dashboards,omitempty"` Apps map[string]*resources.App `json:"apps,omitempty"` + AppSpaces map[string]*resources.AppSpace `json:"app_spaces,omitempty"` SecretScopes map[string]*resources.SecretScope `json:"secret_scopes,omitempty"` Alerts map[string]*resources.Alert `json:"alerts,omitempty"` SqlWarehouses map[string]*resources.SqlWarehouse `json:"sql_warehouses,omitempty"` @@ -102,6 +103,7 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["dashboards"], r.Dashboards), collectResourceMap(descriptions["volumes"], r.Volumes), collectResourceMap(descriptions["apps"], r.Apps), + collectResourceMap(descriptions["app_spaces"], r.AppSpaces), collectResourceMap(descriptions["alerts"], r.Alerts), collectResourceMap(descriptions["secret_scopes"], r.SecretScopes), collectResourceMap(descriptions["sql_warehouses"], r.SqlWarehouses), @@ -156,6 +158,7 @@ func SupportedResources() map[string]resources.ResourceDescription { "dashboards": (&resources.Dashboard{}).ResourceDescription(), "volumes": (&resources.Volume{}).ResourceDescription(), "apps": (&resources.App{}).ResourceDescription(), + "app_spaces": (&resources.AppSpace{}).ResourceDescription(), "secret_scopes": (&resources.SecretScope{}).ResourceDescription(), "alerts": (&resources.Alert{}).ResourceDescription(), "sql_warehouses": (&resources.SqlWarehouse{}).ResourceDescription(), diff --git a/bundle/config/resources/app_spaces.go b/bundle/config/resources/app_spaces.go new file mode 100644 index 0000000000..4c6c4ba59c --- /dev/null +++ b/bundle/config/resources/app_spaces.go @@ -0,0 +1,61 @@ +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/apps" +) + +type AppSpace struct { + BaseResource + apps.Space +} + +func (s *AppSpace) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, s) +} + +func (s AppSpace) MarshalJSON() ([]byte, error) { + return marshal.Marshal(s) +} + +func (s *AppSpace) Exists(ctx context.Context, w *databricks.WorkspaceClient, id string) (bool, error) { + _, err := w.Apps.GetSpace(ctx, apps.GetSpaceRequest{Name: id}) + if err != nil { + log.Debugf(ctx, "app space %s does not exist", id) + return false, err + } + return true, nil +} + +func (*AppSpace) ResourceDescription() ResourceDescription { + return ResourceDescription{ + SingularName: "app_space", + PluralName: "app_spaces", + SingularTitle: "App Space", + PluralTitle: "App Spaces", + } +} + +func (s *AppSpace) InitializeURL(baseURL url.URL) { + if s.ModifiedStatus == "" || s.ModifiedStatus == ModifiedStatusCreated { + return + } + baseURL.Path = "apps/spaces/" + s.GetName() + s.URL = baseURL.String() +} + +func (s *AppSpace) GetName() string { + if s.ID != "" { + return s.ID + } + return s.Name +} + +func (s *AppSpace) GetURL() string { + return s.URL +} diff --git a/bundle/config/resources_test.go b/bundle/config/resources_test.go index ddc90209e8..d6fb1550ae 100644 --- a/bundle/config/resources_test.go +++ b/bundle/config/resources_test.go @@ -170,6 +170,11 @@ func TestResourcesBindSupport(t *testing.T) { App: apps.App{}, }, }, + AppSpaces: map[string]*resources.AppSpace{ + "my_app_space": { + Space: apps.Space{}, + }, + }, Alerts: map[string]*resources.Alert{ "my_alert": { AlertV2: sql.AlertV2{}, @@ -257,6 +262,7 @@ func TestResourcesBindSupport(t *testing.T) { m.GetMockLakeviewAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockVolumesAPI().EXPECT().Read(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockAppsAPI().EXPECT().GetByName(mock.Anything, mock.Anything).Return(nil, nil) + m.GetMockAppsAPI().EXPECT().GetSpace(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockAlertsV2API().EXPECT().GetAlertById(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockQualityMonitorsAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockServingEndpointsAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) diff --git a/bundle/direct/dresources/all.go b/bundle/direct/dresources/all.go index 6a7381a3fc..feac185b9b 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -16,6 +16,7 @@ var SupportedResources = map[string]any{ "volumes": (*ResourceVolume)(nil), "models": (*ResourceMlflowModel)(nil), "apps": (*ResourceApp)(nil), + "app_spaces": (*ResourceAppSpace)(nil), "sql_warehouses": (*ResourceSqlWarehouse)(nil), "database_instances": (*ResourceDatabaseInstance)(nil), "database_catalogs": (*ResourceDatabaseCatalog)(nil), diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 63caa5cfed..f85ba1327b 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -39,6 +39,12 @@ var testConfig map[string]any = map[string]any{ }, }, + "app_spaces": &resources.AppSpace{ + Space: apps.Space{ + Name: "my-app-space", + }, + }, + "catalogs": &resources.Catalog{ CreateCatalog: catalog.CreateCatalog{ Name: "mycatalog", diff --git a/bundle/direct/dresources/app_space.go b/bundle/direct/dresources/app_space.go new file mode 100644 index 0000000000..1ee7d51a96 --- /dev/null +++ b/bundle/direct/dresources/app_space.go @@ -0,0 +1,60 @@ +package dresources + +import ( + "context" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/common/types/fieldmask" + "github.com/databricks/databricks-sdk-go/service/apps" +) + +type ResourceAppSpace struct { + client *databricks.WorkspaceClient +} + +func (*ResourceAppSpace) New(client *databricks.WorkspaceClient) *ResourceAppSpace { + return &ResourceAppSpace{client: client} +} + +func (*ResourceAppSpace) PrepareState(input *resources.AppSpace) *apps.Space { + return &input.Space +} + +func (r *ResourceAppSpace) DoRead(ctx context.Context, id string) (*apps.Space, error) { + return r.client.Apps.GetSpace(ctx, apps.GetSpaceRequest{Name: id}) +} + +func (r *ResourceAppSpace) DoCreate(ctx context.Context, config *apps.Space) (string, *apps.Space, error) { + waiter, err := r.client.Apps.CreateSpace(ctx, apps.CreateSpaceRequest{ + Space: *config, + }) + if err != nil { + return "", nil, err + } + space, err := waiter.Wait(ctx) + if err != nil { + return "", nil, err + } + return space.Name, space, nil +} + +func (r *ResourceAppSpace) DoUpdate(ctx context.Context, id string, config *apps.Space, _ *PlanEntry) (*apps.Space, error) { + waiter, err := r.client.Apps.UpdateSpace(ctx, apps.UpdateSpaceRequest{ + Name: id, + Space: *config, + UpdateMask: fieldmask.FieldMask{Paths: []string{"description", "resources", "user_api_scopes", "usage_policy_id"}}, + }) + if err != nil { + return nil, err + } + return waiter.Wait(ctx) +} + +func (r *ResourceAppSpace) DoDelete(ctx context.Context, id string) error { + waiter, err := r.client.Apps.DeleteSpace(ctx, apps.DeleteSpaceRequest{Name: id}) + if err != nil { + return err + } + return waiter.Wait(ctx) +} diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 51a8f7b8a2..4508b816ce 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -371,6 +371,11 @@ resources: - field: dataset_schema reason: input_only + app_spaces: + recreate_on_changes: + - field: name + reason: immutable + apps: recreate_on_changes: - field: name diff --git a/libs/testserver/app_spaces.go b/libs/testserver/app_spaces.go new file mode 100644 index 0000000000..88d33a55f9 --- /dev/null +++ b/libs/testserver/app_spaces.go @@ -0,0 +1,103 @@ +package testserver + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/databricks/databricks-sdk-go/service/apps" +) + +func (s *FakeWorkspace) AppSpaceUpsert(req Request, name string) Response { + var space apps.Space + if err := json.Unmarshal(req.Body, &space); err != nil { + return Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: http.StatusInternalServerError, + } + } + + defer s.LockUnlock()() + + if name != "" { + // Update path + existing, ok := s.AppSpaces[name] + if !ok { + return Response{StatusCode: 404} + } + if space.Description != "" { + existing.Description = space.Description + } + if space.Resources != nil { + existing.Resources = space.Resources + } + if space.UserApiScopes != nil { + existing.UserApiScopes = space.UserApiScopes + } + if space.UsagePolicyId != "" { + existing.UsagePolicyId = space.UsagePolicyId + } + s.AppSpaces[name] = existing + space = existing + } else { + // Create path + name = space.Name + if name == "" { + return Response{StatusCode: 400, Body: "name is required"} + } + if _, exists := s.AppSpaces[name]; exists { + return Response{ + StatusCode: 409, + Body: map[string]string{ + "error_code": "RESOURCE_ALREADY_EXISTS", + "message": "A space with the same name already exists: " + name, + }, + } + } + space.Id = strconv.Itoa(len(s.AppSpaces) + 2000) + space.Status = &apps.SpaceStatus{ + State: apps.SpaceStatusSpaceStateSpaceActive, + } + space.ServicePrincipalClientId = nextUUID() + space.ServicePrincipalId = nextID() + space.ServicePrincipalName = "space-" + name + s.AppSpaces[name] = space + } + + spaceJSON, _ := json.Marshal(space) + return Response{ + Body: apps.Operation{ + Done: true, + Name: name, + Response: spaceJSON, + }, + } +} + +func (s *FakeWorkspace) AppSpaceGetOperation(_ Request, name string) Response { + defer s.LockUnlock()() + + // Return a completed operation regardless of whether the space exists. + // This supports polling after delete operations. + space, ok := s.AppSpaces[name] + if ok { + spaceJSON, _ := json.Marshal(space) + return Response{ + Body: apps.Operation{ + Done: true, + Name: name, + Response: spaceJSON, + }, + } + } + + emptyJSON, _ := json.Marshal(map[string]any{}) + return Response{ + Body: apps.Operation{ + Done: true, + Name: name, + Response: emptyJSON, + }, + } +} diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 0ac7fe34aa..f687aad8f0 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -166,6 +166,8 @@ type FakeWorkspace struct { DatabaseCatalogs map[string]database.DatabaseCatalog SyncedDatabaseTables map[string]database.SyncedDatabaseTable + AppSpaces map[string]apps.Space + PostgresProjects map[string]postgres.Project PostgresBranches map[string]postgres.Branch PostgresEndpoints map[string]postgres.Endpoint @@ -269,6 +271,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { PipelineUpdates: map[string]bool{}, Monitors: map[string]catalog.MonitorInfo{}, Apps: map[string]apps.App{}, + AppSpaces: map[string]apps.Space{}, Catalogs: map[string]catalog.CatalogInfo{}, ExternalLocations: map[string]catalog.ExternalLocationInfo{}, Schemas: map[string]catalog.SchemaInfo{}, diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index b2a95b1902..9ad02116dc 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -8,6 +8,7 @@ import ( "path" "strings" + "github.com/databricks/databricks-sdk-go/service/apps" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/jobs" @@ -419,6 +420,32 @@ func AddDefaultHandlers(server *Server) { return MapDelete(req.Workspace, req.Workspace.Apps, req.Vars["name"]) }) + // App Spaces: + + server.Handle("GET", "/api/2.0/app-spaces/{name}", func(req Request) any { + return MapGet(req.Workspace, req.Workspace.AppSpaces, req.Vars["name"]) + }) + + server.Handle("POST", "/api/2.0/app-spaces", func(req Request) any { + return req.Workspace.AppSpaceUpsert(req, "") + }) + + server.Handle("PATCH", "/api/2.0/app-spaces/{name}", func(req Request) any { + return req.Workspace.AppSpaceUpsert(req, req.Vars["name"]) + }) + + server.Handle("DELETE", "/api/2.0/app-spaces/{name}", func(req Request) any { + delete(req.Workspace.AppSpaces, req.Vars["name"]) + return apps.Operation{ + Done: true, + Name: req.Vars["name"], + } + }) + + server.Handle("GET", "/api/2.0/app-spaces/{name}/operation", func(req Request) any { + return req.Workspace.AppSpaceGetOperation(req, req.Vars["name"]) + }) + // Schemas: server.Handle("GET", "/api/2.1/unity-catalog/schemas/{full_name}", func(req Request) any {