diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 70758005d43..e77c03ee634 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -18,6 +18,7 @@ * engine/direct: Fix WAL corruption after two consecutive failed deploys ([#5606](https://github.com/databricks/cli/pull/5606)). * engine/direct: Don't open the deployment state WAL when a deploy's plan fails ([#5607](https://github.com/databricks/cli/pull/5607)). * Ignore unity catalog managed schema property defaults to avoid unnecessary drift ([#5195](https://github.com/databricks/cli/pull/5195)). +* Add Postgres role as a bundle resource ([#5467](https://github.com/databricks/cli/pull/5467)). ### 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 53c82a51868..03f7ee422db 100644 --- a/acceptance/bundle/invariant/continue_293/out.test.toml +++ b/acceptance/bundle/invariant/continue_293/out.test.toml @@ -30,6 +30,7 @@ EnvMatrix.INPUT_CONFIG = [ "postgres_catalog.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", + "postgres_role.yml.tmpl", "postgres_synced_table.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", diff --git a/acceptance/bundle/invariant/migrate/out.test.toml b/acceptance/bundle/invariant/migrate/out.test.toml index 53c82a51868..03f7ee422db 100644 --- a/acceptance/bundle/invariant/migrate/out.test.toml +++ b/acceptance/bundle/invariant/migrate/out.test.toml @@ -30,6 +30,7 @@ EnvMatrix.INPUT_CONFIG = [ "postgres_catalog.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", + "postgres_role.yml.tmpl", "postgres_synced_table.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index 53c82a51868..03f7ee422db 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -30,6 +30,7 @@ EnvMatrix.INPUT_CONFIG = [ "postgres_catalog.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", + "postgres_role.yml.tmpl", "postgres_synced_table.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", diff --git a/acceptance/bundle/invariant/test.toml b/acceptance/bundle/invariant/test.toml index 3c16332861d..e6f4a464fe6 100644 --- a/acceptance/bundle/invariant/test.toml +++ b/acceptance/bundle/invariant/test.toml @@ -48,6 +48,7 @@ EnvMatrix.INPUT_CONFIG = [ "postgres_catalog.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", + "postgres_role.yml.tmpl", "postgres_synced_table.yml.tmpl", "registered_model.yml.tmpl", "schema.yml.tmpl", @@ -75,6 +76,7 @@ no_postgres_branch_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_branc no_postgres_endpoint_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_endpoint.yml.tmpl"] no_postgres_catalog_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_catalog.yml.tmpl"] no_postgres_synced_table_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_synced_table.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 80b553561a5..ad57d4f2b4d 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -2977,6 +2977,36 @@ 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 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 ALL +resources.postgres_roles.*.lifecycle resources.Lifecycle INPUT +resources.postgres_roles.*.lifecycle.prevent_destroy bool INPUT +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 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 +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.postgres_synced_tables.*.branch string ALL resources.postgres_synced_tables.*.create_database_objects_if_missing bool ALL resources.postgres_synced_tables.*.create_time *time.Time REMOTE 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/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"] 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..7db7aa88af0 --- /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 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: 400 Bad Request +API error_code: BAD_REQUEST +API message: role with that name 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..3e475e74819 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/test.toml @@ -0,0 +1,7 @@ +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." + +# Inheritance is staged locally via unbind; not run on cloud. +Cloud = false + +# Deploy error wording differs between engines; the conflict itself is engine-agnostic. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] 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/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/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..d09be43517b --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.no_change.direct.json @@ -0,0 +1,59 @@ +{ + "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", + "role_id": "test-role", + "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..aff2400a8b4 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.restore.direct.json @@ -0,0 +1,71 @@ +{ + "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", + "role_id": "test-role", + "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..cf0eed67102 --- /dev/null +++ b/acceptance/bundle/resources/postgres_roles/update/out.plan.update.direct.json @@ -0,0 +1,71 @@ +{ + "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", + "role_id": "test-role", + "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/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index 3fa97c41a7f..5e6dd0f5cab 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -30,6 +30,7 @@ var unsupportedResources = []string{ "postgres_endpoints", "postgres_catalogs", "postgres_synced_tables", + "postgres_roles", "vector_search_indexes", } diff --git a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go index 440221001a7..8e14390c3ab 100644 --- a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go +++ b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go @@ -268,6 +268,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 af1470848d7..ee0e99e5f89 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -50,6 +50,7 @@ func allResourceTypes(t *testing.T) []string { "postgres_catalogs", "postgres_endpoints", "postgres_projects", + "postgres_roles", "postgres_synced_tables", "quality_monitors", "registered_models", @@ -181,6 +182,7 @@ var allowList = []string{ "postgres_catalogs", "postgres_endpoints", "postgres_projects", + "postgres_roles", "postgres_synced_tables", "registered_models", "experiments", diff --git a/bundle/config/resources.go b/bundle/config/resources.go index 3dc7dc295d3..00bc8b61f01 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -38,6 +38,7 @@ type Resources struct { PostgresEndpoints map[string]*resources.PostgresEndpoint `json:"postgres_endpoints,omitempty"` PostgresCatalogs map[string]*resources.PostgresCatalog `json:"postgres_catalogs,omitempty"` PostgresSyncedTables map[string]*resources.PostgresSyncedTable `json:"postgres_synced_tables,omitempty"` + PostgresRoles map[string]*resources.PostgresRole `json:"postgres_roles,omitempty"` VectorSearchEndpoints map[string]*resources.VectorSearchEndpoint `json:"vector_search_endpoints,omitempty"` VectorSearchIndexes map[string]*resources.VectorSearchIndex `json:"vector_search_indexes,omitempty"` } @@ -119,6 +120,7 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["postgres_endpoints"], r.PostgresEndpoints), collectResourceMap(descriptions["postgres_catalogs"], r.PostgresCatalogs), collectResourceMap(descriptions["postgres_synced_tables"], r.PostgresSyncedTables), + collectResourceMap(descriptions["postgres_roles"], r.PostgresRoles), collectResourceMap(descriptions["vector_search_endpoints"], r.VectorSearchEndpoints), collectResourceMap(descriptions["vector_search_indexes"], r.VectorSearchIndexes), } @@ -178,6 +180,7 @@ func SupportedResources() map[string]resources.ResourceDescription { "postgres_endpoints": (&resources.PostgresEndpoint{}).ResourceDescription(), "postgres_catalogs": (&resources.PostgresCatalog{}).ResourceDescription(), "postgres_synced_tables": (&resources.PostgresSyncedTable{}).ResourceDescription(), + "postgres_roles": (&resources.PostgresRole{}).ResourceDescription(), "vector_search_endpoints": (&resources.VectorSearchEndpoint{}).ResourceDescription(), "vector_search_indexes": (&resources.VectorSearchIndex{}).ResourceDescription(), } diff --git a/bundle/config/resources/postgres_role.go b/bundle/config/resources/postgres_role.go new file mode 100644 index 00000000000..34c2f5fcae4 --- /dev/null +++ b/bundle/config/resources/postgres_role.go @@ -0,0 +1,71 @@ +package resources + +import ( + "context" + "net/url" + + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/marshal" + "github.com/databricks/databricks-sdk-go/service/postgres" +) + +type PostgresRoleConfig struct { + postgres.RoleRoleSpec + + // RoleId is the user-specified ID for the role (becomes part of the hierarchical name). + // This is specified during creation and becomes part of Name: "projects/{project_id}/branches/{branch_id}/roles/{role_id}" + RoleId string `json:"role_id"` + + // Parent is the branch containing this role. Format: "projects/{project_id}/branches/{branch_id}" + Parent string `json:"parent"` +} + +func (c *PostgresRoleConfig) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, c) +} + +func (c *PostgresRoleConfig) MarshalJSON() ([]byte, error) { + return marshal.Marshal(c) +} + +type PostgresRole struct { + BaseResource + PostgresRoleConfig +} + +func (r *PostgresRole) Exists(ctx context.Context, w *databricks.WorkspaceClient, name string) (bool, error) { + _, err := w.Postgres.GetRole(ctx, postgres.GetRoleRequest{Name: name}) + if apierr.IsMissing(err) { + log.Debugf(ctx, "postgres role %s does not exist", name) + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func (r *PostgresRole) ResourceDescription() ResourceDescription { + return ResourceDescription{ + SingularName: "postgres_role", + PluralName: "postgres_roles", + SingularTitle: "Postgres role", + PluralTitle: "Postgres roles", + } +} + +func (r *PostgresRole) GetName() string { + // Roles don't have a user-visible name field. + return "" +} + +func (r *PostgresRole) GetURL() string { + // The IDs in the API do not (yet) map to IDs in the web UI. + return "" +} + +func (r *PostgresRole) InitializeURL(_ url.URL) { + // The IDs in the API do not (yet) map to IDs in the web UI. +} diff --git a/bundle/config/resources_test.go b/bundle/config/resources_test.go index d56a24ced46..ee0d206dbd9 100644 --- a/bundle/config/resources_test.go +++ b/bundle/config/resources_test.go @@ -128,6 +128,7 @@ func TestBundleResourcePluralNamesResolveInWorkspaceURLs(t *testing.T) { "postgres_branches": true, "postgres_endpoints": true, "postgres_projects": true, + "postgres_roles": true, "secret_scopes": true, } @@ -289,6 +290,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{ @@ -341,6 +353,7 @@ func TestResourcesBindSupport(t *testing.T) { m.GetMockPostgresAPI().EXPECT().GetEndpoint(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetCatalog(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetSyncedTable(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) m.GetMockVectorSearchIndexesAPI().EXPECT().GetIndexByIndexName(mock.Anything, mock.Anything).Return(nil, nil) diff --git a/bundle/deploy/terraform/interpolate.go b/bundle/deploy/terraform/interpolate.go index 92df9e61cc9..d1a0f39318f 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", "postgres_catalogs", "postgres_synced_tables": + case "postgres_projects", "postgres_branches", "postgres_endpoints", "postgres_catalogs", "postgres_synced_tables", "postgres_roles": return true default: return false diff --git a/bundle/deploy/terraform/pkg.go b/bundle/deploy/terraform/pkg.go index d8dd56c04ea..d1649543260 100644 --- a/bundle/deploy/terraform/pkg.go +++ b/bundle/deploy/terraform/pkg.go @@ -131,6 +131,7 @@ var GroupToTerraformName = map[string]string{ "postgres_endpoints": "databricks_postgres_endpoint", "postgres_catalogs": "databricks_postgres_catalog", "postgres_synced_tables": "databricks_postgres_synced_table", + "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 7ca5e9a1d14..e5b0fe3a113 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", "postgres_catalogs", "postgres_synced_tables": + case "apps", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_endpoints", "postgres_catalogs", "postgres_synced_tables", "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 6cc1eb55437..69c54f414c7 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -25,6 +25,7 @@ var SupportedResources = map[string]any{ "postgres_endpoints": (*ResourcePostgresEndpoint)(nil), "postgres_catalogs": (*ResourcePostgresCatalog)(nil), "postgres_synced_tables": (*ResourcePostgresSyncedTable)(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 4f8f8f5e269..357ab4100eb 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -748,6 +748,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 93faba8ad5a..cfb8d816dd4 100644 --- a/bundle/direct/dresources/apitypes.generated.yml +++ b/bundle/direct/dresources/apitypes.generated.yml @@ -36,6 +36,8 @@ postgres_endpoints: postgres.EndpointSpec postgres_projects: postgres.ProjectStatus +postgres_roles: postgres.RoleRoleStatus + postgres_synced_tables: postgres.SyncedTableSyncedTableSpec quality_monitors: catalog.CreateMonitor diff --git a/bundle/direct/dresources/apitypes.yml b/bundle/direct/dresources/apitypes.yml index eabb39021cf..795d8e24551 100644 --- a/bundle/direct/dresources/apitypes.yml +++ b/bundle/direct/dresources/apitypes.yml @@ -20,3 +20,5 @@ postgres_projects: postgres.ProjectSpec postgres_catalogs: postgres.CatalogCatalogSpec postgres_synced_tables: postgres.SyncedTableSyncedTableSpec + +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..8c48ba639ea --- /dev/null +++ b/bundle/direct/dresources/postgres_role.go @@ -0,0 +1,189 @@ +package dresources + +import ( + "context" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/common/types/fieldmask" + 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" +) + +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"` +} + +// 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} +} + +func (*ResourcePostgresRole) PrepareState(input *resources.PostgresRole) *PostgresRoleState { + return &PostgresRoleState{ + RoleId: input.RoleId, + Parent: input.Parent, + RoleRoleSpec: input.RoleRoleSpec, + } +} + +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 role.Status != nil { + roleID = role.Status.RoleId + } + 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) (*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, *PostgresRoleRemote, 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 + } + + remote := makePostgresRoleRemote(result) + return remote.Name, remote, nil +} + +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. + fieldPaths := collectLeafUpdatePathsWithPrefix(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 + } + + 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 { + 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 e2509c2618e..ecef51a5aff 100644 --- a/bundle/direct/dresources/resources.generated.yml +++ b/bundle/direct/dresources/resources.generated.yml @@ -276,6 +276,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 + postgres_synced_tables: ignore_remote_changes: diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index a56850237df..c2095bd9fa0 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -616,6 +616,25 @@ resources: - field: existing_pipeline_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 + # 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: - field: endpoint_type diff --git a/bundle/direct/dresources/util.go b/bundle/direct/dresources/util.go index 75b4e07fccc..fc68f3ea37c 100644 --- a/bundle/direct/dresources/util.go +++ b/bundle/direct/dresources/util.go @@ -2,6 +2,8 @@ package dresources import ( "errors" + "slices" + "strings" "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/databricks-sdk-go/retries" @@ -32,3 +34,34 @@ func collectUpdatePathsWithPrefix(changes Changes, prefix string) []string { } 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 { + 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) + } + } + 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.")) + }) + } +} diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 69d7c9d025d..8af3c7191d3 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -230,6 +230,9 @@ github.com/databricks/cli/bundle/config.Resources: "postgres_projects": "description": |- PLACEHOLDER + "postgres_roles": + "description": |- + PLACEHOLDER "postgres_synced_tables": "description": |- The Postgres synced table definitions for the bundle, where each key is the name of the synced table. Each entry continuously replicates a Unity Catalog Delta source table into a Postgres table on a Lakebase Autoscaling instance. @@ -978,6 +981,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.PostgresSyncedTable: "branch": "description": |- diff --git a/bundle/internal/schema/annotations_openapi.yml b/bundle/internal/schema/annotations_openapi.yml index 1c301cdc92f..a582548e560 100644 --- a/bundle/internal/schema/annotations_openapi.yml +++ b/bundle/internal/schema/annotations_openapi.yml @@ -7041,6 +7041,69 @@ github.com/databricks/databricks-sdk-go/service/postgres.ProjectDefaultEndpointS Mutually exclusive with `no_suspension`. When updating, use `spec.project_default_settings.suspension` in the update_mask. "x-databricks-launch-stage": |- PUBLIC_BETA +github.com/databricks/databricks-sdk-go/service/postgres.RoleAttributes: + "_": + "description": |- + Attributes that can be granted to a Postgres role. We are only implementing a subset for now, see xref: + https://www.postgresql.org/docs/16/sql-createrole.html + The values follow Postgres keyword naming e.g. CREATEDB, BYPASSRLS, etc. which is why they don't include typical + underscores between words. + "bypassrls": + "x-databricks-launch-stage": |- + PUBLIC_BETA + "createdb": + "x-databricks-launch-stage": |- + PUBLIC_BETA + "createrole": + "x-databricks-launch-stage": |- + PUBLIC_BETA +github.com/databricks/databricks-sdk-go/service/postgres.RoleAuthMethod: + "_": + "description": |- + How the role is authenticated when connecting to Postgres. + "enum": + - |- + NO_LOGIN + - |- + PG_PASSWORD_SCRAM_SHA_256 + - |- + LAKEBASE_OAUTH_V1 + "x-databricks-enum-launch-stages": + "LAKEBASE_OAUTH_V1": |- + PUBLIC_BETA + "NO_LOGIN": |- + PUBLIC_BETA + "PG_PASSWORD_SCRAM_SHA_256": |- + PUBLIC_BETA +github.com/databricks/databricks-sdk-go/service/postgres.RoleIdentityType: + "_": + "description": |- + The type of the Databricks managed identity that this Role represents. + Leave empty if you wish to create a regular Postgres role not associated with a Databricks identity. + "enum": + - |- + USER + - |- + SERVICE_PRINCIPAL + - |- + GROUP + "x-databricks-enum-launch-stages": + "GROUP": |- + PUBLIC_BETA + "SERVICE_PRINCIPAL": |- + PUBLIC_BETA + "USER": |- + PUBLIC_BETA +github.com/databricks/databricks-sdk-go/service/postgres.RoleMembershipRole: + "_": + "description": |- + Roles that the DatabaseInstanceRole can be a member of. + "enum": + - |- + DATABRICKS_SUPERUSER + "x-databricks-enum-launch-stages": + "DATABRICKS_SUPERUSER": |- + PUBLIC_BETA github.com/databricks/databricks-sdk-go/service/postgres.SyncedTableSyncedTableSpecSyncedTableSchedulingPolicy: "_": "description": |- diff --git a/bundle/internal/schema/annotations_openapi_overrides.yml b/bundle/internal/schema/annotations_openapi_overrides.yml index 25fc5869500..107c822b0e8 100644 --- a/bundle/internal/schema/annotations_openapi_overrides.yml +++ b/bundle/internal/schema/annotations_openapi_overrides.yml @@ -1047,6 +1047,16 @@ github.com/databricks/databricks-sdk-go/service/pipelines.Transformer: "json_options": "description": |- PLACEHOLDER +github.com/databricks/databricks-sdk-go/service/postgres.RoleAttributes: + "bypassrls": + "description": |- + PLACEHOLDER + "createdb": + "description": |- + PLACEHOLDER + "createrole": + "description": |- + PLACEHOLDER github.com/databricks/databricks-sdk-go/service/serving.Route: "served_entity_name": "description": |- diff --git a/bundle/internal/validation/generated/enum_fields.go b/bundle/internal/validation/generated/enum_fields.go index 8beb3e25a4a..16aec19fb1c 100644 --- a/bundle/internal/validation/generated/enum_fields.go +++ b/bundle/internal/validation/generated/enum_fields.go @@ -194,6 +194,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.postgres_synced_tables.*.scheduling_policy": {"CONTINUOUS", "SNAPSHOT", "TRIGGERED"}, "resources.quality_monitors.*.custom_metrics[*].type": {"CUSTOM_METRIC_TYPE_AGGREGATE", "CUSTOM_METRIC_TYPE_DERIVED", "CUSTOM_METRIC_TYPE_DRIFT"}, diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index 3d47858587e..43d60e145ca 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -229,6 +229,8 @@ var RequiredFields = map[string][]string{ "resources.postgres_projects.*": {"project_id"}, "resources.postgres_projects.*.permissions[*]": {"level"}, + "resources.postgres_roles.*": {"role_id", "parent"}, + "resources.postgres_synced_tables.*": {"synced_table_id"}, "resources.quality_monitors.*": {"assets_dir", "output_schema_name", "table_name"}, diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index f88a9389348..7f9f4b5fff5 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1631,6 +1631,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(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.PostgresSyncedTable": { "oneOf": [ { @@ -2764,6 +2813,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" + }, "postgres_synced_tables": { "description": "The Postgres synced table definitions for the bundle, where each key is the name of the synced table. Each entry continuously replicates a Unity Catalog Delta source table into a Postgres table on a Lakebase Autoscaling instance.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresSyncedTable" @@ -10715,6 +10767,95 @@ } ] }, + "postgres.RoleAttributes": { + "oneOf": [ + { + "type": "object", + "description": "Attributes that can be granted to a Postgres role. We are only implementing a subset for now, see xref:\nhttps://www.postgresql.org/docs/16/sql-createrole.html\nThe values follow Postgres keyword naming e.g. CREATEDB, BYPASSRLS, etc. which is why they don't include typical\nunderscores between words.", + "properties": { + "bypassrls": { + "description": "[Beta]", + "$ref": "#/$defs/bool" + }, + "createdb": { + "description": "[Beta]", + "$ref": "#/$defs/bool" + }, + "createrole": { + "description": "[Beta]", + "$ref": "#/$defs/bool" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "postgres.RoleAuthMethod": { + "oneOf": [ + { + "type": "string", + "description": "How the role is authenticated when connecting to Postgres.", + "enum": [ + "NO_LOGIN", + "PG_PASSWORD_SCRAM_SHA_256", + "LAKEBASE_OAUTH_V1" + ], + "enumDescriptions": [ + "[Beta]", + "[Beta]", + "[Beta]" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "postgres.RoleIdentityType": { + "oneOf": [ + { + "type": "string", + "description": "The type of the Databricks managed identity that this Role represents.\nLeave empty if you wish to create a regular Postgres role not associated with a Databricks identity.", + "enum": [ + "USER", + "SERVICE_PRINCIPAL", + "GROUP" + ], + "enumDescriptions": [ + "[Beta]", + "[Beta]", + "[Beta]" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "postgres.RoleMembershipRole": { + "oneOf": [ + { + "type": "string", + "description": "Roles that the DatabaseInstanceRole can be a member of.", + "enum": [ + "DATABRICKS_SUPERUSER" + ], + "enumDescriptions": [ + "[Beta]" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "postgres.SyncedTableSyncedTableSpecSyncedTableSchedulingPolicy": { "oneOf": [ { @@ -12927,6 +13068,20 @@ } ] }, + "resources.PostgresRole": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresRole" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.PostgresSyncedTable": { "oneOf": [ { @@ -13826,6 +13981,20 @@ } ] }, + "postgres.RoleMembershipRole": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.RoleMembershipRole" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "serving.AiGatewayRateLimit": { "oneOf": [ { diff --git a/bundle/schema/jsonschema_for_docs.json b/bundle/schema/jsonschema_for_docs.json index 7efe3ffebda..7aa52873499 100644 --- a/bundle/schema/jsonschema_for_docs.json +++ b/bundle/schema/jsonschema_for_docs.json @@ -1623,6 +1623,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.PostgresSyncedTable": { "type": "object", "properties": { @@ -2742,6 +2783,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" + }, "postgres_synced_tables": { "description": "The Postgres synced table definitions for the bundle, where each key is the name of the synced table. Each entry continuously replicates a Unity Catalog Delta source table into a Postgres table on a Lakebase Autoscaling instance.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresSyncedTable", @@ -9213,6 +9257,63 @@ }, "additionalProperties": false }, + "postgres.RoleAttributes": { + "type": "object", + "description": "Attributes that can be granted to a Postgres role. We are only implementing a subset for now, see xref:\nhttps://www.postgresql.org/docs/16/sql-createrole.html\nThe values follow Postgres keyword naming e.g. CREATEDB, BYPASSRLS, etc. which is why they don't include typical\nunderscores between words.", + "properties": { + "bypassrls": { + "description": "[Beta]", + "$ref": "#/$defs/bool" + }, + "createdb": { + "description": "[Beta]", + "$ref": "#/$defs/bool" + }, + "createrole": { + "description": "[Beta]", + "$ref": "#/$defs/bool" + } + }, + "additionalProperties": false + }, + "postgres.RoleAuthMethod": { + "type": "string", + "description": "How the role is authenticated when connecting to Postgres.", + "enum": [ + "NO_LOGIN", + "PG_PASSWORD_SCRAM_SHA_256", + "LAKEBASE_OAUTH_V1" + ], + "enumDescriptions": [ + "[Beta]", + "[Beta]", + "[Beta]" + ] + }, + "postgres.RoleIdentityType": { + "type": "string", + "description": "The type of the Databricks managed identity that this Role represents.\nLeave empty if you wish to create a regular Postgres role not associated with a Databricks identity.", + "enum": [ + "USER", + "SERVICE_PRINCIPAL", + "GROUP" + ], + "enumDescriptions": [ + "[Beta]", + "[Beta]", + "[Beta]" + ] + }, + "postgres.RoleMembershipRole": { + "type": "string", + "description": "Roles that the DatabaseInstanceRole can be a member of.", + "enum": [ + "DATABRICKS_SUPERUSER" + ], + "enumDescriptions": [ + "[Beta]" + ] + }, "postgres.SyncedTableSyncedTableSpecSyncedTableSchedulingPolicy": { "type": "string", "description": "Scheduling policy of the synced table's underlying pipeline.", @@ -10843,6 +10944,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.PostgresSyncedTable": { "type": "object", "additionalProperties": { @@ -11238,6 +11345,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 672cd9855b2..b6a5852901f 100644 --- a/bundle/statemgmt/state_load_test.go +++ b/bundle/statemgmt/state_load_test.go @@ -52,6 +52,7 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { "resources.postgres_endpoints.test_postgres_endpoint": {ID: "projects/test-project/branches/main/endpoints/primary"}, "resources.postgres_catalogs.test_postgres_catalog": {ID: "catalogs/test_catalog"}, "resources.postgres_synced_tables.test_postgres_synced_table": {ID: "synced_tables/main.public.test_synced_table"}, + "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"}, "resources.vector_search_indexes.test_vector_search_index": {ID: "vs-index-1"}, } @@ -128,6 +129,9 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { assert.Equal(t, "catalogs/test_catalog", config.Resources.PostgresCatalogs["test_postgres_catalog"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresCatalogs["test_postgres_catalog"].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) @@ -326,6 +330,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{ @@ -421,6 +433,9 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { assert.Empty(t, config.Resources.PostgresCatalogs["test_postgres_catalog"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresCatalogs["test_postgres_catalog"].ModifiedStatus) + assert.Empty(t, config.Resources.PostgresRoles["test_postgres_role"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresRoles["test_postgres_role"].ModifiedStatus) + assert.Empty(t, config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.VectorSearchEndpoints["test_vector_search_endpoint"].ModifiedStatus) @@ -742,6 +757,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{ @@ -813,6 +842,8 @@ func TestStateToBundleModifiedResources(t *testing.T) { "resources.postgres_endpoints.test_postgres_endpoint_old": {ID: "projects/test-project/branches/main/endpoints/old"}, "resources.postgres_catalogs.test_postgres_catalog": {ID: "catalogs/test_catalog"}, "resources.postgres_catalogs.test_postgres_catalog_old": {ID: "catalogs/test_catalog_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"}, "resources.vector_search_indexes.test_vector_search_index": {ID: "vs-index-1"}, @@ -977,6 +1008,13 @@ func TestStateToBundleModifiedResources(t *testing.T) { assert.Empty(t, config.Resources.PostgresCatalogs["test_postgres_catalog_new"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresCatalogs["test_postgres_catalog_new"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/roles/primary", config.Resources.PostgresRoles["test_postgres_role"].ID) + assert.Empty(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.Empty(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.Empty(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/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", } diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 6c3766ccaeb..34671f22a2c 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -175,6 +175,7 @@ type FakeWorkspace struct { PostgresEndpoints map[string]postgres.Endpoint PostgresCatalogs map[string]postgres.Catalog PostgresSyncedTables map[string]postgres.SyncedTable + PostgresRoles map[string]postgres.Role PostgresOperations map[string]postgres.Operation // Branches and endpoints that the server provisioned implicitly together @@ -314,6 +315,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { PostgresEndpoints: map[string]postgres.Endpoint{}, PostgresCatalogs: map[string]postgres.Catalog{}, PostgresSyncedTables: map[string]postgres.SyncedTable{}, + PostgresRoles: map[string]postgres.Role{}, PostgresOperations: map[string]postgres.Operation{}, postgresImplicitBranches: map[string]bool{}, postgresImplicitEndpoints: map[string]bool{}, diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index 3b0a154a6ee..f05330c39ec 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -912,6 +912,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") @@ -1037,6 +1042,33 @@ func AddDefaultHandlers(server *Server) { return req.Workspace.PostgresOperationGet("operations/" + req.Vars["operation_id"]) }) + // 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 5853132ba5a..e046c0867e9 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -3,6 +3,7 @@ package testserver import ( "encoding/json" "fmt" + "regexp" "strings" "time" @@ -789,6 +790,255 @@ func (s *FakeWorkspace) PostgresCatalogDelete(name string) Response { return Response{Body: s.createOperationLocked(name, nil)} } +// 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 { + // 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() + 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. 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()() + + 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 + } + 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 + } + + role.UpdateTime = nowTime() + s.PostgresRoles[name] = role + + return Response{ + Body: s.createOperationLocked(role.Name, role), + } +} + +// 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. +// +// 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 + } + 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()() + + 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()() @@ -808,7 +1058,9 @@ 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" switch { case strings.HasPrefix(resourceName, "catalogs/"): @@ -817,6 +1069,8 @@ func (s *FakeWorkspace) createOperationLocked(resourceName string, response any) resourceType = "SyncedTable" case strings.Contains(resourceName, "/endpoints/"): resourceType = "Endpoint" + 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 fa8a4eed35d..85264369bf3 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" @@ -273,3 +274,192 @@ 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 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) + + 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() +} + +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() +}