From 36c29a73d5032ea6d2e62ed1b0f088a1829ea529 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 19 May 2026 11:23:39 +0200 Subject: [PATCH 01/20] 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 02/20] 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 03/20] 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 04/20] 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 c253582c0decea9084d356a3b9bf17750c2bd515 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 26 May 2026 15:02:38 +0200 Subject: [PATCH 05/20] 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 06/20] 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 f102b2852b20b8d3b7d8de38ab9d65dcde8e53aa Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 9 Jun 2026 12:54:45 +0200 Subject: [PATCH 07/20] postgres_role: clean up NEXT_CHANGELOG.md entries Remove three entries that were resurrected by a changelog-rotation merge (#5349/#5364, #5370, #4995); they already shipped and live in CHANGELOG.md. Drop the '(preview)' tag from the Postgres role entry and link this PR. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index c84f6dbd4ae..bc28e332c79 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -7,10 +7,7 @@ ### CLI ### Bundles -* Retry transient HTTP 5xx and 408 errors in direct deployment engine ([#5349](https://github.com/databricks/cli/pull/5349), [#5364](https://github.com/databricks/cli/pull/5364)). -* Preserve `.designer.ipynb` suffix when translating notebook task paths so Lakeflow Designer files referenced from a `notebook_task` resolve correctly in the workspace ([#5370](https://github.com/databricks/cli/pull/5370)). -* Fix script output dropping last line without trailing newline ([#4995](https://github.com/databricks/cli/pull/4995)). -* Add Postgres role as a bundle resource (preview). +* Add Postgres role as a bundle resource ([#5467](https://github.com/databricks/cli/pull/5467)). * Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). ### Dependency updates From b6a669da1fe5aa861dd511e4a17ed837eab69f60 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 9 Jun 2026 12:58:11 +0200 Subject: [PATCH 08/20] dresources: isolate the role update-mask leaf collapse from the shared helper Revert collectUpdatePathsWithPrefix to its original form so the widely-used helper (also called by postgres branches/endpoints/projects) is unchanged, and add a dedicated collectLeafUpdatePathsWithPrefix used only by postgres roles. The role PATCH endpoint rejects an update_mask listing both a struct and one of its sub-fields, so {attributes, attributes.createdb} must collapse to the leaf. The new helper also sorts its output so the generated update_mask is stable across map-iteration order. Add unit tests covering the collapse, sorting, and non-update filtering. Co-authored-by: Isaac --- bundle/direct/dresources/postgres_role.go | 2 +- bundle/direct/dresources/util.go | 24 ++++++++++--- bundle/direct/dresources/util_test.go | 44 +++++++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/bundle/direct/dresources/postgres_role.go b/bundle/direct/dresources/postgres_role.go index 2ae25356a4e..b127cabd8c7 100644 --- a/bundle/direct/dresources/postgres_role.go +++ b/bundle/direct/dresources/postgres_role.go @@ -110,7 +110,7 @@ func (r *ResourcePostgresRole) DoUpdate(ctx context.Context, id string, config * // 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.") + fieldPaths := collectLeafUpdatePathsWithPrefix(entry.Changes, "spec.") waiter, err := r.client.Postgres.UpdateRole(ctx, postgres.UpdateRoleRequest{ Name: id, diff --git a/bundle/direct/dresources/util.go b/bundle/direct/dresources/util.go index 565f80c7dd6..fc68f3ea37c 100644 --- a/bundle/direct/dresources/util.go +++ b/bundle/direct/dresources/util.go @@ -2,6 +2,7 @@ package dresources import ( "errors" + "slices" "strings" "github.com/databricks/cli/bundle/deployplan" @@ -24,12 +25,24 @@ 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 { + paths = append(paths, prefix+path) + } + } + return paths +} + +// collectLeafUpdatePathsWithPrefix is like collectUpdatePathsWithPrefix but drops a parent +// path when a more specific child path is also being updated, and sorts the result. +// +// The Postgres Role PATCH endpoint rejects an update_mask that lists both a struct and one +// of its sub-fields, since the parent already implies the whole subtree. E.g. {"attributes", +// "attributes.createdb"} collapses to {"attributes.createdb"}. Sorting keeps the generated +// update_mask stable regardless of map iteration order. +func collectLeafUpdatePathsWithPrefix(changes Changes, prefix string) []string { var paths []string for path, change := range changes { if change.Action != deployplan.Update { @@ -49,5 +62,6 @@ func collectUpdatePathsWithPrefix(changes Changes, prefix string) []string { paths = append(paths, prefix+path) } } + slices.Sort(paths) return paths } diff --git a/bundle/direct/dresources/util_test.go b/bundle/direct/dresources/util_test.go index 544b3e2bde8..4bd5e8f91c2 100644 --- a/bundle/direct/dresources/util_test.go +++ b/bundle/direct/dresources/util_test.go @@ -4,6 +4,7 @@ import ( "reflect" "testing" + "github.com/databricks/cli/bundle/deployplan" "github.com/stretchr/testify/assert" ) @@ -26,3 +27,46 @@ func assertFieldsCovered(t *testing.T, sdkType, remoteType reflect.Type, skip ma assert.Contains(t, remoteFields, field.Name, "field %s from %s is missing in %s", field.Name, sdkType.Name(), remoteType.Name()) } } + +func TestCollectLeafUpdatePathsWithPrefix(t *testing.T) { + upd := func() *deployplan.ChangeDesc { return &deployplan.ChangeDesc{Action: deployplan.Update} } + skip := func() *deployplan.ChangeDesc { return &deployplan.ChangeDesc{Action: deployplan.Skip} } + + tests := []struct { + name string + changes Changes + want []string + }{ + { + name: "drops parent when a child is also updated", + changes: Changes{"attributes": upd(), "attributes.createdb": upd()}, + want: []string{"spec.attributes.createdb"}, + }, + { + name: "keeps parent when its only child is not updated", + changes: Changes{"attributes": upd(), "attributes.createdb": skip()}, + want: []string{"spec.attributes"}, + }, + { + name: "sorts multiple leaf paths", + changes: Changes{"membership_roles": upd(), "attributes.createdb": upd()}, + want: []string{"spec.attributes.createdb", "spec.membership_roles"}, + }, + { + name: "ignores non-update actions", + changes: Changes{"parent": skip(), "role_id": skip(), "attributes.createdb": upd()}, + want: []string{"spec.attributes.createdb"}, + }, + { + name: "no updates yields no paths", + changes: Changes{"parent": skip()}, + want: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, collectLeafUpdatePathsWithPrefix(tc.changes, "spec.")) + }) + } +} From 91871b6e0bfef9459f83b10f9839d26ddd8a53f3 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 9 Jun 2026 13:01:27 +0200 Subject: [PATCH 09/20] postgres_role: map RemapState from remote.Status GET returns the role configuration under Status, not Spec (Spec is nil on read). The previous RemapState zeroed the spec fields and claimed remote drift could not be detected, which is inaccurate: RoleRoleStatus echoes postgres_role, auth_method, identity_type, attributes, membership_roles and role_id. Map those back onto the flattened state so the snapshot reflects the live role. The spec fields stay ignore_remote_changes (spec:input_only), so the plan remains a no-op; the regenerated direct plan output just records the now-visible remote values instead of dropping them. Co-authored-by: Isaac --- .../update/out.plan.no_change.direct.json | 30 +++++++++------- .../update/out.plan.restore.direct.json | 36 +++++++++++-------- .../update/out.plan.update.direct.json | 36 +++++++++++-------- bundle/direct/dresources/postgres_role.go | 34 +++++++++--------- 4 files changed, 76 insertions(+), 60 deletions(-) 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..fd22b1e19ee 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 @@ -24,15 +24,25 @@ "update_time": "[TIMESTAMP]" }, "changes": { - "attributes": { + "attributes.bypassrls": { + "action": "skip", + "reason": "empty", + "remote": false + }, + "attributes.createrole": { + "action": "skip", + "reason": "empty", + "remote": false + }, + "auth_method": { "action": "skip", "reason": "spec:input_only", - "old": { - "createdb": false - }, - "new": { - "createdb": false - } + "remote": "PG_PASSWORD_SCRAM_SHA_256" + }, + "identity_type": { + "action": "skip", + "reason": "spec:input_only", + "remote": "IDENTITY_TYPE_UNSPECIFIED" }, "parent": { "action": "skip", @@ -41,12 +51,6 @@ "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", 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..e479a33822e 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 @@ -32,19 +32,31 @@ "update_time": "[TIMESTAMP]" }, "changes": { - "attributes": { - "action": "update", - "old": { - "createdb": true - }, - "new": { - "createdb": false - } + "attributes.bypassrls": { + "action": "skip", + "reason": "empty", + "remote": false }, "attributes.createdb": { "action": "update", "old": true, - "new": false + "new": false, + "remote": true + }, + "attributes.createrole": { + "action": "skip", + "reason": "empty", + "remote": false + }, + "auth_method": { + "action": "skip", + "reason": "spec:input_only", + "remote": "PG_PASSWORD_SCRAM_SHA_256" + }, + "identity_type": { + "action": "skip", + "reason": "spec:input_only", + "remote": "IDENTITY_TYPE_UNSPECIFIED" }, "parent": { "action": "skip", @@ -53,12 +65,6 @@ "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", 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..e34019b7829 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 @@ -32,19 +32,31 @@ "update_time": "[TIMESTAMP]" }, "changes": { - "attributes": { - "action": "update", - "old": { - "createdb": false - }, - "new": { - "createdb": true - } + "attributes.bypassrls": { + "action": "skip", + "reason": "empty", + "remote": false }, "attributes.createdb": { "action": "update", "old": false, - "new": true + "new": true, + "remote": false + }, + "attributes.createrole": { + "action": "skip", + "reason": "empty", + "remote": false + }, + "auth_method": { + "action": "skip", + "reason": "spec:input_only", + "remote": "PG_PASSWORD_SCRAM_SHA_256" + }, + "identity_type": { + "action": "skip", + "reason": "spec:input_only", + "remote": "IDENTITY_TYPE_UNSPECIFIED" }, "parent": { "action": "skip", @@ -53,12 +65,6 @@ "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", diff --git a/bundle/direct/dresources/postgres_role.go b/bundle/direct/dresources/postgres_role.go index b127cabd8c7..d376d9244ee 100644 --- a/bundle/direct/dresources/postgres_role.go +++ b/bundle/direct/dresources/postgres_role.go @@ -51,26 +51,26 @@ 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{ - RoleId: roleID, + state := &PostgresRoleState{ 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, - }, + // GET returns the role configuration under Status, not Spec (Spec is nil on + // read), so map the status fields back onto our flattened state to reflect the + // live role. The spec fields remain ignore_remote_changes in resources.yml, so + // this produces an accurate state snapshot rather than spurious drift. + if remote.Status != nil { + state.RoleId = remote.Status.RoleId + state.RoleRoleSpec = postgres.RoleRoleSpec{ + Attributes: remote.Status.Attributes, + AuthMethod: remote.Status.AuthMethod, + IdentityType: remote.Status.IdentityType, + MembershipRoles: remote.Status.MembershipRoles, + PostgresRole: remote.Status.PostgresRole, + } } + + return state } func (r *ResourcePostgresRole) DoRead(ctx context.Context, id string) (*postgres.Role, error) { From 4360f213ab16e5dbe87001aefc9ddedcd5553069 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 9 Jun 2026 13:44:33 +0200 Subject: [PATCH 10/20] testserver: honor update_mask in PostgresRoleUpdate The fake role update applied the whole request spec regardless of update_mask, so it could not model partial updates. Read the update_mask query parameter and apply only the named spec fields onto the existing status, preserving the rest; an empty mask updates everything, matching the API ("if unspecified, all fields will be updated when possible"). Add a unit test asserting a field absent from update_mask is preserved even when the request body carries a new value for it. Co-authored-by: Isaac --- libs/testserver/postgres.go | 43 ++++++++++++++++++++++++++++++-- libs/testserver/postgres_test.go | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index 031405d1fee..ecb8acf4c4c 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -941,7 +941,10 @@ func (s *FakeWorkspace) PostgresRoleList(parent string) Response { } } -// PostgresRoleUpdate updates a postgres role. +// PostgresRoleUpdate updates a postgres role. The update_mask query parameter +// selects which spec fields to apply; the request body always carries the full +// desired spec. An empty update_mask updates all fields, matching the API +// ("if unspecified, all fields will be updated when possible"). func (s *FakeWorkspace) PostgresRoleUpdate(req Request, name string) Response { defer s.LockUnlock()() @@ -966,7 +969,11 @@ func (s *FakeWorkspace) PostgresRoleUpdate(req Request, name string) Response { if role.Status != nil { roleID = role.Status.RoleId } - role.Status = roleStatusFromSpec(updateRole.Spec) + var paths []string + if mask := req.URL.Query().Get("update_mask"); mask != "" { + paths = strings.Split(mask, ",") + } + role.Status = applyRoleSpecMask(role.Status, roleStatusFromSpec(updateRole.Spec), paths) role.Status.RoleId = roleID } @@ -978,6 +985,38 @@ func (s *FakeWorkspace) PostgresRoleUpdate(req Request, name string) Response { } } +// applyRoleSpecMask returns the role status after applying the fields named in +// paths (the update_mask) from desired onto existing. Paths are relative to the +// Role and "spec."-prefixed (e.g. "spec.attributes.createdb"); the bare path +// "spec" updates the whole subtree. An empty paths slice updates everything. +// +// Because the request body always carries the full desired spec, a nested path is +// reduced to its top-level spec field — applying the whole field is equivalent. +func applyRoleSpecMask(existing, desired *postgres.RoleRoleStatus, paths []string) *postgres.RoleRoleStatus { + if len(paths) == 0 || existing == nil { + return desired + } + result := *existing + for _, p := range paths { + field, _, _ := strings.Cut(strings.TrimPrefix(p, "spec."), ".") + switch field { + case "spec": + result = *desired + case "postgres_role": + result.PostgresRole = desired.PostgresRole + case "auth_method": + result.AuthMethod = desired.AuthMethod + case "identity_type": + result.IdentityType = desired.IdentityType + case "membership_roles": + result.MembershipRoles = desired.MembershipRoles + case "attributes": + result.Attributes = desired.Attributes + } + } + return &result +} + // PostgresRoleDelete deletes a postgres role. func (s *FakeWorkspace) PostgresRoleDelete(name string) Response { defer s.LockUnlock()() diff --git a/libs/testserver/postgres_test.go b/libs/testserver/postgres_test.go index d4f06c83021..370fe5817d4 100644 --- a/libs/testserver/postgres_test.go +++ b/libs/testserver/postgres_test.go @@ -366,6 +366,49 @@ func TestPostgresRoleCRUD(t *testing.T) { deleteRoleResp.Body.Close() } +func TestPostgresRoleUpdateMaskPreservesUnmaskedFields(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + + client := &http.Client{} + baseURL := server.URL + + do := func(method, url, body string) *http.Response { + req, err := http.NewRequest(method, baseURL+url, strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + require.NoError(t, err) + return resp + } + + do(http.MethodPost, "/api/2.0/postgres/projects?project_id=mask-project", "").Body.Close() + do(http.MethodPost, "/api/2.0/postgres/projects/mask-project/branches?branch_id=main", "").Body.Close() + + createBody := `{"spec":{"postgres_role":"app_role","attributes":{"createdb":false}}}` + createResp := do(http.MethodPost, "/api/2.0/postgres/projects/mask-project/branches/main/roles?role_id=approle", createBody) + require.Equal(t, 200, createResp.StatusCode) + createResp.Body.Close() + + // Update only spec.attributes.createdb. The body also carries a different + // postgres_role, which must be ignored because it is not named in update_mask. + patchBody := `{"spec":{"postgres_role":"renamed","attributes":{"createdb":true}}}` + patchResp := do(http.MethodPatch, "/api/2.0/postgres/projects/mask-project/branches/main/roles/approle?update_mask=spec.attributes.createdb", patchBody) + assert.Equal(t, 200, patchResp.StatusCode) + patchResp.Body.Close() + + getResp := do(http.MethodGet, "/api/2.0/postgres/projects/mask-project/branches/main/roles/approle", "") + var role postgres.Role + require.NoError(t, json.NewDecoder(getResp.Body).Decode(&role)) + getResp.Body.Close() + + require.NotNil(t, role.Status) + require.NotNil(t, role.Status.Attributes) + assert.True(t, role.Status.Attributes.Createdb, "masked field should be updated") + assert.Equal(t, "app_role", role.Status.PostgresRole, "field absent from update_mask should be preserved") +} + func TestPostgresRoleNotFoundWhenBranchNotExists(t *testing.T) { server := testserver.New(t) testserver.AddDefaultHandlers(server) From c8b7a1e09881c7450752ad0f506ff262bf2cecc7 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 12 Jun 2026 09:31:24 +0200 Subject: [PATCH 11/20] acceptance: document postgres role inheritance conflict (Badness) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a local-only acceptance test that reproduces what happens when a bundle declares a Postgres role that already exists on the branch — the situation a child branch lands in, since it inherits the parent's roles at creation. The testserver does not model branch role inheritance, so the precondition is staged with deploy + 'bundle deployment unbind': the role then exists on the branch but is untracked, and the next deploy plans to create it and fails. The Badness note records the real-Lakebase behavior verified on dogfood (HTTP 400 'role with that name already exists') and the two testserver fidelity gaps (no role inheritance on branch create; duplicate create returns 409 instead of 400). The supported fix is 'bundle deployment bind'. Co-authored-by: Isaac --- .../databricks.yml.tmpl | 25 ++++++++++ .../inherited-role-conflict/out.test.toml | 6 +++ .../inherited-role-conflict/output.txt | 50 +++++++++++++++++++ .../inherited-role-conflict/script | 21 ++++++++ .../inherited-role-conflict/test.toml | 7 +++ 5 files changed, 109 insertions(+) create mode 100644 acceptance/bundle/resources/postgres_roles/inherited-role-conflict/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_roles/inherited-role-conflict/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/inherited-role-conflict/output.txt create mode 100644 acceptance/bundle/resources/postgres_roles/inherited-role-conflict/script create mode 100644 acceptance/bundle/resources/postgres_roles/inherited-role-conflict/test.toml diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/databricks.yml.tmpl new file mode 100644 index 00000000000..adf6ac40023 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/databricks.yml.tmpl @@ -0,0 +1,25 @@ +bundle: + name: inherited-role-conflict-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Role Inheritance" + 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 diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/out.test.toml b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/out.test.toml new file mode 100644 index 00000000000..c5b8e7c8a71 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = false +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/output.txt b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/output.txt new file mode 100644 index 00000000000..49642622181 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/output.txt @@ -0,0 +1,50 @@ + +=== Deploy project, branch, and role +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/inherited-role-conflict-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== Unbind the role: it now exists on the branch but is no longer tracked by the bundle +>>> [CLI] bundle deployment unbind my_role +Updating deployment state... + +=== Plan after unbind: the bundle wants to (re-)create the role +>>> [CLI] bundle plan +create postgres_roles.my_role + +Plan: 1 to add, 0 to change, 0 to delete, 2 unchanged + +=== Re-deploy: creating the already-existing role fails +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/inherited-role-conflict-[UNIQUE_NAME]/default/files... +Deploying resources... +Error: cannot create resources.postgres_roles.my_role: role with such id already exists (409 ALREADY_EXISTS) + +Endpoint: POST [DATABRICKS_URL]/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles?role_id=test-role +HTTP Status: 409 Conflict +API error_code: ALREADY_EXISTS +API message: role with such id already exists + +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/inherited-role-conflict-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! + +Exit code: 1 diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/script b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/script new file mode 100644 index 00000000000..0999984022d --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/script @@ -0,0 +1,21 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +title "Deploy project, branch, and role" +trace $CLI bundle deploy + +title "Unbind the role: it now exists on the branch but is no longer tracked by the bundle" +# Mirrors an inherited role on a child branch: present on the branch, but not +# created or owned by this bundle. +trace $CLI bundle deployment unbind my_role + +title "Plan after unbind: the bundle wants to (re-)create the role" +trace $CLI bundle plan + +title "Re-deploy: creating the already-existing role fails" +trace $CLI bundle deploy diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/test.toml b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/test.toml new file mode 100644 index 00000000000..9bd3b30b5fa --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/test.toml @@ -0,0 +1,7 @@ +Badness = "Reproduces the Postgres role inheritance conflict by unbinding a deployed role (so it exists on the branch but is untracked, like an inherited role on a child branch) and redeploying. On real Lakebase a child branch inherits the parent's roles at creation, so a bundle that declares an inherited role fails on create — verified on dogfood 2026-06-10, where the API returns HTTP 400 'role with that name already exists'. Two testserver fidelity gaps make this local-only and staged via unbind: PostgresBranchCreate does not copy parent roles, and duplicate create returns 409 ALREADY_EXISTS instead of 400 BAD_REQUEST. The supported way to manage a pre-existing/inherited role is `bundle deployment bind`." + +# Output captures the testserver's 409, which diverges from the real API's 400. +Cloud = false + +# Deploy error wording differs between engines; the conflict itself is engine-agnostic. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] From 79fb4fed30ce646969d051c35320873ae630f069 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Sat, 13 Jun 2026 05:06:37 +0200 Subject: [PATCH 12/20] testserver: return 400 for duplicate Postgres role create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real Lakebase API rejects creating a role that already exists with HTTP 400 BAD_REQUEST ('role with that name already exists'), not 409 — verified on dogfood. Match it so the conflict a bundle hits on an inherited/pre-existing role looks identical locally and on cloud. Add a testserver unit test for it. Reframe the inherited-role-conflict Badness around the product decision: the 4xx-on-existing-role is the intended interim behavior; until a replace_existing flag lets a bundle take explicit ownership, 'bundle deployment bind' is the supported escape hatch. Co-authored-by: Isaac --- .../inherited-role-conflict/output.txt | 8 ++--- .../inherited-role-conflict/test.toml | 4 +-- libs/testserver/postgres.go | 5 ++- libs/testserver/postgres_test.go | 31 +++++++++++++++++++ 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/output.txt b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/output.txt index 49642622181..7db7aa88af0 100644 --- a/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/output.txt +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/output.txt @@ -20,12 +20,12 @@ Plan: 1 to add, 0 to change, 0 to delete, 2 unchanged >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/inherited-role-conflict-[UNIQUE_NAME]/default/files... Deploying resources... -Error: cannot create resources.postgres_roles.my_role: role with such id already exists (409 ALREADY_EXISTS) +Error: cannot create resources.postgres_roles.my_role: role with that name already exists (400 BAD_REQUEST) Endpoint: POST [DATABRICKS_URL]/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles?role_id=test-role -HTTP Status: 409 Conflict -API error_code: ALREADY_EXISTS -API message: role with such id already exists +HTTP Status: 400 Bad Request +API error_code: BAD_REQUEST +API message: role with that name already exists Updating deployment state... diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/test.toml b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/test.toml index 9bd3b30b5fa..3e475e74819 100644 --- a/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/test.toml +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/test.toml @@ -1,6 +1,6 @@ -Badness = "Reproduces the Postgres role inheritance conflict by unbinding a deployed role (so it exists on the branch but is untracked, like an inherited role on a child branch) and redeploying. On real Lakebase a child branch inherits the parent's roles at creation, so a bundle that declares an inherited role fails on create — verified on dogfood 2026-06-10, where the API returns HTTP 400 'role with that name already exists'. Two testserver fidelity gaps make this local-only and staged via unbind: PostgresBranchCreate does not copy parent roles, and duplicate create returns 409 ALREADY_EXISTS instead of 400 BAD_REQUEST. The supported way to manage a pre-existing/inherited role is `bundle deployment bind`." +Badness = "Reproduces the conflict a bundle hits when it declares a Postgres role that already exists on the branch — the situation on a child branch, which inherits the parent's roles at creation (verified on dogfood 2026-06-10). The bundle plans to create the role and the API rejects it with HTTP 400 'role with that name already exists'. This is the intended interim behavior: until a replace_existing flag lets a bundle take explicit ownership of an existing role, the supported escape hatch is `bundle deployment bind` (see the inherited-role-bind test). The testserver does not model branch role inheritance, so the pre-existing role is staged via `bundle deployment unbind`; the test is therefore local-only." -# Output captures the testserver's 409, which diverges from the real API's 400. +# Inheritance is staged locally via unbind; not run on cloud. Cloud = false # Deploy error wording differs between engines; the conflict itself is engine-agnostic. diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index ecb8acf4c4c..8493f219ff1 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -867,7 +867,10 @@ func (s *FakeWorkspace) PostgresRoleCreate(req Request, parent, roleID string) R 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") + // The real Lakebase API returns 400 BAD_REQUEST (not 409) for a duplicate + // role, with this message (verified on dogfood 2026-06-10). Match it so the + // conflict a bundle hits on an inherited/pre-existing role looks the same. + return postgresErrorResponse(400, "BAD_REQUEST", "role with that name already exists") } now := nowTime() diff --git a/libs/testserver/postgres_test.go b/libs/testserver/postgres_test.go index 370fe5817d4..85264369bf3 100644 --- a/libs/testserver/postgres_test.go +++ b/libs/testserver/postgres_test.go @@ -432,3 +432,34 @@ func TestPostgresRoleNotFoundWhenBranchNotExists(t *testing.T) { assert.Equal(t, 404, createRoleResp.StatusCode) createRoleResp.Body.Close() } + +func TestPostgresRoleCreateDuplicateReturns400(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + + client := &http.Client{} + baseURL := server.URL + + do := func(method, url, body string) *http.Response { + req, err := http.NewRequest(method, baseURL+url, strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + require.NoError(t, err) + return resp + } + + do(http.MethodPost, "/api/2.0/postgres/projects?project_id=dup-role-project", "").Body.Close() + do(http.MethodPost, "/api/2.0/postgres/projects/dup-role-project/branches?branch_id=main", "").Body.Close() + + createBody := `{"spec":{"postgres_role":"app_role"}}` + first := do(http.MethodPost, "/api/2.0/postgres/projects/dup-role-project/branches/main/roles?role_id=approle", createBody) + require.Equal(t, 200, first.StatusCode) + first.Body.Close() + + // Creating the same role again fails the way the real API does: 400, not 409. + second := do(http.MethodPost, "/api/2.0/postgres/projects/dup-role-project/branches/main/roles?role_id=approle", createBody) + assert.Equal(t, 400, second.StatusCode) + second.Body.Close() +} From 08473f8b05ed764b2b821c5efa797400911ca214 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Sat, 13 Jun 2026 05:08:11 +0200 Subject: [PATCH 13/20] acceptance: show bind as the escape hatch for a pre-existing role Companion to inherited-role-conflict. Stages the same pre-existing-role state (deploy + unbind), then uses 'bundle deployment bind' to take explicit ownership; the subsequent deploy manages the role instead of trying to create it and succeeds. This is the supported way to adopt an inherited role until a replace_existing flag lands. Co-authored-by: Isaac --- .../inherited-role-bind/databricks.yml.tmpl | 25 +++++++++++ .../inherited-role-bind/out.test.toml | 6 +++ .../inherited-role-bind/output.txt | 43 +++++++++++++++++++ .../postgres_roles/inherited-role-bind/script | 23 ++++++++++ .../inherited-role-bind/test.toml | 9 ++++ 5 files changed, 106 insertions(+) create mode 100644 acceptance/bundle/resources/postgres_roles/inherited-role-bind/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_roles/inherited-role-bind/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_roles/inherited-role-bind/output.txt create mode 100644 acceptance/bundle/resources/postgres_roles/inherited-role-bind/script create mode 100644 acceptance/bundle/resources/postgres_roles/inherited-role-bind/test.toml diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-bind/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_roles/inherited-role-bind/databricks.yml.tmpl new file mode 100644 index 00000000000..09e24359bc4 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-bind/databricks.yml.tmpl @@ -0,0 +1,25 @@ +bundle: + name: inherited-role-bind-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Role Inheritance" + 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 diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-bind/out.test.toml b/acceptance/bundle/resources/postgres_roles/inherited-role-bind/out.test.toml new file mode 100644 index 00000000000..c5b8e7c8a71 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-bind/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = false +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-bind/output.txt b/acceptance/bundle/resources/postgres_roles/inherited-role-bind/output.txt new file mode 100644 index 00000000000..de5850234a1 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-bind/output.txt @@ -0,0 +1,43 @@ + +=== Deploy project, branch, and role +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/inherited-role-bind-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== Unbind the role: it now exists on the branch but is untracked +>>> [CLI] bundle deployment unbind my_role +Updating deployment state... + +=== Bind the existing role to take explicit ownership of it +>>> [CLI] bundle deployment bind my_role projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role --auto-approve +Updating deployment state... +Successfully bound postgres_role with an id 'projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role' +Run 'bundle deploy' to deploy changes to your workspace + +=== Re-deploy now succeeds: the role is managed, not re-created +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/inherited-role-bind-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [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/inherited-role-bind-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-bind/script b/acceptance/bundle/resources/postgres_roles/inherited-role-bind/script new file mode 100644 index 00000000000..d98aed3e270 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-bind/script @@ -0,0 +1,23 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +title "Deploy project, branch, and role" +trace $CLI bundle deploy + +title "Unbind the role: it now exists on the branch but is untracked" +# Stages the state a child branch lands in: the role exists (it would be +# inherited from the parent) but the bundle does not own it. The testserver +# does not model branch role inheritance, so we stage it via unbind. +trace $CLI bundle deployment unbind my_role + +title "Bind the existing role to take explicit ownership of it" +role_name="projects/test-pg-proj-${UNIQUE_NAME}/branches/main/roles/test-role" +trace $CLI bundle deployment bind my_role "${role_name}" --auto-approve + +title "Re-deploy now succeeds: the role is managed, not re-created" +trace $CLI bundle deploy diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-bind/test.toml b/acceptance/bundle/resources/postgres_roles/inherited-role-bind/test.toml new file mode 100644 index 00000000000..6de86c5f2b6 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-bind/test.toml @@ -0,0 +1,9 @@ +# Companion to inherited-role-conflict: shows the supported escape hatch. +# `bundle deployment bind` takes ownership of a pre-existing (e.g. inherited) +# role so the next deploy manages it instead of trying to create it. + +# Inheritance is staged locally via unbind; not run on cloud. +Cloud = false + +# bind/deploy are engine-agnostic here; run on direct for a single stable output. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] From 81d6c1042149477bd2780423cdbe2d06b2399d8e Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 16 Jun 2026 09:23:25 +0200 Subject: [PATCH 14/20] testserver: clarify applyRoleSpecMask collapse comment Address review feedback: the old comment cited spec.attributes.createdb as if the logic masked that leaf, but it collapses nested paths to their top-level spec field and applies the whole field. State that plainly, and record the verified real-backend behavior (dogfood 2026-06-16: update_mask=spec.attributes.createdb masks at the leaf and preserves sibling attributes; identical to the collapse for the direct engine's full-spec request bodies). Co-authored-by: Isaac --- libs/testserver/postgres.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index 8493f219ff1..e046c0867e9 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -988,13 +988,17 @@ func (s *FakeWorkspace) PostgresRoleUpdate(req Request, name string) Response { } } -// applyRoleSpecMask returns the role status after applying the fields named in -// paths (the update_mask) from desired onto existing. Paths are relative to the -// Role and "spec."-prefixed (e.g. "spec.attributes.createdb"); the bare path -// "spec" updates the whole subtree. An empty paths slice updates everything. +// applyRoleSpecMask applies the fields named in paths (the update_mask) from +// desired onto existing. Paths are relative to the Role and "spec."-prefixed; the +// bare path "spec" replaces the whole subtree, and an empty paths slice replaces +// everything. // -// Because the request body always carries the full desired spec, a nested path is -// reduced to its top-level spec field — applying the whole field is equivalent. +// A nested path such as "spec.attributes.createdb" is collapsed to its top-level +// field ("attributes"), and that whole field is taken from desired. This is a +// simplification: the real backend masks at the individual leaf (verified on +// dogfood 2026-06-16 — update_mask=spec.attributes.createdb leaves the sibling +// attributes untouched), but the direct engine always sends the full spec in the +// request body, so the collapsed result is identical for the requests it makes. func applyRoleSpecMask(existing, desired *postgres.RoleRoleStatus, paths []string) *postgres.RoleRoleStatus { if len(paths) == 0 || existing == nil { return desired From b3bcfd3eb62f1c15e94b0cf1f43707c1bcd6dab5 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 16 Jun 2026 09:43:05 +0200 Subject: [PATCH 15/20] postgres_role: don't synthesize spec from status in RemapState Revert the status->spec mapping: GET returns the role config under Status, which rolls up server-side and parent-inherited defaults mixed with the spec, so mapping it back produces drift that is often unresolvable. Leave the spec empty and rely on ignore_remote_changes, matching how the other postgres resources handle it; revisit once the backend echoes the spec intent back on GET. Regenerate the update plan outputs accordingly. Also explain in type_test why postgres_roles needs knownMissingInRemoteType entries that the sibling resources don't: roles' DoRead returns the raw postgres.Role (spec nested under .Spec/.Status) rather than a custom *Remote type that embeds the spec, which is the deliberate consequence of not synthesizing a spec from status. Co-authored-by: Isaac --- .../update/out.plan.no_change.direct.json | 30 +++++++--------- .../update/out.plan.restore.direct.json | 36 ++++++++----------- .../update/out.plan.update.direct.json | 36 ++++++++----------- bundle/direct/dresources/postgres_role.go | 29 +++++++-------- bundle/direct/dresources/type_test.go | 10 ++++-- 5 files changed, 62 insertions(+), 79 deletions(-) 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 fd22b1e19ee..3772eeef349 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 @@ -24,25 +24,15 @@ "update_time": "[TIMESTAMP]" }, "changes": { - "attributes.bypassrls": { - "action": "skip", - "reason": "empty", - "remote": false - }, - "attributes.createrole": { - "action": "skip", - "reason": "empty", - "remote": false - }, - "auth_method": { + "attributes": { "action": "skip", "reason": "spec:input_only", - "remote": "PG_PASSWORD_SCRAM_SHA_256" - }, - "identity_type": { - "action": "skip", - "reason": "spec:input_only", - "remote": "IDENTITY_TYPE_UNSPECIFIED" + "old": { + "createdb": false + }, + "new": { + "createdb": false + } }, "parent": { "action": "skip", @@ -51,6 +41,12 @@ "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", 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 e479a33822e..647ce8e7991 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 @@ -32,31 +32,19 @@ "update_time": "[TIMESTAMP]" }, "changes": { - "attributes.bypassrls": { - "action": "skip", - "reason": "empty", - "remote": false + "attributes": { + "action": "update", + "old": { + "createdb": true + }, + "new": { + "createdb": false + } }, "attributes.createdb": { "action": "update", "old": true, - "new": false, - "remote": true - }, - "attributes.createrole": { - "action": "skip", - "reason": "empty", - "remote": false - }, - "auth_method": { - "action": "skip", - "reason": "spec:input_only", - "remote": "PG_PASSWORD_SCRAM_SHA_256" - }, - "identity_type": { - "action": "skip", - "reason": "spec:input_only", - "remote": "IDENTITY_TYPE_UNSPECIFIED" + "new": false }, "parent": { "action": "skip", @@ -65,6 +53,12 @@ "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", 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 e34019b7829..7917cf60e44 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 @@ -32,31 +32,19 @@ "update_time": "[TIMESTAMP]" }, "changes": { - "attributes.bypassrls": { - "action": "skip", - "reason": "empty", - "remote": false + "attributes": { + "action": "update", + "old": { + "createdb": false + }, + "new": { + "createdb": true + } }, "attributes.createdb": { "action": "update", "old": false, - "new": true, - "remote": false - }, - "attributes.createrole": { - "action": "skip", - "reason": "empty", - "remote": false - }, - "auth_method": { - "action": "skip", - "reason": "spec:input_only", - "remote": "PG_PASSWORD_SCRAM_SHA_256" - }, - "identity_type": { - "action": "skip", - "reason": "spec:input_only", - "remote": "IDENTITY_TYPE_UNSPECIFIED" + "new": true }, "parent": { "action": "skip", @@ -65,6 +53,12 @@ "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", diff --git a/bundle/direct/dresources/postgres_role.go b/bundle/direct/dresources/postgres_role.go index d376d9244ee..b19267615a4 100644 --- a/bundle/direct/dresources/postgres_role.go +++ b/bundle/direct/dresources/postgres_role.go @@ -51,26 +51,21 @@ func (*ResourcePostgresRole) PrepareState(input *resources.PostgresRole) *Postgr } func (*ResourcePostgresRole) RemapState(remote *postgres.Role) *PostgresRoleState { - state := &PostgresRoleState{ - Parent: remote.Parent, - } - - // GET returns the role configuration under Status, not Spec (Spec is nil on - // read), so map the status fields back onto our flattened state to reflect the - // live role. The spec fields remain ignore_remote_changes in resources.yml, so - // this produces an accurate state snapshot rather than spurious drift. + var roleID string if remote.Status != nil { - state.RoleId = remote.Status.RoleId - state.RoleRoleSpec = postgres.RoleRoleSpec{ - Attributes: remote.Status.Attributes, - AuthMethod: remote.Status.AuthMethod, - IdentityType: remote.Status.IdentityType, - MembershipRoles: remote.Status.MembershipRoles, - PostgresRole: remote.Status.PostgresRole, - } + roleID = remote.Status.RoleId } + return &PostgresRoleState{ + RoleId: roleID, + Parent: remote.Parent, - return state + // Spec is left empty on purpose. GET returns the role config under Status, + // but Status rolls up server-side and parent-inherited defaults mixed with + // the spec, so mapping it back onto the spec produces drift that is often + // unresolvable. The spec fields are ignore_remote_changes in resources.yml; + // revisit once the backend echoes the spec intent back on GET. + RoleRoleSpec: postgres.RoleRoleSpec{}, + } } func (r *ResourcePostgresRole) DoRead(ctx context.Context, id string) (*postgres.Role, error) { diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 1aa4c925a20..e87964b9a1f 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -52,15 +52,19 @@ var knownMissingInRemoteType = map[string][]string{ "postgres_projects": { "purge_on_delete", }, + // Unlike the other postgres resources, whose DoRead returns a custom *Remote + // type that embeds the spec, roles' DoRead returns the raw postgres.Role, which + // nests these under .Spec/.Status instead of exposing them at the top level. + // That is deliberate: roles does not synthesize a spec from status (see + // RemapState) to avoid the drift that mapping rolled-up status defaults causes. "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 is the leaf id derived from the hierarchical name; the remote + // Role only exposes the full Name. "role_id", }, "vector_search_endpoints": { From f0f3b05c82da5215395ab18829a8aa7896189377 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 16 Jun 2026 13:20:10 +0200 Subject: [PATCH 16/20] lint --- bundle/direct/dresources/postgres_role.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bundle/direct/dresources/postgres_role.go b/bundle/direct/dresources/postgres_role.go index b19267615a4..9ddc138994a 100644 --- a/bundle/direct/dresources/postgres_role.go +++ b/bundle/direct/dresources/postgres_role.go @@ -64,7 +64,14 @@ func (*ResourcePostgresRole) RemapState(remote *postgres.Role) *PostgresRoleStat // the spec, so mapping it back onto the spec produces drift that is often // unresolvable. The spec fields are ignore_remote_changes in resources.yml; // revisit once the backend echoes the spec intent back on GET. - RoleRoleSpec: postgres.RoleRoleSpec{}, + RoleRoleSpec: postgres.RoleRoleSpec{ + Attributes: nil, + AuthMethod: "", + IdentityType: "", + MembershipRoles: nil, + PostgresRole: "", + ForceSendFields: nil, + }, } } From 412d086c87ba40f3197c241092b0538d5f49f56d Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 16 Jun 2026 13:20:49 +0200 Subject: [PATCH 17/20] regenerate --- bundle/terraform_dabs_map/generated.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bundle/terraform_dabs_map/generated.go b/bundle/terraform_dabs_map/generated.go index 703f32a5c00..596aef4b5c0 100644 --- a/bundle/terraform_dabs_map/generated.go +++ b/bundle/terraform_dabs_map/generated.go @@ -23,6 +23,7 @@ package terraform_dabs_map // postgres_catalogs / databricks_postgres_catalog: 1 unwraps // postgres_endpoints / databricks_postgres_endpoint: 1 unwraps // postgres_projects / databricks_postgres_project: 1 unwraps +// postgres_roles / databricks_postgres_role: 1 unwraps // postgres_synced_tables / databricks_postgres_synced_table: 1 unwraps // schemas / databricks_schema: 1 tf-only // secret_scopes / databricks_secret_scope: 1 tf-only @@ -69,6 +70,9 @@ var TerraformToDABsFieldMap = map[string]RenameTree{ "postgres_projects": { "spec": {Unwrap: true}, }, + "postgres_roles": { + "spec": {Unwrap: true}, + }, "postgres_synced_tables": { "spec": {Unwrap: true}, }, @@ -602,5 +606,6 @@ var DABsToTerraformWrappers = map[string]string{ "postgres_catalogs": "spec", "postgres_endpoints": "spec", "postgres_projects": "spec", + "postgres_roles": "spec", "postgres_synced_tables": "spec", } From 597650909ab421e8131c1f029ac14b40747d5202 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 16 Jun 2026 14:42:34 +0200 Subject: [PATCH 18/20] Remove comment block --- bundle/direct/dresources/postgres_role.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/bundle/direct/dresources/postgres_role.go b/bundle/direct/dresources/postgres_role.go index 9ddc138994a..1524cc16ad6 100644 --- a/bundle/direct/dresources/postgres_role.go +++ b/bundle/direct/dresources/postgres_role.go @@ -1,12 +1,3 @@ -// 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 ( From ea1f33c1b5164e412c013f98217baa20af3a0460 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 16 Jun 2026 14:51:23 +0200 Subject: [PATCH 19/20] Address comment --- .../update/out.plan.no_change.direct.json | 1 + .../update/out.plan.restore.direct.json | 1 + .../update/out.plan.update.direct.json | 1 + bundle/direct/dresources/postgres_role.go | 95 ++++++++++++++----- bundle/direct/dresources/type_test.go | 15 --- 5 files changed, 72 insertions(+), 41 deletions(-) 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..d09be43517b 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 @@ -10,6 +10,7 @@ "create_time": "[TIMESTAMP]", "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "role_id": "test-role", "status": { "attributes": { "bypassrls": false, 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..aff2400a8b4 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 @@ -18,6 +18,7 @@ "create_time": "[TIMESTAMP]", "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "role_id": "test-role", "status": { "attributes": { "bypassrls": false, 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..cf0eed67102 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 @@ -18,6 +18,7 @@ "create_time": "[TIMESTAMP]", "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/test-role", "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "role_id": "test-role", "status": { "attributes": { "bypassrls": false, diff --git a/bundle/direct/dresources/postgres_role.go b/bundle/direct/dresources/postgres_role.go index 1524cc16ad6..8c48ba639ea 100644 --- a/bundle/direct/dresources/postgres_role.go +++ b/bundle/direct/dresources/postgres_role.go @@ -6,6 +6,8 @@ import ( "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/common/types/fieldmask" + sdktime "github.com/databricks/databricks-sdk-go/common/types/time" + "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/postgres" ) @@ -29,6 +31,31 @@ type PostgresRoleState struct { Parent string `json:"parent"` } +// PostgresRoleRemote is the return type for DoRead. It embeds RoleRoleSpec so that +// all paths in StateType are valid paths in RemoteType, enabling drift detection +// for spec fields once the backend echoes spec on GET. +type PostgresRoleRemote struct { + postgres.RoleRoleSpec + + RoleId string `json:"role_id,omitempty"` + Parent string `json:"parent,omitempty"` + + Name string `json:"name,omitempty"` + Status *postgres.RoleRoleStatus `json:"status,omitempty"` + CreateTime *sdktime.Time `json:"create_time,omitempty"` + UpdateTime *sdktime.Time `json:"update_time,omitempty"` +} + +// Custom marshaler needed because embedded RoleRoleSpec has its own MarshalJSON +// which would otherwise take over and ignore the additional fields. +func (s *PostgresRoleRemote) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, s) +} + +func (s PostgresRoleRemote) MarshalJSON() ([]byte, error) { + return marshal.Marshal(s) +} + func (*ResourcePostgresRole) New(client *databricks.WorkspaceClient) *ResourcePostgresRole { return &ResourcePostgresRole{client: client} } @@ -41,36 +68,47 @@ func (*ResourcePostgresRole) PrepareState(input *resources.PostgresRole) *Postgr } } -func (*ResourcePostgresRole) RemapState(remote *postgres.Role) *PostgresRoleState { +func (*ResourcePostgresRole) RemapState(remote *PostgresRoleRemote) *PostgresRoleState { + return &PostgresRoleState{ + RoleId: remote.RoleId, + Parent: remote.Parent, + RoleRoleSpec: remote.RoleRoleSpec, + } +} + +// makePostgresRoleRemote converts the SDK Role into the embedded remote shape. +// GET does not echo spec today (only status is returned); the embedded spec fields +// stay at their zero values, and resources.yml suppresses phantom drift via +// ignore_remote_changes with reason spec:input_only. +func makePostgresRoleRemote(role *postgres.Role) *PostgresRoleRemote { + var spec postgres.RoleRoleSpec + if role.Spec != nil { + spec = *role.Spec + } var roleID string - if remote.Status != nil { - roleID = remote.Status.RoleId + if role.Status != nil { + roleID = role.Status.RoleId } - return &PostgresRoleState{ - RoleId: roleID, - Parent: remote.Parent, - - // Spec is left empty on purpose. GET returns the role config under Status, - // but Status rolls up server-side and parent-inherited defaults mixed with - // the spec, so mapping it back onto the spec produces drift that is often - // unresolvable. The spec fields are ignore_remote_changes in resources.yml; - // revisit once the backend echoes the spec intent back on GET. - RoleRoleSpec: postgres.RoleRoleSpec{ - Attributes: nil, - AuthMethod: "", - IdentityType: "", - MembershipRoles: nil, - PostgresRole: "", - ForceSendFields: nil, - }, + return &PostgresRoleRemote{ + RoleRoleSpec: spec, + RoleId: roleID, + Parent: role.Parent, + Name: role.Name, + Status: role.Status, + CreateTime: role.CreateTime, + UpdateTime: role.UpdateTime, } } -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) DoRead(ctx context.Context, id string) (*PostgresRoleRemote, error) { + role, err := r.client.Postgres.GetRole(ctx, postgres.GetRoleRequest{Name: id}) + if err != nil { + return nil, err + } + return makePostgresRoleRemote(role), nil } -func (r *ResourcePostgresRole) DoCreate(ctx context.Context, config *PostgresRoleState) (string, *postgres.Role, error) { +func (r *ResourcePostgresRole) DoCreate(ctx context.Context, config *PostgresRoleState) (string, *PostgresRoleRemote, error) { waiter, err := r.client.Postgres.CreateRole(ctx, postgres.CreateRoleRequest{ RoleId: config.RoleId, Parent: config.Parent, @@ -96,10 +134,11 @@ func (r *ResourcePostgresRole) DoCreate(ctx context.Context, config *PostgresRol return "", nil, err } - return result.Name, result, nil + remote := makePostgresRoleRemote(result) + return remote.Name, remote, nil } -func (r *ResourcePostgresRole) DoUpdate(ctx context.Context, id string, config *PostgresRoleState, entry *PlanEntry) (*postgres.Role, error) { +func (r *ResourcePostgresRole) DoUpdate(ctx context.Context, id string, config *PostgresRoleState, entry *PlanEntry) (*PostgresRoleRemote, 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. @@ -126,7 +165,11 @@ func (r *ResourcePostgresRole) DoUpdate(ctx context.Context, id string, config * return nil, err } - return waiter.Wait(ctx) + result, err := waiter.Wait(ctx) + if err != nil { + return nil, err + } + return makePostgresRoleRemote(result), nil } func (r *ResourcePostgresRole) DoDelete(ctx context.Context, id string, _ *PostgresRoleState) error { diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index e87964b9a1f..fee09ed119f 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -52,21 +52,6 @@ var knownMissingInRemoteType = map[string][]string{ "postgres_projects": { "purge_on_delete", }, - // Unlike the other postgres resources, whose DoRead returns a custom *Remote - // type that embeds the spec, roles' DoRead returns the raw postgres.Role, which - // nests these under .Spec/.Status instead of exposing them at the top level. - // That is deliberate: roles does not synthesize a spec from status (see - // RemapState) to avoid the drift that mapping rolled-up status defaults causes. - "postgres_roles": { - "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", From 935a4cf8e386fd9c63c5f613ae22c2223260351d Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 16 Jun 2026 14:51:23 +0200 Subject: [PATCH 20/20] Address comment --- acceptance/bundle/refschema/out.fields.txt | 30 ++++++++-------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 7426b4ded71..ad57d4f2b4d 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -2977,33 +2977,23 @@ 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.*.attributes *postgres.RoleAttributes ALL +resources.postgres_roles.*.attributes.bypassrls bool ALL +resources.postgres_roles.*.attributes.createdb bool ALL +resources.postgres_roles.*.attributes.createrole bool ALL +resources.postgres_roles.*.auth_method postgres.RoleAuthMethod ALL 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.*.identity_type postgres.RoleIdentityType ALL 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.*.membership_roles []postgres.RoleMembershipRole ALL +resources.postgres_roles.*.membership_roles[*] postgres.RoleMembershipRole ALL 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.*.postgres_role string ALL +resources.postgres_roles.*.role_id string ALL resources.postgres_roles.*.status *postgres.RoleRoleStatus REMOTE resources.postgres_roles.*.status.attributes *postgres.RoleAttributes REMOTE resources.postgres_roles.*.status.attributes.bypassrls bool REMOTE