diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 7f93d586b83..d9640f7ebc5 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -2954,6 +2954,7 @@ resources.postgres_branches.*.modified_status string INPUT resources.postgres_branches.*.name string REMOTE resources.postgres_branches.*.no_expiry bool ALL resources.postgres_branches.*.parent string ALL +resources.postgres_branches.*.purge_on_delete bool INPUT STATE resources.postgres_branches.*.replace_existing bool INPUT STATE resources.postgres_branches.*.source_branch string ALL resources.postgres_branches.*.source_branch_lsn string ALL diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_branches/purge_on_delete/databricks.yml.tmpl new file mode 100644 index 00000000000..fe20913512e --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete/databricks.yml.tmpl @@ -0,0 +1,28 @@ +bundle: + name: deploy-postgres-branch-purge-$UNIQUE_NAME + +sync: + paths: [] + +# A project with two branches side by side so the recorded destroy requests +# make the purge_on_delete contrast obvious: +# - hard_delete: purge_on_delete: true -> DELETE …?purge=true +# - soft_delete: field omitted (default) -> DELETE … (no purge query) +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Branch purge_on_delete" + pg_version: 16 + + postgres_branches: + hard_delete: + parent: ${resources.postgres_projects.my_project.id} + branch_id: test-pg-branch-hard-$UNIQUE_NAME + no_expiry: true + purge_on_delete: true + + soft_delete: + parent: ${resources.postgres_projects.my_project.id} + branch_id: test-pg-branch-soft-$UNIQUE_NAME + no_expiry: true diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.destroy.txt b/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.destroy.txt new file mode 100644 index 00000000000..90c4cff4ada --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.destroy.txt @@ -0,0 +1,18 @@ +The following resources will be deleted: + delete resources.postgres_branches.hard_delete + delete resources.postgres_branches.soft_delete + 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.hard_delete + delete resources.postgres_branches.soft_delete + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-branch-purge-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.requests.deploy.direct.json b/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.requests.deploy.direct.json new file mode 100644 index 00000000000..a2d9b93874c --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.requests.deploy.direct.json @@ -0,0 +1,45 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/test-pg-branch-hard-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/test-pg-branch-soft-[UNIQUE_NAME]" +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Branch purge_on_delete", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "test-pg-branch-hard-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "test-pg-branch-soft-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.requests.deploy.terraform.json b/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.requests.deploy.terraform.json new file mode 100644 index 00000000000..01ff62d44c0 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.requests.deploy.terraform.json @@ -0,0 +1,47 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/test-pg-branch-hard-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/test-pg-branch-soft-[UNIQUE_NAME]" +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Branch purge_on_delete", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "test-pg-branch-hard-[UNIQUE_NAME]" + }, + "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", + "q": { + "branch_id": "test-pg-branch-soft-[UNIQUE_NAME]" + }, + "body": { + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "spec": { + "no_expiry": true + } + } +} diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.requests.destroy.json b/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.requests.destroy.json new file mode 100644 index 00000000000..f4296d06190 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.requests.destroy.json @@ -0,0 +1,27 @@ +{ + "method": "DELETE", + "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/test-pg-branch-hard-[UNIQUE_NAME]", + "q": { + "purge": "true" + } +} +{ + "method": "DELETE", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/test-pg-branch-soft-[UNIQUE_NAME]" +} +{ + "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/test-pg-branch-hard-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/test-pg-branch-soft-[UNIQUE_NAME]" +} diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.test.toml b/acceptance/bundle/resources/postgres_branches/purge_on_delete/out.test.toml new file mode 100644 index 00000000000..110f841fa05 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete/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/purge_on_delete/output.txt b/acceptance/bundle/resources/postgres_branches/purge_on_delete/output.txt new file mode 100644 index 00000000000..6e5d1339e69 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete/output.txt @@ -0,0 +1,56 @@ + +>>> [CLI] bundle validate +Name: deploy-postgres-branch-purge-[UNIQUE_NAME] +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-branch-purge-[UNIQUE_NAME]/default + +Validation OK! + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-postgres-branch-purge-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] postgres get-branch projects/test-pg-proj-[UNIQUE_NAME]/branches/test-pg-branch-hard-[UNIQUE_NAME] +{ + "branch_id": "test-pg-branch-hard-[UNIQUE_NAME]", + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/test-pg-branch-hard-[UNIQUE_NAME]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "status": { + "branch_id": "test-pg-branch-hard-[UNIQUE_NAME]", + "current_state": "READY", + "default": false, + "is_protected": false, + "source_branch": "projects/test-pg-proj-[UNIQUE_NAME]/branches/production", + "source_branch_lsn": "[LSN]", + "source_branch_time": "[TIMESTAMP]", + "state_change_time": "[TIMESTAMP]" + }, + "uid": "[BRANCH_UID]" +} + +>>> [CLI] postgres get-branch projects/test-pg-proj-[UNIQUE_NAME]/branches/test-pg-branch-soft-[UNIQUE_NAME] +{ + "branch_id": "test-pg-branch-soft-[UNIQUE_NAME]", + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/test-pg-branch-soft-[UNIQUE_NAME]", + "parent": "projects/test-pg-proj-[UNIQUE_NAME]", + "status": { + "branch_id": "test-pg-branch-soft-[UNIQUE_NAME]", + "current_state": "READY", + "default": false, + "is_protected": false, + "source_branch": "projects/test-pg-proj-[UNIQUE_NAME]/branches/production", + "source_branch_lsn": "[LSN]", + "source_branch_time": "[TIMESTAMP]", + "state_change_time": "[TIMESTAMP]" + }, + "uid": "[BRANCH_UID]" +} + +>>> print_requests.py --del-body project_id,branch_id,endpoint_id,database_id,role_id,catalog_id,synced_table_id --sort --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ + +=== bundle destroy +>>> print_requests.py --del-body project_id,branch_id,endpoint_id,database_id,role_id,catalog_id,synced_table_id --sort --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete/script b/acceptance/bundle/resources/postgres_branches/purge_on_delete/script new file mode 100644 index 00000000000..cff76451f29 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete/script @@ -0,0 +1,35 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +project_name="projects/test-pg-proj-${UNIQUE_NAME}" +hard_branch="${project_name}/branches/test-pg-branch-hard-${UNIQUE_NAME}" +soft_branch="${project_name}/branches/test-pg-branch-soft-${UNIQUE_NAME}" + +cleanup() { + # Belt-and-braces in case bundle destroy was skipped or partially failed. + # The soft-delete case leaves a record in the trash; --purge clears it. + $CLI postgres delete-branch --purge "${hard_branch}" 2>>LOG.delete-branch || true + $CLI postgres delete-branch --purge "${soft_branch}" 2>>LOG.delete-branch || true + $CLI postgres delete-project --purge "${project_name}" 2>>LOG.delete-project || true + rm -f out.requests.txt +} +trap cleanup EXIT + +trace $CLI bundle validate + +rm -f out.requests.txt +trace $CLI bundle deploy + +trace $CLI postgres get-branch "${hard_branch}" | jq 'del(.create_time, .update_time, .status.logical_size_bytes)' +trace $CLI postgres get-branch "${soft_branch}" | jq 'del(.create_time, .update_time, .status.logical_size_bytes)' + +# Deploy requests are split per-engine: Terraform sends "parent" in the branch +# create body, the direct engine sends it only as a query parameter. +trace print_requests.py --del-body project_id,branch_id,endpoint_id,database_id,role_id,catalog_id,synced_table_id --sort --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.deploy.$DATABRICKS_BUNDLE_ENGINE.json + +# bundle destroy should send ?purge=true on the hard_delete branch and no +# purge query on the soft_delete branch. Both DELETEs land in the recorded +# requests so the contrast is visible in the diff. +title "bundle destroy" +$CLI bundle destroy --auto-approve > out.destroy.txt 2>&1 || true + +trace print_requests.py --del-body project_id,branch_id,endpoint_id,database_id,role_id,catalog_id,synced_table_id --sort --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.destroy.json diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/databricks.yml.tmpl new file mode 100644 index 00000000000..a8bc2b191c9 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/databricks.yml.tmpl @@ -0,0 +1,23 @@ +bundle: + name: pg-branch-purge-transitions-$UNIQUE_NAME + +sync: + paths: [] + +# Walks purge_on_delete through unset -> true -> false -> unset, deploying +# at each step and inspecting the persisted state so reviewers can see that +# state tracks the user's latest intent. The final destroy is a soft delete +# (state has purge_on_delete unset), recorded for regression coverage. +resources: + postgres_projects: + proj: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Transitions test" + pg_version: 16 + + postgres_branches: + branch: + parent: ${resources.postgres_projects.proj.id} + branch_id: test-pg-branch-$UNIQUE_NAME + no_expiry: true + # PURGE_ON_DELETE diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/out.destroy.txt b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/out.destroy.txt new file mode 100644 index 00000000000..fe24a0edf04 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/out.destroy.txt @@ -0,0 +1,16 @@ +The following resources will be deleted: + delete resources.postgres_branches.branch + delete resources.postgres_projects.proj + +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.proj + +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.branch + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/pg-branch-purge-transitions-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/out.requests.destroy.json b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/out.requests.destroy.json new file mode 100644 index 00000000000..ebefc7fdaa8 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/out.requests.destroy.json @@ -0,0 +1,16 @@ +{ + "method": "DELETE", + "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/test-pg-branch-[UNIQUE_NAME]" +} +{ + "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/test-pg-branch-[UNIQUE_NAME]" +} diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/out.test.toml b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/out.test.toml new file mode 100644 index 00000000000..4fe23e297fe --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/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_branches/purge_on_delete_transitions/output.txt b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/output.txt new file mode 100644 index 00000000000..24b99e5a9a4 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/output.txt @@ -0,0 +1,43 @@ + +=== Step 1: deploy with purge_on_delete unset +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/pg-branch-purge-transitions-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> get_purge +(unset) + +=== Step 2: set purge_on_delete: true +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/pg-branch-purge-transitions-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> get_purge +json.state.resources.postgres_branches.branch.state.purge_on_delete = true; + +=== Step 3: flip to purge_on_delete: false +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/pg-branch-purge-transitions-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> get_purge +json.state.resources.postgres_branches.branch.state.purge_on_delete = false; + +=== Step 4: remove the line again +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/pg-branch-purge-transitions-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> get_purge +json.state.resources.postgres_branches.branch.state.purge_on_delete = false; + +=== bundle destroy (branch DELETE must be a plain DELETE, no ?purge=true) +>>> print_requests.py --del-body project_id,branch_id,endpoint_id,database_id,role_id,catalog_id,synced_table_id --sort --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/ diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/script b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/script new file mode 100644 index 00000000000..919fe6e4f4f --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/script @@ -0,0 +1,45 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +project_name="projects/test-pg-proj-${UNIQUE_NAME}" +branch_name="${project_name}/branches/test-pg-branch-${UNIQUE_NAME}" + +cleanup() { + # After this test the branch destroy should be a soft delete; --purge here + # so the trash entry doesn't linger past the test. + $CLI postgres delete-branch --purge "${branch_name}" 2>>LOG.delete-branch || true + $CLI postgres delete-project --purge "${project_name}" 2>>LOG.delete-project || true + rm -f out.requests.txt +} +trap cleanup EXIT + +# Prints the persisted purge_on_delete assignment from the direct engine's +# resources.json. The field is omitted from JSON when unset (omitempty + +# ForceSendFields tracking), so an absent value falls back to "(unset)". +get_purge() { + gron.py < .databricks/bundle/default/resources.json | grep 'postgres_branches.branch.state.purge_on_delete' || echo '(unset)' +} + +title "Step 1: deploy with purge_on_delete unset" +trace $CLI bundle deploy +trace get_purge + +title "Step 2: set purge_on_delete: true" +update_file.py databricks.yml "# PURGE_ON_DELETE" "purge_on_delete: true" +trace $CLI bundle deploy +trace get_purge + +title "Step 3: flip to purge_on_delete: false" +update_file.py databricks.yml "purge_on_delete: true" "purge_on_delete: false" +trace $CLI bundle deploy +trace get_purge + +title "Step 4: remove the line again" +update_file.py databricks.yml "purge_on_delete: false" "# PURGE_ON_DELETE" +trace $CLI bundle deploy +trace get_purge + +rm -f out.requests.txt + +title "bundle destroy (branch DELETE must be a plain DELETE, no ?purge=true)" +$CLI bundle destroy --auto-approve > out.destroy.txt 2>&1 || true +trace print_requests.py --del-body project_id,branch_id,endpoint_id,database_id,role_id,catalog_id,synced_table_id --sort --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.destroy.json diff --git a/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/test.toml b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/test.toml new file mode 100644 index 00000000000..797a2d57508 --- /dev/null +++ b/acceptance/bundle/resources/postgres_branches/purge_on_delete_transitions/test.toml @@ -0,0 +1,4 @@ +# Direct engine only: this test exercises the FSF preservation in +# PrepareState and direct's plan/diff classification when the user flips +# purge_on_delete. Terraform has its own provider-managed state lifecycle. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/bundle/config/resources/postgres_branch.go b/bundle/config/resources/postgres_branch.go index a5d093ca194..9ae7f5f9200 100644 --- a/bundle/config/resources/postgres_branch.go +++ b/bundle/config/resources/postgres_branch.go @@ -24,6 +24,18 @@ type PostgresBranchConfig struct { // 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"` + + // PurgeOnDelete, when true, hard-deletes the branch on destroy (Purge=true on + // DeleteBranch). When false or unset, the backend performs a soft delete that + // can be undone within the branch's retention window. Input-only: not + // returned by the GET API. + PurgeOnDelete bool `json:"purge_on_delete,omitempty"` + + // ForceSendFields shadows the embedded BranchSpec.ForceSendFields so the + // SDK's marshal package tracks zero-value top-level fields (branch_id, + // parent, replace_existing, purge_on_delete) here instead of polluting + // BranchSpec.ForceSendFields with names that don't exist in that struct. + ForceSendFields []string `json:"-" url:"-"` } func (c *PostgresBranchConfig) 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 12914e93074..0867c392e0c 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", "replace_existing"} + topLevelFields := []string{"branch_id", "parent", "replace_existing", "purge_on_delete"} // Build the spec block from the flattened fields specMap := make(map[string]dyn.Value) diff --git a/bundle/deploy/terraform/tfdyn/convert_postgres_branch_test.go b/bundle/deploy/terraform/tfdyn/convert_postgres_branch_test.go index 0f8040caf19..39902579cfe 100644 --- a/bundle/deploy/terraform/tfdyn/convert_postgres_branch_test.go +++ b/bundle/deploy/terraform/tfdyn/convert_postgres_branch_test.go @@ -78,6 +78,37 @@ func TestConvertPostgresBranchWithSourceBranch(t *testing.T) { }, postgresBranch) } +func TestConvertPostgresBranchPurgeOnDelete(t *testing.T) { + src := resources.PostgresBranch{ + PostgresBranchConfig: resources.PostgresBranchConfig{ + BranchId: "my-branch", + Parent: "projects/my-project", + PurgeOnDelete: true, + BranchSpec: postgres.BranchSpec{ + IsProtected: true, + }, + }, + } + + vin, err := convert.FromTyped(src, dyn.NilValue) + require.NoError(t, err) + + ctx := t.Context() + out := schema.NewResources() + err = postgresBranchConverter{}.Convert(ctx, "my_postgres_branch", vin, out) + require.NoError(t, err) + + postgresBranch := out.PostgresBranch["my_postgres_branch"] + assert.Equal(t, map[string]any{ + "branch_id": "my-branch", + "parent": "projects/my-project", + "purge_on_delete": true, + "spec": map[string]any{ + "is_protected": true, + }, + }, postgresBranch) +} + func TestConvertPostgresBranchMinimal(t *testing.T) { src := resources.PostgresBranch{ PostgresBranchConfig: resources.PostgresBranchConfig{ diff --git a/bundle/direct/dresources/postgres_branch.go b/bundle/direct/dresources/postgres_branch.go index 82b50f8a10c..b033b42a8f0 100644 --- a/bundle/direct/dresources/postgres_branch.go +++ b/bundle/direct/dresources/postgres_branch.go @@ -2,6 +2,7 @@ package dresources import ( "context" + "slices" "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/databricks-sdk-go" @@ -52,7 +53,9 @@ func (*ResourcePostgresBranch) PrepareState(input *resources.PostgresBranch) *Po BranchId: input.BranchId, Parent: input.Parent, ReplaceExisting: input.ReplaceExisting, + PurgeOnDelete: input.PurgeOnDelete, BranchSpec: input.BranchSpec, + ForceSendFields: input.ForceSendFields, } } @@ -65,6 +68,11 @@ func (*ResourcePostgresBranch) RemapState(remote *PostgresBranchRemote) *Postgre // it, so RemapState leaves it false. ReplaceExisting: false, + // purge_on_delete is a delete-time query parameter; the GET API never + // returns it, so RemapState leaves it false. + PurgeOnDelete: false, + ForceSendFields: nil, + BranchSpec: remote.BranchSpec, } } @@ -133,12 +141,25 @@ func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, config *PostgresB } func (r *ResourcePostgresBranch) DoUpdate(ctx context.Context, id string, config *PostgresBranchState, entry *PlanEntry) (*PostgresBranchRemote, 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 Branch object, - // not relative to our flattened state type. + // Build the mask from the plan's change list and prefix with "spec." (the + // API expects paths relative to Branch). The API rejects mask entries + // that aren't also populated in the request body, and a wildcard "*" + // expands to nested attributes the body would have to set too — so we + // can't use a static all-fields mask. The change list naturally tracks + // what the user actually set, so the body and mask stay consistent. fieldPaths := collectUpdatePathsWithPrefix(entry.Changes, "spec.") + // purge_on_delete is an input-only flag consulted at delete time; it is + // not a spec field. Strip it from the mask so toggling it between deploys + // becomes a state-only refresh (the framework saves newState when this + // returns nil error). + fieldPaths = slices.DeleteFunc(fieldPaths, func(p string) bool { + return p == "spec.purge_on_delete" + }) + if len(fieldPaths) == 0 { + return nil, nil + } + waiter, err := r.client.Postgres.UpdateBranch(ctx, postgres.UpdateBranchRequest{ Branch: postgres.Branch{ Spec: &config.BranchSpec, @@ -170,10 +191,10 @@ func (r *ResourcePostgresBranch) DoUpdate(ctx context.Context, id string, config return makePostgresBranchRemote(result), nil } -func (r *ResourcePostgresBranch) DoDelete(ctx context.Context, id string, _ *PostgresBranchState) error { +func (r *ResourcePostgresBranch) DoDelete(ctx context.Context, id string, state *PostgresBranchState) error { waiter, err := r.client.Postgres.DeleteBranch(ctx, postgres.DeleteBranchRequest{ Name: id, - Purge: false, + Purge: state.PurgeOnDelete, ForceSendFields: nil, }) if err != nil { diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 3474450ed3e..8123dc9f264 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -37,6 +37,7 @@ var knownMissingInRemoteType = map[string][]string{ }, "postgres_branches": { "replace_existing", + "purge_on_delete", }, "postgres_endpoints": { "replace_existing", diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 7fafe145330..b76a51f781b 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -1362,6 +1362,9 @@ resources: "parent": "description": |- PLACEHOLDER + "purge_on_delete": + "description": |- + PLACEHOLDER "replace_existing": "description": |- PLACEHOLDER diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 503ddbf2ec4..b6a3a492318 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1672,6 +1672,9 @@ "parent": { "$ref": "#/$defs/string" }, + "purge_on_delete": { + "$ref": "#/$defs/bool" + }, "replace_existing": { "$ref": "#/$defs/bool" }, diff --git a/bundle/terraform_dabs_map/generated.go b/bundle/terraform_dabs_map/generated.go index c21fa03ed6f..c44321f114a 100644 --- a/bundle/terraform_dabs_map/generated.go +++ b/bundle/terraform_dabs_map/generated.go @@ -18,7 +18,6 @@ package terraform_dabs_map // pipelines / databricks_pipeline: 3 renames // pipelines / databricks_pipeline: 5 dabs-only // pipelines / databricks_pipeline: 2 tf-only -// 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 tf-only @@ -576,9 +575,6 @@ var TerraformOnlyFields = map[string]FieldSet{ "expected_last_modified": {}, "url": {}, }, - "postgres_branches": { - "purge_on_delete": {}, - }, "postgres_databases": { "replace_existing": {}, }, diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index 83b50213a30..7d34e806c98 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -452,7 +452,10 @@ func (s *FakeWorkspace) PostgresBranchUpdate(req Request, name string) Response } } -// PostgresBranchDelete deletes a postgres branch. +// PostgresBranchDelete deletes a postgres branch. The `purge` query parameter +// is ignored: acceptance tests assert on the recorded HTTP request rather than +// on retention semantics, so a single "remove from map" action serves both +// hard- and soft-delete paths. func (s *FakeWorkspace) PostgresBranchDelete(name string) Response { defer s.LockUnlock()()