From 8df75a8672b5958d3755789884ee5fc59a574e3f Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 16 Apr 2026 00:16:54 +0000 Subject: [PATCH 01/10] Add app_spaces as a DABs resource type (direct mode only) Adds support for declaring Databricks App Spaces in bundle YAML via the `app_spaces` resource type. Spaces are containers for apps that provide shared resources, OAuth scopes, and a service principal. Implements direct mode CRUD with async operation waiting, a merge mutator for the space resources array, run_as validation, and test server handlers for the fake workspace. Co-authored-by: Isaac --- .../apply_bundle_permissions_test.go | 1 + .../resourcemutator/apply_target_mode_test.go | 9 +- .../resourcemutator/merge_app_spaces.go | 45 ++++++++ .../resourcemutator/resource_mutator.go | 4 + .../config/mutator/resourcemutator/run_as.go | 10 ++ .../mutator/resourcemutator/run_as_test.go | 1 + bundle/config/resources.go | 3 + bundle/config/resources/app_spaces.go | 61 +++++++++++ bundle/config/resources_test.go | 6 + bundle/direct/dresources/all.go | 1 + bundle/direct/dresources/all_test.go | 6 + bundle/direct/dresources/app_space.go | 60 ++++++++++ bundle/direct/dresources/resources.yml | 5 + libs/testserver/app_spaces.go | 103 ++++++++++++++++++ libs/testserver/fake_workspace.go | 3 + libs/testserver/handlers.go | 27 +++++ 16 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 bundle/config/mutator/resourcemutator/merge_app_spaces.go create mode 100644 bundle/config/resources/app_spaces.go create mode 100644 bundle/direct/dresources/app_space.go create mode 100644 libs/testserver/app_spaces.go diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index c347de79df..b0ffc2f45d 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 ce299d341b..eb4631ca25 100644 --- a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go +++ b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go @@ -161,6 +161,13 @@ func mockBundle(mode config.Mode) *bundle.Bundle { }, }, }, + AppSpaces: map[string]*resources.AppSpace{ + "app_space1": { + Space: apps.Space{ + Name: "app-space-1", + }, + }, + }, SecretScopes: map[string]*resources.SecretScope{ "secretScope1": { Name: "secretScope1", @@ -442,7 +449,7 @@ func TestAllNonUcResourcesAreRenamed(t *testing.T) { // Skip resources that are not renamed (either because they don't have a user-facing Name field, // or because their Name is server-generated rather than user-specified) - if resourceType == "Apps" || resourceType == "SecretScopes" || resourceType == "DatabaseInstances" || resourceType == "DatabaseCatalogs" || resourceType == "SyncedDatabaseTables" || resourceType == "PostgresProjects" || resourceType == "PostgresBranches" || resourceType == "PostgresEndpoints" { + if resourceType == "Apps" || resourceType == "AppSpaces" || resourceType == "SecretScopes" || resourceType == "DatabaseInstances" || resourceType == "DatabaseCatalogs" || resourceType == "SyncedDatabaseTables" || resourceType == "PostgresProjects" || resourceType == "PostgresBranches" || resourceType == "PostgresEndpoints" { continue } diff --git a/bundle/config/mutator/resourcemutator/merge_app_spaces.go b/bundle/config/mutator/resourcemutator/merge_app_spaces.go new file mode 100644 index 0000000000..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 2eb292cfbb..9067f84432 100644 --- a/bundle/config/mutator/resourcemutator/resource_mutator.go +++ b/bundle/config/mutator/resourcemutator/resource_mutator.go @@ -167,6 +167,10 @@ func applyNormalizeMutators(ctx context.Context, b *bundle.Bundle) { // Updates (dynamic): resources.apps.*.resources (merges app resources with the same name) MergeApps(), + // Reads (dynamic): resources.app_spaces.*.resources (reads app space resources to merge) + // Updates (dynamic): resources.app_spaces.*.resources (merges app space resources with the same name) + MergeAppSpaces(), + // Reads (dynamic): resources.{catalogs,schemas,external_locations,volumes,registered_models}.*.grants // Updates (dynamic): same paths — merges grant entries by principal and deduplicates privileges MergeGrants(), diff --git a/bundle/config/mutator/resourcemutator/run_as.go b/bundle/config/mutator/resourcemutator/run_as.go index 4f5e3ce903..35fdaf12c6 100644 --- a/bundle/config/mutator/resourcemutator/run_as.go +++ b/bundle/config/mutator/resourcemutator/run_as.go @@ -126,6 +126,16 @@ func validateRunAs(b *bundle.Bundle) diag.Diagnostics { )) } + // App spaces do not support run_as in the API. + if len(b.Config.Resources.AppSpaces) > 0 { + diags = diags.Extend(reportRunAsNotSupported( + "app_spaces", + b.Config.GetLocation("resources.app_spaces"), + b.Config.Workspace.CurrentUser.UserName, + identity, + )) + } + return diags } diff --git a/bundle/config/mutator/resourcemutator/run_as_test.go b/bundle/config/mutator/resourcemutator/run_as_test.go index 0b7003f587..2a2e096d6e 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -33,6 +33,7 @@ func allResourceTypes(t *testing.T) []string { // also update this check when adding a new resource require.Equal(t, []string{ "alerts", + "app_spaces", "apps", "catalogs", "clusters", diff --git a/bundle/config/resources.go b/bundle/config/resources.go index 225ec32165..f0b26a6c3d 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -26,6 +26,7 @@ type Resources struct { Clusters map[string]*resources.Cluster `json:"clusters,omitempty"` Dashboards map[string]*resources.Dashboard `json:"dashboards,omitempty"` Apps map[string]*resources.App `json:"apps,omitempty"` + AppSpaces map[string]*resources.AppSpace `json:"app_spaces,omitempty"` SecretScopes map[string]*resources.SecretScope `json:"secret_scopes,omitempty"` Alerts map[string]*resources.Alert `json:"alerts,omitempty"` SqlWarehouses map[string]*resources.SqlWarehouse `json:"sql_warehouses,omitempty"` @@ -103,6 +104,7 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["dashboards"], r.Dashboards), collectResourceMap(descriptions["volumes"], r.Volumes), collectResourceMap(descriptions["apps"], r.Apps), + collectResourceMap(descriptions["app_spaces"], r.AppSpaces), collectResourceMap(descriptions["alerts"], r.Alerts), collectResourceMap(descriptions["secret_scopes"], r.SecretScopes), collectResourceMap(descriptions["sql_warehouses"], r.SqlWarehouses), @@ -158,6 +160,7 @@ func SupportedResources() map[string]resources.ResourceDescription { "dashboards": (&resources.Dashboard{}).ResourceDescription(), "volumes": (&resources.Volume{}).ResourceDescription(), "apps": (&resources.App{}).ResourceDescription(), + "app_spaces": (&resources.AppSpace{}).ResourceDescription(), "secret_scopes": (&resources.SecretScope{}).ResourceDescription(), "alerts": (&resources.Alert{}).ResourceDescription(), "sql_warehouses": (&resources.SqlWarehouse{}).ResourceDescription(), diff --git a/bundle/config/resources/app_spaces.go b/bundle/config/resources/app_spaces.go new file mode 100644 index 0000000000..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 576f0db6e4..aeb4b32f78 100644 --- a/bundle/config/resources_test.go +++ b/bundle/config/resources_test.go @@ -205,6 +205,11 @@ func TestResourcesBindSupport(t *testing.T) { App: apps.App{}, }, }, + AppSpaces: map[string]*resources.AppSpace{ + "my_app_space": { + Space: apps.Space{}, + }, + }, Alerts: map[string]*resources.Alert{ "my_alert": { AlertV2: sql.AlertV2{}, @@ -300,6 +305,7 @@ func TestResourcesBindSupport(t *testing.T) { m.GetMockLakeviewAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockVolumesAPI().EXPECT().Read(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockAppsAPI().EXPECT().GetByName(mock.Anything, mock.Anything).Return(nil, nil) + m.GetMockAppsAPI().EXPECT().GetSpace(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockAlertsV2API().EXPECT().GetAlertById(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockQualityMonitorsAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockServingEndpointsAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) diff --git a/bundle/direct/dresources/all.go b/bundle/direct/dresources/all.go index ddc30c41f5..76e6bf1ce9 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 9f0dc07e90..0b2da52768 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -40,6 +40,12 @@ var testConfig map[string]any = map[string]any{ }, }, + "app_spaces": &resources.AppSpace{ + Space: apps.Space{ + Name: "my-app-space", + }, + }, + "catalogs": &resources.Catalog{ CreateCatalog: catalog.CreateCatalog{ Name: "mycatalog", diff --git a/bundle/direct/dresources/app_space.go b/bundle/direct/dresources/app_space.go new file mode 100644 index 0000000000..5061dc7e47 --- /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{"*"}}, + }) + 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 569fca9ee8..963db99090 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -371,6 +371,11 @@ resources: - field: dataset_schema reason: input_only + app_spaces: + recreate_on_changes: + - field: name + reason: immutable + apps: recreate_on_changes: - field: name diff --git a/libs/testserver/app_spaces.go b/libs/testserver/app_spaces.go new file mode 100644 index 0000000000..88d33a55f9 --- /dev/null +++ b/libs/testserver/app_spaces.go @@ -0,0 +1,103 @@ +package testserver + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/databricks/databricks-sdk-go/service/apps" +) + +func (s *FakeWorkspace) AppSpaceUpsert(req Request, name string) Response { + var space apps.Space + if err := json.Unmarshal(req.Body, &space); err != nil { + return Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: http.StatusInternalServerError, + } + } + + defer s.LockUnlock()() + + if name != "" { + // Update path + existing, ok := s.AppSpaces[name] + if !ok { + return Response{StatusCode: 404} + } + if space.Description != "" { + existing.Description = space.Description + } + if space.Resources != nil { + existing.Resources = space.Resources + } + if space.UserApiScopes != nil { + existing.UserApiScopes = space.UserApiScopes + } + if space.UsagePolicyId != "" { + existing.UsagePolicyId = space.UsagePolicyId + } + s.AppSpaces[name] = existing + space = existing + } else { + // Create path + name = space.Name + if name == "" { + return Response{StatusCode: 400, Body: "name is required"} + } + if _, exists := s.AppSpaces[name]; exists { + return Response{ + StatusCode: 409, + Body: map[string]string{ + "error_code": "RESOURCE_ALREADY_EXISTS", + "message": "A space with the same name already exists: " + name, + }, + } + } + space.Id = strconv.Itoa(len(s.AppSpaces) + 2000) + space.Status = &apps.SpaceStatus{ + State: apps.SpaceStatusSpaceStateSpaceActive, + } + space.ServicePrincipalClientId = nextUUID() + space.ServicePrincipalId = nextID() + space.ServicePrincipalName = "space-" + name + s.AppSpaces[name] = space + } + + spaceJSON, _ := json.Marshal(space) + return Response{ + Body: apps.Operation{ + Done: true, + Name: name, + Response: spaceJSON, + }, + } +} + +func (s *FakeWorkspace) AppSpaceGetOperation(_ Request, name string) Response { + defer s.LockUnlock()() + + // Return a completed operation regardless of whether the space exists. + // This supports polling after delete operations. + space, ok := s.AppSpaces[name] + if ok { + spaceJSON, _ := json.Marshal(space) + return Response{ + Body: apps.Operation{ + Done: true, + Name: name, + Response: spaceJSON, + }, + } + } + + emptyJSON, _ := json.Marshal(map[string]any{}) + return Response{ + Body: apps.Operation{ + Done: true, + Name: name, + Response: emptyJSON, + }, + } +} diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 5430c68cbc..4eed46017a 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -168,6 +168,8 @@ type FakeWorkspace struct { DatabaseCatalogs map[string]database.DatabaseCatalog SyncedDatabaseTables map[string]database.SyncedDatabaseTable + AppSpaces map[string]apps.Space + PostgresProjects map[string]postgres.Project PostgresBranches map[string]postgres.Branch PostgresEndpoints map[string]postgres.Endpoint @@ -271,6 +273,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { PipelineUpdates: map[string]bool{}, Monitors: map[string]catalog.MonitorInfo{}, Apps: map[string]apps.App{}, + AppSpaces: map[string]apps.Space{}, Catalogs: map[string]catalog.CatalogInfo{}, ExternalLocations: map[string]catalog.ExternalLocationInfo{}, Schemas: map[string]catalog.SchemaInfo{}, diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index 8bd5339184..198867ad4f 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -8,6 +8,7 @@ import ( "path" "strings" + "github.com/databricks/databricks-sdk-go/service/apps" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/jobs" @@ -419,6 +420,32 @@ func AddDefaultHandlers(server *Server) { return MapDelete(req.Workspace, req.Workspace.Apps, req.Vars["name"]) }) + // App Spaces: + + server.Handle("GET", "/api/2.0/app-spaces/{name}", func(req Request) any { + return MapGet(req.Workspace, req.Workspace.AppSpaces, req.Vars["name"]) + }) + + server.Handle("POST", "/api/2.0/app-spaces", func(req Request) any { + return req.Workspace.AppSpaceUpsert(req, "") + }) + + server.Handle("PATCH", "/api/2.0/app-spaces/{name}", func(req Request) any { + return req.Workspace.AppSpaceUpsert(req, req.Vars["name"]) + }) + + server.Handle("DELETE", "/api/2.0/app-spaces/{name}", func(req Request) any { + delete(req.Workspace.AppSpaces, req.Vars["name"]) + return apps.Operation{ + Done: true, + Name: req.Vars["name"], + } + }) + + server.Handle("GET", "/api/2.0/app-spaces/{name}/operation", func(req Request) any { + return req.Workspace.AppSpaceGetOperation(req, req.Vars["name"]) + }) + // Schemas: server.Handle("GET", "/api/2.1/unity-catalog/schemas/{full_name}", func(req Request) any { From b990cb424527320a8e7adfe12c29168c4efe397a Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 16 Apr 2026 01:22:54 +0000 Subject: [PATCH 02/10] Fix app_spaces update mask to use explicit allowed fields The Spaces API rejects wildcard update masks. Use the explicit list of updatable fields: description, resources, user_api_scopes, usage_policy_id. Co-authored-by: Isaac --- bundle/direct/dresources/app_space.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundle/direct/dresources/app_space.go b/bundle/direct/dresources/app_space.go index 5061dc7e47..1ee7d51a96 100644 --- a/bundle/direct/dresources/app_space.go +++ b/bundle/direct/dresources/app_space.go @@ -43,7 +43,7 @@ func (r *ResourceAppSpace) DoUpdate(ctx context.Context, id string, config *apps waiter, err := r.client.Apps.UpdateSpace(ctx, apps.UpdateSpaceRequest{ Name: id, Space: *config, - UpdateMask: fieldmask.FieldMask{Paths: []string{"*"}}, + UpdateMask: fieldmask.FieldMask{Paths: []string{"description", "resources", "user_api_scopes", "usage_policy_id"}}, }) if err != nil { return nil, err From 16886c3b7b8bf7013016e436c42421840b624a7e Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 20 Apr 2026 13:33:28 +0000 Subject: [PATCH 03/10] Address review feedback: fix lint, regen schema, add acceptance tests - Add nolint comment for Id field collision between BaseResource and apps.Space (same pattern as App resource) - Regenerate annotations.yml and jsonschema.json via make schema - Add acceptance tests: basic (with nested resources), recreate (name immutability), update (create/update/rename/destroy lifecycle) Co-authored-by: Isaac --- .../resources/app_spaces/basic/databricks.yml | 17 +++ .../app_spaces/basic/out.requests.direct.json | 28 +++++ .../resources/app_spaces/basic/out.test.toml | 5 + .../resources/app_spaces/basic/output.txt | 45 ++++++++ .../bundle/resources/app_spaces/basic/script | 12 ++ .../resources/app_spaces/basic/test.toml | 5 + .../app_spaces/recreate/databricks.yml | 8 ++ .../recreate/out.requests.direct.json | 8 ++ .../app_spaces/recreate/out.test.toml | 5 + .../resources/app_spaces/recreate/output.txt | 22 ++++ .../resources/app_spaces/recreate/script | 17 +++ .../resources/app_spaces/recreate/test.toml | 5 + .../app_spaces/update/databricks.yml | 8 ++ .../update/out.requests.direct.json | 8 ++ .../resources/app_spaces/update/out.test.toml | 5 + .../resources/app_spaces/update/output.txt | 34 ++++++ .../bundle/resources/app_spaces/update/script | 28 +++++ .../resources/app_spaces/update/test.toml | 5 + bundle/config/resources/app_spaces.go | 2 +- bundle/internal/schema/annotations.yml | 55 +++++++++ bundle/schema/jsonschema.json | 108 ++++++++++++++++++ 21 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 acceptance/bundle/resources/app_spaces/basic/databricks.yml create mode 100644 acceptance/bundle/resources/app_spaces/basic/out.requests.direct.json create mode 100644 acceptance/bundle/resources/app_spaces/basic/out.test.toml create mode 100644 acceptance/bundle/resources/app_spaces/basic/output.txt create mode 100644 acceptance/bundle/resources/app_spaces/basic/script create mode 100644 acceptance/bundle/resources/app_spaces/basic/test.toml create mode 100644 acceptance/bundle/resources/app_spaces/recreate/databricks.yml create mode 100644 acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json create mode 100644 acceptance/bundle/resources/app_spaces/recreate/out.test.toml create mode 100644 acceptance/bundle/resources/app_spaces/recreate/output.txt create mode 100644 acceptance/bundle/resources/app_spaces/recreate/script create mode 100644 acceptance/bundle/resources/app_spaces/recreate/test.toml create mode 100644 acceptance/bundle/resources/app_spaces/update/databricks.yml create mode 100644 acceptance/bundle/resources/app_spaces/update/out.requests.direct.json create mode 100644 acceptance/bundle/resources/app_spaces/update/out.test.toml create mode 100644 acceptance/bundle/resources/app_spaces/update/output.txt create mode 100644 acceptance/bundle/resources/app_spaces/update/script create mode 100644 acceptance/bundle/resources/app_spaces/update/test.toml diff --git a/acceptance/bundle/resources/app_spaces/basic/databricks.yml b/acceptance/bundle/resources/app_spaces/basic/databricks.yml new file mode 100644 index 0000000000..14f028d7cf --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/basic/databricks.yml @@ -0,0 +1,17 @@ +bundle: + name: test-bundle + +resources: + app_spaces: + mykey: + name: myspacename + description: my_space_description + resources: + - name: my-serving-endpoint + serving_endpoint: + name: my-endpoint + permission: CAN_QUERY + - name: my-warehouse + sql_warehouse: + id: warehouse-id-1 + permission: CAN_USE diff --git a/acceptance/bundle/resources/app_spaces/basic/out.requests.direct.json b/acceptance/bundle/resources/app_spaces/basic/out.requests.direct.json new file mode 100644 index 0000000000..471982178b --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/basic/out.requests.direct.json @@ -0,0 +1,28 @@ +{ + "body": { + "description": "my_space_description", + "name": "myspacename", + "resources": [ + { + "name": "my-serving-endpoint", + "serving_endpoint": { + "name": "my-endpoint", + "permission": "CAN_QUERY" + } + }, + { + "name": "my-warehouse", + "sql_warehouse": { + "id": "warehouse-id-1", + "permission": "CAN_USE" + } + } + ] + }, + "method": "POST", + "path": "/api/2.0/app-spaces" +} +{ + "method": "DELETE", + "path": "/api/2.0/app-spaces/myspacename" +} diff --git a/acceptance/bundle/resources/app_spaces/basic/out.test.toml b/acceptance/bundle/resources/app_spaces/basic/out.test.toml new file mode 100644 index 0000000000..54146af564 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/basic/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/app_spaces/basic/output.txt b/acceptance/bundle/resources/app_spaces/basic/output.txt new file mode 100644 index 0000000000..581d4ac65c --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/basic/output.txt @@ -0,0 +1,45 @@ + +>>> [CLI] bundle validate +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Validation OK! + +>>> [CLI] bundle plan +create app_spaces.mykey + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + App Spaces: + mykey: + Name: myspacename + URL: (not deployed) + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.app_spaces.mykey + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Deleting files... +Destroy complete! + +>>> print_requests diff --git a/acceptance/bundle/resources/app_spaces/basic/script b/acceptance/bundle/resources/app_spaces/basic/script new file mode 100644 index 0000000000..e98103c7bc --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/basic/script @@ -0,0 +1,12 @@ +print_requests() { + jq --sort-keys 'select(.method != "GET" and (.path | contains("/app-spaces")))' < out.requests.txt + rm out.requests.txt +} + +trace $CLI bundle validate +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests > out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace $CLI bundle summary +trace $CLI bundle destroy --auto-approve +trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/app_spaces/basic/test.toml b/acceptance/bundle/resources/app_spaces/basic/test.toml new file mode 100644 index 0000000000..54146af564 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/basic/test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/app_spaces/recreate/databricks.yml b/acceptance/bundle/resources/app_spaces/recreate/databricks.yml new file mode 100644 index 0000000000..f051ad296c --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/recreate/databricks.yml @@ -0,0 +1,8 @@ +bundle: + name: test-bundle + +resources: + app_spaces: + mykey: + name: original-space-name + description: my_space_description diff --git a/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json b/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json new file mode 100644 index 0000000000..ae1ba0094f --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json @@ -0,0 +1,8 @@ +{ + "body": { + "description": "my_space_description", + "name": "original-space-name" + }, + "method": "POST", + "path": "/api/2.0/app-spaces" +} diff --git a/acceptance/bundle/resources/app_spaces/recreate/out.test.toml b/acceptance/bundle/resources/app_spaces/recreate/out.test.toml new file mode 100644 index 0000000000..54146af564 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/recreate/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/app_spaces/recreate/output.txt b/acceptance/bundle/resources/app_spaces/recreate/output.txt new file mode 100644 index 0000000000..5d9c7d1519 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/recreate/output.txt @@ -0,0 +1,22 @@ + +>>> [CLI] bundle plan +create app_spaces.mykey + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests + +=== Change name (immutable field - should recreate) +>>> update_file.py databricks.yml original-space-name renamed-space + File "", line 1 + (old=) + ^ +SyntaxError: invalid syntax + +Exit code: 1 diff --git a/acceptance/bundle/resources/app_spaces/recreate/script b/acceptance/bundle/resources/app_spaces/recreate/script new file mode 100644 index 0000000000..f42aa0107c --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/recreate/script @@ -0,0 +1,17 @@ +print_requests() { + jq --sort-keys 'select(.method != "GET" and (.path | contains("/app-spaces")))' < out.requests.txt + rm out.requests.txt +} + +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests > out.requests.$DATABRICKS_BUNDLE_ENGINE.json + +title "Change name (immutable field - should recreate)" +trace update_file.py databricks.yml original-space-name renamed-space +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace $CLI bundle summary +trace $CLI bundle destroy --auto-approve +trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/app_spaces/recreate/test.toml b/acceptance/bundle/resources/app_spaces/recreate/test.toml new file mode 100644 index 0000000000..54146af564 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/recreate/test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/app_spaces/update/databricks.yml b/acceptance/bundle/resources/app_spaces/update/databricks.yml new file mode 100644 index 0000000000..b64753cf42 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/update/databricks.yml @@ -0,0 +1,8 @@ +bundle: + name: test-bundle + +resources: + app_spaces: + mykey: + name: myspacename + description: my_space_description diff --git a/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json b/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json new file mode 100644 index 0000000000..9192eee585 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json @@ -0,0 +1,8 @@ +{ + "body": { + "description": "my_space_description", + "name": "myspacename" + }, + "method": "POST", + "path": "/api/2.0/app-spaces" +} diff --git a/acceptance/bundle/resources/app_spaces/update/out.test.toml b/acceptance/bundle/resources/app_spaces/update/out.test.toml new file mode 100644 index 0000000000..54146af564 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/update/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/app_spaces/update/output.txt b/acceptance/bundle/resources/app_spaces/update/output.txt new file mode 100644 index 0000000000..b97fbbd0f3 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/update/output.txt @@ -0,0 +1,34 @@ + +>>> [CLI] bundle plan +create app_spaces.mykey + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + App Spaces: + mykey: + Name: myspacename + URL: (not deployed) + +=== Update description and re-deploy +>>> update_file.py databricks.yml my_space_description MY_SPACE_DESCRIPTION + File "", line 1 + (old=) + ^ +SyntaxError: invalid syntax + +Exit code: 1 diff --git a/acceptance/bundle/resources/app_spaces/update/script b/acceptance/bundle/resources/app_spaces/update/script new file mode 100644 index 0000000000..9a7b00ffed --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/update/script @@ -0,0 +1,28 @@ +print_requests() { + jq --sort-keys 'select(.method != "GET" and (.path | contains("/app-spaces")))' < out.requests.txt + rm out.requests.txt +} + +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests > out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace $CLI bundle summary + +title "Update description and re-deploy" +trace update_file.py databricks.yml my_space_description MY_SPACE_DESCRIPTION +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace $CLI bundle summary + +title "Update name and re-deploy" +trace update_file.py databricks.yml myspacename mynewspacename +trace $CLI bundle plan +trace $CLI bundle summary +trace $CLI bundle deploy +trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json + +trace $CLI bundle plan +trace $CLI bundle summary +trace $CLI bundle destroy --auto-approve +trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/app_spaces/update/test.toml b/acceptance/bundle/resources/app_spaces/update/test.toml new file mode 100644 index 0000000000..54146af564 --- /dev/null +++ b/acceptance/bundle/resources/app_spaces/update/test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/bundle/config/resources/app_spaces.go b/bundle/config/resources/app_spaces.go index 4c6c4ba59c..1e5b6ac4e2 100644 --- a/bundle/config/resources/app_spaces.go +++ b/bundle/config/resources/app_spaces.go @@ -12,7 +12,7 @@ import ( type AppSpace struct { BaseResource - apps.Space + apps.Space // nolint Space struct also defines Id field with the same json tag "id" } func (s *AppSpace) UnmarshalJSON(b []byte) error { diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index c869a19926..8b8a540084 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -160,6 +160,9 @@ github.com/databricks/cli/bundle/config.Resources: "alerts": "description": |- PLACEHOLDER + "app_spaces": + "description": |- + PLACEHOLDER "apps": "description": |- The app resource defines a Databricks app. @@ -561,6 +564,58 @@ github.com/databricks/cli/bundle/config/resources.AppPermission: "user_name": "description": |- PLACEHOLDER +github.com/databricks/cli/bundle/config/resources.AppSpace: + "create_time": + "description": |- + PLACEHOLDER + "creator": + "description": |- + PLACEHOLDER + "description": + "description": |- + PLACEHOLDER + "effective_usage_policy_id": + "description": |- + PLACEHOLDER + "effective_user_api_scopes": + "description": |- + PLACEHOLDER + "id": + "description": |- + PLACEHOLDER + "lifecycle": + "description": |- + PLACEHOLDER + "name": + "description": |- + PLACEHOLDER + "resources": + "description": |- + PLACEHOLDER + "service_principal_client_id": + "description": |- + PLACEHOLDER + "service_principal_id": + "description": |- + PLACEHOLDER + "service_principal_name": + "description": |- + PLACEHOLDER + "status": + "description": |- + PLACEHOLDER + "update_time": + "description": |- + PLACEHOLDER + "updater": + "description": |- + PLACEHOLDER + "usage_policy_id": + "description": |- + PLACEHOLDER + "user_api_scopes": + "description": |- + PLACEHOLDER github.com/databricks/cli/bundle/config/resources.Catalog: "comment": "description": |- diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 03654f1f63..13ac9e6bf3 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -284,6 +284,74 @@ } ] }, + "resources.AppSpace": { + "oneOf": [ + { + "type": "object", + "properties": { + "create_time": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/common/types/time.Time" + }, + "creator": { + "$ref": "#/$defs/string" + }, + "description": { + "$ref": "#/$defs/string" + }, + "effective_usage_policy_id": { + "$ref": "#/$defs/string" + }, + "effective_user_api_scopes": { + "$ref": "#/$defs/slice/string" + }, + "id": { + "$ref": "#/$defs/string" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "name": { + "$ref": "#/$defs/string" + }, + "resources": { + "$ref": "#/$defs/slice/github.com/databricks/databricks-sdk-go/service/apps.AppResource" + }, + "service_principal_client_id": { + "$ref": "#/$defs/string" + }, + "service_principal_id": { + "$ref": "#/$defs/int64" + }, + "service_principal_name": { + "$ref": "#/$defs/string" + }, + "status": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/apps.SpaceStatus" + }, + "update_time": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/common/types/time.Time" + }, + "updater": { + "$ref": "#/$defs/string" + }, + "usage_policy_id": { + "$ref": "#/$defs/string" + }, + "user_api_scopes": { + "$ref": "#/$defs/slice/string" + } + }, + "additionalProperties": false, + "required": [ + "name" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.Catalog": { "oneOf": [ { @@ -2471,6 +2539,9 @@ "alerts": { "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.Alert" }, + "app_spaces": { + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.AppSpace" + }, "apps": { "description": "The app resource defines a Databricks app.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.App", @@ -3572,6 +3643,29 @@ } ] }, + "apps.SpaceStatus": { + "oneOf": [ + { + "type": "object", + "properties": { + "message": { + "$ref": "#/$defs/string" + }, + "state": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/apps.SpaceStatusSpaceState" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "apps.SpaceStatusSpaceState": { + "type": "string" + }, "apps.TelemetryExportDestination": { "oneOf": [ { @@ -11201,6 +11295,20 @@ } ] }, + "resources.AppSpace": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.AppSpace" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.Catalog": { "oneOf": [ { From 40d313850cac2100beafe75f6b370e620f30403a Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 20 Apr 2026 13:58:43 +0000 Subject: [PATCH 04/10] Regenerate acceptance test golden files with Python 3.10+ Previous golden files were generated with Python 3.6 which doesn't support f-string self-documenting expressions used in update_file.py, causing bogus SyntaxError output that would fail in CI. Co-authored-by: Isaac --- .../recreate/out.requests.direct.json | 16 ++++ .../resources/app_spaces/recreate/output.txt | 40 +++++++-- .../update/out.requests.direct.json | 27 ++++++ .../resources/app_spaces/update/output.txt | 85 +++++++++++++++++-- 4 files changed, 158 insertions(+), 10 deletions(-) diff --git a/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json b/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json index ae1ba0094f..14431113fc 100644 --- a/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json +++ b/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json @@ -6,3 +6,19 @@ "method": "POST", "path": "/api/2.0/app-spaces" } +{ + "method": "DELETE", + "path": "/api/2.0/app-spaces/original-space-name" +} +{ + "body": { + "description": "my_space_description", + "name": "renamed-space" + }, + "method": "POST", + "path": "/api/2.0/app-spaces" +} +{ + "method": "DELETE", + "path": "/api/2.0/app-spaces/renamed-space" +} diff --git a/acceptance/bundle/resources/app_spaces/recreate/output.txt b/acceptance/bundle/resources/app_spaces/recreate/output.txt index 5d9c7d1519..dd0b60eab9 100644 --- a/acceptance/bundle/resources/app_spaces/recreate/output.txt +++ b/acceptance/bundle/resources/app_spaces/recreate/output.txt @@ -14,9 +14,39 @@ Deployment complete! === Change name (immutable field - should recreate) >>> update_file.py databricks.yml original-space-name renamed-space - File "", line 1 - (old=) - ^ -SyntaxError: invalid syntax -Exit code: 1 +>>> [CLI] bundle plan +recreate app_spaces.mykey + +Plan: 1 to add, 0 to change, 1 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + App Spaces: + mykey: + Name: renamed-space + URL: (not deployed) + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.app_spaces.mykey + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Deleting files... +Destroy complete! + +>>> print_requests diff --git a/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json b/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json index 9192eee585..1c481caca7 100644 --- a/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json +++ b/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json @@ -6,3 +6,30 @@ "method": "POST", "path": "/api/2.0/app-spaces" } +{ + "body": { + "description": "MY_SPACE_DESCRIPTION", + "name": "myspacename" + }, + "method": "PATCH", + "path": "/api/2.0/app-spaces/myspacename", + "q": { + "update_mask": "description,resources,user_api_scopes,usage_policy_id" + } +} +{ + "method": "DELETE", + "path": "/api/2.0/app-spaces/myspacename" +} +{ + "body": { + "description": "MY_SPACE_DESCRIPTION", + "name": "mynewspacename" + }, + "method": "POST", + "path": "/api/2.0/app-spaces" +} +{ + "method": "DELETE", + "path": "/api/2.0/app-spaces/mynewspacename" +} diff --git a/acceptance/bundle/resources/app_spaces/update/output.txt b/acceptance/bundle/resources/app_spaces/update/output.txt index b97fbbd0f3..94d9e794dd 100644 --- a/acceptance/bundle/resources/app_spaces/update/output.txt +++ b/acceptance/bundle/resources/app_spaces/update/output.txt @@ -26,9 +26,84 @@ Resources: === Update description and re-deploy >>> update_file.py databricks.yml my_space_description MY_SPACE_DESCRIPTION - File "", line 1 - (old=) - ^ -SyntaxError: invalid syntax -Exit code: 1 +>>> [CLI] bundle plan +update app_spaces.mykey + +Plan: 0 to add, 1 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + App Spaces: + mykey: + Name: myspacename + URL: (not deployed) + +=== Update name and re-deploy +>>> update_file.py databricks.yml myspacename mynewspacename + +>>> [CLI] bundle plan +recreate app_spaces.mykey + +Plan: 1 to add, 0 to change, 1 to delete, 0 unchanged + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + App Spaces: + mykey: + Name: myspacename + URL: (not deployed) + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests + +>>> [CLI] bundle plan +update app_spaces.mykey + +Plan: 0 to add, 1 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + App Spaces: + mykey: + Name: mynewspacename + URL: (not deployed) + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.app_spaces.mykey + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Deleting files... +Destroy complete! + +>>> print_requests From 00e8b3fc9bf05f337bb9915d926cac866304c6c2 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 20 Apr 2026 14:54:40 +0000 Subject: [PATCH 05/10] Split DoCreate/DoUpdate from waiting into WaitAfterCreate/Update Previously DoCreate and DoUpdate blocked on waiter.Wait, preventing the direct engine from parallelizing operations across resources. Move the wait into dedicated WaitAfterCreate/WaitAfterUpdate methods that poll GetSpace until the space reaches SPACE_ACTIVE state. Addresses review feedback from @andrewnester. Co-authored-by: Isaac --- bundle/direct/dresources/app_space.go | 49 +++++++++++++++++++++------ 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/bundle/direct/dresources/app_space.go b/bundle/direct/dresources/app_space.go index 1ee7d51a96..b11b037f06 100644 --- a/bundle/direct/dresources/app_space.go +++ b/bundle/direct/dresources/app_space.go @@ -2,10 +2,13 @@ package dresources import ( "context" + "fmt" + "time" "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/retries" "github.com/databricks/databricks-sdk-go/service/apps" ) @@ -26,29 +29,53 @@ func (r *ResourceAppSpace) DoRead(ctx context.Context, id string) (*apps.Space, } func (r *ResourceAppSpace) DoCreate(ctx context.Context, config *apps.Space) (string, *apps.Space, error) { - waiter, err := r.client.Apps.CreateSpace(ctx, apps.CreateSpaceRequest{ + // Kick off the create request. Wait for the space to become active in + // WaitAfterCreate so that parallel creates are not blocked here. + _, 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 + return config.Name, nil, 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{ + _, 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) + return nil, err +} + +func (r *ResourceAppSpace) WaitAfterCreate(ctx context.Context, config *apps.Space) (*apps.Space, error) { + return r.waitForSpaceActive(ctx, config.Name) +} + +func (r *ResourceAppSpace) WaitAfterUpdate(ctx context.Context, config *apps.Space) (*apps.Space, error) { + return r.waitForSpaceActive(ctx, config.Name) +} + +func (r *ResourceAppSpace) waitForSpaceActive(ctx context.Context, name string) (*apps.Space, error) { + retrier := retries.New[apps.Space](retries.WithTimeout(20 * time.Minute)) + return retrier.Run(ctx, func(ctx context.Context) (*apps.Space, error) { + space, err := r.client.Apps.GetSpace(ctx, apps.GetSpaceRequest{Name: name}) + if err != nil { + return nil, retries.Halt(err) + } + if space.Status == nil { + return nil, retries.Continues("waiting for status") + } + switch space.Status.State { + case apps.SpaceStatusSpaceStateSpaceActive: + return space, nil + case apps.SpaceStatusSpaceStateSpaceError: + return nil, retries.Halt(fmt.Errorf("space %s is in ERROR state: %s", name, space.Status.Message)) + default: + return nil, retries.Continues(fmt.Sprintf("space state: %s", space.Status.State)) + } + }) } func (r *ResourceAppSpace) DoDelete(ctx context.Context, id string) error { From 0ce6a07ceb1b653daa7162519bd5b498ff34e204 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 21 Apr 2026 20:21:04 +0000 Subject: [PATCH 06/10] Address review feedback round 2: permissions, lint, direct-only, tests - Scope govet lint directive to a specific linter (Jannik) - Remove fabricated InitializeURL; spaces have no stable UI URL yet (Jannik) - Opt out of terraform via ValidateDirectOnlyResources (Jannik) - Replace panic in merge_app_spaces with user-facing diagnostic (Denik) - Add permissions support for app_spaces using the generic /api/2.0/permissions/app-spaces/{name} endpoint - Use print_requests.py helper in acceptance scripts (Denik) - Add invariant, drift (via invariant no_drift), and bind/unbind acceptance tests (Jannik) - Exclude app_space from migrate invariant test since the resource is direct-only (same pattern as catalog, external_location) - Regenerate annotations, jsonschema, enum_fields, required_fields - Fix TestConvertLifecycleForAllResources and state_load tests to account for the new resource - Add NEXT_CHANGELOG.md entry Note: resources.generated.yml needs make generate-direct to populate output-only fields (create_time, service_principal_*, status, etc.) from the OpenAPI spec. Until then, invariant no_drift for app_space will fail locally; CI pipelines with VPN access will keep it in sync. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 6 +++ .../bind/app_space/databricks.yml.tmpl | 8 ++++ .../deployment/bind/app_space/out.test.toml | 5 ++ .../deployment/bind/app_space/output.txt | 46 +++++++++++++++++++ .../bundle/deployment/bind/app_space/script | 26 +++++++++++ .../deployment/bind/app_space/test.toml | 10 ++++ .../invariant/configs/app_space.yml.tmpl | 7 +++ .../invariant/continue_293/out.test.toml | 2 +- .../bundle/invariant/migrate/out.test.toml | 2 +- acceptance/bundle/invariant/migrate/test.toml | 1 + .../bundle/invariant/no_drift/out.test.toml | 2 +- acceptance/bundle/invariant/test.toml | 1 + .../app_spaces/basic/out.requests.direct.json | 6 +-- .../resources/app_spaces/basic/output.txt | 4 +- .../bundle/resources/app_spaces/basic/script | 9 +--- .../recreate/out.requests.direct.json | 12 ++--- .../resources/app_spaces/recreate/output.txt | 6 +-- .../resources/app_spaces/recreate/script | 11 ++--- .../update/out.requests.direct.json | 20 ++++---- .../resources/app_spaces/update/output.txt | 8 ++-- .../bundle/resources/app_spaces/update/script | 13 ++---- .../apply_bundle_permissions.go | 5 ++ .../apply_bundle_permissions_test.go | 10 +++- .../resourcemutator/merge_app_spaces.go | 40 +++++++++++++++- .../mutator/validate_direct_only_resources.go | 12 +++++ bundle/config/resources/app_spaces.go | 12 ++--- bundle/config/resources/permission_types.go | 1 + bundle/deploy/terraform/lifecycle_test.go | 1 + bundle/direct/dresources/all.go | 1 + bundle/direct/dresources/all_test.go | 23 ++++++++++ bundle/direct/dresources/permissions.go | 1 + bundle/internal/schema/annotations.yml | 16 +++++++ .../validation/generated/enum_fields.go | 14 ++++++ .../validation/generated/required_fields.go | 12 +++++ bundle/schema/jsonschema.json | 46 +++++++++++++++++++ bundle/statemgmt/state_load_test.go | 37 +++++++++++++++ libs/testserver/permissions.go | 1 + 37 files changed, 373 insertions(+), 64 deletions(-) create mode 100644 acceptance/bundle/deployment/bind/app_space/databricks.yml.tmpl create mode 100644 acceptance/bundle/deployment/bind/app_space/out.test.toml create mode 100644 acceptance/bundle/deployment/bind/app_space/output.txt create mode 100644 acceptance/bundle/deployment/bind/app_space/script create mode 100644 acceptance/bundle/deployment/bind/app_space/test.toml create mode 100644 acceptance/bundle/invariant/configs/app_space.yml.tmpl diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 0f802f7bc0..f865221a97 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -14,6 +14,12 @@ * engine/direct: Added support for Vector Search Endpoints ([#4887](https://github.com/databricks/cli/pull/4887)). * engine/direct: Exclude deploy-only fields (e.g. `lifecycle`) from the Apps update mask so requests that change both `description` and `lifecycle.started` in the same deploy no longer fail with `INVALID_PARAMETER_VALUE` ([#5042](https://github.com/databricks/cli/pull/5042), [#5051](https://github.com/databricks/cli/pull/5051)). * engine/direct: Fix phantom diffs from `depends_on` reordering in job tasks ([#4990](https://github.com/databricks/cli/pull/4990)). +* engine/direct: Added support for Vector Search Endpoints ([#4887](https://github.com/databricks/cli/pull/4887)) +* engine/direct: Exclude deploy-only fields (e.g. `lifecycle`) from the Apps update mask so requests that change both `description` and `lifecycle.started` in the same deploy no longer fail with `INVALID_PARAMETER_VALUE`. +* engine/direct: Fix phantom diffs from `depends_on` reordering in job tasks ([#4990](https://github.com/databricks/cli/pull/4990)) +* Added support for lifecycle.started option for apps ([#4672](https://github.com/databricks/cli/pull/4672)) +* engine/direct: Fix permissions for resources.models ([#4941](https://github.com/databricks/cli/pull/4941)) +* engine/direct: Added support for Databricks App Spaces (`app_spaces` resource type) ([#4982](https://github.com/databricks/cli/pull/4982)) ### Dependency updates * Bump `github.com/databricks/databricks-sdk-go` from v0.126.0 to v0.128.0 ([#4984](https://github.com/databricks/cli/pull/4984), [#5031](https://github.com/databricks/cli/pull/5031)). diff --git a/acceptance/bundle/deployment/bind/app_space/databricks.yml.tmpl b/acceptance/bundle/deployment/bind/app_space/databricks.yml.tmpl new file mode 100644 index 0000000000..4c8f5016ae --- /dev/null +++ b/acceptance/bundle/deployment/bind/app_space/databricks.yml.tmpl @@ -0,0 +1,8 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + app_spaces: + foo: + name: test-space-$UNIQUE_NAME + description: This is a test app space diff --git a/acceptance/bundle/deployment/bind/app_space/out.test.toml b/acceptance/bundle/deployment/bind/app_space/out.test.toml new file mode 100644 index 0000000000..19b2c349a3 --- /dev/null +++ b/acceptance/bundle/deployment/bind/app_space/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = true + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/deployment/bind/app_space/output.txt b/acceptance/bundle/deployment/bind/app_space/output.txt new file mode 100644 index 0000000000..b51a53fb24 --- /dev/null +++ b/acceptance/bundle/deployment/bind/app_space/output.txt @@ -0,0 +1,46 @@ + +>>> [CLI] apps create-space test-space-[UNIQUE_NAME] --description Pre-existing space +{ + "name": "test-space-[UNIQUE_NAME]", + "description": "Pre-existing space" +} + +>>> [CLI] bundle deployment bind foo test-space-[UNIQUE_NAME] --auto-approve +Updating deployment state... +Successfully bound app_space with an id 'test-space-[UNIQUE_NAME]' +Run 'bundle deploy' to deploy changes to your workspace + +=== Deploy bundle +>>> [CLI] bundle deploy --force-lock --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] apps get-space test-space-[UNIQUE_NAME] +{ + "name": "test-space-[UNIQUE_NAME]", + "description": "This is a test app space" +} + +=== Unbind app space +>>> [CLI] bundle deployment unbind foo +Updating deployment state... + +=== Destroy bundle +>>> [CLI] bundle destroy --auto-approve +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! + +=== Read the pre-defined app space again (expecting it still exists): +>>> [CLI] apps get-space test-space-[UNIQUE_NAME] +{ + "name": "test-space-[UNIQUE_NAME]", + "description": "This is a test app space" +} + +=== Test cleanup +>>> [CLI] apps delete-space test-space-[UNIQUE_NAME] +0 diff --git a/acceptance/bundle/deployment/bind/app_space/script b/acceptance/bundle/deployment/bind/app_space/script new file mode 100644 index 0000000000..489b5a1fdc --- /dev/null +++ b/acceptance/bundle/deployment/bind/app_space/script @@ -0,0 +1,26 @@ +cleanup() { + title "Test cleanup" + trace $CLI apps delete-space "test-space-$UNIQUE_NAME" + echo $? +} +trap cleanup EXIT + +envsubst < databricks.yml.tmpl > databricks.yml + +trace $CLI apps create-space "test-space-$UNIQUE_NAME" --description "Pre-existing space" | jq '{name, description}' + +trace $CLI bundle deployment bind foo "test-space-$UNIQUE_NAME" --auto-approve + +title "Deploy bundle" +trace $CLI bundle deploy --force-lock --auto-approve + +trace $CLI apps get-space "test-space-$UNIQUE_NAME" | jq '{name, description}' + +title "Unbind app space" +trace $CLI bundle deployment unbind foo + +title "Destroy bundle" +trace $CLI bundle destroy --auto-approve + +title "Read the pre-defined app space again (expecting it still exists): " +trace $CLI apps get-space "test-space-$UNIQUE_NAME" | jq '{name, description}' diff --git a/acceptance/bundle/deployment/bind/app_space/test.toml b/acceptance/bundle/deployment/bind/app_space/test.toml new file mode 100644 index 0000000000..bc31b13cdb --- /dev/null +++ b/acceptance/bundle/deployment/bind/app_space/test.toml @@ -0,0 +1,10 @@ +Local = true +Cloud = true + +Ignore = [ + ".databricks", + "databricks.yml", +] + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/invariant/configs/app_space.yml.tmpl b/acceptance/bundle/invariant/configs/app_space.yml.tmpl new file mode 100644 index 0000000000..26f3355ceb --- /dev/null +++ b/acceptance/bundle/invariant/configs/app_space.yml.tmpl @@ -0,0 +1,7 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + app_spaces: + foo: + name: app-space-$UNIQUE_NAME diff --git a/acceptance/bundle/invariant/continue_293/out.test.toml b/acceptance/bundle/invariant/continue_293/out.test.toml index 0622360897..24e0c6e7f2 100644 --- a/acceptance/bundle/invariant/continue_293/out.test.toml +++ b/acceptance/bundle/invariant/continue_293/out.test.toml @@ -4,4 +4,4 @@ RequiresUnityCatalog = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] - INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_depends_on.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] + INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] diff --git a/acceptance/bundle/invariant/migrate/out.test.toml b/acceptance/bundle/invariant/migrate/out.test.toml index 0622360897..24e0c6e7f2 100644 --- a/acceptance/bundle/invariant/migrate/out.test.toml +++ b/acceptance/bundle/invariant/migrate/out.test.toml @@ -4,4 +4,4 @@ RequiresUnityCatalog = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] - INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_depends_on.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] + INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] diff --git a/acceptance/bundle/invariant/migrate/test.toml b/acceptance/bundle/invariant/migrate/test.toml index adc49c2992..e1fe849bd9 100644 --- a/acceptance/bundle/invariant/migrate/test.toml +++ b/acceptance/bundle/invariant/migrate/test.toml @@ -4,6 +4,7 @@ EnvMatrixExclude.no_vector_search_endpoint = ["INPUT_CONFIG=vector_search_endpoi # Error: Catalog resources are only supported with direct deployment mode EnvMatrixExclude.no_catalog = ["INPUT_CONFIG=catalog.yml.tmpl"] EnvMatrixExclude.no_external_location = ["INPUT_CONFIG=external_location.yml.tmpl"] +EnvMatrixExclude.no_app_space = ["INPUT_CONFIG=app_space.yml.tmpl"] # Cross-resource permission references (e.g. ${resources.jobs.job_b.permissions[0].level}) # don't work in terraform mode: the terraform interpolator converts the path to diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index 0622360897..24e0c6e7f2 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -4,4 +4,4 @@ RequiresUnityCatalog = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] - INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_depends_on.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] + INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] diff --git a/acceptance/bundle/invariant/test.toml b/acceptance/bundle/invariant/test.toml index bb66a393be..96ddfe7104 100644 --- a/acceptance/bundle/invariant/test.toml +++ b/acceptance/bundle/invariant/test.toml @@ -23,6 +23,7 @@ EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [ EnvMatrix.INPUT_CONFIG = [ "alert.yml.tmpl", "app.yml.tmpl", + "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", diff --git a/acceptance/bundle/resources/app_spaces/basic/out.requests.direct.json b/acceptance/bundle/resources/app_spaces/basic/out.requests.direct.json index 471982178b..0dae61a297 100644 --- a/acceptance/bundle/resources/app_spaces/basic/out.requests.direct.json +++ b/acceptance/bundle/resources/app_spaces/basic/out.requests.direct.json @@ -1,4 +1,6 @@ { + "method": "POST", + "path": "/api/2.0/app-spaces", "body": { "description": "my_space_description", "name": "myspacename", @@ -18,9 +20,7 @@ } } ] - }, - "method": "POST", - "path": "/api/2.0/app-spaces" + } } { "method": "DELETE", diff --git a/acceptance/bundle/resources/app_spaces/basic/output.txt b/acceptance/bundle/resources/app_spaces/basic/output.txt index 581d4ac65c..420d1192f8 100644 --- a/acceptance/bundle/resources/app_spaces/basic/output.txt +++ b/acceptance/bundle/resources/app_spaces/basic/output.txt @@ -19,7 +19,7 @@ Deploying resources... Updating deployment state... Deployment complete! ->>> print_requests +>>> print_requests.py //app-spaces >>> [CLI] bundle summary Name: test-bundle @@ -42,4 +42,4 @@ All files and directories at the following location will be deleted: /Workspace/ Deleting files... Destroy complete! ->>> print_requests +>>> print_requests.py //app-spaces diff --git a/acceptance/bundle/resources/app_spaces/basic/script b/acceptance/bundle/resources/app_spaces/basic/script index e98103c7bc..3ce322b1db 100644 --- a/acceptance/bundle/resources/app_spaces/basic/script +++ b/acceptance/bundle/resources/app_spaces/basic/script @@ -1,12 +1,7 @@ -print_requests() { - jq --sort-keys 'select(.method != "GET" and (.path | contains("/app-spaces")))' < out.requests.txt - rm out.requests.txt -} - trace $CLI bundle validate trace $CLI bundle plan trace $CLI bundle deploy -trace print_requests > out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace print_requests.py //app-spaces > out.requests.$DATABRICKS_BUNDLE_ENGINE.json trace $CLI bundle summary trace $CLI bundle destroy --auto-approve -trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace print_requests.py //app-spaces >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json b/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json index 14431113fc..d2d13b09bb 100644 --- a/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json +++ b/acceptance/bundle/resources/app_spaces/recreate/out.requests.direct.json @@ -1,22 +1,22 @@ { + "method": "POST", + "path": "/api/2.0/app-spaces", "body": { "description": "my_space_description", "name": "original-space-name" - }, - "method": "POST", - "path": "/api/2.0/app-spaces" + } } { "method": "DELETE", "path": "/api/2.0/app-spaces/original-space-name" } { + "method": "POST", + "path": "/api/2.0/app-spaces", "body": { "description": "my_space_description", "name": "renamed-space" - }, - "method": "POST", - "path": "/api/2.0/app-spaces" + } } { "method": "DELETE", diff --git a/acceptance/bundle/resources/app_spaces/recreate/output.txt b/acceptance/bundle/resources/app_spaces/recreate/output.txt index dd0b60eab9..0e8d16e826 100644 --- a/acceptance/bundle/resources/app_spaces/recreate/output.txt +++ b/acceptance/bundle/resources/app_spaces/recreate/output.txt @@ -10,7 +10,7 @@ Deploying resources... Updating deployment state... Deployment complete! ->>> print_requests +>>> print_requests.py //app-spaces === Change name (immutable field - should recreate) >>> update_file.py databricks.yml original-space-name renamed-space @@ -26,7 +26,7 @@ Deploying resources... Updating deployment state... Deployment complete! ->>> print_requests +>>> print_requests.py //app-spaces >>> [CLI] bundle summary Name: test-bundle @@ -49,4 +49,4 @@ All files and directories at the following location will be deleted: /Workspace/ Deleting files... Destroy complete! ->>> print_requests +>>> print_requests.py //app-spaces diff --git a/acceptance/bundle/resources/app_spaces/recreate/script b/acceptance/bundle/resources/app_spaces/recreate/script index f42aa0107c..54f5503dc5 100644 --- a/acceptance/bundle/resources/app_spaces/recreate/script +++ b/acceptance/bundle/resources/app_spaces/recreate/script @@ -1,17 +1,12 @@ -print_requests() { - jq --sort-keys 'select(.method != "GET" and (.path | contains("/app-spaces")))' < out.requests.txt - rm out.requests.txt -} - trace $CLI bundle plan trace $CLI bundle deploy -trace print_requests > out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace print_requests.py //app-spaces > out.requests.$DATABRICKS_BUNDLE_ENGINE.json title "Change name (immutable field - should recreate)" trace update_file.py databricks.yml original-space-name renamed-space trace $CLI bundle plan trace $CLI bundle deploy -trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace print_requests.py //app-spaces >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json trace $CLI bundle summary trace $CLI bundle destroy --auto-approve -trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace print_requests.py //app-spaces >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json b/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json index 1c481caca7..d1df4cd7c6 100644 --- a/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json +++ b/acceptance/bundle/resources/app_spaces/update/out.requests.direct.json @@ -1,20 +1,20 @@ { + "method": "POST", + "path": "/api/2.0/app-spaces", "body": { "description": "my_space_description", "name": "myspacename" - }, - "method": "POST", - "path": "/api/2.0/app-spaces" + } } { - "body": { - "description": "MY_SPACE_DESCRIPTION", - "name": "myspacename" - }, "method": "PATCH", "path": "/api/2.0/app-spaces/myspacename", "q": { "update_mask": "description,resources,user_api_scopes,usage_policy_id" + }, + "body": { + "description": "MY_SPACE_DESCRIPTION", + "name": "myspacename" } } { @@ -22,12 +22,12 @@ "path": "/api/2.0/app-spaces/myspacename" } { + "method": "POST", + "path": "/api/2.0/app-spaces", "body": { "description": "MY_SPACE_DESCRIPTION", "name": "mynewspacename" - }, - "method": "POST", - "path": "/api/2.0/app-spaces" + } } { "method": "DELETE", diff --git a/acceptance/bundle/resources/app_spaces/update/output.txt b/acceptance/bundle/resources/app_spaces/update/output.txt index 94d9e794dd..d7e4c608a0 100644 --- a/acceptance/bundle/resources/app_spaces/update/output.txt +++ b/acceptance/bundle/resources/app_spaces/update/output.txt @@ -10,7 +10,7 @@ Deploying resources... Updating deployment state... Deployment complete! ->>> print_requests +>>> print_requests.py //app-spaces >>> [CLI] bundle summary Name: test-bundle @@ -38,7 +38,7 @@ Deploying resources... Updating deployment state... Deployment complete! ->>> print_requests +>>> print_requests.py //app-spaces >>> [CLI] bundle summary Name: test-bundle @@ -78,7 +78,7 @@ Deploying resources... Updating deployment state... Deployment complete! ->>> print_requests +>>> print_requests.py //app-spaces >>> [CLI] bundle plan update app_spaces.mykey @@ -106,4 +106,4 @@ All files and directories at the following location will be deleted: /Workspace/ Deleting files... Destroy complete! ->>> print_requests +>>> print_requests.py //app-spaces diff --git a/acceptance/bundle/resources/app_spaces/update/script b/acceptance/bundle/resources/app_spaces/update/script index 9a7b00ffed..2d6341f7b6 100644 --- a/acceptance/bundle/resources/app_spaces/update/script +++ b/acceptance/bundle/resources/app_spaces/update/script @@ -1,18 +1,13 @@ -print_requests() { - jq --sort-keys 'select(.method != "GET" and (.path | contains("/app-spaces")))' < out.requests.txt - rm out.requests.txt -} - trace $CLI bundle plan trace $CLI bundle deploy -trace print_requests > out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace print_requests.py //app-spaces > out.requests.$DATABRICKS_BUNDLE_ENGINE.json trace $CLI bundle summary title "Update description and re-deploy" trace update_file.py databricks.yml my_space_description MY_SPACE_DESCRIPTION trace $CLI bundle plan trace $CLI bundle deploy -trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace print_requests.py //app-spaces >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json trace $CLI bundle summary title "Update name and re-deploy" @@ -20,9 +15,9 @@ trace update_file.py databricks.yml myspacename mynewspacename trace $CLI bundle plan trace $CLI bundle summary trace $CLI bundle deploy -trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace print_requests.py //app-spaces >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json trace $CLI bundle plan trace $CLI bundle summary trace $CLI bundle destroy --auto-approve -trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace print_requests.py //app-spaces >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go index fd019479d7..8a3ff7128a 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go @@ -51,6 +51,11 @@ var ( permissions.CAN_MANAGE: "CAN_MANAGE", permissions.CAN_VIEW: "CAN_USE", }, + "app_spaces": { + permissions.CAN_MANAGE: "CAN_MANAGE", + permissions.CAN_VIEW: "CAN_READ", + permissions.CAN_RUN: "CAN_CREATE_APP", + }, "secret_scopes": { permissions.CAN_MANAGE: "MANAGE", permissions.CAN_VIEW: "READ", diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index b0ffc2f45d..aa74875352 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -18,7 +18,6 @@ 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", @@ -79,6 +78,10 @@ func TestApplyBundlePermissions(t *testing.T) { "app_1": {}, "app_2": {}, }, + AppSpaces: map[string]*resources.AppSpace{ + "space_1": {}, + "space_2": {}, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "vs_1": {}, "vs_2": {}, @@ -144,6 +147,11 @@ func TestApplyBundlePermissions(t *testing.T) { require.Contains(t, b.Config.Resources.Apps["app_1"].Permissions, resources.AppPermission{Level: "CAN_MANAGE", UserName: "TestUser"}) require.Contains(t, b.Config.Resources.Apps["app_1"].Permissions, resources.AppPermission{Level: "CAN_USE", GroupName: "TestGroup"}) + require.Len(t, b.Config.Resources.AppSpaces["space_1"].Permissions, 3) + require.Contains(t, b.Config.Resources.AppSpaces["space_1"].Permissions, resources.AppSpacePermission{Level: "CAN_MANAGE", UserName: "TestUser"}) + require.Contains(t, b.Config.Resources.AppSpaces["space_1"].Permissions, resources.AppSpacePermission{Level: "CAN_READ", GroupName: "TestGroup"}) + require.Contains(t, b.Config.Resources.AppSpaces["space_1"].Permissions, resources.AppSpacePermission{Level: "CAN_CREATE_APP", ServicePrincipalName: "TestServicePrincipal"}) + require.Len(t, b.Config.Resources.VectorSearchEndpoints["vs_1"].Permissions, 2) require.Contains(t, b.Config.Resources.VectorSearchEndpoints["vs_1"].Permissions, resources.Permission{Level: "CAN_MANAGE", UserName: "TestUser"}) require.Contains(t, b.Config.Resources.VectorSearchEndpoints["vs_1"].Permissions, resources.Permission{Level: "CAN_USE", GroupName: "TestGroup"}) diff --git a/bundle/config/mutator/resourcemutator/merge_app_spaces.go b/bundle/config/mutator/resourcemutator/merge_app_spaces.go index cb05238adf..573ea3afa3 100644 --- a/bundle/config/mutator/resourcemutator/merge_app_spaces.go +++ b/bundle/config/mutator/resourcemutator/merge_app_spaces.go @@ -26,11 +26,49 @@ func (m *mergeAppSpaces) resourceName(v dyn.Value) string { case dyn.KindString: return v.MustString() default: - panic("app space resource name must be a string") + // Validated in Apply before this is reached; unreachable under normal operation. + return "" + } +} + +// validateResourceNames walks resources.app_spaces.*.resources[*].name and returns +// diagnostics for any entries where the name is not a string. +func (m *mergeAppSpaces) validateResourceNames(root dyn.Value) diag.Diagnostics { + var diags diag.Diagnostics + + spaces := root.Get("resources").Get("app_spaces") + if spaces.Kind() != dyn.KindMap { + return nil + } + + for _, spaceKV := range spaces.MustMap().Pairs() { + resources := spaceKV.Value.Get("resources") + if resources.Kind() != dyn.KindSequence { + continue + } + for _, r := range resources.MustSequence() { + name := r.Get("name") + switch name.Kind() { + case dyn.KindInvalid, dyn.KindNil, dyn.KindString: + continue + default: + diags = diags.Extend(diag.Diagnostics{{ + Summary: "app space resource name must be a string", + Locations: []dyn.Location{name.Location()}, + Severity: diag.Error, + }}) + } + } } + + return diags } func (m *mergeAppSpaces) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { + if diags := m.validateResourceNames(b.Config.Value()); diags.HasError() { + return diags + } + err := b.Config.Mutate(func(v dyn.Value) (dyn.Value, error) { if v.Kind() == dyn.KindNil { return v, nil diff --git a/bundle/config/mutator/validate_direct_only_resources.go b/bundle/config/mutator/validate_direct_only_resources.go index 5717497205..c56298ffa1 100644 --- a/bundle/config/mutator/validate_direct_only_resources.go +++ b/bundle/config/mutator/validate_direct_only_resources.go @@ -54,6 +54,18 @@ var directOnlyResources = []directOnlyResource{ return result }, }, + { + resourceType: "app_spaces", + pluralName: "App Space", + singularName: "app space", + getResources: func(b *bundle.Bundle) map[string]any { + result := make(map[string]any) + for k, v := range b.Config.Resources.AppSpaces { + result[k] = v + } + return result + }, + }, } type validateDirectOnlyResources struct { diff --git a/bundle/config/resources/app_spaces.go b/bundle/config/resources/app_spaces.go index 1e5b6ac4e2..7bacfd79bb 100644 --- a/bundle/config/resources/app_spaces.go +++ b/bundle/config/resources/app_spaces.go @@ -12,7 +12,9 @@ import ( type AppSpace struct { BaseResource - apps.Space // nolint Space struct also defines Id field with the same json tag "id" + apps.Space //nolint:govet // Space struct also defines Id field with the same json tag "id" + + Permissions []AppSpacePermission `json:"permissions,omitempty"` } func (s *AppSpace) UnmarshalJSON(b []byte) error { @@ -41,12 +43,8 @@ func (*AppSpace) ResourceDescription() ResourceDescription { } } -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) InitializeURL(_ url.URL) { + // App spaces do not currently have a stable UI URL. } func (s *AppSpace) GetName() string { diff --git a/bundle/config/resources/permission_types.go b/bundle/config/resources/permission_types.go index 3029ee40b8..076894536e 100644 --- a/bundle/config/resources/permission_types.go +++ b/bundle/config/resources/permission_types.go @@ -25,6 +25,7 @@ func (p Permission) String() string { // If the SDK exposes a resource's permission level, add it here. type ( AppPermission PermissionT[apps.AppPermissionLevel] + AppSpacePermission PermissionT[iam.PermissionLevel] ClusterPermission PermissionT[compute.ClusterPermissionLevel] JobPermission PermissionT[jobs.JobPermissionLevel] MlflowExperimentPermission PermissionT[ml.ExperimentPermissionLevel] diff --git a/bundle/deploy/terraform/lifecycle_test.go b/bundle/deploy/terraform/lifecycle_test.go index 7f56248bb4..56c3253b12 100644 --- a/bundle/deploy/terraform/lifecycle_test.go +++ b/bundle/deploy/terraform/lifecycle_test.go @@ -18,6 +18,7 @@ func TestConvertLifecycleForAllResources(t *testing.T) { "catalogs", "external_locations", "vector_search_endpoints", + "app_spaces", } for resourceType := range supportedResources { diff --git a/bundle/direct/dresources/all.go b/bundle/direct/dresources/all.go index 76e6bf1ce9..f4a58234b0 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -37,6 +37,7 @@ var SupportedResources = map[string]any{ "jobs.permissions": (*ResourcePermissions)(nil), "pipelines.permissions": (*ResourcePermissions)(nil), "apps.permissions": (*ResourcePermissions)(nil), + "app_spaces.permissions": (*ResourcePermissions)(nil), "alerts.permissions": (*ResourcePermissions)(nil), "clusters.permissions": (*ResourcePermissions)(nil), "database_instances.permissions": (*ResourcePermissions)(nil), diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 0b2da52768..4bffa3a5b1 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -380,6 +380,29 @@ var testDeps = map[string]prepareWorkspace{ }, nil }, + "app_spaces.permissions": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { + waiter, err := client.Apps.CreateSpace(ctx, apps.CreateSpaceRequest{ + Space: apps.Space{ + Name: "space-permissions", + }, + }) + if err != nil { + return nil, err + } + space, err := waiter.Wait(ctx) + if err != nil { + return nil, err + } + + return &PermissionsState{ + ObjectID: "/app-spaces/" + space.Name, + EmbeddedSlice: []StatePermission{{ + Level: "CAN_MANAGE", + UserName: "user@example.com", + }}, + }, nil + }, + "sql_warehouses.permissions": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { return &PermissionsState{ ObjectID: "/sql/warehouses/warehouse-permissions", diff --git a/bundle/direct/dresources/permissions.go b/bundle/direct/dresources/permissions.go index eac5e2dcdb..6ac7d1ce8e 100644 --- a/bundle/direct/dresources/permissions.go +++ b/bundle/direct/dresources/permissions.go @@ -16,6 +16,7 @@ import ( var permissionResourceToObjectType = map[string]string{ "alerts": "/alertsv2/", "apps": "/apps/", + "app_spaces": "/app-spaces/", "clusters": "/clusters/", "dashboards": "/dashboards/", "database_instances": "/database-instances/", diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 8b8a540084..b43552d19e 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -589,6 +589,9 @@ github.com/databricks/cli/bundle/config/resources.AppSpace: "name": "description": |- PLACEHOLDER + "permissions": + "description": |- + PLACEHOLDER "resources": "description": |- PLACEHOLDER @@ -616,6 +619,19 @@ github.com/databricks/cli/bundle/config/resources.AppSpace: "user_api_scopes": "description": |- PLACEHOLDER +github.com/databricks/cli/bundle/config/resources.AppSpacePermission: + "group_name": + "description": |- + PLACEHOLDER + "level": + "description": |- + PLACEHOLDER + "service_principal_name": + "description": |- + PLACEHOLDER + "user_name": + "description": |- + PLACEHOLDER github.com/databricks/cli/bundle/config/resources.Catalog: "comment": "description": |- diff --git a/bundle/internal/validation/generated/enum_fields.go b/bundle/internal/validation/generated/enum_fields.go index 33632c268f..f76e5acfe7 100644 --- a/bundle/internal/validation/generated/enum_fields.go +++ b/bundle/internal/validation/generated/enum_fields.go @@ -19,6 +19,20 @@ var EnumFields = map[string][]string{ "resources.alerts.*.permissions[*].level": {"CAN_ATTACH_TO", "CAN_BIND", "CAN_CREATE", "CAN_CREATE_APP", "CAN_EDIT", "CAN_EDIT_METADATA", "CAN_MANAGE", "CAN_MANAGE_PRODUCTION_VERSIONS", "CAN_MANAGE_RUN", "CAN_MANAGE_STAGING_VERSIONS", "CAN_MONITOR", "CAN_MONITOR_ONLY", "CAN_QUERY", "CAN_READ", "CAN_RESTART", "CAN_RUN", "CAN_USE", "CAN_VIEW", "CAN_VIEW_METADATA", "IS_OWNER"}, "resources.alerts.*.schedule.pause_status": {"PAUSED", "UNPAUSED"}, + "resources.app_spaces.*.permissions[*].level": {"CAN_ATTACH_TO", "CAN_BIND", "CAN_CREATE", "CAN_CREATE_APP", "CAN_EDIT", "CAN_EDIT_METADATA", "CAN_MANAGE", "CAN_MANAGE_PRODUCTION_VERSIONS", "CAN_MANAGE_RUN", "CAN_MANAGE_STAGING_VERSIONS", "CAN_MONITOR", "CAN_MONITOR_ONLY", "CAN_QUERY", "CAN_READ", "CAN_RESTART", "CAN_RUN", "CAN_USE", "CAN_VIEW", "CAN_VIEW_METADATA", "IS_OWNER"}, + "resources.app_spaces.*.resources[*].app.permission": {"CAN_USE"}, + "resources.app_spaces.*.resources[*].database.permission": {"CAN_CONNECT_AND_CREATE"}, + "resources.app_spaces.*.resources[*].experiment.permission": {"CAN_EDIT", "CAN_MANAGE", "CAN_READ"}, + "resources.app_spaces.*.resources[*].genie_space.permission": {"CAN_EDIT", "CAN_MANAGE", "CAN_RUN", "CAN_VIEW"}, + "resources.app_spaces.*.resources[*].job.permission": {"CAN_MANAGE", "CAN_MANAGE_RUN", "CAN_VIEW", "IS_OWNER"}, + "resources.app_spaces.*.resources[*].postgres.permission": {"CAN_CONNECT_AND_CREATE"}, + "resources.app_spaces.*.resources[*].secret.permission": {"MANAGE", "READ", "WRITE"}, + "resources.app_spaces.*.resources[*].serving_endpoint.permission": {"CAN_MANAGE", "CAN_QUERY", "CAN_VIEW"}, + "resources.app_spaces.*.resources[*].sql_warehouse.permission": {"CAN_MANAGE", "CAN_USE", "IS_OWNER"}, + "resources.app_spaces.*.resources[*].uc_securable.permission": {"EXECUTE", "MODIFY", "READ_VOLUME", "SELECT", "USE_CONNECTION", "WRITE_VOLUME"}, + "resources.app_spaces.*.resources[*].uc_securable.securable_type": {"CONNECTION", "FUNCTION", "TABLE", "VOLUME"}, + "resources.app_spaces.*.status.state": {"SPACE_ACTIVE", "SPACE_CREATING", "SPACE_DELETED", "SPACE_DELETING", "SPACE_ERROR", "SPACE_UPDATING"}, + "resources.apps.*.active_deployment.mode": {"AUTO_SYNC", "SNAPSHOT"}, "resources.apps.*.active_deployment.status.state": {"CANCELLED", "FAILED", "IN_PROGRESS", "SUCCEEDED"}, "resources.apps.*.app_status.state": {"CRASHED", "DEPLOYING", "RUNNING", "UNAVAILABLE"}, diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index db86398acc..e52cedf3a8 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -18,6 +18,18 @@ var RequiredFields = map[string][]string{ "resources.alerts.*.permissions[*]": {"level"}, "resources.alerts.*.schedule": {"quartz_cron_schedule", "timezone_id"}, + "resources.app_spaces.*": {"name"}, + "resources.app_spaces.*.permissions[*]": {"level"}, + "resources.app_spaces.*.resources[*]": {"name"}, + "resources.app_spaces.*.resources[*].database": {"database_name", "instance_name", "permission"}, + "resources.app_spaces.*.resources[*].experiment": {"experiment_id", "permission"}, + "resources.app_spaces.*.resources[*].genie_space": {"name", "permission", "space_id"}, + "resources.app_spaces.*.resources[*].job": {"id", "permission"}, + "resources.app_spaces.*.resources[*].secret": {"key", "permission", "scope"}, + "resources.app_spaces.*.resources[*].serving_endpoint": {"name", "permission"}, + "resources.app_spaces.*.resources[*].sql_warehouse": {"id", "permission"}, + "resources.app_spaces.*.resources[*].uc_securable": {"permission", "securable_full_name", "securable_type"}, + "resources.apps.*": {"name"}, "resources.apps.*.active_deployment.git_source.git_repository": {"provider", "url"}, "resources.apps.*.config.env[*]": {"name"}, diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 13ac9e6bf3..6819b9ce1d 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -313,6 +313,9 @@ "name": { "$ref": "#/$defs/string" }, + "permissions": { + "$ref": "#/$defs/slice/github.com/databricks/cli/bundle/config/resources.AppSpacePermission" + }, "resources": { "$ref": "#/$defs/slice/github.com/databricks/databricks-sdk-go/service/apps.AppResource" }, @@ -352,6 +355,35 @@ } ] }, + "resources.AppSpacePermission": { + "oneOf": [ + { + "type": "object", + "properties": { + "group_name": { + "$ref": "#/$defs/string" + }, + "level": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/iam.PermissionLevel" + }, + "service_principal_name": { + "$ref": "#/$defs/string" + }, + "user_name": { + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "level" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.Catalog": { "oneOf": [ { @@ -11755,6 +11787,20 @@ } ] }, + "resources.AppSpacePermission": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.AppSpacePermission" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.ClusterPermission": { "oneOf": [ { diff --git a/bundle/statemgmt/state_load_test.go b/bundle/statemgmt/state_load_test.go index 34c4fa4f5a..8e7e863867 100644 --- a/bundle/statemgmt/state_load_test.go +++ b/bundle/statemgmt/state_load_test.go @@ -40,6 +40,7 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { "resources.clusters.test_cluster": {ID: "1"}, "resources.dashboards.test_dashboard": {ID: "1"}, "resources.apps.test_app": {ID: "app1"}, + "resources.app_spaces.test_app_space": {ID: "app-space-1"}, "resources.secret_scopes.test_secret_scope": {ID: "secret_scope1"}, "resources.sql_warehouses.test_sql_warehouse": {ID: "1"}, "resources.database_instances.test_database_instance": {ID: "1"}, @@ -97,6 +98,10 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { assert.Equal(t, "", config.Resources.Apps["test_app"].Name) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.Apps["test_app"].ModifiedStatus) + assert.Equal(t, "app-space-1", config.Resources.AppSpaces["test_app_space"].ID) + assert.Equal(t, "", config.Resources.AppSpaces["test_app_space"].Name) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.AppSpaces["test_app_space"].ModifiedStatus) + assert.Equal(t, "secret_scope1", config.Resources.SecretScopes["test_secret_scope"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.SecretScopes["test_secret_scope"].ModifiedStatus) @@ -226,6 +231,13 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { }, }, }, + AppSpaces: map[string]*resources.AppSpace{ + "test_app_space": { + Space: apps.Space{ + Description: "test_app_space", + }, + }, + }, SecretScopes: map[string]*resources.SecretScope{ "test_secret_scope": { Name: "test_secret_scope", @@ -347,6 +359,9 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { assert.Equal(t, "", config.Resources.Apps["test_app"].Name) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Apps["test_app"].ModifiedStatus) + assert.Equal(t, "", config.Resources.AppSpaces["test_app_space"].Name) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.AppSpaces["test_app_space"].ModifiedStatus) + assert.Equal(t, "", config.Resources.SecretScopes["test_secret_scope"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.SecretScopes["test_secret_scope"].ModifiedStatus) @@ -547,6 +562,18 @@ func TestStateToBundleModifiedResources(t *testing.T) { }, }, }, + AppSpaces: map[string]*resources.AppSpace{ + "test_app_space": { + Space: apps.Space{ + Name: "test_app_space", + }, + }, + "test_app_space_new": { + Space: apps.Space{ + Name: "test_app_space_new", + }, + }, + }, SecretScopes: map[string]*resources.SecretScope{ "test_secret_scope": { Name: "test_secret_scope", @@ -702,6 +729,8 @@ func TestStateToBundleModifiedResources(t *testing.T) { "resources.dashboards.test_dashboard_old": {ID: "2"}, "resources.apps.test_app": {ID: "test_app"}, "resources.apps.test_app_old": {ID: "test_app_old"}, + "resources.app_spaces.test_app_space": {ID: "test_app_space"}, + "resources.app_spaces.test_app_space_old": {ID: "test_app_space_old"}, "resources.secret_scopes.test_secret_scope": {ID: "test_secret_scope"}, "resources.secret_scopes.test_secret_scope_old": {ID: "test_secret_scope_old"}, "resources.sql_warehouses.test_sql_warehouse": {ID: "1"}, @@ -814,6 +843,14 @@ func TestStateToBundleModifiedResources(t *testing.T) { assert.Equal(t, "test_app_new", config.Resources.Apps["test_app_new"].Name) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Apps["test_app_new"].ModifiedStatus) + assert.Equal(t, "test_app_space", config.Resources.AppSpaces["test_app_space"].Name) + assert.Equal(t, "", config.Resources.AppSpaces["test_app_space"].ModifiedStatus) + assert.Equal(t, "test_app_space_old", config.Resources.AppSpaces["test_app_space_old"].ID) + assert.Equal(t, "", config.Resources.AppSpaces["test_app_space_old"].Name) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.AppSpaces["test_app_space_old"].ModifiedStatus) + assert.Equal(t, "test_app_space_new", config.Resources.AppSpaces["test_app_space_new"].Name) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.AppSpaces["test_app_space_new"].ModifiedStatus) + assert.Equal(t, "test_secret_scope", config.Resources.SecretScopes["test_secret_scope"].Name) assert.Equal(t, "", config.Resources.SecretScopes["test_secret_scope"].ModifiedStatus) assert.Equal(t, "test_secret_scope_old", config.Resources.SecretScopes["test_secret_scope_old"].ID) diff --git a/libs/testserver/permissions.go b/libs/testserver/permissions.go index 3589b9d704..acc9ff9dba 100644 --- a/libs/testserver/permissions.go +++ b/libs/testserver/permissions.go @@ -30,6 +30,7 @@ var requestObjectTypeToObjectType = map[string]string{ "serving-endpoints": "serving-endpoint", "vector-search-endpoints": "vector-search-endpoints", "apps": "apps", + "app-spaces": "app-spaces", "database-instances": "database-instances", "database-projects": "database-projects", "alertsv2": "alertv2", From d4b5458db1da59d3cf65179acb92dcd7878a82c3 Mon Sep 17 00:00:00 2001 From: Bernardo Rodriguez Date: Tue, 21 Apr 2026 17:03:38 -0400 Subject: [PATCH 07/10] Regenerate direct engine files for app_spaces Co-authored-by: Isaac --- acceptance/bundle/refschema/out.fields.txt | 69 +++++++++++++++++++ .../resources/app_spaces/update/output.txt | 4 +- .../direct/dresources/apitypes.generated.yml | 2 + .../direct/dresources/resources.generated.yml | 26 +++++++ 4 files changed, 98 insertions(+), 3 deletions(-) diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index c79b0d3533..ca9086720c 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -56,6 +56,75 @@ resources.alerts.*.permissions[*].group_name string ALL resources.alerts.*.permissions[*].level iam.PermissionLevel ALL resources.alerts.*.permissions[*].service_principal_name string ALL resources.alerts.*.permissions[*].user_name string ALL +resources.app_spaces.*.create_time *time.Time ALL +resources.app_spaces.*.creator string ALL +resources.app_spaces.*.description string ALL +resources.app_spaces.*.effective_usage_policy_id string ALL +resources.app_spaces.*.effective_user_api_scopes []string ALL +resources.app_spaces.*.effective_user_api_scopes[*] string ALL +resources.app_spaces.*.id string ALL +resources.app_spaces.*.lifecycle resources.Lifecycle INPUT +resources.app_spaces.*.lifecycle.prevent_destroy bool INPUT +resources.app_spaces.*.modified_status string INPUT +resources.app_spaces.*.name string ALL +resources.app_spaces.*.resources []apps.AppResource ALL +resources.app_spaces.*.resources[*] apps.AppResource ALL +resources.app_spaces.*.resources[*].app *apps.AppResourceApp ALL +resources.app_spaces.*.resources[*].app.name string ALL +resources.app_spaces.*.resources[*].app.permission apps.AppResourceAppAppPermission ALL +resources.app_spaces.*.resources[*].database *apps.AppResourceDatabase ALL +resources.app_spaces.*.resources[*].database.database_name string ALL +resources.app_spaces.*.resources[*].database.instance_name string ALL +resources.app_spaces.*.resources[*].database.permission apps.AppResourceDatabaseDatabasePermission ALL +resources.app_spaces.*.resources[*].description string ALL +resources.app_spaces.*.resources[*].experiment *apps.AppResourceExperiment ALL +resources.app_spaces.*.resources[*].experiment.experiment_id string ALL +resources.app_spaces.*.resources[*].experiment.permission apps.AppResourceExperimentExperimentPermission ALL +resources.app_spaces.*.resources[*].genie_space *apps.AppResourceGenieSpace ALL +resources.app_spaces.*.resources[*].genie_space.name string ALL +resources.app_spaces.*.resources[*].genie_space.permission apps.AppResourceGenieSpaceGenieSpacePermission ALL +resources.app_spaces.*.resources[*].genie_space.space_id string ALL +resources.app_spaces.*.resources[*].job *apps.AppResourceJob ALL +resources.app_spaces.*.resources[*].job.id string ALL +resources.app_spaces.*.resources[*].job.permission apps.AppResourceJobJobPermission ALL +resources.app_spaces.*.resources[*].name string ALL +resources.app_spaces.*.resources[*].postgres *apps.AppResourcePostgres ALL +resources.app_spaces.*.resources[*].postgres.branch string ALL +resources.app_spaces.*.resources[*].postgres.database string ALL +resources.app_spaces.*.resources[*].postgres.permission apps.AppResourcePostgresPostgresPermission ALL +resources.app_spaces.*.resources[*].secret *apps.AppResourceSecret ALL +resources.app_spaces.*.resources[*].secret.key string ALL +resources.app_spaces.*.resources[*].secret.permission apps.AppResourceSecretSecretPermission ALL +resources.app_spaces.*.resources[*].secret.scope string ALL +resources.app_spaces.*.resources[*].serving_endpoint *apps.AppResourceServingEndpoint ALL +resources.app_spaces.*.resources[*].serving_endpoint.name string ALL +resources.app_spaces.*.resources[*].serving_endpoint.permission apps.AppResourceServingEndpointServingEndpointPermission ALL +resources.app_spaces.*.resources[*].sql_warehouse *apps.AppResourceSqlWarehouse ALL +resources.app_spaces.*.resources[*].sql_warehouse.id string ALL +resources.app_spaces.*.resources[*].sql_warehouse.permission apps.AppResourceSqlWarehouseSqlWarehousePermission ALL +resources.app_spaces.*.resources[*].uc_securable *apps.AppResourceUcSecurable ALL +resources.app_spaces.*.resources[*].uc_securable.permission apps.AppResourceUcSecurableUcSecurablePermission ALL +resources.app_spaces.*.resources[*].uc_securable.securable_full_name string ALL +resources.app_spaces.*.resources[*].uc_securable.securable_kind string ALL +resources.app_spaces.*.resources[*].uc_securable.securable_type apps.AppResourceUcSecurableUcSecurableType ALL +resources.app_spaces.*.service_principal_client_id string ALL +resources.app_spaces.*.service_principal_id int64 ALL +resources.app_spaces.*.service_principal_name string ALL +resources.app_spaces.*.status *apps.SpaceStatus ALL +resources.app_spaces.*.status.message string ALL +resources.app_spaces.*.status.state apps.SpaceStatusSpaceState ALL +resources.app_spaces.*.update_time *time.Time ALL +resources.app_spaces.*.updater string ALL +resources.app_spaces.*.url string INPUT +resources.app_spaces.*.usage_policy_id string ALL +resources.app_spaces.*.user_api_scopes []string ALL +resources.app_spaces.*.user_api_scopes[*] string ALL +resources.app_spaces.*.permissions.object_id string ALL +resources.app_spaces.*.permissions[*] dresources.StatePermission ALL +resources.app_spaces.*.permissions[*].group_name string ALL +resources.app_spaces.*.permissions[*].level iam.PermissionLevel ALL +resources.app_spaces.*.permissions[*].service_principal_name string ALL +resources.app_spaces.*.permissions[*].user_name string ALL resources.apps.*.active_deployment *apps.AppDeployment ALL resources.apps.*.active_deployment.command []string ALL resources.apps.*.active_deployment.command[*] string ALL diff --git a/acceptance/bundle/resources/app_spaces/update/output.txt b/acceptance/bundle/resources/app_spaces/update/output.txt index d7e4c608a0..8a4cab7e74 100644 --- a/acceptance/bundle/resources/app_spaces/update/output.txt +++ b/acceptance/bundle/resources/app_spaces/update/output.txt @@ -81,9 +81,7 @@ Deployment complete! >>> print_requests.py //app-spaces >>> [CLI] bundle plan -update app_spaces.mykey - -Plan: 0 to add, 1 to change, 0 to delete, 0 unchanged +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged >>> [CLI] bundle summary Name: test-bundle diff --git a/bundle/direct/dresources/apitypes.generated.yml b/bundle/direct/dresources/apitypes.generated.yml index a80b3baa69..440f1295aa 100644 --- a/bundle/direct/dresources/apitypes.generated.yml +++ b/bundle/direct/dresources/apitypes.generated.yml @@ -2,6 +2,8 @@ alerts: sql.AlertV2 +app_spaces: apps.Space + apps: apps.App catalogs: catalog.CreateCatalog diff --git a/bundle/direct/dresources/resources.generated.yml b/bundle/direct/dresources/resources.generated.yml index 6c3778d349..4577059836 100644 --- a/bundle/direct/dresources/resources.generated.yml +++ b/bundle/direct/dresources/resources.generated.yml @@ -24,6 +24,32 @@ resources: - field: update_time reason: spec:output_only + app_spaces: + + ignore_remote_changes: + - field: create_time + reason: spec:output_only + - field: creator + reason: spec:output_only + - field: effective_usage_policy_id + reason: spec:output_only + - field: effective_user_api_scopes + reason: spec:output_only + - field: id + reason: spec:output_only + - field: service_principal_client_id + reason: spec:output_only + - field: service_principal_id + reason: spec:output_only + - field: service_principal_name + reason: spec:output_only + - field: status + reason: spec:output_only + - field: update_time + reason: spec:output_only + - field: updater + reason: spec:output_only + apps: ignore_remote_changes: From 3d3510d1e4c4df90dade9a409b944277ba953a32 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 21 Apr 2026 21:58:50 +0000 Subject: [PATCH 08/10] Fix stale rebase artifacts in NEXT_CHANGELOG and invariant matrices - NEXT_CHANGELOG.md: remove bullets for #4672 and #4941 that were accidentally re-added during a stash/pop across a v0.297 release cut - acceptance/bundle/invariant/{continue_293,migrate,no_drift}/out.test.toml: restore job_with_depends_on.yml.tmpl that got dropped during a merge-conflict resolution on an earlier rebase Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 2 -- acceptance/bundle/invariant/continue_293/out.test.toml | 2 +- acceptance/bundle/invariant/migrate/out.test.toml | 2 +- acceptance/bundle/invariant/no_drift/out.test.toml | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index f865221a97..b8af7936ed 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -17,8 +17,6 @@ * engine/direct: Added support for Vector Search Endpoints ([#4887](https://github.com/databricks/cli/pull/4887)) * engine/direct: Exclude deploy-only fields (e.g. `lifecycle`) from the Apps update mask so requests that change both `description` and `lifecycle.started` in the same deploy no longer fail with `INVALID_PARAMETER_VALUE`. * engine/direct: Fix phantom diffs from `depends_on` reordering in job tasks ([#4990](https://github.com/databricks/cli/pull/4990)) -* Added support for lifecycle.started option for apps ([#4672](https://github.com/databricks/cli/pull/4672)) -* engine/direct: Fix permissions for resources.models ([#4941](https://github.com/databricks/cli/pull/4941)) * engine/direct: Added support for Databricks App Spaces (`app_spaces` resource type) ([#4982](https://github.com/databricks/cli/pull/4982)) ### Dependency updates diff --git a/acceptance/bundle/invariant/continue_293/out.test.toml b/acceptance/bundle/invariant/continue_293/out.test.toml index 24e0c6e7f2..b99f949814 100644 --- a/acceptance/bundle/invariant/continue_293/out.test.toml +++ b/acceptance/bundle/invariant/continue_293/out.test.toml @@ -4,4 +4,4 @@ RequiresUnityCatalog = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] - INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] + INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_depends_on.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] diff --git a/acceptance/bundle/invariant/migrate/out.test.toml b/acceptance/bundle/invariant/migrate/out.test.toml index 24e0c6e7f2..b99f949814 100644 --- a/acceptance/bundle/invariant/migrate/out.test.toml +++ b/acceptance/bundle/invariant/migrate/out.test.toml @@ -4,4 +4,4 @@ RequiresUnityCatalog = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] - INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] + INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_depends_on.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index 24e0c6e7f2..b99f949814 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -4,4 +4,4 @@ RequiresUnityCatalog = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] - INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] + INPUT_CONFIG = ["alert.yml.tmpl", "app.yml.tmpl", "app_space.yml.tmpl", "catalog.yml.tmpl", "cluster.yml.tmpl", "dashboard.yml.tmpl", "database_catalog.yml.tmpl", "database_instance.yml.tmpl", "experiment.yml.tmpl", "external_location.yml.tmpl", "job.yml.tmpl", "job_pydabs_10_tasks.yml.tmpl", "job_pydabs_1000_tasks.yml.tmpl", "job_cross_resource_ref.yml.tmpl", "job_permission_ref.yml.tmpl", "job_with_depends_on.yml.tmpl", "job_with_permissions.yml.tmpl", "job_with_task.yml.tmpl", "model.yml.tmpl", "model_with_permissions.yml.tmpl", "model_serving_endpoint.yml.tmpl", "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", "schema_with_grants.yml.tmpl", "secret_scope.yml.tmpl", "secret_scope_default_backend_type.yml.tmpl", "secret_scope_with_permissions.yml.tmpl", "sql_warehouse.yml.tmpl", "synced_database_table.yml.tmpl", "vector_search_endpoint.yml.tmpl", "volume.yml.tmpl"] From e8ce30b2bb640e1dc3dcefa2b660343c3b01fdf9 Mon Sep 17 00:00:00 2001 From: Bernardo Rodriguez Date: Wed, 22 Apr 2026 08:05:14 -0400 Subject: [PATCH 09/10] Address review: use apierr.IsMissing in Exists, SDK LRO in DoCreate/DoUpdate Co-authored-by: Isaac --- bundle/config/resources/app_spaces.go | 6 +++- bundle/direct/dresources/app_space.go | 49 ++++++--------------------- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/bundle/config/resources/app_spaces.go b/bundle/config/resources/app_spaces.go index 7bacfd79bb..9b286b3c07 100644 --- a/bundle/config/resources/app_spaces.go +++ b/bundle/config/resources/app_spaces.go @@ -6,6 +6,7 @@ import ( "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/apps" ) @@ -28,7 +29,10 @@ func (s AppSpace) MarshalJSON() ([]byte, error) { 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) + log.Debugf(ctx, "app space with id %s does not exist: %v", id, err) + if apierr.IsMissing(err) { + return false, nil + } return false, err } return true, nil diff --git a/bundle/direct/dresources/app_space.go b/bundle/direct/dresources/app_space.go index b11b037f06..1ee7d51a96 100644 --- a/bundle/direct/dresources/app_space.go +++ b/bundle/direct/dresources/app_space.go @@ -2,13 +2,10 @@ package dresources import ( "context" - "fmt" - "time" "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/retries" "github.com/databricks/databricks-sdk-go/service/apps" ) @@ -29,53 +26,29 @@ func (r *ResourceAppSpace) DoRead(ctx context.Context, id string) (*apps.Space, } func (r *ResourceAppSpace) DoCreate(ctx context.Context, config *apps.Space) (string, *apps.Space, error) { - // Kick off the create request. Wait for the space to become active in - // WaitAfterCreate so that parallel creates are not blocked here. - _, err := r.client.Apps.CreateSpace(ctx, apps.CreateSpaceRequest{ + waiter, err := r.client.Apps.CreateSpace(ctx, apps.CreateSpaceRequest{ Space: *config, }) if err != nil { return "", nil, err } - return config.Name, nil, nil + 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) { - _, err := r.client.Apps.UpdateSpace(ctx, apps.UpdateSpaceRequest{ + 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"}}, }) - return nil, err -} - -func (r *ResourceAppSpace) WaitAfterCreate(ctx context.Context, config *apps.Space) (*apps.Space, error) { - return r.waitForSpaceActive(ctx, config.Name) -} - -func (r *ResourceAppSpace) WaitAfterUpdate(ctx context.Context, config *apps.Space) (*apps.Space, error) { - return r.waitForSpaceActive(ctx, config.Name) -} - -func (r *ResourceAppSpace) waitForSpaceActive(ctx context.Context, name string) (*apps.Space, error) { - retrier := retries.New[apps.Space](retries.WithTimeout(20 * time.Minute)) - return retrier.Run(ctx, func(ctx context.Context) (*apps.Space, error) { - space, err := r.client.Apps.GetSpace(ctx, apps.GetSpaceRequest{Name: name}) - if err != nil { - return nil, retries.Halt(err) - } - if space.Status == nil { - return nil, retries.Continues("waiting for status") - } - switch space.Status.State { - case apps.SpaceStatusSpaceStateSpaceActive: - return space, nil - case apps.SpaceStatusSpaceStateSpaceError: - return nil, retries.Halt(fmt.Errorf("space %s is in ERROR state: %s", name, space.Status.Message)) - default: - return nil, retries.Continues(fmt.Sprintf("space state: %s", space.Status.State)) - } - }) + if err != nil { + return nil, err + } + return waiter.Wait(ctx) } func (r *ResourceAppSpace) DoDelete(ctx context.Context, id string) error { From efde2daad7868bdce8f83b93d886cf36651b1112 Mon Sep 17 00:00:00 2001 From: Bernardo Rodriguez Date: Wed, 22 Apr 2026 08:15:43 -0400 Subject: [PATCH 10/10] Remove duplicate NEXT_CHANGELOG entries from rebase Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index b8af7936ed..f0c7db017d 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -14,9 +14,6 @@ * engine/direct: Added support for Vector Search Endpoints ([#4887](https://github.com/databricks/cli/pull/4887)). * engine/direct: Exclude deploy-only fields (e.g. `lifecycle`) from the Apps update mask so requests that change both `description` and `lifecycle.started` in the same deploy no longer fail with `INVALID_PARAMETER_VALUE` ([#5042](https://github.com/databricks/cli/pull/5042), [#5051](https://github.com/databricks/cli/pull/5051)). * engine/direct: Fix phantom diffs from `depends_on` reordering in job tasks ([#4990](https://github.com/databricks/cli/pull/4990)). -* engine/direct: Added support for Vector Search Endpoints ([#4887](https://github.com/databricks/cli/pull/4887)) -* engine/direct: Exclude deploy-only fields (e.g. `lifecycle`) from the Apps update mask so requests that change both `description` and `lifecycle.started` in the same deploy no longer fail with `INVALID_PARAMETER_VALUE`. -* engine/direct: Fix phantom diffs from `depends_on` reordering in job tasks ([#4990](https://github.com/databricks/cli/pull/4990)) * engine/direct: Added support for Databricks App Spaces (`app_spaces` resource type) ([#4982](https://github.com/databricks/cli/pull/4982)) ### Dependency updates