diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index a6afadbd0a3..693fbc7bd59 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -21,7 +21,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)). +* Add `postgres_roles` and `postgres_databases` resources to create Postgres roles and databases on a Lakebase branch ([#5467](https://github.com/databricks/cli/pull/5467), [#5627](https://github.com/databricks/cli/pull/5627)). * direct: Stop spurious recreate/rename on redeploy when the backend normalizes a resource's name-based ID (e.g. Unity Catalog lowercasing a schema or volume name) ([#5599](https://github.com/databricks/cli/pull/5599)). ### Dependency updates diff --git a/acceptance/bundle/deployment/bind/postgres_database/databricks.yml b/acceptance/bundle/deployment/bind/postgres_database/databricks.yml new file mode 100644 index 00000000000..ac626e53b24 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_database/databricks.yml @@ -0,0 +1,9 @@ +bundle: + name: test-bundle + +resources: + postgres_databases: + database1: + parent: projects/test-project/branches/main + database_id: test-database + postgres_database: app_db diff --git a/acceptance/bundle/deployment/bind/postgres_database/out.test.toml b/acceptance/bundle/deployment/bind/postgres_database/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_database/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/deployment/bind/postgres_database/output.txt b/acceptance/bundle/deployment/bind/postgres_database/output.txt new file mode 100644 index 00000000000..799751d7e91 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_database/output.txt @@ -0,0 +1,32 @@ + +>>> [CLI] bundle deployment bind database1 projects/test-project/branches/main/databases/test-database --auto-approve +Updating deployment state... +Successfully bound postgres_database with an id 'projects/test-project/branches/main/databases/test-database' +Run 'bundle deploy' to deploy changes to your workspace + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + Postgres databases: + database1: + Name: + URL: (not deployed) + +>>> [CLI] bundle deployment unbind database1 +Updating deployment state... + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + Postgres databases: + database1: + Name: + URL: (not deployed) diff --git a/acceptance/bundle/deployment/bind/postgres_database/script b/acceptance/bundle/deployment/bind/postgres_database/script new file mode 100644 index 00000000000..cc3f69efa2f --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_database/script @@ -0,0 +1,6 @@ +DATABASE_NAME="projects/test-project/branches/main/databases/test-database" +trace $CLI bundle deployment bind database1 "${DATABASE_NAME}" --auto-approve +trace $CLI bundle summary + +trace $CLI bundle deployment unbind database1 +trace $CLI bundle summary diff --git a/acceptance/bundle/deployment/bind/postgres_database/test.toml b/acceptance/bundle/deployment/bind/postgres_database/test.toml new file mode 100644 index 00000000000..dcdc554d97e --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_database/test.toml @@ -0,0 +1,18 @@ +Local = true +Cloud = false + +Ignore = [ + ".databricks" +] + +[[Server]] +Pattern = "GET /api/2.0/postgres/projects/test-project/branches/main/databases/test-database" +Response.Body = ''' +{ + "name": "projects/test-project/branches/main/databases/test-database", + "parent": "projects/test-project/branches/main", + "status": { + "postgres_database": "app_db" + } +} +''' diff --git a/acceptance/bundle/invariant/configs/postgres_database.yml.tmpl b/acceptance/bundle/invariant/configs/postgres_database.yml.tmpl new file mode 100644 index 00000000000..dcfca2c6ef6 --- /dev/null +++ b/acceptance/bundle/invariant/configs/postgres_database.yml.tmpl @@ -0,0 +1,27 @@ +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: + owner: + parent: ${resources.postgres_branches.branch.name} + role_id: test-role-$UNIQUE_NAME + postgres_role: app_role + + postgres_databases: + foo: + parent: ${resources.postgres_branches.branch.name} + database_id: test-database-$UNIQUE_NAME + postgres_database: app_db + role: ${resources.postgres_roles.owner.name} diff --git a/acceptance/bundle/invariant/continue_293/out.test.toml b/acceptance/bundle/invariant/continue_293/out.test.toml index 03f7ee422db..e3c03283751 100644 --- a/acceptance/bundle/invariant/continue_293/out.test.toml +++ b/acceptance/bundle/invariant/continue_293/out.test.toml @@ -28,6 +28,7 @@ EnvMatrix.INPUT_CONFIG = [ "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_catalog.yml.tmpl", + "postgres_database.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "postgres_role.yml.tmpl", diff --git a/acceptance/bundle/invariant/migrate/out.test.toml b/acceptance/bundle/invariant/migrate/out.test.toml index 03f7ee422db..e3c03283751 100644 --- a/acceptance/bundle/invariant/migrate/out.test.toml +++ b/acceptance/bundle/invariant/migrate/out.test.toml @@ -28,6 +28,7 @@ EnvMatrix.INPUT_CONFIG = [ "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_catalog.yml.tmpl", + "postgres_database.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "postgres_role.yml.tmpl", diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index 03f7ee422db..e3c03283751 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -28,6 +28,7 @@ EnvMatrix.INPUT_CONFIG = [ "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_catalog.yml.tmpl", + "postgres_database.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "postgres_role.yml.tmpl", diff --git a/acceptance/bundle/invariant/test.toml b/acceptance/bundle/invariant/test.toml index e6f4a464fe6..e782f7ae269 100644 --- a/acceptance/bundle/invariant/test.toml +++ b/acceptance/bundle/invariant/test.toml @@ -46,6 +46,7 @@ EnvMatrix.INPUT_CONFIG = [ "pipeline_config_dots.yml.tmpl", "postgres_branch.yml.tmpl", "postgres_catalog.yml.tmpl", + "postgres_database.yml.tmpl", "postgres_endpoint.yml.tmpl", "postgres_project.yml.tmpl", "postgres_role.yml.tmpl", @@ -77,6 +78,7 @@ no_postgres_endpoint_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_end 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"] +no_postgres_database_on_cloud = ["CONFIG_Cloud=true", "INPUT_CONFIG=postgres_database.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 ad57d4f2b4d..563f95a8626 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -2870,6 +2870,22 @@ resources.postgres_catalogs.*.status.project string REMOTE resources.postgres_catalogs.*.uid string REMOTE resources.postgres_catalogs.*.update_time *time.Time REMOTE resources.postgres_catalogs.*.url string INPUT +resources.postgres_databases.*.create_time *time.Time REMOTE +resources.postgres_databases.*.database_id string ALL +resources.postgres_databases.*.id string INPUT +resources.postgres_databases.*.lifecycle resources.Lifecycle INPUT +resources.postgres_databases.*.lifecycle.prevent_destroy bool INPUT +resources.postgres_databases.*.modified_status string INPUT +resources.postgres_databases.*.name string REMOTE +resources.postgres_databases.*.parent string ALL +resources.postgres_databases.*.postgres_database string ALL +resources.postgres_databases.*.role string ALL +resources.postgres_databases.*.status *postgres.DatabaseDatabaseStatus REMOTE +resources.postgres_databases.*.status.database_id string REMOTE +resources.postgres_databases.*.status.postgres_database string REMOTE +resources.postgres_databases.*.status.role string REMOTE +resources.postgres_databases.*.update_time *time.Time REMOTE +resources.postgres_databases.*.url string INPUT resources.postgres_endpoints.*.autoscaling_limit_max_cu float64 ALL resources.postgres_endpoints.*.autoscaling_limit_min_cu float64 ALL resources.postgres_endpoints.*.create_time *time.Time REMOTE diff --git a/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl new file mode 100644 index 00000000000..72750fed4db --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/databricks.yml.tmpl @@ -0,0 +1,35 @@ +bundle: + name: deploy-postgres-database-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Database" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + database_id: my-database + postgres_database: app_db + # The live API requires `role`. Declare an explicit role so the bundle is + # portable across users (the auto-created project-owner role's id is + # derived from the creator's identity). + role: ${resources.postgres_roles.owner.id} diff --git a/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json b/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json new file mode 100644 index 00000000000..54b67db8049 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/out.requests.direct.json @@ -0,0 +1,55 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Database", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "app-owner" + }, + "body": { + "spec": { + "postgres_role": "app_owner" + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", + "q": { + "database_id": "my-database" + }, + "body": { + "spec": { + "postgres_database": "app_db", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases/my-database" +} diff --git a/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json b/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json new file mode 100644 index 00000000000..4ddff90db2b --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/out.requests.terraform.json @@ -0,0 +1,58 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Database", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "app-owner" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "postgres_role": "app_owner" + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", + "q": { + "database_id": "my-database" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "postgres_database": "app_db", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases/my-database" +} diff --git a/acceptance/bundle/resources/postgres_databases/basic/out.test.toml b/acceptance/bundle/resources/postgres_databases/basic/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/postgres_databases/basic/output.txt b/acceptance/bundle/resources/postgres_databases/basic/output.txt new file mode 100644 index 00000000000..1268083d34a --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/output.txt @@ -0,0 +1,100 @@ + +>>> [CLI] bundle validate +Name: deploy-postgres-database-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-[UNIQUE_NAME]/default + +Validation OK! + +>>> [CLI] bundle summary +Name: deploy-postgres-database-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-[UNIQUE_NAME]/default +Resources: + Postgres branches: + main: + Name: + URL: (not deployed) + Postgres databases: + my_database: + Name: + URL: (not deployed) + Postgres projects: + my_project: + Name: Test Project for Database + URL: (not deployed) + Postgres roles: + owner: + Name: + URL: (not deployed) + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] postgres get-database projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases/my-database +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases/my-database", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "database_id": "my-database", + "postgres_database": "app_db", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } +} + +>>> [CLI] bundle summary +Name: deploy-postgres-database-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-[UNIQUE_NAME]/default +Resources: + Postgres branches: + main: + Name: + URL: (not deployed) + Postgres databases: + my_database: + Name: + URL: (not deployed) + Postgres projects: + my_project: + Name: Test Project for Database + URL: (not deployed) + Postgres roles: + owner: + Name: + URL: (not deployed) + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_databases.my_database + delete resources.postgres_projects.my_project + delete resources.postgres_roles.owner + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +This action will result in the deletion of the following Lakebase databases. +All data stored in them will be permanently lost: + delete resources.postgres_databases.my_database + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_databases/basic/script b/acceptance/bundle/resources/postgres_databases/basic/script new file mode 100644 index 00000000000..d6c5e1a4138 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/basic/script @@ -0,0 +1,25 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +trace $CLI bundle validate + +trace $CLI bundle summary + +rm -f out.requests.txt +trace $CLI bundle deploy + +# Get database details +project_name="projects/test-pg-proj-${UNIQUE_NAME}" +branch_name="${project_name}/branches/main" +database_name="${branch_name}/databases/my-database" +trace $CLI postgres get-database "${database_name}" | jq 'del(.create_time, .update_time)' + +trace $CLI bundle summary + +# Filter requests to only show postgres operations (exclude workspace, telemetry, and operation polling) +trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/databricks.yml.tmpl new file mode 100644 index 00000000000..d93f8eab8a7 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/databricks.yml.tmpl @@ -0,0 +1,33 @@ +bundle: + name: bad-database-id-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Bad database_id error test" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + # database_id must be lowercase + DNS-safe; this violates RFC 1123. + database_id: Invalid_DB_ID + postgres_database: app_db + role: ${resources.postgres_roles.owner.id} diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/out.test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/out.test.toml new file mode 100644 index 00000000000..4fe23e297fe --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/output.txt b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/output.txt new file mode 100644 index 00000000000..f4d984a0e12 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/output.txt @@ -0,0 +1,29 @@ +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/bad-database-id-[UNIQUE_NAME]/default/files... +Deploying resources... +Error: cannot create resources.postgres_databases.my_database: Field database_id must match pattern ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$, got 'Invalid_DB_ID'. (400 INVALID_PARAMETER_VALUE) + +Endpoint: POST [DATABRICKS_URL]/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases?database_id=Invalid_DB_ID +HTTP Status: 400 Bad Request +API error_code: INVALID_PARAMETER_VALUE +API message: Field database_id must match pattern ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$, got 'Invalid_DB_ID'. + +Updating deployment state... + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + delete resources.postgres_roles.owner + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/bad-database-id-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/script b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/script new file mode 100644 index 00000000000..7315f6f5d37 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/script @@ -0,0 +1,8 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +musterr $CLI bundle deploy 2>&1 | contains.py "Field database_id must match pattern" diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/test.toml new file mode 100644 index 00000000000..68b282e620a --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/test.toml @@ -0,0 +1,8 @@ +# Stub the database create endpoint to reject a non-pattern database_id, +# matching the live API behavior observed on aws-prod-ucws 2026-05-19: +# 400 Bad Request: Field database_id must match pattern +# ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$, got ''. +[[Server]] +Pattern = "POST /api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases" +Response.StatusCode = 400 +Response.Body = '''{"error_code": "INVALID_PARAMETER_VALUE", "message": "Field database_id must match pattern ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$, got 'Invalid_DB_ID'."}''' diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/databricks.yml.tmpl new file mode 100644 index 00000000000..2f096ac7d02 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/databricks.yml.tmpl @@ -0,0 +1,28 @@ +bundle: + name: bad-role-ref-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Bad role-ref error test" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + database_id: my-database + postgres_database: app_db + # The referenced role is never declared anywhere; the live API + # rejects database creation with "role not found". + role: ${resources.postgres_branches.main.id}/roles/does-not-exist diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/out.test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/out.test.toml new file mode 100644 index 00000000000..4fe23e297fe --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/output.txt b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/output.txt new file mode 100644 index 00000000000..0b19de889b2 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/output.txt @@ -0,0 +1,28 @@ +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/bad-role-ref-[UNIQUE_NAME]/default/files... +Deploying resources... +Error: cannot create resources.postgres_databases.my_database: role not found; role_id:"does-not-exist" [TraceId: [TRACE_ID]] (404 NOT_FOUND) + +Endpoint: POST [DATABRICKS_URL]/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases?database_id=my-database +HTTP Status: 404 Not Found +API error_code: NOT_FOUND +API message: role not found; role_id:"does-not-exist" [TraceId: [TRACE_ID]] + +Updating deployment state... + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/bad-role-ref-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/script b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/script new file mode 100644 index 00000000000..0d040869c10 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/script @@ -0,0 +1,8 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +musterr $CLI bundle deploy 2>&1 | contains.py "role not found" diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/test.toml new file mode 100644 index 00000000000..4658193db32 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/test.toml @@ -0,0 +1,14 @@ +# Stub the database create endpoint to reject the unknown role reference, +# matching the live API behavior observed on aws-prod-ucws 2026-05-19: +# 404 Not Found: role not found; role_id:"" [TraceId: ] +# The default testserver does not validate that the referenced role exists. +[[Server]] +Pattern = "POST /api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases" +Response.StatusCode = 404 +Response.Body = '''{"error_code": "NOT_FOUND", "message": "role not found; role_id:\"does-not-exist\" [TraceId: [TRACE_ID]]"}''' + +[[Repls]] +# Cloud responses include a "[TraceId: ]" suffix on this error; normalize. +Old = '\[TraceId: [a-f0-9]+\]' +New = '[TraceId: [TRACE_ID]]' +Order = 5 diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/databricks.yml.tmpl new file mode 100644 index 00000000000..9a4d3ed0dfa --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/databricks.yml.tmpl @@ -0,0 +1,26 @@ +bundle: + name: missing-role-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Missing-role error test" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + database_id: my-database + postgres_database: app_db + # Deliberately omitting `role`; the live API rejects this with 400. diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/out.test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/out.test.toml new file mode 100644 index 00000000000..4fe23e297fe --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/output.txt b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/output.txt new file mode 100644 index 00000000000..7e279281222 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/output.txt @@ -0,0 +1,28 @@ +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/missing-role-[UNIQUE_NAME]/default/files... +Deploying resources... +Error: cannot create resources.postgres_databases.my_database: Field 'spec.role' cannot be empty (400 INVALID_PARAMETER_VALUE) + +Endpoint: POST [DATABRICKS_URL]/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases?database_id=my-database +HTTP Status: 400 Bad Request +API error_code: INVALID_PARAMETER_VALUE +API message: Field 'spec.role' cannot be empty + +Updating deployment state... + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/missing-role-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/script b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/script new file mode 100644 index 00000000000..c8687770909 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/missing_role/script @@ -0,0 +1,8 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +musterr $CLI bundle deploy 2>&1 | contains.py "Field 'spec.role' cannot be empty" diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/test.toml b/acceptance/bundle/resources/postgres_databases/live_errors/test.toml new file mode 100644 index 00000000000..550eda094ce --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/live_errors/test.toml @@ -0,0 +1,40 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true + +# Error-path tests only assert via output.txt; recording requests adds noise +# (and on cloud, flaky timestamps/operation IDs). +RecordRequests = false + +# Lakebase v2 (postgres) is only available in AWS as of January 2026 +CloudEnvs.gcp = false +CloudEnvs.azure = false + +# Error-path tests don't iterate cleanly under terraform: the terraform engine +# rolls back to its prior state on a partial failure, which makes the recorded +# output diverge from the direct engine. Run them on direct only; the error +# surface is the same regardless of engine. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +Ignore = [ + "databricks.yml", + ".databricks", +] + +[[Repls]] +# Clean up ?o= suffix after URL since not all workspaces have that +Old = '\?o=\[(NUMID|ALPHANUMID)\]' +New = '' +Order = 1000 + +[[Repls]] +# Normalize branch UIDs (br-xxx-yyy-zzz format) +Old = 'br-[a-z0-9-]+' +New = '[BRANCH_UID]' +Order = 1 + +[[Repls]] +# Normalize project UIDs (proj-xxx-yyy-zzz format) +Old = 'proj-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]' +New = '[PROJECT_UID]' +Order = 1 diff --git a/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl new file mode 100644 index 00000000000..a4ce357b68a --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/recreate/databricks.yml.tmpl @@ -0,0 +1,32 @@ +bundle: + name: deploy-postgres-database-recreate-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Database" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + database_id: DATABASE_ID_PLACEHOLDER + postgres_database: app_db + role: ${resources.postgres_roles.owner.id} diff --git a/acceptance/bundle/resources/postgres_databases/recreate/out.test.toml b/acceptance/bundle/resources/postgres_databases/recreate/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/recreate/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/postgres_databases/recreate/output.txt b/acceptance/bundle/resources/postgres_databases/recreate/output.txt new file mode 100644 index 00000000000..23ba9f55936 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/recreate/output.txt @@ -0,0 +1,201 @@ + +>>> cat databricks.yml +bundle: + name: deploy-postgres-database-recreate-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Database" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + database_id: test-database-[UNIQUE_NAME] + postgres_database: app_db + role: ${resources.postgres_roles.owner.id} + +>>> [CLI] bundle plan +create postgres_branches.main +create postgres_databases.my_database +create postgres_projects.my_project +create postgres_roles.owner + +Plan: 4 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests +{ + "body": { + "spec": { + "display_name": "Test Project for Database", + "history_retention_duration": "604800s", + "pg_version": 16 + } + }, + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + } +} + "no_expiry": true + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "branch_id": "main" + "postgres_role": "app_owner" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "role_id": "app-owner" + "postgres_database": "app_db", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", + "database_id": "test-database-[UNIQUE_NAME]" + +>>> cat databricks.yml +bundle: + name: deploy-postgres-database-recreate-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Database" + pg_version: 16 + history_retention_duration: "604800s" + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + + postgres_databases: + my_database: + parent: ${resources.postgres_branches.main.id} + database_id: test-database-[UNIQUE_NAME]-v2 + postgres_database: app_db + role: ${resources.postgres_roles.owner.id} + +>>> [CLI] bundle plan +recreate postgres_databases.my_database + +Plan: 1 to add, 0 to change, 1 to delete, 3 unchanged + +=== Recreate requires approval: non-interactive deploy must abort, not silently delete the database +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default/files... + +This action will result in the deletion or recreation of the following Lakebase databases. +All data stored in them will be permanently lost: + recreate resources.postgres_databases.my_database +Error: the deployment requires destructive actions, but the current console does not support prompting. +Deleting data assets such as schemas, pipelines, or volumes may cause permanent data loss and should be carefully reviewed. +To proceed, use --auto-approve after reviewing the plan above. + + +>>> [CLI] bundle deploy --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default/files... + +This action will result in the deletion or recreation of the following Lakebase databases. +All data stored in them will be permanently lost: + recreate resources.postgres_databases.my_database +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests +{ + "method": "DELETE", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} + "body": { + "spec": { + "postgres_database": "app_db", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + }, + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", + "q": { + "database_id": "test-database-[UNIQUE_NAME]-v2" + } + +=== Fetch database and verify it exists after recreation +>>> [CLI] postgres get-database [MY_DATABASE_ID]-v2 +{ + "name": "[MY_DATABASE_ID]-v2", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "database_id": "test-database-[UNIQUE_NAME]-v2", + "postgres_database": "app_db", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } +} + +=== Destroy and verify cleanup +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_databases.my_database + delete resources.postgres_projects.my_project + delete resources.postgres_roles.owner + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +This action will result in the deletion of the following Lakebase databases. +All data stored in them will be permanently lost: + delete resources.postgres_databases.my_database + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-database-recreate-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! + +>>> print_requests +{ + "method": "DELETE", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]-v2" +} + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" + +>>> [CLI] bundle destroy --auto-approve +No active deployment found to destroy! diff --git a/acceptance/bundle/resources/postgres_databases/recreate/script b/acceptance/bundle/resources/postgres_databases/recreate/script new file mode 100644 index 00000000000..324b6f8eafb --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/recreate/script @@ -0,0 +1,57 @@ +# Cleanup function to delete database and other resources +cleanup() { + # Try to delete with current config + trace $CLI bundle destroy --auto-approve + + # Also try to delete the databases directly in case they weren't cleaned up + $CLI postgres delete-database "projects/test-pg-proj-${UNIQUE_NAME}/branches/main/databases/test-database-${UNIQUE_NAME}" 2>/dev/null || true + $CLI postgres delete-database "projects/test-pg-proj-${UNIQUE_NAME}/branches/main/databases/test-database-${UNIQUE_NAME}-v2" 2>/dev/null || true + + rm -f out.requests.txt +} +trap cleanup EXIT + +# Deploy with first database_id +envsubst < databricks.yml.tmpl | sed "s/DATABASE_ID_PLACEHOLDER/test-database-${UNIQUE_NAME}/" > databricks.yml + +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle deploy + +database_id_1=`read_id.py my_database` + +print_requests() { + # Filter postgres requests (excluding GET), remove parent field (differs between engines), + # then deduplicate consecutive retries + jq --sort-keys 'select(.method != "GET" and (.path | contains("/postgres"))) | del(.body.parent)' < out.requests.txt | \ + awk '!seen[$0]++ {print}' + rm -f out.requests.txt +} + +trace print_requests + +# Change database_id (should trigger recreation) +envsubst < databricks.yml.tmpl | sed "s/DATABASE_ID_PLACEHOLDER/test-database-${UNIQUE_NAME}-v2/" > databricks.yml + +trace cat databricks.yml + +trace $CLI bundle plan + +title "Recreate requires approval: non-interactive deploy must abort, not silently delete the database" +musterr trace $CLI bundle deploy + +trace $CLI bundle deploy --auto-approve + +trace print_requests + +title "Fetch database and verify it exists after recreation" + +database_id_2=`read_id.py my_database` +# Verify the database was recreated (DELETE/POST in requests proves recreation) +trace $CLI postgres get-database $database_id_2 | jq 'del(.create_time, .update_time)' + +title "Destroy and verify cleanup" +trace $CLI bundle destroy --auto-approve + +trace print_requests diff --git a/acceptance/bundle/resources/postgres_databases/test.toml b/acceptance/bundle/resources/postgres_databases/test.toml new file mode 100644 index 00000000000..66fd405c0c6 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/test.toml @@ -0,0 +1,45 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true + +# Lakebase v2 (postgres) is only available in AWS as of January 2026 +CloudEnvs.gcp = false +CloudEnvs.azure = false + +# Run on both direct and Terraform modes +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] + +Ignore = [ + "databricks.yml", + ".databricks", +] + +[[Repls]] +# Clean up ?o= suffix after URL since not all workspaces have that +Old = '\?o=\[(NUMID|ALPHANUMID)\]' +New = '' +Order = 1000 + +[[Repls]] +# Normalize endpoint UIDs (ep-xxx-yyy format, supports both word-based and hex-based UIDs) +Old = 'ep-[a-z0-9-]+' +New = '[ENDPOINT_UID]' +Order = 1 + +[[Repls]] +# Normalize branch UIDs (br-xxx-yyy-zzz format, supports both word-based and hex-based UIDs) +Old = 'br-[a-z0-9-]+' +New = '[BRANCH_UID]' +Order = 1 + +[[Repls]] +# Normalize project UIDs (proj-xxx-yyy-zzz format, supports both word-based and hex-based UIDs) +Old = 'proj-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]' +New = '[PROJECT_UID]' +Order = 1 + +[[Repls]] +# Normalize LSN values (format: 0/HEXVALUE or 0/0) +Old = '"source_branch_lsn": "0/[A-F0-9]+"' +New = '"source_branch_lsn": "[LSN]"' +Order = 1 diff --git a/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl new file mode 100644 index 00000000000..52e26cc75b1 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/databricks.yml.tmpl @@ -0,0 +1,32 @@ +bundle: + name: update-postgres-database-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Database Update" + pg_version: 16 + history_retention_duration: 604800s + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + + postgres_databases: + my_database: + database_id: my-database + parent: ${resources.postgres_branches.main.id} + postgres_database: initial_db_name + role: ${resources.postgres_roles.owner.id} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json new file mode 100644 index 00000000000..2cb56d4a187 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.create.direct.json @@ -0,0 +1,25 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + }, + { + "node": "resources.postgres_roles.owner", + "label": "${resources.postgres_roles.owner.id}" + } + ], + "action": "create", + "new_state": { + "value": { + "database_id": "my-database", + "parent": "${resources.postgres_branches.main.id}", + "postgres_database": "initial_db_name", + "role": "${resources.postgres_roles.owner.id}" + }, + "vars": { + "parent": "${resources.postgres_branches.main.id}", + "role": "${resources.postgres_roles.owner.id}" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.create.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.create.terraform.json new file mode 100644 index 00000000000..b4425b9687c --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.create.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "create" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json new file mode 100644 index 00000000000..0f36efeb891 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.direct.json @@ -0,0 +1,39 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + }, + { + "node": "resources.postgres_roles.owner", + "label": "${resources.postgres_roles.owner.id}" + } + ], + "action": "skip", + "remote_state": { + "create_time": "[TIMESTAMP]", + "database_id": "my-database", + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "database_id": "my-database", + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + }, + "update_time": "[TIMESTAMP]" + }, + "changes": { + "postgres_database": { + "action": "skip", + "reason": "spec:input_only", + "old": "initial_db_name", + "new": "initial_db_name" + }, + "role": { + "action": "skip", + "reason": "spec:input_only", + "old": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.terraform.json new file mode 100644 index 00000000000..b2f23a5015f --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.no_change.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "skip" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json new file mode 100644 index 00000000000..cb7b036c5eb --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.direct.json @@ -0,0 +1,46 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + }, + { + "node": "resources.postgres_roles.owner", + "label": "${resources.postgres_roles.owner.id}" + } + ], + "action": "update", + "new_state": { + "value": { + "database_id": "my-database", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + }, + "remote_state": { + "create_time": "[TIMESTAMP]", + "database_id": "my-database", + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "database_id": "my-database", + "postgres_database": "renamed_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + }, + "update_time": "[TIMESTAMP]" + }, + "changes": { + "postgres_database": { + "action": "update", + "old": "renamed_db_name", + "new": "initial_db_name" + }, + "role": { + "action": "skip", + "reason": "spec:input_only", + "old": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.terraform.json new file mode 100644 index 00000000000..375b496fef0 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.restore.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "update" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json new file mode 100644 index 00000000000..be6e9f20978 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.update.direct.json @@ -0,0 +1,46 @@ +{ + "depends_on": [ + { + "node": "resources.postgres_branches.main", + "label": "${resources.postgres_branches.main.id}" + }, + { + "node": "resources.postgres_roles.owner", + "label": "${resources.postgres_roles.owner.id}" + } + ], + "action": "update", + "new_state": { + "value": { + "database_id": "my-database", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "postgres_database": "renamed_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + }, + "remote_state": { + "create_time": "[TIMESTAMP]", + "database_id": "my-database", + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "database_id": "my-database", + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + }, + "update_time": "[TIMESTAMP]" + }, + "changes": { + "postgres_database": { + "action": "update", + "old": "initial_db_name", + "new": "renamed_db_name" + }, + "role": { + "action": "skip", + "reason": "spec:input_only", + "old": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner", + "new": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.plan.update.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.plan.update.terraform.json new file mode 100644 index 00000000000..375b496fef0 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.plan.update.terraform.json @@ -0,0 +1,3 @@ +{ + "action": "update" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json new file mode 100644 index 00000000000..15308c66f4e --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.direct.json @@ -0,0 +1,51 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Database Update", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "app-owner" + }, + "body": { + "spec": { + "postgres_role": "app_owner" + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", + "q": { + "database_id": "my-database" + }, + "body": { + "spec": { + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json new file mode 100644 index 00000000000..c4e31a5d55d --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.create.terraform.json @@ -0,0 +1,54 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Database Update", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles", + "q": { + "role_id": "app-owner" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "postgres_role": "app_owner" + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/databases", + "q": { + "database_id": "my-database" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.direct.json new file mode 100644 index 00000000000..42d1bd84b95 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.direct.json @@ -0,0 +1,28 @@ +{ + "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/app-owner" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.terraform.json new file mode 100644 index 00000000000..ff91f1561cf --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.no_change.terraform.json @@ -0,0 +1,16 @@ +{ + "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/app-owner" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json new file mode 100644 index 00000000000..05d035f3dc0 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.direct.json @@ -0,0 +1,41 @@ +{ + "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/app-owner" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]", + "q": { + "update_mask": "spec.postgres_database" + }, + "body": { + "spec": { + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json new file mode 100644 index 00000000000..be32218e65e --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.restore.terraform.json @@ -0,0 +1,31 @@ +{ + "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/app-owner" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]", + "q": { + "update_mask": "spec" + }, + "body": { + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json new file mode 100644 index 00000000000..82d82c5a603 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.direct.json @@ -0,0 +1,41 @@ +{ + "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/app-owner" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]", + "q": { + "update_mask": "spec.postgres_database" + }, + "body": { + "spec": { + "postgres_database": "renamed_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json new file mode 100644 index 00000000000..f3e8e7858c3 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.requests.update.terraform.json @@ -0,0 +1,31 @@ +{ + "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/app-owner" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/[MY_DATABASE_ID]", + "q": { + "update_mask": "spec" + }, + "body": { + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "spec": { + "postgres_database": "renamed_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } + } +} diff --git a/acceptance/bundle/resources/postgres_databases/update/out.test.toml b/acceptance/bundle/resources/postgres_databases/update/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/resources/postgres_databases/update/output.txt b/acceptance/bundle/resources/postgres_databases/update/output.txt new file mode 100644 index 00000000000..0693e98a4ee --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/output.txt @@ -0,0 +1,169 @@ + +=== Initial deployment +>>> [CLI] bundle validate +Name: update-postgres-database-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default + +Validation OK! + +>>> [CLI] bundle plan +create postgres_branches.main +create postgres_databases.my_database +create postgres_projects.my_project +create postgres_roles.owner + +Plan: 4 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] postgres get-database [MY_DATABASE_ID] +{ + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "database_id": "my-database", + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } +} + +=== Verify no changes +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 4 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +=== Update postgres_database and re-deploy +>>> update_file.py databricks.yml postgres_database: initial_db_name postgres_database: renamed_db_name + +>>> cat databricks.yml +bundle: + name: update-postgres-database-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Database Update" + pg_version: 16 + history_retention_duration: 604800s + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_roles: + owner: + parent: ${resources.postgres_branches.main.id} + role_id: app-owner + postgres_role: app_owner + + postgres_databases: + my_database: + database_id: my-database + parent: ${resources.postgres_branches.main.id} + postgres_database: renamed_db_name + role: ${resources.postgres_roles.owner.id} + +>>> [CLI] bundle plan +update postgres_databases.my_database + +Plan: 0 to add, 1 to change, 0 to delete, 3 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] postgres get-database [MY_DATABASE_ID] +{ + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "database_id": "my-database", + "postgres_database": "renamed_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } +} + +=== Restore postgres_database to original value +>>> update_file.py databricks.yml postgres_database: renamed_db_name postgres_database: initial_db_name + +>>> [CLI] bundle plan +update postgres_databases.my_database + +Plan: 0 to add, 1 to change, 0 to delete, 3 unchanged + +>>> [CLI] bundle plan -o json + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +>>> [CLI] postgres get-database [MY_DATABASE_ID] +{ + "name": "[MY_DATABASE_ID]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main", + "status": { + "database_id": "my-database", + "postgres_database": "initial_db_name", + "role": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/roles/app-owner" + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_databases.my_database + delete resources.postgres_projects.my_project + delete resources.postgres_roles.owner + +This action will result in the deletion of the following Lakebase projects along with +all their branches, databases, and endpoints. All data stored in them will be permanently lost: + delete resources.postgres_projects.my_project + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +This action will result in the deletion of the following Lakebase databases. +All data stored in them will be permanently lost: + delete resources.postgres_databases.my_database + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/update-postgres-database-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_databases/update/script b/acceptance/bundle/resources/postgres_databases/update/script new file mode 100644 index 00000000000..488c1719f1b --- /dev/null +++ b/acceptance/bundle/resources/postgres_databases/update/script @@ -0,0 +1,61 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +print_requests() { + local name=$1 + trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.${name}.$DATABRICKS_BUNDLE_ENGINE.json + rm -f out.requests.txt +} + +title "Initial deployment" +trace $CLI bundle validate +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_databases.my_database" // .' > out.plan.create.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests create + +project_name="projects/test-pg-proj-${UNIQUE_NAME}" +branch_name="${project_name}/branches/main" +database_name="${branch_name}/databases/my-database" +database_id_1=`read_id.py my_database` +trace $CLI postgres get-database "${database_name}" | jq 'del(.create_time, .update_time)' + +title "Verify no changes" +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_databases.my_database" // .' > out.plan.no_change.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests no_change + +title "Update postgres_database and re-deploy" +trace update_file.py databricks.yml "postgres_database: initial_db_name" "postgres_database: renamed_db_name" +trace cat databricks.yml + +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_databases.my_database" // .' > out.plan.update.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests update + +database_id_2=`read_id.py my_database` +trace $CLI postgres get-database "${database_name}" | jq 'del(.create_time, .update_time)' + +title "Restore postgres_database to original value" +trace update_file.py databricks.yml "postgres_database: renamed_db_name" "postgres_database: initial_db_name" +trace $CLI bundle plan +trace $CLI bundle plan -o json | jq '.plan."resources.postgres_databases.my_database" // .' > out.plan.restore.$DATABRICKS_BUNDLE_ENGINE.json +rm -f out.requests.txt +trace $CLI bundle deploy + +print_requests restore + +trace $CLI postgres get-database "${database_name}" | jq 'del(.create_time, .update_time)' diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index 5e6dd0f5cab..2fbe07b270a 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -27,10 +27,11 @@ var unsupportedResources = []string{ "database_catalogs", "synced_database_tables", "postgres_branches", + "postgres_databases", "postgres_endpoints", "postgres_catalogs", - "postgres_synced_tables", "postgres_roles", + "postgres_synced_tables", "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 8e14390c3ab..bc3b44cbdca 100644 --- a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go +++ b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go @@ -261,10 +261,11 @@ func mockBundle(mode config.Mode) *bundle.Bundle { }, }, }, - PostgresSyncedTables: map[string]*resources.PostgresSyncedTable{ - "postgres_synced_table1": { - PostgresSyncedTableConfig: resources.PostgresSyncedTableConfig{ - SyncedTableId: "catalog.schema.table1", + PostgresDatabases: map[string]*resources.PostgresDatabase{ + "postgres_database1": { + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "postgres-database-1", + Parent: "projects/postgres-project-1/branches/postgres-branch-1", }, }, }, @@ -279,6 +280,13 @@ func mockBundle(mode config.Mode) *bundle.Bundle { }, }, }, + PostgresSyncedTables: map[string]*resources.PostgresSyncedTable{ + "postgres_synced_table1": { + PostgresSyncedTableConfig: resources.PostgresSyncedTableConfig{ + SyncedTableId: "catalog.schema.table1", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "vs_endpoint1": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -491,6 +499,7 @@ func TestAppropriateResourcesAreRenamed(t *testing.T) { "PostgresBranches", "PostgresEndpoints", "PostgresCatalogs", + "PostgresDatabases", "PostgresSyncedTables", } diff --git a/bundle/config/mutator/resourcemutator/run_as_test.go b/bundle/config/mutator/resourcemutator/run_as_test.go index ee0e99e5f89..5f98190ecb3 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -48,6 +48,7 @@ func allResourceTypes(t *testing.T) []string { "pipelines", "postgres_branches", "postgres_catalogs", + "postgres_databases", "postgres_endpoints", "postgres_projects", "postgres_roles", @@ -180,6 +181,7 @@ var allowList = []string{ "models", "postgres_branches", "postgres_catalogs", + "postgres_databases", "postgres_endpoints", "postgres_projects", "postgres_roles", diff --git a/bundle/config/resources.go b/bundle/config/resources.go index 00bc8b61f01..8d294aef103 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -37,8 +37,9 @@ type Resources struct { PostgresBranches map[string]*resources.PostgresBranch `json:"postgres_branches,omitempty"` 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"` + PostgresDatabases map[string]*resources.PostgresDatabase `json:"postgres_databases,omitempty"` PostgresRoles map[string]*resources.PostgresRole `json:"postgres_roles,omitempty"` + PostgresSyncedTables map[string]*resources.PostgresSyncedTable `json:"postgres_synced_tables,omitempty"` VectorSearchEndpoints map[string]*resources.VectorSearchEndpoint `json:"vector_search_endpoints,omitempty"` VectorSearchIndexes map[string]*resources.VectorSearchIndex `json:"vector_search_indexes,omitempty"` } @@ -119,8 +120,9 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["postgres_branches"], r.PostgresBranches), collectResourceMap(descriptions["postgres_endpoints"], r.PostgresEndpoints), collectResourceMap(descriptions["postgres_catalogs"], r.PostgresCatalogs), - collectResourceMap(descriptions["postgres_synced_tables"], r.PostgresSyncedTables), + collectResourceMap(descriptions["postgres_databases"], r.PostgresDatabases), collectResourceMap(descriptions["postgres_roles"], r.PostgresRoles), + collectResourceMap(descriptions["postgres_synced_tables"], r.PostgresSyncedTables), collectResourceMap(descriptions["vector_search_endpoints"], r.VectorSearchEndpoints), collectResourceMap(descriptions["vector_search_indexes"], r.VectorSearchIndexes), } @@ -179,8 +181,9 @@ func SupportedResources() map[string]resources.ResourceDescription { "postgres_branches": (&resources.PostgresBranch{}).ResourceDescription(), "postgres_endpoints": (&resources.PostgresEndpoint{}).ResourceDescription(), "postgres_catalogs": (&resources.PostgresCatalog{}).ResourceDescription(), - "postgres_synced_tables": (&resources.PostgresSyncedTable{}).ResourceDescription(), + "postgres_databases": (&resources.PostgresDatabase{}).ResourceDescription(), "postgres_roles": (&resources.PostgresRole{}).ResourceDescription(), + "postgres_synced_tables": (&resources.PostgresSyncedTable{}).ResourceDescription(), "vector_search_endpoints": (&resources.VectorSearchEndpoint{}).ResourceDescription(), "vector_search_indexes": (&resources.VectorSearchIndex{}).ResourceDescription(), } diff --git a/bundle/config/resources/postgres_database.go b/bundle/config/resources/postgres_database.go new file mode 100644 index 00000000000..1830e8586d3 --- /dev/null +++ b/bundle/config/resources/postgres_database.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 PostgresDatabaseConfig struct { + postgres.DatabaseDatabaseSpec + + // DatabaseId is the user-specified ID for the database (becomes part of the hierarchical name). + // This is specified during creation and becomes part of Name: "projects/{project_id}/branches/{branch_id}/databases/{database_id}" + DatabaseId string `json:"database_id"` + + // Parent is the branch containing this database. Format: "projects/{project_id}/branches/{branch_id}" + Parent string `json:"parent"` +} + +func (c *PostgresDatabaseConfig) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, c) +} + +func (c *PostgresDatabaseConfig) MarshalJSON() ([]byte, error) { + return marshal.Marshal(c) +} + +type PostgresDatabase struct { + BaseResource + PostgresDatabaseConfig +} + +func (d *PostgresDatabase) Exists(ctx context.Context, w *databricks.WorkspaceClient, name string) (bool, error) { + _, err := w.Postgres.GetDatabase(ctx, postgres.GetDatabaseRequest{Name: name}) + if apierr.IsMissing(err) { + log.Debugf(ctx, "postgres database %s does not exist", name) + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func (d *PostgresDatabase) ResourceDescription() ResourceDescription { + return ResourceDescription{ + SingularName: "postgres_database", + PluralName: "postgres_databases", + SingularTitle: "Postgres database", + PluralTitle: "Postgres databases", + } +} + +func (d *PostgresDatabase) GetName() string { + // Databases don't have a user-visible name field. + return "" +} + +func (d *PostgresDatabase) GetURL() string { + // The IDs in the API do not (yet) map to IDs in the web UI. + return "" +} + +func (d *PostgresDatabase) InitializeURL(_ url.URL) { + // The IDs in the API do not (yet) map to IDs in the web UI. +} diff --git a/bundle/config/resources_test.go b/bundle/config/resources_test.go index ee0d206dbd9..c5f32267531 100644 --- a/bundle/config/resources_test.go +++ b/bundle/config/resources_test.go @@ -126,6 +126,7 @@ func TestBundleResourcePluralNamesResolveInWorkspaceURLs(t *testing.T) { noURL := map[string]bool{ "external_locations": true, "postgres_branches": true, + "postgres_databases": true, "postgres_endpoints": true, "postgres_projects": true, "postgres_roles": true, @@ -283,10 +284,11 @@ func TestResourcesBindSupport(t *testing.T) { }, }, }, - PostgresSyncedTables: map[string]*resources.PostgresSyncedTable{ - "my_postgres_synced_table": { - PostgresSyncedTableConfig: resources.PostgresSyncedTableConfig{ - SyncedTableId: "catalog.schema.my_postgres_synced_table", + PostgresDatabases: map[string]*resources.PostgresDatabase{ + "my_postgres_database": { + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "my-postgres-database", + Parent: "projects/my-postgres-project/branches/my-postgres-branch", }, }, }, @@ -301,6 +303,13 @@ func TestResourcesBindSupport(t *testing.T) { }, }, }, + PostgresSyncedTables: map[string]*resources.PostgresSyncedTable{ + "my_postgres_synced_table": { + PostgresSyncedTableConfig: resources.PostgresSyncedTableConfig{ + SyncedTableId: "catalog.schema.my_postgres_synced_table", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "my_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -352,6 +361,8 @@ func TestResourcesBindSupport(t *testing.T) { m.GetMockPostgresAPI().EXPECT().GetBranch(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetEndpoint(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetCatalog(mock.Anything, mock.Anything).Return(nil, nil) + m.GetMockPostgresAPI().EXPECT().GetDatabase(mock.Anything, mock.Anything).Return(nil, nil) + m.GetMockPostgresAPI().EXPECT().GetRole(mock.Anything, mock.Anything).Return(nil, nil) m.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) diff --git a/bundle/deploy/terraform/interpolate.go b/bundle/deploy/terraform/interpolate.go index d1a0f39318f..edf46782743 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", "postgres_roles": + case "postgres_projects", "postgres_branches", "postgres_catalogs", "postgres_databases", "postgres_endpoints", "postgres_roles", "postgres_synced_tables": return true default: return false diff --git a/bundle/deploy/terraform/pkg.go b/bundle/deploy/terraform/pkg.go index d1649543260..891747caad9 100644 --- a/bundle/deploy/terraform/pkg.go +++ b/bundle/deploy/terraform/pkg.go @@ -128,10 +128,11 @@ var GroupToTerraformName = map[string]string{ "synced_database_tables": "databricks_database_synced_database_table", "postgres_projects": "databricks_postgres_project", "postgres_branches": "databricks_postgres_branch", + "postgres_databases": "databricks_postgres_database", "postgres_endpoints": "databricks_postgres_endpoint", "postgres_catalogs": "databricks_postgres_catalog", - "postgres_synced_tables": "databricks_postgres_synced_table", "postgres_roles": "databricks_postgres_role", + "postgres_synced_tables": "databricks_postgres_synced_table", // 3 level groups: resources.*.GROUP "permissions": "databricks_permissions", diff --git a/bundle/deploy/terraform/tfdyn/convert_postgres_database.go b/bundle/deploy/terraform/tfdyn/convert_postgres_database.go new file mode 100644 index 00000000000..cb89f4aed33 --- /dev/null +++ b/bundle/deploy/terraform/tfdyn/convert_postgres_database.go @@ -0,0 +1,63 @@ +package tfdyn + +import ( + "context" + + "github.com/databricks/cli/bundle/internal/tf/schema" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/convert" + "github.com/databricks/cli/libs/log" +) + +type postgresDatabaseConverter struct{} + +func (c postgresDatabaseConverter) Convert(ctx context.Context, key string, vin dyn.Value, out *schema.Resources) error { + // The bundle config has flattened DatabaseSpec fields at the top level. + // Terraform expects them nested in a "spec" block. + specFields := specFieldNames(schema.ResourcePostgresDatabaseSpec{}) + topLevelFields := []string{"database_id", "parent"} + + // Build the spec block from the flattened fields + specMap := make(map[string]dyn.Value) + for _, field := range specFields { + if v := vin.Get(field); v.Kind() != dyn.KindInvalid { + specMap[field] = v + } + } + + // Build the output with top-level fields and spec + outMap := make(map[string]dyn.Value) + + // Keep top-level fields + for _, field := range topLevelFields { + if v := vin.Get(field); v.Kind() != dyn.KindInvalid { + outMap[field] = v + } + } + + // Add spec block if we have any spec fields + if len(specMap) > 0 { + outMap["spec"] = dyn.V(specMap) + } + + vout := dyn.V(outMap) + + // Normalize the output value to the Terraform schema. + vout, diags := convert.Normalize(schema.ResourcePostgresDatabase{}, vout) + for _, diag := range diags { + log.Debugf(ctx, "postgres database normalization diagnostic: %s", diag.Summary) + } + + vout, err := convertLifecycle(ctx, vout, vin.Get("lifecycle")) + if err != nil { + return err + } + + out.PostgresDatabase[key] = vout.AsAny() + + return nil +} + +func init() { + registerConverter("postgres_databases", postgresDatabaseConverter{}) +} diff --git a/bundle/deploy/terraform/tfdyn/convert_postgres_database_test.go b/bundle/deploy/terraform/tfdyn/convert_postgres_database_test.go new file mode 100644 index 00000000000..3276be62856 --- /dev/null +++ b/bundle/deploy/terraform/tfdyn/convert_postgres_database_test.go @@ -0,0 +1,73 @@ +package tfdyn + +import ( + "testing" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/bundle/internal/tf/schema" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/convert" + "github.com/databricks/databricks-sdk-go/service/postgres" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertPostgresDatabase(t *testing.T) { + src := resources.PostgresDatabase{ + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "my-database", + Parent: "projects/my-project/branches/main", + DatabaseDatabaseSpec: postgres.DatabaseDatabaseSpec{ + PostgresDatabase: "my_postgres_db", + Role: "projects/my-project/branches/main/roles/owner", + }, + }, + } + + vin, err := convert.FromTyped(src, dyn.NilValue) + require.NoError(t, err) + + ctx := t.Context() + out := schema.NewResources() + err = postgresDatabaseConverter{}.Convert(ctx, "my_postgres_database", vin, out) + require.NoError(t, err) + + postgresDatabase := out.PostgresDatabase["my_postgres_database"] + assert.Equal(t, map[string]any{ + "database_id": "my-database", + "parent": "projects/my-project/branches/main", + "spec": map[string]any{ + "postgres_database": "my_postgres_db", + "role": "projects/my-project/branches/main/roles/owner", + }, + }, postgresDatabase) +} + +func TestConvertPostgresDatabaseMinimal(t *testing.T) { + src := resources.PostgresDatabase{ + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "minimal-database", + Parent: "projects/my-project/branches/main", + DatabaseDatabaseSpec: postgres.DatabaseDatabaseSpec{ + PostgresDatabase: "minimal_db", + }, + }, + } + + vin, err := convert.FromTyped(src, dyn.NilValue) + require.NoError(t, err) + + ctx := t.Context() + out := schema.NewResources() + err = postgresDatabaseConverter{}.Convert(ctx, "minimal_postgres_database", vin, out) + require.NoError(t, err) + + postgresDatabase := out.PostgresDatabase["minimal_postgres_database"] + assert.Equal(t, map[string]any{ + "database_id": "minimal-database", + "parent": "projects/my-project/branches/main", + "spec": map[string]any{ + "postgres_database": "minimal_db", + }, + }, postgresDatabase) +} diff --git a/bundle/deploy/terraform/util.go b/bundle/deploy/terraform/util.go index e5b0fe3a113..07384d6b262 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", "postgres_roles": + case "apps", "database_instances", "database_catalogs", "synced_database_tables", "postgres_projects", "postgres_branches", "postgres_catalogs", "postgres_databases", "postgres_endpoints", "postgres_roles", "postgres_synced_tables": 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 69c54f414c7..4df9dfe58b8 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -24,8 +24,9 @@ var SupportedResources = map[string]any{ "postgres_branches": (*ResourcePostgresBranch)(nil), "postgres_endpoints": (*ResourcePostgresEndpoint)(nil), "postgres_catalogs": (*ResourcePostgresCatalog)(nil), - "postgres_synced_tables": (*ResourcePostgresSyncedTable)(nil), + "postgres_databases": (*ResourcePostgresDatabase)(nil), "postgres_roles": (*ResourcePostgresRole)(nil), + "postgres_synced_tables": (*ResourcePostgresSyncedTable)(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 7490e954d12..0d7051322af 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -749,6 +749,43 @@ var testDeps = map[string]prepareWorkspace{ }, nil }, + "postgres_databases": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { + // Create parent project first + _, err := client.Postgres.CreateProject(ctx, postgres.CreateProjectRequest{ + ProjectId: "test-project-for-database", + Project: postgres.Project{ + Spec: &postgres.ProjectSpec{ + DisplayName: "Test Project for Database", + PgVersion: 16, + }, + }, + }) + if err != nil { + return nil, err + } + + // Create parent branch + _, err = client.Postgres.CreateBranch(ctx, postgres.CreateBranchRequest{ + Parent: "projects/test-project-for-database", + BranchId: "test-branch-for-database", + Branch: postgres.Branch{}, + }) + if err != nil { + return nil, err + } + + return &resources.PostgresDatabase{ + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + Parent: "projects/test-project-for-database/branches/test-branch-for-database", + DatabaseId: "test-database", + DatabaseDatabaseSpec: postgres.DatabaseDatabaseSpec{ + PostgresDatabase: "app_db", + Role: "projects/test-project-for-database/branches/test-branch-for-database/roles/owner", + }, + }, + }, nil + }, + "postgres_roles": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { // Create parent project first _, err := client.Postgres.CreateProject(ctx, postgres.CreateProjectRequest{ diff --git a/bundle/direct/dresources/apitypes.generated.yml b/bundle/direct/dresources/apitypes.generated.yml index cfb8d816dd4..8fb3f0117c4 100644 --- a/bundle/direct/dresources/apitypes.generated.yml +++ b/bundle/direct/dresources/apitypes.generated.yml @@ -32,6 +32,8 @@ postgres_branches: postgres.BranchSpec postgres_catalogs: postgres.CatalogCatalogSpec +postgres_databases: postgres.DatabaseDatabaseStatus + postgres_endpoints: postgres.EndpointSpec postgres_projects: postgres.ProjectStatus diff --git a/bundle/direct/dresources/apitypes.yml b/bundle/direct/dresources/apitypes.yml index 795d8e24551..b3f83ba12a3 100644 --- a/bundle/direct/dresources/apitypes.yml +++ b/bundle/direct/dresources/apitypes.yml @@ -13,6 +13,8 @@ genie_spaces: dashboards.GenieSpace postgres_branches: postgres.BranchSpec +postgres_databases: postgres.DatabaseDatabaseSpec + postgres_endpoints: postgres.EndpointSpec postgres_projects: postgres.ProjectSpec diff --git a/bundle/direct/dresources/postgres_database.go b/bundle/direct/dresources/postgres_database.go new file mode 100644 index 00000000000..2464e864465 --- /dev/null +++ b/bundle/direct/dresources/postgres_database.go @@ -0,0 +1,173 @@ +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" +) + +// PostgresDatabaseRemote is the return type for DoRead. It embeds +// DatabaseDatabaseSpec 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 PostgresDatabaseRemote struct { + postgres.DatabaseDatabaseSpec + + DatabaseId string `json:"database_id,omitempty"` + Parent string `json:"parent,omitempty"` + + Name string `json:"name,omitempty"` + Status *postgres.DatabaseDatabaseStatus `json:"status,omitempty"` + CreateTime *sdktime.Time `json:"create_time,omitempty"` + UpdateTime *sdktime.Time `json:"update_time,omitempty"` +} + +// Custom marshaler needed because embedded DatabaseDatabaseSpec has its own +// MarshalJSON which would otherwise take over and ignore the additional fields. +func (s *PostgresDatabaseRemote) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, s) +} + +func (s PostgresDatabaseRemote) MarshalJSON() ([]byte, error) { + return marshal.Marshal(s) +} + +type ResourcePostgresDatabase struct { + client *databricks.WorkspaceClient +} + +type PostgresDatabaseState = resources.PostgresDatabaseConfig + +func (*ResourcePostgresDatabase) New(client *databricks.WorkspaceClient) *ResourcePostgresDatabase { + return &ResourcePostgresDatabase{client: client} +} + +func (*ResourcePostgresDatabase) PrepareState(input *resources.PostgresDatabase) *PostgresDatabaseState { + return &PostgresDatabaseState{ + DatabaseId: input.DatabaseId, + Parent: input.Parent, + DatabaseDatabaseSpec: input.DatabaseDatabaseSpec, + } +} + +func (*ResourcePostgresDatabase) RemapState(remote *PostgresDatabaseRemote) *PostgresDatabaseState { + return &PostgresDatabaseState{ + DatabaseId: remote.DatabaseId, + Parent: remote.Parent, + DatabaseDatabaseSpec: remote.DatabaseDatabaseSpec, + } +} + +// makePostgresDatabaseRemote converts the SDK Database 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 makePostgresDatabaseRemote(database *postgres.Database) *PostgresDatabaseRemote { + var spec postgres.DatabaseDatabaseSpec + if database.Spec != nil { + spec = *database.Spec + } + var databaseID string + if database.Status != nil { + databaseID = database.Status.DatabaseId + } + return &PostgresDatabaseRemote{ + DatabaseDatabaseSpec: spec, + DatabaseId: databaseID, + Parent: database.Parent, + Name: database.Name, + Status: database.Status, + CreateTime: database.CreateTime, + UpdateTime: database.UpdateTime, + } +} + +func (r *ResourcePostgresDatabase) DoRead(ctx context.Context, id string) (*PostgresDatabaseRemote, error) { + database, err := r.client.Postgres.GetDatabase(ctx, postgres.GetDatabaseRequest{Name: id}) + if err != nil { + return nil, err + } + return makePostgresDatabaseRemote(database), nil +} + +func (r *ResourcePostgresDatabase) DoCreate(ctx context.Context, config *PostgresDatabaseState) (string, *PostgresDatabaseRemote, error) { + waiter, err := r.client.Postgres.CreateDatabase(ctx, postgres.CreateDatabaseRequest{ + DatabaseId: config.DatabaseId, + Parent: config.Parent, + Database: postgres.Database{ + Spec: &config.DatabaseDatabaseSpec, + + // Output-only fields. + CreateTime: nil, + Name: "", + Parent: "", + Status: nil, + UpdateTime: nil, + ForceSendFields: nil, + }, + ForceSendFields: nil, + }) + if err != nil { + return "", nil, err + } + + // Wait for the database to be ready (long-running operation) + result, err := waiter.Wait(ctx) + if err != nil { + return "", nil, err + } + + remote := makePostgresDatabaseRemote(result) + return remote.Name, remote, nil +} + +func (r *ResourcePostgresDatabase) DoUpdate(ctx context.Context, id string, config *PostgresDatabaseState, entry *PlanEntry) (*PostgresDatabaseRemote, error) { + // Build update mask from fields that have action="update" in the changes map. + // This excludes immutable fields and fields that haven't changed. + // Prefix with "spec." because the API expects paths relative to the Database object, + // not relative to our flattened state type. + fieldPaths := collectLeafUpdatePathsWithPrefix(entry.Changes, "spec.") + + waiter, err := r.client.Postgres.UpdateDatabase(ctx, postgres.UpdateDatabaseRequest{ + Database: postgres.Database{ + Spec: &config.DatabaseDatabaseSpec, + + // Output-only fields. + CreateTime: nil, + Name: "", + Parent: "", + Status: nil, + UpdateTime: nil, + ForceSendFields: nil, + }, + Name: id, + UpdateMask: fieldmask.FieldMask{ + Paths: fieldPaths, + }, + }) + if err != nil { + return nil, err + } + + // Wait for the update to complete + result, err := waiter.Wait(ctx) + if err != nil { + return nil, err + } + return makePostgresDatabaseRemote(result), nil +} + +func (r *ResourcePostgresDatabase) DoDelete(ctx context.Context, id string, _ *PostgresDatabaseState) error { + waiter, err := r.client.Postgres.DeleteDatabase(ctx, postgres.DeleteDatabaseRequest{ + Name: id, + }) + 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 ecef51a5aff..3fc24b0c8f5 100644 --- a/bundle/direct/dresources/resources.generated.yml +++ b/bundle/direct/dresources/resources.generated.yml @@ -228,6 +228,14 @@ resources: - field: postgres_database reason: spec:input_only + postgres_databases: + + ignore_remote_changes: + - field: postgres_database + reason: spec:input_only + - field: role + reason: spec:input_only + postgres_endpoints: recreate_on_changes: diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 2aa10111952..e876a0cefc3 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -588,6 +588,14 @@ resources: - field: replace_existing reason: "input_only; cannot be updated after create" + postgres_databases: + provided_id_fields: + # parent and database_id are immutable (part of hierarchical name, not in API spec) + - field: parent + reason: id_field + - field: database_id + reason: id_field + postgres_endpoints: provided_id_fields: # parent and endpoint_id are immutable (part of hierarchical name, not in API spec) diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index c2810273714..76d87429c70 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -1535,6 +1535,25 @@ resources: "postgres_database": "description": |- PLACEHOLDER + "postgres_databases": + "description": |- + PLACEHOLDER + "$fields": + "database_id": + "description": |- + PLACEHOLDER + "lifecycle": + "description": |- + PLACEHOLDER + "parent": + "description": |- + PLACEHOLDER + "postgres_database": + "description": |- + PLACEHOLDER + "role": + "description": |- + PLACEHOLDER "postgres_endpoints": "description": |- PLACEHOLDER diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index 43d60e145ca..1ba87532c4f 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -223,6 +223,8 @@ var RequiredFields = map[string][]string{ "resources.postgres_catalogs.*": {"postgres_database", "catalog_id"}, + "resources.postgres_databases.*": {"database_id", "parent"}, + "resources.postgres_endpoints.*": {"endpoint_type", "endpoint_id", "parent"}, "resources.postgres_endpoints.*.group": {"max", "min"}, diff --git a/bundle/phases/deploy.go b/bundle/phases/deploy.go index e3b0b777eb4..fd76151483c 100644 --- a/bundle/phases/deploy.go +++ b/bundle/phases/deploy.go @@ -36,6 +36,7 @@ var deployApprovalGroups = []approvalGroup{ {group: "synced_database_tables", message: deleteOrRecreateSyncedDatabaseTableMessage}, {group: "postgres_projects", message: deleteOrRecreatePostgresProjectMessage}, {group: "postgres_branches", message: deleteOrRecreatePostgresBranchMessage}, + {group: "postgres_databases", message: deleteOrRecreatePostgresDatabaseMessage}, {group: "vector_search_indexes", message: deleteOrRecreateVectorSearchIndexMessage}, {group: "genie_spaces", message: deleteOrRecreateGenieSpaceMessage}, } diff --git a/bundle/phases/destroy.go b/bundle/phases/destroy.go index b75e1e7b5a4..74049f26f42 100644 --- a/bundle/phases/destroy.go +++ b/bundle/phases/destroy.go @@ -40,6 +40,7 @@ var destroyApprovalGroups = []approvalGroup{ {group: "synced_database_tables", message: deleteSyncedDatabaseTableMessage}, {group: "postgres_projects", message: deletePostgresProjectMessage}, {group: "postgres_branches", message: deletePostgresBranchMessage}, + {group: "postgres_databases", message: deletePostgresDatabaseMessage}, {group: "vector_search_indexes", message: deleteVectorSearchIndexMessage}, {group: "genie_spaces", message: deleteGenieSpaceMessage}, } diff --git a/bundle/phases/messages.go b/bundle/phases/messages.go index 22b20bb378c..8f07b1e2429 100644 --- a/bundle/phases/messages.go +++ b/bundle/phases/messages.go @@ -35,6 +35,10 @@ all their branches, databases, and endpoints. All data stored in them will be pe deleteOrRecreatePostgresBranchMessage = ` This action will result in the deletion or recreation of the following Lakebase branches. +All data stored in them will be permanently lost:` + + deleteOrRecreatePostgresDatabaseMessage = ` +This action will result in the deletion or recreation of the following Lakebase databases. All data stored in them will be permanently lost:` deleteOrRecreateVectorSearchIndexMessage = ` @@ -75,6 +79,9 @@ all their branches, databases, and endpoints. All data stored in them will be pe deletePostgresBranchMessage = `This action will result in the deletion of the following Lakebase branches. All data stored in them will be permanently lost:` + deletePostgresDatabaseMessage = `This action will result in the deletion of the following Lakebase databases. +All data stored in them will be permanently lost:` + deleteVectorSearchIndexMessage = `This action will result in the deletion of the following Vector Search indexes. For Delta Sync indexes, the source Delta Table is preserved but the embedding pipeline is removed. For Direct Access indexes, all upserted vectors are permanently lost:` diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index d7710c50f07..36c65a4f841 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1523,6 +1523,39 @@ } ] }, + "resources.PostgresDatabase": { + "oneOf": [ + { + "type": "object", + "properties": { + "database_id": { + "$ref": "#/$defs/string" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "parent": { + "$ref": "#/$defs/string" + }, + "postgres_database": { + "$ref": "#/$defs/string" + }, + "role": { + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "database_id", + "parent" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.PostgresEndpoint": { "oneOf": [ { @@ -2807,6 +2840,9 @@ "description": "The Postgres catalog definitions for the bundle, where each key is the name of the catalog. Each entry binds a Unity Catalog catalog to a Postgres database on a Lakebase Autoscaling branch.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresCatalog" }, + "postgres_databases": { + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresDatabase" + }, "postgres_endpoints": { "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresEndpoint" }, @@ -13016,6 +13052,20 @@ } ] }, + "resources.PostgresDatabase": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresDatabase" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.PostgresEndpoint": { "oneOf": [ { diff --git a/bundle/schema/jsonschema_for_docs.json b/bundle/schema/jsonschema_for_docs.json index 40b80a1f6ef..e61e22de80b 100644 --- a/bundle/schema/jsonschema_for_docs.json +++ b/bundle/schema/jsonschema_for_docs.json @@ -1507,6 +1507,31 @@ "postgres_database" ] }, + "resources.PostgresDatabase": { + "type": "object", + "properties": { + "database_id": { + "$ref": "#/$defs/string" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "parent": { + "$ref": "#/$defs/string" + }, + "postgres_database": { + "$ref": "#/$defs/string" + }, + "role": { + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "database_id", + "parent" + ] + }, "resources.PostgresEndpoint": { "type": "object", "properties": { @@ -2775,6 +2800,9 @@ "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresCatalog", "x-since-version": "v1.0.0" }, + "postgres_databases": { + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresDatabase" + }, "postgres_endpoints": { "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresEndpoint", "x-since-version": "v0.287.0" @@ -10904,6 +10932,12 @@ "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresCatalog" } }, + "resources.PostgresDatabase": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresDatabase" + } + }, "resources.PostgresEndpoint": { "type": "object", "additionalProperties": { diff --git a/bundle/statemgmt/state_load_test.go b/bundle/statemgmt/state_load_test.go index b6a5852901f..86d7f76f806 100644 --- a/bundle/statemgmt/state_load_test.go +++ b/bundle/statemgmt/state_load_test.go @@ -51,8 +51,9 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { "resources.postgres_branches.test_postgres_branch": {ID: "projects/test-project/branches/main"}, "resources.postgres_endpoints.test_postgres_endpoint": {ID: "projects/test-project/branches/main/endpoints/primary"}, "resources.postgres_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_databases.test_postgres_database": {ID: "projects/test-project/branches/main/databases/test-db"}, "resources.postgres_roles.test_postgres_role": {ID: "projects/test-project/branches/main/roles/test-role"}, + "resources.postgres_synced_tables.test_postgres_synced_table": {ID: "synced_tables/main.public.test_synced_table"}, "resources.vector_search_endpoints.test_vector_search_endpoint": {ID: "vs-endpoint-1"}, "resources.vector_search_indexes.test_vector_search_index": {ID: "vs-index-1"}, } @@ -129,9 +130,15 @@ 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/databases/test-db", config.Resources.PostgresDatabases["test_postgres_database"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresDatabases["test_postgres_database"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/roles/test-role", config.Resources.PostgresRoles["test_postgres_role"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresRoles["test_postgres_role"].ModifiedStatus) + assert.Equal(t, "synced_tables/main.public.test_synced_table", config.Resources.PostgresSyncedTables["test_postgres_synced_table"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresSyncedTables["test_postgres_synced_table"].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) @@ -323,10 +330,11 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { }, }, }, - PostgresSyncedTables: map[string]*resources.PostgresSyncedTable{ - "test_postgres_synced_table": { - PostgresSyncedTableConfig: resources.PostgresSyncedTableConfig{ - SyncedTableId: "main.public.test_synced_table", + PostgresDatabases: map[string]*resources.PostgresDatabase{ + "test_postgres_database": { + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "test-db", + Parent: "projects/test-project/branches/main", }, }, }, @@ -338,6 +346,13 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { }, }, }, + PostgresSyncedTables: map[string]*resources.PostgresSyncedTable{ + "test_postgres_synced_table": { + PostgresSyncedTableConfig: resources.PostgresSyncedTableConfig{ + SyncedTableId: "main.public.test_synced_table", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "test_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -433,9 +448,15 @@ 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.PostgresDatabases["test_postgres_database"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresDatabases["test_postgres_database"].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.PostgresSyncedTables["test_postgres_synced_table"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresSyncedTables["test_postgres_synced_table"].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) @@ -750,10 +771,17 @@ func TestStateToBundleModifiedResources(t *testing.T) { }, }, }, - PostgresSyncedTables: map[string]*resources.PostgresSyncedTable{ - "test_postgres_synced_table": { - PostgresSyncedTableConfig: resources.PostgresSyncedTableConfig{ - SyncedTableId: "main.public.test_synced_table", + PostgresDatabases: map[string]*resources.PostgresDatabase{ + "test_postgres_database": { + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "test-db", + Parent: "projects/test-project/branches/main", + }, + }, + "test_postgres_database_new": { + PostgresDatabaseConfig: resources.PostgresDatabaseConfig{ + DatabaseId: "new-db", + Parent: "projects/test-project-new/branches/dev", }, }, }, @@ -771,6 +799,13 @@ func TestStateToBundleModifiedResources(t *testing.T) { }, }, }, + PostgresSyncedTables: map[string]*resources.PostgresSyncedTable{ + "test_postgres_synced_table": { + PostgresSyncedTableConfig: resources.PostgresSyncedTableConfig{ + SyncedTableId: "main.public.test_synced_table", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "test_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -842,6 +877,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_databases.test_postgres_database": {ID: "projects/test-project/branches/main/databases/test-db"}, + "resources.postgres_databases.test_postgres_database_old": {ID: "projects/test-project/branches/main/databases/old-db"}, "resources.postgres_roles.test_postgres_role": {ID: "projects/test-project/branches/main/roles/primary"}, "resources.postgres_roles.test_postgres_role_old": {ID: "projects/test-project/branches/main/roles/old"}, "resources.vector_search_endpoints.test_vector_search_endpoint": {ID: "vs-endpoint-1"}, @@ -1008,6 +1045,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/databases/test-db", config.Resources.PostgresDatabases["test_postgres_database"].ID) + assert.Empty(t, config.Resources.PostgresDatabases["test_postgres_database"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/databases/old-db", config.Resources.PostgresDatabases["test_postgres_database_old"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.PostgresDatabases["test_postgres_database_old"].ModifiedStatus) + assert.Empty(t, config.Resources.PostgresDatabases["test_postgres_database_new"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.PostgresDatabases["test_postgres_database_new"].ModifiedStatus) + assert.Equal(t, "projects/test-project/branches/main/roles/primary", config.Resources.PostgresRoles["test_postgres_role"].ID) assert.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) diff --git a/bundle/terraform_dabs_map/generated.go b/bundle/terraform_dabs_map/generated.go index 596aef4b5c0..4609816632f 100644 --- a/bundle/terraform_dabs_map/generated.go +++ b/bundle/terraform_dabs_map/generated.go @@ -21,6 +21,7 @@ package terraform_dabs_map // postgres_branches / databricks_postgres_branch: 1 tf-only // postgres_branches / databricks_postgres_branch: 1 unwraps // postgres_catalogs / databricks_postgres_catalog: 1 unwraps +// postgres_databases / databricks_postgres_database: 1 unwraps // postgres_endpoints / databricks_postgres_endpoint: 1 unwraps // postgres_projects / databricks_postgres_project: 1 unwraps // postgres_roles / databricks_postgres_role: 1 unwraps @@ -64,6 +65,9 @@ var TerraformToDABsFieldMap = map[string]RenameTree{ "postgres_catalogs": { "spec": {Unwrap: true}, }, + "postgres_databases": { + "spec": {Unwrap: true}, + }, "postgres_endpoints": { "spec": {Unwrap: true}, }, @@ -604,6 +608,7 @@ var DABsToTerraformRenameMap = map[string]RenameTree{ var DABsToTerraformWrappers = map[string]string{ "postgres_branches": "spec", "postgres_catalogs": "spec", + "postgres_databases": "spec", "postgres_endpoints": "spec", "postgres_projects": "spec", "postgres_roles": "spec", diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 34671f22a2c..d2925fb1e38 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -172,10 +172,11 @@ type FakeWorkspace struct { PostgresProjects map[string]postgres.Project PostgresBranches map[string]postgres.Branch - PostgresEndpoints map[string]postgres.Endpoint PostgresCatalogs map[string]postgres.Catalog - PostgresSyncedTables map[string]postgres.SyncedTable + PostgresDatabases map[string]postgres.Database + PostgresEndpoints map[string]postgres.Endpoint PostgresRoles map[string]postgres.Role + PostgresSyncedTables map[string]postgres.SyncedTable PostgresOperations map[string]postgres.Operation // Branches and endpoints that the server provisioned implicitly together @@ -312,10 +313,11 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { SyncedDatabaseTables: map[string]database.SyncedDatabaseTable{}, PostgresProjects: map[string]postgres.Project{}, PostgresBranches: map[string]postgres.Branch{}, - PostgresEndpoints: map[string]postgres.Endpoint{}, PostgresCatalogs: map[string]postgres.Catalog{}, - PostgresSyncedTables: map[string]postgres.SyncedTable{}, + PostgresDatabases: map[string]postgres.Database{}, + PostgresEndpoints: map[string]postgres.Endpoint{}, PostgresRoles: map[string]postgres.Role{}, + PostgresSyncedTables: map[string]postgres.SyncedTable{}, 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 f05330c39ec..8f611a7c7c9 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}/databases/{database_id}/operations/{operation_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/databases/" + req.Vars["database_id"] + "/operations/" + req.Vars["operation_id"] + return req.Workspace.PostgresOperationGet(name) + }) + 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) @@ -1019,6 +1024,60 @@ func AddDefaultHandlers(server *Server) { return req.Workspace.PostgresOperationGet(name) }) + // Postgres Databases: + server.Handle("POST", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases", func(req Request) any { + parent := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + databaseID := req.URL.Query().Get("database_id") + return req.Workspace.PostgresDatabaseCreate(req, parent, databaseID) + }) + + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases", func(req Request) any { + parent := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + return req.Workspace.PostgresDatabaseList(parent) + }) + + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases/{database_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/databases/" + req.Vars["database_id"] + return req.Workspace.PostgresDatabaseGet(name) + }) + + server.Handle("PATCH", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases/{database_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/databases/" + req.Vars["database_id"] + return req.Workspace.PostgresDatabaseUpdate(req, name) + }) + + server.Handle("DELETE", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/databases/{database_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/databases/" + req.Vars["database_id"] + return req.Workspace.PostgresDatabaseDelete(name) + }) + + // 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) + }) + // Postgres Synced Tables: server.Handle("POST", "/api/2.0/postgres/synced_tables", func(req Request) any { syncedTableID := req.URL.Query().Get("synced_table_id") diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index e046c0867e9..effd4b356dd 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -715,6 +715,212 @@ func (s *FakeWorkspace) PostgresEndpointDelete(name string) Response { } } +// PostgresDatabaseCreate creates a new postgres database. +func (s *FakeWorkspace) PostgresDatabaseCreate(req Request, parent, databaseID string) Response { + defer s.LockUnlock()() + + if databaseID == "" { + return postgresErrorResponse(400, "INVALID_PARAMETER_VALUE", `Field 'database_id' is required, expected non-default value (not "")!`) + } + + // Check if parent branch exists + if _, exists := s.PostgresBranches[parent]; !exists { + return postgresNotFoundResponse("branch") + } + + var database postgres.Database + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &database); err != nil { + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("cannot unmarshal request body: %v", err), + } + } + } + + // The real Lakebase API requires the owning role on create and rejects an empty + // one with this exact error (verified on e2-dogfood 2026-06-16). The fake does + // not synthesize a default, matching that behavior. + if database.Spec == nil || database.Spec.Role == "" { + return postgresErrorResponse(400, "INVALID_PARAMETER_VALUE", "Field 'spec.role' cannot be empty") + } + + name := fmt.Sprintf("%s/databases/%s", parent, databaseID) + + if _, exists := s.PostgresDatabases[name]; exists { + // The real Lakebase API returns 400 BAD_REQUEST (not 409) for a duplicate + // create, the same as postgres_roles. Match it so the conflict a bundle hits + // on a pre-existing database looks the same. + return postgresErrorResponse(400, "BAD_REQUEST", "database with that name already exists") + } + + now := nowTime() + database.Name = name + database.Parent = parent + database.CreateTime = now + database.UpdateTime = now + + // Mirror spec onto status; the real API only echoes Status on GET. + status := &postgres.DatabaseDatabaseStatus{ + DatabaseId: databaseID, + PostgresDatabase: database.Spec.PostgresDatabase, + Role: database.Spec.Role, + } + database.Status = status + database.Spec = nil + + s.PostgresDatabases[name] = database + + return Response{ + Body: s.createOperationLocked(database.Name, database), + } +} + +// PostgresDatabaseGet retrieves a postgres database by name. +func (s *FakeWorkspace) PostgresDatabaseGet(name string) Response { + defer s.LockUnlock()() + + // Extract project and branch names from database name + // Format: projects/{project}/branches/{branch}/databases/{database} + parts := strings.Split(name, "/branches/") + if len(parts) == 2 { + projectName := parts[0] + if _, exists := s.PostgresProjects[projectName]; !exists { + return postgresNotFoundResponse("project") + } + branchParts := strings.Split(parts[1], "/databases/") + if len(branchParts) == 2 { + branchName := projectName + "/branches/" + branchParts[0] + if _, exists := s.PostgresBranches[branchName]; !exists { + return postgresNotFoundResponse("branch") + } + } + } + + database, exists := s.PostgresDatabases[name] + if !exists { + return postgresNotFoundResponse("database") + } + + return Response{ + Body: database, + } +} + +// PostgresDatabaseList lists all postgres databases for a branch. +func (s *FakeWorkspace) PostgresDatabaseList(parent string) Response { + defer s.LockUnlock()() + + if _, exists := s.PostgresBranches[parent]; !exists { + return postgresNotFoundResponse("branch") + } + + var databases []postgres.Database + prefix := parent + "/databases/" + for name, d := range s.PostgresDatabases { + if strings.HasPrefix(name, prefix) { + databases = append(databases, d) + } + } + + return Response{ + Body: postgres.ListDatabasesResponse{ + Databases: databases, + }, + } +} + +// PostgresDatabaseUpdate updates a postgres database. +func (s *FakeWorkspace) PostgresDatabaseUpdate(req Request, name string) Response { + defer s.LockUnlock()() + + database, exists := s.PostgresDatabases[name] + if !exists { + return postgresNotFoundResponse("database") + } + + var updateDatabase postgres.Database + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &updateDatabase); err != nil { + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("cannot unmarshal request body: %v", err), + } + } + } + + if updateDatabase.Spec != nil { + // Preserve database_id which is derived from the resource name. + databaseID := "" + if database.Status != nil { + databaseID = database.Status.DatabaseId + } + var paths []string + if mask := req.URL.Query().Get("update_mask"); mask != "" { + paths = strings.Split(mask, ",") + } + database.Status = applyDatabaseSpecMask(database.Status, databaseStatusFromSpec(updateDatabase.Spec), paths) + database.Status.DatabaseId = databaseID + } + + database.UpdateTime = nowTime() + s.PostgresDatabases[name] = database + + return Response{ + Body: s.createOperationLocked(database.Name, database), + } +} + +// databaseStatusFromSpec mirrors the real Postgres Database server's behavior of +// echoing the spec onto Status while leaving Spec=nil on GET responses. +func databaseStatusFromSpec(spec *postgres.DatabaseDatabaseSpec) *postgres.DatabaseDatabaseStatus { + status := &postgres.DatabaseDatabaseStatus{} + if spec == nil { + return status + } + status.PostgresDatabase = spec.PostgresDatabase + status.Role = spec.Role + return status +} + +// applyDatabaseSpecMask applies the fields named in paths (the update_mask) from +// desired onto existing. Paths are relative to the Database and "spec."-prefixed; +// the bare path "spec" replaces the whole subtree, and an empty paths slice +// replaces everything. Same shape as applyRoleSpecMask. +func applyDatabaseSpecMask(existing, desired *postgres.DatabaseDatabaseStatus, paths []string) *postgres.DatabaseDatabaseStatus { + 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_database": + result.PostgresDatabase = desired.PostgresDatabase + case "role": + result.Role = desired.Role + } + } + return &result +} + +// PostgresDatabaseDelete deletes a postgres database. +func (s *FakeWorkspace) PostgresDatabaseDelete(name string) Response { + defer s.LockUnlock()() + + if _, exists := s.PostgresDatabases[name]; !exists { + return postgresNotFoundResponse("database") + } + + delete(s.PostgresDatabases, name) + + return Response{ + Body: s.createOperationLocked(name, nil), + } +} + // PostgresCatalogCreate creates a new postgres catalog. func (s *FakeWorkspace) PostgresCatalogCreate(req Request, catalogID string) Response { defer s.LockUnlock()() @@ -1059,8 +1265,8 @@ func (s *FakeWorkspace) createOperationLocked(resourceName string, response any) operationName := resourceName + "/operations/" + operationID // Determine resource type from name for metadata @type. - // Check the more specific suffixes first since role/endpoint names also - // contain "/branches/". + // Check the more specific suffixes first since database/role/endpoint names + // also contain "/branches/". resourceType := "Project" switch { case strings.HasPrefix(resourceName, "catalogs/"): @@ -1069,6 +1275,8 @@ func (s *FakeWorkspace) createOperationLocked(resourceName string, response any) resourceType = "SyncedTable" case strings.Contains(resourceName, "/endpoints/"): resourceType = "Endpoint" + case strings.Contains(resourceName, "/databases/"): + resourceType = "Database" case strings.Contains(resourceName, "/roles/"): resourceType = "Role" case strings.Contains(resourceName, "/branches/"): diff --git a/libs/testserver/postgres_test.go b/libs/testserver/postgres_test.go index 85264369bf3..1a414cf6762 100644 --- a/libs/testserver/postgres_test.go +++ b/libs/testserver/postgres_test.go @@ -251,6 +251,195 @@ func TestPostgresEndpointCRUD(t *testing.T) { deleteEpResp.Body.Close() } +func TestPostgresDatabaseCRUD(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + + client := &http.Client{} + baseURL := server.URL + + // Create project first + createProjReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects?project_id=database-test-project", nil) + createProjReq.Header.Set("Authorization", "Bearer test-token") + createProjResp, err := client.Do(createProjReq) + require.NoError(t, err) + assert.Equal(t, 200, createProjResp.StatusCode) + createProjResp.Body.Close() + + // Create branch + createBranchReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/database-test-project/branches?branch_id=main", nil) + createBranchReq.Header.Set("Authorization", "Bearer test-token") + createBranchResp, err := client.Do(createBranchReq) + require.NoError(t, err) + assert.Equal(t, 200, createBranchResp.StatusCode) + createBranchResp.Body.Close() + + // Create database + createDbBody := `{"spec":{"postgres_database":"my_db","role":"projects/database-test-project/branches/main/roles/owner"}}` + createDbReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases?database_id=my-db", strings.NewReader(createDbBody)) + createDbReq.Header.Set("Authorization", "Bearer test-token") + createDbReq.Header.Set("Content-Type", "application/json") + createDbResp, err := client.Do(createDbReq) + require.NoError(t, err) + assert.Equal(t, 200, createDbResp.StatusCode) + createDbResp.Body.Close() + + // Get database + getDbReq, _ := http.NewRequest(http.MethodGet, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases/my-db", nil) + getDbReq.Header.Set("Authorization", "Bearer test-token") + getDbResp, err := client.Do(getDbReq) + require.NoError(t, err) + assert.Equal(t, 200, getDbResp.StatusCode) + + var database postgres.Database + require.NoError(t, json.NewDecoder(getDbResp.Body).Decode(&database)) + assert.Equal(t, "projects/database-test-project/branches/main/databases/my-db", database.Name) + assert.Equal(t, "projects/database-test-project/branches/main", database.Parent) + require.NotNil(t, database.Status) + assert.Equal(t, "my_db", database.Status.PostgresDatabase) + assert.NotEmpty(t, database.Status.Role) + getDbResp.Body.Close() + + // List databases + listDbReq, _ := http.NewRequest(http.MethodGet, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases", nil) + listDbReq.Header.Set("Authorization", "Bearer test-token") + listDbResp, err := client.Do(listDbReq) + require.NoError(t, err) + assert.Equal(t, 200, listDbResp.StatusCode) + + var listDatabases postgres.ListDatabasesResponse + require.NoError(t, json.NewDecoder(listDbResp.Body).Decode(&listDatabases)) + assert.Len(t, listDatabases.Databases, 1) + listDbResp.Body.Close() + + // Update database (rename via spec.postgres_database) + updateDbBody := `{"spec":{"postgres_database":"my_db_renamed"}}` + updateDbReq, _ := http.NewRequest(http.MethodPatch, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases/my-db?update_mask=spec.postgres_database", strings.NewReader(updateDbBody)) + updateDbReq.Header.Set("Authorization", "Bearer test-token") + updateDbReq.Header.Set("Content-Type", "application/json") + updateDbResp, err := client.Do(updateDbReq) + require.NoError(t, err) + assert.Equal(t, 200, updateDbResp.StatusCode) + updateDbResp.Body.Close() + + // Verify rename was applied + getDbReq2, _ := http.NewRequest(http.MethodGet, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases/my-db", nil) + getDbReq2.Header.Set("Authorization", "Bearer test-token") + getDbResp2, err := client.Do(getDbReq2) + require.NoError(t, err) + assert.Equal(t, 200, getDbResp2.StatusCode) + var database2 postgres.Database + require.NoError(t, json.NewDecoder(getDbResp2.Body).Decode(&database2)) + require.NotNil(t, database2.Status) + assert.Equal(t, "my_db_renamed", database2.Status.PostgresDatabase) + getDbResp2.Body.Close() + + // Delete database + deleteDbReq, _ := http.NewRequest(http.MethodDelete, baseURL+"/api/2.0/postgres/projects/database-test-project/branches/main/databases/my-db", nil) + deleteDbReq.Header.Set("Authorization", "Bearer test-token") + deleteDbResp, err := client.Do(deleteDbReq) + require.NoError(t, err) + assert.Equal(t, 200, deleteDbResp.StatusCode) + deleteDbResp.Body.Close() +} + +func TestPostgresDatabaseNotFoundWhenBranchNotExists(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + + client := &http.Client{} + baseURL := server.URL + + // Create project first + createProjReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects?project_id=db-test-project", nil) + createProjReq.Header.Set("Authorization", "Bearer test-token") + createProjResp, err := client.Do(createProjReq) + require.NoError(t, err) + assert.Equal(t, 200, createProjResp.StatusCode) + createProjResp.Body.Close() + + // Try to create database without branch + createDbReq, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/postgres/projects/db-test-project/branches/nonexistent/databases?database_id=my-db", nil) + createDbReq.Header.Set("Authorization", "Bearer test-token") + createDbResp, err := client.Do(createDbReq) + require.NoError(t, err) + assert.Equal(t, 404, createDbResp.StatusCode) + createDbResp.Body.Close() +} + +func TestPostgresDatabaseUpdateMaskPreservesUnmaskedFields(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-db-project", "").Body.Close() + do(http.MethodPost, "/api/2.0/postgres/projects/mask-db-project/branches?branch_id=main", "").Body.Close() + + createBody := `{"spec":{"postgres_database":"app_db","role":"projects/mask-db-project/branches/main/roles/owner"}}` + createResp := do(http.MethodPost, "/api/2.0/postgres/projects/mask-db-project/branches/main/databases?database_id=appdb", createBody) + require.Equal(t, 200, createResp.StatusCode) + createResp.Body.Close() + + // Update only spec.postgres_database. The body also carries a different role, + // which must be ignored because it is not named in update_mask. + patchBody := `{"spec":{"postgres_database":"renamed_db","role":"projects/mask-db-project/branches/main/roles/other"}}` + patchResp := do(http.MethodPatch, "/api/2.0/postgres/projects/mask-db-project/branches/main/databases/appdb?update_mask=spec.postgres_database", patchBody) + assert.Equal(t, 200, patchResp.StatusCode) + patchResp.Body.Close() + + getResp := do(http.MethodGet, "/api/2.0/postgres/projects/mask-db-project/branches/main/databases/appdb", "") + var database postgres.Database + require.NoError(t, json.NewDecoder(getResp.Body).Decode(&database)) + getResp.Body.Close() + + require.NotNil(t, database.Status) + assert.Equal(t, "renamed_db", database.Status.PostgresDatabase, "masked field should be updated") + assert.Equal(t, "projects/mask-db-project/branches/main/roles/owner", database.Status.Role, "field absent from update_mask should be preserved") +} + +func TestPostgresDatabaseCreateDuplicateReturns400(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-db-project", "").Body.Close() + do(http.MethodPost, "/api/2.0/postgres/projects/dup-db-project/branches?branch_id=main", "").Body.Close() + + createBody := `{"spec":{"postgres_database":"app_db","role":"projects/dup-db-project/branches/main/roles/owner"}}` + first := do(http.MethodPost, "/api/2.0/postgres/projects/dup-db-project/branches/main/databases?database_id=appdb", createBody) + require.Equal(t, 200, first.StatusCode) + first.Body.Close() + + // Creating the same database again fails the way the real API does: 400, not 409. + second := do(http.MethodPost, "/api/2.0/postgres/projects/dup-db-project/branches/main/databases?database_id=appdb", createBody) + assert.Equal(t, 400, second.StatusCode) + second.Body.Close() +} + func TestPostgresEndpointNotFoundWhenBranchNotExists(t *testing.T) { server := testserver.New(t) testserver.AddDefaultHandlers(server)