From d31b4fefafc4ef6dd7e1e07323f1612510cb6676 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 23:31:51 +0200 Subject: [PATCH 01/41] dresources: extend ParsePostgresName for databases Co-authored-by: Isaac --- bundle/direct/dresources/util.go | 7 +++++-- bundle/direct/dresources/util_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/bundle/direct/dresources/util.go b/bundle/direct/dresources/util.go index 3bd0ab4ec73..0e6f302e5c8 100644 --- a/bundle/direct/dresources/util.go +++ b/bundle/direct/dresources/util.go @@ -14,16 +14,18 @@ import ( // - projects/{project_id} // - projects/{project_id}/branches/{branch_id} // - projects/{project_id}/branches/{branch_id}/endpoints/{endpoint_id} -var postgresNamePattern = regexp.MustCompile(`^projects/([^/]+)(?:/branches/([^/]+)(?:/endpoints/([^/]+))?)?$`) +// - projects/{project_id}/branches/{branch_id}/databases/{database_id} +var postgresNamePattern = regexp.MustCompile(`^projects/([^/]+)(?:/branches/([^/]+)(?:/endpoints/([^/]+)|/databases/([^/]+))?)?$`) // PostgresNameComponents holds the extracted components from a Postgres resource name. type PostgresNameComponents struct { ProjectID string BranchID string EndpointID string + DatabaseID string } -// ParsePostgresName extracts project, branch, and endpoint IDs from a hierarchical Postgres resource name. +// ParsePostgresName extracts project, branch, and endpoint or database IDs from a hierarchical Postgres resource name. // Returns an error if the name doesn't match the expected format. func ParsePostgresName(name string) (PostgresNameComponents, error) { matches := postgresNamePattern.FindStringSubmatch(name) @@ -35,6 +37,7 @@ func ParsePostgresName(name string) (PostgresNameComponents, error) { ProjectID: matches[1], BranchID: matches[2], EndpointID: matches[3], + DatabaseID: matches[4], }, nil } diff --git a/bundle/direct/dresources/util_test.go b/bundle/direct/dresources/util_test.go index bbf04717099..fd2b1041f1c 100644 --- a/bundle/direct/dresources/util_test.go +++ b/bundle/direct/dresources/util_test.go @@ -37,6 +37,7 @@ func TestParsePostgresName(t *testing.T) { projectID string branchID string endpointID string + databaseID string expectErr bool }{ { @@ -57,6 +58,13 @@ func TestParsePostgresName(t *testing.T) { branchID: "my-branch", endpointID: "my-endpoint", }, + { + name: "database", + input: "projects/my-project/branches/my-branch/databases/my-database", + projectID: "my-project", + branchID: "my-branch", + databaseID: "my-database", + }, { name: "with hyphens and numbers", input: "projects/my-app-123/branches/dev-branch/endpoints/primary-1", @@ -64,6 +72,13 @@ func TestParsePostgresName(t *testing.T) { branchID: "dev-branch", endpointID: "primary-1", }, + { + name: "database with hyphens and numbers", + input: "projects/my-app-123/branches/dev-branch/databases/db-1", + projectID: "my-app-123", + branchID: "dev-branch", + databaseID: "db-1", + }, { name: "empty", input: "", @@ -89,6 +104,11 @@ func TestParsePostgresName(t *testing.T) { input: "projects/my-project/branches/my-branch/endpoints/", expectErr: true, }, + { + name: "missing database id", + input: "projects/my-project/branches/my-branch/databases/", + expectErr: true, + }, { name: "extra segments", input: "projects/my-project/branches/my-branch/endpoints/my-endpoint/extra", @@ -104,6 +124,11 @@ func TestParsePostgresName(t *testing.T) { input: "projects/my-project/branches/my-branch/endpoints", expectErr: true, }, + { + name: "databases without database", + input: "projects/my-project/branches/my-branch/databases", + expectErr: true, + }, } for _, tt := range tests { @@ -117,6 +142,7 @@ func TestParsePostgresName(t *testing.T) { assert.Equal(t, tt.projectID, components.ProjectID) assert.Equal(t, tt.branchID, components.BranchID) assert.Equal(t, tt.endpointID, components.EndpointID) + assert.Equal(t, tt.databaseID, components.DatabaseID) }) } } From a97b14f26fa2e25e48312f9f95f18eb27cedcf9e Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 23:39:00 +0200 Subject: [PATCH 02/41] testserver: add postgres databases CRUD Co-authored-by: Isaac --- libs/testserver/fake_workspace.go | 2 + libs/testserver/handlers.go | 27 +++++ libs/testserver/postgres.go | 174 +++++++++++++++++++++++++++++- libs/testserver/postgres_test.go | 117 ++++++++++++++++++++ 4 files changed, 317 insertions(+), 3 deletions(-) diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 5430c68cbcc..1beed244c19 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -171,6 +171,7 @@ type FakeWorkspace struct { PostgresProjects map[string]postgres.Project PostgresBranches map[string]postgres.Branch PostgresEndpoints map[string]postgres.Endpoint + PostgresDatabases map[string]postgres.Database PostgresOperations map[string]postgres.Operation // clusterVenvs caches Python venvs per existing cluster ID, @@ -299,6 +300,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { PostgresProjects: map[string]postgres.Project{}, PostgresBranches: map[string]postgres.Branch{}, PostgresEndpoints: map[string]postgres.Endpoint{}, + PostgresDatabases: map[string]postgres.Database{}, PostgresOperations: map[string]postgres.Operation{}, clusterVenvs: map[string]*clusterEnv{}, Alerts: map[string]sql.AlertV2{}, diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index d98011fc7ba..0d1bb9a0af4 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -936,6 +936,33 @@ func AddDefaultHandlers(server *Server) { return req.Workspace.PostgresEndpointDelete(name) }) + // Postgres Databases: + server.Handle("POST", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases", func(req Request) any { + parent := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + databaseID := req.URL.Query().Get("database_id") + return req.Workspace.PostgresDatabaseCreate(req, parent, databaseID) + }) + + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases", func(req Request) any { + parent := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + return req.Workspace.PostgresDatabaseList(parent) + }) + + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases/{database_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/databases/" + req.Vars["database_id"] + return req.Workspace.PostgresDatabaseGet(name) + }) + + server.Handle("PATCH", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases/{database_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/databases/" + req.Vars["database_id"] + return req.Workspace.PostgresDatabaseUpdate(req, name) + }) + + server.Handle("DELETE", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases/{database_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/databases/" + req.Vars["database_id"] + return req.Workspace.PostgresDatabaseDelete(name) + }) + // Catch-all handler for invalid postgres resource names. // This handles cases like GET /api/2.0/postgres/1234 where "1234" is not a valid resource name. server.Handle("GET", "/api/2.0/postgres/{name}", func(req Request) any { diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index f3a488b5704..0dcf4fe3c24 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -587,6 +587,169 @@ func (s *FakeWorkspace) PostgresEndpointDelete(name string) Response { } } +// PostgresDatabaseCreate creates a new postgres database. +func (s *FakeWorkspace) PostgresDatabaseCreate(req Request, parent, databaseID string) Response { + defer s.LockUnlock()() + + if databaseID == "" { + return postgresErrorResponse(400, "INVALID_PARAMETER_VALUE", `Field 'database_id' is required, expected non-default value (not "")!`) + } + + // Check if parent branch exists + if _, exists := s.PostgresBranches[parent]; !exists { + return postgresNotFoundResponse("branch") + } + + var database postgres.Database + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &database); err != nil { + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("cannot unmarshal request body: %v", err), + } + } + } + + name := fmt.Sprintf("%s/databases/%s", parent, databaseID) + + if _, exists := s.PostgresDatabases[name]; exists { + return postgresErrorResponse(409, "ALREADY_EXISTS", "database with such id already exists") + } + + now := nowTime() + database.Name = name + database.Parent = parent + database.CreateTime = now + database.UpdateTime = now + + // Mirror spec onto status; the real API only echoes Status on GET. + status := &postgres.DatabaseDatabaseStatus{} + if database.Spec != nil { + status.PostgresDatabase = database.Spec.PostgresDatabase + status.Role = database.Spec.Role + } + // When no role is provided, the real API assigns the project-owner role. + if status.Role == "" { + status.Role = parent + "/roles/" + TestUser.UserName + } + database.Status = status + database.Spec = nil + + s.PostgresDatabases[name] = database + + return Response{ + Body: s.createOperationLocked(database.Name, database), + } +} + +// PostgresDatabaseGet retrieves a postgres database by name. +func (s *FakeWorkspace) PostgresDatabaseGet(name string) Response { + defer s.LockUnlock()() + + // Extract project and branch names from database name + // Format: projects/{project}/branches/{branch}/databases/{database} + parts := strings.Split(name, "/branches/") + if len(parts) == 2 { + projectName := parts[0] + if _, exists := s.PostgresProjects[projectName]; !exists { + return postgresNotFoundResponse("project") + } + branchParts := strings.Split(parts[1], "/databases/") + if len(branchParts) == 2 { + branchName := projectName + "/branches/" + branchParts[0] + if _, exists := s.PostgresBranches[branchName]; !exists { + return postgresNotFoundResponse("branch") + } + } + } + + database, exists := s.PostgresDatabases[name] + if !exists { + return postgresNotFoundResponse("database") + } + + return Response{ + Body: database, + } +} + +// PostgresDatabaseList lists all postgres databases for a branch. +func (s *FakeWorkspace) PostgresDatabaseList(parent string) Response { + defer s.LockUnlock()() + + if _, exists := s.PostgresBranches[parent]; !exists { + return postgresNotFoundResponse("branch") + } + + var databases []postgres.Database + prefix := parent + "/databases/" + for name, d := range s.PostgresDatabases { + if strings.HasPrefix(name, prefix) { + databases = append(databases, d) + } + } + + return Response{ + Body: postgres.ListDatabasesResponse{ + Databases: databases, + }, + } +} + +// PostgresDatabaseUpdate updates a postgres database. +func (s *FakeWorkspace) PostgresDatabaseUpdate(req Request, name string) Response { + defer s.LockUnlock()() + + database, exists := s.PostgresDatabases[name] + if !exists { + return postgresNotFoundResponse("database") + } + + var updateDatabase postgres.Database + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &updateDatabase); err != nil { + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("cannot unmarshal request body: %v", err), + } + } + } + + if updateDatabase.Spec != nil { + if database.Status == nil { + database.Status = &postgres.DatabaseDatabaseStatus{} + } + if updateDatabase.Spec.PostgresDatabase != "" { + database.Status.PostgresDatabase = updateDatabase.Spec.PostgresDatabase + } + if updateDatabase.Spec.Role != "" { + database.Status.Role = updateDatabase.Spec.Role + } + } + + database.UpdateTime = nowTime() + s.PostgresDatabases[name] = database + + return Response{ + Body: s.createOperationLocked(database.Name, database), + } +} + +// PostgresDatabaseDelete deletes a postgres database. +func (s *FakeWorkspace) PostgresDatabaseDelete(name string) Response { + defer s.LockUnlock()() + + if _, exists := s.PostgresDatabases[name]; !exists { + return postgresNotFoundResponse("database") + } + + delete(s.PostgresDatabases, name) + + return Response{ + Body: s.createOperationLocked(name, nil), + } +} + // PostgresOperationGet retrieves a postgres operation by name. func (s *FakeWorkspace) PostgresOperationGet(name string) Response { defer s.LockUnlock()() @@ -606,11 +769,16 @@ func (s *FakeWorkspace) createOperationLocked(resourceName string, response any) operationID := nextUUID() operationName := resourceName + "/operations/" + operationID - // Determine resource type from name for metadata @type + // Determine resource type from name for metadata @type. + // Check the more specific suffixes first since database/endpoint names also + // contain "/branches/". resourceType := "Project" - if strings.Contains(resourceName, "/endpoints/") { + switch { + case strings.Contains(resourceName, "/endpoints/"): resourceType = "Endpoint" - } else if strings.Contains(resourceName, "/branches/") { + case strings.Contains(resourceName, "/databases/"): + resourceType = "Database" + case strings.Contains(resourceName, "/branches/"): resourceType = "Branch" } diff --git a/libs/testserver/postgres_test.go b/libs/testserver/postgres_test.go index d421212ed9c..c2310d18039 100644 --- a/libs/testserver/postgres_test.go +++ b/libs/testserver/postgres_test.go @@ -3,6 +3,7 @@ package testserver_test import ( "encoding/json" "net/http" + "strings" "testing" "github.com/databricks/cli/libs/testserver" @@ -248,6 +249,122 @@ func TestPostgresEndpointCRUD(t *testing.T) { deleteEpResp.Body.Close() } +func TestPostgresDatabaseCRUD(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + + client := &http.Client{} + baseURL := server.URL + + // Create project first + createProjReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects?project_id=database-test-project", nil) + createProjReq.Header.Set("Authorization", "Bearer test-token") + createProjResp, err := client.Do(createProjReq) + require.NoError(t, err) + assert.Equal(t, 200, createProjResp.StatusCode) + createProjResp.Body.Close() + + // Create branch + createBranchReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/database-test-project/branches?branch_id=main", nil) + createBranchReq.Header.Set("Authorization", "Bearer test-token") + createBranchResp, err := client.Do(createBranchReq) + require.NoError(t, err) + assert.Equal(t, 200, createBranchResp.StatusCode) + createBranchResp.Body.Close() + + // Create database + createDbBody := `{"spec":{"postgres_database":"my_db"}}` + createDbReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases?database_id=my-db", strings.NewReader(createDbBody)) + createDbReq.Header.Set("Authorization", "Bearer test-token") + createDbReq.Header.Set("Content-Type", "application/json") + createDbResp, err := client.Do(createDbReq) + require.NoError(t, err) + assert.Equal(t, 200, createDbResp.StatusCode) + createDbResp.Body.Close() + + // Get database + getDbReq, _ := http.NewRequest(http.MethodGet, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases/my-db", nil) + getDbReq.Header.Set("Authorization", "Bearer test-token") + getDbResp, err := client.Do(getDbReq) + require.NoError(t, err) + assert.Equal(t, 200, getDbResp.StatusCode) + + var database postgres.Database + require.NoError(t, json.NewDecoder(getDbResp.Body).Decode(&database)) + assert.Equal(t, "projects/database-test-project/branches/main/databases/my-db", database.Name) + assert.Equal(t, "projects/database-test-project/branches/main", database.Parent) + require.NotNil(t, database.Status) + assert.Equal(t, "my_db", database.Status.PostgresDatabase) + assert.NotEmpty(t, database.Status.Role) + getDbResp.Body.Close() + + // List databases + listDbReq, _ := http.NewRequest(http.MethodGet, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases", nil) + listDbReq.Header.Set("Authorization", "Bearer test-token") + listDbResp, err := client.Do(listDbReq) + require.NoError(t, err) + assert.Equal(t, 200, listDbResp.StatusCode) + + var listDatabases postgres.ListDatabasesResponse + require.NoError(t, json.NewDecoder(listDbResp.Body).Decode(&listDatabases)) + assert.Len(t, listDatabases.Databases, 1) + listDbResp.Body.Close() + + // Update database (rename via spec.postgres_database) + updateDbBody := `{"spec":{"postgres_database":"my_db_renamed"}}` + updateDbReq, _ := http.NewRequest(http.MethodPatch, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases/my-db", strings.NewReader(updateDbBody)) + updateDbReq.Header.Set("Authorization", "Bearer test-token") + updateDbReq.Header.Set("Content-Type", "application/json") + updateDbResp, err := client.Do(updateDbReq) + require.NoError(t, err) + assert.Equal(t, 200, updateDbResp.StatusCode) + updateDbResp.Body.Close() + + // Verify rename was applied + getDbReq2, _ := http.NewRequest(http.MethodGet, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases/my-db", nil) + getDbReq2.Header.Set("Authorization", "Bearer test-token") + getDbResp2, err := client.Do(getDbReq2) + require.NoError(t, err) + assert.Equal(t, 200, getDbResp2.StatusCode) + var database2 postgres.Database + require.NoError(t, json.NewDecoder(getDbResp2.Body).Decode(&database2)) + require.NotNil(t, database2.Status) + assert.Equal(t, "my_db_renamed", database2.Status.PostgresDatabase) + getDbResp2.Body.Close() + + // Delete database + deleteDbReq, _ := http.NewRequest(http.MethodDelete, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases/my-db", nil) + deleteDbReq.Header.Set("Authorization", "Bearer test-token") + deleteDbResp, err := client.Do(deleteDbReq) + require.NoError(t, err) + assert.Equal(t, 200, deleteDbResp.StatusCode) + deleteDbResp.Body.Close() +} + +func TestPostgresDatabaseNotFoundWhenBranchNotExists(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + + client := &http.Client{} + baseURL := server.URL + + // Create project first + createProjReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects?project_id=db-test-project", nil) + createProjReq.Header.Set("Authorization", "Bearer test-token") + createProjResp, err := client.Do(createProjReq) + require.NoError(t, err) + assert.Equal(t, 200, createProjResp.StatusCode) + createProjResp.Body.Close() + + // Try to create database without branch + createDbReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/db-test-project/branches/nonexistent/databases?database_id=my-db", nil) + createDbReq.Header.Set("Authorization", "Bearer test-token") + createDbResp, err := client.Do(createDbReq) + require.NoError(t, err) + assert.Equal(t, 404, createDbResp.StatusCode) + createDbResp.Body.Close() +} + func TestPostgresEndpointNotFoundWhenBranchNotExists(t *testing.T) { server := testserver.New(t) testserver.AddDefaultHandlers(server) From 2aaffcf2aa377ae92e67584bbf90eb98eb297287 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 23:43:08 +0200 Subject: [PATCH 03/41] bundle: add PostgresDatabase config resource type Co-authored-by: Isaac --- bundle/config/resources/postgres_database.go | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 bundle/config/resources/postgres_database.go diff --git a/bundle/config/resources/postgres_database.go b/bundle/config/resources/postgres_database.go new file mode 100644 index 00000000000..5c04abcd49c --- /dev/null +++ b/bundle/config/resources/postgres_database.go @@ -0,0 +1,66 @@ +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/postgres" +) + +type PostgresDatabaseConfig struct { + postgres.DatabaseDatabaseSpec + + // DatabaseId is the user-specified ID for the database (becomes part of the hierarchical name). + // This is specified during creation and becomes part of Name: "projects/{project_id}/branches/{branch_id}/databases/{database_id}" + DatabaseId string `json:"database_id"` + + // Parent is the branch containing this database. Format: "projects/{project_id}/branches/{branch_id}" + Parent string `json:"parent"` +} + +func (c *PostgresDatabaseConfig) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, c) +} + +func (c *PostgresDatabaseConfig) MarshalJSON() ([]byte, error) { + return marshal.Marshal(c) +} + +type PostgresDatabase struct { + BaseResource + PostgresDatabaseConfig +} + +func (d *PostgresDatabase) Exists(ctx context.Context, w *databricks.WorkspaceClient, name string) (bool, error) { + _, err := w.Postgres.GetDatabase(ctx, postgres.GetDatabaseRequest{Name: name}) + if err != nil { + log.Debugf(ctx, "postgres database %s does not exist", name) + return false, err + } + return true, nil +} + +func (d *PostgresDatabase) ResourceDescription() ResourceDescription { + return ResourceDescription{ + SingularName: "postgres_database", + PluralName: "postgres_databases", + SingularTitle: "Postgres database", + PluralTitle: "Postgres databases", + } +} + +func (d *PostgresDatabase) GetName() string { + return d.DatabaseId +} + +func (d *PostgresDatabase) GetURL() string { + // The IDs in the API do not (yet) map to IDs in the web UI. + return "" +} + +func (d *PostgresDatabase) InitializeURL(_ url.URL) { + // The IDs in the API do not (yet) map to IDs in the web UI. +} From 8c6e9f9d300dcb2d10254bb50843caf8f6c17334 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 23:48:40 +0200 Subject: [PATCH 04/41] bundle: register postgres_databases resource Co-authored-by: Isaac --- .../resourcemutator/apply_bundle_permissions_test.go | 1 + .../mutator/resourcemutator/apply_target_mode_test.go | 9 +++++++++ bundle/config/mutator/resourcemutator/run_as_test.go | 2 ++ bundle/config/resources.go | 3 +++ bundle/config/resources_test.go | 9 +++++++++ 5 files changed, 24 insertions(+) diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index e472241f282..88233279729 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -27,6 +27,7 @@ var unsupportedResources = []string{ "database_catalogs", "synced_database_tables", "postgres_branches", + "postgres_databases", "postgres_endpoints", } diff --git a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go index fe9c9a1db06..e69ba5422f9 100644 --- a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go +++ b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go @@ -247,6 +247,14 @@ func mockBundle(mode config.Mode) *bundle.Bundle { }, }, }, + PostgresDatabases: map[string]*resources.PostgresDatabase{ + "postgres_database1": { + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "postgres-database-1", + Parent: "projects/postgres-project-1/branches/postgres-branch-1", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "vs_endpoint1": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -441,6 +449,7 @@ func TestAppropriateResourcesAreRenamed(t *testing.T) { "PostgresProjects", "PostgresBranches", "PostgresEndpoints", + "PostgresDatabases", } diags := bundle.ApplySeq(t.Context(), b, ApplyTargetMode(), ApplyPresets()) diff --git a/bundle/config/mutator/resourcemutator/run_as_test.go b/bundle/config/mutator/resourcemutator/run_as_test.go index 0b7003f5873..ea75d4a1c46 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -46,6 +46,7 @@ func allResourceTypes(t *testing.T) []string { "models", "pipelines", "postgres_branches", + "postgres_databases", "postgres_endpoints", "postgres_projects", "quality_monitors", @@ -174,6 +175,7 @@ var allowList = []string{ "pipelines", "models", "postgres_branches", + "postgres_databases", "postgres_endpoints", "postgres_projects", "registered_models", diff --git a/bundle/config/resources.go b/bundle/config/resources.go index 225ec32165d..cd08242dcb3 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -35,6 +35,7 @@ type Resources struct { PostgresProjects map[string]*resources.PostgresProject `json:"postgres_projects,omitempty"` PostgresBranches map[string]*resources.PostgresBranch `json:"postgres_branches,omitempty"` PostgresEndpoints map[string]*resources.PostgresEndpoint `json:"postgres_endpoints,omitempty"` + PostgresDatabases map[string]*resources.PostgresDatabase `json:"postgres_databases,omitempty"` VectorSearchEndpoints map[string]*resources.VectorSearchEndpoint `json:"vector_search_endpoints,omitempty"` } @@ -112,6 +113,7 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["postgres_projects"], r.PostgresProjects), collectResourceMap(descriptions["postgres_branches"], r.PostgresBranches), collectResourceMap(descriptions["postgres_endpoints"], r.PostgresEndpoints), + collectResourceMap(descriptions["postgres_databases"], r.PostgresDatabases), collectResourceMap(descriptions["vector_search_endpoints"], r.VectorSearchEndpoints), } } @@ -167,6 +169,7 @@ func SupportedResources() map[string]resources.ResourceDescription { "postgres_projects": (&resources.PostgresProject{}).ResourceDescription(), "postgres_branches": (&resources.PostgresBranch{}).ResourceDescription(), "postgres_endpoints": (&resources.PostgresEndpoint{}).ResourceDescription(), + "postgres_databases": (&resources.PostgresDatabase{}).ResourceDescription(), "vector_search_endpoints": (&resources.VectorSearchEndpoint{}).ResourceDescription(), } } diff --git a/bundle/config/resources_test.go b/bundle/config/resources_test.go index 943b279a288..4411ccc79d1 100644 --- a/bundle/config/resources_test.go +++ b/bundle/config/resources_test.go @@ -273,6 +273,14 @@ func TestResourcesBindSupport(t *testing.T) { }, }, }, + PostgresDatabases: map[string]*resources.PostgresDatabase{ + "my_postgres_database": { + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "my-postgres-database", + Parent: "projects/my-postgres-project/branches/my-postgres-branch", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "my_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -312,6 +320,7 @@ func TestResourcesBindSupport(t *testing.T) { m.GetMockPostgresAPI().EXPECT().GetProject(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetBranch(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetEndpoint(mock.Anything, mock.Anything).Return(nil, nil) + m.GetMockPostgresAPI().EXPECT().GetDatabase(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockVectorSearchEndpointsAPI().EXPECT().GetEndpoint(mock.Anything, mock.Anything).Return(nil, nil) allResources := supportedResources.AllResources() From 7bce379f6d9bb5157cdfe45056a78f89d220ad3e Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 23:53:06 +0200 Subject: [PATCH 05/41] dresources: implement postgres database handler Co-authored-by: Isaac --- bundle/direct/dresources/postgres_database.go | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 bundle/direct/dresources/postgres_database.go diff --git a/bundle/direct/dresources/postgres_database.go b/bundle/direct/dresources/postgres_database.go new file mode 100644 index 00000000000..1705631b1cb --- /dev/null +++ b/bundle/direct/dresources/postgres_database.go @@ -0,0 +1,124 @@ +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/postgres" +) + +type ResourcePostgresDatabase struct { + client *databricks.WorkspaceClient +} + +type PostgresDatabaseState = resources.PostgresDatabaseConfig + +func (*ResourcePostgresDatabase) New(client *databricks.WorkspaceClient) *ResourcePostgresDatabase { + return &ResourcePostgresDatabase{client: client} +} + +func (*ResourcePostgresDatabase) PrepareState(input *resources.PostgresDatabase) *PostgresDatabaseState { + return &PostgresDatabaseState{ + DatabaseId: input.DatabaseId, + Parent: input.Parent, + DatabaseDatabaseSpec: input.DatabaseDatabaseSpec, + } +} + +func (*ResourcePostgresDatabase) RemapState(remote *postgres.Database) *PostgresDatabaseState { + // Extract database_id from hierarchical name: "projects/{project_id}/branches/{branch_id}/databases/{database_id}" + // TODO: log error when we have access to the context + components, _ := ParsePostgresName(remote.Name) + + return &PostgresDatabaseState{ + DatabaseId: components.DatabaseID, + Parent: remote.Parent, + + // The read API does not return the spec, only the status. + // This means we cannot detect remote drift for spec fields. + // Use an empty struct (not nil) so field-level diffing works correctly. + DatabaseDatabaseSpec: postgres.DatabaseDatabaseSpec{ + PostgresDatabase: "", + Role: "", + ForceSendFields: nil, + }, + } +} + +func (r *ResourcePostgresDatabase) DoRead(ctx context.Context, id string) (*postgres.Database, error) { + return r.client.Postgres.GetDatabase(ctx, postgres.GetDatabaseRequest{Name: id}) +} + +func (r *ResourcePostgresDatabase) DoCreate(ctx context.Context, config *PostgresDatabaseState) (string, *postgres.Database, error) { + waiter, err := r.client.Postgres.CreateDatabase(ctx, postgres.CreateDatabaseRequest{ + DatabaseId: config.DatabaseId, + Parent: config.Parent, + Database: postgres.Database{ + Spec: &config.DatabaseDatabaseSpec, + + // Output-only fields. + CreateTime: nil, + Name: "", + Parent: "", + Status: nil, + UpdateTime: nil, + ForceSendFields: nil, + }, + }) + if err != nil { + return "", nil, err + } + + // Wait for the database to be ready (long-running operation) + result, err := waiter.Wait(ctx) + if err != nil { + return "", nil, err + } + + return result.Name, result, nil +} + +func (r *ResourcePostgresDatabase) DoUpdate(ctx context.Context, id string, config *PostgresDatabaseState, entry *PlanEntry) (*postgres.Database, error) { + // Build update mask from fields that have action="update" in the changes map. + // This excludes immutable fields and fields that haven't changed. + // Prefix with "spec." because the API expects paths relative to the Database object, + // not relative to our flattened state type. + fieldPaths := collectUpdatePathsWithPrefix(entry.Changes, "spec.") + + waiter, err := r.client.Postgres.UpdateDatabase(ctx, postgres.UpdateDatabaseRequest{ + Database: postgres.Database{ + Spec: &config.DatabaseDatabaseSpec, + + // Output-only fields. + CreateTime: nil, + Name: "", + Parent: "", + Status: nil, + UpdateTime: nil, + ForceSendFields: nil, + }, + Name: id, + UpdateMask: fieldmask.FieldMask{ + Paths: fieldPaths, + }, + }) + if err != nil { + return nil, err + } + + // Wait for the update to complete + result, err := waiter.Wait(ctx) + return result, err +} + +func (r *ResourcePostgresDatabase) DoDelete(ctx context.Context, id string) error { + waiter, err := r.client.Postgres.DeleteDatabase(ctx, postgres.DeleteDatabaseRequest{ + Name: id, + }) + if err != nil { + return err + } + return waiter.Wait(ctx) +} From b8c1435ccbe654a5e02a39db78d6c52ad5e61d7e Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 23:59:56 +0200 Subject: [PATCH 06/41] dresources: register postgres_databases handler and fixtures Co-authored-by: Isaac --- bundle/direct/dresources/all.go | 1 + bundle/direct/dresources/all_test.go | 34 +++++++++++++++++++++++++++ bundle/direct/dresources/type_test.go | 5 ++++ libs/testserver/handlers.go | 5 ++++ 4 files changed, 45 insertions(+) diff --git a/bundle/direct/dresources/all.go b/bundle/direct/dresources/all.go index ddc30c41f54..75859812d92 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -23,6 +23,7 @@ var SupportedResources = map[string]any{ "postgres_projects": (*ResourcePostgresProject)(nil), "postgres_branches": (*ResourcePostgresBranch)(nil), "postgres_endpoints": (*ResourcePostgresEndpoint)(nil), + "postgres_databases": (*ResourcePostgresDatabase)(nil), "alerts": (*ResourceAlert)(nil), "clusters": (*ResourceCluster)(nil), "registered_models": (*ResourceRegisteredModel)(nil), diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 2c0a2e52f22..5caf3a8c1d9 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -669,6 +669,40 @@ var testDeps = map[string]prepareWorkspace{ }, }, nil }, + + "postgres_databases": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { + // Create parent project first + _, err := client.Postgres.CreateProject(ctx, postgres.CreateProjectRequest{ + ProjectId: "test-project-for-database", + Project: postgres.Project{ + Spec: &postgres.ProjectSpec{ + DisplayName: "Test Project for Database", + PgVersion: 16, + }, + }, + }) + if err != nil { + return nil, err + } + + // Create parent branch + _, err = client.Postgres.CreateBranch(ctx, postgres.CreateBranchRequest{ + Parent: "projects/test-project-for-database", + BranchId: "test-branch-for-database", + Branch: postgres.Branch{}, + }) + if err != nil { + return nil, err + } + + return &resources.PostgresDatabase{ + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + Parent: "projects/test-project-for-database/branches/test-branch-for-database", + DatabaseId: "test-database", + DatabaseDatabaseSpec: postgres.DatabaseDatabaseSpec{}, + }, + }, nil + }, } func TestAll(t *testing.T) { diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 88f246723bf..60f61eb1aea 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -53,6 +53,11 @@ var knownMissingInRemoteType = map[string][]string{ "source_branch_time", "ttl", }, + "postgres_databases": { + "database_id", + "postgres_database", + "role", + }, "postgres_endpoints": { "autoscaling_limit_max_cu", "autoscaling_limit_min_cu", diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index 0d1bb9a0af4..a94e8b07acc 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -857,6 +857,11 @@ func AddDefaultHandlers(server *Server) { return req.Workspace.PostgresOperationGet(name) }) + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases/{database_id}/operations/{operation_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/databases/" + req.Vars["database_id"] + "/operations/" + req.Vars["operation_id"] + return req.Workspace.PostgresOperationGet(name) + }) + // Postgres Projects: server.Handle("POST", "/api/2.0/postgres/projects", func(req Request) any { projectID := req.URL.Query().Get("project_id") From 475f117154a8932a6301c3cccf6ff7bd50f740a9 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 00:07:31 +0200 Subject: [PATCH 07/41] dresources: declare postgres_databases lifecycle Co-authored-by: Isaac --- bundle/direct/dresources/all_test.go | 8 +++++--- bundle/direct/dresources/apitypes.generated.yml | 2 ++ bundle/direct/dresources/apitypes.yml | 2 ++ bundle/direct/dresources/resources.generated.yml | 8 ++++++++ bundle/direct/dresources/resources.yml | 8 ++++++++ 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 5caf3a8c1d9..93383b4bddc 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -697,9 +697,11 @@ var testDeps = map[string]prepareWorkspace{ return &resources.PostgresDatabase{ PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ - Parent: "projects/test-project-for-database/branches/test-branch-for-database", - DatabaseId: "test-database", - DatabaseDatabaseSpec: postgres.DatabaseDatabaseSpec{}, + Parent: "projects/test-project-for-database/branches/test-branch-for-database", + DatabaseId: "test-database", + DatabaseDatabaseSpec: postgres.DatabaseDatabaseSpec{ + PostgresDatabase: "app_db", + }, }, }, nil }, diff --git a/bundle/direct/dresources/apitypes.generated.yml b/bundle/direct/dresources/apitypes.generated.yml index a80b3baa69b..e077dc2ec62 100644 --- a/bundle/direct/dresources/apitypes.generated.yml +++ b/bundle/direct/dresources/apitypes.generated.yml @@ -28,6 +28,8 @@ pipelines: pipelines.CreatePipeline postgres_branches: postgres.BranchSpec +postgres_databases: postgres.DatabaseDatabaseStatus + postgres_endpoints: postgres.EndpointSpec postgres_projects: postgres.ProjectStatus diff --git a/bundle/direct/dresources/apitypes.yml b/bundle/direct/dresources/apitypes.yml index 29db9b67b20..db72f3eb5ae 100644 --- a/bundle/direct/dresources/apitypes.yml +++ b/bundle/direct/dresources/apitypes.yml @@ -6,6 +6,8 @@ postgres_branches: postgres.BranchSpec +postgres_databases: postgres.DatabaseDatabaseSpec + postgres_endpoints: postgres.EndpointSpec postgres_projects: postgres.ProjectSpec diff --git a/bundle/direct/dresources/resources.generated.yml b/bundle/direct/dresources/resources.generated.yml index 85c15d6f343..6fb412ea400 100644 --- a/bundle/direct/dresources/resources.generated.yml +++ b/bundle/direct/dresources/resources.generated.yml @@ -208,6 +208,14 @@ resources: - field: ttl reason: spec:input_only + postgres_databases: + + ignore_remote_changes: + - field: postgres_database + reason: spec:input_only + - field: role + reason: spec:input_only + postgres_endpoints: recreate_on_changes: diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 569fca9ee82..7e099059063 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -500,6 +500,14 @@ resources: - field: branch_id reason: immutable + postgres_databases: + recreate_on_changes: + # parent and database_id are part of the hierarchical name and immutable. + - field: parent + reason: immutable + - field: database_id + reason: immutable + postgres_endpoints: recreate_on_changes: # parent and endpoint_id are immutable (part of hierarchical name, not in API spec) From 99b0a904f7a0b4a163a2ddd61d0336de174ffc70 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 00:13:36 +0200 Subject: [PATCH 08/41] bundle: terraform engine support for postgres_databases Co-authored-by: Isaac --- bundle/deploy/terraform/interpolate.go | 2 +- bundle/deploy/terraform/pkg.go | 1 + .../tfdyn/convert_postgres_database.go | 63 ++++++++++++++++ .../tfdyn/convert_postgres_database_test.go | 73 +++++++++++++++++++ bundle/deploy/terraform/util.go | 2 +- 5 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 bundle/deploy/terraform/tfdyn/convert_postgres_database.go create mode 100644 bundle/deploy/terraform/tfdyn/convert_postgres_database_test.go diff --git a/bundle/deploy/terraform/interpolate.go b/bundle/deploy/terraform/interpolate.go index fdcb671bdd3..52c93b87623 100644 --- a/bundle/deploy/terraform/interpolate.go +++ b/bundle/deploy/terraform/interpolate.go @@ -16,7 +16,7 @@ type interpolateMutator struct{} // Postgres resources use "name" instead of "id" as their identifier attribute. func isPostgresResource(resourceType string) bool { switch resourceType { - case "postgres_projects", "postgres_branches", "postgres_endpoints": + case "postgres_projects", "postgres_branches", "postgres_databases", "postgres_endpoints": return true default: return false diff --git a/bundle/deploy/terraform/pkg.go b/bundle/deploy/terraform/pkg.go index a66e5cb6a06..f120cfff890 100644 --- a/bundle/deploy/terraform/pkg.go +++ b/bundle/deploy/terraform/pkg.go @@ -128,6 +128,7 @@ var GroupToTerraformName = map[string]string{ "synced_database_tables": "databricks_database_synced_database_table", "postgres_projects": "databricks_postgres_project", "postgres_branches": "databricks_postgres_branch", + "postgres_databases": "databricks_postgres_database", "postgres_endpoints": "databricks_postgres_endpoint", // 3 level groups: resources.*.GROUP diff --git a/bundle/deploy/terraform/tfdyn/convert_postgres_database.go b/bundle/deploy/terraform/tfdyn/convert_postgres_database.go new file mode 100644 index 00000000000..cb89f4aed33 --- /dev/null +++ b/bundle/deploy/terraform/tfdyn/convert_postgres_database.go @@ -0,0 +1,63 @@ +package tfdyn + +import ( + "context" + + "github.com/databricks/cli/bundle/internal/tf/schema" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/convert" + "github.com/databricks/cli/libs/log" +) + +type postgresDatabaseConverter struct{} + +func (c postgresDatabaseConverter) Convert(ctx context.Context, key string, vin dyn.Value, out *schema.Resources) error { + // The bundle config has flattened DatabaseSpec fields at the top level. + // Terraform expects them nested in a "spec" block. + specFields := specFieldNames(schema.ResourcePostgresDatabaseSpec{}) + topLevelFields := []string{"database_id", "parent"} + + // Build the spec block from the flattened fields + specMap := make(map[string]dyn.Value) + for _, field := range specFields { + if v := vin.Get(field); v.Kind() != dyn.KindInvalid { + specMap[field] = v + } + } + + // Build the output with top-level fields and spec + outMap := make(map[string]dyn.Value) + + // Keep top-level fields + for _, field := range topLevelFields { + if v := vin.Get(field); v.Kind() != dyn.KindInvalid { + outMap[field] = v + } + } + + // Add spec block if we have any spec fields + if len(specMap) > 0 { + outMap["spec"] = dyn.V(specMap) + } + + vout := dyn.V(outMap) + + // Normalize the output value to the Terraform schema. + vout, diags := convert.Normalize(schema.ResourcePostgresDatabase{}, vout) + for _, diag := range diags { + log.Debugf(ctx, "postgres database normalization diagnostic: %s", diag.Summary) + } + + vout, err := convertLifecycle(ctx, vout, vin.Get("lifecycle")) + if err != nil { + return err + } + + out.PostgresDatabase[key] = vout.AsAny() + + return nil +} + +func init() { + registerConverter("postgres_databases", postgresDatabaseConverter{}) +} diff --git a/bundle/deploy/terraform/tfdyn/convert_postgres_database_test.go b/bundle/deploy/terraform/tfdyn/convert_postgres_database_test.go new file mode 100644 index 00000000000..3276be62856 --- /dev/null +++ b/bundle/deploy/terraform/tfdyn/convert_postgres_database_test.go @@ -0,0 +1,73 @@ +package tfdyn + +import ( + "testing" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/bundle/internal/tf/schema" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/convert" + "github.com/databricks/databricks-sdk-go/service/postgres" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertPostgresDatabase(t *testing.T) { + src := resources.PostgresDatabase{ + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "my-database", + Parent: "projects/my-project/branches/main", + DatabaseDatabaseSpec: postgres.DatabaseDatabaseSpec{ + PostgresDatabase: "my_postgres_db", + Role: "projects/my-project/branches/main/roles/owner", + }, + }, + } + + vin, err := convert.FromTyped(src, dyn.NilValue) + require.NoError(t, err) + + ctx := t.Context() + out := schema.NewResources() + err = postgresDatabaseConverter{}.Convert(ctx, "my_postgres_database", vin, out) + require.NoError(t, err) + + postgresDatabase := out.PostgresDatabase["my_postgres_database"] + assert.Equal(t, map[string]any{ + "database_id": "my-database", + "parent": "projects/my-project/branches/main", + "spec": map[string]any{ + "postgres_database": "my_postgres_db", + "role": "projects/my-project/branches/main/roles/owner", + }, + }, postgresDatabase) +} + +func TestConvertPostgresDatabaseMinimal(t *testing.T) { + src := resources.PostgresDatabase{ + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "minimal-database", + Parent: "projects/my-project/branches/main", + DatabaseDatabaseSpec: postgres.DatabaseDatabaseSpec{ + PostgresDatabase: "minimal_db", + }, + }, + } + + vin, err := convert.FromTyped(src, dyn.NilValue) + require.NoError(t, err) + + ctx := t.Context() + out := schema.NewResources() + err = postgresDatabaseConverter{}.Convert(ctx, "minimal_postgres_database", vin, out) + require.NoError(t, err) + + postgresDatabase := out.PostgresDatabase["minimal_postgres_database"] + assert.Equal(t, map[string]any{ + "database_id": "minimal-database", + "parent": "projects/my-project/branches/main", + "spec": map[string]any{ + "postgres_database": "minimal_db", + }, + }, postgresDatabase) +} diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index 632d32bca19..96a7b405005 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -96,7 +96,7 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap // The direct engine manages permissions as a sub-resource // (SecretScopeFixups adds MANAGE ACL for the current user). result[resourceKey+".permissions"] = ResourceState{ID: instance.Attributes.Name} - case "apps", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_endpoints": + case "apps", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_databases", "postgres_endpoints": resourceKey = "resources." + groupName + "." + resource.Name resourceState = ResourceState{ID: instance.Attributes.Name} case "dashboards": From 1ccb524566d13cf9325ea6b7d9344f79ce6d98d9 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 00:20:26 +0200 Subject: [PATCH 09/41] bundle: statemgmt and schema annotations for postgres_databases Co-authored-by: Isaac --- bundle/internal/schema/annotations.yml | 19 ++++++++++ .../validation/generated/required_fields.go | 2 + bundle/statemgmt/state_load_test.go | 38 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index f6ac5c45d4d..52af791558e 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -215,6 +215,9 @@ github.com/databricks/cli/bundle/config.Resources: "postgres_branches": "description": |- PLACEHOLDER + "postgres_databases": + "description": |- + PLACEHOLDER "postgres_endpoints": "description": |- PLACEHOLDER @@ -793,6 +796,22 @@ github.com/databricks/cli/bundle/config/resources.PostgresBranch: "update_time": "description": |- PLACEHOLDER +github.com/databricks/cli/bundle/config/resources.PostgresDatabase: + "database_id": + "description": |- + PLACEHOLDER + "lifecycle": + "description": |- + PLACEHOLDER + "parent": + "description": |- + PLACEHOLDER + "postgres_database": + "description": |- + PLACEHOLDER + "role": + "description": |- + PLACEHOLDER github.com/databricks/cli/bundle/config/resources.PostgresEndpoint: "autoscaling_limit_max_cu": "description": |- diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index db86398accb..ff6af8bae0a 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -219,6 +219,8 @@ var RequiredFields = map[string][]string{ "resources.postgres_branches.*": {"branch_id", "parent"}, + "resources.postgres_databases.*": {"database_id", "parent"}, + "resources.postgres_endpoints.*": {"endpoint_type", "endpoint_id", "parent"}, "resources.postgres_endpoints.*.group": {"max", "min"}, diff --git a/bundle/statemgmt/state_load_test.go b/bundle/statemgmt/state_load_test.go index 34c4fa4f5aa..9b52ee480b4 100644 --- a/bundle/statemgmt/state_load_test.go +++ b/bundle/statemgmt/state_load_test.go @@ -49,6 +49,7 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { "resources.postgres_projects.test_postgres_project": {ID: "projects/test-project"}, "resources.postgres_branches.test_postgres_branch": {ID: "projects/test-project/branches/main"}, "resources.postgres_endpoints.test_postgres_endpoint": {ID: "projects/test-project/branches/main/endpoints/primary"}, + "resources.postgres_databases.test_postgres_database": {ID: "projects/test-project/branches/main/databases/test-db"}, "resources.vector_search_endpoints.test_vector_search_endpoint": {ID: "vs-endpoint-1"}, } err := StateToBundle(t.Context(), state, &config) @@ -118,6 +119,9 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { assert.Equal(t, "projects/test-project/branches/main/endpoints/primary", config.Resources.PostgresEndpoints["test_postgres_endpoint"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresEndpoints["test_postgres_endpoint"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/databases/test-db", config.Resources.PostgresDatabases["test_postgres_database"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresDatabases["test_postgres_database"].ModifiedStatus) + assert.Equal(t, "vs-endpoint-1", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ModifiedStatus) @@ -292,6 +296,14 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { }, }, }, + PostgresDatabases: map[string]*resources.PostgresDatabase{ + "test_postgres_database": { + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "test-db", + Parent: "projects/test-project/branches/main", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "test_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -374,6 +386,9 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { assert.Equal(t, "", config.Resources.PostgresEndpoints["test_postgres_endpoint"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresEndpoints["test_postgres_endpoint"].ModifiedStatus) + assert.Equal(t, "", config.Resources.PostgresDatabases["test_postgres_database"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresDatabases["test_postgres_database"].ModifiedStatus) + assert.Equal(t, "", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ModifiedStatus) @@ -661,6 +676,20 @@ func TestStateToBundleModifiedResources(t *testing.T) { }, }, }, + PostgresDatabases: map[string]*resources.PostgresDatabase{ + "test_postgres_database": { + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "test-db", + Parent: "projects/test-project/branches/main", + }, + }, + "test_postgres_database_new": { + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "new-db", + Parent: "projects/test-project-new/branches/dev", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "test_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -716,6 +745,8 @@ func TestStateToBundleModifiedResources(t *testing.T) { "resources.postgres_branches.test_postgres_branch_old": {ID: "projects/test-project/branches/old"}, "resources.postgres_endpoints.test_postgres_endpoint": {ID: "projects/test-project/branches/main/endpoints/primary"}, "resources.postgres_endpoints.test_postgres_endpoint_old": {ID: "projects/test-project/branches/main/endpoints/old"}, + "resources.postgres_databases.test_postgres_database": {ID: "projects/test-project/branches/main/databases/test-db"}, + "resources.postgres_databases.test_postgres_database_old": {ID: "projects/test-project/branches/main/databases/old-db"}, "resources.vector_search_endpoints.test_vector_search_endpoint": {ID: "vs-endpoint-1"}, "resources.vector_search_endpoints.test_vector_search_endpoint_old": {ID: "vs-endpoint-old"}, } @@ -864,6 +895,13 @@ func TestStateToBundleModifiedResources(t *testing.T) { assert.Equal(t, "", config.Resources.PostgresEndpoints["test_postgres_endpoint_new"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresEndpoints["test_postgres_endpoint_new"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/databases/test-db", config.Resources.PostgresDatabases["test_postgres_database"].ID) + assert.Equal(t, "", config.Resources.PostgresDatabases["test_postgres_database"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/databases/old-db", config.Resources.PostgresDatabases["test_postgres_database_old"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresDatabases["test_postgres_database_old"].ModifiedStatus) + assert.Equal(t, "", config.Resources.PostgresDatabases["test_postgres_database_new"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresDatabases["test_postgres_database_new"].ModifiedStatus) + assert.Equal(t, "vs-endpoint-1", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ID) assert.Equal(t, "", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ModifiedStatus) assert.Equal(t, "vs-endpoint-old", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint_old"].ID) From 725898352baa3a5de6d885d81b7f5e744e07c9c1 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 00:26:25 +0200 Subject: [PATCH 10/41] bundle: regenerate schema for postgres_databases Co-authored-by: Isaac --- acceptance/bundle/refschema/out.fields.txt | 19 ++++++++ bundle/schema/jsonschema.json | 50 ++++++++++++++++++++++ bundle/schema/jsonschema_for_docs.json | 34 +++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 999296b2139..fbce2194b41 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -2733,6 +2733,25 @@ resources.postgres_branches.*.ttl *duration.Duration INPUT STATE resources.postgres_branches.*.uid string REMOTE resources.postgres_branches.*.update_time *time.Time REMOTE resources.postgres_branches.*.url string INPUT +resources.postgres_databases.*.create_time *time.Time REMOTE +resources.postgres_databases.*.database_id string INPUT STATE +resources.postgres_databases.*.id string INPUT +resources.postgres_databases.*.lifecycle resources.Lifecycle INPUT +resources.postgres_databases.*.lifecycle.prevent_destroy bool INPUT +resources.postgres_databases.*.modified_status string INPUT +resources.postgres_databases.*.name string REMOTE +resources.postgres_databases.*.parent string ALL +resources.postgres_databases.*.postgres_database string INPUT STATE +resources.postgres_databases.*.role string INPUT STATE +resources.postgres_databases.*.spec *postgres.DatabaseDatabaseSpec REMOTE +resources.postgres_databases.*.spec.postgres_database string REMOTE +resources.postgres_databases.*.spec.role string REMOTE +resources.postgres_databases.*.status *postgres.DatabaseDatabaseStatus REMOTE +resources.postgres_databases.*.status.database_id string REMOTE +resources.postgres_databases.*.status.postgres_database string REMOTE +resources.postgres_databases.*.status.role string REMOTE +resources.postgres_databases.*.update_time *time.Time REMOTE +resources.postgres_databases.*.url string INPUT resources.postgres_endpoints.*.autoscaling_limit_max_cu float64 INPUT STATE resources.postgres_endpoints.*.autoscaling_limit_min_cu float64 INPUT STATE resources.postgres_endpoints.*.create_time *time.Time REMOTE diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 26e773f64a4..bdeae14ef5d 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1409,6 +1409,39 @@ } ] }, + "resources.PostgresDatabase": { + "oneOf": [ + { + "type": "object", + "properties": { + "database_id": { + "$ref": "#/$defs/string" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "parent": { + "$ref": "#/$defs/string" + }, + "postgres_database": { + "$ref": "#/$defs/string" + }, + "role": { + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "database_id", + "parent" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.PostgresEndpoint": { "oneOf": [ { @@ -2522,6 +2555,9 @@ "postgres_branches": { "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresBranch" }, + "postgres_databases": { + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresDatabase" + }, "postgres_endpoints": { "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresEndpoint" }, @@ -11744,6 +11780,20 @@ } ] }, + "resources.PostgresDatabase": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresDatabase" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.PostgresEndpoint": { "oneOf": [ { diff --git a/bundle/schema/jsonschema_for_docs.json b/bundle/schema/jsonschema_for_docs.json index 0748cf84e47..d23b5b50012 100644 --- a/bundle/schema/jsonschema_for_docs.json +++ b/bundle/schema/jsonschema_for_docs.json @@ -1391,6 +1391,31 @@ "parent" ] }, + "resources.PostgresDatabase": { + "type": "object", + "properties": { + "database_id": { + "$ref": "#/$defs/string" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "parent": { + "$ref": "#/$defs/string" + }, + "postgres_database": { + "$ref": "#/$defs/string" + }, + "role": { + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "database_id", + "parent" + ] + }, "resources.PostgresEndpoint": { "type": "object", "properties": { @@ -2486,6 +2511,9 @@ "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresBranch", "x-since-version": "v0.287.0" }, + "postgres_databases": { + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresDatabase" + }, "postgres_endpoints": { "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresEndpoint", "x-since-version": "v0.287.0" @@ -9716,6 +9744,12 @@ "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresBranch" } }, + "resources.PostgresDatabase": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresDatabase" + } + }, "resources.PostgresEndpoint": { "type": "object", "additionalProperties": { From 6f9aca4be2eb716d98aee3a264730a2ceffc3072 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 01:16:29 +0200 Subject: [PATCH 11/41] acc: postgres database basic deploy Co-authored-by: Isaac --- .../basic/databricks.yml.tmpl | 25 ++++++ .../basic/out.requests.direct.json | 42 +++++++++ .../basic/out.requests.terraform.json | 44 ++++++++++ .../postgres_databases/basic/out.test.toml | 6 ++ .../postgres_databases/basic/output.txt | 86 +++++++++++++++++++ .../resources/postgres_databases/basic/script | 25 ++++++ .../postgres_databases/basic/test.toml | 1 + .../resources/postgres_databases/test.toml | 45 ++++++++++ 8 files changed, 274 insertions(+) create mode 100644 acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json create mode 100644 acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json create mode 100644 acceptance/bundle/resources/postgres_databases/basic/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_databases/basic/output.txt create mode 100644 acceptance/bundle/resources/postgres_databases/basic/script create mode 100644 acceptance/bundle/resources/postgres_databases/basic/test.toml create mode 100644 acceptance/bundle/resources/postgres_databases/test.toml diff --git a/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl new file mode 100644 index 00000000000..eef99ab5df8 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl @@ -0,0 +1,25 @@ +bundle: + name: deploy-postgres-database-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Database" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + database_id: my-database + postgres_database: app_db diff --git a/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json b/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json new file mode 100644 index 00000000000..d7d8a3a1dc9 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json @@ -0,0 +1,42 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Database", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", + "q": { + "database_id": "my-database" + }, + "body": { + "spec": { + "postgres_database": "app_db" + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases/my-database" +} diff --git a/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json b/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json new file mode 100644 index 00000000000..23b01ed569a --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json @@ -0,0 +1,44 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Database", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", + "q": { + "database_id": "my-database" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "postgres_database": "app_db" + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases/my-database" +} diff --git a/acceptance/bundle/resources/postgres_databases/basic/out.test.toml b/acceptance/bundle/resources/postgres_databases/basic/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/postgres_databases/basic/output.txt b/acceptance/bundle/resources/postgres_databases/basic/output.txt new file mode 100644 index 00000000000..6120fad3593 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/output.txt @@ -0,0 +1,86 @@ + +>>> [CLI] bundle validate +Name: deploy-postgres-database-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-[UNIQUE_NAME]/default + +Validation OK! + +>>> [CLI] bundle summary +Name: deploy-postgres-database-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-[UNIQUE_NAME]/default +Resources: + Postgres branches: + main: + Name: + URL: (not deployed) + Postgres databases: + my_database: + Name: my-database + URL: (not deployed) + Postgres projects: + my_project: + Name: Test Project for Database + URL: (not deployed) + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] postgres get-database projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases/my-database +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases/my-database", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "postgres_database": "app_db", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + } +} + +>>> [CLI] bundle summary +Name: deploy-postgres-database-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-[UNIQUE_NAME]/default +Resources: + Postgres branches: + main: + Name: + URL: (not deployed) + Postgres databases: + my_database: + Name: my-database + URL: (not deployed) + Postgres projects: + my_project: + Name: Test Project for Database + URL: (not deployed) + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_databases.my_database + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_databases/basic/script b/acceptance/bundle/resources/postgres_databases/basic/script new file mode 100644 index 00000000000..d6c5e1a4138 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/script @@ -0,0 +1,25 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +trace $CLI bundle validate + +trace $CLI bundle summary + +rm -f out.requests.txt +trace $CLI bundle deploy + +# Get database details +project_name="projects/test-pg-proj-${UNIQUE_NAME}" +branch_name="${project_name}/branches/main" +database_name="${branch_name}/databases/my-database" +trace $CLI postgres get-database "${database_name}" | jq 'del(.create_time, .update_time)' + +trace $CLI bundle summary + +# Filter requests to only show postgres operations (exclude workspace, telemetry, and operation polling) +trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/postgres_databases/basic/test.toml b/acceptance/bundle/resources/postgres_databases/basic/test.toml new file mode 100644 index 00000000000..f8b3bbe49dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/test.toml @@ -0,0 +1 @@ +# All configuration inherited from parent test.toml diff --git a/acceptance/bundle/resources/postgres_databases/test.toml b/acceptance/bundle/resources/postgres_databases/test.toml new file mode 100644 index 00000000000..66fd405c0c6 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/test.toml @@ -0,0 +1,45 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true + +# Lakebase v2 (postgres) is only available in AWS as of January 2026 +CloudEnvs.gcp = false +CloudEnvs.azure = false + +# Run on both direct and Terraform modes +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] + +Ignore = [ + "databricks.yml", + ".databricks", +] + +[[Repls]] +# Clean up ?o= suffix after URL since not all workspaces have that +Old = '\?o=\[(NUMID|ALPHANUMID)\]' +New = '' +Order = 1000 + +[[Repls]] +# Normalize endpoint UIDs (ep-xxx-yyy format, supports both word-based and hex-based UIDs) +Old = 'ep-[a-z0-9-]+' +New = '[ENDPOINT_UID]' +Order = 1 + +[[Repls]] +# Normalize branch UIDs (br-xxx-yyy-zzz format, supports both word-based and hex-based UIDs) +Old = 'br-[a-z0-9-]+' +New = '[BRANCH_UID]' +Order = 1 + +[[Repls]] +# Normalize project UIDs (proj-xxx-yyy-zzz format, supports both word-based and hex-based UIDs) +Old = 'proj-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]' +New = '[PROJECT_UID]' +Order = 1 + +[[Repls]] +# Normalize LSN values (format: 0/HEXVALUE or 0/0) +Old = '"source_branch_lsn": "0/[A-F0-9]+"' +New = '"source_branch_lsn": "[LSN]"' +Order = 1 From 39e3781e831f42bd2aa93afe9ed4ffa12a8ee776 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 01:21:01 +0200 Subject: [PATCH 12/41] acc: postgres database recreate on immutable change Co-authored-by: Isaac --- .../recreate/databricks.yml.tmpl | 25 +++ .../postgres_databases/recreate/out.test.toml | 6 + .../postgres_databases/recreate/output.txt | 158 ++++++++++++++++++ .../postgres_databases/recreate/script | 53 ++++++ .../postgres_databases/recreate/test.toml | 1 + 5 files changed, 243 insertions(+) create mode 100644 acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_databases/recreate/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_databases/recreate/output.txt create mode 100644 acceptance/bundle/resources/postgres_databases/recreate/script create mode 100644 acceptance/bundle/resources/postgres_databases/recreate/test.toml diff --git a/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl new file mode 100644 index 00000000000..eeda6e99242 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl @@ -0,0 +1,25 @@ +bundle: + name: deploy-postgres-database-recreate-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Database" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + database_id: DATABASE_ID_PLACEHOLDER + postgres_database: app_db diff --git a/acceptance/bundle/resources/postgres_databases/recreate/out.test.toml b/acceptance/bundle/resources/postgres_databases/recreate/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/recreate/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/postgres_databases/recreate/output.txt b/acceptance/bundle/resources/postgres_databases/recreate/output.txt new file mode 100644 index 00000000000..6d2808705ae --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/recreate/output.txt @@ -0,0 +1,158 @@ + +>>> cat databricks.yml +bundle: + name: deploy-postgres-database-recreate-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Database" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + database_id: test-database-[UNIQUE_NAME] + postgres_database: app_db + +>>> [CLI] bundle plan +create postgres_branches.main +create postgres_databases.my_database +create postgres_projects.my_project + +Plan: 3 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests +{ + "body": { + "spec": { + "display_name": "Test Project for Database", + "history_retention_duration": "604800s", + "pg_version": 16 + } + }, + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + } +} + "no_expiry": true + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "branch_id": "main" + "postgres_database": "app_db" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", + "database_id": "test-database-[UNIQUE_NAME]" + +>>> cat databricks.yml +bundle: + name: deploy-postgres-database-recreate-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Database" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + database_id: test-database-[UNIQUE_NAME]-v2 + postgres_database: app_db + +>>> [CLI] bundle plan +recreate postgres_databases.my_database + +Plan: 1 to add, 0 to change, 1 to delete, 2 unchanged + +>>> [CLI] bundle deploy --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests +{ + "method": "DELETE", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} + "body": { + "spec": { + "postgres_database": "app_db" + } + }, + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", + "q": { + "database_id": "test-database-[UNIQUE_NAME]-v2" + } + +=== Fetch database and verify it exists after recreation +>>> [CLI] postgres get-database [MY_DATABASE_ID]-v2 +{ + "name": "[MY_DATABASE_ID]-v2", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "postgres_database": "app_db", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + } +} + +=== Destroy and verify cleanup +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_databases.my_database + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! + +>>> print_requests +{ + "method": "DELETE", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]-v2" +} + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" + +>>> [CLI] bundle destroy --auto-approve +No active deployment found to destroy! diff --git a/acceptance/bundle/resources/postgres_databases/recreate/script b/acceptance/bundle/resources/postgres_databases/recreate/script new file mode 100644 index 00000000000..98e37747c43 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/recreate/script @@ -0,0 +1,53 @@ +# Cleanup function to delete database and other resources +cleanup() { + # Try to delete with current config + trace $CLI bundle destroy --auto-approve + + # Also try to delete the databases directly in case they weren't cleaned up + $CLI postgres delete-database "projects/test-pg-proj-${UNIQUE_NAME}/branches/main/databases/test-database-${UNIQUE_NAME}" 2>/dev/null || true + $CLI postgres delete-database "projects/test-pg-proj-${UNIQUE_NAME}/branches/main/databases/test-database-${UNIQUE_NAME}-v2" 2>/dev/null || true + + rm -f out.requests.txt +} +trap cleanup EXIT + +# Deploy with first database_id +envsubst < databricks.yml.tmpl | sed "s/DATABASE_ID_PLACEHOLDER/test-database-${UNIQUE_NAME}/" > databricks.yml + +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle deploy + +database_id_1=`read_id.py my_database` + +print_requests() { + # Filter postgres requests (excluding GET), remove parent field (differs between engines), + # then deduplicate consecutive retries + jq --sort-keys 'select(.method != "GET" and (.path | contains("/postgres"))) | del(.body.parent)' < out.requests.txt | \ + awk '!seen[$0]++ {print}' + rm -f out.requests.txt +} + +trace print_requests + +# Change database_id (should trigger recreation) +envsubst < databricks.yml.tmpl | sed "s/DATABASE_ID_PLACEHOLDER/test-database-${UNIQUE_NAME}-v2/" > databricks.yml + +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle deploy --auto-approve + +trace print_requests + +title "Fetch database and verify it exists after recreation" + +database_id_2=`read_id.py my_database` +# Verify the database was recreated (DELETE/POST in requests proves recreation) +trace $CLI postgres get-database $database_id_2 | jq 'del(.create_time, .update_time)' + +title "Destroy and verify cleanup" +trace $CLI bundle destroy --auto-approve + +trace print_requests diff --git a/acceptance/bundle/resources/postgres_databases/recreate/test.toml b/acceptance/bundle/resources/postgres_databases/recreate/test.toml new file mode 100644 index 00000000000..f8b3bbe49dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/recreate/test.toml @@ -0,0 +1 @@ +# All configuration inherited from parent test.toml From 93fa24674edbdaa25333ba9a83337b5a86fcb5b3 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 07:32:01 +0200 Subject: [PATCH 13/41] acc: postgres database update via rename Co-authored-by: Isaac --- .../update/databricks.yml.tmpl | 25 +++ .../update/out.plan.create.direct.json | 19 +++ .../update/out.plan.create.terraform.json | 3 + .../update/out.plan.no_change.direct.json | 27 ++++ .../update/out.plan.no_change.terraform.json | 3 + .../update/out.plan.restore.direct.json | 33 ++++ .../update/out.plan.restore.terraform.json | 3 + .../update/out.plan.update.direct.json | 33 ++++ .../update/out.plan.update.terraform.json | 3 + .../update/out.requests.create.direct.json | 38 +++++ .../update/out.requests.create.terraform.json | 40 +++++ .../update/out.requests.no_change.direct.json | 20 +++ .../out.requests.no_change.terraform.json | 12 ++ .../update/out.requests.restore.direct.json | 32 ++++ .../out.requests.restore.terraform.json | 26 +++ .../update/out.requests.update.direct.json | 32 ++++ .../update/out.requests.update.terraform.json | 26 +++ .../postgres_databases/update/out.test.toml | 6 + .../postgres_databases/update/output.txt | 153 ++++++++++++++++++ .../postgres_databases/update/script | 61 +++++++ .../postgres_databases/update/test.toml | 1 + 21 files changed, 596 insertions(+) create mode 100644 acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.plan.create.terraform.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.terraform.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.plan.restore.terraform.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.plan.update.terraform.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.direct.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.terraform.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json create mode 100644 acceptance/bundle/resources/postgres_databases/update/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_databases/update/output.txt create mode 100644 acceptance/bundle/resources/postgres_databases/update/script create mode 100644 acceptance/bundle/resources/postgres_databases/update/test.toml diff --git a/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl new file mode 100644 index 00000000000..522d0a6c305 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl @@ -0,0 +1,25 @@ +bundle: + name: update-postgres-database-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Database Update" + pg_version: 16 + history_retention_duration: 604800s + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_databases: + my_database: + database_id: my-database + parent: ${resources.postgres_branches.main.id} + postgres_database: initial_db_name diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json new file mode 100644 index 00000000000..8b402c6f12c --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json @@ -0,0 +1,19 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + } + ], + "action": "create", + "new_state": { + "value": { + "database_id": "my-database", + "parent": "${resources.postgres_branches.main.id}", + "postgres_database": "initial_db_name" + }, + "vars": { + "parent": "${resources.postgres_branches.main.id}" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.create.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.create.terraform.json new file mode 100644 index 00000000000..b4425b9687c --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.create.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "create" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json new file mode 100644 index 00000000000..f331785fd2d --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json @@ -0,0 +1,27 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + } + ], + "action": "skip", + "remote_state": { + "create_time": "[TIMESTAMP]", + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + }, + "update_time": "[TIMESTAMP]" + }, + "changes": { + "postgres_database": { + "action": "skip", + "reason": "spec:input_only", + "old": "initial_db_name", + "new": "initial_db_name" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.terraform.json new file mode 100644 index 00000000000..b2f23a5015f --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "skip" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json new file mode 100644 index 00000000000..34a3329d99e --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json @@ -0,0 +1,33 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + } + ], + "action": "update", + "new_state": { + "value": { + "database_id": "my-database", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "postgres_database": "initial_db_name" + } + }, + "remote_state": { + "create_time": "[TIMESTAMP]", + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "postgres_database": "renamed_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + }, + "update_time": "[TIMESTAMP]" + }, + "changes": { + "postgres_database": { + "action": "update", + "old": "renamed_db_name", + "new": "initial_db_name" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.terraform.json new file mode 100644 index 00000000000..375b496fef0 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "update" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json new file mode 100644 index 00000000000..ce4c77b1a10 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json @@ -0,0 +1,33 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + } + ], + "action": "update", + "new_state": { + "value": { + "database_id": "my-database", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "postgres_database": "renamed_db_name" + } + }, + "remote_state": { + "create_time": "[TIMESTAMP]", + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + }, + "update_time": "[TIMESTAMP]" + }, + "changes": { + "postgres_database": { + "action": "update", + "old": "initial_db_name", + "new": "renamed_db_name" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.update.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.update.terraform.json new file mode 100644 index 00000000000..375b496fef0 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.update.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "update" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json new file mode 100644 index 00000000000..6b6154d7938 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json @@ -0,0 +1,38 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Database Update", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", + "q": { + "database_id": "my-database" + }, + "body": { + "spec": { + "postgres_database": "initial_db_name" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json new file mode 100644 index 00000000000..fa017f0b408 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json @@ -0,0 +1,40 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Database Update", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", + "q": { + "database_id": "my-database" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "postgres_database": "initial_db_name" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.direct.json new file mode 100644 index 00000000000..741fd31fc1d --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.direct.json @@ -0,0 +1,20 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.terraform.json new file mode 100644 index 00000000000..0616ddea143 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.terraform.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json new file mode 100644 index 00000000000..85d115935b6 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json @@ -0,0 +1,32 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]", + "q": { + "update_mask": "spec.postgres_database" + }, + "body": { + "spec": { + "postgres_database": "initial_db_name" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json new file mode 100644 index 00000000000..689c68e6f4f --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json @@ -0,0 +1,26 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]", + "q": { + "update_mask": "spec" + }, + "body": { + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "postgres_database": "initial_db_name" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json new file mode 100644 index 00000000000..d3e3064d4fe --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json @@ -0,0 +1,32 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]", + "q": { + "update_mask": "spec.postgres_database" + }, + "body": { + "spec": { + "postgres_database": "renamed_db_name" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json new file mode 100644 index 00000000000..b56c0e3535e --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json @@ -0,0 +1,26 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]", + "q": { + "update_mask": "spec" + }, + "body": { + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "postgres_database": "renamed_db_name" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.test.toml b/acceptance/bundle/resources/postgres_databases/update/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/postgres_databases/update/output.txt b/acceptance/bundle/resources/postgres_databases/update/output.txt new file mode 100644 index 00000000000..6f0b139ceed --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/output.txt @@ -0,0 +1,153 @@ + +=== Initial deployment +>>> [CLI] bundle validate +Name: update-postgres-database-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default + +Validation OK! + +>>> [CLI] bundle plan +create postgres_branches.main +create postgres_databases.my_database +create postgres_projects.my_project + +Plan: 3 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] postgres get-database [MY_DATABASE_ID] +{ + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + } +} + +=== Verify no changes +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 3 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +=== Update postgres_database and re-deploy +>>> update_file.py databricks.yml postgres_database: initial_db_name postgres_database: renamed_db_name + +>>> cat databricks.yml +bundle: + name: update-postgres-database-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Database Update" + pg_version: 16 + history_retention_duration: 604800s + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_databases: + my_database: + database_id: my-database + parent: ${resources.postgres_branches.main.id} + postgres_database: renamed_db_name + +>>> [CLI] bundle plan +update postgres_databases.my_database + +Plan: 0 to add, 1 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] postgres get-database [MY_DATABASE_ID] +{ + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "postgres_database": "renamed_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + } +} + +=== Restore postgres_database to original value +>>> update_file.py databricks.yml postgres_database: renamed_db_name postgres_database: initial_db_name + +>>> [CLI] bundle plan +update postgres_databases.my_database + +Plan: 0 to add, 1 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] postgres get-database [MY_DATABASE_ID] +{ + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_databases.my_database + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_databases/update/script b/acceptance/bundle/resources/postgres_databases/update/script new file mode 100644 index 00000000000..488c1719f1b --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/script @@ -0,0 +1,61 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +print_requests() { + local name=$1 + trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.${name}.$DATABRICKS_BUNDLE_ENGINE.json + rm -f out.requests.txt +} + +title "Initial deployment" +trace $CLI bundle validate +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_databases.my_database" // .' > out.plan.create.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests create + +project_name="projects/test-pg-proj-${UNIQUE_NAME}" +branch_name="${project_name}/branches/main" +database_name="${branch_name}/databases/my-database" +database_id_1=`read_id.py my_database` +trace $CLI postgres get-database "${database_name}" | jq 'del(.create_time, .update_time)' + +title "Verify no changes" +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_databases.my_database" // .' > out.plan.no_change.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests no_change + +title "Update postgres_database and re-deploy" +trace update_file.py databricks.yml "postgres_database: initial_db_name" "postgres_database: renamed_db_name" +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_databases.my_database" // .' > out.plan.update.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests update + +database_id_2=`read_id.py my_database` +trace $CLI postgres get-database "${database_name}" | jq 'del(.create_time, .update_time)' + +title "Restore postgres_database to original value" +trace update_file.py databricks.yml "postgres_database: renamed_db_name" "postgres_database: initial_db_name" +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_databases.my_database" // .' > out.plan.restore.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests restore + +trace $CLI postgres get-database "${database_name}" | jq 'del(.create_time, .update_time)' diff --git a/acceptance/bundle/resources/postgres_databases/update/test.toml b/acceptance/bundle/resources/postgres_databases/update/test.toml new file mode 100644 index 00000000000..f8b3bbe49dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/test.toml @@ -0,0 +1 @@ +# All configuration inherited from parent test.toml From 539c522b038bea2373b2c411b584dcc3b7d5538d Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 07:49:25 +0200 Subject: [PATCH 14/41] acc: cover postgres_databases with invariant configs Co-authored-by: Isaac --- .../configs/postgres_database.yml.tmpl | 20 +++++++++++++++++++ .../invariant/continue_293/out.test.toml | 1 + .../bundle/invariant/migrate/out.test.toml | 1 + .../bundle/invariant/no_drift/out.test.toml | 1 + acceptance/bundle/invariant/test.toml | 2 ++ 5 files changed, 25 insertions(+) create mode 100644 acceptance/bundle/invariant/configs/postgres_database.yml.tmpl diff --git a/acceptance/bundle/invariant/configs/postgres_database.yml.tmpl b/acceptance/bundle/invariant/configs/postgres_database.yml.tmpl new file mode 100644 index 00000000000..5364c28f41a --- /dev/null +++ b/acceptance/bundle/invariant/configs/postgres_database.yml.tmpl @@ -0,0 +1,20 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + postgres_projects: + project: + project_id: test-pg-project-$UNIQUE_NAME + display_name: Test Postgres Project + + postgres_branches: + branch: + parent: ${resources.postgres_projects.project.name} + branch_id: test-branch-$UNIQUE_NAME + no_expiry: true + + postgres_databases: + foo: + parent: ${resources.postgres_branches.branch.name} + database_id: test-database-$UNIQUE_NAME + postgres_database: app_db diff --git a/acceptance/bundle/invariant/continue_293/out.test.toml b/acceptance/bundle/invariant/continue_293/out.test.toml index 11aaf584918..2c4db891c9a 100644 --- a/acceptance/bundle/invariant/continue_293/out.test.toml +++ b/acceptance/bundle/invariant/continue_293/out.test.toml @@ -26,6 +26,7 @@ EnvMatrix.INPUT_CONFIG = [ "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", + "postgres_database.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", diff --git a/acceptance/bundle/invariant/migrate/out.test.toml b/acceptance/bundle/invariant/migrate/out.test.toml index 11aaf584918..2c4db891c9a 100644 --- a/acceptance/bundle/invariant/migrate/out.test.toml +++ b/acceptance/bundle/invariant/migrate/out.test.toml @@ -26,6 +26,7 @@ EnvMatrix.INPUT_CONFIG = [ "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", + "postgres_database.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index 11aaf584918..2c4db891c9a 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -26,6 +26,7 @@ EnvMatrix.INPUT_CONFIG = [ "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", + "postgres_database.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", diff --git a/acceptance/bundle/invariant/test.toml b/acceptance/bundle/invariant/test.toml index 257e33005a3..b889e0e1a9c 100644 --- a/acceptance/bundle/invariant/test.toml +++ b/acceptance/bundle/invariant/test.toml @@ -44,6 +44,7 @@ EnvMatrix.INPUT_CONFIG = [ "pipeline.yml.tmpl", "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", + "postgres_database.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "registered_model.yml.tmpl", @@ -66,6 +67,7 @@ no_alert_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=alert.yml.tmpl"] # Postgres resources only work on AWS no_postgres_project_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_project.yml.tmpl"] no_postgres_branch_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_branch.yml.tmpl"] +no_postgres_database_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_database.yml.tmpl"] no_postgres_endpoint_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_endpoint.yml.tmpl"] # External locations require actual storage credentials with cloud IAM setup From b8bc8dffa31886a14661fa219c648f1bd3e99f3e Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 07:51:15 +0200 Subject: [PATCH 15/41] changelog: postgres_databases bundle resource Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index fa2adeb1e8a..64b2259846c 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -10,5 +10,6 @@ ### Bundles * Make sure warnings asking for approval are understood by agents ([#5239](https://github.com/databricks/cli/pull/5239)) +* Add support for `postgres_databases` bundle resource. ### Dependency updates From 3dfe94e0eb4a5fe0030951672c35b804bba7e1bf Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 07:55:18 +0200 Subject: [PATCH 16/41] dresources: lint and comment polish for postgres_databases Add exhaustruct ForceSendFields zero on CreateDatabaseRequest and align the resources.yml comment with sibling phrasing. Co-authored-by: Isaac --- bundle/direct/dresources/postgres_database.go | 1 + 1 file changed, 1 insertion(+) diff --git a/bundle/direct/dresources/postgres_database.go b/bundle/direct/dresources/postgres_database.go index 1705631b1cb..e6a27f92d71 100644 --- a/bundle/direct/dresources/postgres_database.go +++ b/bundle/direct/dresources/postgres_database.go @@ -66,6 +66,7 @@ func (r *ResourcePostgresDatabase) DoCreate(ctx context.Context, config *Postgre UpdateTime: nil, ForceSendFields: nil, }, + ForceSendFields: nil, }) if err != nil { return "", nil, err From d7b0056f43e21a4419ed661e8d74f6d75d8b7afe Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 07:55:46 +0200 Subject: [PATCH 17/41] dresources: align postgres_databases comment with sibling phrasing Co-authored-by: Isaac --- bundle/direct/dresources/resources.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 7e099059063..6eb9a642da5 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -502,7 +502,7 @@ resources: postgres_databases: recreate_on_changes: - # parent and database_id are part of the hierarchical name and immutable. + # parent and database_id are immutable (part of hierarchical name, not in API spec) - field: parent reason: immutable - field: database_id From 36c29a73d5032ea6d2e62ed1b0f088a1829ea529 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 11:23:39 +0200 Subject: [PATCH 18/41] bundle: add postgres_roles as a resource type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DAB support for Lakebase Postgres roles, mirroring the existing postgres_databases resource. The state holds role_id and parent separately (so bundle variable references resolve), and RemapState recovers role_id from remote.Name via a local strings.TrimPrefix — no shared parser helper. recreate_on_changes fires on either field since both are part of the immutable hierarchical name. Also fixes collectUpdatePathsWithPrefix to drop a parent path when a more specific child path is present; the real Postgres API rejects an update_mask that contains both (e.g. spec.attributes plus spec.attributes.createdb), expecting all sibling fields when the parent is named. Tested end-to-end against AWS prod (basic, recreate, update, bind) as well as the invariant suite. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + .../bind/postgres_role/databricks.yml | 9 + .../bind/postgres_role/out.test.toml | 3 + .../deployment/bind/postgres_role/output.txt | 32 +++ .../deployment/bind/postgres_role/script | 6 + .../deployment/bind/postgres_role/test.toml | 18 ++ .../invariant/configs/postgres_role.yml.tmpl | 20 ++ .../invariant/continue_293/out.test.toml | 1 + .../bundle/invariant/migrate/out.test.toml | 1 + .../bundle/invariant/no_drift/out.test.toml | 1 + acceptance/bundle/invariant/test.toml | 2 + acceptance/bundle/refschema/out.fields.txt | 40 ++++ .../postgres_roles/basic/databricks.yml.tmpl | 27 +++ .../basic/out.requests.direct.json | 45 ++++ .../basic/out.requests.terraform.json | 47 ++++ .../postgres_roles/basic/out.test.toml | 6 + .../resources/postgres_roles/basic/output.txt | 93 ++++++++ .../resources/postgres_roles/basic/script | 25 ++ .../resources/postgres_roles/basic/test.toml | 1 + .../recreate/databricks.yml.tmpl | 25 ++ .../postgres_roles/recreate/out.test.toml | 6 + .../postgres_roles/recreate/output.txt | 165 ++++++++++++++ .../resources/postgres_roles/recreate/script | 50 ++++ .../postgres_roles/recreate/test.toml | 1 + .../bundle/resources/postgres_roles/test.toml | 39 ++++ .../postgres_roles/update/databricks.yml.tmpl | 27 +++ .../update/out.plan.create.direct.json | 20 ++ .../update/out.plan.create.terraform.json | 3 + .../update/out.plan.no_change.direct.json | 58 +++++ .../update/out.plan.no_change.terraform.json | 3 + .../update/out.plan.restore.direct.json | 70 ++++++ .../update/out.plan.restore.terraform.json | 3 + .../update/out.plan.update.direct.json | 70 ++++++ .../update/out.plan.update.terraform.json | 3 + .../update/out.requests.create.direct.json | 41 ++++ .../update/out.requests.create.terraform.json | 43 ++++ .../update/out.requests.no_change.direct.json | 20 ++ .../out.requests.no_change.terraform.json | 12 + .../update/out.requests.restore.direct.json | 35 +++ .../out.requests.restore.terraform.json | 29 +++ .../update/out.requests.update.direct.json | 35 +++ .../update/out.requests.update.terraform.json | 29 +++ .../postgres_roles/update/out.test.toml | 6 + .../postgres_roles/update/output.txt | 176 ++++++++++++++ .../resources/postgres_roles/update/script | 59 +++++ .../resources/postgres_roles/update/test.toml | 1 + .../apply_bundle_permissions_test.go | 1 + .../resourcemutator/apply_target_mode_test.go | 11 + .../mutator/resourcemutator/run_as_test.go | 2 + bundle/config/resources.go | 3 + bundle/config/resources/postgres_role.go | 67 ++++++ bundle/config/resources_test.go | 12 + bundle/deploy/terraform/interpolate.go | 2 +- bundle/deploy/terraform/pkg.go | 1 + .../terraform/tfdyn/convert_postgres_role.go | 63 +++++ .../tfdyn/convert_postgres_role_test.go | 81 +++++++ bundle/deploy/terraform/util.go | 2 +- bundle/direct/dresources/all.go | 1 + bundle/direct/dresources/all_test.go | 36 +++ .../direct/dresources/apitypes.generated.yml | 2 + bundle/direct/dresources/apitypes.yml | 2 + bundle/direct/dresources/postgres_role.go | 151 ++++++++++++ .../direct/dresources/resources.generated.yml | 14 ++ bundle/direct/dresources/resources.yml | 8 + bundle/direct/dresources/type_test.go | 11 + bundle/direct/dresources/util.go | 21 +- bundle/internal/schema/annotations.yml | 28 +++ .../validation/generated/required_fields.go | 2 + bundle/schema/jsonschema.json | 112 +++++++++ bundle/schema/jsonschema_for_docs.json | 170 +++++++++++--- bundle/statemgmt/state_load_test.go | 38 ++++ libs/testserver/fake_workspace.go | 2 + libs/testserver/handlers.go | 32 +++ libs/testserver/postgres.go | 215 +++++++++++++++++- libs/testserver/postgres_test.go | 116 ++++++++++ 75 files changed, 2578 insertions(+), 35 deletions(-) create mode 100644 acceptance/bundle/deployment/bind/postgres_role/databricks.yml create mode 100644 acceptance/bundle/deployment/bind/postgres_role/out.test.toml create mode 100644 acceptance/bundle/deployment/bind/postgres_role/output.txt create mode 100644 acceptance/bundle/deployment/bind/postgres_role/script create mode 100644 acceptance/bundle/deployment/bind/postgres_role/test.toml create mode 100644 acceptance/bundle/invariant/configs/postgres_role.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_roles/basic/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_roles/basic/out.requests.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/basic/out.requests.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/basic/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/basic/output.txt create mode 100644 acceptance/bundle/resources/postgres_roles/basic/script create mode 100644 acceptance/bundle/resources/postgres_roles/basic/test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/recreate/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_roles/recreate/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/recreate/output.txt create mode 100644 acceptance/bundle/resources/postgres_roles/recreate/script create mode 100644 acceptance/bundle/resources/postgres_roles/recreate/test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/update/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.create.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.create.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.restore.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.update.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.create.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.create.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.restore.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.restore.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.update.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.update.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/update/output.txt create mode 100644 acceptance/bundle/resources/postgres_roles/update/script create mode 100644 acceptance/bundle/resources/postgres_roles/update/test.toml create mode 100644 bundle/config/resources/postgres_role.go create mode 100644 bundle/deploy/terraform/tfdyn/convert_postgres_role.go create mode 100644 bundle/deploy/terraform/tfdyn/convert_postgres_role_test.go create mode 100644 bundle/direct/dresources/postgres_role.go diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index fa2adeb1e8a..ebafccd5415 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -10,5 +10,6 @@ ### Bundles * Make sure warnings asking for approval are understood by agents ([#5239](https://github.com/databricks/cli/pull/5239)) +* Add Postgres role as a bundle resource (preview). ### Dependency updates diff --git a/acceptance/bundle/deployment/bind/postgres_role/databricks.yml b/acceptance/bundle/deployment/bind/postgres_role/databricks.yml new file mode 100644 index 00000000000..b7c555794e6 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_role/databricks.yml @@ -0,0 +1,9 @@ +bundle: + name: test-bundle + +resources: + postgres_roles: + role1: + parent: projects/test-project/branches/main + role_id: test-role + postgres_role: app_role diff --git a/acceptance/bundle/deployment/bind/postgres_role/out.test.toml b/acceptance/bundle/deployment/bind/postgres_role/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_role/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/deployment/bind/postgres_role/output.txt b/acceptance/bundle/deployment/bind/postgres_role/output.txt new file mode 100644 index 00000000000..b439a090ee5 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_role/output.txt @@ -0,0 +1,32 @@ + +>>> [CLI] bundle deployment bind role1 projects/test-project/branches/main/roles/test-role --auto-approve +Updating deployment state... +Successfully bound postgres_role with an id 'projects/test-project/branches/main/roles/test-role' +Run 'bundle deploy' to deploy changes to your workspace + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + Postgres roles: + role1: + Name: + URL: (not deployed) + +>>> [CLI] bundle deployment unbind role1 +Updating deployment state... + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + Postgres roles: + role1: + Name: + URL: (not deployed) diff --git a/acceptance/bundle/deployment/bind/postgres_role/script b/acceptance/bundle/deployment/bind/postgres_role/script new file mode 100644 index 00000000000..547aadd944f --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_role/script @@ -0,0 +1,6 @@ +ROLE_NAME="projects/test-project/branches/main/roles/test-role" +trace $CLI bundle deployment bind role1 "${ROLE_NAME}" --auto-approve +trace $CLI bundle summary + +trace $CLI bundle deployment unbind role1 +trace $CLI bundle summary diff --git a/acceptance/bundle/deployment/bind/postgres_role/test.toml b/acceptance/bundle/deployment/bind/postgres_role/test.toml new file mode 100644 index 00000000000..5113676039f --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_role/test.toml @@ -0,0 +1,18 @@ +Local = true +Cloud = false + +Ignore = [ + ".databricks" +] + +[[Server]] +Pattern = "GET /api/2.0/postgres/projects/test-project/branches/main/roles/test-role" +Response.Body = ''' +{ + "name": "projects/test-project/branches/main/roles/test-role", + "parent": "projects/test-project/branches/main", + "status": { + "postgres_role": "app_role" + } +} +''' diff --git a/acceptance/bundle/invariant/configs/postgres_role.yml.tmpl b/acceptance/bundle/invariant/configs/postgres_role.yml.tmpl new file mode 100644 index 00000000000..9c4aba0b5b8 --- /dev/null +++ b/acceptance/bundle/invariant/configs/postgres_role.yml.tmpl @@ -0,0 +1,20 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + postgres_projects: + project: + project_id: test-pg-project-$UNIQUE_NAME + display_name: Test Postgres Project + + postgres_branches: + branch: + parent: ${resources.postgres_projects.project.name} + branch_id: test-branch-$UNIQUE_NAME + no_expiry: true + + postgres_roles: + foo: + parent: ${resources.postgres_branches.branch.name} + role_id: test-role-$UNIQUE_NAME + postgres_role: app_role diff --git a/acceptance/bundle/invariant/continue_293/out.test.toml b/acceptance/bundle/invariant/continue_293/out.test.toml index 11aaf584918..5653de3f28e 100644 --- a/acceptance/bundle/invariant/continue_293/out.test.toml +++ b/acceptance/bundle/invariant/continue_293/out.test.toml @@ -28,6 +28,7 @@ EnvMatrix.INPUT_CONFIG = [ "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", + "postgres_role.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", diff --git a/acceptance/bundle/invariant/migrate/out.test.toml b/acceptance/bundle/invariant/migrate/out.test.toml index 11aaf584918..5653de3f28e 100644 --- a/acceptance/bundle/invariant/migrate/out.test.toml +++ b/acceptance/bundle/invariant/migrate/out.test.toml @@ -28,6 +28,7 @@ EnvMatrix.INPUT_CONFIG = [ "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", + "postgres_role.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index 11aaf584918..5653de3f28e 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -28,6 +28,7 @@ EnvMatrix.INPUT_CONFIG = [ "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", + "postgres_role.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", diff --git a/acceptance/bundle/invariant/test.toml b/acceptance/bundle/invariant/test.toml index 257e33005a3..a95fa1854ef 100644 --- a/acceptance/bundle/invariant/test.toml +++ b/acceptance/bundle/invariant/test.toml @@ -46,6 +46,7 @@ EnvMatrix.INPUT_CONFIG = [ "postgres_branch.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", + "postgres_role.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", @@ -67,6 +68,7 @@ no_alert_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=alert.yml.tmpl"] no_postgres_project_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_project.yml.tmpl"] no_postgres_branch_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_branch.yml.tmpl"] no_postgres_endpoint_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_endpoint.yml.tmpl"] +no_postgres_role_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_role.yml.tmpl"] # External locations require actual storage credentials with cloud IAM setup # which are environment-specific, so we only test locally with the mock server diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 999296b2139..46f824a7282 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -2870,6 +2870,46 @@ resources.postgres_projects.*.permissions[*].group_name string ALL resources.postgres_projects.*.permissions[*].level iam.PermissionLevel ALL resources.postgres_projects.*.permissions[*].service_principal_name string ALL resources.postgres_projects.*.permissions[*].user_name string ALL +resources.postgres_roles.*.attributes *postgres.RoleAttributes INPUT STATE +resources.postgres_roles.*.attributes.bypassrls bool INPUT STATE +resources.postgres_roles.*.attributes.createdb bool INPUT STATE +resources.postgres_roles.*.attributes.createrole bool INPUT STATE +resources.postgres_roles.*.auth_method postgres.RoleAuthMethod INPUT STATE +resources.postgres_roles.*.create_time *time.Time REMOTE +resources.postgres_roles.*.id string INPUT +resources.postgres_roles.*.identity_type postgres.RoleIdentityType INPUT STATE +resources.postgres_roles.*.lifecycle resources.Lifecycle INPUT +resources.postgres_roles.*.lifecycle.prevent_destroy bool INPUT +resources.postgres_roles.*.membership_roles []postgres.RoleMembershipRole INPUT STATE +resources.postgres_roles.*.membership_roles[*] postgres.RoleMembershipRole INPUT STATE +resources.postgres_roles.*.modified_status string INPUT +resources.postgres_roles.*.name string REMOTE +resources.postgres_roles.*.parent string ALL +resources.postgres_roles.*.postgres_role string INPUT STATE +resources.postgres_roles.*.role_id string INPUT STATE +resources.postgres_roles.*.spec *postgres.RoleRoleSpec REMOTE +resources.postgres_roles.*.spec.attributes *postgres.RoleAttributes REMOTE +resources.postgres_roles.*.spec.attributes.bypassrls bool REMOTE +resources.postgres_roles.*.spec.attributes.createdb bool REMOTE +resources.postgres_roles.*.spec.attributes.createrole bool REMOTE +resources.postgres_roles.*.spec.auth_method postgres.RoleAuthMethod REMOTE +resources.postgres_roles.*.spec.identity_type postgres.RoleIdentityType REMOTE +resources.postgres_roles.*.spec.membership_roles []postgres.RoleMembershipRole REMOTE +resources.postgres_roles.*.spec.membership_roles[*] postgres.RoleMembershipRole REMOTE +resources.postgres_roles.*.spec.postgres_role string REMOTE +resources.postgres_roles.*.status *postgres.RoleRoleStatus REMOTE +resources.postgres_roles.*.status.attributes *postgres.RoleAttributes REMOTE +resources.postgres_roles.*.status.attributes.bypassrls bool REMOTE +resources.postgres_roles.*.status.attributes.createdb bool REMOTE +resources.postgres_roles.*.status.attributes.createrole bool REMOTE +resources.postgres_roles.*.status.auth_method postgres.RoleAuthMethod REMOTE +resources.postgres_roles.*.status.identity_type postgres.RoleIdentityType REMOTE +resources.postgres_roles.*.status.membership_roles []postgres.RoleMembershipRole REMOTE +resources.postgres_roles.*.status.membership_roles[*] postgres.RoleMembershipRole REMOTE +resources.postgres_roles.*.status.postgres_role string REMOTE +resources.postgres_roles.*.status.role_id string REMOTE +resources.postgres_roles.*.update_time *time.Time REMOTE +resources.postgres_roles.*.url string INPUT resources.quality_monitors.*.assets_dir string ALL resources.quality_monitors.*.baseline_table_name string ALL resources.quality_monitors.*.custom_metrics []catalog.MonitorMetric ALL diff --git a/acceptance/bundle/resources/postgres_roles/basic/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_roles/basic/databricks.yml.tmpl new file mode 100644 index 00000000000..bba8e0d7d94 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/databricks.yml.tmpl @@ -0,0 +1,27 @@ +bundle: + name: deploy-postgres-role-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Role" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role + postgres_role: app_role + attributes: + createdb: true diff --git a/acceptance/bundle/resources/postgres_roles/basic/out.requests.direct.json b/acceptance/bundle/resources/postgres_roles/basic/out.requests.direct.json new file mode 100644 index 00000000000..67bf2b4cc52 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/out.requests.direct.json @@ -0,0 +1,45 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Role", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "test-role" + }, + "body": { + "spec": { + "attributes": { + "createdb": true + }, + "postgres_role": "app_role" + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} diff --git a/acceptance/bundle/resources/postgres_roles/basic/out.requests.terraform.json b/acceptance/bundle/resources/postgres_roles/basic/out.requests.terraform.json new file mode 100644 index 00000000000..ad5c2ec0d03 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/out.requests.terraform.json @@ -0,0 +1,47 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Role", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "test-role" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "attributes": { + "createdb": true + }, + "postgres_role": "app_role" + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} diff --git a/acceptance/bundle/resources/postgres_roles/basic/out.test.toml b/acceptance/bundle/resources/postgres_roles/basic/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/postgres_roles/basic/output.txt b/acceptance/bundle/resources/postgres_roles/basic/output.txt new file mode 100644 index 00000000000..28fe1344203 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/output.txt @@ -0,0 +1,93 @@ + +>>> [CLI] bundle validate +Name: deploy-postgres-role-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-[UNIQUE_NAME]/default + +Validation OK! + +>>> [CLI] bundle summary +Name: deploy-postgres-role-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-[UNIQUE_NAME]/default +Resources: + Postgres branches: + main: + Name: + URL: (not deployed) + Postgres projects: + my_project: + Name: Test Project for Role + URL: (not deployed) + Postgres roles: + my_role: + Name: + URL: (not deployed) + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] postgres get-role projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": true, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + } +} + +>>> [CLI] bundle summary +Name: deploy-postgres-role-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-[UNIQUE_NAME]/default +Resources: + Postgres branches: + main: + Name: + URL: (not deployed) + Postgres projects: + my_project: + Name: Test Project for Role + URL: (not deployed) + Postgres roles: + my_role: + Name: + URL: (not deployed) + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + delete resources.postgres_roles.my_role + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_roles/basic/script b/acceptance/bundle/resources/postgres_roles/basic/script new file mode 100644 index 00000000000..831c5ecb3e3 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/script @@ -0,0 +1,25 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +trace $CLI bundle validate + +trace $CLI bundle summary + +rm -f out.requests.txt +trace $CLI bundle deploy + +# Get role details +project_name="projects/test-pg-proj-${UNIQUE_NAME}" +branch_name="${project_name}/branches/main" +role_name="${branch_name}/roles/test-role" +trace $CLI postgres get-role "${role_name}" | jq 'del(.create_time, .update_time)' + +trace $CLI bundle summary + +# Filter requests to only show postgres operations (exclude workspace, telemetry, and operation polling) +trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/postgres_roles/basic/test.toml b/acceptance/bundle/resources/postgres_roles/basic/test.toml new file mode 100644 index 00000000000..f8b3bbe49dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/test.toml @@ -0,0 +1 @@ +# All configuration inherited from parent test.toml diff --git a/acceptance/bundle/resources/postgres_roles/recreate/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_roles/recreate/databricks.yml.tmpl new file mode 100644 index 00000000000..2daa730fbef --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate/databricks.yml.tmpl @@ -0,0 +1,25 @@ +bundle: + name: deploy-postgres-role-recreate-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Role" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: ROLE_ID_PLACEHOLDER + postgres_role: app_role diff --git a/acceptance/bundle/resources/postgres_roles/recreate/out.test.toml b/acceptance/bundle/resources/postgres_roles/recreate/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/postgres_roles/recreate/output.txt b/acceptance/bundle/resources/postgres_roles/recreate/output.txt new file mode 100644 index 00000000000..bff2a3cf4dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate/output.txt @@ -0,0 +1,165 @@ + +>>> cat databricks.yml +bundle: + name: deploy-postgres-role-recreate-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Role" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role-[UNIQUE_NAME] + postgres_role: app_role + +>>> [CLI] bundle plan +create postgres_branches.main +create postgres_projects.my_project +create postgres_roles.my_role + +Plan: 3 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-recreate-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests +{ + "body": { + "spec": { + "display_name": "Test Project for Role", + "history_retention_duration": "604800s", + "pg_version": 16 + } + }, + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + } +} + "no_expiry": true + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "branch_id": "main" + "postgres_role": "app_role" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "role_id": "test-role-[UNIQUE_NAME]" + +>>> cat databricks.yml +bundle: + name: deploy-postgres-role-recreate-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Role" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role-[UNIQUE_NAME]-v2 + postgres_role: app_role + +>>> [CLI] bundle plan +recreate postgres_roles.my_role + +Plan: 1 to add, 0 to change, 1 to delete, 2 unchanged + +>>> [CLI] bundle deploy --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-recreate-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests +{ + "method": "DELETE", + "path": "/api/2.0/postgres/[MY_ROLE_ID]" +} + "body": { + "spec": { + "postgres_role": "app_role" + } + }, + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "test-role-[UNIQUE_NAME]-v2" + } + +=== Fetch role and verify it exists after recreation +>>> [CLI] postgres get-role [MY_ROLE_ID]-v2 +{ + "name": "[MY_ROLE_ID]-v2", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": false, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role-[UNIQUE_NAME]-v2" + } +} + +=== Destroy and verify cleanup +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + delete resources.postgres_roles.my_role + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-recreate-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! + +>>> print_requests +{ + "method": "DELETE", + "path": "/api/2.0/postgres/[MY_ROLE_ID]-v2" +} + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" + +>>> [CLI] bundle destroy --auto-approve +No active deployment found to destroy! diff --git a/acceptance/bundle/resources/postgres_roles/recreate/script b/acceptance/bundle/resources/postgres_roles/recreate/script new file mode 100644 index 00000000000..9bc72f3ccd4 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate/script @@ -0,0 +1,50 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + + # Best-effort cleanup if a deploy left a role behind under either id. + $CLI postgres delete-role "projects/test-pg-proj-${UNIQUE_NAME}/branches/main/roles/test-role-${UNIQUE_NAME}" 2>/dev/null || true + $CLI postgres delete-role "projects/test-pg-proj-${UNIQUE_NAME}/branches/main/roles/test-role-${UNIQUE_NAME}-v2" 2>/dev/null || true + + rm -f out.requests.txt +} +trap cleanup EXIT + +# Deploy with first role_id +envsubst < databricks.yml.tmpl | sed "s/ROLE_ID_PLACEHOLDER/test-role-${UNIQUE_NAME}/" > databricks.yml + +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle deploy + +role_id_1=`read_id.py my_role` + +print_requests() { + # Filter postgres requests (excluding GET), remove parent field (differs between engines), + # then deduplicate consecutive retries + jq --sort-keys 'select(.method != "GET" and (.path | contains("/postgres"))) | del(.body.parent)' < out.requests.txt | \ + awk '!seen[$0]++ {print}' + rm -f out.requests.txt +} + +trace print_requests + +# Change role_id (encoded in name); should trigger recreation. +envsubst < databricks.yml.tmpl | sed "s/ROLE_ID_PLACEHOLDER/test-role-${UNIQUE_NAME}-v2/" > databricks.yml + +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle deploy --auto-approve + +trace print_requests + +title "Fetch role and verify it exists after recreation" + +role_id_2=`read_id.py my_role` +trace $CLI postgres get-role $role_id_2 | jq 'del(.create_time, .update_time)' + +title "Destroy and verify cleanup" +trace $CLI bundle destroy --auto-approve + +trace print_requests diff --git a/acceptance/bundle/resources/postgres_roles/recreate/test.toml b/acceptance/bundle/resources/postgres_roles/recreate/test.toml new file mode 100644 index 00000000000..f8b3bbe49dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate/test.toml @@ -0,0 +1 @@ +# All configuration inherited from parent test.toml diff --git a/acceptance/bundle/resources/postgres_roles/test.toml b/acceptance/bundle/resources/postgres_roles/test.toml new file mode 100644 index 00000000000..1bae7718330 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/test.toml @@ -0,0 +1,39 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true + +# Lakebase v2 (postgres) is only available in AWS as of January 2026 +CloudEnvs.gcp = false +CloudEnvs.azure = false + +# Run on both direct and Terraform modes +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] + +Ignore = [ + "databricks.yml", + ".databricks", +] + +[[Repls]] +# Clean up ?o= suffix after URL since not all workspaces have that +Old = '\?o=\[(NUMID|ALPHANUMID)\]' +New = '' +Order = 1000 + +[[Repls]] +# Normalize branch UIDs (br-xxx-yyy-zzz format, supports both word-based and hex-based UIDs) +Old = 'br-[a-z0-9-]+' +New = '[BRANCH_UID]' +Order = 1 + +[[Repls]] +# Normalize project UIDs (proj-xxx-yyy-zzz format) +Old = 'proj-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]' +New = '[PROJECT_UID]' +Order = 1 + +[[Repls]] +# Normalize LSN values (format: 0/HEXVALUE or 0/0) +Old = '"source_branch_lsn": "0/[A-F0-9]+"' +New = '"source_branch_lsn": "[LSN]"' +Order = 1 diff --git a/acceptance/bundle/resources/postgres_roles/update/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_roles/update/databricks.yml.tmpl new file mode 100644 index 00000000000..f0ca4e48870 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/databricks.yml.tmpl @@ -0,0 +1,27 @@ +bundle: + name: update-postgres-role-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Role Update" + pg_version: 16 + history_retention_duration: 604800s + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role + postgres_role: app_role + attributes: + createdb: false diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.create.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.create.direct.json new file mode 100644 index 00000000000..498c1d25124 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.create.direct.json @@ -0,0 +1,20 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + } + ], + "action": "create", + "new_state": { + "value": { + "attributes": { + "createdb": false + }, + "postgres_role": "app_role" + }, + "vars": { + "parent": "${resources.postgres_branches.main.id}" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.create.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.create.terraform.json new file mode 100644 index 00000000000..b4425b9687c --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.create.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "create" +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json new file mode 100644 index 00000000000..3772eeef349 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json @@ -0,0 +1,58 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + } + ], + "action": "skip", + "remote_state": { + "create_time": "[TIMESTAMP]", + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": false, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + }, + "update_time": "[TIMESTAMP]" + }, + "changes": { + "attributes": { + "action": "skip", + "reason": "spec:input_only", + "old": { + "createdb": false + }, + "new": { + "createdb": false + } + }, + "parent": { + "action": "skip", + "reason": "remote_already_set", + "old": "", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "remote": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main" + }, + "postgres_role": { + "action": "skip", + "reason": "spec:input_only", + "old": "app_role", + "new": "app_role" + }, + "role_id": { + "action": "skip", + "reason": "remote_already_set", + "old": "", + "new": "test-role", + "remote": "test-role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.terraform.json new file mode 100644 index 00000000000..b2f23a5015f --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "skip" +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json new file mode 100644 index 00000000000..647ce8e7991 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json @@ -0,0 +1,70 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + } + ], + "action": "update", + "new_state": { + "value": { + "attributes": { + "createdb": false + }, + "postgres_role": "app_role" + } + }, + "remote_state": { + "create_time": "[TIMESTAMP]", + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": true, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + }, + "update_time": "[TIMESTAMP]" + }, + "changes": { + "attributes": { + "action": "update", + "old": { + "createdb": true + }, + "new": { + "createdb": false + } + }, + "attributes.createdb": { + "action": "update", + "old": true, + "new": false + }, + "parent": { + "action": "skip", + "reason": "remote_already_set", + "old": "", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "remote": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main" + }, + "postgres_role": { + "action": "skip", + "reason": "spec:input_only", + "old": "app_role", + "new": "app_role" + }, + "role_id": { + "action": "skip", + "reason": "remote_already_set", + "old": "", + "new": "test-role", + "remote": "test-role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.terraform.json new file mode 100644 index 00000000000..375b496fef0 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "update" +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json new file mode 100644 index 00000000000..7917cf60e44 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json @@ -0,0 +1,70 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + } + ], + "action": "update", + "new_state": { + "value": { + "attributes": { + "createdb": true + }, + "postgres_role": "app_role" + } + }, + "remote_state": { + "create_time": "[TIMESTAMP]", + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": false, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + }, + "update_time": "[TIMESTAMP]" + }, + "changes": { + "attributes": { + "action": "update", + "old": { + "createdb": false + }, + "new": { + "createdb": true + } + }, + "attributes.createdb": { + "action": "update", + "old": false, + "new": true + }, + "parent": { + "action": "skip", + "reason": "remote_already_set", + "old": "", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "remote": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main" + }, + "postgres_role": { + "action": "skip", + "reason": "spec:input_only", + "old": "app_role", + "new": "app_role" + }, + "role_id": { + "action": "skip", + "reason": "remote_already_set", + "old": "", + "new": "test-role", + "remote": "test-role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.update.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.update.terraform.json new file mode 100644 index 00000000000..375b496fef0 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.update.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "update" +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.create.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.create.direct.json new file mode 100644 index 00000000000..b102e4bb50a --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.create.direct.json @@ -0,0 +1,41 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Role Update", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "test-role" + }, + "body": { + "spec": { + "attributes": { + "createdb": false + }, + "postgres_role": "app_role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.create.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.create.terraform.json new file mode 100644 index 00000000000..0a6bbfc61da --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.create.terraform.json @@ -0,0 +1,43 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Role Update", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "test-role" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "attributes": { + "createdb": false + }, + "postgres_role": "app_role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.direct.json new file mode 100644 index 00000000000..1d23ba72202 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.direct.json @@ -0,0 +1,20 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.terraform.json new file mode 100644 index 00000000000..4296a1779c8 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.terraform.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.restore.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.restore.direct.json new file mode 100644 index 00000000000..198552d19ed --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.restore.direct.json @@ -0,0 +1,35 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "q": { + "update_mask": "spec.attributes.createdb" + }, + "body": { + "spec": { + "attributes": { + "createdb": false + }, + "postgres_role": "app_role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.restore.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.restore.terraform.json new file mode 100644 index 00000000000..d62792a95a0 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.restore.terraform.json @@ -0,0 +1,29 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "q": { + "update_mask": "spec" + }, + "body": { + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "attributes": { + "createdb": false + }, + "postgres_role": "app_role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.update.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.update.direct.json new file mode 100644 index 00000000000..cdf033ce54f --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.update.direct.json @@ -0,0 +1,35 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "q": { + "update_mask": "spec.attributes.createdb" + }, + "body": { + "spec": { + "attributes": { + "createdb": true + }, + "postgres_role": "app_role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.update.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.update.terraform.json new file mode 100644 index 00000000000..3f56f596530 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.update.terraform.json @@ -0,0 +1,29 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "q": { + "update_mask": "spec" + }, + "body": { + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "attributes": { + "createdb": true + }, + "postgres_role": "app_role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.test.toml b/acceptance/bundle/resources/postgres_roles/update/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/postgres_roles/update/output.txt b/acceptance/bundle/resources/postgres_roles/update/output.txt new file mode 100644 index 00000000000..beefa368ce4 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/output.txt @@ -0,0 +1,176 @@ + +=== Initial deployment +>>> [CLI] bundle validate +Name: update-postgres-role-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/update-postgres-role-[UNIQUE_NAME]/default + +Validation OK! + +>>> [CLI] bundle plan +create postgres_branches.main +create postgres_projects.my_project +create postgres_roles.my_role + +Plan: 3 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] postgres get-role projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": false, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + } +} + +=== Verify no changes +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 3 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +=== Toggle attributes.createdb and re-deploy +>>> update_file.py databricks.yml createdb: false createdb: true + +>>> cat databricks.yml +bundle: + name: update-postgres-role-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Role Update" + pg_version: 16 + history_retention_duration: 604800s + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role + postgres_role: app_role + attributes: + createdb: true + +>>> [CLI] bundle plan +update postgres_roles.my_role + +Plan: 0 to add, 1 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] postgres get-role projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": true, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + } +} + +=== Restore attributes.createdb to original value +>>> update_file.py databricks.yml createdb: true createdb: false + +>>> [CLI] bundle plan +update postgres_roles.my_role + +Plan: 0 to add, 1 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] postgres get-role projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": false, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + delete resources.postgres_roles.my_role + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/update-postgres-role-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_roles/update/script b/acceptance/bundle/resources/postgres_roles/update/script new file mode 100644 index 00000000000..de57297e70b --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/script @@ -0,0 +1,59 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +print_requests() { + local name=$1 + trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.${name}.$DATABRICKS_BUNDLE_ENGINE.json + rm -f out.requests.txt +} + +title "Initial deployment" +trace $CLI bundle validate +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_roles.my_role" // .' > out.plan.create.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests create + +project_name="projects/test-pg-proj-${UNIQUE_NAME}" +branch_name="${project_name}/branches/main" +role_name="${branch_name}/roles/test-role" +trace $CLI postgres get-role "${role_name}" | jq 'del(.create_time, .update_time)' + +title "Verify no changes" +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_roles.my_role" // .' > out.plan.no_change.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests no_change + +title "Toggle attributes.createdb and re-deploy" +trace update_file.py databricks.yml "createdb: false" "createdb: true" +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_roles.my_role" // .' > out.plan.update.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests update + +trace $CLI postgres get-role "${role_name}" | jq 'del(.create_time, .update_time)' + +title "Restore attributes.createdb to original value" +trace update_file.py databricks.yml "createdb: true" "createdb: false" +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_roles.my_role" // .' > out.plan.restore.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests restore + +trace $CLI postgres get-role "${role_name}" | jq 'del(.create_time, .update_time)' diff --git a/acceptance/bundle/resources/postgres_roles/update/test.toml b/acceptance/bundle/resources/postgres_roles/update/test.toml new file mode 100644 index 00000000000..f8b3bbe49dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/test.toml @@ -0,0 +1 @@ +# All configuration inherited from parent test.toml diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index e472241f282..35dfef29f4f 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -28,6 +28,7 @@ var unsupportedResources = []string{ "synced_database_tables", "postgres_branches", "postgres_endpoints", + "postgres_roles", } func TestApplyBundlePermissions(t *testing.T) { diff --git a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go index fe9c9a1db06..39dde987824 100644 --- a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go +++ b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go @@ -247,6 +247,17 @@ func mockBundle(mode config.Mode) *bundle.Bundle { }, }, }, + PostgresRoles: map[string]*resources.PostgresRole{ + "postgres_role1": { + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "postgres-role-1", + Parent: "projects/postgres-project-1/branches/postgres-branch-1", + RoleRoleSpec: postgres.RoleRoleSpec{ + PostgresRole: "postgres_role_1", + }, + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "vs_endpoint1": { CreateEndpoint: vectorsearch.CreateEndpoint{ diff --git a/bundle/config/mutator/resourcemutator/run_as_test.go b/bundle/config/mutator/resourcemutator/run_as_test.go index 0b7003f5873..500839c7acb 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -48,6 +48,7 @@ func allResourceTypes(t *testing.T) []string { "postgres_branches", "postgres_endpoints", "postgres_projects", + "postgres_roles", "quality_monitors", "registered_models", "schemas", @@ -176,6 +177,7 @@ var allowList = []string{ "postgres_branches", "postgres_endpoints", "postgres_projects", + "postgres_roles", "registered_models", "experiments", "schemas", diff --git a/bundle/config/resources.go b/bundle/config/resources.go index 225ec32165d..db76aaa98e8 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -35,6 +35,7 @@ type Resources struct { PostgresProjects map[string]*resources.PostgresProject `json:"postgres_projects,omitempty"` PostgresBranches map[string]*resources.PostgresBranch `json:"postgres_branches,omitempty"` PostgresEndpoints map[string]*resources.PostgresEndpoint `json:"postgres_endpoints,omitempty"` + PostgresRoles map[string]*resources.PostgresRole `json:"postgres_roles,omitempty"` VectorSearchEndpoints map[string]*resources.VectorSearchEndpoint `json:"vector_search_endpoints,omitempty"` } @@ -112,6 +113,7 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["postgres_projects"], r.PostgresProjects), collectResourceMap(descriptions["postgres_branches"], r.PostgresBranches), collectResourceMap(descriptions["postgres_endpoints"], r.PostgresEndpoints), + collectResourceMap(descriptions["postgres_roles"], r.PostgresRoles), collectResourceMap(descriptions["vector_search_endpoints"], r.VectorSearchEndpoints), } } @@ -167,6 +169,7 @@ func SupportedResources() map[string]resources.ResourceDescription { "postgres_projects": (&resources.PostgresProject{}).ResourceDescription(), "postgres_branches": (&resources.PostgresBranch{}).ResourceDescription(), "postgres_endpoints": (&resources.PostgresEndpoint{}).ResourceDescription(), + "postgres_roles": (&resources.PostgresRole{}).ResourceDescription(), "vector_search_endpoints": (&resources.VectorSearchEndpoint{}).ResourceDescription(), } } diff --git a/bundle/config/resources/postgres_role.go b/bundle/config/resources/postgres_role.go new file mode 100644 index 00000000000..d8641565548 --- /dev/null +++ b/bundle/config/resources/postgres_role.go @@ -0,0 +1,67 @@ +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/postgres" +) + +type PostgresRoleConfig struct { + postgres.RoleRoleSpec + + // RoleId is the user-specified ID for the role (becomes part of the hierarchical name). + // This is specified during creation and becomes part of Name: "projects/{project_id}/branches/{branch_id}/roles/{role_id}" + RoleId string `json:"role_id"` + + // Parent is the branch containing this role. Format: "projects/{project_id}/branches/{branch_id}" + Parent string `json:"parent"` +} + +func (c *PostgresRoleConfig) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, c) +} + +func (c *PostgresRoleConfig) MarshalJSON() ([]byte, error) { + return marshal.Marshal(c) +} + +type PostgresRole struct { + BaseResource + PostgresRoleConfig +} + +func (r *PostgresRole) Exists(ctx context.Context, w *databricks.WorkspaceClient, name string) (bool, error) { + _, err := w.Postgres.GetRole(ctx, postgres.GetRoleRequest{Name: name}) + if err != nil { + log.Debugf(ctx, "postgres role %s does not exist", name) + return false, err + } + return true, nil +} + +func (r *PostgresRole) ResourceDescription() ResourceDescription { + return ResourceDescription{ + SingularName: "postgres_role", + PluralName: "postgres_roles", + SingularTitle: "Postgres role", + PluralTitle: "Postgres roles", + } +} + +func (r *PostgresRole) GetName() string { + // Roles don't have a user-visible name field. + return "" +} + +func (r *PostgresRole) GetURL() string { + // The IDs in the API do not (yet) map to IDs in the web UI. + return "" +} + +func (r *PostgresRole) InitializeURL(_ url.URL) { + // The IDs in the API do not (yet) map to IDs in the web UI. +} diff --git a/bundle/config/resources_test.go b/bundle/config/resources_test.go index 943b279a288..cd6008e3998 100644 --- a/bundle/config/resources_test.go +++ b/bundle/config/resources_test.go @@ -273,6 +273,17 @@ func TestResourcesBindSupport(t *testing.T) { }, }, }, + PostgresRoles: map[string]*resources.PostgresRole{ + "my_postgres_role": { + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "my-postgres-role", + Parent: "projects/my-postgres-project/branches/my-postgres-branch", + RoleRoleSpec: postgres.RoleRoleSpec{ + PostgresRole: "my_postgres_role", + }, + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "my_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -312,6 +323,7 @@ func TestResourcesBindSupport(t *testing.T) { m.GetMockPostgresAPI().EXPECT().GetProject(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetBranch(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetEndpoint(mock.Anything, mock.Anything).Return(nil, nil) + m.GetMockPostgresAPI().EXPECT().GetRole(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockVectorSearchEndpointsAPI().EXPECT().GetEndpoint(mock.Anything, mock.Anything).Return(nil, nil) allResources := supportedResources.AllResources() diff --git a/bundle/deploy/terraform/interpolate.go b/bundle/deploy/terraform/interpolate.go index fdcb671bdd3..1178389ff5b 100644 --- a/bundle/deploy/terraform/interpolate.go +++ b/bundle/deploy/terraform/interpolate.go @@ -16,7 +16,7 @@ type interpolateMutator struct{} // Postgres resources use "name" instead of "id" as their identifier attribute. func isPostgresResource(resourceType string) bool { switch resourceType { - case "postgres_projects", "postgres_branches", "postgres_endpoints": + case "postgres_projects", "postgres_branches", "postgres_endpoints", "postgres_roles": return true default: return false diff --git a/bundle/deploy/terraform/pkg.go b/bundle/deploy/terraform/pkg.go index a66e5cb6a06..7df54bf4496 100644 --- a/bundle/deploy/terraform/pkg.go +++ b/bundle/deploy/terraform/pkg.go @@ -129,6 +129,7 @@ var GroupToTerraformName = map[string]string{ "postgres_projects": "databricks_postgres_project", "postgres_branches": "databricks_postgres_branch", "postgres_endpoints": "databricks_postgres_endpoint", + "postgres_roles": "databricks_postgres_role", // 3 level groups: resources.*.GROUP "permissions": "databricks_permissions", diff --git a/bundle/deploy/terraform/tfdyn/convert_postgres_role.go b/bundle/deploy/terraform/tfdyn/convert_postgres_role.go new file mode 100644 index 00000000000..d1a2449f22e --- /dev/null +++ b/bundle/deploy/terraform/tfdyn/convert_postgres_role.go @@ -0,0 +1,63 @@ +package tfdyn + +import ( + "context" + + "github.com/databricks/cli/bundle/internal/tf/schema" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/convert" + "github.com/databricks/cli/libs/log" +) + +type postgresRoleConverter struct{} + +func (c postgresRoleConverter) Convert(ctx context.Context, key string, vin dyn.Value, out *schema.Resources) error { + // The bundle config has flattened RoleRoleSpec fields at the top level. + // Terraform expects them nested in a "spec" block. + specFields := specFieldNames(schema.ResourcePostgresRoleSpec{}) + topLevelFields := []string{"role_id", "parent"} + + // Build the spec block from the flattened fields + specMap := make(map[string]dyn.Value) + for _, field := range specFields { + if v := vin.Get(field); v.Kind() != dyn.KindInvalid { + specMap[field] = v + } + } + + // Build the output with top-level fields and spec + outMap := make(map[string]dyn.Value) + + // Keep top-level fields + for _, field := range topLevelFields { + if v := vin.Get(field); v.Kind() != dyn.KindInvalid { + outMap[field] = v + } + } + + // Add spec block if we have any spec fields + if len(specMap) > 0 { + outMap["spec"] = dyn.V(specMap) + } + + vout := dyn.V(outMap) + + // Normalize the output value to the Terraform schema. + vout, diags := convert.Normalize(schema.ResourcePostgresRole{}, vout) + for _, diag := range diags { + log.Debugf(ctx, "postgres role normalization diagnostic: %s", diag.Summary) + } + + vout, err := convertLifecycle(ctx, vout, vin.Get("lifecycle")) + if err != nil { + return err + } + + out.PostgresRole[key] = vout.AsAny() + + return nil +} + +func init() { + registerConverter("postgres_roles", postgresRoleConverter{}) +} diff --git a/bundle/deploy/terraform/tfdyn/convert_postgres_role_test.go b/bundle/deploy/terraform/tfdyn/convert_postgres_role_test.go new file mode 100644 index 00000000000..2fd11988ec0 --- /dev/null +++ b/bundle/deploy/terraform/tfdyn/convert_postgres_role_test.go @@ -0,0 +1,81 @@ +package tfdyn + +import ( + "testing" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/bundle/internal/tf/schema" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/convert" + "github.com/databricks/databricks-sdk-go/service/postgres" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertPostgresRole(t *testing.T) { + src := resources.PostgresRole{ + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "my-role", + Parent: "projects/my-project/branches/main", + RoleRoleSpec: postgres.RoleRoleSpec{ + PostgresRole: "my_postgres_role", + IdentityType: postgres.RoleIdentityTypeUser, + AuthMethod: postgres.RoleAuthMethodLakebaseOauthV1, + Attributes: &postgres.RoleAttributes{ + Createdb: true, + }, + }, + }, + } + + vin, err := convert.FromTyped(src, dyn.NilValue) + require.NoError(t, err) + + ctx := t.Context() + out := schema.NewResources() + err = postgresRoleConverter{}.Convert(ctx, "my_postgres_role", vin, out) + require.NoError(t, err) + + postgresRole := out.PostgresRole["my_postgres_role"] + assert.Equal(t, map[string]any{ + "role_id": "my-role", + "parent": "projects/my-project/branches/main", + "spec": map[string]any{ + "postgres_role": "my_postgres_role", + "identity_type": "USER", + "auth_method": "LAKEBASE_OAUTH_V1", + "attributes": map[string]any{ + "createdb": true, + }, + }, + }, postgresRole) +} + +func TestConvertPostgresRoleMinimal(t *testing.T) { + src := resources.PostgresRole{ + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "minimal-role", + Parent: "projects/my-project/branches/main", + RoleRoleSpec: postgres.RoleRoleSpec{ + PostgresRole: "minimal_role", + }, + }, + } + + vin, err := convert.FromTyped(src, dyn.NilValue) + require.NoError(t, err) + + ctx := t.Context() + out := schema.NewResources() + err = postgresRoleConverter{}.Convert(ctx, "minimal_postgres_role", vin, out) + require.NoError(t, err) + + postgresRole := out.PostgresRole["minimal_postgres_role"] + assert.Equal(t, map[string]any{ + "role_id": "minimal-role", + "parent": "projects/my-project/branches/main", + "spec": map[string]any{ + "postgres_role": "minimal_role", + }, + }, postgresRole) +} diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index 632d32bca19..5a5158fdd3e 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -96,7 +96,7 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap // The direct engine manages permissions as a sub-resource // (SecretScopeFixups adds MANAGE ACL for the current user). result[resourceKey+".permissions"] = ResourceState{ID: instance.Attributes.Name} - case "apps", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_endpoints": + case "apps", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_endpoints", "postgres_roles": resourceKey = "resources." + groupName + "." + resource.Name resourceState = ResourceState{ID: instance.Attributes.Name} case "dashboards": diff --git a/bundle/direct/dresources/all.go b/bundle/direct/dresources/all.go index ddc30c41f54..6d9788a1363 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -23,6 +23,7 @@ var SupportedResources = map[string]any{ "postgres_projects": (*ResourcePostgresProject)(nil), "postgres_branches": (*ResourcePostgresBranch)(nil), "postgres_endpoints": (*ResourcePostgresEndpoint)(nil), + "postgres_roles": (*ResourcePostgresRole)(nil), "alerts": (*ResourceAlert)(nil), "clusters": (*ResourceCluster)(nil), "registered_models": (*ResourceRegisteredModel)(nil), diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 2c0a2e52f22..fe5f1f69541 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -669,6 +669,42 @@ var testDeps = map[string]prepareWorkspace{ }, }, nil }, + + "postgres_roles": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { + // Create parent project first + _, err := client.Postgres.CreateProject(ctx, postgres.CreateProjectRequest{ + ProjectId: "test-project-for-role", + Project: postgres.Project{ + Spec: &postgres.ProjectSpec{ + DisplayName: "Test Project for Role", + PgVersion: 16, + }, + }, + }) + if err != nil { + return nil, err + } + + // Create parent branch + _, err = client.Postgres.CreateBranch(ctx, postgres.CreateBranchRequest{ + Parent: "projects/test-project-for-role", + BranchId: "test-branch-for-role", + Branch: postgres.Branch{}, + }) + if err != nil { + return nil, err + } + + return &resources.PostgresRole{ + PostgresRoleConfig: resources.PostgresRoleConfig{ + Parent: "projects/test-project-for-role/branches/test-branch-for-role", + RoleId: "test-role", + RoleRoleSpec: postgres.RoleRoleSpec{ + PostgresRole: "test_role", + }, + }, + }, nil + }, } func TestAll(t *testing.T) { diff --git a/bundle/direct/dresources/apitypes.generated.yml b/bundle/direct/dresources/apitypes.generated.yml index a80b3baa69b..5c2eafbebc9 100644 --- a/bundle/direct/dresources/apitypes.generated.yml +++ b/bundle/direct/dresources/apitypes.generated.yml @@ -32,6 +32,8 @@ postgres_endpoints: postgres.EndpointSpec postgres_projects: postgres.ProjectStatus +postgres_roles: postgres.RoleRoleStatus + quality_monitors: catalog.CreateMonitor registered_models: catalog.RegisteredModelInfo diff --git a/bundle/direct/dresources/apitypes.yml b/bundle/direct/dresources/apitypes.yml index 29db9b67b20..5f038adecc6 100644 --- a/bundle/direct/dresources/apitypes.yml +++ b/bundle/direct/dresources/apitypes.yml @@ -9,3 +9,5 @@ postgres_branches: postgres.BranchSpec postgres_endpoints: postgres.EndpointSpec postgres_projects: postgres.ProjectSpec + +postgres_roles: postgres.RoleRoleSpec diff --git a/bundle/direct/dresources/postgres_role.go b/bundle/direct/dresources/postgres_role.go new file mode 100644 index 00000000000..2a7d03f6e57 --- /dev/null +++ b/bundle/direct/dresources/postgres_role.go @@ -0,0 +1,151 @@ +// Postgres Role resource for the direct deployment engine. +// +// Terraform resource: databricks_postgres_role +// +// https://registry.terraform.io/providers/databricks/databricks/latest/docs/resources/postgres_role +// +// REST API: Lakebase Postgres Roles +// +// https://docs.databricks.com/api/workspace/postgres/createrole +package dresources + +import ( + "context" + "strings" + + "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/postgres" +) + +type ResourcePostgresRole struct { + client *databricks.WorkspaceClient +} + +// PostgresRoleState keeps role_id and parent as separate fields rather than a +// pre-joined hierarchical name. That alignment matters because bundle variable +// resolution only rewrites state fields whose JSON paths appear in the input +// config's refs map (parent, role_id, etc.); a synthesized "name" field built +// from input.Parent at PrepareState time would keep the literal ${...} string +// when parent comes from a resource reference. +type PostgresRoleState struct { + postgres.RoleRoleSpec + + // RoleId is the leaf id, matching the user-facing config. + RoleId string `json:"role_id"` + + // Parent is "projects/{project_id}/branches/{branch_id}". + Parent string `json:"parent"` +} + +func (*ResourcePostgresRole) New(client *databricks.WorkspaceClient) *ResourcePostgresRole { + return &ResourcePostgresRole{client: client} +} + +func (*ResourcePostgresRole) PrepareState(input *resources.PostgresRole) *PostgresRoleState { + return &PostgresRoleState{ + RoleId: input.RoleId, + Parent: input.Parent, + RoleRoleSpec: input.RoleRoleSpec, + } +} + +func (*ResourcePostgresRole) RemapState(remote *postgres.Role) *PostgresRoleState { + return &PostgresRoleState{ + // Derive role_id from the hierarchical name: "/roles/". + RoleId: strings.TrimPrefix(remote.Name, remote.Parent+"/roles/"), + Parent: remote.Parent, + + // The read API does not return the spec, only the status. + // This means we cannot detect remote drift for spec fields. + // Use an empty struct (not nil) so field-level diffing works correctly. + RoleRoleSpec: postgres.RoleRoleSpec{ + Attributes: nil, + AuthMethod: "", + IdentityType: "", + MembershipRoles: nil, + PostgresRole: "", + ForceSendFields: nil, + }, + } +} + +func (r *ResourcePostgresRole) DoRead(ctx context.Context, id string) (*postgres.Role, error) { + return r.client.Postgres.GetRole(ctx, postgres.GetRoleRequest{Name: id}) +} + +func (r *ResourcePostgresRole) DoCreate(ctx context.Context, config *PostgresRoleState) (string, *postgres.Role, error) { + waiter, err := r.client.Postgres.CreateRole(ctx, postgres.CreateRoleRequest{ + RoleId: config.RoleId, + Parent: config.Parent, + Role: postgres.Role{ + Spec: &config.RoleRoleSpec, + + // Output-only fields. + CreateTime: nil, + Name: "", + Parent: "", + Status: nil, + UpdateTime: nil, + ForceSendFields: nil, + }, + ForceSendFields: nil, + }) + if err != nil { + return "", nil, err + } + + result, err := waiter.Wait(ctx) + if err != nil { + return "", nil, err + } + + return result.Name, result, nil +} + +func (r *ResourcePostgresRole) DoUpdate(ctx context.Context, id string, config *PostgresRoleState, entry *PlanEntry) (*postgres.Role, error) { + // Build update mask from fields that have action="update" in the changes map. + // Prefix with "spec." because the API expects paths relative to the Role + // object, not relative to our flattened state type. + fieldPaths := collectUpdatePathsWithPrefix(entry.Changes, "spec.") + + waiter, err := r.client.Postgres.UpdateRole(ctx, postgres.UpdateRoleRequest{ + Name: id, + Role: postgres.Role{ + Spec: &config.RoleRoleSpec, + + // Output-only fields. + CreateTime: nil, + Name: "", + Parent: "", + Status: nil, + UpdateTime: nil, + ForceSendFields: nil, + }, + UpdateMask: fieldmask.FieldMask{ + Paths: fieldPaths, + }, + }) + if err != nil { + return nil, err + } + + return waiter.Wait(ctx) +} + +func (r *ResourcePostgresRole) DoDelete(ctx context.Context, id string) error { + waiter, err := r.client.Postgres.DeleteRole(ctx, postgres.DeleteRoleRequest{ + Name: id, + + // ReassignOwnedTo is intentionally unset; honoring it would require + // user-facing config we don't expose, and it would spin up compute to + // run reassignment SQL. + ReassignOwnedTo: "", + ForceSendFields: nil, + }) + if err != nil { + return err + } + return waiter.Wait(ctx) +} diff --git a/bundle/direct/dresources/resources.generated.yml b/bundle/direct/dresources/resources.generated.yml index 85c15d6f343..af6ee3f8429 100644 --- a/bundle/direct/dresources/resources.generated.yml +++ b/bundle/direct/dresources/resources.generated.yml @@ -256,6 +256,20 @@ resources: - field: pg_version reason: spec:input_only + postgres_roles: + + ignore_remote_changes: + - field: attributes + reason: spec:input_only + - field: auth_method + reason: spec:input_only + - field: identity_type + reason: spec:input_only + - field: membership_roles + reason: spec:input_only + - field: postgres_role + reason: spec:input_only + # quality_monitors: no api field behaviors # registered_models: no api field behaviors diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 569fca9ee82..c637c7f4efe 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -508,6 +508,14 @@ resources: - field: endpoint_id reason: immutable + postgres_roles: + recreate_on_changes: + # parent and role_id are immutable (together they form the hierarchical name). + - field: parent + reason: immutable + - field: role_id + reason: immutable + vector_search_endpoints: recreate_on_changes: - field: endpoint_type diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 88f246723bf..76ee6f46429 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -75,6 +75,17 @@ var knownMissingInRemoteType = map[string][]string{ "pg_version", "project_id", }, + "postgres_roles": { + // RoleRoleSpec fields live under spec.* on the remote Role, not at top level. + "attributes", + "auth_method", + "identity_type", + "membership_roles", + "postgres_role", + // role_id is the leaf id derived from the hierarchical name; the + // remote Role only exposes the full Name. + "role_id", + }, "vector_search_endpoints": { "target_qps", "usage_policy_id", diff --git a/bundle/direct/dresources/util.go b/bundle/direct/dresources/util.go index 3bd0ab4ec73..bb4a7ff88e1 100644 --- a/bundle/direct/dresources/util.go +++ b/bundle/direct/dresources/util.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "regexp" + "strings" "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/libs/structs/structpath" @@ -51,10 +52,28 @@ func shouldRetry(err error) bool { // collectUpdatePathsWithPrefix extracts field paths from Changes that have action=Update, // adding a prefix to each path. This is used when the state type has a flattened structure // but the API expects paths relative to a nested object (e.g., "spec.display_name"). +// +// Parent paths are dropped when a more specific child path is also present, because +// servers typically reject an update_mask that contains both a parent and a child (the +// parent implies the whole subtree must be provided). E.g. {"attributes", +// "attributes.createdb"} collapses to {"attributes.createdb"}. func collectUpdatePathsWithPrefix(changes Changes, prefix string) []string { var paths []string for path, change := range changes { - if change.Action == deployplan.Update { + if change.Action != deployplan.Update { + continue + } + hasChild := false + for other := range changes { + if other == path || changes[other].Action != deployplan.Update { + continue + } + if strings.HasPrefix(other, path+".") { + hasChild = true + break + } + } + if !hasChild { paths = append(paths, prefix+path) } } diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index f6ac5c45d4d..6b6183b8d2d 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -221,6 +221,9 @@ github.com/databricks/cli/bundle/config.Resources: "postgres_projects": "description": |- PLACEHOLDER + "postgres_roles": + "description": |- + PLACEHOLDER "quality_monitors": "description": |- The quality monitor definitions for the bundle, where each key is the name of the quality monitor. @@ -897,6 +900,31 @@ github.com/databricks/cli/bundle/config/resources.PostgresProject: "update_time": "description": |- PLACEHOLDER +github.com/databricks/cli/bundle/config/resources.PostgresRole: + "attributes": + "description": |- + The desired API-exposed Postgres role attributes to associate with the role. + "auth_method": + "description": |- + How the role is authenticated when connecting to Postgres. If left unspecified, a meaningful authentication method is derived from the identity_type. + "identity_type": + "description": |- + The type of the Databricks managed identity that this Role represents. Leave empty to create a regular Postgres role not associated with a Databricks identity. + "lifecycle": + "description": |- + PLACEHOLDER + "membership_roles": + "description": |- + Standard roles that this role is a member of. + "parent": + "description": |- + The branch where this role is created. Format projects/{project_id}/branches/{branch_id}. + "postgres_role": + "description": |- + The name of the Postgres role. Required when creating the role. + "role_id": + "description": |- + The user-specified role ID; becomes the final component of the role's resource name. Must be 4-63 characters, lowercase letters, numbers, and hyphens (RFC 1123). github.com/databricks/cli/bundle/config/resources.SecretScope: "backend_type": "description": |- diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index db86398accb..19eab280208 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -225,6 +225,8 @@ var RequiredFields = map[string][]string{ "resources.postgres_projects.*": {"project_id"}, "resources.postgres_projects.*.permissions[*]": {"level"}, + "resources.postgres_roles.*": {"parent"}, + "resources.quality_monitors.*": {"assets_dir", "output_schema_name", "table_name"}, "resources.quality_monitors.*.custom_metrics[*]": {"definition", "input_columns", "name", "output_data_type", "type"}, "resources.quality_monitors.*.inference_log": {"granularities", "model_id_col", "prediction_col", "problem_type", "timestamp_col"}, diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 26e773f64a4..59236e26e26 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1511,6 +1511,55 @@ } ] }, + "resources.PostgresRole": { + "oneOf": [ + { + "type": "object", + "properties": { + "attributes": { + "description": "The desired API-exposed Postgres role attributes to associate with the role.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleAttributes" + }, + "auth_method": { + "description": "How the role is authenticated when connecting to Postgres. If left unspecified, a meaningful authentication method is derived from the identity_type.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleAuthMethod" + }, + "identity_type": { + "description": "The type of the Databricks managed identity that this Role represents. Leave empty to create a regular Postgres role not associated with a Databricks identity.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleIdentityType" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "membership_roles": { + "description": "Standard roles that this role is a member of.", + "$ref": "#/$defs/slice/github.com/databricks/databricks-sdk-go/service/postgres.RoleMembershipRole" + }, + "parent": { + "description": "The branch where this role is created. Format projects/{project_id}/branches/{branch_id}.", + "$ref": "#/$defs/string" + }, + "postgres_role": { + "description": "The name of the Postgres role. Required when creating the role.", + "$ref": "#/$defs/string" + }, + "role_id": { + "description": "The user-specified role ID; becomes the final component of the role's resource name. Must be 4-63 characters, lowercase letters, numbers, and hyphens (RFC 1123).", + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "role_id", + "parent" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.QualityMonitor": { "oneOf": [ { @@ -2528,6 +2577,9 @@ "postgres_projects": { "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresProject" }, + "postgres_roles": { + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresRole" + }, "quality_monitors": { "description": "The quality monitor definitions for the bundle, where each key is the name of the quality monitor.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.QualityMonitor", @@ -9877,6 +9929,38 @@ } ] }, + "postgres.RoleAttributes": { + "oneOf": [ + { + "type": "object", + "properties": { + "bypassrls": { + "$ref": "#/$defs/bool" + }, + "createdb": { + "$ref": "#/$defs/bool" + }, + "createrole": { + "$ref": "#/$defs/bool" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "postgres.RoleAuthMethod": { + "type": "string" + }, + "postgres.RoleIdentityType": { + "type": "string" + }, + "postgres.RoleMembershipRole": { + "type": "string" + }, "serving.Ai21LabsConfig": { "oneOf": [ { @@ -11772,6 +11856,20 @@ } ] }, + "resources.PostgresRole": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresRole" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.QualityMonitor": { "oneOf": [ { @@ -12629,6 +12727,20 @@ } ] }, + "postgres.RoleMembershipRole": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleMembershipRole" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "serving.AiGatewayRateLimit": { "oneOf": [ { diff --git a/bundle/schema/jsonschema_for_docs.json b/bundle/schema/jsonschema_for_docs.json index 0748cf84e47..2514234eacb 100644 --- a/bundle/schema/jsonschema_for_docs.json +++ b/bundle/schema/jsonschema_for_docs.json @@ -1499,6 +1499,47 @@ "project_id" ] }, + "resources.PostgresRole": { + "type": "object", + "properties": { + "attributes": { + "description": "The desired API-exposed Postgres role attributes to associate with the role.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleAttributes" + }, + "auth_method": { + "description": "How the role is authenticated when connecting to Postgres. If left unspecified, a meaningful authentication method is derived from the identity_type.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleAuthMethod" + }, + "identity_type": { + "description": "The type of the Databricks managed identity that this Role represents. Leave empty to create a regular Postgres role not associated with a Databricks identity.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleIdentityType" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "membership_roles": { + "description": "Standard roles that this role is a member of.", + "$ref": "#/$defs/slice/github.com/databricks/databricks-sdk-go/service/postgres.RoleMembershipRole" + }, + "parent": { + "description": "The branch where this role is created. Format projects/{project_id}/branches/{branch_id}.", + "$ref": "#/$defs/string" + }, + "postgres_role": { + "description": "The name of the Postgres role. Required when creating the role.", + "$ref": "#/$defs/string" + }, + "role_id": { + "description": "The user-specified role ID; becomes the final component of the role's resource name. Must be 4-63 characters, lowercase letters, numbers, and hyphens (RFC 1123).", + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "role_id", + "parent" + ] + }, "resources.QualityMonitor": { "type": "object", "properties": { @@ -1942,7 +1983,8 @@ "x-since-version": "v0.298.0" }, "target_qps": { - "$ref": "#/$defs/int64" + "$ref": "#/$defs/int64", + "x-since-version": "v0.299.2" }, "usage_policy_id": { "$ref": "#/$defs/string", @@ -2494,6 +2536,9 @@ "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresProject", "x-since-version": "v0.287.0" }, + "postgres_roles": { + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresRole" + }, "quality_monitors": { "description": "The quality monitor definitions for the bundle, where each key is the name of the quality monitor.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.QualityMonitor", @@ -4286,7 +4331,8 @@ "description": "The confidential computing technology for this cluster's instances.\nCurrently only SEV_SNP is supported, and only on N2D instance types.\nWhen not set, no confidential computing is applied.", "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/compute.ConfidentialComputeType", "x-databricks-preview": "PRIVATE", - "doNotSuggest": true + "doNotSuggest": true, + "x-since-version": "v0.299.2" }, "first_on_demand": { "description": "The first `first_on_demand` nodes of the cluster will be placed on on-demand instances.\nThis value should be greater than 0, to make sure the cluster driver node is placed on an\non-demand instance. If this value is greater than or equal to the current cluster size, all\nnodes will be placed on on-demand instances. If this value is less than the current cluster\nsize, `first_on_demand` nodes will be placed on on-demand instances and the remainder will\nbe placed on `availability` instances. Note that this value does not affect\ncluster size and cannot currently be mutated over the lifetime of a cluster.", @@ -6759,7 +6805,8 @@ "properties": { "include_confluence_spaces": { "description": "(Optional) Spaces to filter Confluence data on", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -6783,7 +6830,8 @@ "properties": { "confluence_options": { "description": "Confluence specific options for ingestion", - "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.ConfluenceConnectorOptions" + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.ConfluenceConnectorOptions", + "x-since-version": "v0.299.2" }, "gdrive_options": { "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.GoogleDriveOptions", @@ -6800,17 +6848,20 @@ }, "jira_options": { "description": "Jira specific options for ingestion", - "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.JiraConnectorOptions" + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.JiraConnectorOptions", + "x-since-version": "v0.299.2" }, "meta_ads_options": { "description": "Meta Marketing (Meta Ads) specific options for ingestion", - "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.MetaMarketingOptions" + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.MetaMarketingOptions", + "x-since-version": "v0.299.2" }, "outlook_options": { "description": "Outlook specific options for ingestion", "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.OutlookOptions", "x-databricks-preview": "PRIVATE", - "doNotSuggest": true + "doNotSuggest": true, + "x-since-version": "v0.299.2" }, "sharepoint_options": { "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.SharepointOptions", @@ -6822,7 +6873,8 @@ "description": "Smartsheet specific options for ingestion", "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.SmartsheetOptions", "x-databricks-preview": "PRIVATE", - "doNotSuggest": true + "doNotSuggest": true, + "x-since-version": "v0.299.2" }, "tiktok_ads_options": { "description": "TikTok Ads specific options for ingestion", @@ -6835,7 +6887,8 @@ "description": "Zendesk Support specific options for ingestion", "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.ZendeskSupportOptions", "x-databricks-preview": "PRIVATE", - "doNotSuggest": true + "doNotSuggest": true, + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -7063,7 +7116,8 @@ "properties": { "manager_account_id": { "description": "(Required) Manager Account ID (also called MCC Account ID) used to list and access\ncustomer accounts under this manager account. This is required for fetching the list\nof customer accounts during source selection.\nIf the same field is also set in the object-level GoogleAdsOptions (connector_options),\nthe object-level value takes precedence over this top-level config.", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -7422,7 +7476,8 @@ "properties": { "include_jira_spaces": { "description": "(Optional) Projects to filter Jira data on", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -7437,35 +7492,43 @@ "properties": { "action_attribution_windows": { "description": "(Optional) Action attribution windows for insights reporting (e.g. \"28d_click\", \"1d_view\")", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "action_breakdowns": { "description": "(Optional) Action breakdowns to configure for data aggregation", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "action_report_time": { "description": "(Optional) Timing used to report action statistics (impression, conversion, mixed, or lifetime)", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" }, "breakdowns": { "description": "(Optional) Breakdowns to configure for data aggregation", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "custom_insights_lookback_window": { "description": "(Optional) Window in days to revisit data during sync to capture\nupdated conversion data from the API.", - "$ref": "#/$defs/int" + "$ref": "#/$defs/int", + "x-since-version": "v0.299.2" }, "level": { "description": "(Optional) Granularity of data to pull (account, ad, adset, campaign)", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" }, "start_date": { "description": "(Optional) Start date in yyyy-MM-dd format (e.g. 2025-01-15). Data added\nafter this date will be ingested", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" }, "time_increment": { "description": "(Optional) Value in string by which to aggregate statistics (can take all_days, monthly or number of days)", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -7546,48 +7609,58 @@ "properties": { "attachment_mode": { "description": "(Optional) Controls which attachments to ingest.\nIf not specified, defaults to ALL.", - "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.OutlookAttachmentMode" + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.OutlookAttachmentMode", + "x-since-version": "v0.299.2" }, "body_format": { "description": "(Optional) Defines how the body_content column is populated.\nTEXT_HTML: Preserves full formatting, links, and styling.\nTEXT_PLAIN: Converts body to plain text. Recommended for AI/RAG pipelines to reduce token usage and noise.", - "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.OutlookBodyFormat" + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.OutlookBodyFormat", + "x-since-version": "v0.299.2" }, "folder_filter": { "description": "Deprecated. Use include_folders instead.", "$ref": "#/$defs/slice/string", "deprecationMessage": "This field is deprecated", + "x-since-version": "v0.299.2", "deprecated": true }, "include_folders": { "description": "(Optional) Filter mail folders to include in the sync.\nIf not specified, all folders will be synced.\nExamples: Inbox, Sent Items, Custom_Folder\nFilter semantics: OR between different folders.", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "include_mailboxes": { "description": "(Optional) List of mailboxes to sync (e.g. mailbox email addresses or identifiers).\nIf not specified, all accessible mailboxes are ingested.\nFilter semantics: OR between different mailboxes.", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "include_senders": { "description": "(Optional) Filter emails by sender address. Uses exact email match.\nExamples: user@vendor.com, alerts@system.io, noreply@company.com\nIf not specified, emails from all senders will be synced.\nFilter semantics: OR between different senders.", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "include_subjects": { "description": "(Optional) Filter emails by subject line. Values ending with \"*\" use prefix match (subject starts with\nthe part before \"*\"); otherwise substring match (subject contains the value).\nExamples: \"Invoice\" (substring), \"Re:*\" (prefix), \"Support Ticket\", \"URGENT*\"\nIf not specified, emails with all subjects will be synced.\nFilter semantics: OR between different subjects.", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "sender_filter": { "description": "Deprecated. Use include_senders instead.", "$ref": "#/$defs/slice/string", "deprecationMessage": "This field is deprecated", + "x-since-version": "v0.299.2", "deprecated": true }, "start_date": { "description": "(Optional) Start date for the initial sync in YYYY-MM-DD format.\nFormat: YYYY-MM-DD (e.g., 2024-01-01)\nThis determines the earliest date from which to sync historical data.\nIf not specified, complete history is ingested.", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" }, "subject_filter": { "description": "Deprecated. Use include_subjects instead.", "$ref": "#/$defs/slice/string", "deprecationMessage": "This field is deprecated", + "x-since-version": "v0.299.2", "deprecated": true } }, @@ -8025,7 +8098,8 @@ "properties": { "enforce_schema": { "description": "(Optional) When true, maps each column to its Smartsheet-declared type (Text/Number/Date/\nCheckbox/etc.). Cells that do not conform to the declared type are set to NULL.\nWhen false, all columns land as STRING. Use false for sheets with irregular data or columns\nthat frequently violate their own declared type.\nIf not specified, defaults to true.", - "$ref": "#/$defs/bool" + "$ref": "#/$defs/bool", + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -8058,7 +8132,8 @@ "google_ads_config": { "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.GoogleAdsConfig", "x-databricks-preview": "PRIVATE", - "doNotSuggest": true + "doNotSuggest": true, + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -8252,7 +8327,8 @@ "properties": { "start_date": { "description": "(Optional) Start date in YYYY-MM-DD format for the initial sync.\nThis determines the earliest date from which to sync historical data.", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -8350,6 +8426,30 @@ }, "additionalProperties": false }, + "postgres.RoleAttributes": { + "type": "object", + "properties": { + "bypassrls": { + "$ref": "#/$defs/bool" + }, + "createdb": { + "$ref": "#/$defs/bool" + }, + "createrole": { + "$ref": "#/$defs/bool" + } + }, + "additionalProperties": false + }, + "postgres.RoleAuthMethod": { + "type": "string" + }, + "postgres.RoleIdentityType": { + "type": "string" + }, + "postgres.RoleMembershipRole": { + "type": "string" + }, "serving.Ai21LabsConfig": { "type": "object", "properties": { @@ -9728,6 +9828,12 @@ "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresProject" } }, + "resources.PostgresRole": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresRole" + } + }, "resources.QualityMonitor": { "type": "object", "additionalProperties": { @@ -10105,6 +10211,12 @@ "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.ProjectCustomTag" } }, + "postgres.RoleMembershipRole": { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleMembershipRole" + } + }, "serving.AiGatewayRateLimit": { "type": "array", "items": { diff --git a/bundle/statemgmt/state_load_test.go b/bundle/statemgmt/state_load_test.go index 34c4fa4f5aa..5667d9431c8 100644 --- a/bundle/statemgmt/state_load_test.go +++ b/bundle/statemgmt/state_load_test.go @@ -49,6 +49,7 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { "resources.postgres_projects.test_postgres_project": {ID: "projects/test-project"}, "resources.postgres_branches.test_postgres_branch": {ID: "projects/test-project/branches/main"}, "resources.postgres_endpoints.test_postgres_endpoint": {ID: "projects/test-project/branches/main/endpoints/primary"}, + "resources.postgres_roles.test_postgres_role": {ID: "projects/test-project/branches/main/roles/test-role"}, "resources.vector_search_endpoints.test_vector_search_endpoint": {ID: "vs-endpoint-1"}, } err := StateToBundle(t.Context(), state, &config) @@ -118,6 +119,9 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { assert.Equal(t, "projects/test-project/branches/main/endpoints/primary", config.Resources.PostgresEndpoints["test_postgres_endpoint"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresEndpoints["test_postgres_endpoint"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/roles/test-role", config.Resources.PostgresRoles["test_postgres_role"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresRoles["test_postgres_role"].ModifiedStatus) + assert.Equal(t, "vs-endpoint-1", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ModifiedStatus) @@ -292,6 +296,14 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { }, }, }, + PostgresRoles: map[string]*resources.PostgresRole{ + "test_postgres_role": { + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "test-role", + Parent: "projects/test-project/branches/main", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "test_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -374,6 +386,9 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { assert.Equal(t, "", config.Resources.PostgresEndpoints["test_postgres_endpoint"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresEndpoints["test_postgres_endpoint"].ModifiedStatus) + assert.Equal(t, "", config.Resources.PostgresRoles["test_postgres_role"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresRoles["test_postgres_role"].ModifiedStatus) + assert.Equal(t, "", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ModifiedStatus) @@ -661,6 +676,20 @@ func TestStateToBundleModifiedResources(t *testing.T) { }, }, }, + PostgresRoles: map[string]*resources.PostgresRole{ + "test_postgres_role": { + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "primary", + Parent: "projects/test-project/branches/main", + }, + }, + "test_postgres_role_new": { + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "replica", + Parent: "projects/test-project-new/branches/dev", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "test_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -716,6 +745,8 @@ func TestStateToBundleModifiedResources(t *testing.T) { "resources.postgres_branches.test_postgres_branch_old": {ID: "projects/test-project/branches/old"}, "resources.postgres_endpoints.test_postgres_endpoint": {ID: "projects/test-project/branches/main/endpoints/primary"}, "resources.postgres_endpoints.test_postgres_endpoint_old": {ID: "projects/test-project/branches/main/endpoints/old"}, + "resources.postgres_roles.test_postgres_role": {ID: "projects/test-project/branches/main/roles/primary"}, + "resources.postgres_roles.test_postgres_role_old": {ID: "projects/test-project/branches/main/roles/old"}, "resources.vector_search_endpoints.test_vector_search_endpoint": {ID: "vs-endpoint-1"}, "resources.vector_search_endpoints.test_vector_search_endpoint_old": {ID: "vs-endpoint-old"}, } @@ -864,6 +895,13 @@ func TestStateToBundleModifiedResources(t *testing.T) { assert.Equal(t, "", config.Resources.PostgresEndpoints["test_postgres_endpoint_new"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresEndpoints["test_postgres_endpoint_new"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/roles/primary", config.Resources.PostgresRoles["test_postgres_role"].ID) + assert.Equal(t, "", config.Resources.PostgresRoles["test_postgres_role"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/roles/old", config.Resources.PostgresRoles["test_postgres_role_old"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresRoles["test_postgres_role_old"].ModifiedStatus) + assert.Equal(t, "", config.Resources.PostgresRoles["test_postgres_role_new"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresRoles["test_postgres_role_new"].ModifiedStatus) + assert.Equal(t, "vs-endpoint-1", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ID) assert.Equal(t, "", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ModifiedStatus) assert.Equal(t, "vs-endpoint-old", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint_old"].ID) diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 5430c68cbcc..08f8699e721 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -171,6 +171,7 @@ type FakeWorkspace struct { PostgresProjects map[string]postgres.Project PostgresBranches map[string]postgres.Branch PostgresEndpoints map[string]postgres.Endpoint + PostgresRoles map[string]postgres.Role PostgresOperations map[string]postgres.Operation // clusterVenvs caches Python venvs per existing cluster ID, @@ -299,6 +300,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { PostgresProjects: map[string]postgres.Project{}, PostgresBranches: map[string]postgres.Branch{}, PostgresEndpoints: map[string]postgres.Endpoint{}, + PostgresRoles: map[string]postgres.Role{}, PostgresOperations: map[string]postgres.Operation{}, clusterVenvs: map[string]*clusterEnv{}, Alerts: map[string]sql.AlertV2{}, diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index d98011fc7ba..9052c9f0f28 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -857,6 +857,11 @@ func AddDefaultHandlers(server *Server) { return req.Workspace.PostgresOperationGet(name) }) + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/roles/{role_id}/operations/{operation_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/roles/" + req.Vars["role_id"] + "/operations/" + req.Vars["operation_id"] + return req.Workspace.PostgresOperationGet(name) + }) + // Postgres Projects: server.Handle("POST", "/api/2.0/postgres/projects", func(req Request) any { projectID := req.URL.Query().Get("project_id") @@ -936,6 +941,33 @@ func AddDefaultHandlers(server *Server) { return req.Workspace.PostgresEndpointDelete(name) }) + // Postgres Roles: + server.Handle("POST", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/roles", func(req Request) any { + parent := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + roleID := req.URL.Query().Get("role_id") + return req.Workspace.PostgresRoleCreate(req, parent, roleID) + }) + + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/roles", func(req Request) any { + parent := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + return req.Workspace.PostgresRoleList(parent) + }) + + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/roles/{role_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/roles/" + req.Vars["role_id"] + return req.Workspace.PostgresRoleGet(name) + }) + + server.Handle("PATCH", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/roles/{role_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/roles/" + req.Vars["role_id"] + return req.Workspace.PostgresRoleUpdate(req, name) + }) + + server.Handle("DELETE", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/roles/{role_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/roles/" + req.Vars["role_id"] + return req.Workspace.PostgresRoleDelete(name) + }) + // Catch-all handler for invalid postgres resource names. // This handles cases like GET /api/2.0/postgres/1234 where "1234" is not a valid resource name. server.Handle("GET", "/api/2.0/postgres/{name}", func(req Request) any { diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index f3a488b5704..74383ebbb39 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -3,6 +3,7 @@ package testserver import ( "encoding/json" "fmt" + "regexp" "strings" "time" @@ -587,6 +588,209 @@ func (s *FakeWorkspace) PostgresEndpointDelete(name string) Response { } } +// roleIDPattern matches a valid postgres role_id per RFC 1123: lowercase letters, +// numbers and hyphens; 4-63 chars; must start with a letter. +var roleIDPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{3,62}$`) + +// roleStatusFromSpec mirrors the real Postgres Role server's behavior of echoing +// the spec onto Status (plus default-deriving fields the user did not specify) +// while leaving Spec=nil on GET responses. +func roleStatusFromSpec(spec *postgres.RoleRoleSpec) *postgres.RoleRoleStatus { + status := &postgres.RoleRoleStatus{} + if spec == nil { + return status + } + status.PostgresRole = spec.PostgresRole + status.MembershipRoles = spec.MembershipRoles + status.IdentityType = spec.IdentityType + if status.IdentityType == "" { + // Server returns IDENTITY_TYPE_UNSPECIFIED for plain Postgres roles. + status.IdentityType = "IDENTITY_TYPE_UNSPECIFIED" + } + status.AuthMethod = spec.AuthMethod + if status.AuthMethod == "" { + // Server derives auth_method from identity_type when the user omits it: + // see SDK comment on postgres.RoleRoleSpec.AuthMethod. + switch spec.IdentityType { + case postgres.RoleIdentityTypeGroup: + status.AuthMethod = postgres.RoleAuthMethodNoLogin + case postgres.RoleIdentityTypeUser, postgres.RoleIdentityTypeServicePrincipal: + status.AuthMethod = postgres.RoleAuthMethodLakebaseOauthV1 + default: + status.AuthMethod = postgres.RoleAuthMethodPgPasswordScramSha256 + } + } + // Real server always echoes an attributes block (all-false when unspecified). + attrs := &postgres.RoleAttributes{ + ForceSendFields: []string{"Bypassrls", "Createdb", "Createrole"}, + } + if spec.Attributes != nil { + attrs.Bypassrls = spec.Attributes.Bypassrls + attrs.Createdb = spec.Attributes.Createdb + attrs.Createrole = spec.Attributes.Createrole + } + status.Attributes = attrs + return status +} + +// PostgresRoleCreate creates a new postgres role. +func (s *FakeWorkspace) PostgresRoleCreate(req Request, parent, roleID string) Response { + defer s.LockUnlock()() + + // Check if parent branch exists + if _, exists := s.PostgresBranches[parent]; !exists { + return postgresNotFoundResponse("branch") + } + + // When role_id is empty the real API generates one; mirror that here so the + // CLI's "let the server pick" path is exercised by tests. + if roleID == "" { + roleID = "role-" + nextUUID()[:8] + } + if !roleIDPattern.MatchString(roleID) { + return postgresErrorResponse(400, "INVALID_PARAMETER_VALUE", + `Field 'role_id' must be 4-63 characters, start with a lowercase letter, and contain only lowercase letters, numbers and hyphens (RFC 1123).`) + } + + var role postgres.Role + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &role); err != nil { + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("cannot unmarshal request body: %v", err), + } + } + } + + name := fmt.Sprintf("%s/roles/%s", parent, roleID) + + if _, exists := s.PostgresRoles[name]; exists { + return postgresErrorResponse(409, "ALREADY_EXISTS", "role with such id already exists") + } + + now := nowTime() + role.Name = name + role.Parent = parent + role.CreateTime = now + role.UpdateTime = now + + role.Status = roleStatusFromSpec(role.Spec) + role.Status.RoleId = roleID + role.Spec = nil + + s.PostgresRoles[name] = role + + return Response{ + Body: s.createOperationLocked(role.Name, role), + } +} + +// PostgresRoleGet retrieves a postgres role by name. +func (s *FakeWorkspace) PostgresRoleGet(name string) Response { + defer s.LockUnlock()() + + // Extract project and branch names from role name + // Format: projects/{project}/branches/{branch}/roles/{role} + parts := strings.Split(name, "/branches/") + if len(parts) == 2 { + projectName := parts[0] + if _, exists := s.PostgresProjects[projectName]; !exists { + return postgresNotFoundResponse("project") + } + branchParts := strings.Split(parts[1], "/roles/") + if len(branchParts) == 2 { + branchName := projectName + "/branches/" + branchParts[0] + if _, exists := s.PostgresBranches[branchName]; !exists { + return postgresNotFoundResponse("branch") + } + } + } + + role, exists := s.PostgresRoles[name] + if !exists { + return postgresNotFoundResponse("role") + } + + return Response{ + Body: role, + } +} + +// PostgresRoleList lists all postgres roles for a branch. +func (s *FakeWorkspace) PostgresRoleList(parent string) Response { + defer s.LockUnlock()() + + if _, exists := s.PostgresBranches[parent]; !exists { + return postgresNotFoundResponse("branch") + } + + var roles []postgres.Role + prefix := parent + "/roles/" + for name, r := range s.PostgresRoles { + if strings.HasPrefix(name, prefix) { + roles = append(roles, r) + } + } + + return Response{ + Body: postgres.ListRolesResponse{ + Roles: roles, + }, + } +} + +// PostgresRoleUpdate updates a postgres role. +func (s *FakeWorkspace) PostgresRoleUpdate(req Request, name string) Response { + defer s.LockUnlock()() + + role, exists := s.PostgresRoles[name] + if !exists { + return postgresNotFoundResponse("role") + } + + var updateRole postgres.Role + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &updateRole); err != nil { + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("cannot unmarshal request body: %v", err), + } + } + } + + if updateRole.Spec != nil { + // Preserve role_id which is derived from the resource name. + roleID := "" + if role.Status != nil { + roleID = role.Status.RoleId + } + role.Status = roleStatusFromSpec(updateRole.Spec) + role.Status.RoleId = roleID + } + + role.UpdateTime = nowTime() + s.PostgresRoles[name] = role + + return Response{ + Body: s.createOperationLocked(role.Name, role), + } +} + +// PostgresRoleDelete deletes a postgres role. +func (s *FakeWorkspace) PostgresRoleDelete(name string) Response { + defer s.LockUnlock()() + + if _, exists := s.PostgresRoles[name]; !exists { + return postgresNotFoundResponse("role") + } + + delete(s.PostgresRoles, name) + + return Response{ + Body: s.createOperationLocked(name, nil), + } +} + // PostgresOperationGet retrieves a postgres operation by name. func (s *FakeWorkspace) PostgresOperationGet(name string) Response { defer s.LockUnlock()() @@ -606,11 +810,16 @@ func (s *FakeWorkspace) createOperationLocked(resourceName string, response any) operationID := nextUUID() operationName := resourceName + "/operations/" + operationID - // Determine resource type from name for metadata @type + // Determine resource type from name for metadata @type. + // Check the more specific suffixes first since role/endpoint names also + // contain "/branches/". resourceType := "Project" - if strings.Contains(resourceName, "/endpoints/") { + switch { + case strings.Contains(resourceName, "/endpoints/"): resourceType = "Endpoint" - } else if strings.Contains(resourceName, "/branches/") { + case strings.Contains(resourceName, "/roles/"): + resourceType = "Role" + case strings.Contains(resourceName, "/branches/"): resourceType = "Branch" } diff --git a/libs/testserver/postgres_test.go b/libs/testserver/postgres_test.go index d421212ed9c..985077daff1 100644 --- a/libs/testserver/postgres_test.go +++ b/libs/testserver/postgres_test.go @@ -3,6 +3,7 @@ package testserver_test import ( "encoding/json" "net/http" + "strings" "testing" "github.com/databricks/cli/libs/testserver" @@ -271,3 +272,118 @@ func TestPostgresEndpointNotFoundWhenBranchNotExists(t *testing.T) { assert.Equal(t, 404, createEpResp.StatusCode) createEpResp.Body.Close() } + +func TestPostgresRoleCRUD(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + + client := &http.Client{} + baseURL := server.URL + + // Create project first + createProjReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects?project_id=role-test-project", nil) + createProjReq.Header.Set("Authorization", "Bearer test-token") + createProjResp, err := client.Do(createProjReq) + require.NoError(t, err) + assert.Equal(t, 200, createProjResp.StatusCode) + createProjResp.Body.Close() + + // Create branch + createBranchReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/role-test-project/branches?branch_id=main", nil) + createBranchReq.Header.Set("Authorization", "Bearer test-token") + createBranchResp, err := client.Do(createBranchReq) + require.NoError(t, err) + assert.Equal(t, 200, createBranchResp.StatusCode) + createBranchResp.Body.Close() + + // Create role + createRoleBody := `{"spec":{"postgres_role":"my_role"}}` + createRoleReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/role-test-project/branches/main/roles?role_id=my-role", strings.NewReader(createRoleBody)) + createRoleReq.Header.Set("Authorization", "Bearer test-token") + createRoleReq.Header.Set("Content-Type", "application/json") + createRoleResp, err := client.Do(createRoleReq) + require.NoError(t, err) + assert.Equal(t, 200, createRoleResp.StatusCode) + createRoleResp.Body.Close() + + // Get role + getRoleReq, _ := http.NewRequest(http.MethodGet, baseURL+"/api/2.0/postgres/projects/role-test-project/branches/main/roles/my-role", nil) + getRoleReq.Header.Set("Authorization", "Bearer test-token") + getRoleResp, err := client.Do(getRoleReq) + require.NoError(t, err) + assert.Equal(t, 200, getRoleResp.StatusCode) + + var role postgres.Role + require.NoError(t, json.NewDecoder(getRoleResp.Body).Decode(&role)) + assert.Equal(t, "projects/role-test-project/branches/main/roles/my-role", role.Name) + assert.Equal(t, "projects/role-test-project/branches/main", role.Parent) + require.NotNil(t, role.Status) + assert.Equal(t, "my_role", role.Status.PostgresRole) + getRoleResp.Body.Close() + + // List roles + listRoleReq, _ := http.NewRequest(http.MethodGet, baseURL+"/api/2.0/postgres/projects/role-test-project/branches/main/roles", nil) + listRoleReq.Header.Set("Authorization", "Bearer test-token") + listRoleResp, err := client.Do(listRoleReq) + require.NoError(t, err) + assert.Equal(t, 200, listRoleResp.StatusCode) + + var listRoles postgres.ListRolesResponse + require.NoError(t, json.NewDecoder(listRoleResp.Body).Decode(&listRoles)) + assert.Len(t, listRoles.Roles, 1) + listRoleResp.Body.Close() + + // Update role (rename via spec.postgres_role) + updateRoleBody := `{"spec":{"postgres_role":"my_role_renamed"}}` + updateRoleReq, _ := http.NewRequest(http.MethodPatch, baseURL+"/api/2.0/postgres/projects/role-test-project/branches/main/roles/my-role", strings.NewReader(updateRoleBody)) + updateRoleReq.Header.Set("Authorization", "Bearer test-token") + updateRoleReq.Header.Set("Content-Type", "application/json") + updateRoleResp, err := client.Do(updateRoleReq) + require.NoError(t, err) + assert.Equal(t, 200, updateRoleResp.StatusCode) + updateRoleResp.Body.Close() + + // Verify rename was applied + getRoleReq2, _ := http.NewRequest(http.MethodGet, baseURL+"/api/2.0/postgres/projects/role-test-project/branches/main/roles/my-role", nil) + getRoleReq2.Header.Set("Authorization", "Bearer test-token") + getRoleResp2, err := client.Do(getRoleReq2) + require.NoError(t, err) + assert.Equal(t, 200, getRoleResp2.StatusCode) + var role2 postgres.Role + require.NoError(t, json.NewDecoder(getRoleResp2.Body).Decode(&role2)) + require.NotNil(t, role2.Status) + assert.Equal(t, "my_role_renamed", role2.Status.PostgresRole) + getRoleResp2.Body.Close() + + // Delete role + deleteRoleReq, _ := http.NewRequest(http.MethodDelete, baseURL+"/api/2.0/postgres/projects/role-test-project/branches/main/roles/my-role", nil) + deleteRoleReq.Header.Set("Authorization", "Bearer test-token") + deleteRoleResp, err := client.Do(deleteRoleReq) + require.NoError(t, err) + assert.Equal(t, 200, deleteRoleResp.StatusCode) + deleteRoleResp.Body.Close() +} + +func TestPostgresRoleNotFoundWhenBranchNotExists(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + + client := &http.Client{} + baseURL := server.URL + + // Create project first + createProjReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects?project_id=role-no-branch-project", nil) + createProjReq.Header.Set("Authorization", "Bearer test-token") + createProjResp, err := client.Do(createProjReq) + require.NoError(t, err) + assert.Equal(t, 200, createProjResp.StatusCode) + createProjResp.Body.Close() + + // Try to create role without branch + createRoleReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/role-no-branch-project/branches/nonexistent/roles?role_id=my-role", nil) + createRoleReq.Header.Set("Authorization", "Bearer test-token") + createRoleResp, err := client.Do(createRoleReq) + require.NoError(t, err) + assert.Equal(t, 404, createRoleResp.StatusCode) + createRoleResp.Body.Close() +} From 48d17014a692c3f21dff10b6cac8ed89e1518315 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 11:47:30 +0200 Subject: [PATCH 19/41] postgres_role: require role_id and treat 404 from GetRole as not-found Two follow-ups to the postgres_roles resource: - Regenerate required-field validation so role_id is required alongside parent, matching the JSON schema (jsonschema.json already lists both under required). Without this, bundle validate accepted a role config missing role_id and the failure only surfaced during deploy. - In PostgresRole.Exists, recognize 404 via apierr.IsMissing and return (false, nil) so bundle deployment bind reports the user-friendly "postgres_role ... is not found" path instead of a generic fetch error. Co-authored-by: Isaac --- bundle/config/resources/postgres_role.go | 6 +++++- bundle/internal/validation/generated/required_fields.go | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/bundle/config/resources/postgres_role.go b/bundle/config/resources/postgres_role.go index d8641565548..30967175692 100644 --- a/bundle/config/resources/postgres_role.go +++ b/bundle/config/resources/postgres_role.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/postgres" ) @@ -37,7 +38,10 @@ type PostgresRole struct { func (r *PostgresRole) Exists(ctx context.Context, w *databricks.WorkspaceClient, name string) (bool, error) { _, err := w.Postgres.GetRole(ctx, postgres.GetRoleRequest{Name: name}) if err != nil { - log.Debugf(ctx, "postgres role %s does not exist", name) + log.Debugf(ctx, "postgres role %s does not exist: %v", name, err) + if apierr.IsMissing(err) { + return false, nil + } return false, err } return true, nil diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index 19eab280208..d7b62446296 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -225,7 +225,7 @@ var RequiredFields = map[string][]string{ "resources.postgres_projects.*": {"project_id"}, "resources.postgres_projects.*.permissions[*]": {"level"}, - "resources.postgres_roles.*": {"parent"}, + "resources.postgres_roles.*": {"role_id", "parent"}, "resources.quality_monitors.*": {"assets_dir", "output_schema_name", "table_name"}, "resources.quality_monitors.*.custom_metrics[*]": {"definition", "input_columns", "name", "output_data_type", "type"}, From efe72ff81ad3c0e7899539b97a6aa060a1703336 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 11:47:48 +0200 Subject: [PATCH 20/41] postgres_role: include enum_fields from validation regeneration Missed alongside required_fields in the previous commit. Same generator run, just the second output file. Co-authored-by: Isaac --- bundle/internal/validation/generated/enum_fields.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bundle/internal/validation/generated/enum_fields.go b/bundle/internal/validation/generated/enum_fields.go index 2f1593a890e..6a0864ee892 100644 --- a/bundle/internal/validation/generated/enum_fields.go +++ b/bundle/internal/validation/generated/enum_fields.go @@ -184,6 +184,10 @@ var EnumFields = map[string][]string{ "resources.postgres_projects.*.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.postgres_roles.*.auth_method": {"LAKEBASE_OAUTH_V1", "NO_LOGIN", "PG_PASSWORD_SCRAM_SHA_256"}, + "resources.postgres_roles.*.identity_type": {"GROUP", "SERVICE_PRINCIPAL", "USER"}, + "resources.postgres_roles.*.membership_roles[*]": {"DATABRICKS_SUPERUSER"}, + "resources.quality_monitors.*.custom_metrics[*].type": {"CUSTOM_METRIC_TYPE_AGGREGATE", "CUSTOM_METRIC_TYPE_DERIVED", "CUSTOM_METRIC_TYPE_DRIFT"}, "resources.quality_monitors.*.inference_log.problem_type": {"PROBLEM_TYPE_CLASSIFICATION", "PROBLEM_TYPE_REGRESSION"}, "resources.quality_monitors.*.schedule.pause_status": {"PAUSED", "UNPAUSED", "UNSPECIFIED"}, From 02108c6f7e86b70ff205453eb8f61857fbbc34a6 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 12:10:20 +0200 Subject: [PATCH 21/41] postgres_role: only log "does not exist" on the not-found path Previously logged "does not exist" for any GetRole error, including transient failures, before checking apierr.IsMissing. Flip the order so the debug message only fires when the role is genuinely absent. Co-authored-by: Isaac --- bundle/config/resources/postgres_role.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bundle/config/resources/postgres_role.go b/bundle/config/resources/postgres_role.go index 30967175692..34c2f5fcae4 100644 --- a/bundle/config/resources/postgres_role.go +++ b/bundle/config/resources/postgres_role.go @@ -37,11 +37,11 @@ type PostgresRole struct { func (r *PostgresRole) Exists(ctx context.Context, w *databricks.WorkspaceClient, name string) (bool, error) { _, err := w.Postgres.GetRole(ctx, postgres.GetRoleRequest{Name: name}) + if apierr.IsMissing(err) { + log.Debugf(ctx, "postgres role %s does not exist", name) + return false, nil + } if err != nil { - log.Debugf(ctx, "postgres role %s does not exist: %v", name, err) - if apierr.IsMissing(err) { - return false, nil - } return false, err } return true, nil From ad485dea5b27128721f7e7c0e0eac5a6b4a3cfa1 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 14:04:41 +0200 Subject: [PATCH 22/41] Merge branch 'postgres-roles' into postgres-databases --- NEXT_CHANGELOG.md | 4 + .../bind/postgres_role/databricks.yml | 9 + .../bind/postgres_role/out.test.toml | 3 + .../deployment/bind/postgres_role/output.txt | 32 +++ .../deployment/bind/postgres_role/script | 6 + .../deployment/bind/postgres_role/test.toml | 18 ++ .../invariant/configs/postgres_role.yml.tmpl | 20 ++ .../invariant/continue_293/out.test.toml | 1 + .../bundle/invariant/migrate/out.test.toml | 1 + .../bundle/invariant/no_drift/out.test.toml | 1 + acceptance/bundle/invariant/test.toml | 2 + acceptance/bundle/refschema/out.fields.txt | 40 ++++ .../postgres_roles/basic/databricks.yml.tmpl | 27 +++ .../basic/out.requests.direct.json | 45 ++++ .../basic/out.requests.terraform.json | 47 ++++ .../postgres_roles/basic/out.test.toml | 6 + .../resources/postgres_roles/basic/output.txt | 93 ++++++++ .../resources/postgres_roles/basic/script | 25 +++ .../resources/postgres_roles/basic/test.toml | 1 + .../recreate/databricks.yml.tmpl | 25 +++ .../postgres_roles/recreate/out.test.toml | 6 + .../postgres_roles/recreate/output.txt | 165 ++++++++++++++ .../resources/postgres_roles/recreate/script | 50 +++++ .../postgres_roles/recreate/test.toml | 1 + .../bundle/resources/postgres_roles/test.toml | 39 ++++ .../postgres_roles/update/databricks.yml.tmpl | 27 +++ .../update/out.plan.create.direct.json | 20 ++ .../update/out.plan.create.terraform.json | 3 + .../update/out.plan.no_change.direct.json | 58 +++++ .../update/out.plan.no_change.terraform.json | 3 + .../update/out.plan.restore.direct.json | 70 ++++++ .../update/out.plan.restore.terraform.json | 3 + .../update/out.plan.update.direct.json | 70 ++++++ .../update/out.plan.update.terraform.json | 3 + .../update/out.requests.create.direct.json | 41 ++++ .../update/out.requests.create.terraform.json | 43 ++++ .../update/out.requests.no_change.direct.json | 20 ++ .../out.requests.no_change.terraform.json | 12 + .../update/out.requests.restore.direct.json | 35 +++ .../out.requests.restore.terraform.json | 29 +++ .../update/out.requests.update.direct.json | 35 +++ .../update/out.requests.update.terraform.json | 29 +++ .../postgres_roles/update/out.test.toml | 6 + .../postgres_roles/update/output.txt | 176 +++++++++++++++ .../resources/postgres_roles/update/script | 59 +++++ .../resources/postgres_roles/update/test.toml | 1 + .../apply_bundle_permissions_test.go | 1 + .../resourcemutator/apply_target_mode_test.go | 11 + .../mutator/resourcemutator/run_as_test.go | 2 + bundle/config/resources.go | 3 + bundle/config/resources/postgres_role.go | 71 ++++++ bundle/config/resources_test.go | 12 + bundle/deploy/terraform/interpolate.go | 2 +- bundle/deploy/terraform/pkg.go | 1 + .../terraform/tfdyn/convert_postgres_role.go | 63 ++++++ .../tfdyn/convert_postgres_role_test.go | 81 +++++++ bundle/deploy/terraform/util.go | 2 +- bundle/direct/dresources/all.go | 1 + bundle/direct/dresources/all_test.go | 35 +++ .../direct/dresources/apitypes.generated.yml | 2 + bundle/direct/dresources/apitypes.yml | 2 + bundle/direct/dresources/postgres_role.go | 151 +++++++++++++ .../direct/dresources/resources.generated.yml | 14 ++ bundle/direct/dresources/resources.yml | 8 + bundle/direct/dresources/type_test.go | 11 + bundle/direct/dresources/util.go | 21 +- bundle/internal/schema/annotations.yml | 28 +++ .../validation/generated/enum_fields.go | 4 + .../validation/generated/required_fields.go | 2 + bundle/schema/jsonschema.json | 112 ++++++++++ bundle/schema/jsonschema_for_docs.json | 170 ++++++++++++--- bundle/statemgmt/state_load_test.go | 35 +++ experimental/aitools/cmd/discover_schema.go | 82 ++++--- .../aitools/cmd/discover_schema_test.go | 59 +++++ libs/testproxy/server.go | 34 +-- libs/testserver/fake_workspace.go | 2 + libs/testserver/handlers.go | 32 +++ libs/testserver/postgres.go | 206 ++++++++++++++++++ libs/testserver/postgres_test.go | 115 ++++++++++ 79 files changed, 2706 insertions(+), 79 deletions(-) create mode 100644 acceptance/bundle/deployment/bind/postgres_role/databricks.yml create mode 100644 acceptance/bundle/deployment/bind/postgres_role/out.test.toml create mode 100644 acceptance/bundle/deployment/bind/postgres_role/output.txt create mode 100644 acceptance/bundle/deployment/bind/postgres_role/script create mode 100644 acceptance/bundle/deployment/bind/postgres_role/test.toml create mode 100644 acceptance/bundle/invariant/configs/postgres_role.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_roles/basic/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_roles/basic/out.requests.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/basic/out.requests.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/basic/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/basic/output.txt create mode 100644 acceptance/bundle/resources/postgres_roles/basic/script create mode 100644 acceptance/bundle/resources/postgres_roles/basic/test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/recreate/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_roles/recreate/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/recreate/output.txt create mode 100644 acceptance/bundle/resources/postgres_roles/recreate/script create mode 100644 acceptance/bundle/resources/postgres_roles/recreate/test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/update/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.create.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.create.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.restore.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.plan.update.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.create.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.create.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.restore.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.restore.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.update.direct.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.requests.update.terraform.json create mode 100644 acceptance/bundle/resources/postgres_roles/update/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/update/output.txt create mode 100644 acceptance/bundle/resources/postgres_roles/update/script create mode 100644 acceptance/bundle/resources/postgres_roles/update/test.toml create mode 100644 bundle/config/resources/postgres_role.go create mode 100644 bundle/deploy/terraform/tfdyn/convert_postgres_role.go create mode 100644 bundle/deploy/terraform/tfdyn/convert_postgres_role_test.go create mode 100644 bundle/direct/dresources/postgres_role.go diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 64b2259846c..4dc1c9f3a2d 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -10,6 +10,10 @@ ### Bundles * Make sure warnings asking for approval are understood by agents ([#5239](https://github.com/databricks/cli/pull/5239)) +<<<<<<< HEAD * Add support for `postgres_databases` bundle resource. +======= +* Add Postgres role as a bundle resource (preview). +>>>>>>> postgres-roles ### Dependency updates diff --git a/acceptance/bundle/deployment/bind/postgres_role/databricks.yml b/acceptance/bundle/deployment/bind/postgres_role/databricks.yml new file mode 100644 index 00000000000..b7c555794e6 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_role/databricks.yml @@ -0,0 +1,9 @@ +bundle: + name: test-bundle + +resources: + postgres_roles: + role1: + parent: projects/test-project/branches/main + role_id: test-role + postgres_role: app_role diff --git a/acceptance/bundle/deployment/bind/postgres_role/out.test.toml b/acceptance/bundle/deployment/bind/postgres_role/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_role/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/deployment/bind/postgres_role/output.txt b/acceptance/bundle/deployment/bind/postgres_role/output.txt new file mode 100644 index 00000000000..b439a090ee5 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_role/output.txt @@ -0,0 +1,32 @@ + +>>> [CLI] bundle deployment bind role1 projects/test-project/branches/main/roles/test-role --auto-approve +Updating deployment state... +Successfully bound postgres_role with an id 'projects/test-project/branches/main/roles/test-role' +Run 'bundle deploy' to deploy changes to your workspace + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + Postgres roles: + role1: + Name: + URL: (not deployed) + +>>> [CLI] bundle deployment unbind role1 +Updating deployment state... + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + Postgres roles: + role1: + Name: + URL: (not deployed) diff --git a/acceptance/bundle/deployment/bind/postgres_role/script b/acceptance/bundle/deployment/bind/postgres_role/script new file mode 100644 index 00000000000..547aadd944f --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_role/script @@ -0,0 +1,6 @@ +ROLE_NAME="projects/test-project/branches/main/roles/test-role" +trace $CLI bundle deployment bind role1 "${ROLE_NAME}" --auto-approve +trace $CLI bundle summary + +trace $CLI bundle deployment unbind role1 +trace $CLI bundle summary diff --git a/acceptance/bundle/deployment/bind/postgres_role/test.toml b/acceptance/bundle/deployment/bind/postgres_role/test.toml new file mode 100644 index 00000000000..5113676039f --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_role/test.toml @@ -0,0 +1,18 @@ +Local = true +Cloud = false + +Ignore = [ + ".databricks" +] + +[[Server]] +Pattern = "GET /api/2.0/postgres/projects/test-project/branches/main/roles/test-role" +Response.Body = ''' +{ + "name": "projects/test-project/branches/main/roles/test-role", + "parent": "projects/test-project/branches/main", + "status": { + "postgres_role": "app_role" + } +} +''' diff --git a/acceptance/bundle/invariant/configs/postgres_role.yml.tmpl b/acceptance/bundle/invariant/configs/postgres_role.yml.tmpl new file mode 100644 index 00000000000..9c4aba0b5b8 --- /dev/null +++ b/acceptance/bundle/invariant/configs/postgres_role.yml.tmpl @@ -0,0 +1,20 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + postgres_projects: + project: + project_id: test-pg-project-$UNIQUE_NAME + display_name: Test Postgres Project + + postgres_branches: + branch: + parent: ${resources.postgres_projects.project.name} + branch_id: test-branch-$UNIQUE_NAME + no_expiry: true + + postgres_roles: + foo: + parent: ${resources.postgres_branches.branch.name} + role_id: test-role-$UNIQUE_NAME + postgres_role: app_role diff --git a/acceptance/bundle/invariant/continue_293/out.test.toml b/acceptance/bundle/invariant/continue_293/out.test.toml index 2c4db891c9a..09367d0c12e 100644 --- a/acceptance/bundle/invariant/continue_293/out.test.toml +++ b/acceptance/bundle/invariant/continue_293/out.test.toml @@ -29,6 +29,7 @@ EnvMatrix.INPUT_CONFIG = [ "postgres_database.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", + "postgres_role.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", diff --git a/acceptance/bundle/invariant/migrate/out.test.toml b/acceptance/bundle/invariant/migrate/out.test.toml index 2c4db891c9a..09367d0c12e 100644 --- a/acceptance/bundle/invariant/migrate/out.test.toml +++ b/acceptance/bundle/invariant/migrate/out.test.toml @@ -29,6 +29,7 @@ EnvMatrix.INPUT_CONFIG = [ "postgres_database.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", + "postgres_role.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index 2c4db891c9a..09367d0c12e 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -29,6 +29,7 @@ EnvMatrix.INPUT_CONFIG = [ "postgres_database.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", + "postgres_role.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", diff --git a/acceptance/bundle/invariant/test.toml b/acceptance/bundle/invariant/test.toml index b889e0e1a9c..708de8b7077 100644 --- a/acceptance/bundle/invariant/test.toml +++ b/acceptance/bundle/invariant/test.toml @@ -47,6 +47,7 @@ EnvMatrix.INPUT_CONFIG = [ "postgres_database.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", + "postgres_role.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", "schema_grant_ref.yml.tmpl", @@ -69,6 +70,7 @@ no_postgres_project_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_proj no_postgres_branch_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_branch.yml.tmpl"] no_postgres_database_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_database.yml.tmpl"] no_postgres_endpoint_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_endpoint.yml.tmpl"] +no_postgres_role_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_role.yml.tmpl"] # External locations require actual storage credentials with cloud IAM setup # which are environment-specific, so we only test locally with the mock server diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index fbce2194b41..9d83749711d 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -2889,6 +2889,46 @@ resources.postgres_projects.*.permissions[*].group_name string ALL resources.postgres_projects.*.permissions[*].level iam.PermissionLevel ALL resources.postgres_projects.*.permissions[*].service_principal_name string ALL resources.postgres_projects.*.permissions[*].user_name string ALL +resources.postgres_roles.*.attributes *postgres.RoleAttributes INPUT STATE +resources.postgres_roles.*.attributes.bypassrls bool INPUT STATE +resources.postgres_roles.*.attributes.createdb bool INPUT STATE +resources.postgres_roles.*.attributes.createrole bool INPUT STATE +resources.postgres_roles.*.auth_method postgres.RoleAuthMethod INPUT STATE +resources.postgres_roles.*.create_time *time.Time REMOTE +resources.postgres_roles.*.id string INPUT +resources.postgres_roles.*.identity_type postgres.RoleIdentityType INPUT STATE +resources.postgres_roles.*.lifecycle resources.Lifecycle INPUT +resources.postgres_roles.*.lifecycle.prevent_destroy bool INPUT +resources.postgres_roles.*.membership_roles []postgres.RoleMembershipRole INPUT STATE +resources.postgres_roles.*.membership_roles[*] postgres.RoleMembershipRole INPUT STATE +resources.postgres_roles.*.modified_status string INPUT +resources.postgres_roles.*.name string REMOTE +resources.postgres_roles.*.parent string ALL +resources.postgres_roles.*.postgres_role string INPUT STATE +resources.postgres_roles.*.role_id string INPUT STATE +resources.postgres_roles.*.spec *postgres.RoleRoleSpec REMOTE +resources.postgres_roles.*.spec.attributes *postgres.RoleAttributes REMOTE +resources.postgres_roles.*.spec.attributes.bypassrls bool REMOTE +resources.postgres_roles.*.spec.attributes.createdb bool REMOTE +resources.postgres_roles.*.spec.attributes.createrole bool REMOTE +resources.postgres_roles.*.spec.auth_method postgres.RoleAuthMethod REMOTE +resources.postgres_roles.*.spec.identity_type postgres.RoleIdentityType REMOTE +resources.postgres_roles.*.spec.membership_roles []postgres.RoleMembershipRole REMOTE +resources.postgres_roles.*.spec.membership_roles[*] postgres.RoleMembershipRole REMOTE +resources.postgres_roles.*.spec.postgres_role string REMOTE +resources.postgres_roles.*.status *postgres.RoleRoleStatus REMOTE +resources.postgres_roles.*.status.attributes *postgres.RoleAttributes REMOTE +resources.postgres_roles.*.status.attributes.bypassrls bool REMOTE +resources.postgres_roles.*.status.attributes.createdb bool REMOTE +resources.postgres_roles.*.status.attributes.createrole bool REMOTE +resources.postgres_roles.*.status.auth_method postgres.RoleAuthMethod REMOTE +resources.postgres_roles.*.status.identity_type postgres.RoleIdentityType REMOTE +resources.postgres_roles.*.status.membership_roles []postgres.RoleMembershipRole REMOTE +resources.postgres_roles.*.status.membership_roles[*] postgres.RoleMembershipRole REMOTE +resources.postgres_roles.*.status.postgres_role string REMOTE +resources.postgres_roles.*.status.role_id string REMOTE +resources.postgres_roles.*.update_time *time.Time REMOTE +resources.postgres_roles.*.url string INPUT resources.quality_monitors.*.assets_dir string ALL resources.quality_monitors.*.baseline_table_name string ALL resources.quality_monitors.*.custom_metrics []catalog.MonitorMetric ALL diff --git a/acceptance/bundle/resources/postgres_roles/basic/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_roles/basic/databricks.yml.tmpl new file mode 100644 index 00000000000..bba8e0d7d94 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/databricks.yml.tmpl @@ -0,0 +1,27 @@ +bundle: + name: deploy-postgres-role-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Role" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role + postgres_role: app_role + attributes: + createdb: true diff --git a/acceptance/bundle/resources/postgres_roles/basic/out.requests.direct.json b/acceptance/bundle/resources/postgres_roles/basic/out.requests.direct.json new file mode 100644 index 00000000000..67bf2b4cc52 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/out.requests.direct.json @@ -0,0 +1,45 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Role", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "test-role" + }, + "body": { + "spec": { + "attributes": { + "createdb": true + }, + "postgres_role": "app_role" + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} diff --git a/acceptance/bundle/resources/postgres_roles/basic/out.requests.terraform.json b/acceptance/bundle/resources/postgres_roles/basic/out.requests.terraform.json new file mode 100644 index 00000000000..ad5c2ec0d03 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/out.requests.terraform.json @@ -0,0 +1,47 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Role", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "test-role" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "attributes": { + "createdb": true + }, + "postgres_role": "app_role" + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} diff --git a/acceptance/bundle/resources/postgres_roles/basic/out.test.toml b/acceptance/bundle/resources/postgres_roles/basic/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/postgres_roles/basic/output.txt b/acceptance/bundle/resources/postgres_roles/basic/output.txt new file mode 100644 index 00000000000..28fe1344203 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/output.txt @@ -0,0 +1,93 @@ + +>>> [CLI] bundle validate +Name: deploy-postgres-role-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-[UNIQUE_NAME]/default + +Validation OK! + +>>> [CLI] bundle summary +Name: deploy-postgres-role-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-[UNIQUE_NAME]/default +Resources: + Postgres branches: + main: + Name: + URL: (not deployed) + Postgres projects: + my_project: + Name: Test Project for Role + URL: (not deployed) + Postgres roles: + my_role: + Name: + URL: (not deployed) + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] postgres get-role projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": true, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + } +} + +>>> [CLI] bundle summary +Name: deploy-postgres-role-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-[UNIQUE_NAME]/default +Resources: + Postgres branches: + main: + Name: + URL: (not deployed) + Postgres projects: + my_project: + Name: Test Project for Role + URL: (not deployed) + Postgres roles: + my_role: + Name: + URL: (not deployed) + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + delete resources.postgres_roles.my_role + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_roles/basic/script b/acceptance/bundle/resources/postgres_roles/basic/script new file mode 100644 index 00000000000..831c5ecb3e3 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/script @@ -0,0 +1,25 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +trace $CLI bundle validate + +trace $CLI bundle summary + +rm -f out.requests.txt +trace $CLI bundle deploy + +# Get role details +project_name="projects/test-pg-proj-${UNIQUE_NAME}" +branch_name="${project_name}/branches/main" +role_name="${branch_name}/roles/test-role" +trace $CLI postgres get-role "${role_name}" | jq 'del(.create_time, .update_time)' + +trace $CLI bundle summary + +# Filter requests to only show postgres operations (exclude workspace, telemetry, and operation polling) +trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/postgres_roles/basic/test.toml b/acceptance/bundle/resources/postgres_roles/basic/test.toml new file mode 100644 index 00000000000..f8b3bbe49dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/basic/test.toml @@ -0,0 +1 @@ +# All configuration inherited from parent test.toml diff --git a/acceptance/bundle/resources/postgres_roles/recreate/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_roles/recreate/databricks.yml.tmpl new file mode 100644 index 00000000000..2daa730fbef --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate/databricks.yml.tmpl @@ -0,0 +1,25 @@ +bundle: + name: deploy-postgres-role-recreate-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Role" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: ROLE_ID_PLACEHOLDER + postgres_role: app_role diff --git a/acceptance/bundle/resources/postgres_roles/recreate/out.test.toml b/acceptance/bundle/resources/postgres_roles/recreate/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/postgres_roles/recreate/output.txt b/acceptance/bundle/resources/postgres_roles/recreate/output.txt new file mode 100644 index 00000000000..bff2a3cf4dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate/output.txt @@ -0,0 +1,165 @@ + +>>> cat databricks.yml +bundle: + name: deploy-postgres-role-recreate-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Role" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role-[UNIQUE_NAME] + postgres_role: app_role + +>>> [CLI] bundle plan +create postgres_branches.main +create postgres_projects.my_project +create postgres_roles.my_role + +Plan: 3 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-recreate-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests +{ + "body": { + "spec": { + "display_name": "Test Project for Role", + "history_retention_duration": "604800s", + "pg_version": 16 + } + }, + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + } +} + "no_expiry": true + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "branch_id": "main" + "postgres_role": "app_role" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "role_id": "test-role-[UNIQUE_NAME]" + +>>> cat databricks.yml +bundle: + name: deploy-postgres-role-recreate-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Role" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role-[UNIQUE_NAME]-v2 + postgres_role: app_role + +>>> [CLI] bundle plan +recreate postgres_roles.my_role + +Plan: 1 to add, 0 to change, 1 to delete, 2 unchanged + +>>> [CLI] bundle deploy --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-recreate-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests +{ + "method": "DELETE", + "path": "/api/2.0/postgres/[MY_ROLE_ID]" +} + "body": { + "spec": { + "postgres_role": "app_role" + } + }, + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "test-role-[UNIQUE_NAME]-v2" + } + +=== Fetch role and verify it exists after recreation +>>> [CLI] postgres get-role [MY_ROLE_ID]-v2 +{ + "name": "[MY_ROLE_ID]-v2", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": false, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role-[UNIQUE_NAME]-v2" + } +} + +=== Destroy and verify cleanup +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + delete resources.postgres_roles.my_role + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-recreate-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! + +>>> print_requests +{ + "method": "DELETE", + "path": "/api/2.0/postgres/[MY_ROLE_ID]-v2" +} + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" + +>>> [CLI] bundle destroy --auto-approve +No active deployment found to destroy! diff --git a/acceptance/bundle/resources/postgres_roles/recreate/script b/acceptance/bundle/resources/postgres_roles/recreate/script new file mode 100644 index 00000000000..9bc72f3ccd4 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate/script @@ -0,0 +1,50 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + + # Best-effort cleanup if a deploy left a role behind under either id. + $CLI postgres delete-role "projects/test-pg-proj-${UNIQUE_NAME}/branches/main/roles/test-role-${UNIQUE_NAME}" 2>/dev/null || true + $CLI postgres delete-role "projects/test-pg-proj-${UNIQUE_NAME}/branches/main/roles/test-role-${UNIQUE_NAME}-v2" 2>/dev/null || true + + rm -f out.requests.txt +} +trap cleanup EXIT + +# Deploy with first role_id +envsubst < databricks.yml.tmpl | sed "s/ROLE_ID_PLACEHOLDER/test-role-${UNIQUE_NAME}/" > databricks.yml + +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle deploy + +role_id_1=`read_id.py my_role` + +print_requests() { + # Filter postgres requests (excluding GET), remove parent field (differs between engines), + # then deduplicate consecutive retries + jq --sort-keys 'select(.method != "GET" and (.path | contains("/postgres"))) | del(.body.parent)' < out.requests.txt | \ + awk '!seen[$0]++ {print}' + rm -f out.requests.txt +} + +trace print_requests + +# Change role_id (encoded in name); should trigger recreation. +envsubst < databricks.yml.tmpl | sed "s/ROLE_ID_PLACEHOLDER/test-role-${UNIQUE_NAME}-v2/" > databricks.yml + +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle deploy --auto-approve + +trace print_requests + +title "Fetch role and verify it exists after recreation" + +role_id_2=`read_id.py my_role` +trace $CLI postgres get-role $role_id_2 | jq 'del(.create_time, .update_time)' + +title "Destroy and verify cleanup" +trace $CLI bundle destroy --auto-approve + +trace print_requests diff --git a/acceptance/bundle/resources/postgres_roles/recreate/test.toml b/acceptance/bundle/resources/postgres_roles/recreate/test.toml new file mode 100644 index 00000000000..f8b3bbe49dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate/test.toml @@ -0,0 +1 @@ +# All configuration inherited from parent test.toml diff --git a/acceptance/bundle/resources/postgres_roles/test.toml b/acceptance/bundle/resources/postgres_roles/test.toml new file mode 100644 index 00000000000..1bae7718330 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/test.toml @@ -0,0 +1,39 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true + +# Lakebase v2 (postgres) is only available in AWS as of January 2026 +CloudEnvs.gcp = false +CloudEnvs.azure = false + +# Run on both direct and Terraform modes +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] + +Ignore = [ + "databricks.yml", + ".databricks", +] + +[[Repls]] +# Clean up ?o= suffix after URL since not all workspaces have that +Old = '\?o=\[(NUMID|ALPHANUMID)\]' +New = '' +Order = 1000 + +[[Repls]] +# Normalize branch UIDs (br-xxx-yyy-zzz format, supports both word-based and hex-based UIDs) +Old = 'br-[a-z0-9-]+' +New = '[BRANCH_UID]' +Order = 1 + +[[Repls]] +# Normalize project UIDs (proj-xxx-yyy-zzz format) +Old = 'proj-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]' +New = '[PROJECT_UID]' +Order = 1 + +[[Repls]] +# Normalize LSN values (format: 0/HEXVALUE or 0/0) +Old = '"source_branch_lsn": "0/[A-F0-9]+"' +New = '"source_branch_lsn": "[LSN]"' +Order = 1 diff --git a/acceptance/bundle/resources/postgres_roles/update/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_roles/update/databricks.yml.tmpl new file mode 100644 index 00000000000..f0ca4e48870 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/databricks.yml.tmpl @@ -0,0 +1,27 @@ +bundle: + name: update-postgres-role-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Role Update" + pg_version: 16 + history_retention_duration: 604800s + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role + postgres_role: app_role + attributes: + createdb: false diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.create.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.create.direct.json new file mode 100644 index 00000000000..498c1d25124 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.create.direct.json @@ -0,0 +1,20 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + } + ], + "action": "create", + "new_state": { + "value": { + "attributes": { + "createdb": false + }, + "postgres_role": "app_role" + }, + "vars": { + "parent": "${resources.postgres_branches.main.id}" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.create.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.create.terraform.json new file mode 100644 index 00000000000..b4425b9687c --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.create.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "create" +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json new file mode 100644 index 00000000000..3772eeef349 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json @@ -0,0 +1,58 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + } + ], + "action": "skip", + "remote_state": { + "create_time": "[TIMESTAMP]", + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": false, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + }, + "update_time": "[TIMESTAMP]" + }, + "changes": { + "attributes": { + "action": "skip", + "reason": "spec:input_only", + "old": { + "createdb": false + }, + "new": { + "createdb": false + } + }, + "parent": { + "action": "skip", + "reason": "remote_already_set", + "old": "", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "remote": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main" + }, + "postgres_role": { + "action": "skip", + "reason": "spec:input_only", + "old": "app_role", + "new": "app_role" + }, + "role_id": { + "action": "skip", + "reason": "remote_already_set", + "old": "", + "new": "test-role", + "remote": "test-role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.terraform.json new file mode 100644 index 00000000000..b2f23a5015f --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "skip" +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json new file mode 100644 index 00000000000..647ce8e7991 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json @@ -0,0 +1,70 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + } + ], + "action": "update", + "new_state": { + "value": { + "attributes": { + "createdb": false + }, + "postgres_role": "app_role" + } + }, + "remote_state": { + "create_time": "[TIMESTAMP]", + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": true, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + }, + "update_time": "[TIMESTAMP]" + }, + "changes": { + "attributes": { + "action": "update", + "old": { + "createdb": true + }, + "new": { + "createdb": false + } + }, + "attributes.createdb": { + "action": "update", + "old": true, + "new": false + }, + "parent": { + "action": "skip", + "reason": "remote_already_set", + "old": "", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "remote": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main" + }, + "postgres_role": { + "action": "skip", + "reason": "spec:input_only", + "old": "app_role", + "new": "app_role" + }, + "role_id": { + "action": "skip", + "reason": "remote_already_set", + "old": "", + "new": "test-role", + "remote": "test-role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.terraform.json new file mode 100644 index 00000000000..375b496fef0 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "update" +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json new file mode 100644 index 00000000000..7917cf60e44 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json @@ -0,0 +1,70 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + } + ], + "action": "update", + "new_state": { + "value": { + "attributes": { + "createdb": true + }, + "postgres_role": "app_role" + } + }, + "remote_state": { + "create_time": "[TIMESTAMP]", + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": false, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + }, + "update_time": "[TIMESTAMP]" + }, + "changes": { + "attributes": { + "action": "update", + "old": { + "createdb": false + }, + "new": { + "createdb": true + } + }, + "attributes.createdb": { + "action": "update", + "old": false, + "new": true + }, + "parent": { + "action": "skip", + "reason": "remote_already_set", + "old": "", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "remote": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main" + }, + "postgres_role": { + "action": "skip", + "reason": "spec:input_only", + "old": "app_role", + "new": "app_role" + }, + "role_id": { + "action": "skip", + "reason": "remote_already_set", + "old": "", + "new": "test-role", + "remote": "test-role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.update.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.update.terraform.json new file mode 100644 index 00000000000..375b496fef0 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.update.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "update" +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.create.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.create.direct.json new file mode 100644 index 00000000000..b102e4bb50a --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.create.direct.json @@ -0,0 +1,41 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Role Update", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "test-role" + }, + "body": { + "spec": { + "attributes": { + "createdb": false + }, + "postgres_role": "app_role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.create.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.create.terraform.json new file mode 100644 index 00000000000..0a6bbfc61da --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.create.terraform.json @@ -0,0 +1,43 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Role Update", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "test-role" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "attributes": { + "createdb": false + }, + "postgres_role": "app_role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.direct.json new file mode 100644 index 00000000000..1d23ba72202 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.direct.json @@ -0,0 +1,20 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.terraform.json new file mode 100644 index 00000000000..4296a1779c8 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.no_change.terraform.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.restore.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.restore.direct.json new file mode 100644 index 00000000000..198552d19ed --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.restore.direct.json @@ -0,0 +1,35 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "q": { + "update_mask": "spec.attributes.createdb" + }, + "body": { + "spec": { + "attributes": { + "createdb": false + }, + "postgres_role": "app_role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.restore.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.restore.terraform.json new file mode 100644 index 00000000000..d62792a95a0 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.restore.terraform.json @@ -0,0 +1,29 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "q": { + "update_mask": "spec" + }, + "body": { + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "attributes": { + "createdb": false + }, + "postgres_role": "app_role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.update.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.update.direct.json new file mode 100644 index 00000000000..cdf033ce54f --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.update.direct.json @@ -0,0 +1,35 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "q": { + "update_mask": "spec.attributes.createdb" + }, + "body": { + "spec": { + "attributes": { + "createdb": true + }, + "postgres_role": "app_role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.requests.update.terraform.json b/acceptance/bundle/resources/postgres_roles/update/out.requests.update.terraform.json new file mode 100644 index 00000000000..3f56f596530 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.requests.update.terraform.json @@ -0,0 +1,29 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "q": { + "update_mask": "spec" + }, + "body": { + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "attributes": { + "createdb": true + }, + "postgres_role": "app_role" + } + } +} diff --git a/acceptance/bundle/resources/postgres_roles/update/out.test.toml b/acceptance/bundle/resources/postgres_roles/update/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/postgres_roles/update/output.txt b/acceptance/bundle/resources/postgres_roles/update/output.txt new file mode 100644 index 00000000000..beefa368ce4 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/output.txt @@ -0,0 +1,176 @@ + +=== Initial deployment +>>> [CLI] bundle validate +Name: update-postgres-role-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/update-postgres-role-[UNIQUE_NAME]/default + +Validation OK! + +>>> [CLI] bundle plan +create postgres_branches.main +create postgres_projects.my_project +create postgres_roles.my_role + +Plan: 3 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] postgres get-role projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": false, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + } +} + +=== Verify no changes +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 3 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +=== Toggle attributes.createdb and re-deploy +>>> update_file.py databricks.yml createdb: false createdb: true + +>>> cat databricks.yml +bundle: + name: update-postgres-role-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Role Update" + pg_version: 16 + history_retention_duration: 604800s + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role + postgres_role: app_role + attributes: + createdb: true + +>>> [CLI] bundle plan +update postgres_roles.my_role + +Plan: 0 to add, 1 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] postgres get-role projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": true, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + } +} + +=== Restore attributes.createdb to original value +>>> update_file.py databricks.yml createdb: true createdb: false + +>>> [CLI] bundle plan +update postgres_roles.my_role + +Plan: 0 to add, 1 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] postgres get-role projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": false, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role", + "role_id": "test-role" + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + delete resources.postgres_roles.my_role + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/update-postgres-role-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_roles/update/script b/acceptance/bundle/resources/postgres_roles/update/script new file mode 100644 index 00000000000..de57297e70b --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/script @@ -0,0 +1,59 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +print_requests() { + local name=$1 + trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.${name}.$DATABRICKS_BUNDLE_ENGINE.json + rm -f out.requests.txt +} + +title "Initial deployment" +trace $CLI bundle validate +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_roles.my_role" // .' > out.plan.create.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests create + +project_name="projects/test-pg-proj-${UNIQUE_NAME}" +branch_name="${project_name}/branches/main" +role_name="${branch_name}/roles/test-role" +trace $CLI postgres get-role "${role_name}" | jq 'del(.create_time, .update_time)' + +title "Verify no changes" +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_roles.my_role" // .' > out.plan.no_change.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests no_change + +title "Toggle attributes.createdb and re-deploy" +trace update_file.py databricks.yml "createdb: false" "createdb: true" +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_roles.my_role" // .' > out.plan.update.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests update + +trace $CLI postgres get-role "${role_name}" | jq 'del(.create_time, .update_time)' + +title "Restore attributes.createdb to original value" +trace update_file.py databricks.yml "createdb: true" "createdb: false" +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_roles.my_role" // .' > out.plan.restore.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests restore + +trace $CLI postgres get-role "${role_name}" | jq 'del(.create_time, .update_time)' diff --git a/acceptance/bundle/resources/postgres_roles/update/test.toml b/acceptance/bundle/resources/postgres_roles/update/test.toml new file mode 100644 index 00000000000..f8b3bbe49dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/test.toml @@ -0,0 +1 @@ +# All configuration inherited from parent test.toml diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index 88233279729..4d2851f322b 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -29,6 +29,7 @@ var unsupportedResources = []string{ "postgres_branches", "postgres_databases", "postgres_endpoints", + "postgres_roles", } func TestApplyBundlePermissions(t *testing.T) { diff --git a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go index e69ba5422f9..f97362d214e 100644 --- a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go +++ b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go @@ -255,6 +255,17 @@ func mockBundle(mode config.Mode) *bundle.Bundle { }, }, }, + PostgresRoles: map[string]*resources.PostgresRole{ + "postgres_role1": { + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "postgres-role-1", + Parent: "projects/postgres-project-1/branches/postgres-branch-1", + RoleRoleSpec: postgres.RoleRoleSpec{ + PostgresRole: "postgres_role_1", + }, + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "vs_endpoint1": { CreateEndpoint: vectorsearch.CreateEndpoint{ diff --git a/bundle/config/mutator/resourcemutator/run_as_test.go b/bundle/config/mutator/resourcemutator/run_as_test.go index ea75d4a1c46..224e7f0a104 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -49,6 +49,7 @@ func allResourceTypes(t *testing.T) []string { "postgres_databases", "postgres_endpoints", "postgres_projects", + "postgres_roles", "quality_monitors", "registered_models", "schemas", @@ -178,6 +179,7 @@ var allowList = []string{ "postgres_databases", "postgres_endpoints", "postgres_projects", + "postgres_roles", "registered_models", "experiments", "schemas", diff --git a/bundle/config/resources.go b/bundle/config/resources.go index cd08242dcb3..b9bec2a89e3 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -36,6 +36,7 @@ type Resources struct { PostgresBranches map[string]*resources.PostgresBranch `json:"postgres_branches,omitempty"` PostgresEndpoints map[string]*resources.PostgresEndpoint `json:"postgres_endpoints,omitempty"` PostgresDatabases map[string]*resources.PostgresDatabase `json:"postgres_databases,omitempty"` + PostgresRoles map[string]*resources.PostgresRole `json:"postgres_roles,omitempty"` VectorSearchEndpoints map[string]*resources.VectorSearchEndpoint `json:"vector_search_endpoints,omitempty"` } @@ -114,6 +115,7 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["postgres_branches"], r.PostgresBranches), collectResourceMap(descriptions["postgres_endpoints"], r.PostgresEndpoints), collectResourceMap(descriptions["postgres_databases"], r.PostgresDatabases), + collectResourceMap(descriptions["postgres_roles"], r.PostgresRoles), collectResourceMap(descriptions["vector_search_endpoints"], r.VectorSearchEndpoints), } } @@ -170,6 +172,7 @@ func SupportedResources() map[string]resources.ResourceDescription { "postgres_branches": (&resources.PostgresBranch{}).ResourceDescription(), "postgres_endpoints": (&resources.PostgresEndpoint{}).ResourceDescription(), "postgres_databases": (&resources.PostgresDatabase{}).ResourceDescription(), + "postgres_roles": (&resources.PostgresRole{}).ResourceDescription(), "vector_search_endpoints": (&resources.VectorSearchEndpoint{}).ResourceDescription(), } } diff --git a/bundle/config/resources/postgres_role.go b/bundle/config/resources/postgres_role.go new file mode 100644 index 00000000000..34c2f5fcae4 --- /dev/null +++ b/bundle/config/resources/postgres_role.go @@ -0,0 +1,71 @@ +package resources + +import ( + "context" + "net/url" + + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/marshal" + "github.com/databricks/databricks-sdk-go/service/postgres" +) + +type PostgresRoleConfig struct { + postgres.RoleRoleSpec + + // RoleId is the user-specified ID for the role (becomes part of the hierarchical name). + // This is specified during creation and becomes part of Name: "projects/{project_id}/branches/{branch_id}/roles/{role_id}" + RoleId string `json:"role_id"` + + // Parent is the branch containing this role. Format: "projects/{project_id}/branches/{branch_id}" + Parent string `json:"parent"` +} + +func (c *PostgresRoleConfig) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, c) +} + +func (c *PostgresRoleConfig) MarshalJSON() ([]byte, error) { + return marshal.Marshal(c) +} + +type PostgresRole struct { + BaseResource + PostgresRoleConfig +} + +func (r *PostgresRole) Exists(ctx context.Context, w *databricks.WorkspaceClient, name string) (bool, error) { + _, err := w.Postgres.GetRole(ctx, postgres.GetRoleRequest{Name: name}) + if apierr.IsMissing(err) { + log.Debugf(ctx, "postgres role %s does not exist", name) + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func (r *PostgresRole) ResourceDescription() ResourceDescription { + return ResourceDescription{ + SingularName: "postgres_role", + PluralName: "postgres_roles", + SingularTitle: "Postgres role", + PluralTitle: "Postgres roles", + } +} + +func (r *PostgresRole) GetName() string { + // Roles don't have a user-visible name field. + return "" +} + +func (r *PostgresRole) GetURL() string { + // The IDs in the API do not (yet) map to IDs in the web UI. + return "" +} + +func (r *PostgresRole) InitializeURL(_ url.URL) { + // The IDs in the API do not (yet) map to IDs in the web UI. +} diff --git a/bundle/config/resources_test.go b/bundle/config/resources_test.go index 4411ccc79d1..42a17b0a9b4 100644 --- a/bundle/config/resources_test.go +++ b/bundle/config/resources_test.go @@ -281,6 +281,17 @@ func TestResourcesBindSupport(t *testing.T) { }, }, }, + PostgresRoles: map[string]*resources.PostgresRole{ + "my_postgres_role": { + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "my-postgres-role", + Parent: "projects/my-postgres-project/branches/my-postgres-branch", + RoleRoleSpec: postgres.RoleRoleSpec{ + PostgresRole: "my_postgres_role", + }, + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "my_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -321,6 +332,7 @@ func TestResourcesBindSupport(t *testing.T) { m.GetMockPostgresAPI().EXPECT().GetBranch(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetEndpoint(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetDatabase(mock.Anything, mock.Anything).Return(nil, nil) + m.GetMockPostgresAPI().EXPECT().GetRole(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockVectorSearchEndpointsAPI().EXPECT().GetEndpoint(mock.Anything, mock.Anything).Return(nil, nil) allResources := supportedResources.AllResources() diff --git a/bundle/deploy/terraform/interpolate.go b/bundle/deploy/terraform/interpolate.go index 52c93b87623..9daadbe1efd 100644 --- a/bundle/deploy/terraform/interpolate.go +++ b/bundle/deploy/terraform/interpolate.go @@ -16,7 +16,7 @@ type interpolateMutator struct{} // Postgres resources use "name" instead of "id" as their identifier attribute. func isPostgresResource(resourceType string) bool { switch resourceType { - case "postgres_projects", "postgres_branches", "postgres_databases", "postgres_endpoints": + case "postgres_projects", "postgres_branches", "postgres_databases", "postgres_endpoints", "postgres_roles": return true default: return false diff --git a/bundle/deploy/terraform/pkg.go b/bundle/deploy/terraform/pkg.go index f120cfff890..570615bb346 100644 --- a/bundle/deploy/terraform/pkg.go +++ b/bundle/deploy/terraform/pkg.go @@ -130,6 +130,7 @@ var GroupToTerraformName = map[string]string{ "postgres_branches": "databricks_postgres_branch", "postgres_databases": "databricks_postgres_database", "postgres_endpoints": "databricks_postgres_endpoint", + "postgres_roles": "databricks_postgres_role", // 3 level groups: resources.*.GROUP "permissions": "databricks_permissions", diff --git a/bundle/deploy/terraform/tfdyn/convert_postgres_role.go b/bundle/deploy/terraform/tfdyn/convert_postgres_role.go new file mode 100644 index 00000000000..d1a2449f22e --- /dev/null +++ b/bundle/deploy/terraform/tfdyn/convert_postgres_role.go @@ -0,0 +1,63 @@ +package tfdyn + +import ( + "context" + + "github.com/databricks/cli/bundle/internal/tf/schema" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/convert" + "github.com/databricks/cli/libs/log" +) + +type postgresRoleConverter struct{} + +func (c postgresRoleConverter) Convert(ctx context.Context, key string, vin dyn.Value, out *schema.Resources) error { + // The bundle config has flattened RoleRoleSpec fields at the top level. + // Terraform expects them nested in a "spec" block. + specFields := specFieldNames(schema.ResourcePostgresRoleSpec{}) + topLevelFields := []string{"role_id", "parent"} + + // Build the spec block from the flattened fields + specMap := make(map[string]dyn.Value) + for _, field := range specFields { + if v := vin.Get(field); v.Kind() != dyn.KindInvalid { + specMap[field] = v + } + } + + // Build the output with top-level fields and spec + outMap := make(map[string]dyn.Value) + + // Keep top-level fields + for _, field := range topLevelFields { + if v := vin.Get(field); v.Kind() != dyn.KindInvalid { + outMap[field] = v + } + } + + // Add spec block if we have any spec fields + if len(specMap) > 0 { + outMap["spec"] = dyn.V(specMap) + } + + vout := dyn.V(outMap) + + // Normalize the output value to the Terraform schema. + vout, diags := convert.Normalize(schema.ResourcePostgresRole{}, vout) + for _, diag := range diags { + log.Debugf(ctx, "postgres role normalization diagnostic: %s", diag.Summary) + } + + vout, err := convertLifecycle(ctx, vout, vin.Get("lifecycle")) + if err != nil { + return err + } + + out.PostgresRole[key] = vout.AsAny() + + return nil +} + +func init() { + registerConverter("postgres_roles", postgresRoleConverter{}) +} diff --git a/bundle/deploy/terraform/tfdyn/convert_postgres_role_test.go b/bundle/deploy/terraform/tfdyn/convert_postgres_role_test.go new file mode 100644 index 00000000000..2fd11988ec0 --- /dev/null +++ b/bundle/deploy/terraform/tfdyn/convert_postgres_role_test.go @@ -0,0 +1,81 @@ +package tfdyn + +import ( + "testing" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/bundle/internal/tf/schema" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/convert" + "github.com/databricks/databricks-sdk-go/service/postgres" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertPostgresRole(t *testing.T) { + src := resources.PostgresRole{ + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "my-role", + Parent: "projects/my-project/branches/main", + RoleRoleSpec: postgres.RoleRoleSpec{ + PostgresRole: "my_postgres_role", + IdentityType: postgres.RoleIdentityTypeUser, + AuthMethod: postgres.RoleAuthMethodLakebaseOauthV1, + Attributes: &postgres.RoleAttributes{ + Createdb: true, + }, + }, + }, + } + + vin, err := convert.FromTyped(src, dyn.NilValue) + require.NoError(t, err) + + ctx := t.Context() + out := schema.NewResources() + err = postgresRoleConverter{}.Convert(ctx, "my_postgres_role", vin, out) + require.NoError(t, err) + + postgresRole := out.PostgresRole["my_postgres_role"] + assert.Equal(t, map[string]any{ + "role_id": "my-role", + "parent": "projects/my-project/branches/main", + "spec": map[string]any{ + "postgres_role": "my_postgres_role", + "identity_type": "USER", + "auth_method": "LAKEBASE_OAUTH_V1", + "attributes": map[string]any{ + "createdb": true, + }, + }, + }, postgresRole) +} + +func TestConvertPostgresRoleMinimal(t *testing.T) { + src := resources.PostgresRole{ + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "minimal-role", + Parent: "projects/my-project/branches/main", + RoleRoleSpec: postgres.RoleRoleSpec{ + PostgresRole: "minimal_role", + }, + }, + } + + vin, err := convert.FromTyped(src, dyn.NilValue) + require.NoError(t, err) + + ctx := t.Context() + out := schema.NewResources() + err = postgresRoleConverter{}.Convert(ctx, "minimal_postgres_role", vin, out) + require.NoError(t, err) + + postgresRole := out.PostgresRole["minimal_postgres_role"] + assert.Equal(t, map[string]any{ + "role_id": "minimal-role", + "parent": "projects/my-project/branches/main", + "spec": map[string]any{ + "postgres_role": "minimal_role", + }, + }, postgresRole) +} diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index 96a7b405005..24500d21833 100644 --- a/bundle/deploy/terraform/util.go +++ b/bundle/deploy/terraform/util.go @@ -96,7 +96,7 @@ func parseResourcesState(ctx context.Context, path string) (ExportedResourcesMap // The direct engine manages permissions as a sub-resource // (SecretScopeFixups adds MANAGE ACL for the current user). result[resourceKey+".permissions"] = ResourceState{ID: instance.Attributes.Name} - case "apps", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_databases", "postgres_endpoints": + case "apps", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_databases", "postgres_endpoints", "postgres_roles": resourceKey = "resources." + groupName + "." + resource.Name resourceState = ResourceState{ID: instance.Attributes.Name} case "dashboards": diff --git a/bundle/direct/dresources/all.go b/bundle/direct/dresources/all.go index 75859812d92..14d9e214103 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -24,6 +24,7 @@ var SupportedResources = map[string]any{ "postgres_branches": (*ResourcePostgresBranch)(nil), "postgres_endpoints": (*ResourcePostgresEndpoint)(nil), "postgres_databases": (*ResourcePostgresDatabase)(nil), + "postgres_roles": (*ResourcePostgresRole)(nil), "alerts": (*ResourceAlert)(nil), "clusters": (*ResourceCluster)(nil), "registered_models": (*ResourceRegisteredModel)(nil), diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 93383b4bddc..a985a756893 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -705,6 +705,41 @@ var testDeps = map[string]prepareWorkspace{ }, }, nil }, + "postgres_roles": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { + // Create parent project first + _, err := client.Postgres.CreateProject(ctx, postgres.CreateProjectRequest{ + ProjectId: "test-project-for-role", + Project: postgres.Project{ + Spec: &postgres.ProjectSpec{ + DisplayName: "Test Project for Role", + PgVersion: 16, + }, + }, + }) + if err != nil { + return nil, err + } + + // Create parent branch + _, err = client.Postgres.CreateBranch(ctx, postgres.CreateBranchRequest{ + Parent: "projects/test-project-for-role", + BranchId: "test-branch-for-role", + Branch: postgres.Branch{}, + }) + if err != nil { + return nil, err + } + + return &resources.PostgresRole{ + PostgresRoleConfig: resources.PostgresRoleConfig{ + Parent: "projects/test-project-for-role/branches/test-branch-for-role", + RoleId: "test-role", + RoleRoleSpec: postgres.RoleRoleSpec{ + PostgresRole: "test_role", + }, + }, + }, nil + }, } func TestAll(t *testing.T) { diff --git a/bundle/direct/dresources/apitypes.generated.yml b/bundle/direct/dresources/apitypes.generated.yml index e077dc2ec62..3edc5c0d938 100644 --- a/bundle/direct/dresources/apitypes.generated.yml +++ b/bundle/direct/dresources/apitypes.generated.yml @@ -34,6 +34,8 @@ postgres_endpoints: postgres.EndpointSpec postgres_projects: postgres.ProjectStatus +postgres_roles: postgres.RoleRoleStatus + quality_monitors: catalog.CreateMonitor registered_models: catalog.RegisteredModelInfo diff --git a/bundle/direct/dresources/apitypes.yml b/bundle/direct/dresources/apitypes.yml index db72f3eb5ae..e98ab290c65 100644 --- a/bundle/direct/dresources/apitypes.yml +++ b/bundle/direct/dresources/apitypes.yml @@ -11,3 +11,5 @@ postgres_databases: postgres.DatabaseDatabaseSpec postgres_endpoints: postgres.EndpointSpec postgres_projects: postgres.ProjectSpec + +postgres_roles: postgres.RoleRoleSpec diff --git a/bundle/direct/dresources/postgres_role.go b/bundle/direct/dresources/postgres_role.go new file mode 100644 index 00000000000..2a7d03f6e57 --- /dev/null +++ b/bundle/direct/dresources/postgres_role.go @@ -0,0 +1,151 @@ +// Postgres Role resource for the direct deployment engine. +// +// Terraform resource: databricks_postgres_role +// +// https://registry.terraform.io/providers/databricks/databricks/latest/docs/resources/postgres_role +// +// REST API: Lakebase Postgres Roles +// +// https://docs.databricks.com/api/workspace/postgres/createrole +package dresources + +import ( + "context" + "strings" + + "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/postgres" +) + +type ResourcePostgresRole struct { + client *databricks.WorkspaceClient +} + +// PostgresRoleState keeps role_id and parent as separate fields rather than a +// pre-joined hierarchical name. That alignment matters because bundle variable +// resolution only rewrites state fields whose JSON paths appear in the input +// config's refs map (parent, role_id, etc.); a synthesized "name" field built +// from input.Parent at PrepareState time would keep the literal ${...} string +// when parent comes from a resource reference. +type PostgresRoleState struct { + postgres.RoleRoleSpec + + // RoleId is the leaf id, matching the user-facing config. + RoleId string `json:"role_id"` + + // Parent is "projects/{project_id}/branches/{branch_id}". + Parent string `json:"parent"` +} + +func (*ResourcePostgresRole) New(client *databricks.WorkspaceClient) *ResourcePostgresRole { + return &ResourcePostgresRole{client: client} +} + +func (*ResourcePostgresRole) PrepareState(input *resources.PostgresRole) *PostgresRoleState { + return &PostgresRoleState{ + RoleId: input.RoleId, + Parent: input.Parent, + RoleRoleSpec: input.RoleRoleSpec, + } +} + +func (*ResourcePostgresRole) RemapState(remote *postgres.Role) *PostgresRoleState { + return &PostgresRoleState{ + // Derive role_id from the hierarchical name: "/roles/". + RoleId: strings.TrimPrefix(remote.Name, remote.Parent+"/roles/"), + Parent: remote.Parent, + + // The read API does not return the spec, only the status. + // This means we cannot detect remote drift for spec fields. + // Use an empty struct (not nil) so field-level diffing works correctly. + RoleRoleSpec: postgres.RoleRoleSpec{ + Attributes: nil, + AuthMethod: "", + IdentityType: "", + MembershipRoles: nil, + PostgresRole: "", + ForceSendFields: nil, + }, + } +} + +func (r *ResourcePostgresRole) DoRead(ctx context.Context, id string) (*postgres.Role, error) { + return r.client.Postgres.GetRole(ctx, postgres.GetRoleRequest{Name: id}) +} + +func (r *ResourcePostgresRole) DoCreate(ctx context.Context, config *PostgresRoleState) (string, *postgres.Role, error) { + waiter, err := r.client.Postgres.CreateRole(ctx, postgres.CreateRoleRequest{ + RoleId: config.RoleId, + Parent: config.Parent, + Role: postgres.Role{ + Spec: &config.RoleRoleSpec, + + // Output-only fields. + CreateTime: nil, + Name: "", + Parent: "", + Status: nil, + UpdateTime: nil, + ForceSendFields: nil, + }, + ForceSendFields: nil, + }) + if err != nil { + return "", nil, err + } + + result, err := waiter.Wait(ctx) + if err != nil { + return "", nil, err + } + + return result.Name, result, nil +} + +func (r *ResourcePostgresRole) DoUpdate(ctx context.Context, id string, config *PostgresRoleState, entry *PlanEntry) (*postgres.Role, error) { + // Build update mask from fields that have action="update" in the changes map. + // Prefix with "spec." because the API expects paths relative to the Role + // object, not relative to our flattened state type. + fieldPaths := collectUpdatePathsWithPrefix(entry.Changes, "spec.") + + waiter, err := r.client.Postgres.UpdateRole(ctx, postgres.UpdateRoleRequest{ + Name: id, + Role: postgres.Role{ + Spec: &config.RoleRoleSpec, + + // Output-only fields. + CreateTime: nil, + Name: "", + Parent: "", + Status: nil, + UpdateTime: nil, + ForceSendFields: nil, + }, + UpdateMask: fieldmask.FieldMask{ + Paths: fieldPaths, + }, + }) + if err != nil { + return nil, err + } + + return waiter.Wait(ctx) +} + +func (r *ResourcePostgresRole) DoDelete(ctx context.Context, id string) error { + waiter, err := r.client.Postgres.DeleteRole(ctx, postgres.DeleteRoleRequest{ + Name: id, + + // ReassignOwnedTo is intentionally unset; honoring it would require + // user-facing config we don't expose, and it would spin up compute to + // run reassignment SQL. + ReassignOwnedTo: "", + ForceSendFields: nil, + }) + if err != nil { + return err + } + return waiter.Wait(ctx) +} diff --git a/bundle/direct/dresources/resources.generated.yml b/bundle/direct/dresources/resources.generated.yml index 6fb412ea400..b75b92e077c 100644 --- a/bundle/direct/dresources/resources.generated.yml +++ b/bundle/direct/dresources/resources.generated.yml @@ -264,6 +264,20 @@ resources: - field: pg_version reason: spec:input_only + postgres_roles: + + ignore_remote_changes: + - field: attributes + reason: spec:input_only + - field: auth_method + reason: spec:input_only + - field: identity_type + reason: spec:input_only + - field: membership_roles + reason: spec:input_only + - field: postgres_role + reason: spec:input_only + # quality_monitors: no api field behaviors # registered_models: no api field behaviors diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 6eb9a642da5..f651b0acb29 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -516,6 +516,14 @@ resources: - field: endpoint_id reason: immutable + postgres_roles: + recreate_on_changes: + # parent and role_id are immutable (together they form the hierarchical name). + - field: parent + reason: immutable + - field: role_id + reason: immutable + vector_search_endpoints: recreate_on_changes: - field: endpoint_type diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 60f61eb1aea..17eb24c08fc 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -80,6 +80,17 @@ var knownMissingInRemoteType = map[string][]string{ "pg_version", "project_id", }, + "postgres_roles": { + // RoleRoleSpec fields live under spec.* on the remote Role, not at top level. + "attributes", + "auth_method", + "identity_type", + "membership_roles", + "postgres_role", + // role_id is the leaf id derived from the hierarchical name; the + // remote Role only exposes the full Name. + "role_id", + }, "vector_search_endpoints": { "target_qps", "usage_policy_id", diff --git a/bundle/direct/dresources/util.go b/bundle/direct/dresources/util.go index 0e6f302e5c8..4d539600072 100644 --- a/bundle/direct/dresources/util.go +++ b/bundle/direct/dresources/util.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "regexp" + "strings" "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/libs/structs/structpath" @@ -54,10 +55,28 @@ func shouldRetry(err error) bool { // collectUpdatePathsWithPrefix extracts field paths from Changes that have action=Update, // adding a prefix to each path. This is used when the state type has a flattened structure // but the API expects paths relative to a nested object (e.g., "spec.display_name"). +// +// Parent paths are dropped when a more specific child path is also present, because +// servers typically reject an update_mask that contains both a parent and a child (the +// parent implies the whole subtree must be provided). E.g. {"attributes", +// "attributes.createdb"} collapses to {"attributes.createdb"}. func collectUpdatePathsWithPrefix(changes Changes, prefix string) []string { var paths []string for path, change := range changes { - if change.Action == deployplan.Update { + if change.Action != deployplan.Update { + continue + } + hasChild := false + for other := range changes { + if other == path || changes[other].Action != deployplan.Update { + continue + } + if strings.HasPrefix(other, path+".") { + hasChild = true + break + } + } + if !hasChild { paths = append(paths, prefix+path) } } diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 52af791558e..25e57ed4178 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -224,6 +224,9 @@ github.com/databricks/cli/bundle/config.Resources: "postgres_projects": "description": |- PLACEHOLDER + "postgres_roles": + "description": |- + PLACEHOLDER "quality_monitors": "description": |- The quality monitor definitions for the bundle, where each key is the name of the quality monitor. @@ -916,6 +919,31 @@ github.com/databricks/cli/bundle/config/resources.PostgresProject: "update_time": "description": |- PLACEHOLDER +github.com/databricks/cli/bundle/config/resources.PostgresRole: + "attributes": + "description": |- + The desired API-exposed Postgres role attributes to associate with the role. + "auth_method": + "description": |- + How the role is authenticated when connecting to Postgres. If left unspecified, a meaningful authentication method is derived from the identity_type. + "identity_type": + "description": |- + The type of the Databricks managed identity that this Role represents. Leave empty to create a regular Postgres role not associated with a Databricks identity. + "lifecycle": + "description": |- + PLACEHOLDER + "membership_roles": + "description": |- + Standard roles that this role is a member of. + "parent": + "description": |- + The branch where this role is created. Format projects/{project_id}/branches/{branch_id}. + "postgres_role": + "description": |- + The name of the Postgres role. Required when creating the role. + "role_id": + "description": |- + The user-specified role ID; becomes the final component of the role's resource name. Must be 4-63 characters, lowercase letters, numbers, and hyphens (RFC 1123). github.com/databricks/cli/bundle/config/resources.SecretScope: "backend_type": "description": |- diff --git a/bundle/internal/validation/generated/enum_fields.go b/bundle/internal/validation/generated/enum_fields.go index 2f1593a890e..6a0864ee892 100644 --- a/bundle/internal/validation/generated/enum_fields.go +++ b/bundle/internal/validation/generated/enum_fields.go @@ -184,6 +184,10 @@ var EnumFields = map[string][]string{ "resources.postgres_projects.*.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.postgres_roles.*.auth_method": {"LAKEBASE_OAUTH_V1", "NO_LOGIN", "PG_PASSWORD_SCRAM_SHA_256"}, + "resources.postgres_roles.*.identity_type": {"GROUP", "SERVICE_PRINCIPAL", "USER"}, + "resources.postgres_roles.*.membership_roles[*]": {"DATABRICKS_SUPERUSER"}, + "resources.quality_monitors.*.custom_metrics[*].type": {"CUSTOM_METRIC_TYPE_AGGREGATE", "CUSTOM_METRIC_TYPE_DERIVED", "CUSTOM_METRIC_TYPE_DRIFT"}, "resources.quality_monitors.*.inference_log.problem_type": {"PROBLEM_TYPE_CLASSIFICATION", "PROBLEM_TYPE_REGRESSION"}, "resources.quality_monitors.*.schedule.pause_status": {"PAUSED", "UNPAUSED", "UNSPECIFIED"}, diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index ff6af8bae0a..62422df2f6f 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -227,6 +227,8 @@ var RequiredFields = map[string][]string{ "resources.postgres_projects.*": {"project_id"}, "resources.postgres_projects.*.permissions[*]": {"level"}, + "resources.postgres_roles.*": {"role_id", "parent"}, + "resources.quality_monitors.*": {"assets_dir", "output_schema_name", "table_name"}, "resources.quality_monitors.*.custom_metrics[*]": {"definition", "input_columns", "name", "output_data_type", "type"}, "resources.quality_monitors.*.inference_log": {"granularities", "model_id_col", "prediction_col", "problem_type", "timestamp_col"}, diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index bdeae14ef5d..e1970b9c081 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1544,6 +1544,55 @@ } ] }, + "resources.PostgresRole": { + "oneOf": [ + { + "type": "object", + "properties": { + "attributes": { + "description": "The desired API-exposed Postgres role attributes to associate with the role.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleAttributes" + }, + "auth_method": { + "description": "How the role is authenticated when connecting to Postgres. If left unspecified, a meaningful authentication method is derived from the identity_type.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleAuthMethod" + }, + "identity_type": { + "description": "The type of the Databricks managed identity that this Role represents. Leave empty to create a regular Postgres role not associated with a Databricks identity.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleIdentityType" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "membership_roles": { + "description": "Standard roles that this role is a member of.", + "$ref": "#/$defs/slice/github.com/databricks/databricks-sdk-go/service/postgres.RoleMembershipRole" + }, + "parent": { + "description": "The branch where this role is created. Format projects/{project_id}/branches/{branch_id}.", + "$ref": "#/$defs/string" + }, + "postgres_role": { + "description": "The name of the Postgres role. Required when creating the role.", + "$ref": "#/$defs/string" + }, + "role_id": { + "description": "The user-specified role ID; becomes the final component of the role's resource name. Must be 4-63 characters, lowercase letters, numbers, and hyphens (RFC 1123).", + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "role_id", + "parent" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.QualityMonitor": { "oneOf": [ { @@ -2564,6 +2613,9 @@ "postgres_projects": { "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresProject" }, + "postgres_roles": { + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresRole" + }, "quality_monitors": { "description": "The quality monitor definitions for the bundle, where each key is the name of the quality monitor.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.QualityMonitor", @@ -9913,6 +9965,38 @@ } ] }, + "postgres.RoleAttributes": { + "oneOf": [ + { + "type": "object", + "properties": { + "bypassrls": { + "$ref": "#/$defs/bool" + }, + "createdb": { + "$ref": "#/$defs/bool" + }, + "createrole": { + "$ref": "#/$defs/bool" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "postgres.RoleAuthMethod": { + "type": "string" + }, + "postgres.RoleIdentityType": { + "type": "string" + }, + "postgres.RoleMembershipRole": { + "type": "string" + }, "serving.Ai21LabsConfig": { "oneOf": [ { @@ -11822,6 +11906,20 @@ } ] }, + "resources.PostgresRole": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresRole" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.QualityMonitor": { "oneOf": [ { @@ -12679,6 +12777,20 @@ } ] }, + "postgres.RoleMembershipRole": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleMembershipRole" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "serving.AiGatewayRateLimit": { "oneOf": [ { diff --git a/bundle/schema/jsonschema_for_docs.json b/bundle/schema/jsonschema_for_docs.json index d23b5b50012..5563e11faca 100644 --- a/bundle/schema/jsonschema_for_docs.json +++ b/bundle/schema/jsonschema_for_docs.json @@ -1524,6 +1524,47 @@ "project_id" ] }, + "resources.PostgresRole": { + "type": "object", + "properties": { + "attributes": { + "description": "The desired API-exposed Postgres role attributes to associate with the role.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleAttributes" + }, + "auth_method": { + "description": "How the role is authenticated when connecting to Postgres. If left unspecified, a meaningful authentication method is derived from the identity_type.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleAuthMethod" + }, + "identity_type": { + "description": "The type of the Databricks managed identity that this Role represents. Leave empty to create a regular Postgres role not associated with a Databricks identity.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleIdentityType" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "membership_roles": { + "description": "Standard roles that this role is a member of.", + "$ref": "#/$defs/slice/github.com/databricks/databricks-sdk-go/service/postgres.RoleMembershipRole" + }, + "parent": { + "description": "The branch where this role is created. Format projects/{project_id}/branches/{branch_id}.", + "$ref": "#/$defs/string" + }, + "postgres_role": { + "description": "The name of the Postgres role. Required when creating the role.", + "$ref": "#/$defs/string" + }, + "role_id": { + "description": "The user-specified role ID; becomes the final component of the role's resource name. Must be 4-63 characters, lowercase letters, numbers, and hyphens (RFC 1123).", + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "role_id", + "parent" + ] + }, "resources.QualityMonitor": { "type": "object", "properties": { @@ -1967,7 +2008,8 @@ "x-since-version": "v0.298.0" }, "target_qps": { - "$ref": "#/$defs/int64" + "$ref": "#/$defs/int64", + "x-since-version": "v0.299.2" }, "usage_policy_id": { "$ref": "#/$defs/string", @@ -2522,6 +2564,9 @@ "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresProject", "x-since-version": "v0.287.0" }, + "postgres_roles": { + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresRole" + }, "quality_monitors": { "description": "The quality monitor definitions for the bundle, where each key is the name of the quality monitor.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.QualityMonitor", @@ -4314,7 +4359,8 @@ "description": "The confidential computing technology for this cluster's instances.\nCurrently only SEV_SNP is supported, and only on N2D instance types.\nWhen not set, no confidential computing is applied.", "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/compute.ConfidentialComputeType", "x-databricks-preview": "PRIVATE", - "doNotSuggest": true + "doNotSuggest": true, + "x-since-version": "v0.299.2" }, "first_on_demand": { "description": "The first `first_on_demand` nodes of the cluster will be placed on on-demand instances.\nThis value should be greater than 0, to make sure the cluster driver node is placed on an\non-demand instance. If this value is greater than or equal to the current cluster size, all\nnodes will be placed on on-demand instances. If this value is less than the current cluster\nsize, `first_on_demand` nodes will be placed on on-demand instances and the remainder will\nbe placed on `availability` instances. Note that this value does not affect\ncluster size and cannot currently be mutated over the lifetime of a cluster.", @@ -6787,7 +6833,8 @@ "properties": { "include_confluence_spaces": { "description": "(Optional) Spaces to filter Confluence data on", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -6811,7 +6858,8 @@ "properties": { "confluence_options": { "description": "Confluence specific options for ingestion", - "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.ConfluenceConnectorOptions" + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.ConfluenceConnectorOptions", + "x-since-version": "v0.299.2" }, "gdrive_options": { "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.GoogleDriveOptions", @@ -6828,17 +6876,20 @@ }, "jira_options": { "description": "Jira specific options for ingestion", - "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.JiraConnectorOptions" + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.JiraConnectorOptions", + "x-since-version": "v0.299.2" }, "meta_ads_options": { "description": "Meta Marketing (Meta Ads) specific options for ingestion", - "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.MetaMarketingOptions" + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.MetaMarketingOptions", + "x-since-version": "v0.299.2" }, "outlook_options": { "description": "Outlook specific options for ingestion", "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.OutlookOptions", "x-databricks-preview": "PRIVATE", - "doNotSuggest": true + "doNotSuggest": true, + "x-since-version": "v0.299.2" }, "sharepoint_options": { "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.SharepointOptions", @@ -6850,7 +6901,8 @@ "description": "Smartsheet specific options for ingestion", "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.SmartsheetOptions", "x-databricks-preview": "PRIVATE", - "doNotSuggest": true + "doNotSuggest": true, + "x-since-version": "v0.299.2" }, "tiktok_ads_options": { "description": "TikTok Ads specific options for ingestion", @@ -6863,7 +6915,8 @@ "description": "Zendesk Support specific options for ingestion", "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.ZendeskSupportOptions", "x-databricks-preview": "PRIVATE", - "doNotSuggest": true + "doNotSuggest": true, + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -7091,7 +7144,8 @@ "properties": { "manager_account_id": { "description": "(Required) Manager Account ID (also called MCC Account ID) used to list and access\ncustomer accounts under this manager account. This is required for fetching the list\nof customer accounts during source selection.\nIf the same field is also set in the object-level GoogleAdsOptions (connector_options),\nthe object-level value takes precedence over this top-level config.", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -7450,7 +7504,8 @@ "properties": { "include_jira_spaces": { "description": "(Optional) Projects to filter Jira data on", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -7465,35 +7520,43 @@ "properties": { "action_attribution_windows": { "description": "(Optional) Action attribution windows for insights reporting (e.g. \"28d_click\", \"1d_view\")", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "action_breakdowns": { "description": "(Optional) Action breakdowns to configure for data aggregation", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "action_report_time": { "description": "(Optional) Timing used to report action statistics (impression, conversion, mixed, or lifetime)", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" }, "breakdowns": { "description": "(Optional) Breakdowns to configure for data aggregation", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "custom_insights_lookback_window": { "description": "(Optional) Window in days to revisit data during sync to capture\nupdated conversion data from the API.", - "$ref": "#/$defs/int" + "$ref": "#/$defs/int", + "x-since-version": "v0.299.2" }, "level": { "description": "(Optional) Granularity of data to pull (account, ad, adset, campaign)", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" }, "start_date": { "description": "(Optional) Start date in yyyy-MM-dd format (e.g. 2025-01-15). Data added\nafter this date will be ingested", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" }, "time_increment": { "description": "(Optional) Value in string by which to aggregate statistics (can take all_days, monthly or number of days)", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -7574,48 +7637,58 @@ "properties": { "attachment_mode": { "description": "(Optional) Controls which attachments to ingest.\nIf not specified, defaults to ALL.", - "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.OutlookAttachmentMode" + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.OutlookAttachmentMode", + "x-since-version": "v0.299.2" }, "body_format": { "description": "(Optional) Defines how the body_content column is populated.\nTEXT_HTML: Preserves full formatting, links, and styling.\nTEXT_PLAIN: Converts body to plain text. Recommended for AI/RAG pipelines to reduce token usage and noise.", - "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.OutlookBodyFormat" + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.OutlookBodyFormat", + "x-since-version": "v0.299.2" }, "folder_filter": { "description": "Deprecated. Use include_folders instead.", "$ref": "#/$defs/slice/string", "deprecationMessage": "This field is deprecated", + "x-since-version": "v0.299.2", "deprecated": true }, "include_folders": { "description": "(Optional) Filter mail folders to include in the sync.\nIf not specified, all folders will be synced.\nExamples: Inbox, Sent Items, Custom_Folder\nFilter semantics: OR between different folders.", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "include_mailboxes": { "description": "(Optional) List of mailboxes to sync (e.g. mailbox email addresses or identifiers).\nIf not specified, all accessible mailboxes are ingested.\nFilter semantics: OR between different mailboxes.", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "include_senders": { "description": "(Optional) Filter emails by sender address. Uses exact email match.\nExamples: user@vendor.com, alerts@system.io, noreply@company.com\nIf not specified, emails from all senders will be synced.\nFilter semantics: OR between different senders.", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "include_subjects": { "description": "(Optional) Filter emails by subject line. Values ending with \"*\" use prefix match (subject starts with\nthe part before \"*\"); otherwise substring match (subject contains the value).\nExamples: \"Invoice\" (substring), \"Re:*\" (prefix), \"Support Ticket\", \"URGENT*\"\nIf not specified, emails with all subjects will be synced.\nFilter semantics: OR between different subjects.", - "$ref": "#/$defs/slice/string" + "$ref": "#/$defs/slice/string", + "x-since-version": "v0.299.2" }, "sender_filter": { "description": "Deprecated. Use include_senders instead.", "$ref": "#/$defs/slice/string", "deprecationMessage": "This field is deprecated", + "x-since-version": "v0.299.2", "deprecated": true }, "start_date": { "description": "(Optional) Start date for the initial sync in YYYY-MM-DD format.\nFormat: YYYY-MM-DD (e.g., 2024-01-01)\nThis determines the earliest date from which to sync historical data.\nIf not specified, complete history is ingested.", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" }, "subject_filter": { "description": "Deprecated. Use include_subjects instead.", "$ref": "#/$defs/slice/string", "deprecationMessage": "This field is deprecated", + "x-since-version": "v0.299.2", "deprecated": true } }, @@ -8053,7 +8126,8 @@ "properties": { "enforce_schema": { "description": "(Optional) When true, maps each column to its Smartsheet-declared type (Text/Number/Date/\nCheckbox/etc.). Cells that do not conform to the declared type are set to NULL.\nWhen false, all columns land as STRING. Use false for sheets with irregular data or columns\nthat frequently violate their own declared type.\nIf not specified, defaults to true.", - "$ref": "#/$defs/bool" + "$ref": "#/$defs/bool", + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -8086,7 +8160,8 @@ "google_ads_config": { "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/pipelines.GoogleAdsConfig", "x-databricks-preview": "PRIVATE", - "doNotSuggest": true + "doNotSuggest": true, + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -8280,7 +8355,8 @@ "properties": { "start_date": { "description": "(Optional) Start date in YYYY-MM-DD format for the initial sync.\nThis determines the earliest date from which to sync historical data.", - "$ref": "#/$defs/string" + "$ref": "#/$defs/string", + "x-since-version": "v0.299.2" } }, "additionalProperties": false @@ -8378,6 +8454,30 @@ }, "additionalProperties": false }, + "postgres.RoleAttributes": { + "type": "object", + "properties": { + "bypassrls": { + "$ref": "#/$defs/bool" + }, + "createdb": { + "$ref": "#/$defs/bool" + }, + "createrole": { + "$ref": "#/$defs/bool" + } + }, + "additionalProperties": false + }, + "postgres.RoleAuthMethod": { + "type": "string" + }, + "postgres.RoleIdentityType": { + "type": "string" + }, + "postgres.RoleMembershipRole": { + "type": "string" + }, "serving.Ai21LabsConfig": { "type": "object", "properties": { @@ -9762,6 +9862,12 @@ "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresProject" } }, + "resources.PostgresRole": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresRole" + } + }, "resources.QualityMonitor": { "type": "object", "additionalProperties": { @@ -10139,6 +10245,12 @@ "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.ProjectCustomTag" } }, + "postgres.RoleMembershipRole": { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleMembershipRole" + } + }, "serving.AiGatewayRateLimit": { "type": "array", "items": { diff --git a/bundle/statemgmt/state_load_test.go b/bundle/statemgmt/state_load_test.go index 9b52ee480b4..56602ddcbe7 100644 --- a/bundle/statemgmt/state_load_test.go +++ b/bundle/statemgmt/state_load_test.go @@ -50,6 +50,7 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { "resources.postgres_branches.test_postgres_branch": {ID: "projects/test-project/branches/main"}, "resources.postgres_endpoints.test_postgres_endpoint": {ID: "projects/test-project/branches/main/endpoints/primary"}, "resources.postgres_databases.test_postgres_database": {ID: "projects/test-project/branches/main/databases/test-db"}, + "resources.postgres_roles.test_postgres_role": {ID: "projects/test-project/branches/main/roles/test-role"}, "resources.vector_search_endpoints.test_vector_search_endpoint": {ID: "vs-endpoint-1"}, } err := StateToBundle(t.Context(), state, &config) @@ -121,6 +122,8 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { assert.Equal(t, "projects/test-project/branches/main/databases/test-db", config.Resources.PostgresDatabases["test_postgres_database"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresDatabases["test_postgres_database"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/roles/test-role", config.Resources.PostgresRoles["test_postgres_role"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresRoles["test_postgres_role"].ModifiedStatus) assert.Equal(t, "vs-endpoint-1", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ModifiedStatus) @@ -304,6 +307,14 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { }, }, }, + PostgresRoles: map[string]*resources.PostgresRole{ + "test_postgres_role": { + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "test-role", + Parent: "projects/test-project/branches/main", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "test_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -388,6 +399,8 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { assert.Equal(t, "", config.Resources.PostgresDatabases["test_postgres_database"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresDatabases["test_postgres_database"].ModifiedStatus) + assert.Equal(t, "", config.Resources.PostgresRoles["test_postgres_role"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresRoles["test_postgres_role"].ModifiedStatus) assert.Equal(t, "", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ModifiedStatus) @@ -690,6 +703,20 @@ func TestStateToBundleModifiedResources(t *testing.T) { }, }, }, + PostgresRoles: map[string]*resources.PostgresRole{ + "test_postgres_role": { + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "primary", + Parent: "projects/test-project/branches/main", + }, + }, + "test_postgres_role_new": { + PostgresRoleConfig: resources.PostgresRoleConfig{ + RoleId: "replica", + Parent: "projects/test-project-new/branches/dev", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "test_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -747,6 +774,8 @@ func TestStateToBundleModifiedResources(t *testing.T) { "resources.postgres_endpoints.test_postgres_endpoint_old": {ID: "projects/test-project/branches/main/endpoints/old"}, "resources.postgres_databases.test_postgres_database": {ID: "projects/test-project/branches/main/databases/test-db"}, "resources.postgres_databases.test_postgres_database_old": {ID: "projects/test-project/branches/main/databases/old-db"}, + "resources.postgres_roles.test_postgres_role": {ID: "projects/test-project/branches/main/roles/primary"}, + "resources.postgres_roles.test_postgres_role_old": {ID: "projects/test-project/branches/main/roles/old"}, "resources.vector_search_endpoints.test_vector_search_endpoint": {ID: "vs-endpoint-1"}, "resources.vector_search_endpoints.test_vector_search_endpoint_old": {ID: "vs-endpoint-old"}, } @@ -901,6 +930,12 @@ func TestStateToBundleModifiedResources(t *testing.T) { assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresDatabases["test_postgres_database_old"].ModifiedStatus) assert.Equal(t, "", config.Resources.PostgresDatabases["test_postgres_database_new"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresDatabases["test_postgres_database_new"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/roles/primary", config.Resources.PostgresRoles["test_postgres_role"].ID) + assert.Equal(t, "", config.Resources.PostgresRoles["test_postgres_role"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/roles/old", config.Resources.PostgresRoles["test_postgres_role_old"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresRoles["test_postgres_role_old"].ModifiedStatus) + assert.Equal(t, "", config.Resources.PostgresRoles["test_postgres_role_new"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresRoles["test_postgres_role_new"].ModifiedStatus) assert.Equal(t, "vs-endpoint-1", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ID) assert.Equal(t, "", config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ModifiedStatus) diff --git a/experimental/aitools/cmd/discover_schema.go b/experimental/aitools/cmd/discover_schema.go index 091222368d9..418ab78e257 100644 --- a/experimental/aitools/cmd/discover_schema.go +++ b/experimental/aitools/cmd/discover_schema.go @@ -10,6 +10,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "syscall" "github.com/databricks/cli/cmd/root" @@ -161,47 +162,20 @@ CancelExecution before the command exits.`, gate := newSQLGate(concurrency) - results := make([]string, len(args)) - g := new(errgroup.Group) - for i, table := range args { - g.Go(func() error { - result, err := discoverTable(pollCtx, gate, w, warehouseID, table) - if err != nil { - results[i] = fmt.Sprintf("Error discovering %s: %v", table, err) - } else { - results[i] = result - } - // A failure on one table shouldn't abort the others. - return nil - }) - } - _ = g.Wait() + output, anyFailed := runDiscoverSchemas(pollCtx, gate, w, warehouseID, args) if pollCtx.Err() != nil { cancelDiscoverInFlight(ctx, w.StatementExecution, gate.trackedIDs()) return root.ErrAlreadyPrinted } - // format output with dividers for multiple tables - var output string - if len(results) == 1 { - output = results[0] - } else { - divider := strings.Repeat("-", 70) - var sb strings.Builder - for i, result := range results { - if i > 0 { - sb.WriteByte('\n') - sb.WriteString(divider) - sb.WriteByte('\n') - } - fmt.Fprintf(&sb, "TABLE: %s\n%s\n", args[i], divider) - sb.WriteString(result) - } - output = sb.String() - } - cmdio.LogString(ctx, output) + if anyFailed { + // Per-table errors are already in `output`; ErrAlreadyPrinted + // gives a non-zero exit without re-printing them so scripts + // and CI can detect failure via the exit code. + return root.ErrAlreadyPrinted + } return nil }, } @@ -211,6 +185,46 @@ CancelExecution before the command exits.`, return cmd } +// runDiscoverSchemas discovers schemas for tables concurrently and returns the +// rendered output. The bool is true if any table failed; per-table errors are +// inlined into the output so one bad table doesn't abort the others. +func runDiscoverSchemas(ctx context.Context, gate *sqlGate, w *databricks.WorkspaceClient, warehouseID string, tables []string) (string, bool) { + results := make([]string, len(tables)) + var anyFailed atomic.Bool + g := new(errgroup.Group) + for i, table := range tables { + g.Go(func() error { + result, err := discoverTable(ctx, gate, w, warehouseID, table) + if err != nil { + results[i] = fmt.Sprintf("Error discovering %s: %v", table, err) + anyFailed.Store(true) + } else { + results[i] = result + } + // A failure on one table shouldn't abort the others. + return nil + }) + } + _ = g.Wait() + + if len(tables) == 1 { + return results[0], anyFailed.Load() + } + + divider := strings.Repeat("-", 70) + var sb strings.Builder + for i, result := range results { + if i > 0 { + sb.WriteByte('\n') + sb.WriteString(divider) + sb.WriteByte('\n') + } + fmt.Fprintf(&sb, "TABLE: %s\n%s\n", tables[i], divider) + sb.WriteString(result) + } + return sb.String(), anyFailed.Load() +} + // cancelDiscoverInFlight sends CancelExecution for every recorded statement_id. // Best effort: errors are logged but don't fail the user-visible exit. // Statements that already finished server-side return an error which we just diff --git a/experimental/aitools/cmd/discover_schema_test.go b/experimental/aitools/cmd/discover_schema_test.go index fe6d86e799f..b76004367c9 100644 --- a/experimental/aitools/cmd/discover_schema_test.go +++ b/experimental/aitools/cmd/discover_schema_test.go @@ -324,3 +324,62 @@ func TestDiscoverSchemaInvalidTableNameRejectedBeforeWorkspaceClient(t *testing. require.Error(t, err) assert.Contains(t, err.Error(), "expected CATALOG.SCHEMA.TABLE") } + +func TestRunDiscoverSchemasFlagsTableFailureForExitCode(t *testing.T) { + // runDiscoverSchemas must report any per-table failure via the bool + // return so the caller can produce a non-zero exit. Without this signal + // scripts and CI parse stdout to detect failure, which is brittle. + ctx := cmdio.MockDiscard(t.Context()) + mockAPI := mocksql.NewMockStatementExecutionInterface(t) + + mockAPI.EXPECT().ExecuteStatement(mock.Anything, mock.Anything).Return(&dbsql.StatementResponse{ + StatementId: "stmt-bad", + Status: &dbsql.StatementStatus{ + State: dbsql.StatementStateFailed, + Error: &dbsql.ServiceError{ErrorCode: "TABLE_OR_VIEW_NOT_FOUND", Message: "no such table"}, + }, + }, nil).Once() + + w := &databricks.WorkspaceClient{StatementExecution: mockAPI} + output, anyFailed := runDiscoverSchemas(ctx, newSQLGate(8), w, "wh-1", []string{"main.public.missing"}) + + assert.True(t, anyFailed) + assert.Contains(t, output, "Error discovering main.public.missing") + assert.Contains(t, output, "TABLE_OR_VIEW_NOT_FOUND") +} + +func TestRunDiscoverSchemasAllSucceedReturnsFalse(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + mockAPI := mocksql.NewMockStatementExecutionInterface(t) + + mockAPI.EXPECT().ExecuteStatement(mock.Anything, mock.MatchedBy(func(req dbsql.ExecuteStatementRequest) bool { + return strings.HasPrefix(req.Statement, "DESCRIBE TABLE") + })).Return(&dbsql.StatementResponse{ + StatementId: "stmt-desc", + Status: &dbsql.StatementStatus{State: dbsql.StatementStateSucceeded}, + Result: &dbsql.ResultData{DataArray: [][]string{{"id", "BIGINT", ""}}}, + }, nil).Once() + + mockAPI.EXPECT().ExecuteStatement(mock.Anything, mock.MatchedBy(func(req dbsql.ExecuteStatementRequest) bool { + return strings.HasPrefix(req.Statement, "SELECT *") + })).Return(&dbsql.StatementResponse{ + StatementId: "stmt-sample", + Status: &dbsql.StatementStatus{State: dbsql.StatementStateSucceeded}, + }, nil).Once() + + mockAPI.EXPECT().ExecuteStatement(mock.Anything, mock.MatchedBy(func(req dbsql.ExecuteStatementRequest) bool { + return strings.Contains(req.Statement, "SUM(CASE WHEN") + })).Return(&dbsql.StatementResponse{ + StatementId: "stmt-null", + Status: &dbsql.StatementStatus{State: dbsql.StatementStateSucceeded}, + Manifest: &dbsql.ResultManifest{Schema: &dbsql.ResultSchema{Columns: []dbsql.ColumnInfo{{Name: "total_rows"}, {Name: "id_nulls"}}}}, + Result: &dbsql.ResultData{DataArray: [][]string{{"7", "0"}}}, + }, nil).Once() + + w := &databricks.WorkspaceClient{StatementExecution: mockAPI} + output, anyFailed := runDiscoverSchemas(ctx, newSQLGate(8), w, "wh-1", []string{"main.public.orders"}) + + assert.False(t, anyFailed) + assert.Contains(t, output, "COLUMNS:") + assert.NotContains(t, output, "Error discovering") +} diff --git a/libs/testproxy/server.go b/libs/testproxy/server.go index f3510d7adb0..fd6038c8ed5 100644 --- a/libs/testproxy/server.go +++ b/libs/testproxy/server.go @@ -2,7 +2,6 @@ package testproxy import ( "bytes" - "encoding/json" "errors" "net/http" "net/http/httptest" @@ -129,22 +128,31 @@ func (s *ProxyServer) proxyToCloud(w http.ResponseWriter, r *http.Request) { var encodedResponse *testserver.EncodedResponse - // API errors from the SDK are expected to be of the type [apierr.APIError]. If we - // get an API error then parse the error and forward it back to the client - // in an appropriate format. + // API errors from the SDK are of type [apierr.APIError]. Forward the raw + // response bytes verbatim — including any error details — so callers see + // exactly what the workspace returned. Re-marshalling from the parsed + // APIError would drop fields the SDK doesn't surface (e.g. metadata in + // details[]) and silently break callers that inspect them. apiErr := &apierr.APIError{} if errors.As(err, &apiErr) { - body := map[string]string{ - "error_code": apiErr.ErrorCode, - "message": apiErr.Message, + rw := apiErr.ResponseWrapper + if rw == nil { + // The SDK populates ResponseWrapper for every APIError produced + // from a real HTTP response. If this ever fires the SDK changed + // shape and we need to revisit how we forward error bodies. + panic("apierr.APIError has no ResponseWrapper") } - - b, err := json.Marshal(body) - assert.NoError(s.t, err) - encodedResponse = &testserver.EncodedResponse{ - StatusCode: apiErr.StatusCode, - Body: b, + StatusCode: rw.Response.StatusCode, + Body: rw.DebugBytes, + } + // Visitors registered via WithResponseHeader are not invoked when + // the SDK returns an error, so populate the include list directly + // from the original response headers. + for _, header := range includeResponseHeaders { + if v := rw.Response.Header.Get(header); v != "" { + *responseHeaders[header] = v + } } } diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 1beed244c19..1d6de443c75 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -172,6 +172,7 @@ type FakeWorkspace struct { PostgresBranches map[string]postgres.Branch PostgresEndpoints map[string]postgres.Endpoint PostgresDatabases map[string]postgres.Database + PostgresRoles map[string]postgres.Role PostgresOperations map[string]postgres.Operation // clusterVenvs caches Python venvs per existing cluster ID, @@ -301,6 +302,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { PostgresBranches: map[string]postgres.Branch{}, PostgresEndpoints: map[string]postgres.Endpoint{}, PostgresDatabases: map[string]postgres.Database{}, + PostgresRoles: map[string]postgres.Role{}, PostgresOperations: map[string]postgres.Operation{}, clusterVenvs: map[string]*clusterEnv{}, Alerts: map[string]sql.AlertV2{}, diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index a94e8b07acc..ef8842d1b5b 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -862,6 +862,11 @@ func AddDefaultHandlers(server *Server) { return req.Workspace.PostgresOperationGet(name) }) + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/roles/{role_id}/operations/{operation_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/roles/" + req.Vars["role_id"] + "/operations/" + req.Vars["operation_id"] + return req.Workspace.PostgresOperationGet(name) + }) + // Postgres Projects: server.Handle("POST", "/api/2.0/postgres/projects", func(req Request) any { projectID := req.URL.Query().Get("project_id") @@ -968,6 +973,33 @@ func AddDefaultHandlers(server *Server) { return req.Workspace.PostgresDatabaseDelete(name) }) + // Postgres Roles: + server.Handle("POST", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/roles", func(req Request) any { + parent := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + roleID := req.URL.Query().Get("role_id") + return req.Workspace.PostgresRoleCreate(req, parent, roleID) + }) + + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/roles", func(req Request) any { + parent := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + return req.Workspace.PostgresRoleList(parent) + }) + + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/roles/{role_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/roles/" + req.Vars["role_id"] + return req.Workspace.PostgresRoleGet(name) + }) + + server.Handle("PATCH", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/roles/{role_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/roles/" + req.Vars["role_id"] + return req.Workspace.PostgresRoleUpdate(req, name) + }) + + server.Handle("DELETE", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/roles/{role_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/roles/" + req.Vars["role_id"] + return req.Workspace.PostgresRoleDelete(name) + }) + // Catch-all handler for invalid postgres resource names. // This handles cases like GET /api/2.0/postgres/1234 where "1234" is not a valid resource name. server.Handle("GET", "/api/2.0/postgres/{name}", func(req Request) any { diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index 0dcf4fe3c24..ffcf8409fef 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -3,6 +3,7 @@ package testserver import ( "encoding/json" "fmt" + "regexp" "strings" "time" @@ -750,6 +751,209 @@ func (s *FakeWorkspace) PostgresDatabaseDelete(name string) Response { } } +// roleIDPattern matches a valid postgres role_id per RFC 1123: lowercase letters, +// numbers and hyphens; 4-63 chars; must start with a letter. +var roleIDPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{3,62}$`) + +// roleStatusFromSpec mirrors the real Postgres Role server's behavior of echoing +// the spec onto Status (plus default-deriving fields the user did not specify) +// while leaving Spec=nil on GET responses. +func roleStatusFromSpec(spec *postgres.RoleRoleSpec) *postgres.RoleRoleStatus { + status := &postgres.RoleRoleStatus{} + if spec == nil { + return status + } + status.PostgresRole = spec.PostgresRole + status.MembershipRoles = spec.MembershipRoles + status.IdentityType = spec.IdentityType + if status.IdentityType == "" { + // Server returns IDENTITY_TYPE_UNSPECIFIED for plain Postgres roles. + status.IdentityType = "IDENTITY_TYPE_UNSPECIFIED" + } + status.AuthMethod = spec.AuthMethod + if status.AuthMethod == "" { + // Server derives auth_method from identity_type when the user omits it: + // see SDK comment on postgres.RoleRoleSpec.AuthMethod. + switch spec.IdentityType { + case postgres.RoleIdentityTypeGroup: + status.AuthMethod = postgres.RoleAuthMethodNoLogin + case postgres.RoleIdentityTypeUser, postgres.RoleIdentityTypeServicePrincipal: + status.AuthMethod = postgres.RoleAuthMethodLakebaseOauthV1 + default: + status.AuthMethod = postgres.RoleAuthMethodPgPasswordScramSha256 + } + } + // Real server always echoes an attributes block (all-false when unspecified). + attrs := &postgres.RoleAttributes{ + ForceSendFields: []string{"Bypassrls", "Createdb", "Createrole"}, + } + if spec.Attributes != nil { + attrs.Bypassrls = spec.Attributes.Bypassrls + attrs.Createdb = spec.Attributes.Createdb + attrs.Createrole = spec.Attributes.Createrole + } + status.Attributes = attrs + return status +} + +// PostgresRoleCreate creates a new postgres role. +func (s *FakeWorkspace) PostgresRoleCreate(req Request, parent, roleID string) Response { + defer s.LockUnlock()() + + // Check if parent branch exists + if _, exists := s.PostgresBranches[parent]; !exists { + return postgresNotFoundResponse("branch") + } + + // When role_id is empty the real API generates one; mirror that here so the + // CLI's "let the server pick" path is exercised by tests. + if roleID == "" { + roleID = "role-" + nextUUID()[:8] + } + if !roleIDPattern.MatchString(roleID) { + return postgresErrorResponse(400, "INVALID_PARAMETER_VALUE", + `Field 'role_id' must be 4-63 characters, start with a lowercase letter, and contain only lowercase letters, numbers and hyphens (RFC 1123).`) + } + + var role postgres.Role + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &role); err != nil { + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("cannot unmarshal request body: %v", err), + } + } + } + + name := fmt.Sprintf("%s/roles/%s", parent, roleID) + + if _, exists := s.PostgresRoles[name]; exists { + return postgresErrorResponse(409, "ALREADY_EXISTS", "role with such id already exists") + } + + now := nowTime() + role.Name = name + role.Parent = parent + role.CreateTime = now + role.UpdateTime = now + + role.Status = roleStatusFromSpec(role.Spec) + role.Status.RoleId = roleID + role.Spec = nil + + s.PostgresRoles[name] = role + + return Response{ + Body: s.createOperationLocked(role.Name, role), + } +} + +// PostgresRoleGet retrieves a postgres role by name. +func (s *FakeWorkspace) PostgresRoleGet(name string) Response { + defer s.LockUnlock()() + + // Extract project and branch names from role name + // Format: projects/{project}/branches/{branch}/roles/{role} + parts := strings.Split(name, "/branches/") + if len(parts) == 2 { + projectName := parts[0] + if _, exists := s.PostgresProjects[projectName]; !exists { + return postgresNotFoundResponse("project") + } + branchParts := strings.Split(parts[1], "/roles/") + if len(branchParts) == 2 { + branchName := projectName + "/branches/" + branchParts[0] + if _, exists := s.PostgresBranches[branchName]; !exists { + return postgresNotFoundResponse("branch") + } + } + } + + role, exists := s.PostgresRoles[name] + if !exists { + return postgresNotFoundResponse("role") + } + + return Response{ + Body: role, + } +} + +// PostgresRoleList lists all postgres roles for a branch. +func (s *FakeWorkspace) PostgresRoleList(parent string) Response { + defer s.LockUnlock()() + + if _, exists := s.PostgresBranches[parent]; !exists { + return postgresNotFoundResponse("branch") + } + + var roles []postgres.Role + prefix := parent + "/roles/" + for name, r := range s.PostgresRoles { + if strings.HasPrefix(name, prefix) { + roles = append(roles, r) + } + } + + return Response{ + Body: postgres.ListRolesResponse{ + Roles: roles, + }, + } +} + +// PostgresRoleUpdate updates a postgres role. +func (s *FakeWorkspace) PostgresRoleUpdate(req Request, name string) Response { + defer s.LockUnlock()() + + role, exists := s.PostgresRoles[name] + if !exists { + return postgresNotFoundResponse("role") + } + + var updateRole postgres.Role + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &updateRole); err != nil { + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("cannot unmarshal request body: %v", err), + } + } + } + + if updateRole.Spec != nil { + // Preserve role_id which is derived from the resource name. + roleID := "" + if role.Status != nil { + roleID = role.Status.RoleId + } + role.Status = roleStatusFromSpec(updateRole.Spec) + role.Status.RoleId = roleID + } + + role.UpdateTime = nowTime() + s.PostgresRoles[name] = role + + return Response{ + Body: s.createOperationLocked(role.Name, role), + } +} + +// PostgresRoleDelete deletes a postgres role. +func (s *FakeWorkspace) PostgresRoleDelete(name string) Response { + defer s.LockUnlock()() + + if _, exists := s.PostgresRoles[name]; !exists { + return postgresNotFoundResponse("role") + } + + delete(s.PostgresRoles, name) + + return Response{ + Body: s.createOperationLocked(name, nil), + } +} + // PostgresOperationGet retrieves a postgres operation by name. func (s *FakeWorkspace) PostgresOperationGet(name string) Response { defer s.LockUnlock()() @@ -778,6 +982,8 @@ func (s *FakeWorkspace) createOperationLocked(resourceName string, response any) resourceType = "Endpoint" case strings.Contains(resourceName, "/databases/"): resourceType = "Database" + case strings.Contains(resourceName, "/roles/"): + resourceType = "Role" case strings.Contains(resourceName, "/branches/"): resourceType = "Branch" } diff --git a/libs/testserver/postgres_test.go b/libs/testserver/postgres_test.go index c2310d18039..f0cff22d0b3 100644 --- a/libs/testserver/postgres_test.go +++ b/libs/testserver/postgres_test.go @@ -388,3 +388,118 @@ func TestPostgresEndpointNotFoundWhenBranchNotExists(t *testing.T) { assert.Equal(t, 404, createEpResp.StatusCode) createEpResp.Body.Close() } + +func TestPostgresRoleCRUD(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + + client := &http.Client{} + baseURL := server.URL + + // Create project first + createProjReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects?project_id=role-test-project", nil) + createProjReq.Header.Set("Authorization", "Bearer test-token") + createProjResp, err := client.Do(createProjReq) + require.NoError(t, err) + assert.Equal(t, 200, createProjResp.StatusCode) + createProjResp.Body.Close() + + // Create branch + createBranchReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/role-test-project/branches?branch_id=main", nil) + createBranchReq.Header.Set("Authorization", "Bearer test-token") + createBranchResp, err := client.Do(createBranchReq) + require.NoError(t, err) + assert.Equal(t, 200, createBranchResp.StatusCode) + createBranchResp.Body.Close() + + // Create role + createRoleBody := `{"spec":{"postgres_role":"my_role"}}` + createRoleReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/role-test-project/branches/main/roles?role_id=my-role", strings.NewReader(createRoleBody)) + createRoleReq.Header.Set("Authorization", "Bearer test-token") + createRoleReq.Header.Set("Content-Type", "application/json") + createRoleResp, err := client.Do(createRoleReq) + require.NoError(t, err) + assert.Equal(t, 200, createRoleResp.StatusCode) + createRoleResp.Body.Close() + + // Get role + getRoleReq, _ := http.NewRequest(http.MethodGet, baseURL+"/api/2.0/postgres/projects/role-test-project/branches/main/roles/my-role", nil) + getRoleReq.Header.Set("Authorization", "Bearer test-token") + getRoleResp, err := client.Do(getRoleReq) + require.NoError(t, err) + assert.Equal(t, 200, getRoleResp.StatusCode) + + var role postgres.Role + require.NoError(t, json.NewDecoder(getRoleResp.Body).Decode(&role)) + assert.Equal(t, "projects/role-test-project/branches/main/roles/my-role", role.Name) + assert.Equal(t, "projects/role-test-project/branches/main", role.Parent) + require.NotNil(t, role.Status) + assert.Equal(t, "my_role", role.Status.PostgresRole) + getRoleResp.Body.Close() + + // List roles + listRoleReq, _ := http.NewRequest(http.MethodGet, baseURL+"/api/2.0/postgres/projects/role-test-project/branches/main/roles", nil) + listRoleReq.Header.Set("Authorization", "Bearer test-token") + listRoleResp, err := client.Do(listRoleReq) + require.NoError(t, err) + assert.Equal(t, 200, listRoleResp.StatusCode) + + var listRoles postgres.ListRolesResponse + require.NoError(t, json.NewDecoder(listRoleResp.Body).Decode(&listRoles)) + assert.Len(t, listRoles.Roles, 1) + listRoleResp.Body.Close() + + // Update role (rename via spec.postgres_role) + updateRoleBody := `{"spec":{"postgres_role":"my_role_renamed"}}` + updateRoleReq, _ := http.NewRequest(http.MethodPatch, baseURL+"/api/2.0/postgres/projects/role-test-project/branches/main/roles/my-role", strings.NewReader(updateRoleBody)) + updateRoleReq.Header.Set("Authorization", "Bearer test-token") + updateRoleReq.Header.Set("Content-Type", "application/json") + updateRoleResp, err := client.Do(updateRoleReq) + require.NoError(t, err) + assert.Equal(t, 200, updateRoleResp.StatusCode) + updateRoleResp.Body.Close() + + // Verify rename was applied + getRoleReq2, _ := http.NewRequest(http.MethodGet, baseURL+"/api/2.0/postgres/projects/role-test-project/branches/main/roles/my-role", nil) + getRoleReq2.Header.Set("Authorization", "Bearer test-token") + getRoleResp2, err := client.Do(getRoleReq2) + require.NoError(t, err) + assert.Equal(t, 200, getRoleResp2.StatusCode) + var role2 postgres.Role + require.NoError(t, json.NewDecoder(getRoleResp2.Body).Decode(&role2)) + require.NotNil(t, role2.Status) + assert.Equal(t, "my_role_renamed", role2.Status.PostgresRole) + getRoleResp2.Body.Close() + + // Delete role + deleteRoleReq, _ := http.NewRequest(http.MethodDelete, baseURL+"/api/2.0/postgres/projects/role-test-project/branches/main/roles/my-role", nil) + deleteRoleReq.Header.Set("Authorization", "Bearer test-token") + deleteRoleResp, err := client.Do(deleteRoleReq) + require.NoError(t, err) + assert.Equal(t, 200, deleteRoleResp.StatusCode) + deleteRoleResp.Body.Close() +} + +func TestPostgresRoleNotFoundWhenBranchNotExists(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + + client := &http.Client{} + baseURL := server.URL + + // Create project first + createProjReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects?project_id=role-no-branch-project", nil) + createProjReq.Header.Set("Authorization", "Bearer test-token") + createProjResp, err := client.Do(createProjReq) + require.NoError(t, err) + assert.Equal(t, 200, createProjResp.StatusCode) + createProjResp.Body.Close() + + // Try to create role without branch + createRoleReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/role-no-branch-project/branches/nonexistent/roles?role_id=my-role", nil) + createRoleReq.Header.Set("Authorization", "Bearer test-token") + createRoleResp, err := client.Do(createRoleReq) + require.NoError(t, err) + assert.Equal(t, 404, createRoleResp.StatusCode) + createRoleResp.Body.Close() +} From cbf69bc6380d9a1a6dce50eb9aee35f7cf5160fa Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 14:50:17 +0200 Subject: [PATCH 23/41] acc: parameterize postgres_databases role in fixtures The live Lakebase API rejects POST without spec.role despite the SDK's omitempty tag. Interpolate ${workspace.current_user.domain_friendly_name} into the existing basic/recreate/update fixtures so they run unchanged on both the local testserver and cloud. Regenerate recorded outputs. Co-authored-by: Isaac --- .../postgres_databases/basic/databricks.yml.tmpl | 3 +++ .../postgres_databases/basic/out.requests.direct.json | 3 ++- .../postgres_databases/basic/out.requests.terraform.json | 3 ++- .../postgres_databases/recreate/databricks.yml.tmpl | 1 + .../resources/postgres_databases/recreate/output.txt | 8 ++++++-- .../postgres_databases/update/databricks.yml.tmpl | 1 + .../update/out.plan.create.direct.json | 6 ++++-- .../update/out.plan.no_change.direct.json | 6 ++++++ .../update/out.plan.restore.direct.json | 9 ++++++++- .../update/out.plan.update.direct.json | 9 ++++++++- .../update/out.requests.create.direct.json | 3 ++- .../update/out.requests.create.terraform.json | 3 ++- .../update/out.requests.restore.direct.json | 3 ++- .../update/out.requests.restore.terraform.json | 3 ++- .../update/out.requests.update.direct.json | 3 ++- .../update/out.requests.update.terraform.json | 3 ++- .../resources/postgres_databases/update/output.txt | 1 + 17 files changed, 54 insertions(+), 14 deletions(-) diff --git a/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl index eef99ab5df8..ade82cc7177 100644 --- a/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl +++ b/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl @@ -23,3 +23,6 @@ resources: parent: ${resources.postgres_branches.main.id} database_id: my-database postgres_database: app_db + # The live API requires `role`. `domain_friendly_name` kebab-cases the user's + # email local part, which matches the auto-created project-owner role naming. + role: ${resources.postgres_branches.main.id}/roles/${workspace.current_user.domain_friendly_name} diff --git a/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json b/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json index d7d8a3a1dc9..50e6ffa29be 100644 --- a/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json +++ b/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json @@ -32,7 +32,8 @@ }, "body": { "spec": { - "postgres_database": "app_db" + "postgres_database": "app_db", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } } } diff --git a/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json b/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json index 23b01ed569a..26811e852c7 100644 --- a/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json +++ b/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json @@ -34,7 +34,8 @@ "body": { "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "spec": { - "postgres_database": "app_db" + "postgres_database": "app_db", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } } } diff --git a/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl index eeda6e99242..5976647395d 100644 --- a/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl +++ b/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl @@ -23,3 +23,4 @@ resources: parent: ${resources.postgres_branches.main.id} database_id: DATABASE_ID_PLACEHOLDER postgres_database: app_db + role: ${resources.postgres_branches.main.id}/roles/${workspace.current_user.domain_friendly_name} diff --git a/acceptance/bundle/resources/postgres_databases/recreate/output.txt b/acceptance/bundle/resources/postgres_databases/recreate/output.txt index 6d2808705ae..be9c34582ed 100644 --- a/acceptance/bundle/resources/postgres_databases/recreate/output.txt +++ b/acceptance/bundle/resources/postgres_databases/recreate/output.txt @@ -25,6 +25,7 @@ resources: parent: ${resources.postgres_branches.main.id} database_id: test-database-[UNIQUE_NAME] postgres_database: app_db + role: ${resources.postgres_branches.main.id}/roles/${workspace.current_user.domain_friendly_name} >>> [CLI] bundle plan create postgres_branches.main @@ -57,7 +58,8 @@ Deployment complete! "no_expiry": true "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", "branch_id": "main" - "postgres_database": "app_db" + "postgres_database": "app_db", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", "database_id": "test-database-[UNIQUE_NAME]" @@ -87,6 +89,7 @@ resources: parent: ${resources.postgres_branches.main.id} database_id: test-database-[UNIQUE_NAME]-v2 postgres_database: app_db + role: ${resources.postgres_branches.main.id}/roles/${workspace.current_user.domain_friendly_name} >>> [CLI] bundle plan recreate postgres_databases.my_database @@ -106,7 +109,8 @@ Deployment complete! } "body": { "spec": { - "postgres_database": "app_db" + "postgres_database": "app_db", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } }, "method": "POST", diff --git a/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl index 522d0a6c305..b50b35191e0 100644 --- a/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl +++ b/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl @@ -23,3 +23,4 @@ resources: database_id: my-database parent: ${resources.postgres_branches.main.id} postgres_database: initial_db_name + role: ${resources.postgres_branches.main.id}/roles/${workspace.current_user.domain_friendly_name} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json index 8b402c6f12c..ae4ce8ac2fe 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json @@ -10,10 +10,12 @@ "value": { "database_id": "my-database", "parent": "${resources.postgres_branches.main.id}", - "postgres_database": "initial_db_name" + "postgres_database": "initial_db_name", + "role": "${resources.postgres_branches.main.id}/roles/[USERNAME]" }, "vars": { - "parent": "${resources.postgres_branches.main.id}" + "parent": "${resources.postgres_branches.main.id}", + "role": "${resources.postgres_branches.main.id}/roles/[USERNAME]" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json index f331785fd2d..b43af5091d7 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json @@ -22,6 +22,12 @@ "reason": "spec:input_only", "old": "initial_db_name", "new": "initial_db_name" + }, + "role": { + "action": "skip", + "reason": "spec:input_only", + "old": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json index 34a3329d99e..6327132249a 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json @@ -10,7 +10,8 @@ "value": { "database_id": "my-database", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", - "postgres_database": "initial_db_name" + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } }, "remote_state": { @@ -28,6 +29,12 @@ "action": "update", "old": "renamed_db_name", "new": "initial_db_name" + }, + "role": { + "action": "skip", + "reason": "spec:input_only", + "old": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json index ce4c77b1a10..553cd966377 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json @@ -10,7 +10,8 @@ "value": { "database_id": "my-database", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", - "postgres_database": "renamed_db_name" + "postgres_database": "renamed_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } }, "remote_state": { @@ -28,6 +29,12 @@ "action": "update", "old": "initial_db_name", "new": "renamed_db_name" + }, + "role": { + "action": "skip", + "reason": "spec:input_only", + "old": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json index 6b6154d7938..cccbb510d96 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json @@ -32,7 +32,8 @@ }, "body": { "spec": { - "postgres_database": "initial_db_name" + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json index fa017f0b408..1a6e254793f 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json @@ -34,7 +34,8 @@ "body": { "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "spec": { - "postgres_database": "initial_db_name" + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json index 85d115935b6..d5b9517b151 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json @@ -26,7 +26,8 @@ }, "body": { "spec": { - "postgres_database": "initial_db_name" + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json index 689c68e6f4f..ccd4ba776c0 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json @@ -20,7 +20,8 @@ "name": "[MY_DATABASE_ID]", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "spec": { - "postgres_database": "initial_db_name" + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json index d3e3064d4fe..016c95a96a5 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json @@ -26,7 +26,8 @@ }, "body": { "spec": { - "postgres_database": "renamed_db_name" + "postgres_database": "renamed_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json index b56c0e3535e..b0d5d7e5d12 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json @@ -20,7 +20,8 @@ "name": "[MY_DATABASE_ID]", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "spec": { - "postgres_database": "renamed_db_name" + "postgres_database": "renamed_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/output.txt b/acceptance/bundle/resources/postgres_databases/update/output.txt index 6f0b139ceed..0ba528ac1eb 100644 --- a/acceptance/bundle/resources/postgres_databases/update/output.txt +++ b/acceptance/bundle/resources/postgres_databases/update/output.txt @@ -79,6 +79,7 @@ resources: database_id: my-database parent: ${resources.postgres_branches.main.id} postgres_database: renamed_db_name + role: ${resources.postgres_branches.main.id}/roles/${workspace.current_user.domain_friendly_name} >>> [CLI] bundle plan update postgres_databases.my_database From 61f98eddf845b111885c73e99ad1d7fa1535c21d Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 14:50:28 +0200 Subject: [PATCH 24/41] acc: cover postgres_databases missing-role rejection Add live_errors/missing_role to lock in the live API behavior observed during the 2026-05-19 dogfood2 smoke: POST .../databases without spec.role returns 400 with "Field 'spec.role' cannot be empty". A local Server stub overrides the default testserver auto-fill so the test runs identically on local and cloud. direct engine only; terraform's rollback-on-failure semantics diverge on error paths (same reason jobs/create-error is direct-only). Co-authored-by: Isaac --- .../missing_role/databricks.yml.tmpl | 26 ++++++++++++ .../live_errors/missing_role/out.test.toml | 6 +++ .../live_errors/missing_role/output.txt | 28 +++++++++++++ .../live_errors/missing_role/script | 8 ++++ .../live_errors/missing_role/test.toml | 8 ++++ .../postgres_databases/live_errors/test.toml | 40 +++++++++++++++++++ 6 files changed, 116 insertions(+) create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/missing_role/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/missing_role/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/missing_role/output.txt create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/missing_role/script create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/missing_role/test.toml create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/test.toml diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/databricks.yml.tmpl new file mode 100644 index 00000000000..9a4d3ed0dfa --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/databricks.yml.tmpl @@ -0,0 +1,26 @@ +bundle: + name: missing-role-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Missing-role error test" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + database_id: my-database + postgres_database: app_db + # Deliberately omitting `role`; the live API rejects this with 400. diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/out.test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/out.test.toml new file mode 100644 index 00000000000..4fe23e297fe --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/output.txt b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/output.txt new file mode 100644 index 00000000000..7e279281222 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/output.txt @@ -0,0 +1,28 @@ +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/missing-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Error: cannot create resources.postgres_databases.my_database: Field 'spec.role' cannot be empty (400 INVALID_PARAMETER_VALUE) + +Endpoint: POST [DATABRICKS_URL]/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases?database_id=my-database +HTTP Status: 400 Bad Request +API error_code: INVALID_PARAMETER_VALUE +API message: Field 'spec.role' cannot be empty + +Updating deployment state... + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/missing-role-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/script b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/script new file mode 100644 index 00000000000..c8687770909 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/script @@ -0,0 +1,8 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +musterr $CLI bundle deploy 2>&1 | contains.py "Field 'spec.role' cannot be empty" diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/test.toml new file mode 100644 index 00000000000..0a6b8fb39c9 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/test.toml @@ -0,0 +1,8 @@ +# Stub the database create endpoint to reject empty `role`, matching the live +# API behavior observed on 2026-05-19 dogfood2: +# 400 Bad Request: Field 'spec.role' cannot be empty +# The default testserver auto-fills role when empty, so we override here. +[[Server]] +Pattern = "POST /api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases" +Response.StatusCode = 400 +Response.Body = '''{"error_code": "INVALID_PARAMETER_VALUE", "message": "Field 'spec.role' cannot be empty"}''' diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/test.toml new file mode 100644 index 00000000000..550eda094ce --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/test.toml @@ -0,0 +1,40 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true + +# Error-path tests only assert via output.txt; recording requests adds noise +# (and on cloud, flaky timestamps/operation IDs). +RecordRequests = false + +# Lakebase v2 (postgres) is only available in AWS as of January 2026 +CloudEnvs.gcp = false +CloudEnvs.azure = false + +# Error-path tests don't iterate cleanly under terraform: the terraform engine +# rolls back to its prior state on a partial failure, which makes the recorded +# output diverge from the direct engine. Run them on direct only; the error +# surface is the same regardless of engine. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +Ignore = [ + "databricks.yml", + ".databricks", +] + +[[Repls]] +# Clean up ?o= suffix after URL since not all workspaces have that +Old = '\?o=\[(NUMID|ALPHANUMID)\]' +New = '' +Order = 1000 + +[[Repls]] +# Normalize branch UIDs (br-xxx-yyy-zzz format) +Old = 'br-[a-z0-9-]+' +New = '[BRANCH_UID]' +Order = 1 + +[[Repls]] +# Normalize project UIDs (proj-xxx-yyy-zzz format) +Old = 'proj-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]' +New = '[PROJECT_UID]' +Order = 1 From 72870899e2b0e30b6645d46df427ea829947eba5 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 15:02:00 +0200 Subject: [PATCH 25/41] testserver: mirror status.database_id on postgres database GET The real Lakebase API includes database_id in the database status, echoing the create-time query parameter. The testserver omitted it, so local and cloud acceptance outputs diverged after the role parameterization landed. Capture it on create so GET responses match. Co-authored-by: Isaac --- libs/testserver/postgres.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index ffcf8409fef..d962568162c 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -624,7 +624,9 @@ func (s *FakeWorkspace) PostgresDatabaseCreate(req Request, parent, databaseID s database.UpdateTime = now // Mirror spec onto status; the real API only echoes Status on GET. - status := &postgres.DatabaseDatabaseStatus{} + status := &postgres.DatabaseDatabaseStatus{ + DatabaseId: databaseID, + } if database.Spec != nil { status.PostgresDatabase = database.Spec.PostgresDatabase status.Role = database.Spec.Role From a8ca9b466dacb3689f1e0942cb14567ba7038681 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 15:02:09 +0200 Subject: [PATCH 26/41] acc: portable postgres_databases role via postgres_roles Replace ${workspace.current_user.domain_friendly_name} with an explicit postgres_roles.owner resource so the bundle works for any caller. The previous approach worked for human users on dogfood2 but failed on the SP-authenticated aws-prod-ucws because the auto-created project-owner role's id is not consistently derivable from the workspace user identity. Declaring the role explicitly sidesteps that naming variance and exercises postgres_roles as a side effect. All three tests (basic, recreate, update) pass against the live API on aws-prod-ucws for both direct and terraform engines. Co-authored-by: Isaac --- .../basic/databricks.yml.tmpl | 13 ++++++-- .../basic/out.requests.direct.json | 14 +++++++- .../basic/out.requests.terraform.json | 15 ++++++++- .../postgres_databases/basic/output.txt | 12 ++++++- .../recreate/databricks.yml.tmpl | 8 ++++- .../postgres_databases/recreate/output.txt | 33 +++++++++++++++---- .../update/databricks.yml.tmpl | 8 ++++- .../update/out.plan.create.direct.json | 8 +++-- .../update/out.plan.no_change.direct.json | 11 +++++-- .../update/out.plan.restore.direct.json | 13 +++++--- .../update/out.plan.update.direct.json | 13 +++++--- .../update/out.requests.create.direct.json | 14 +++++++- .../update/out.requests.create.terraform.json | 15 ++++++++- .../update/out.requests.no_change.direct.json | 8 +++++ .../out.requests.no_change.terraform.json | 4 +++ .../update/out.requests.restore.direct.json | 10 +++++- .../out.requests.restore.terraform.json | 6 +++- .../update/out.requests.update.direct.json | 10 +++++- .../update/out.requests.update.terraform.json | 6 +++- .../postgres_databases/update/output.txt | 27 ++++++++++----- 20 files changed, 206 insertions(+), 42 deletions(-) diff --git a/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl index ade82cc7177..72750fed4db 100644 --- a/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl +++ b/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl @@ -18,11 +18,18 @@ resources: branch_id: main no_expiry: true + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + postgres_databases: my_database: parent: ${resources.postgres_branches.main.id} database_id: my-database postgres_database: app_db - # The live API requires `role`. `domain_friendly_name` kebab-cases the user's - # email local part, which matches the auto-created project-owner role naming. - role: ${resources.postgres_branches.main.id}/roles/${workspace.current_user.domain_friendly_name} + # The live API requires `role`. Declare an explicit role so the bundle is + # portable across users (the auto-created project-owner role's id is + # derived from the creator's identity). + role: ${resources.postgres_roles.owner.id} diff --git a/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json b/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json index 50e6ffa29be..54b67db8049 100644 --- a/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json +++ b/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json @@ -24,6 +24,18 @@ } } } +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "app-owner" + }, + "body": { + "spec": { + "postgres_role": "app_owner" + } + } +} { "method": "POST", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", @@ -33,7 +45,7 @@ "body": { "spec": { "postgres_database": "app_db", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } } diff --git a/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json b/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json index 26811e852c7..4ddff90db2b 100644 --- a/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json +++ b/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json @@ -25,6 +25,19 @@ } } } +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "app-owner" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "postgres_role": "app_owner" + } + } +} { "method": "POST", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", @@ -35,7 +48,7 @@ "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "spec": { "postgres_database": "app_db", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } } diff --git a/acceptance/bundle/resources/postgres_databases/basic/output.txt b/acceptance/bundle/resources/postgres_databases/basic/output.txt index 6120fad3593..63105d9dca1 100644 --- a/acceptance/bundle/resources/postgres_databases/basic/output.txt +++ b/acceptance/bundle/resources/postgres_databases/basic/output.txt @@ -27,6 +27,10 @@ Resources: my_project: Name: Test Project for Database URL: (not deployed) + Postgres roles: + owner: + Name: + URL: (not deployed) >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-[UNIQUE_NAME]/default/files... @@ -39,8 +43,9 @@ Deployment complete! "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases/my-database", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "status": { + "database_id": "my-database", "postgres_database": "app_db", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } @@ -63,6 +68,10 @@ Resources: my_project: Name: Test Project for Database URL: (not deployed) + Postgres roles: + owner: + Name: + URL: (not deployed) >>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ @@ -71,6 +80,7 @@ The following resources will be deleted: delete resources.postgres_branches.main delete resources.postgres_databases.my_database delete resources.postgres_projects.my_project + delete resources.postgres_roles.owner This action will result in the deletion of the following Lakebase projects along with all their branches, databases, and endpoints. All data stored in them will be permanently lost: diff --git a/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl index 5976647395d..a4ce357b68a 100644 --- a/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl +++ b/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl @@ -18,9 +18,15 @@ resources: branch_id: main no_expiry: true + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + postgres_databases: my_database: parent: ${resources.postgres_branches.main.id} database_id: DATABASE_ID_PLACEHOLDER postgres_database: app_db - role: ${resources.postgres_branches.main.id}/roles/${workspace.current_user.domain_friendly_name} + role: ${resources.postgres_roles.owner.id} diff --git a/acceptance/bundle/resources/postgres_databases/recreate/output.txt b/acceptance/bundle/resources/postgres_databases/recreate/output.txt index be9c34582ed..1c3ebee471c 100644 --- a/acceptance/bundle/resources/postgres_databases/recreate/output.txt +++ b/acceptance/bundle/resources/postgres_databases/recreate/output.txt @@ -20,19 +20,26 @@ resources: branch_id: main no_expiry: true + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + postgres_databases: my_database: parent: ${resources.postgres_branches.main.id} database_id: test-database-[UNIQUE_NAME] postgres_database: app_db - role: ${resources.postgres_branches.main.id}/roles/${workspace.current_user.domain_friendly_name} + role: ${resources.postgres_roles.owner.id} >>> [CLI] bundle plan create postgres_branches.main create postgres_databases.my_database create postgres_projects.my_project +create postgres_roles.owner -Plan: 3 to add, 0 to change, 0 to delete, 0 unchanged +Plan: 4 to add, 0 to change, 0 to delete, 0 unchanged >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default/files... @@ -58,8 +65,11 @@ Deployment complete! "no_expiry": true "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", "branch_id": "main" + "postgres_role": "app_owner" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "role_id": "app-owner" "postgres_database": "app_db", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", "database_id": "test-database-[UNIQUE_NAME]" @@ -84,17 +94,23 @@ resources: branch_id: main no_expiry: true + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + postgres_databases: my_database: parent: ${resources.postgres_branches.main.id} database_id: test-database-[UNIQUE_NAME]-v2 postgres_database: app_db - role: ${resources.postgres_branches.main.id}/roles/${workspace.current_user.domain_friendly_name} + role: ${resources.postgres_roles.owner.id} >>> [CLI] bundle plan recreate postgres_databases.my_database -Plan: 1 to add, 0 to change, 1 to delete, 2 unchanged +Plan: 1 to add, 0 to change, 1 to delete, 3 unchanged >>> [CLI] bundle deploy --auto-approve Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default/files... @@ -110,7 +126,7 @@ Deployment complete! "body": { "spec": { "postgres_database": "app_db", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } }, "method": "POST", @@ -125,8 +141,9 @@ Deployment complete! "name": "[MY_DATABASE_ID]-v2", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "status": { + "database_id": "test-database-[UNIQUE_NAME]-v2", "postgres_database": "app_db", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } @@ -136,6 +153,7 @@ The following resources will be deleted: delete resources.postgres_branches.main delete resources.postgres_databases.my_database delete resources.postgres_projects.my_project + delete resources.postgres_roles.owner This action will result in the deletion of the following Lakebase projects along with all their branches, databases, and endpoints. All data stored in them will be permanently lost: @@ -155,6 +173,7 @@ Destroy complete! "method": "DELETE", "path": "/api/2.0/postgres/[MY_DATABASE_ID]-v2" } + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" diff --git a/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl index b50b35191e0..52e26cc75b1 100644 --- a/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl +++ b/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl @@ -18,9 +18,15 @@ resources: branch_id: main no_expiry: true + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + postgres_databases: my_database: database_id: my-database parent: ${resources.postgres_branches.main.id} postgres_database: initial_db_name - role: ${resources.postgres_branches.main.id}/roles/${workspace.current_user.domain_friendly_name} + role: ${resources.postgres_roles.owner.id} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json index ae4ce8ac2fe..2cb56d4a187 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json @@ -3,6 +3,10 @@ { "node": "resources.postgres_branches.main", "label": "${resources.postgres_branches.main.id}" + }, + { + "node": "resources.postgres_roles.owner", + "label": "${resources.postgres_roles.owner.id}" } ], "action": "create", @@ -11,11 +15,11 @@ "database_id": "my-database", "parent": "${resources.postgres_branches.main.id}", "postgres_database": "initial_db_name", - "role": "${resources.postgres_branches.main.id}/roles/[USERNAME]" + "role": "${resources.postgres_roles.owner.id}" }, "vars": { "parent": "${resources.postgres_branches.main.id}", - "role": "${resources.postgres_branches.main.id}/roles/[USERNAME]" + "role": "${resources.postgres_roles.owner.id}" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json index b43af5091d7..be8303c36d6 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json @@ -3,6 +3,10 @@ { "node": "resources.postgres_branches.main", "label": "${resources.postgres_branches.main.id}" + }, + { + "node": "resources.postgres_roles.owner", + "label": "${resources.postgres_roles.owner.id}" } ], "action": "skip", @@ -11,8 +15,9 @@ "name": "[MY_DATABASE_ID]", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "status": { + "database_id": "my-database", "postgres_database": "initial_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" }, "update_time": "[TIMESTAMP]" }, @@ -26,8 +31,8 @@ "role": { "action": "skip", "reason": "spec:input_only", - "old": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]", - "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "old": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json index 6327132249a..c67e44b51ca 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json @@ -3,6 +3,10 @@ { "node": "resources.postgres_branches.main", "label": "${resources.postgres_branches.main.id}" + }, + { + "node": "resources.postgres_roles.owner", + "label": "${resources.postgres_roles.owner.id}" } ], "action": "update", @@ -11,7 +15,7 @@ "database_id": "my-database", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "postgres_database": "initial_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } }, "remote_state": { @@ -19,8 +23,9 @@ "name": "[MY_DATABASE_ID]", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "status": { + "database_id": "my-database", "postgres_database": "renamed_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" }, "update_time": "[TIMESTAMP]" }, @@ -33,8 +38,8 @@ "role": { "action": "skip", "reason": "spec:input_only", - "old": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]", - "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "old": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json index 553cd966377..b15adfc872b 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json @@ -3,6 +3,10 @@ { "node": "resources.postgres_branches.main", "label": "${resources.postgres_branches.main.id}" + }, + { + "node": "resources.postgres_roles.owner", + "label": "${resources.postgres_roles.owner.id}" } ], "action": "update", @@ -11,7 +15,7 @@ "database_id": "my-database", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "postgres_database": "renamed_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } }, "remote_state": { @@ -19,8 +23,9 @@ "name": "[MY_DATABASE_ID]", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "status": { + "database_id": "my-database", "postgres_database": "initial_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" }, "update_time": "[TIMESTAMP]" }, @@ -33,8 +38,8 @@ "role": { "action": "skip", "reason": "spec:input_only", - "old": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]", - "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "old": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json index cccbb510d96..15308c66f4e 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json @@ -24,6 +24,18 @@ } } } +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "app-owner" + }, + "body": { + "spec": { + "postgres_role": "app_owner" + } + } +} { "method": "POST", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", @@ -33,7 +45,7 @@ "body": { "spec": { "postgres_database": "initial_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json index 1a6e254793f..c4e31a5d55d 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json @@ -25,6 +25,19 @@ } } } +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "app-owner" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "postgres_role": "app_owner" + } + } +} { "method": "POST", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", @@ -35,7 +48,7 @@ "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "spec": { "postgres_database": "initial_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.direct.json index 741fd31fc1d..42d1bd84b95 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.direct.json @@ -6,6 +6,10 @@ "method": "GET", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" } +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" +} { "method": "GET", "path": "/api/2.0/postgres/[MY_DATABASE_ID]" @@ -18,3 +22,7 @@ "method": "GET", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" } +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.terraform.json index 0616ddea143..ff91f1561cf 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.terraform.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.terraform.json @@ -6,6 +6,10 @@ "method": "GET", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" } +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" +} { "method": "GET", "path": "/api/2.0/postgres/[MY_DATABASE_ID]" diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json index d5b9517b151..05d035f3dc0 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json @@ -6,6 +6,10 @@ "method": "GET", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" } +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" +} { "method": "GET", "path": "/api/2.0/postgres/[MY_DATABASE_ID]" @@ -18,6 +22,10 @@ "method": "GET", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" } +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" +} { "method": "PATCH", "path": "/api/2.0/postgres/[MY_DATABASE_ID]", @@ -27,7 +35,7 @@ "body": { "spec": { "postgres_database": "initial_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json index ccd4ba776c0..be32218e65e 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json @@ -6,6 +6,10 @@ "method": "GET", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" } +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" +} { "method": "GET", "path": "/api/2.0/postgres/[MY_DATABASE_ID]" @@ -21,7 +25,7 @@ "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "spec": { "postgres_database": "initial_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json index 016c95a96a5..82d82c5a603 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json @@ -6,6 +6,10 @@ "method": "GET", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" } +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" +} { "method": "GET", "path": "/api/2.0/postgres/[MY_DATABASE_ID]" @@ -18,6 +22,10 @@ "method": "GET", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" } +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" +} { "method": "PATCH", "path": "/api/2.0/postgres/[MY_DATABASE_ID]", @@ -27,7 +35,7 @@ "body": { "spec": { "postgres_database": "renamed_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json index b0d5d7e5d12..f3e8e7858c3 100644 --- a/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json @@ -6,6 +6,10 @@ "method": "GET", "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" } +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" +} { "method": "GET", "path": "/api/2.0/postgres/[MY_DATABASE_ID]" @@ -21,7 +25,7 @@ "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "spec": { "postgres_database": "renamed_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } } diff --git a/acceptance/bundle/resources/postgres_databases/update/output.txt b/acceptance/bundle/resources/postgres_databases/update/output.txt index 0ba528ac1eb..b78180520c8 100644 --- a/acceptance/bundle/resources/postgres_databases/update/output.txt +++ b/acceptance/bundle/resources/postgres_databases/update/output.txt @@ -13,8 +13,9 @@ Validation OK! create postgres_branches.main create postgres_databases.my_database create postgres_projects.my_project +create postgres_roles.owner -Plan: 3 to add, 0 to change, 0 to delete, 0 unchanged +Plan: 4 to add, 0 to change, 0 to delete, 0 unchanged >>> [CLI] bundle plan -o json @@ -31,14 +32,15 @@ Deployment complete! "name": "[MY_DATABASE_ID]", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "status": { + "database_id": "my-database", "postgres_database": "initial_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } === Verify no changes >>> [CLI] bundle plan -Plan: 0 to add, 0 to change, 0 to delete, 3 unchanged +Plan: 0 to add, 0 to change, 0 to delete, 4 unchanged >>> [CLI] bundle plan -o json @@ -74,17 +76,23 @@ resources: branch_id: main no_expiry: true + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + postgres_databases: my_database: database_id: my-database parent: ${resources.postgres_branches.main.id} postgres_database: renamed_db_name - role: ${resources.postgres_branches.main.id}/roles/${workspace.current_user.domain_friendly_name} + role: ${resources.postgres_roles.owner.id} >>> [CLI] bundle plan update postgres_databases.my_database -Plan: 0 to add, 1 to change, 0 to delete, 2 unchanged +Plan: 0 to add, 1 to change, 0 to delete, 3 unchanged >>> [CLI] bundle plan -o json @@ -101,8 +109,9 @@ Deployment complete! "name": "[MY_DATABASE_ID]", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "status": { + "database_id": "my-database", "postgres_database": "renamed_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } @@ -112,7 +121,7 @@ Deployment complete! >>> [CLI] bundle plan update postgres_databases.my_database -Plan: 0 to add, 1 to change, 0 to delete, 2 unchanged +Plan: 0 to add, 1 to change, 0 to delete, 3 unchanged >>> [CLI] bundle plan -o json @@ -129,8 +138,9 @@ Deployment complete! "name": "[MY_DATABASE_ID]", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", "status": { + "database_id": "my-database", "postgres_database": "initial_db_name", - "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/[USERNAME]" + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" } } @@ -139,6 +149,7 @@ The following resources will be deleted: delete resources.postgres_branches.main delete resources.postgres_databases.my_database delete resources.postgres_projects.my_project + delete resources.postgres_roles.owner This action will result in the deletion of the following Lakebase projects along with all their branches, databases, and endpoints. All data stored in them will be permanently lost: From 58e99a2a2405dac9a87fb5c25d074ec68a82f2e2 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 15:11:41 +0200 Subject: [PATCH 27/41] acc: cover postgres_databases bad-role-ref and bad-database-id rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additional live-validated error paths, both observed against aws-prod-ucws on 2026-05-19: - bad_role_ref: referencing a role that does not exist yields 404 NOT_FOUND with message "role not found; role_id:..." and a trailing [TraceId: ] suffix. Stub mirrors the response shape; a regex Repls normalizes the TraceId so output is deterministic. - bad_database_id: an underscore in database_id violates the pattern ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$. The API echoes the failing value and the pattern in the message; locking that in catches regressions on either side. Findings during probing (not turned into tests): "postgres" as a database_id is accepted, a 3-char database_id is accepted, and a postgres_database starting with a digit is accepted — the live API does not currently validate these inputs despite SDK doc-comments implying otherwise. direct engine only; matches missing_role rationale. Co-authored-by: Isaac --- .../bad_database_id/databricks.yml.tmpl | 33 +++++++++++++++++++ .../live_errors/bad_database_id/out.test.toml | 6 ++++ .../live_errors/bad_database_id/output.txt | 29 ++++++++++++++++ .../live_errors/bad_database_id/script | 8 +++++ .../live_errors/bad_database_id/test.toml | 8 +++++ .../bad_role_ref/databricks.yml.tmpl | 28 ++++++++++++++++ .../live_errors/bad_role_ref/out.test.toml | 6 ++++ .../live_errors/bad_role_ref/output.txt | 28 ++++++++++++++++ .../live_errors/bad_role_ref/script | 8 +++++ .../live_errors/bad_role_ref/test.toml | 14 ++++++++ 10 files changed, 168 insertions(+) create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/output.txt create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/script create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/test.toml create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/output.txt create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/script create mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/test.toml diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/databricks.yml.tmpl new file mode 100644 index 00000000000..d93f8eab8a7 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/databricks.yml.tmpl @@ -0,0 +1,33 @@ +bundle: + name: bad-database-id-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Bad database_id error test" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + # database_id must be lowercase + DNS-safe; this violates RFC 1123. + database_id: Invalid_DB_ID + postgres_database: app_db + role: ${resources.postgres_roles.owner.id} diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/out.test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/out.test.toml new file mode 100644 index 00000000000..4fe23e297fe --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/output.txt b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/output.txt new file mode 100644 index 00000000000..f4d984a0e12 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/output.txt @@ -0,0 +1,29 @@ +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/bad-database-id-[UNIQUE_NAME]/default/files... +Deploying resources... +Error: cannot create resources.postgres_databases.my_database: Field database_id must match pattern ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$, got 'Invalid_DB_ID'. (400 INVALID_PARAMETER_VALUE) + +Endpoint: POST [DATABRICKS_URL]/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases?database_id=Invalid_DB_ID +HTTP Status: 400 Bad Request +API error_code: INVALID_PARAMETER_VALUE +API message: Field database_id must match pattern ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$, got 'Invalid_DB_ID'. + +Updating deployment state... + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + delete resources.postgres_roles.owner + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/bad-database-id-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/script b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/script new file mode 100644 index 00000000000..7315f6f5d37 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/script @@ -0,0 +1,8 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +musterr $CLI bundle deploy 2>&1 | contains.py "Field database_id must match pattern" diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/test.toml new file mode 100644 index 00000000000..68b282e620a --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/test.toml @@ -0,0 +1,8 @@ +# Stub the database create endpoint to reject a non-pattern database_id, +# matching the live API behavior observed on aws-prod-ucws 2026-05-19: +# 400 Bad Request: Field database_id must match pattern +# ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$, got ''. +[[Server]] +Pattern = "POST /api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases" +Response.StatusCode = 400 +Response.Body = '''{"error_code": "INVALID_PARAMETER_VALUE", "message": "Field database_id must match pattern ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$, got 'Invalid_DB_ID'."}''' diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/databricks.yml.tmpl new file mode 100644 index 00000000000..2f096ac7d02 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/databricks.yml.tmpl @@ -0,0 +1,28 @@ +bundle: + name: bad-role-ref-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Bad role-ref error test" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + database_id: my-database + postgres_database: app_db + # The referenced role is never declared anywhere; the live API + # rejects database creation with "role not found". + role: ${resources.postgres_branches.main.id}/roles/does-not-exist diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/out.test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/out.test.toml new file mode 100644 index 00000000000..4fe23e297fe --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/output.txt b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/output.txt new file mode 100644 index 00000000000..0b19de889b2 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/output.txt @@ -0,0 +1,28 @@ +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/bad-role-ref-[UNIQUE_NAME]/default/files... +Deploying resources... +Error: cannot create resources.postgres_databases.my_database: role not found; role_id:"does-not-exist" [TraceId: [TRACE_ID]] (404 NOT_FOUND) + +Endpoint: POST [DATABRICKS_URL]/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases?database_id=my-database +HTTP Status: 404 Not Found +API error_code: NOT_FOUND +API message: role not found; role_id:"does-not-exist" [TraceId: [TRACE_ID]] + +Updating deployment state... + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/bad-role-ref-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/script b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/script new file mode 100644 index 00000000000..0d040869c10 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/script @@ -0,0 +1,8 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +musterr $CLI bundle deploy 2>&1 | contains.py "role not found" diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/test.toml new file mode 100644 index 00000000000..4658193db32 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/test.toml @@ -0,0 +1,14 @@ +# Stub the database create endpoint to reject the unknown role reference, +# matching the live API behavior observed on aws-prod-ucws 2026-05-19: +# 404 Not Found: role not found; role_id:"" [TraceId: ] +# The default testserver does not validate that the referenced role exists. +[[Server]] +Pattern = "POST /api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases" +Response.StatusCode = 404 +Response.Body = '''{"error_code": "NOT_FOUND", "message": "role not found; role_id:\"does-not-exist\" [TraceId: [TRACE_ID]]"}''' + +[[Repls]] +# Cloud responses include a "[TraceId: ]" suffix on this error; normalize. +Old = '\[TraceId: [a-f0-9]+\]' +New = '[TraceId: [TRACE_ID]]' +Order = 5 From c253582c0decea9084d356a3b9bf17750c2bd515 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 26 May 2026 15:02:38 +0200 Subject: [PATCH 28/41] postgres_role: prefer Status.RoleId over TrimPrefix in RemapState The SDK's RoleRoleStatus already carries role_id; use it directly instead of stripping the "/roles/" prefix from remote.Name. Matches the catalog convention (Status.CatalogId) and avoids a local string parse. Co-authored-by: Isaac --- bundle/direct/dresources/postgres_role.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bundle/direct/dresources/postgres_role.go b/bundle/direct/dresources/postgres_role.go index 2a7d03f6e57..4894bcaef5a 100644 --- a/bundle/direct/dresources/postgres_role.go +++ b/bundle/direct/dresources/postgres_role.go @@ -11,7 +11,6 @@ package dresources import ( "context" - "strings" "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/databricks-sdk-go" @@ -52,9 +51,12 @@ func (*ResourcePostgresRole) PrepareState(input *resources.PostgresRole) *Postgr } func (*ResourcePostgresRole) RemapState(remote *postgres.Role) *PostgresRoleState { + var roleID string + if remote.Status != nil { + roleID = remote.Status.RoleId + } return &PostgresRoleState{ - // Derive role_id from the hierarchical name: "/roles/". - RoleId: strings.TrimPrefix(remote.Name, remote.Parent+"/roles/"), + RoleId: roleID, Parent: remote.Parent, // The read API does not return the spec, only the status. From 8768a755ecea42c859f3283ef78b6e5791e6ed1f Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 2 Jun 2026 15:06:54 +0200 Subject: [PATCH 29/41] postgres_role: treat postgres_role/auth_method/identity_type as recreate-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing showed the PATCH update_mask only accepts spec.attributes and spec.membership_roles; the backend rejects spec.postgres_role, spec.auth_method, and spec.identity_type with 400 INVALID_PARAMETER_VALUE "Unknown field path in update_mask". Without declaring these as recreate_on_changes: - direct engine: deploy fails on PATCH and re-plan loops on the same "1 to change" forever - terraform engine: silently no-ops the change (state records new value, remote keeps old, GET returns no spec → invisible divergence) These spec fields aren't marked immutable in the OpenAPI definition, so the generator can't pick them up — declare them in the manual resources.yml until upstream is fixed. Adds an acceptance test that toggles postgres_role and confirms the plan recreates instead of patching. Restricted to the direct engine because the terraform provider still treats the field as updateable and would silently diverge from the bundle. Co-authored-by: Isaac --- .../databricks.yml.tmpl | 25 +++ .../recreate-postgres-role/out.test.toml | 6 + .../recreate-postgres-role/output.txt | 165 ++++++++++++++++++ .../recreate-postgres-role/script | 48 +++++ .../recreate-postgres-role/test.toml | 2 + bundle/direct/dresources/resources.yml | 11 ++ 6 files changed, 257 insertions(+) create mode 100644 acceptance/bundle/resources/postgres_roles/recreate-postgres-role/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_roles/recreate-postgres-role/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/recreate-postgres-role/output.txt create mode 100644 acceptance/bundle/resources/postgres_roles/recreate-postgres-role/script create mode 100644 acceptance/bundle/resources/postgres_roles/recreate-postgres-role/test.toml diff --git a/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/databricks.yml.tmpl new file mode 100644 index 00000000000..7a85f471b06 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/databricks.yml.tmpl @@ -0,0 +1,25 @@ +bundle: + name: deploy-postgres-role-recreate-postgres-role-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Role" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role-$UNIQUE_NAME + postgres_role: POSTGRES_ROLE_PLACEHOLDER diff --git a/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/out.test.toml b/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/out.test.toml new file mode 100644 index 00000000000..4fe23e297fe --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/output.txt b/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/output.txt new file mode 100644 index 00000000000..4781121afa7 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/output.txt @@ -0,0 +1,165 @@ + +>>> cat databricks.yml +bundle: + name: deploy-postgres-role-recreate-postgres-role-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Role" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role-[UNIQUE_NAME] + postgres_role: app_role_v1 + +>>> [CLI] bundle plan +create postgres_branches.main +create postgres_projects.my_project +create postgres_roles.my_role + +Plan: 3 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-recreate-postgres-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests +{ + "body": { + "spec": { + "display_name": "Test Project for Role", + "history_retention_duration": "604800s", + "pg_version": 16 + } + }, + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + } +} + "no_expiry": true + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "branch_id": "main" + "postgres_role": "app_role_v1" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "role_id": "test-role-[UNIQUE_NAME]" + +>>> cat databricks.yml +bundle: + name: deploy-postgres-role-recreate-postgres-role-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Role" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + my_role: + parent: ${resources.postgres_branches.main.id} + role_id: test-role-[UNIQUE_NAME] + postgres_role: app_role_v2 + +>>> [CLI] bundle plan +recreate postgres_roles.my_role + +Plan: 1 to add, 0 to change, 1 to delete, 2 unchanged + +>>> [CLI] bundle deploy --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-recreate-postgres-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests +{ + "method": "DELETE", + "path": "/api/2.0/postgres/[MY_ROLE_ID]" +} + "body": { + "spec": { + "postgres_role": "app_role_v2" + } + }, + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "test-role-[UNIQUE_NAME]" + } + +=== Fetch role and verify the new postgres_role value is live +>>> [CLI] postgres get-role [MY_ROLE_ID] +{ + "name": "[MY_ROLE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "attributes": { + "bypassrls": false, + "createdb": false, + "createrole": false + }, + "auth_method": "PG_PASSWORD_SCRAM_SHA_256", + "identity_type": "IDENTITY_TYPE_UNSPECIFIED", + "postgres_role": "app_role_v2", + "role_id": "test-role-[UNIQUE_NAME]" + } +} + +=== Destroy and verify cleanup +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + delete resources.postgres_roles.my_role + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-role-recreate-postgres-role-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! + +>>> print_requests +{ + "method": "DELETE", + "path": "/api/2.0/postgres/[MY_ROLE_ID]" +} + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" + +>>> [CLI] bundle destroy --auto-approve +No active deployment found to destroy! diff --git a/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/script b/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/script new file mode 100644 index 00000000000..16891865b51 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/script @@ -0,0 +1,48 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + + # Best-effort cleanup if a deploy left the role behind. + $CLI postgres delete-role "projects/test-pg-proj-${UNIQUE_NAME}/branches/main/roles/test-role-${UNIQUE_NAME}" 2>/dev/null || true + + rm -f out.requests.txt +} +trap cleanup EXIT + +# Deploy with the first postgres_role value. +envsubst < databricks.yml.tmpl | sed "s/POSTGRES_ROLE_PLACEHOLDER/app_role_v1/" > databricks.yml + +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle deploy + +print_requests() { + # Filter postgres requests (excluding GET), remove parent (engine-dependent), + # then deduplicate consecutive retries. + jq --sort-keys 'select(.method != "GET" and (.path | contains("/postgres"))) | del(.body.parent)' < out.requests.txt | \ + awk '!seen[$0]++ {print}' + rm -f out.requests.txt +} + +trace print_requests + +# Change postgres_role; the backend rejects this field in update_mask, so the +# plan must show delete + create rather than an in-place PATCH. +envsubst < databricks.yml.tmpl | sed "s/POSTGRES_ROLE_PLACEHOLDER/app_role_v2/" > databricks.yml + +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle deploy --auto-approve + +trace print_requests + +title "Fetch role and verify the new postgres_role value is live" + +role_name=`read_id.py my_role` +trace $CLI postgres get-role $role_name | jq 'del(.create_time, .update_time)' + +title "Destroy and verify cleanup" +trace $CLI bundle destroy --auto-approve + +trace print_requests diff --git a/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/test.toml b/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/test.toml new file mode 100644 index 00000000000..6d0c603a991 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/recreate-postgres-role/test.toml @@ -0,0 +1,2 @@ +Badness = "Terraform provider treats spec.postgres_role as updateable and sends update_mask=spec instead of recreating; backend silently no-ops the change. Direct engine correctly recreates." +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 547b9d8afc5..2e6ffbbcd3f 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -569,6 +569,17 @@ resources: reason: immutable - field: role_id reason: immutable + # The PATCH update_mask only accepts spec.attributes and spec.membership_roles; + # the backend rejects spec.postgres_role, spec.auth_method, and spec.identity_type + # with 400 INVALID_PARAMETER_VALUE "Unknown field path in update_mask". These spec + # fields are not marked immutable in the OpenAPI definition yet, so the generator + # doesn't catch them — declare the constraint manually until upstream is fixed. + - field: postgres_role + reason: immutable + - field: auth_method + reason: immutable + - field: identity_type + reason: immutable vector_search_endpoints: recreate_on_changes: From aa811bf9a05b584b7beac3e7d77b99e19c8ed5ef Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 9 Jun 2026 13:52:20 +0200 Subject: [PATCH 30/41] postgres_role: preserve role_id and parent in serialized state PostgresRoleState embeds postgres.RoleRoleSpec, which carries its own MarshalJSON. That method gets promoted to PostgresRoleState, so json.Marshal emitted only the spec fields and silently dropped role_id and parent from persisted direct-engine state. The no-op plan masked it via remote_already_set (RemapState refills both from the GET response), but the on-disk state was incomplete and references could break. Add MarshalJSON/UnmarshalJSON mirroring PostgresDatabaseRemote, plus a round-trip test asserting role_id and parent survive serialization. Regenerate the postgres_roles/update direct-engine plans: state now persists role_id/parent and the plans no longer show phantom remote_already_set skips for them. Co-authored-by: Isaac --- .../update/out.plan.create.direct.json | 4 +- .../update/out.plan.no_change.direct.json | 14 ------- .../update/out.plan.restore.direct.json | 18 ++------- .../update/out.plan.update.direct.json | 18 ++------- bundle/direct/dresources/postgres_role.go | 11 ++++++ .../direct/dresources/postgres_role_test.go | 37 +++++++++++++++++++ 6 files changed, 57 insertions(+), 45 deletions(-) create mode 100644 bundle/direct/dresources/postgres_role_test.go diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.create.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.create.direct.json index 498c1d25124..d79dbc488a0 100644 --- a/acceptance/bundle/resources/postgres_roles/update/out.plan.create.direct.json +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.create.direct.json @@ -11,7 +11,9 @@ "attributes": { "createdb": false }, - "postgres_role": "app_role" + "parent": "${resources.postgres_branches.main.id}", + "postgres_role": "app_role", + "role_id": "test-role" }, "vars": { "parent": "${resources.postgres_branches.main.id}" diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json index 3772eeef349..01aba3a58a1 100644 --- a/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json @@ -34,25 +34,11 @@ "createdb": false } }, - "parent": { - "action": "skip", - "reason": "remote_already_set", - "old": "", - "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", - "remote": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main" - }, "postgres_role": { "action": "skip", "reason": "spec:input_only", "old": "app_role", "new": "app_role" - }, - "role_id": { - "action": "skip", - "reason": "remote_already_set", - "old": "", - "new": "test-role", - "remote": "test-role" } } } diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json index 647ce8e7991..ffe6c25471c 100644 --- a/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json @@ -11,7 +11,9 @@ "attributes": { "createdb": false }, - "postgres_role": "app_role" + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "postgres_role": "app_role", + "role_id": "test-role" } }, "remote_state": { @@ -46,25 +48,11 @@ "old": true, "new": false }, - "parent": { - "action": "skip", - "reason": "remote_already_set", - "old": "", - "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", - "remote": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main" - }, "postgres_role": { "action": "skip", "reason": "spec:input_only", "old": "app_role", "new": "app_role" - }, - "role_id": { - "action": "skip", - "reason": "remote_already_set", - "old": "", - "new": "test-role", - "remote": "test-role" } } } diff --git a/acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json b/acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json index 7917cf60e44..55404b13f1a 100644 --- a/acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json @@ -11,7 +11,9 @@ "attributes": { "createdb": true }, - "postgres_role": "app_role" + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "postgres_role": "app_role", + "role_id": "test-role" } }, "remote_state": { @@ -46,25 +48,11 @@ "old": false, "new": true }, - "parent": { - "action": "skip", - "reason": "remote_already_set", - "old": "", - "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", - "remote": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main" - }, "postgres_role": { "action": "skip", "reason": "spec:input_only", "old": "app_role", "new": "app_role" - }, - "role_id": { - "action": "skip", - "reason": "remote_already_set", - "old": "", - "new": "test-role", - "remote": "test-role" } } } diff --git a/bundle/direct/dresources/postgres_role.go b/bundle/direct/dresources/postgres_role.go index 2ae25356a4e..8b576fcf346 100644 --- a/bundle/direct/dresources/postgres_role.go +++ b/bundle/direct/dresources/postgres_role.go @@ -15,6 +15,7 @@ import ( "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/marshal" "github.com/databricks/databricks-sdk-go/service/postgres" ) @@ -38,6 +39,16 @@ type PostgresRoleState struct { Parent string `json:"parent"` } +// Custom marshaler needed because embedded RoleRoleSpec has its own +// MarshalJSON which would otherwise take over and ignore the additional fields. +func (s *PostgresRoleState) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, s) +} + +func (s PostgresRoleState) MarshalJSON() ([]byte, error) { + return marshal.Marshal(s) +} + func (*ResourcePostgresRole) New(client *databricks.WorkspaceClient) *ResourcePostgresRole { return &ResourcePostgresRole{client: client} } diff --git a/bundle/direct/dresources/postgres_role_test.go b/bundle/direct/dresources/postgres_role_test.go new file mode 100644 index 00000000000..cbada17ac97 --- /dev/null +++ b/bundle/direct/dresources/postgres_role_test.go @@ -0,0 +1,37 @@ +package dresources + +import ( + "encoding/json" + "testing" + + "github.com/databricks/databricks-sdk-go/service/postgres" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostgresRoleStateRoundTrip(t *testing.T) { + state := PostgresRoleState{ + RoleId: "app-owner", + Parent: "projects/p/branches/main", + RoleRoleSpec: postgres.RoleRoleSpec{ + PostgresRole: "app_owner", + }, + } + + b, err := json.Marshal(state) + require.NoError(t, err) + + // role_id and parent must survive marshaling. The embedded RoleRoleSpec has + // its own MarshalJSON that would otherwise take over and drop them from the + // persisted state. + var raw map[string]any + require.NoError(t, json.Unmarshal(b, &raw)) + assert.Equal(t, "app-owner", raw["role_id"]) + assert.Equal(t, "projects/p/branches/main", raw["parent"]) + + var got PostgresRoleState + require.NoError(t, json.Unmarshal(b, &got)) + assert.Equal(t, "app-owner", got.RoleId) + assert.Equal(t, "projects/p/branches/main", got.Parent) + assert.Equal(t, "app_owner", got.PostgresRole) +} From dd651e9b2b3b3be88acc2e55bd1579f4afa779a4 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 9 Jun 2026 13:52:29 +0200 Subject: [PATCH 31/41] postgres_database: return (false, nil) when the database is missing Exists previously returned the raw API error for any failure, so a 404 on a non-existent database surfaced through `bundle deployment bind` as "failed to fetch the resource, err: ... (404 NOT_FOUND)" instead of the intended "postgres_database with an id '' is not found". Match PostgresRole.Exists: treat apierr.IsMissing as not-found and only propagate other errors. Co-authored-by: Isaac --- bundle/config/resources/postgres_database.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bundle/config/resources/postgres_database.go b/bundle/config/resources/postgres_database.go index 5c04abcd49c..b600fee4897 100644 --- a/bundle/config/resources/postgres_database.go +++ b/bundle/config/resources/postgres_database.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/postgres" ) @@ -36,8 +37,11 @@ type PostgresDatabase struct { func (d *PostgresDatabase) Exists(ctx context.Context, w *databricks.WorkspaceClient, name string) (bool, error) { _, err := w.Postgres.GetDatabase(ctx, postgres.GetDatabaseRequest{Name: name}) - if err != nil { + if apierr.IsMissing(err) { log.Debugf(ctx, "postgres database %s does not exist", name) + return false, nil + } + if err != nil { return false, err } return true, nil From ddeabf92c32d06ae8ec0864a7d2827832766b6b4 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 9 Jun 2026 13:52:37 +0200 Subject: [PATCH 32/41] dresources: drop duplicate postgres_roles key from apitypes.yml override A merge of postgres-roles into postgres-databases left two postgres_roles entries in the hand-edited apitypes.yml override -- one in alphabetical position and a second appended at the end. Both mapped to postgres.RoleRoleSpec, so generated output was unaffected, but the duplicate key is a hazard. Keep the in-order entry, remove the stray one. Co-authored-by: Isaac --- bundle/direct/dresources/apitypes.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/bundle/direct/dresources/apitypes.yml b/bundle/direct/dresources/apitypes.yml index 998f94dd778..3b7cdf12a35 100644 --- a/bundle/direct/dresources/apitypes.yml +++ b/bundle/direct/dresources/apitypes.yml @@ -17,5 +17,3 @@ postgres_projects: postgres.ProjectSpec postgres_roles: postgres.RoleRoleSpec postgres_synced_tables: postgres.SyncedTableSyncedTableSpec - -postgres_roles: postgres.RoleRoleSpec From ad3eea03540a3fbb344ed9475bef4aa24265d659 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 16 Jun 2026 19:18:12 +0200 Subject: [PATCH 33/41] testserver: require spec.role on postgres_database create The real Lakebase API rejects a database create that omits spec.role with 400 INVALID_PARAMETER_VALUE "Field 'spec.role' cannot be empty" (confirmed on e2-dogfood 2026-06-16). The fake previously synthesized a default project-owner role, so the live_errors/missing_role acceptance test had to stub the endpoint with a per-test Server override to reproduce the error. Make the fake reject an empty role directly, matching the backend and the postgres_roles pattern. This removes the now-redundant Server override and requires role to be set wherever a database is created against the fake (the invariant config, the dresources roundtrip testdep, and the testserver tests). Co-authored-by: Isaac --- .../configs/postgres_database.yml.tmpl | 7 +++++++ .../live_errors/missing_role/test.toml | 8 -------- bundle/direct/dresources/all_test.go | 1 + libs/testserver/postgres.go | 19 ++++++++++--------- libs/testserver/postgres_test.go | 4 ++-- 5 files changed, 20 insertions(+), 19 deletions(-) delete mode 100644 acceptance/bundle/resources/postgres_databases/live_errors/missing_role/test.toml diff --git a/acceptance/bundle/invariant/configs/postgres_database.yml.tmpl b/acceptance/bundle/invariant/configs/postgres_database.yml.tmpl index 5364c28f41a..dcfca2c6ef6 100644 --- a/acceptance/bundle/invariant/configs/postgres_database.yml.tmpl +++ b/acceptance/bundle/invariant/configs/postgres_database.yml.tmpl @@ -13,8 +13,15 @@ resources: branch_id: test-branch-$UNIQUE_NAME no_expiry: true + postgres_roles: + owner: + parent: ${resources.postgres_branches.branch.name} + role_id: test-role-$UNIQUE_NAME + postgres_role: app_role + postgres_databases: foo: parent: ${resources.postgres_branches.branch.name} database_id: test-database-$UNIQUE_NAME postgres_database: app_db + role: ${resources.postgres_roles.owner.name} diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/test.toml deleted file mode 100644 index 0a6b8fb39c9..00000000000 --- a/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/test.toml +++ /dev/null @@ -1,8 +0,0 @@ -# Stub the database create endpoint to reject empty `role`, matching the live -# API behavior observed on 2026-05-19 dogfood2: -# 400 Bad Request: Field 'spec.role' cannot be empty -# The default testserver auto-fills role when empty, so we override here. -[[Server]] -Pattern = "POST /api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases" -Response.StatusCode = 400 -Response.Body = '''{"error_code": "INVALID_PARAMETER_VALUE", "message": "Field 'spec.role' cannot be empty"}''' diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index ce63c99f73a..2fb7fda3635 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -780,6 +780,7 @@ var testDeps = map[string]prepareWorkspace{ DatabaseId: "test-database", DatabaseDatabaseSpec: postgres.DatabaseDatabaseSpec{ PostgresDatabase: "app_db", + Role: "projects/test-project-for-database/branches/test-branch-for-database/roles/owner", }, }, }, nil diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index 28e6bafdcdc..effd4b356dd 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -738,6 +738,13 @@ func (s *FakeWorkspace) PostgresDatabaseCreate(req Request, parent, databaseID s } } + // The real Lakebase API requires the owning role on create and rejects an empty + // one with this exact error (verified on e2-dogfood 2026-06-16). The fake does + // not synthesize a default, matching that behavior. + if database.Spec == nil || database.Spec.Role == "" { + return postgresErrorResponse(400, "INVALID_PARAMETER_VALUE", "Field 'spec.role' cannot be empty") + } + name := fmt.Sprintf("%s/databases/%s", parent, databaseID) if _, exists := s.PostgresDatabases[name]; exists { @@ -755,15 +762,9 @@ func (s *FakeWorkspace) PostgresDatabaseCreate(req Request, parent, databaseID s // Mirror spec onto status; the real API only echoes Status on GET. status := &postgres.DatabaseDatabaseStatus{ - DatabaseId: databaseID, - } - if database.Spec != nil { - status.PostgresDatabase = database.Spec.PostgresDatabase - status.Role = database.Spec.Role - } - // When no role is provided, the real API assigns the project-owner role. - if status.Role == "" { - status.Role = parent + "/roles/" + TestUser.UserName + DatabaseId: databaseID, + PostgresDatabase: database.Spec.PostgresDatabase, + Role: database.Spec.Role, } database.Status = status database.Spec = nil diff --git a/libs/testserver/postgres_test.go b/libs/testserver/postgres_test.go index e3199f69f2d..1a414cf6762 100644 --- a/libs/testserver/postgres_test.go +++ b/libs/testserver/postgres_test.go @@ -275,7 +275,7 @@ func TestPostgresDatabaseCRUD(t *testing.T) { createBranchResp.Body.Close() // Create database - createDbBody := `{"spec":{"postgres_database":"my_db"}}` + createDbBody := `{"spec":{"postgres_database":"my_db","role":"projects/database-test-project/branches/main/roles/owner"}}` createDbReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases?database_id=my-db", strings.NewReader(createDbBody)) createDbReq.Header.Set("Authorization", "Bearer test-token") createDbReq.Header.Set("Content-Type", "application/json") @@ -429,7 +429,7 @@ func TestPostgresDatabaseCreateDuplicateReturns400(t *testing.T) { do(http.MethodPost, "/api/2.0/postgres/projects?project_id=dup-db-project", "").Body.Close() do(http.MethodPost, "/api/2.0/postgres/projects/dup-db-project/branches?branch_id=main", "").Body.Close() - createBody := `{"spec":{"postgres_database":"app_db"}}` + createBody := `{"spec":{"postgres_database":"app_db","role":"projects/dup-db-project/branches/main/roles/owner"}}` first := do(http.MethodPost, "/api/2.0/postgres/projects/dup-db-project/branches/main/databases?database_id=appdb", createBody) require.Equal(t, 200, first.StatusCode) first.Body.Close() From b5992d4046c22f51dc7453141f0f524102d6f26d Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Wed, 17 Jun 2026 09:33:58 +0200 Subject: [PATCH 34/41] Add postgres_database bind acceptance test; dedup postgres_roles in permissions fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-checking against the postgres_roles PR (#5467) surfaced two misses for postgres_databases: - deployment/bind: ~20 resources (incl. postgres_role) have a deployment/bind acceptance test, but postgres_database had none. Add one mirroring the role test (bind -> summary -> unbind -> summary, both engines). - apply_bundle_permissions_test.go: the unsupportedResources slice listed "postgres_roles" twice — a slice-element add/add artifact from the earlier merge that compiles cleanly so build/test didn't catch it. Deduplicated. Other role-PR changes were already present for databases (config methods, the tfdyn converter, generated schema/validation/apitypes/resources, testserver handlers, mutator fixtures). Role-only items correctly have no database analog: enum_fields/annotation type-entries (the database spec has only scalar fields), and the inherited-role / recreate-postgres-role tests (role-specific semantics). Co-authored-by: Isaac --- .../bind/postgres_database/databricks.yml | 9 ++++++ .../bind/postgres_database/out.test.toml | 3 ++ .../bind/postgres_database/output.txt | 32 +++++++++++++++++++ .../deployment/bind/postgres_database/script | 6 ++++ .../bind/postgres_database/test.toml | 18 +++++++++++ .../apply_bundle_permissions_test.go | 1 - 6 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 acceptance/bundle/deployment/bind/postgres_database/databricks.yml create mode 100644 acceptance/bundle/deployment/bind/postgres_database/out.test.toml create mode 100644 acceptance/bundle/deployment/bind/postgres_database/output.txt create mode 100644 acceptance/bundle/deployment/bind/postgres_database/script create mode 100644 acceptance/bundle/deployment/bind/postgres_database/test.toml diff --git a/acceptance/bundle/deployment/bind/postgres_database/databricks.yml b/acceptance/bundle/deployment/bind/postgres_database/databricks.yml new file mode 100644 index 00000000000..ac626e53b24 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_database/databricks.yml @@ -0,0 +1,9 @@ +bundle: + name: test-bundle + +resources: + postgres_databases: + database1: + parent: projects/test-project/branches/main + database_id: test-database + postgres_database: app_db diff --git a/acceptance/bundle/deployment/bind/postgres_database/out.test.toml b/acceptance/bundle/deployment/bind/postgres_database/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_database/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/deployment/bind/postgres_database/output.txt b/acceptance/bundle/deployment/bind/postgres_database/output.txt new file mode 100644 index 00000000000..25da0d4dd0e --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_database/output.txt @@ -0,0 +1,32 @@ + +>>> [CLI] bundle deployment bind database1 projects/test-project/branches/main/databases/test-database --auto-approve +Updating deployment state... +Successfully bound postgres_database with an id 'projects/test-project/branches/main/databases/test-database' +Run 'bundle deploy' to deploy changes to your workspace + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + Postgres databases: + database1: + Name: test-database + URL: (not deployed) + +>>> [CLI] bundle deployment unbind database1 +Updating deployment state... + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + Postgres databases: + database1: + Name: test-database + URL: (not deployed) diff --git a/acceptance/bundle/deployment/bind/postgres_database/script b/acceptance/bundle/deployment/bind/postgres_database/script new file mode 100644 index 00000000000..cc3f69efa2f --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_database/script @@ -0,0 +1,6 @@ +DATABASE_NAME="projects/test-project/branches/main/databases/test-database" +trace $CLI bundle deployment bind database1 "${DATABASE_NAME}" --auto-approve +trace $CLI bundle summary + +trace $CLI bundle deployment unbind database1 +trace $CLI bundle summary diff --git a/acceptance/bundle/deployment/bind/postgres_database/test.toml b/acceptance/bundle/deployment/bind/postgres_database/test.toml new file mode 100644 index 00000000000..dcdc554d97e --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_database/test.toml @@ -0,0 +1,18 @@ +Local = true +Cloud = false + +Ignore = [ + ".databricks" +] + +[[Server]] +Pattern = "GET /api/2.0/postgres/projects/test-project/branches/main/databases/test-database" +Response.Body = ''' +{ + "name": "projects/test-project/branches/main/databases/test-database", + "parent": "projects/test-project/branches/main", + "status": { + "postgres_database": "app_db" + } +} +''' diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index 3a6e801b96b..2fbe07b270a 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -32,7 +32,6 @@ var unsupportedResources = []string{ "postgres_catalogs", "postgres_roles", "postgres_synced_tables", - "postgres_roles", "vector_search_indexes", } From 4561bb6fa22549fb6703e069e0a925a7b6999b62 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Wed, 17 Jun 2026 09:47:55 +0200 Subject: [PATCH 35/41] resources: postgres_database GetName returns empty, matching postgres_role postgres_role.GetName returns "" ("roles don't have a user-visible name field"); postgres_database returned DatabaseId, so bundle summary showed a Name for databases but not roles. Return "" for consistency. Regenerated the affected summary outputs (bind test and basic). Co-authored-by: Isaac --- .../bundle/deployment/bind/postgres_database/output.txt | 4 ++-- .../bundle/resources/postgres_databases/basic/output.txt | 4 ++-- bundle/config/resources/postgres_database.go | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/acceptance/bundle/deployment/bind/postgres_database/output.txt b/acceptance/bundle/deployment/bind/postgres_database/output.txt index 25da0d4dd0e..799751d7e91 100644 --- a/acceptance/bundle/deployment/bind/postgres_database/output.txt +++ b/acceptance/bundle/deployment/bind/postgres_database/output.txt @@ -13,7 +13,7 @@ Workspace: Resources: Postgres databases: database1: - Name: test-database + Name: URL: (not deployed) >>> [CLI] bundle deployment unbind database1 @@ -28,5 +28,5 @@ Workspace: Resources: Postgres databases: database1: - Name: test-database + Name: URL: (not deployed) diff --git a/acceptance/bundle/resources/postgres_databases/basic/output.txt b/acceptance/bundle/resources/postgres_databases/basic/output.txt index 63105d9dca1..1f468552dda 100644 --- a/acceptance/bundle/resources/postgres_databases/basic/output.txt +++ b/acceptance/bundle/resources/postgres_databases/basic/output.txt @@ -21,7 +21,7 @@ Resources: URL: (not deployed) Postgres databases: my_database: - Name: my-database + Name: URL: (not deployed) Postgres projects: my_project: @@ -62,7 +62,7 @@ Resources: URL: (not deployed) Postgres databases: my_database: - Name: my-database + Name: URL: (not deployed) Postgres projects: my_project: diff --git a/bundle/config/resources/postgres_database.go b/bundle/config/resources/postgres_database.go index b600fee4897..1830e8586d3 100644 --- a/bundle/config/resources/postgres_database.go +++ b/bundle/config/resources/postgres_database.go @@ -57,7 +57,8 @@ func (d *PostgresDatabase) ResourceDescription() ResourceDescription { } func (d *PostgresDatabase) GetName() string { - return d.DatabaseId + // Databases don't have a user-visible name field. + return "" } func (d *PostgresDatabase) GetURL() string { From 35c0bccc210b11c139c406e43314a58fccc39315 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Wed, 17 Jun 2026 10:21:11 +0200 Subject: [PATCH 36/41] Add changelog entry for Lakebase postgres_roles and postgres_databases Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 01337f2be9c..8c65bc5f38d 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -21,8 +21,7 @@ * engine/direct: Don't open the deployment state WAL when a deploy's plan fails ([#5607](https://github.com/databricks/cli/pull/5607)). * Ignore unity catalog managed schema property defaults to avoid unnecessary drift ([#5195](https://github.com/databricks/cli/pull/5195)). * Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). -* Add Postgres role as a bundle resource ([#5467](https://github.com/databricks/cli/pull/5467)). -* Add support for `postgres_databases` bundle resource. +* Add `postgres_roles` and `postgres_databases` resources to create Postgres roles and databases on a Lakebase branch ([#5467](https://github.com/databricks/cli/pull/5467)). * direct: Stop spurious recreate/rename on redeploy when the backend normalizes a resource's name-based ID (e.g. Unity Catalog lowercasing a schema or volume name) ([#5599](https://github.com/databricks/cli/pull/5599)). ### Dependency updates From c5cf109563ece2b7247b1b770917458393fd6037 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Wed, 17 Jun 2026 10:33:07 +0200 Subject: [PATCH 37/41] phases: require approval to recreate or delete postgres_databases postgres_databases was absent from the deploy/destroy approval groups, so a recreate (database_id/parent change) or delete silently dropped the Postgres database with no prompt and no abort in non-interactive mode. Add it to both groups with data-loss messages matching the sibling postgres_projects/branches entries, and cover the non-interactive abort in the recreate acceptance test. Co-authored-by: Isaac --- .../postgres_databases/basic/output.txt | 4 ++++ .../postgres_databases/recreate/output.txt | 22 +++++++++++++++++++ .../postgres_databases/recreate/script | 4 ++++ .../postgres_databases/update/output.txt | 4 ++++ bundle/phases/deploy.go | 1 + bundle/phases/destroy.go | 1 + bundle/phases/messages.go | 7 ++++++ 7 files changed, 43 insertions(+) diff --git a/acceptance/bundle/resources/postgres_databases/basic/output.txt b/acceptance/bundle/resources/postgres_databases/basic/output.txt index 1f468552dda..1268083d34a 100644 --- a/acceptance/bundle/resources/postgres_databases/basic/output.txt +++ b/acceptance/bundle/resources/postgres_databases/basic/output.txt @@ -90,6 +90,10 @@ This action will result in the deletion of the following Lakebase branches. All data stored in them will be permanently lost: delete resources.postgres_branches.main +This action will result in the deletion of the following Lakebase databases. +All data stored in them will be permanently lost: + delete resources.postgres_databases.my_database + All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-[UNIQUE_NAME]/default Deleting files... diff --git a/acceptance/bundle/resources/postgres_databases/recreate/output.txt b/acceptance/bundle/resources/postgres_databases/recreate/output.txt index 1c3ebee471c..78ac47ae27e 100644 --- a/acceptance/bundle/resources/postgres_databases/recreate/output.txt +++ b/acceptance/bundle/resources/postgres_databases/recreate/output.txt @@ -112,8 +112,26 @@ recreate postgres_databases.my_database Plan: 1 to add, 0 to change, 1 to delete, 3 unchanged +=== Recreate requires approval: non-interactive deploy must abort, not silently delete the database +>>> errcode [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default/files... + +This action will result in the deletion or recreation of the following Lakebase databases. +All data stored in them will be permanently lost: + recreate resources.postgres_databases.my_database +Error: the deployment requires destructive actions, but the current console does not support prompting. +Deleting data assets such as schemas, pipelines, or volumes may cause permanent data loss and should be carefully reviewed. +To proceed, use --auto-approve after reviewing the plan above. + + +Exit code: 1 + >>> [CLI] bundle deploy --auto-approve Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default/files... + +This action will result in the deletion or recreation of the following Lakebase databases. +All data stored in them will be permanently lost: + recreate resources.postgres_databases.my_database Deploying resources... Updating deployment state... Deployment complete! @@ -163,6 +181,10 @@ This action will result in the deletion of the following Lakebase branches. All data stored in them will be permanently lost: delete resources.postgres_branches.main +This action will result in the deletion of the following Lakebase databases. +All data stored in them will be permanently lost: + delete resources.postgres_databases.my_database + All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default Deleting files... diff --git a/acceptance/bundle/resources/postgres_databases/recreate/script b/acceptance/bundle/resources/postgres_databases/recreate/script index 98e37747c43..34090313fcd 100644 --- a/acceptance/bundle/resources/postgres_databases/recreate/script +++ b/acceptance/bundle/resources/postgres_databases/recreate/script @@ -37,6 +37,10 @@ envsubst < databricks.yml.tmpl | sed "s/DATABASE_ID_PLACEHOLDER/test-database-${ trace cat databricks.yml trace $CLI bundle plan + +title "Recreate requires approval: non-interactive deploy must abort, not silently delete the database" +trace errcode $CLI bundle deploy + trace $CLI bundle deploy --auto-approve trace print_requests diff --git a/acceptance/bundle/resources/postgres_databases/update/output.txt b/acceptance/bundle/resources/postgres_databases/update/output.txt index b78180520c8..0693e98a4ee 100644 --- a/acceptance/bundle/resources/postgres_databases/update/output.txt +++ b/acceptance/bundle/resources/postgres_databases/update/output.txt @@ -159,6 +159,10 @@ This action will result in the deletion of the following Lakebase branches. All data stored in them will be permanently lost: delete resources.postgres_branches.main +This action will result in the deletion of the following Lakebase databases. +All data stored in them will be permanently lost: + delete resources.postgres_databases.my_database + All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default Deleting files... diff --git a/bundle/phases/deploy.go b/bundle/phases/deploy.go index e3b0b777eb4..fd76151483c 100644 --- a/bundle/phases/deploy.go +++ b/bundle/phases/deploy.go @@ -36,6 +36,7 @@ var deployApprovalGroups = []approvalGroup{ {group: "synced_database_tables", message: deleteOrRecreateSyncedDatabaseTableMessage}, {group: "postgres_projects", message: deleteOrRecreatePostgresProjectMessage}, {group: "postgres_branches", message: deleteOrRecreatePostgresBranchMessage}, + {group: "postgres_databases", message: deleteOrRecreatePostgresDatabaseMessage}, {group: "vector_search_indexes", message: deleteOrRecreateVectorSearchIndexMessage}, {group: "genie_spaces", message: deleteOrRecreateGenieSpaceMessage}, } diff --git a/bundle/phases/destroy.go b/bundle/phases/destroy.go index 5cd735a6302..1828545d3da 100644 --- a/bundle/phases/destroy.go +++ b/bundle/phases/destroy.go @@ -40,6 +40,7 @@ var destroyApprovalGroups = []approvalGroup{ {group: "synced_database_tables", message: deleteSyncedDatabaseTableMessage}, {group: "postgres_projects", message: deletePostgresProjectMessage}, {group: "postgres_branches", message: deletePostgresBranchMessage}, + {group: "postgres_databases", message: deletePostgresDatabaseMessage}, {group: "vector_search_indexes", message: deleteVectorSearchIndexMessage}, {group: "genie_spaces", message: deleteGenieSpaceMessage}, } diff --git a/bundle/phases/messages.go b/bundle/phases/messages.go index 22b20bb378c..8f07b1e2429 100644 --- a/bundle/phases/messages.go +++ b/bundle/phases/messages.go @@ -35,6 +35,10 @@ all their branches, databases, and endpoints. All data stored in them will be pe deleteOrRecreatePostgresBranchMessage = ` This action will result in the deletion or recreation of the following Lakebase branches. +All data stored in them will be permanently lost:` + + deleteOrRecreatePostgresDatabaseMessage = ` +This action will result in the deletion or recreation of the following Lakebase databases. All data stored in them will be permanently lost:` deleteOrRecreateVectorSearchIndexMessage = ` @@ -75,6 +79,9 @@ all their branches, databases, and endpoints. All data stored in them will be pe deletePostgresBranchMessage = `This action will result in the deletion of the following Lakebase branches. All data stored in them will be permanently lost:` + deletePostgresDatabaseMessage = `This action will result in the deletion of the following Lakebase databases. +All data stored in them will be permanently lost:` + deleteVectorSearchIndexMessage = `This action will result in the deletion of the following Vector Search indexes. For Delta Sync indexes, the source Delta Table is preserved but the embedding pipeline is removed. For Direct Access indexes, all upserted vectors are permanently lost:` From e0125c77e0f5eafae707f8a179038e2b1761e2cd Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Wed, 17 Jun 2026 10:34:39 +0200 Subject: [PATCH 38/41] Reference the postgres_databases PR in the changelog entry Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 8c65bc5f38d..eb865b247cc 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -21,7 +21,7 @@ * engine/direct: Don't open the deployment state WAL when a deploy's plan fails ([#5607](https://github.com/databricks/cli/pull/5607)). * Ignore unity catalog managed schema property defaults to avoid unnecessary drift ([#5195](https://github.com/databricks/cli/pull/5195)). * Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). -* Add `postgres_roles` and `postgres_databases` resources to create Postgres roles and databases on a Lakebase branch ([#5467](https://github.com/databricks/cli/pull/5467)). +* Add `postgres_roles` and `postgres_databases` resources to create Postgres roles and databases on a Lakebase branch ([#5467](https://github.com/databricks/cli/pull/5467), [#5627](https://github.com/databricks/cli/pull/5627)). * direct: Stop spurious recreate/rename on redeploy when the backend normalizes a resource's name-based ID (e.g. Unity Catalog lowercasing a schema or volume name) ([#5599](https://github.com/databricks/cli/pull/5599)). ### Dependency updates From 7ea2ab04df9407d6b7691dc7be1505eec2fdec52 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Wed, 17 Jun 2026 11:05:10 +0200 Subject: [PATCH 39/41] postgres_databases: use provided_id_fields and minimize apitypes.yml diff Match the sibling Lakebase resources (projects/branches/endpoints/catalogs/ synced_tables): declare parent and database_id via provided_id_fields so a local change recreates while a backend-normalized remote value is skipped, instead of recreate_on_changes. Also keep apitypes.yml to a single added line. Co-authored-by: Isaac --- bundle/direct/dresources/apitypes.yml | 6 +++--- bundle/direct/dresources/resources.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bundle/direct/dresources/apitypes.yml b/bundle/direct/dresources/apitypes.yml index 869fefa93e0..b3f83ba12a3 100644 --- a/bundle/direct/dresources/apitypes.yml +++ b/bundle/direct/dresources/apitypes.yml @@ -13,14 +13,14 @@ genie_spaces: dashboards.GenieSpace postgres_branches: postgres.BranchSpec -postgres_catalogs: postgres.CatalogCatalogSpec - postgres_databases: postgres.DatabaseDatabaseSpec postgres_endpoints: postgres.EndpointSpec postgres_projects: postgres.ProjectSpec -postgres_roles: postgres.RoleRoleSpec +postgres_catalogs: postgres.CatalogCatalogSpec postgres_synced_tables: postgres.SyncedTableSyncedTableSpec + +postgres_roles: postgres.RoleRoleSpec diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 412ef00f9c5..e876a0cefc3 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -589,12 +589,12 @@ resources: reason: "input_only; cannot be updated after create" postgres_databases: - recreate_on_changes: + provided_id_fields: # parent and database_id are immutable (part of hierarchical name, not in API spec) - field: parent - reason: immutable + reason: id_field - field: database_id - reason: immutable + reason: id_field postgres_endpoints: provided_id_fields: From 30a63e7c1d34e7fdf23c09cfdeb8b863fcba7321 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Wed, 17 Jun 2026 11:18:04 +0200 Subject: [PATCH 40/41] Drop already-released data_security_mode entry from NEXT_CHANGELOG #5452 shipped and was moved to CHANGELOG.md upstream; it was carried over here through the changelog merge conflicts. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index e5551ee552f..693fbc7bd59 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -21,7 +21,6 @@ * engine/direct: Fix WAL corruption after two consecutive failed deploys ([#5606](https://github.com/databricks/cli/pull/5606)). * engine/direct: Don't open the deployment state WAL when a deploy's plan fails ([#5607](https://github.com/databricks/cli/pull/5607)). * Ignore unity catalog managed schema property defaults to avoid unnecessary drift ([#5195](https://github.com/databricks/cli/pull/5195)). -* Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). * Add `postgres_roles` and `postgres_databases` resources to create Postgres roles and databases on a Lakebase branch ([#5467](https://github.com/databricks/cli/pull/5467), [#5627](https://github.com/databricks/cli/pull/5627)). * direct: Stop spurious recreate/rename on redeploy when the backend normalizes a resource's name-based ID (e.g. Unity Catalog lowercasing a schema or volume name) ([#5599](https://github.com/databricks/cli/pull/5599)). From 717d4c1f6f26c0416739aeda24baa74c1c9dedc8 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Wed, 17 Jun 2026 11:35:27 +0200 Subject: [PATCH 41/41] acceptance: assert recreate deploy aborts via musterr The recreate test relied on errcode to record the exit code of the approval-gated deploy; musterr asserts it must abort, so a regression that silently recreates the database (losing data) fails the test. Co-authored-by: Isaac --- .../bundle/resources/postgres_databases/recreate/output.txt | 4 +--- .../bundle/resources/postgres_databases/recreate/script | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/acceptance/bundle/resources/postgres_databases/recreate/output.txt b/acceptance/bundle/resources/postgres_databases/recreate/output.txt index 78ac47ae27e..23ba9f55936 100644 --- a/acceptance/bundle/resources/postgres_databases/recreate/output.txt +++ b/acceptance/bundle/resources/postgres_databases/recreate/output.txt @@ -113,7 +113,7 @@ recreate postgres_databases.my_database Plan: 1 to add, 0 to change, 1 to delete, 3 unchanged === Recreate requires approval: non-interactive deploy must abort, not silently delete the database ->>> errcode [CLI] bundle deploy +>>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default/files... This action will result in the deletion or recreation of the following Lakebase databases. @@ -124,8 +124,6 @@ Deleting data assets such as schemas, pipelines, or volumes may cause permanent To proceed, use --auto-approve after reviewing the plan above. -Exit code: 1 - >>> [CLI] bundle deploy --auto-approve Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default/files... diff --git a/acceptance/bundle/resources/postgres_databases/recreate/script b/acceptance/bundle/resources/postgres_databases/recreate/script index 34090313fcd..324b6f8eafb 100644 --- a/acceptance/bundle/resources/postgres_databases/recreate/script +++ b/acceptance/bundle/resources/postgres_databases/recreate/script @@ -39,7 +39,7 @@ trace cat databricks.yml trace $CLI bundle plan title "Recreate requires approval: non-interactive deploy must abort, not silently delete the database" -trace errcode $CLI bundle deploy +musterr trace $CLI bundle deploy trace $CLI bundle deploy --auto-approve