From 468ea806078058ea5eee1408c5229e440d36e088 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 16:10:14 +0200 Subject: [PATCH 1/7] postgres: Support replace_existing on branches and endpoints Lakebase auto-provisions a "production" branch on every project and a "primary" read-write endpoint on every branch. Without replace_existing, declaring these in a bundle returns 409 ALREADY_EXISTS and the user has no way to bring them under management. Wires the replace_existing field through PostgresBranchConfig / PostgresEndpointConfig and the direct + terraform engines, marks it as input-only (the GET API never returns it; it cannot be updated after create), and adds two focused acceptance tests that each prove a default setting is overridden: * postgres_branches/replace_existing: flip is_protected on the implicit production branch from false (default) to true. * postgres_endpoints/replace_existing: override suspend_timeout_duration on the implicit primary endpoint of a managed branch from the project-inherited 300s to 600s. Aligns the in-memory testserver: every branch (default and user-created) now implicitly provisions a primary RW endpoint, and replace_existing=true on CreateBranch / CreateEndpoint updates in place instead of returning 409. Both tests pass on aws-prod-ucws and locally on both engines. Co-authored-by: Isaac --- .../replace_existing/databricks.yml.tmpl | 23 ++ .../replace_existing/out.requests.direct.json | 31 +++ .../out.requests.terraform.json | 32 +++ .../replace_existing/out.test.toml | 6 + .../replace_existing/output.txt | 31 +++ .../postgres_branches/replace_existing/script | 20 ++ .../replace_existing/test.toml | 1 + .../replace_existing/databricks.yml.tmpl | 43 ++++ .../replace_existing/out.requests.direct.json | 55 +++++ .../out.requests.terraform.json | 53 +++++ .../replace_existing/out.test.toml | 6 + .../replace_existing/output.txt | 42 ++++ .../replace_existing/script | 21 ++ .../replace_existing/test.toml | 1 + bundle/config/resources/postgres_branch.go | 5 + bundle/config/resources/postgres_endpoint.go | 5 + .../tfdyn/convert_postgres_branch.go | 2 +- .../tfdyn/convert_postgres_endpoint.go | 2 +- bundle/direct/dresources/postgres_branch.go | 13 +- bundle/direct/dresources/postgres_endpoint.go | 13 +- bundle/direct/dresources/resources.yml | 16 ++ bundle/direct/dresources/type_test.go | 2 + bundle/internal/schema/annotations.yml | 6 + bundle/schema/jsonschema.json | 6 + libs/testserver/handlers.go | 6 +- libs/testserver/postgres.go | 224 ++++++++++++------ libs/testserver/postgres_test.go | 4 +- 27 files changed, 584 insertions(+), 85 deletions(-) create mode 100644 acceptance/bundle/resources/postgres_branches/replace_existing/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.direct.json create mode 100644 acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.terraform.json create mode 100644 acceptance/bundle/resources/postgres_branches/replace_existing/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_branches/replace_existing/output.txt create mode 100644 acceptance/bundle/resources/postgres_branches/replace_existing/script create mode 100644 acceptance/bundle/resources/postgres_branches/replace_existing/test.toml create mode 100644 acceptance/bundle/resources/postgres_endpoints/replace_existing/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.direct.json create mode 100644 acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.terraform.json create mode 100644 acceptance/bundle/resources/postgres_endpoints/replace_existing/out.test.toml create mode 100644 acceptance/bundle/resources/postgres_endpoints/replace_existing/output.txt create mode 100644 acceptance/bundle/resources/postgres_endpoints/replace_existing/script create mode 100644 acceptance/bundle/resources/postgres_endpoints/replace_existing/test.toml diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_branches/replace_existing/databricks.yml.tmpl new file mode 100644 index 00000000000..17484af8060 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/databricks.yml.tmpl @@ -0,0 +1,23 @@ +bundle: + name: deploy-postgres-replace-branch-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Replace existing branch test" + pg_version: 16 + history_retention_duration: "604800s" + + # Take over the implicitly-created production branch and flip is_protected + # from its default value (false) to true. A successful deploy with the new + # value reflected in get-branch proves replace_existing applied the spec. + postgres_branches: + production: + parent: ${resources.postgres_projects.my_project.id} + branch_id: production + replace_existing: true + is_protected: true diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.direct.json b/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.direct.json new file mode 100644 index 00000000000..21cb677c1ec --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.direct.json @@ -0,0 +1,31 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Replace existing branch test", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "production", + "replace_existing": "true" + }, + "body": { + "spec": { + "is_protected": true + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/production" +} diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.terraform.json b/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.terraform.json new file mode 100644 index 00000000000..864dd230261 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.terraform.json @@ -0,0 +1,32 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Replace existing branch test", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "production", + "replace_existing": "true" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "spec": { + "is_protected": true + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/production" +} diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/out.test.toml b/acceptance/bundle/resources/postgres_branches/replace_existing/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/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_branches/replace_existing/output.txt b/acceptance/bundle/resources/postgres_branches/replace_existing/output.txt new file mode 100644 index 00000000000..b31122f697c --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/output.txt @@ -0,0 +1,31 @@ + +>>> [CLI] bundle validate +Name: deploy-postgres-replace-branch-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-replace-branch-[UNIQUE_NAME]/default + +Validation OK! + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-replace-branch-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] postgres get-branch projects/test-pg-proj-[UNIQUE_NAME]/branches/production +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/production", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "status": { + "branch_id": "production", + "current_state": "READY", + "default": true, + "is_protected": true, + "state_change_time": "[TIMESTAMP]" + }, + "uid": "[BRANCH_UID]" +} + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/script b/acceptance/bundle/resources/postgres_branches/replace_existing/script new file mode 100644 index 00000000000..03a3fbfb45c --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/script @@ -0,0 +1,20 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + # The production branch cannot be deleted independently of its project, so + # destroy by cascading from the project. + $CLI postgres delete-project "projects/test-pg-proj-${UNIQUE_NAME}" 2>/dev/null || true + rm -f out.requests.txt +} +trap cleanup EXIT + +trace $CLI bundle validate + +rm -f out.requests.txt +trace $CLI bundle deploy + +# The production branch defaults to is_protected=false. After deploy with +# replace_existing=true, the bundle's spec must be applied: is_protected=true. +trace $CLI postgres get-branch "projects/test-pg-proj-${UNIQUE_NAME}/branches/production" | jq 'del(.create_time, .update_time, .status.logical_size_bytes)' + +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_branches/replace_existing/test.toml b/acceptance/bundle/resources/postgres_branches/replace_existing/test.toml new file mode 100644 index 00000000000..f8b3bbe49dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/test.toml @@ -0,0 +1 @@ +# All configuration inherited from parent test.toml diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_endpoints/replace_existing/databricks.yml.tmpl new file mode 100644 index 00000000000..220d7b0077e --- /dev/null +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/databricks.yml.tmpl @@ -0,0 +1,43 @@ +bundle: + name: deploy-postgres-replace-endpoint-$UNIQUE_NAME + +sync: + paths: [] + +resources: + # The project's default_endpoint_settings are inherited by every implicit + # primary endpoint. We set suspend_timeout_duration to 300s here so the + # primary endpoint of the "develop" branch below would otherwise come up + # with 300s. + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Replace existing endpoint test" + pg_version: 16 + history_retention_duration: "604800s" + default_endpoint_settings: + autoscaling_limit_min_cu: 0.5 + autoscaling_limit_max_cu: 4 + suspend_timeout_duration: "300s" + + # Non-default branch managed normally — the server auto-provisions its + # primary read-write endpoint with the project defaults above. + postgres_branches: + develop: + parent: ${resources.postgres_projects.my_project.id} + branch_id: develop + no_expiry: true + + # Take over the implicitly-created primary endpoint of the develop branch + # and override the inherited suspend_timeout_duration (300s) with 600s. A + # successful deploy with 600s reflected in get-endpoint proves + # replace_existing applied the spec. + postgres_endpoints: + primary: + parent: ${resources.postgres_branches.develop.id} + endpoint_id: primary + replace_existing: true + endpoint_type: ENDPOINT_TYPE_READ_WRITE + autoscaling_limit_min_cu: 0.5 + autoscaling_limit_max_cu: 4 + suspend_timeout_duration: "600s" diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.direct.json b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.direct.json new file mode 100644 index 00000000000..28e0d2433a9 --- /dev/null +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/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": { + "default_endpoint_settings": { + "autoscaling_limit_max_cu": 4, + "autoscaling_limit_min_cu": 0.5, + "suspend_timeout_duration": "300s" + }, + "display_name": "Replace existing endpoint test", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "develop" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints", + "q": { + "endpoint_id": "primary", + "replace_existing": "true" + }, + "body": { + "spec": { + "autoscaling_limit_max_cu": 4, + "autoscaling_limit_min_cu": 0.5, + "endpoint_type": "ENDPOINT_TYPE_READ_WRITE", + "suspend_timeout_duration": "600s" + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints/primary" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints/primary" +} diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.terraform.json b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.terraform.json new file mode 100644 index 00000000000..6ab68fc5dd9 --- /dev/null +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.terraform.json @@ -0,0 +1,53 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "default_endpoint_settings": { + "autoscaling_limit_max_cu": 4, + "autoscaling_limit_min_cu": 0.5, + "suspend_timeout_duration": "300s" + }, + "display_name": "Replace existing endpoint test", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "develop" + }, + "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/develop/endpoints", + "q": { + "endpoint_id": "primary", + "replace_existing": "true" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/develop", + "spec": { + "autoscaling_limit_max_cu": 4, + "autoscaling_limit_min_cu": 0.5, + "endpoint_type": "ENDPOINT_TYPE_READ_WRITE", + "suspend_timeout_duration": "600s" + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints/primary" +} diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.test.toml b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/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_endpoints/replace_existing/output.txt b/acceptance/bundle/resources/postgres_endpoints/replace_existing/output.txt new file mode 100644 index 00000000000..dce26f233ee --- /dev/null +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/output.txt @@ -0,0 +1,42 @@ + +>>> [CLI] bundle validate +Name: deploy-postgres-replace-endpoint-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-replace-endpoint-[UNIQUE_NAME]/default + +Validation OK! + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-replace-endpoint-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] postgres get-endpoint projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints/primary +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints/primary", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/develop", + "status": { + "autoscaling_limit_max_cu": 4, + "autoscaling_limit_min_cu": 0.5, + "current_state": "ACTIVE", + "disabled": false, + "endpoint_id": "primary", + "endpoint_type": "ENDPOINT_TYPE_READ_WRITE", + "group": { + "enable_readable_secondaries": false, + "max": 1, + "min": 1 + }, + "hosts": { + "host": "[ENDPOINT_UID].database.us-east-1.cloud.databricks.com" + }, + "settings": {}, + "suspend_timeout_duration": "600s" + }, + "uid": "[ENDPOINT_UID]" +} + +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/script b/acceptance/bundle/resources/postgres_endpoints/replace_existing/script new file mode 100644 index 00000000000..53f5d1db311 --- /dev/null +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/script @@ -0,0 +1,21 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + # The primary read-write endpoint cannot be deleted independently of its + # branch, so destroy by cascading from the project. + $CLI postgres delete-project "projects/test-pg-proj-${UNIQUE_NAME}" 2>/dev/null || true + rm -f out.requests.txt +} +trap cleanup EXIT + +trace $CLI bundle validate + +rm -f out.requests.txt +trace $CLI bundle deploy + +# The implicit primary endpoint inherits suspend_timeout_duration from the +# project default (300s). After deploy with replace_existing=true, the +# bundle's spec must be applied: 600s. +trace $CLI postgres get-endpoint "projects/test-pg-proj-${UNIQUE_NAME}/branches/develop/endpoints/primary" | jq 'del(.create_time, .update_time)' + +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_endpoints/replace_existing/test.toml b/acceptance/bundle/resources/postgres_endpoints/replace_existing/test.toml new file mode 100644 index 00000000000..f8b3bbe49dd --- /dev/null +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/test.toml @@ -0,0 +1 @@ +# All configuration inherited from parent test.toml diff --git a/bundle/config/resources/postgres_branch.go b/bundle/config/resources/postgres_branch.go index dd6e9daacdf..a5d093ca194 100644 --- a/bundle/config/resources/postgres_branch.go +++ b/bundle/config/resources/postgres_branch.go @@ -19,6 +19,11 @@ type PostgresBranchConfig struct { // Parent is the project containing this branch. Format: "projects/{project_id}" Parent string `json:"parent"` + + // ReplaceExisting, when true, takes over an existing branch with the same ID + // instead of returning ALREADY_EXISTS. Used to manage the implicitly-created + // production branch of a new project. Input-only: not returned by the GET API. + ReplaceExisting bool `json:"replace_existing,omitempty"` } func (c *PostgresBranchConfig) UnmarshalJSON(b []byte) error { diff --git a/bundle/config/resources/postgres_endpoint.go b/bundle/config/resources/postgres_endpoint.go index 6dfe4fe7f33..1a9ce34665c 100644 --- a/bundle/config/resources/postgres_endpoint.go +++ b/bundle/config/resources/postgres_endpoint.go @@ -19,6 +19,11 @@ type PostgresEndpointConfig struct { // Parent is the branch containing this endpoint. Format: "projects/{project_id}/branches/{branch_id}" Parent string `json:"parent"` + + // ReplaceExisting, when true, takes over an existing endpoint with the same ID + // instead of returning ALREADY_EXISTS. Used to manage the implicitly-created + // primary read-write endpoint of a new branch. Input-only: not returned by the GET API. + ReplaceExisting bool `json:"replace_existing,omitempty"` } func (c *PostgresEndpointConfig) UnmarshalJSON(b []byte) error { diff --git a/bundle/deploy/terraform/tfdyn/convert_postgres_branch.go b/bundle/deploy/terraform/tfdyn/convert_postgres_branch.go index c1d598a1e64..12914e93074 100644 --- a/bundle/deploy/terraform/tfdyn/convert_postgres_branch.go +++ b/bundle/deploy/terraform/tfdyn/convert_postgres_branch.go @@ -15,7 +15,7 @@ func (c postgresBranchConverter) Convert(ctx context.Context, key string, vin dy // The bundle config has flattened BranchSpec fields at the top level. // Terraform expects them nested in a "spec" block. specFields := specFieldNames(schema.ResourcePostgresBranchSpec{}) - topLevelFields := []string{"branch_id", "parent"} + topLevelFields := []string{"branch_id", "parent", "replace_existing"} // Build the spec block from the flattened fields specMap := make(map[string]dyn.Value) diff --git a/bundle/deploy/terraform/tfdyn/convert_postgres_endpoint.go b/bundle/deploy/terraform/tfdyn/convert_postgres_endpoint.go index f97582a2a33..b603abfcb75 100644 --- a/bundle/deploy/terraform/tfdyn/convert_postgres_endpoint.go +++ b/bundle/deploy/terraform/tfdyn/convert_postgres_endpoint.go @@ -15,7 +15,7 @@ func (c postgresEndpointConverter) Convert(ctx context.Context, key string, vin // The bundle config has flattened EndpointSpec fields at the top level. // Terraform expects them nested in a "spec" block. specFields := specFieldNames(schema.ResourcePostgresEndpointSpec{}) - topLevelFields := []string{"endpoint_id", "parent"} + topLevelFields := []string{"endpoint_id", "parent", "replace_existing"} // Build the spec block from the flattened fields specMap := make(map[string]dyn.Value) diff --git a/bundle/direct/dresources/postgres_branch.go b/bundle/direct/dresources/postgres_branch.go index 19aa77ce7f1..be8833acc95 100644 --- a/bundle/direct/dresources/postgres_branch.go +++ b/bundle/direct/dresources/postgres_branch.go @@ -21,9 +21,10 @@ func (*ResourcePostgresBranch) New(client *databricks.WorkspaceClient) *Resource func (*ResourcePostgresBranch) PrepareState(input *resources.PostgresBranch) *PostgresBranchState { return &PostgresBranchState{ - BranchId: input.BranchId, - Parent: input.Parent, - BranchSpec: input.BranchSpec, + BranchId: input.BranchId, + Parent: input.Parent, + ReplaceExisting: input.ReplaceExisting, + BranchSpec: input.BranchSpec, } } @@ -36,6 +37,10 @@ func (*ResourcePostgresBranch) RemapState(remote *postgres.Branch) *PostgresBran BranchId: components.BranchID, Parent: remote.Parent, + // replace_existing is a create-time-only flag; the GET API never returns + // it, so RemapState leaves it false. + ReplaceExisting: false, + // The read API does not return the spec, only the status. // This means we cannot detect remote drift for spec fields. // Use an empty struct (not nil) so field-level diffing works correctly. @@ -72,7 +77,7 @@ func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, config *PostgresB UpdateTime: nil, ForceSendFields: nil, }, - ReplaceExisting: false, + ReplaceExisting: config.ReplaceExisting, ForceSendFields: nil, }) if err != nil { diff --git a/bundle/direct/dresources/postgres_endpoint.go b/bundle/direct/dresources/postgres_endpoint.go index 9ccec27f327..14698d7de2b 100644 --- a/bundle/direct/dresources/postgres_endpoint.go +++ b/bundle/direct/dresources/postgres_endpoint.go @@ -30,9 +30,10 @@ func (*ResourcePostgresEndpoint) New(client *databricks.WorkspaceClient) *Resour func (*ResourcePostgresEndpoint) PrepareState(input *resources.PostgresEndpoint) *PostgresEndpointState { return &PostgresEndpointState{ - EndpointId: input.EndpointId, - Parent: input.Parent, - EndpointSpec: input.EndpointSpec, + EndpointId: input.EndpointId, + Parent: input.Parent, + ReplaceExisting: input.ReplaceExisting, + EndpointSpec: input.EndpointSpec, } } @@ -45,6 +46,10 @@ func (*ResourcePostgresEndpoint) RemapState(remote *postgres.Endpoint) *Postgres EndpointId: components.EndpointID, Parent: remote.Parent, + // replace_existing is a create-time-only flag; the GET API never returns + // it, so RemapState leaves it false. + ReplaceExisting: false, + // The read API does not return the spec, only the status. // This means we cannot detect remote drift for spec fields. // Use an empty struct (not nil) so field-level diffing works correctly. @@ -112,7 +117,7 @@ func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, config *Postgre UpdateTime: nil, ForceSendFields: nil, }, - ReplaceExisting: false, + ReplaceExisting: config.ReplaceExisting, ForceSendFields: nil, }) if err != nil { diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 569fca9ee82..4eb5933ad25 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -499,6 +499,14 @@ resources: reason: immutable - field: branch_id reason: immutable + ignore_remote_changes: + # replace_existing is a create-time query parameter; not returned by GET. + - field: replace_existing + reason: input_only + ignore_local_changes: + # replace_existing only takes effect on create; toggling it later is a no-op. + - field: replace_existing + reason: "input_only; cannot be updated after create" postgres_endpoints: recreate_on_changes: @@ -507,6 +515,14 @@ resources: reason: immutable - field: endpoint_id reason: immutable + ignore_remote_changes: + # replace_existing is a create-time query parameter; not returned by GET. + - field: replace_existing + reason: input_only + ignore_local_changes: + # replace_existing only takes effect on create; toggling it later is a no-op. + - field: replace_existing + reason: "input_only; cannot be updated after create" vector_search_endpoints: recreate_on_changes: diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 88f246723bf..d666b966f04 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -48,6 +48,7 @@ var knownMissingInRemoteType = map[string][]string{ "expire_time", "is_protected", "no_expiry", + "replace_existing", "source_branch", "source_branch_lsn", "source_branch_time", @@ -61,6 +62,7 @@ var knownMissingInRemoteType = map[string][]string{ "endpoint_type", "group", "no_suspension", + "replace_existing", "settings", "suspend_timeout_duration", }, diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index f6ac5c45d4d..f15766a0d3b 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -769,6 +769,9 @@ github.com/databricks/cli/bundle/config/resources.PostgresBranch: "parent": "description": |- PLACEHOLDER + "replace_existing": + "description": |- + PLACEHOLDER "source_branch": "description": |- PLACEHOLDER @@ -827,6 +830,9 @@ github.com/databricks/cli/bundle/config/resources.PostgresEndpoint: "parent": "description": |- PLACEHOLDER + "replace_existing": + "description": |- + PLACEHOLDER "settings": "description": |- PLACEHOLDER diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 26e773f64a4..ac57cedbe53 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1384,6 +1384,9 @@ "parent": { "$ref": "#/$defs/string" }, + "replace_existing": { + "$ref": "#/$defs/bool" + }, "source_branch": { "$ref": "#/$defs/string" }, @@ -1441,6 +1444,9 @@ "parent": { "$ref": "#/$defs/string" }, + "replace_existing": { + "$ref": "#/$defs/bool" + }, "settings": { "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.EndpointSettings" }, diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index d98011fc7ba..8a1188a79ec 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -886,7 +886,8 @@ func AddDefaultHandlers(server *Server) { server.Handle("POST", "/api/2.0/postgres/projects/{project_id}/branches", func(req Request) any { parent := "projects/" + req.Vars["project_id"] branchID := req.URL.Query().Get("branch_id") - return req.Workspace.PostgresBranchCreate(req, parent, branchID) + replaceExisting := req.URL.Query().Get("replace_existing") == "true" + return req.Workspace.PostgresBranchCreate(req, parent, branchID, replaceExisting) }) server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches", func(req Request) any { @@ -913,7 +914,8 @@ func AddDefaultHandlers(server *Server) { server.Handle("POST", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/endpoints", func(req Request) any { parent := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] endpointID := req.URL.Query().Get("endpoint_id") - return req.Workspace.PostgresEndpointCreate(req, parent, endpointID) + replaceExisting := req.URL.Query().Get("replace_existing") == "true" + return req.Workspace.PostgresEndpointCreate(req, parent, endpointID, replaceExisting) }) server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/endpoints", func(req Request) any { diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index f3a488b5704..973adb3cfb0 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -200,7 +200,11 @@ func (s *FakeWorkspace) PostgresProjectDelete(name string) Response { } // PostgresBranchCreate creates a new postgres branch. -func (s *FakeWorkspace) PostgresBranchCreate(req Request, parent, branchID string) Response { +// +// When replaceExisting is true, an existing branch with the same ID is updated +// in place instead of returning ALREADY_EXISTS. This mirrors the backend behavior +// that lets users bring the implicitly-created production branch under management. +func (s *FakeWorkspace) PostgresBranchCreate(req Request, parent, branchID string, replaceExisting bool) Response { defer s.LockUnlock()() // Validate that branch_id is provided @@ -225,44 +229,51 @@ func (s *FakeWorkspace) PostgresBranchCreate(req Request, parent, branchID strin name := fmt.Sprintf("%s/branches/%s", parent, branchID) - // Check for duplicate - if _, exists := s.PostgresBranches[name]; exists { + existing, exists := s.PostgresBranches[name] + if exists && !replaceExisting { return postgresErrorResponse(409, "ALREADY_EXISTS", "branch with such id already exists") } now := nowTime() - branch.Name = name - branch.Parent = parent - branch.Uid = "br-" + nextUUID()[:20] - branch.CreateTime = now - branch.UpdateTime = now - - // Find the default branch to use as source - var defaultBranch *postgres.Branch - prefix := parent + "/branches/" - for brName, br := range s.PostgresBranches { - if strings.HasPrefix(brName, prefix) && br.Status != nil && br.Status.Default { - defaultBranch = &br - break + if exists { + // Preserve identifying / output-only fields; apply incoming spec to status. + branch.Name = existing.Name + branch.Parent = existing.Parent + branch.Uid = existing.Uid + branch.CreateTime = existing.CreateTime + branch.UpdateTime = now + branch.Status = existing.Status + } else { + branch.Name = name + branch.Parent = parent + branch.Uid = "br-" + nextUUID()[:20] + branch.CreateTime = now + branch.UpdateTime = now + branch.Status = &postgres.BranchStatus{ + BranchId: branchID, + CurrentState: postgres.BranchStatusStateReady, + StateChangeTime: now, + Default: false, + IsProtected: false, + LogicalSizeBytes: 0, + ForceSendFields: []string{"Default", "IsProtected", "LogicalSizeBytes"}, } - } - // Initialize status with all required fields - branch.Status = &postgres.BranchStatus{ - BranchId: branchID, - CurrentState: postgres.BranchStatusStateReady, - StateChangeTime: now, - Default: false, - IsProtected: false, - LogicalSizeBytes: 0, - ForceSendFields: []string{"Default", "IsProtected", "LogicalSizeBytes"}, + // Set source branch info if a default branch exists. + prefix := parent + "/branches/" + for brName, br := range s.PostgresBranches { + if strings.HasPrefix(brName, prefix) && br.Status != nil && br.Status.Default { + branch.Status.SourceBranch = br.Name + branch.Status.SourceBranchLsn = "0/0" + branch.Status.SourceBranchTime = nowTime() + break + } + } } - // Set source branch info if a default branch exists - if defaultBranch != nil { - branch.Status.SourceBranch = defaultBranch.Name - branch.Status.SourceBranchLsn = "0/0" - branch.Status.SourceBranchTime = nowTime() + // Apply user-provided spec fields to status (where input fields are surfaced). + if branch.Spec != nil { + branch.Status.IsProtected = branch.Spec.IsProtected } // Clear spec - API only returns status @@ -270,6 +281,12 @@ func (s *FakeWorkspace) PostgresBranchCreate(req Request, parent, branchID strin s.PostgresBranches[name] = branch + // Each branch implicitly provisions a primary read-write endpoint. Only create + // it on first branch creation; on replace_existing the primary already exists. + if !exists { + s.createDefaultEndpointLocked(name) + } + return Response{ Body: s.createOperationLocked(branch.Name, branch), } @@ -379,7 +396,12 @@ func (s *FakeWorkspace) PostgresBranchDelete(name string) Response { } // PostgresEndpointCreate creates a new postgres endpoint. -func (s *FakeWorkspace) PostgresEndpointCreate(req Request, parent, endpointID string) Response { +// +// When replaceExisting is true, an existing endpoint with the same ID is updated +// in place instead of returning ALREADY_EXISTS. This mirrors the backend behavior +// that lets users bring the implicitly-created primary read-write endpoint under +// management. +func (s *FakeWorkspace) PostgresEndpointCreate(req Request, parent, endpointID string, replaceExisting bool) Response { defer s.LockUnlock()() // Validate that endpoint_id is provided @@ -405,37 +427,56 @@ func (s *FakeWorkspace) PostgresEndpointCreate(req Request, parent, endpointID s name := fmt.Sprintf("%s/endpoints/%s", parent, endpointID) - // Check for duplicate - if _, exists := s.PostgresEndpoints[name]; exists { + existing, exists := s.PostgresEndpoints[name] + if exists && !replaceExisting { return postgresErrorResponse(409, "ALREADY_EXISTS", "endpoint with such id already exists") } now := nowTime() - endpoint.Name = name - endpoint.Parent = parent - endpoint.Uid = "ep-" + nextUUID()[:20] - endpoint.CreateTime = now - endpoint.UpdateTime = now - - // Get default suspend timeout from project - var defaultSuspendTimeout *duration.Duration - if project, ok := s.PostgresProjects[branch.Parent]; ok { - if project.Status != nil && project.Status.DefaultEndpointSettings != nil { - defaultSuspendTimeout = project.Status.DefaultEndpointSettings.SuspendTimeoutDuration + if exists { + endpoint.Name = existing.Name + endpoint.Parent = existing.Parent + endpoint.Uid = existing.Uid + endpoint.CreateTime = existing.CreateTime + endpoint.UpdateTime = now + endpoint.Status = existing.Status + } else { + endpoint.Name = name + endpoint.Parent = parent + endpoint.Uid = "ep-" + nextUUID()[:20] + endpoint.CreateTime = now + endpoint.UpdateTime = now + + // Inherit suspend timeout default from the project. + var defaultSuspendTimeout *duration.Duration + if project, ok := s.PostgresProjects[branch.Parent]; ok { + if project.Status != nil && project.Status.DefaultEndpointSettings != nil { + defaultSuspendTimeout = project.Status.DefaultEndpointSettings.SuspendTimeoutDuration + } + } + + endpoint.Status = &postgres.EndpointStatus{ + EndpointId: endpointID, + CurrentState: postgres.EndpointStatusStateActive, + Disabled: false, + Settings: &postgres.EndpointSettings{}, + SuspendTimeoutDuration: defaultSuspendTimeout, + ForceSendFields: []string{"Disabled"}, } - } - // Initialize status with all required fields - endpoint.Status = &postgres.EndpointStatus{ - EndpointId: endpointID, - CurrentState: postgres.EndpointStatusStateActive, - Disabled: false, - Settings: &postgres.EndpointSettings{}, - SuspendTimeoutDuration: defaultSuspendTimeout, - ForceSendFields: []string{"Disabled"}, + host := endpoint.Uid + ".database.us-east-1.cloud.databricks.com" + endpoint.Status.Hosts = &postgres.EndpointHosts{ + Host: host, + ReadOnlyHost: host, + } + endpoint.Status.Group = &postgres.EndpointGroupStatus{ + EnableReadableSecondaries: true, + Max: 1, + Min: 1, + } } - // Copy spec values to status + // Apply user-provided spec to status (where input fields are surfaced). if endpoint.Spec != nil { endpoint.Status.EndpointType = endpoint.Spec.EndpointType endpoint.Status.AutoscalingLimitMinCu = endpoint.Spec.AutoscalingLimitMinCu @@ -443,23 +484,7 @@ func (s *FakeWorkspace) PostgresEndpointCreate(req Request, parent, endpointID s if endpoint.Spec.SuspendTimeoutDuration != nil { endpoint.Status.SuspendTimeoutDuration = endpoint.Spec.SuspendTimeoutDuration } - if endpoint.Spec.Disabled { - endpoint.Status.Disabled = true - } - } - - // Generate fake hosts - host := endpoint.Uid + ".database.us-east-1.cloud.databricks.com" - endpoint.Status.Hosts = &postgres.EndpointHosts{ - Host: host, - ReadOnlyHost: host, - } - - // Set default group status - endpoint.Status.Group = &postgres.EndpointGroupStatus{ - EnableReadableSecondaries: true, - Max: 1, - Min: 1, + endpoint.Status.Disabled = endpoint.Spec.Disabled } // Clear spec - API only returns status @@ -656,4 +681,59 @@ func (s *FakeWorkspace) createDefaultBranchLocked(projectName string) { } s.PostgresBranches[branchName] = defaultBranch + + // Each branch implicitly provisions a primary read-write endpoint. + s.createDefaultEndpointLocked(branchName) +} + +// createDefaultEndpointLocked creates the implicit primary read-write endpoint for +// a branch (caller must hold lock). The endpoint is named "primary" to match cloud +// API behavior. +func (s *FakeWorkspace) createDefaultEndpointLocked(branchName string) { + now := nowTime() + endpointName := branchName + "/endpoints/primary" + + // Inherit default endpoint settings from the project where available. + var ( + minCu, maxCu float64 + suspendTimeout *duration.Duration + projectName = strings.Split(branchName, "/branches/")[0] + ) + if project, ok := s.PostgresProjects[projectName]; ok { + if project.Status != nil && project.Status.DefaultEndpointSettings != nil { + minCu = project.Status.DefaultEndpointSettings.AutoscalingLimitMinCu + maxCu = project.Status.DefaultEndpointSettings.AutoscalingLimitMaxCu + suspendTimeout = project.Status.DefaultEndpointSettings.SuspendTimeoutDuration + } + } + + endpointUID := "ep-" + nextUUID()[:20] + host := endpointUID + ".database.us-east-1.cloud.databricks.com" + + s.PostgresEndpoints[endpointName] = postgres.Endpoint{ + Name: endpointName, + Parent: branchName, + Uid: endpointUID, + CreateTime: now, + UpdateTime: now, + Status: &postgres.EndpointStatus{ + EndpointId: "primary", + EndpointType: postgres.EndpointTypeEndpointTypeReadWrite, + CurrentState: postgres.EndpointStatusStateActive, + Disabled: false, + Settings: &postgres.EndpointSettings{}, + AutoscalingLimitMinCu: minCu, + AutoscalingLimitMaxCu: maxCu, + SuspendTimeoutDuration: suspendTimeout, + // Primary read-write endpoint exposes only the read-write host; + // read_only_host is set on read-only endpoints created later. + Hosts: &postgres.EndpointHosts{Host: host}, + Group: &postgres.EndpointGroupStatus{ + Max: 1, + Min: 1, + ForceSendFields: []string{"EnableReadableSecondaries"}, + }, + ForceSendFields: []string{"Disabled"}, + }, + } } diff --git a/libs/testserver/postgres_test.go b/libs/testserver/postgres_test.go index d421212ed9c..fa8a4eed35d 100644 --- a/libs/testserver/postgres_test.go +++ b/libs/testserver/postgres_test.go @@ -234,9 +234,11 @@ func TestPostgresEndpointCRUD(t *testing.T) { require.NoError(t, err) assert.Equal(t, 200, listEpResp.StatusCode) + // Each branch implicitly provisions a "primary" RW endpoint in addition to + // the explicitly-created "rw-endpoint". var listEndpoints postgres.ListEndpointsResponse require.NoError(t, json.NewDecoder(listEpResp.Body).Decode(&listEndpoints)) - assert.Len(t, listEndpoints.Endpoints, 1) + assert.Len(t, listEndpoints.Endpoints, 2) listEpResp.Body.Close() // Delete endpoint From 34211e7dda7932756c06e95ffbbe3eb46324eb17 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 17:36:31 +0200 Subject: [PATCH 2/7] direct: Suppress MANAGED_BY_PARENT errors on Delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lakebase resources whose lifecycle is owned by a parent (the implicit production branch on a project, the implicit primary read-write endpoint on a branch) cannot be deleted independently. The backend signals this by attaching declarative_context=MANAGED_BY_PARENT to ErrorInfo.metadata on the 400 response; declarative tools are expected to disregard the delete and rely on the parent to cascade-clean. The Terraform provider v1.115.0 already implements this via declarative.IsDeleteError. Mirrors the suppression in the direct engine: new isManagedByParent helper in bundle/direct/util.go, used alongside isResourceGone in DeploymentUnit.Delete. With this in place, bundle destroy now does the expected cascade — leaf delete fails with the metadata marker, is disregarded, parent delete proceeds and cleans the leaf along the way. The acceptance testserver is taught the same payload (new postgresManagedByParentErrorResponse) so the implicit-branch and implicit-endpoint delete paths emit the cloud-shape error including details[].ErrorInfo. Both replace_existing acceptance tests now exercise bundle destroy end-to-end on both engines; outputs and request + response traces are captured per-engine. Note: an existing test-proxy bug (libs/testproxy/server.go re-marshals APIError to only error_code+message, dropping details[]) means cloud runs still see destroy fail; that is being addressed separately. Co-authored-by: Isaac --- .../replace_existing/databricks.yml.tmpl | 10 ++- .../replace_existing/out.destroy.direct.txt | 16 ++++ .../out.destroy.terraform.txt | 16 ++++ ...t.json => out.requests.deploy.direct.json} | 2 +- ...son => out.requests.deploy.terraform.json} | 2 +- .../out.requests.destroy.direct.json | 47 +++++++++++ .../out.requests.destroy.terraform.json | 48 +++++++++++ .../replace_existing/output.txt | 5 +- .../postgres_branches/replace_existing/script | 18 +++-- .../replace_existing/out.destroy.direct.txt | 17 ++++ .../out.destroy.terraform.txt | 17 ++++ ...t.json => out.requests.deploy.direct.json} | 0 ...son => out.requests.deploy.terraform.json} | 0 .../out.requests.destroy.direct.json | 79 +++++++++++++++++++ .../out.requests.destroy.terraform.json | 77 ++++++++++++++++++ .../replace_existing/output.txt | 3 + .../replace_existing/script | 16 +++- bundle/direct/apply.go | 2 +- bundle/direct/util.go | 16 ++++ libs/testserver/fake_workspace.go | 49 +++++++----- libs/testserver/postgres.go | 77 +++++++++++++++++- 21 files changed, 480 insertions(+), 37 deletions(-) create mode 100644 acceptance/bundle/resources/postgres_branches/replace_existing/out.destroy.direct.txt create mode 100644 acceptance/bundle/resources/postgres_branches/replace_existing/out.destroy.terraform.txt rename acceptance/bundle/resources/postgres_branches/replace_existing/{out.requests.direct.json => out.requests.deploy.direct.json} (95%) rename acceptance/bundle/resources/postgres_branches/replace_existing/{out.requests.terraform.json => out.requests.deploy.terraform.json} (96%) create mode 100644 acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.destroy.direct.json create mode 100644 acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.destroy.terraform.json create mode 100644 acceptance/bundle/resources/postgres_endpoints/replace_existing/out.destroy.direct.txt create mode 100644 acceptance/bundle/resources/postgres_endpoints/replace_existing/out.destroy.terraform.txt rename acceptance/bundle/resources/postgres_endpoints/replace_existing/{out.requests.direct.json => out.requests.deploy.direct.json} (100%) rename acceptance/bundle/resources/postgres_endpoints/replace_existing/{out.requests.terraform.json => out.requests.deploy.terraform.json} (100%) create mode 100644 acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.destroy.direct.json create mode 100644 acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.destroy.terraform.json diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_branches/replace_existing/databricks.yml.tmpl index 17484af8060..6d2637285b2 100644 --- a/acceptance/bundle/resources/postgres_branches/replace_existing/databricks.yml.tmpl +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/databricks.yml.tmpl @@ -12,12 +12,14 @@ resources: pg_version: 16 history_retention_duration: "604800s" - # Take over the implicitly-created production branch and flip is_protected - # from its default value (false) to true. A successful deploy with the new - # value reflected in get-branch proves replace_existing applied the spec. + # Take over the implicitly-created production branch with replace_existing. + # is_protected is left at its default (false) so that bundle destroy can + # delete the branch at the end of the test. no_expiry is sent in the spec + # to demonstrate the create request goes through; the field is input-only + # and not surfaced in get-branch. postgres_branches: production: parent: ${resources.postgres_projects.my_project.id} branch_id: production replace_existing: true - is_protected: true + no_expiry: true diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/out.destroy.direct.txt b/acceptance/bundle/resources/postgres_branches/replace_existing/out.destroy.direct.txt new file mode 100644 index 00000000000..03109f9364d --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/out.destroy.direct.txt @@ -0,0 +1,16 @@ +The following resources will be deleted: + delete resources.postgres_branches.production + 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.production + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-replace-branch-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/out.destroy.terraform.txt b/acceptance/bundle/resources/postgres_branches/replace_existing/out.destroy.terraform.txt new file mode 100644 index 00000000000..03109f9364d --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/out.destroy.terraform.txt @@ -0,0 +1,16 @@ +The following resources will be deleted: + delete resources.postgres_branches.production + 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.production + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-replace-branch-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.direct.json b/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.deploy.direct.json similarity index 95% rename from acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.direct.json rename to acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.deploy.direct.json index 21cb677c1ec..781311e084c 100644 --- a/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.direct.json +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.deploy.direct.json @@ -21,7 +21,7 @@ }, "body": { "spec": { - "is_protected": true + "no_expiry": true } } } diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.terraform.json b/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.deploy.terraform.json similarity index 96% rename from acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.terraform.json rename to acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.deploy.terraform.json index 864dd230261..f70714acb2b 100644 --- a/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.terraform.json +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.deploy.terraform.json @@ -22,7 +22,7 @@ "body": { "parent": "projects/test-pg-proj-[UNIQUE_NAME]", "spec": { - "is_protected": true + "no_expiry": true } } } diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.destroy.direct.json b/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.destroy.direct.json new file mode 100644 index 00000000000..1ddf631fc65 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.destroy.direct.json @@ -0,0 +1,47 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Replace existing branch test", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "production", + "replace_existing": "true" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/production" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/production" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "DELETE", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/production" +} +{ + "method": "DELETE", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.destroy.terraform.json b/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.destroy.terraform.json new file mode 100644 index 00000000000..ad6e8580927 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/out.requests.destroy.terraform.json @@ -0,0 +1,48 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Replace existing branch test", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "production", + "replace_existing": "true" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "spec": { + "no_expiry": true + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/production" +} +{ + "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/production" +} +{ + "method": "DELETE", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/production" +} +{ + "method": "DELETE", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/output.txt b/acceptance/bundle/resources/postgres_branches/replace_existing/output.txt index b31122f697c..c34b80fc008 100644 --- a/acceptance/bundle/resources/postgres_branches/replace_existing/output.txt +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/output.txt @@ -22,10 +22,13 @@ Deployment complete! "branch_id": "production", "current_state": "READY", "default": true, - "is_protected": true, + "is_protected": false, "state_change_time": "[TIMESTAMP]" }, "uid": "[BRANCH_UID]" } >>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +=== bundle destroy (expected to fail on root branch) +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/script b/acceptance/bundle/resources/postgres_branches/replace_existing/script index 03a3fbfb45c..d93184df63b 100644 --- a/acceptance/bundle/resources/postgres_branches/replace_existing/script +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/script @@ -1,8 +1,8 @@ envsubst < databricks.yml.tmpl > databricks.yml cleanup() { - # The production branch cannot be deleted independently of its project, so - # destroy by cascading from the project. + # bundle destroy cannot remove the implicit production branch, so the + # project will still exist after destroy. Cascade-delete to clean up. $CLI postgres delete-project "projects/test-pg-proj-${UNIQUE_NAME}" 2>/dev/null || true rm -f out.requests.txt } @@ -13,8 +13,16 @@ trace $CLI bundle validate rm -f out.requests.txt trace $CLI bundle deploy -# The production branch defaults to is_protected=false. After deploy with -# replace_existing=true, the bundle's spec must be applied: is_protected=true. +# Confirm the implicit production branch is now under management. trace $CLI postgres get-branch "projects/test-pg-proj-${UNIQUE_NAME}/branches/production" | jq 'del(.create_time, .update_time, .status.logical_size_bytes)' -trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.deploy.$DATABRICKS_BUNDLE_ENGINE.json + +# bundle destroy is expected to fail: the production branch is the root +# branch of its project and the backend rejects independent deletion. The +# per-engine output captures the exact error shape so any regression in +# engine error formatting (or in server behaviour) is detectable. +title "bundle destroy (expected to fail on root branch)" +$CLI bundle destroy --auto-approve > out.destroy.$DATABRICKS_BUNDLE_ENGINE.txt 2>&1 || true + +trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.destroy.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.destroy.direct.txt b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.destroy.direct.txt new file mode 100644 index 00000000000..2ac6d613c05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.destroy.direct.txt @@ -0,0 +1,17 @@ +The following resources will be deleted: + delete resources.postgres_branches.develop + delete resources.postgres_endpoints.primary + 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.develop + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-replace-endpoint-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.destroy.terraform.txt b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.destroy.terraform.txt new file mode 100644 index 00000000000..2ac6d613c05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.destroy.terraform.txt @@ -0,0 +1,17 @@ +The following resources will be deleted: + delete resources.postgres_branches.develop + delete resources.postgres_endpoints.primary + 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.develop + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-replace-endpoint-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.direct.json b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.deploy.direct.json similarity index 100% rename from acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.direct.json rename to acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.deploy.direct.json diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.terraform.json b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.deploy.terraform.json similarity index 100% rename from acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.terraform.json rename to acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.deploy.terraform.json diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.destroy.direct.json b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.destroy.direct.json new file mode 100644 index 00000000000..51e48eb6b96 --- /dev/null +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.destroy.direct.json @@ -0,0 +1,79 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "default_endpoint_settings": { + "autoscaling_limit_max_cu": 4, + "autoscaling_limit_min_cu": 0.5, + "suspend_timeout_duration": "300s" + }, + "display_name": "Replace existing endpoint test", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "develop" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints", + "q": { + "endpoint_id": "primary", + "replace_existing": "true" + }, + "body": { + "spec": { + "autoscaling_limit_max_cu": 4, + "autoscaling_limit_min_cu": 0.5, + "endpoint_type": "ENDPOINT_TYPE_READ_WRITE", + "suspend_timeout_duration": "600s" + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints/primary" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints/primary" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints/primary" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "DELETE", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints/primary" +} +{ + "method": "DELETE", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop" +} +{ + "method": "DELETE", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.destroy.terraform.json b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.destroy.terraform.json new file mode 100644 index 00000000000..1bebfa32251 --- /dev/null +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/out.requests.destroy.terraform.json @@ -0,0 +1,77 @@ +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "default_endpoint_settings": { + "autoscaling_limit_max_cu": 4, + "autoscaling_limit_min_cu": 0.5, + "suspend_timeout_duration": "300s" + }, + "display_name": "Replace existing endpoint test", + "history_retention_duration": "604800s", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "develop" + }, + "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/develop/endpoints", + "q": { + "endpoint_id": "primary", + "replace_existing": "true" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]/branches/develop", + "spec": { + "autoscaling_limit_max_cu": 4, + "autoscaling_limit_min_cu": 0.5, + "endpoint_type": "ENDPOINT_TYPE_READ_WRITE", + "suspend_timeout_duration": "600s" + } + } +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints/primary" +} +{ + "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/develop" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints/primary" +} +{ + "method": "DELETE", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop/endpoints/primary" +} +{ + "method": "DELETE", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/develop" +} +{ + "method": "DELETE", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/output.txt b/acceptance/bundle/resources/postgres_endpoints/replace_existing/output.txt index dce26f233ee..ac1a74b2952 100644 --- a/acceptance/bundle/resources/postgres_endpoints/replace_existing/output.txt +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/output.txt @@ -40,3 +40,6 @@ Deployment complete! } >>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +=== bundle destroy (expected to fail on read-write endpoint) +>>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/script b/acceptance/bundle/resources/postgres_endpoints/replace_existing/script index 53f5d1db311..464cfd7c45f 100644 --- a/acceptance/bundle/resources/postgres_endpoints/replace_existing/script +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/script @@ -1,8 +1,8 @@ envsubst < databricks.yml.tmpl > databricks.yml cleanup() { - # The primary read-write endpoint cannot be deleted independently of its - # branch, so destroy by cascading from the project. + # bundle destroy cannot remove the implicit primary endpoint, so the + # project will still exist after destroy. Cascade-delete to clean up. $CLI postgres delete-project "projects/test-pg-proj-${UNIQUE_NAME}" 2>/dev/null || true rm -f out.requests.txt } @@ -18,4 +18,14 @@ trace $CLI bundle deploy # bundle's spec must be applied: 600s. trace $CLI postgres get-endpoint "projects/test-pg-proj-${UNIQUE_NAME}/branches/develop/endpoints/primary" | jq 'del(.create_time, .update_time)' -trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.$DATABRICKS_BUNDLE_ENGINE.json +trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.deploy.$DATABRICKS_BUNDLE_ENGINE.json + +# bundle destroy is expected to fail: the primary endpoint of the develop +# branch is a read-write endpoint and the backend rejects independent +# deletion. The per-engine output captures the exact error shape so any +# regression in engine error formatting (or in server behaviour) is +# detectable. +title "bundle destroy (expected to fail on read-write endpoint)" +$CLI bundle destroy --auto-approve > out.destroy.$DATABRICKS_BUNDLE_ENGINE.txt 2>&1 || true + +trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.destroy.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index ba0f54bfc7b..6bda08505a6 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -168,7 +168,7 @@ func (d *DeploymentUnit) UpdateWithID(ctx context.Context, db *dstate.Deployment func (d *DeploymentUnit) Delete(ctx context.Context, db *dstate.DeploymentState, oldID string) error { err := d.Adapter.DoDelete(ctx, oldID) - if err != nil && !isResourceGone(err) { + if err != nil && !isResourceGone(err) && !isManagedByParent(err) { // Rather than failing delete and requiring user to unbind, we perform unbind automatically there. // Some services, e.g. jobs, return 403 for missing resources if caller did not have permissions to it when job existed. // In those cases 403 hides 404. In other cases, user not having permissions to resource but having in the bundle might diff --git a/bundle/direct/util.go b/bundle/direct/util.go index 43c39c9a67f..3b4c9abd651 100644 --- a/bundle/direct/util.go +++ b/bundle/direct/util.go @@ -9,3 +9,19 @@ import ( func isResourceGone(err error) bool { return errors.Is(err, apierr.ErrResourceDoesNotExist) || errors.Is(err, apierr.ErrNotFound) } + +// isManagedByParent reports whether err is an API error carrying the +// declarative_context=MANAGED_BY_PARENT marker in ErrorInfo.metadata. The +// server uses this to signal that a resource's lifecycle is owned by a +// parent (e.g. a Lakebase RW endpoint inside a branch, or a root branch +// inside a project) and the standalone Delete can be safely disregarded — +// the parent's Delete will cascade-clean. Mirrors the TF provider's +// declarative.IsDeleteError suppression. +func isManagedByParent(err error) bool { + var apiErr *apierr.APIError + if !errors.As(err, &apiErr) || apiErr == nil { + return false + } + info := apiErr.ErrorDetails().ErrorInfo + return info != nil && info.Metadata["declarative_context"] == "MANAGED_BY_PARENT" +} diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 5430c68cbcc..6fc686c4fd3 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -173,6 +173,13 @@ type FakeWorkspace struct { PostgresEndpoints map[string]postgres.Endpoint PostgresOperations map[string]postgres.Operation + // Branches and endpoints that the server provisioned implicitly together + // with their parent (e.g. the production branch on a new project, or the + // primary endpoint on a new branch). The real backend rejects independent + // deletion of these — they go away only when the parent is deleted. + postgresImplicitBranches map[string]bool + postgresImplicitEndpoints map[string]bool + // clusterVenvs caches Python venvs per existing cluster ID, // matching cloud behavior where libraries are cached on running clusters. clusterVenvs map[string]*clusterEnv @@ -285,26 +292,28 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { State: sql.StateRunning, }, }, - ServingEndpoints: map[string]serving.ServingEndpointDetailed{}, - VectorSearchEndpoints: map[string]vectorsearch.EndpointInfo{}, - Repos: map[string]workspace.RepoInfo{}, - SecretScopes: map[string]workspace.SecretScope{}, - Secrets: map[string]map[string]string{}, - Acls: map[string][]workspace.AclItem{}, - Permissions: map[string]iam.ObjectPermissions{}, - Groups: map[string]iam.Group{}, - DatabaseInstances: map[string]database.DatabaseInstance{}, - DatabaseCatalogs: map[string]database.DatabaseCatalog{}, - SyncedDatabaseTables: map[string]database.SyncedDatabaseTable{}, - PostgresProjects: map[string]postgres.Project{}, - PostgresBranches: map[string]postgres.Branch{}, - PostgresEndpoints: map[string]postgres.Endpoint{}, - PostgresOperations: map[string]postgres.Operation{}, - clusterVenvs: map[string]*clusterEnv{}, - Alerts: map[string]sql.AlertV2{}, - Experiments: map[string]ml.GetExperimentResponse{}, - ModelRegistryModels: map[string]ml.Model{}, - ModelRegistryModelIDs: map[string]string{}, + ServingEndpoints: map[string]serving.ServingEndpointDetailed{}, + VectorSearchEndpoints: map[string]vectorsearch.EndpointInfo{}, + Repos: map[string]workspace.RepoInfo{}, + SecretScopes: map[string]workspace.SecretScope{}, + Secrets: map[string]map[string]string{}, + Acls: map[string][]workspace.AclItem{}, + Permissions: map[string]iam.ObjectPermissions{}, + Groups: map[string]iam.Group{}, + DatabaseInstances: map[string]database.DatabaseInstance{}, + DatabaseCatalogs: map[string]database.DatabaseCatalog{}, + SyncedDatabaseTables: map[string]database.SyncedDatabaseTable{}, + PostgresProjects: map[string]postgres.Project{}, + PostgresBranches: map[string]postgres.Branch{}, + PostgresEndpoints: map[string]postgres.Endpoint{}, + PostgresOperations: map[string]postgres.Operation{}, + postgresImplicitBranches: map[string]bool{}, + postgresImplicitEndpoints: map[string]bool{}, + clusterVenvs: map[string]*clusterEnv{}, + Alerts: map[string]sql.AlertV2{}, + Experiments: map[string]ml.GetExperimentResponse{}, + ModelRegistryModels: map[string]ml.Model{}, + ModelRegistryModelIDs: map[string]string{}, Clusters: map[string]compute.ClusterDetails{ TestDefaultClusterId: { ClusterId: TestDefaultClusterId, diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index 973adb3cfb0..8a7c22b4ecf 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -26,6 +26,36 @@ func postgresErrorResponse(statusCode int, errorCode, message string) Response { } } +// postgresManagedByParentErrorResponse creates a 400 BAD_REQUEST response that +// mirrors the real backend's "managed by parent" payload — including the +// details[].ErrorInfo.metadata.declarative_context=MANAGED_BY_PARENT marker +// that declarative clients (TF provider, direct engine) use to disregard the +// delete and rely on the parent to cascade. +func postgresManagedByParentErrorResponse(message string) Response { + return Response{ + StatusCode: 400, + Body: map[string]any{ + "error_code": "BAD_REQUEST", + "message": message, + "details": []any{ + map[string]any{ + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "domain": "databricks.com", + "reason": "RESOURCE_CONFLICT", + "metadata": map[string]string{ + "declarative_context": "MANAGED_BY_PARENT", + }, + }, + map[string]any{ + "@type": "type.googleapis.com/google.rpc.RequestInfo", + "request_id": nextUUID(), + "serving_data": "", + }, + }, + }, + } +} + // postgresNotFoundResponse creates a NOT_FOUND error response for a resource type. func postgresNotFoundResponse(resourceType string) Response { // Include trace ID to match real API behavior for not found errors @@ -192,6 +222,22 @@ func (s *FakeWorkspace) PostgresProjectDelete(name string) Response { return postgresNotFoundResponse("project") } + // Deleting a project cascade-deletes all its branches and endpoints. + branchPrefix := name + "/branches/" + for brName := range s.PostgresBranches { + if strings.HasPrefix(brName, branchPrefix) { + delete(s.PostgresBranches, brName) + delete(s.postgresImplicitBranches, brName) + endpointPrefix := brName + "/endpoints/" + for epName := range s.PostgresEndpoints { + if strings.HasPrefix(epName, endpointPrefix) { + delete(s.PostgresEndpoints, epName) + delete(s.postgresImplicitEndpoints, epName) + } + } + } + } + delete(s.PostgresProjects, name) return Response{ @@ -383,12 +429,30 @@ func (s *FakeWorkspace) PostgresBranchDelete(name string) Response { return postgresNotFoundResponse("branch") } - // Check if branch is protected if branch.Status != nil && branch.Status.IsProtected { return postgresErrorResponse(400, "BAD_REQUEST", "cannot delete protected branch") } + // Branches the server provisioned implicitly (the project's root branch) + // cannot be deleted independently; they are removed when the project is + // deleted. Matches the real backend's error payload including the + // declarative_context=MANAGED_BY_PARENT marker. + if s.postgresImplicitBranches[name] { + return postgresManagedByParentErrorResponse(fmt.Sprintf("Cannot delete root branch '%s'. Root branches cannot be deleted independently.", name)) + } + + // Deleting a branch cascade-deletes its endpoints (including the implicit + // primary). + endpointPrefix := name + "/endpoints/" + for epName := range s.PostgresEndpoints { + if strings.HasPrefix(epName, endpointPrefix) { + delete(s.PostgresEndpoints, epName) + delete(s.postgresImplicitEndpoints, epName) + } + } + delete(s.PostgresBranches, name) + delete(s.postgresImplicitBranches, name) return Response{ Body: s.createOperationLocked(name, nil), @@ -605,7 +669,16 @@ func (s *FakeWorkspace) PostgresEndpointDelete(name string) Response { return postgresNotFoundResponse("endpoint") } + // Endpoints the server provisioned implicitly (the primary read-write + // endpoint on every branch) cannot be deleted independently; they are + // removed when the parent branch is deleted. Matches the real backend's + // error payload including the declarative_context=MANAGED_BY_PARENT marker. + if s.postgresImplicitEndpoints[name] { + return postgresManagedByParentErrorResponse(fmt.Sprintf("Cannot delete read-write endpoint '%s'. Read-write endpoints cannot be deleted.", name)) + } + delete(s.PostgresEndpoints, name) + delete(s.postgresImplicitEndpoints, name) return Response{ Body: s.createOperationLocked(name, nil), @@ -681,6 +754,7 @@ func (s *FakeWorkspace) createDefaultBranchLocked(projectName string) { } s.PostgresBranches[branchName] = defaultBranch + s.postgresImplicitBranches[branchName] = true // Each branch implicitly provisions a primary read-write endpoint. s.createDefaultEndpointLocked(branchName) @@ -736,4 +810,5 @@ func (s *FakeWorkspace) createDefaultEndpointLocked(branchName string) { ForceSendFields: []string{"Disabled"}, }, } + s.postgresImplicitEndpoints[endpointName] = true } From 1e799c9e4ce1d2ab3ce285d49859c987f13e3a11 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 17:42:55 +0200 Subject: [PATCH 3/7] testproxy: forward raw error body and headers from upstream The reverse proxy in libs/testproxy re-marshalled apierr.APIError into a {error_code, message} envelope, dropping details[] and any other fields the workspace returned. As a result, acceptance tests run against the cloud could not observe error metadata that real CLI/TF invocations rely on. Forward apiErr.ResponseWrapper.DebugBytes verbatim with the original status code so callers see exactly what the workspace sent. Also pass through response headers in includeResponseHeaders on the error path; WithResponseHeader visitors are not invoked when apiClient.Do returns an error. ResponseWrapper has been populated on every APIError since databricks/databricks-sdk-go#1261 (v0.100.0); the CLI is on v0.132.0. A panic guards the invariant in case the SDK ever changes shape. Co-authored-by: Isaac --- libs/testproxy/server.go | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/libs/testproxy/server.go b/libs/testproxy/server.go index f3510d7adb0..fd6038c8ed5 100644 --- a/libs/testproxy/server.go +++ b/libs/testproxy/server.go @@ -2,7 +2,6 @@ package testproxy import ( "bytes" - "encoding/json" "errors" "net/http" "net/http/httptest" @@ -129,22 +128,31 @@ func (s *ProxyServer) proxyToCloud(w http.ResponseWriter, r *http.Request) { var encodedResponse *testserver.EncodedResponse - // API errors from the SDK are expected to be of the type [apierr.APIError]. If we - // get an API error then parse the error and forward it back to the client - // in an appropriate format. + // API errors from the SDK are of type [apierr.APIError]. Forward the raw + // response bytes verbatim — including any error details — so callers see + // exactly what the workspace returned. Re-marshalling from the parsed + // APIError would drop fields the SDK doesn't surface (e.g. metadata in + // details[]) and silently break callers that inspect them. apiErr := &apierr.APIError{} if errors.As(err, &apiErr) { - body := map[string]string{ - "error_code": apiErr.ErrorCode, - "message": apiErr.Message, + rw := apiErr.ResponseWrapper + if rw == nil { + // The SDK populates ResponseWrapper for every APIError produced + // from a real HTTP response. If this ever fires the SDK changed + // shape and we need to revisit how we forward error bodies. + panic("apierr.APIError has no ResponseWrapper") } - - b, err := json.Marshal(body) - assert.NoError(s.t, err) - encodedResponse = &testserver.EncodedResponse{ - StatusCode: apiErr.StatusCode, - Body: b, + StatusCode: rw.Response.StatusCode, + Body: rw.DebugBytes, + } + // Visitors registered via WithResponseHeader are not invoked when + // the SDK returns an error, so populate the include list directly + // from the original response headers. + for _, header := range includeResponseHeaders { + if v := rw.Response.Header.Get(header); v != "" { + *responseHeaders[header] = v + } } } From fd48cdc97837ac5c76af58e1da9c64515bb601f2 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 20:58:48 +0200 Subject: [PATCH 4/7] changelog: replace_existing for postgres branches and endpoints Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 36cbb445d2c..2fd7b638a63 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -8,5 +8,6 @@ ### Bundles * Make sure warnings asking for approval are understood by agents ([#5239](https://github.com/databricks/cli/pull/5239)) +* Support `replace_existing: true` on `postgres_branches` and `postgres_endpoints` so bundles can manage the implicitly-created production branch and primary read-write endpoint of a Lakebase project. ### Dependency updates From 24c2d0fb75940a680cf653692c03957aa1b09578 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 21:06:53 +0200 Subject: [PATCH 5/7] acc: refresh replace_existing test comments after destroy started passing The destroy-side suppression landed in the previous commit; the test scripts still referred to bundle destroy as "expected to fail." Update the inline comments and section titles to describe the actual flow (backend signals MANAGED_BY_PARENT, both engines disregard, parent delete cascades). No behavioural change. Co-authored-by: Isaac --- .../replace_existing/databricks.yml.tmpl | 9 ++++----- .../postgres_branches/replace_existing/output.txt | 2 +- .../postgres_branches/replace_existing/script | 14 +++++++------- .../replace_existing/output.txt | 2 +- .../postgres_endpoints/replace_existing/script | 15 +++++++-------- 5 files changed, 20 insertions(+), 22 deletions(-) diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_branches/replace_existing/databricks.yml.tmpl index 6d2637285b2..d55c43cb741 100644 --- a/acceptance/bundle/resources/postgres_branches/replace_existing/databricks.yml.tmpl +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/databricks.yml.tmpl @@ -12,11 +12,10 @@ resources: pg_version: 16 history_retention_duration: "604800s" - # Take over the implicitly-created production branch with replace_existing. - # is_protected is left at its default (false) so that bundle destroy can - # delete the branch at the end of the test. no_expiry is sent in the spec - # to demonstrate the create request goes through; the field is input-only - # and not surfaced in get-branch. + # Take over the implicitly-created production branch with replace_existing + # and apply a non-default spec (no_expiry: true). The field is input-only + # and not surfaced in get-branch, but it is visible in the recorded + # request body so the diff confirms it was sent. postgres_branches: production: parent: ${resources.postgres_projects.my_project.id} diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/output.txt b/acceptance/bundle/resources/postgres_branches/replace_existing/output.txt index c34b80fc008..3f1092385aa 100644 --- a/acceptance/bundle/resources/postgres_branches/replace_existing/output.txt +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/output.txt @@ -30,5 +30,5 @@ Deployment complete! >>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ -=== bundle destroy (expected to fail on root branch) +=== bundle destroy >>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ diff --git a/acceptance/bundle/resources/postgres_branches/replace_existing/script b/acceptance/bundle/resources/postgres_branches/replace_existing/script index d93184df63b..3c3cc7e9300 100644 --- a/acceptance/bundle/resources/postgres_branches/replace_existing/script +++ b/acceptance/bundle/resources/postgres_branches/replace_existing/script @@ -1,8 +1,7 @@ envsubst < databricks.yml.tmpl > databricks.yml cleanup() { - # bundle destroy cannot remove the implicit production branch, so the - # project will still exist after destroy. Cascade-delete to clean up. + # Belt-and-braces in case bundle destroy was skipped or partially failed. $CLI postgres delete-project "projects/test-pg-proj-${UNIQUE_NAME}" 2>/dev/null || true rm -f out.requests.txt } @@ -18,11 +17,12 @@ trace $CLI postgres get-branch "projects/test-pg-proj-${UNIQUE_NAME}/branches/pr trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.deploy.$DATABRICKS_BUNDLE_ENGINE.json -# bundle destroy is expected to fail: the production branch is the root -# branch of its project and the backend rejects independent deletion. The -# per-engine output captures the exact error shape so any regression in -# engine error formatting (or in server behaviour) is detectable. -title "bundle destroy (expected to fail on root branch)" +# bundle destroy: the backend rejects independent deletion of the root +# branch with a MANAGED_BY_PARENT marker; both engines disregard that +# error and let the project delete cascade-clean the branch. Per-engine +# output is captured so regressions in either engine's destroy flow show +# up in the diff. +title "bundle destroy" $CLI bundle destroy --auto-approve > out.destroy.$DATABRICKS_BUNDLE_ENGINE.txt 2>&1 || true trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.destroy.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/output.txt b/acceptance/bundle/resources/postgres_endpoints/replace_existing/output.txt index ac1a74b2952..c1823ef73c0 100644 --- a/acceptance/bundle/resources/postgres_endpoints/replace_existing/output.txt +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/output.txt @@ -41,5 +41,5 @@ Deployment complete! >>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ -=== bundle destroy (expected to fail on read-write endpoint) +=== bundle destroy >>> print_requests.py --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ diff --git a/acceptance/bundle/resources/postgres_endpoints/replace_existing/script b/acceptance/bundle/resources/postgres_endpoints/replace_existing/script index 464cfd7c45f..b88120d5f31 100644 --- a/acceptance/bundle/resources/postgres_endpoints/replace_existing/script +++ b/acceptance/bundle/resources/postgres_endpoints/replace_existing/script @@ -1,8 +1,7 @@ envsubst < databricks.yml.tmpl > databricks.yml cleanup() { - # bundle destroy cannot remove the implicit primary endpoint, so the - # project will still exist after destroy. Cascade-delete to clean up. + # Belt-and-braces in case bundle destroy was skipped or partially failed. $CLI postgres delete-project "projects/test-pg-proj-${UNIQUE_NAME}" 2>/dev/null || true rm -f out.requests.txt } @@ -20,12 +19,12 @@ trace $CLI postgres get-endpoint "projects/test-pg-proj-${UNIQUE_NAME}/branches/ trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.deploy.$DATABRICKS_BUNDLE_ENGINE.json -# bundle destroy is expected to fail: the primary endpoint of the develop -# branch is a read-write endpoint and the backend rejects independent -# deletion. The per-engine output captures the exact error shape so any -# regression in engine error formatting (or in server behaviour) is -# detectable. -title "bundle destroy (expected to fail on read-write endpoint)" +# bundle destroy: the backend rejects independent deletion of the primary +# read-write endpoint with a MANAGED_BY_PARENT marker; both engines +# disregard that error and let the parent branch delete cascade-clean the +# endpoint. Per-engine output is captured so regressions in either +# engine's destroy flow show up in the diff. +title "bundle destroy" $CLI bundle destroy --auto-approve > out.destroy.$DATABRICKS_BUNDLE_ENGINE.txt 2>&1 || true trace print_requests.py --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.destroy.$DATABRICKS_BUNDLE_ENGINE.json From dcb0c5f3f332bc04dfc6585735ea77e20ad741c6 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 21:28:32 +0200 Subject: [PATCH 6/7] acc: refresh refschema snapshot for replace_existing fields The bundle/refschema test catalogs every resource field. Adding replace_existing to PostgresBranchConfig and PostgresEndpointConfig shows up there and needed the snapshot update. Co-authored-by: Isaac --- acceptance/bundle/refschema/out.fields.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 999296b2139..decf27ca913 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -2706,6 +2706,7 @@ resources.postgres_branches.*.modified_status string INPUT resources.postgres_branches.*.name string REMOTE resources.postgres_branches.*.no_expiry bool INPUT STATE resources.postgres_branches.*.parent string ALL +resources.postgres_branches.*.replace_existing bool INPUT STATE resources.postgres_branches.*.source_branch string INPUT STATE resources.postgres_branches.*.source_branch_lsn string INPUT STATE resources.postgres_branches.*.source_branch_time *time.Time INPUT STATE @@ -2750,6 +2751,7 @@ resources.postgres_endpoints.*.modified_status string INPUT resources.postgres_endpoints.*.name string REMOTE resources.postgres_endpoints.*.no_suspension bool INPUT STATE resources.postgres_endpoints.*.parent string ALL +resources.postgres_endpoints.*.replace_existing bool INPUT STATE resources.postgres_endpoints.*.settings *postgres.EndpointSettings INPUT STATE resources.postgres_endpoints.*.settings.pg_settings map[string]string INPUT STATE resources.postgres_endpoints.*.settings.pg_settings.* string INPUT STATE From 60141ba222a67c4f485cc54a810e14af357a17e5 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 18 May 2026 22:04:00 +0200 Subject: [PATCH 7/7] direct: Suppress MANAGED_BY_PARENT in Recreate as well Recreate is a separate path from Delete in the direct engine and only checked isResourceGone. That left a direct/terraform behavior gap: an immutable-field change on a parent-managed resource (e.g. flipping endpoint_type on an implicit primary endpoint) would hard-fail on the delete-half of recreate in direct mode, while terraform routes the same call through the provider's Delete with the suppression in place. Apply isManagedByParent here too so the subsequent Create with replace_existing=true reconfigures the resource in place, matching terraform. Co-authored-by: Isaac --- bundle/direct/apply.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index 6bda08505a6..e327cb8563e 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -80,9 +80,12 @@ func (d *DeploymentUnit) Create(ctx context.Context, db *dstate.DeploymentState, } func (d *DeploymentUnit) Recreate(ctx context.Context, db *dstate.DeploymentState, oldID string, newState any) error { - // Note, unlike Delete(), we hard error on 403 here intentionally + // Note, unlike Delete(), we hard error on 403 here intentionally. + // MANAGED_BY_PARENT is still disregarded — the subsequent Create with + // replace_existing=true will reconfigure the parent-managed resource in + // place, matching the Terraform provider's recreate behaviour. err := d.Adapter.DoDelete(ctx, oldID) - if err != nil && !isResourceGone(err) { + if err != nil && !isResourceGone(err) && !isManagedByParent(err) { return fmt.Errorf("deleting old id=%s: %w", oldID, err) }