From 8fc59b4ab4d6c8d1a6259b645b4a085ec715a5bd Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 07:58:34 +0000 Subject: [PATCH 1/6] Record deployment history for bundle resource unbindings --- .nextchanges/bundles/record-unbind-history.md | 1 + .../bind/feature/out.unbind.false.txt | 2 + .../bind/feature/out.unbind.true.txt | 3 + .../bundle/deployment/bind/feature/output.txt | 2 - .../bundle/deployment/bind/feature/script | 2 +- .../dms/bind-unbind/databricks.yml.tmpl | 3 + acceptance/bundle/dms/bind-unbind/output.txt | 25 ++++-- acceptance/bundle/dms/bind-unbind/script | 8 ++ .../databricks.yml.tmpl | 13 +++ .../dms/unbind-history-failures/out.test.toml | 2 + .../dms/unbind-history-failures/output.txt | 80 +++++++++++++++++++ .../bundle/dms/unbind-history-failures/script | 37 +++++++++ .../dms/unbind-history-failures/test.toml | 1 + bundle/direct/bind.go | 37 +++++++-- bundle/direct/bind_test.go | 58 ++++++++++++++ bundle/phases/bind.go | 33 +++++++- 16 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 .nextchanges/bundles/record-unbind-history.md create mode 100644 acceptance/bundle/deployment/bind/feature/out.unbind.false.txt create mode 100644 acceptance/bundle/deployment/bind/feature/out.unbind.true.txt create mode 100644 acceptance/bundle/dms/unbind-history-failures/databricks.yml.tmpl create mode 100644 acceptance/bundle/dms/unbind-history-failures/out.test.toml create mode 100644 acceptance/bundle/dms/unbind-history-failures/output.txt create mode 100644 acceptance/bundle/dms/unbind-history-failures/script create mode 100644 acceptance/bundle/dms/unbind-history-failures/test.toml create mode 100644 bundle/direct/bind_test.go diff --git a/.nextchanges/bundles/record-unbind-history.md b/.nextchanges/bundles/record-unbind-history.md new file mode 100644 index 00000000000..f615c93c5a3 --- /dev/null +++ b/.nextchanges/bundles/record-unbind-history.md @@ -0,0 +1 @@ +* Record deployment history when unbinding resources with `bundle deployment unbind`. ([#7401](https://github.com/databricks/cli/pull/7401)) diff --git a/acceptance/bundle/deployment/bind/feature/out.unbind.false.txt b/acceptance/bundle/deployment/bind/feature/out.unbind.false.txt new file mode 100644 index 00000000000..6779f487c1f --- /dev/null +++ b/acceptance/bundle/deployment/bind/feature/out.unbind.false.txt @@ -0,0 +1,2 @@ + +>>> [CLI] bundle deployment unbind feature1 diff --git a/acceptance/bundle/deployment/bind/feature/out.unbind.true.txt b/acceptance/bundle/deployment/bind/feature/out.unbind.true.txt new file mode 100644 index 00000000000..2dace068795 --- /dev/null +++ b/acceptance/bundle/deployment/bind/feature/out.unbind.true.txt @@ -0,0 +1,3 @@ + +>>> [CLI] bundle deployment unbind feature1 +Warn: Failed to record unbind history: failed to create deployment version: operations.resource_key must have a known resource type prefix (e.g. 'jobs.', 'pipelines.'): features.feature1 diff --git a/acceptance/bundle/deployment/bind/feature/output.txt b/acceptance/bundle/deployment/bind/feature/output.txt index d4d47f6a660..e0bca28a059 100644 --- a/acceptance/bundle/deployment/bind/feature/output.txt +++ b/acceptance/bundle/deployment/bind/feature/output.txt @@ -15,8 +15,6 @@ Resources: Name: main.myschema.boundfeature URL: [DATABRICKS_URL]/explore/data/features/main/myschema/boundfeature?w=[WORKSPACE_ID] ->>> [CLI] bundle deployment unbind feature1 - >>> [CLI] bundle summary Name: test-bundle Target: default diff --git a/acceptance/bundle/deployment/bind/feature/script b/acceptance/bundle/deployment/bind/feature/script index 8e27f69056c..019816f7499 100644 --- a/acceptance/bundle/deployment/bind/feature/script +++ b/acceptance/bundle/deployment/bind/feature/script @@ -1,5 +1,5 @@ trace $CLI bundle deployment bind feature1 main.myschema.boundfeature --auto-approve trace $CLI bundle summary -trace $CLI bundle deployment unbind feature1 +trace $CLI bundle deployment unbind feature1 2> out.unbind.$DMS.txt trace $CLI bundle summary diff --git a/acceptance/bundle/dms/bind-unbind/databricks.yml.tmpl b/acceptance/bundle/dms/bind-unbind/databricks.yml.tmpl index 92a70710626..417b10b4962 100644 --- a/acceptance/bundle/dms/bind-unbind/databricks.yml.tmpl +++ b/acceptance/bundle/dms/bind-unbind/databricks.yml.tmpl @@ -4,3 +4,6 @@ resources: jobs: foo: name: foo + permissions: + - group_name: users + level: CAN_VIEW diff --git a/acceptance/bundle/dms/bind-unbind/output.txt b/acceptance/bundle/dms/bind-unbind/output.txt index 922b2985955..3afd7f718d3 100644 --- a/acceptance/bundle/dms/bind-unbind/output.txt +++ b/acceptance/bundle/dms/bind-unbind/output.txt @@ -41,9 +41,10 @@ Run 'bundle deploy' to deploy changes to your workspace === Deploy keeps the bound job ID with recorded history >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-bind-unbind-[UNIQUE_NAME]/default/files... +Created jobs.foo.permissions Updated jobs.foo Files: 2 uploaded, 0 deleted -Resources: 0 created, 1 changed, 0 deleted, 0 unchanged +Resources: 1 created, 1 changed, 0 deleted, 0 unchanged >>> jq {features, resource_id: .state["resources.jobs.foo"].__id__} .databricks/bundle/default/resources.json { @@ -56,6 +57,14 @@ Resources: 0 created, 1 changed, 0 deleted, 0 unchanged === Unbind preserves the shadow history marker and leaves the job intact >>> [CLI] bundle deployment unbind foo +>>> print_requests.py --dms //versions --oneline --keep +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "6"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.foo"}, {"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.foo.permissions"}], "previous_version_id": "4", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/operations/jobs.foo", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/operations/jobs.foo.permissions", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} + +>>> print_requests.py //jobs //permissions + >>> jq {features, state} .databricks/bundle/default/resources.json { "features": { @@ -69,6 +78,11 @@ Resources: 0 created, 1 changed, 0 deleted, 0 unchanged "job_id": [JOB_ID] } +=== Unbinding an already unbound resource creates no history version +>>> [CLI] bundle deployment unbind foo + +>>> print_requests.py --dms //versions + === Rebind preserves the existing shadow history marker >>> [CLI] bundle deployment bind foo [JOB_ID] --auto-approve Successfully bound job with an id '[JOB_ID]' @@ -83,15 +97,16 @@ Run 'bundle deploy' to deploy changes to your workspace } >>> print_requests.py --dms //versions --oneline -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "7"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.foo"}], "previous_version_id": "4", "version_type": "VERSION_TYPE_DEPLOY"}} -{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/7/operations/jobs.foo", "q": {"update_mask": "state,error_message,resource_id,status"}, "body": {"error_message": "", "resource_id": "[JOB_ID]", "sequence_id": 0, "state": "{\"state\":{\"deployment\":{\"kind\":\"BUNDLE\",\"metadata_file_path\":\"/Workspace/Users/[USERNAME]/.bundle/dms-bind-unbind-[UNIQUE_NAME]/default/state/metadata.json\"},\"edit_mode\":\"UI_LOCKED\",\"format\":\"MULTI_TASK\",\"max_concurrent_runs\":1,\"name\":\"foo\",\"queue\":{\"enabled\":true}}}", "status": "OPERATION_STATUS_SUCCEEDED"}} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/7/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "10"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.foo"}], "previous_version_id": "6", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/10/operations/jobs.foo", "q": {"update_mask": "state,error_message,resource_id,status"}, "body": {"error_message": "", "resource_id": "[JOB_ID]", "sequence_id": 0, "state": "{\"state\":{\"deployment\":{\"kind\":\"BUNDLE\",\"metadata_file_path\":\"/Workspace/Users/[USERNAME]/.bundle/dms-bind-unbind-[UNIQUE_NAME]/default/state/metadata.json\"},\"edit_mode\":\"UI_LOCKED\",\"format\":\"MULTI_TASK\",\"max_concurrent_runs\":1,\"name\":\"foo\",\"queue\":{\"enabled\":true}}}", "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/10/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} === A subsequent deploy continues managing the same job >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-bind-unbind-[UNIQUE_NAME]/default/files... +Created jobs.foo.permissions Files: 0 uploaded, 0 deleted -Resources: 0 created, 0 changed, 0 deleted, 1 unchanged +Resources: 1 created, 0 changed, 0 deleted, 1 unchanged >>> jq {features, resource_id: .state["resources.jobs.foo"].__id__} .databricks/bundle/default/resources.json { diff --git a/acceptance/bundle/dms/bind-unbind/script b/acceptance/bundle/dms/bind-unbind/script index 8c7d75c3d43..8d0f34a0b11 100644 --- a/acceptance/bundle/dms/bind-unbind/script +++ b/acceptance/bundle/dms/bind-unbind/script @@ -23,13 +23,21 @@ title "Deploy keeps the bound job ID with recorded history" trace $CLI bundle deploy trace jq '{features, resource_id: .state["resources.jobs.foo"].__id__}' .databricks/bundle/default/resources.json +rm -f "$OUT_REQUESTS" + title "Unbind preserves the shadow history marker and leaves the job intact" trace $CLI bundle deployment unbind foo +trace print_requests.py --dms //versions --oneline --keep +trace print_requests.py //jobs //permissions trace jq '{features, state}' .databricks/bundle/default/resources.json trace $CLI jobs get "$job_id" | jq '{job_id}' rm -f "$OUT_REQUESTS" +title "Unbinding an already unbound resource creates no history version" +trace $CLI bundle deployment unbind foo +trace print_requests.py --dms //versions + title "Rebind preserves the existing shadow history marker" trace $CLI bundle deployment bind foo "$job_id" --auto-approve trace jq '{features, resource_id: .state["resources.jobs.foo"].__id__}' .databricks/bundle/default/resources.json diff --git a/acceptance/bundle/dms/unbind-history-failures/databricks.yml.tmpl b/acceptance/bundle/dms/unbind-history-failures/databricks.yml.tmpl new file mode 100644 index 00000000000..a30c578aa55 --- /dev/null +++ b/acceptance/bundle/dms/unbind-history-failures/databricks.yml.tmpl @@ -0,0 +1,13 @@ +bundle: + name: dms-unbind-history-failures-$UNIQUE_NAME + +resources: + jobs: + create_failed: + name: create_failed + record_failed: + name: record_failed + complete_failed: + name: complete_failed + upload_failed: + name: upload_failed diff --git a/acceptance/bundle/dms/unbind-history-failures/out.test.toml b/acceptance/bundle/dms/unbind-history-failures/out.test.toml new file mode 100644 index 00000000000..88e6fbd847c --- /dev/null +++ b/acceptance/bundle/dms/unbind-history-failures/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["true"] diff --git a/acceptance/bundle/dms/unbind-history-failures/output.txt b/acceptance/bundle/dms/unbind-history-failures/output.txt new file mode 100644 index 00000000000..782f59dd225 --- /dev/null +++ b/acceptance/bundle/dms/unbind-history-failures/output.txt @@ -0,0 +1,80 @@ + +=== A version creation failure does not prevent unbinding +>>> fault.py POST /api/2.0/bundle/deployments/*/versions 400 0 1 + +>>> [CLI] bundle deployment unbind create_failed +Warn: Failed to record unbind history: failed to create deployment version: Fault injected by test. + +=== An operation recording failure does not prevent unbinding +>>> fault.py PATCH /api/2.0/bundle/** 400 0 1 + +>>> [CLI] bundle deployment unbind record_failed +Warn: Failed to record deployment history: recording operation for resources.jobs.record_failed: Fault injected by test. + +=== A version completion failure does not prevent unbinding +>>> fault.py POST /api/2.0/bundle/deployments/*/versions/*/complete 400 0 1 + +>>> [CLI] bundle deployment unbind complete_failed +Warn: Failed to complete deployment history version: Fault injected by test. + +=== Successful unbindings survived the remote state round trips and the job still exists +>>> jq .state .databricks/bundle/default/resources.json +{ + "resources.jobs.upload_failed": { + "__id__": "[JOB_ID]", + "state": { + "deployment": { + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-unbind-history-failures-[UNIQUE_NAME]/default/state/metadata.json" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "upload_failed", + "queue": { + "enabled": true + } + } + } +} + +>>> [CLI] jobs get [JOB_ID] +{ + "job_id": [JOB_ID], + "name": "unbind-target-[UNIQUE_NAME]" +} + +>>> print_requests.py --dms //versions --oneline +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "14"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.create_failed"}], "previous_version_id": "12", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "15"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.record_failed"}], "previous_version_id": "12", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/15/operations/jobs.record_failed", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/15/complete", "body": {"completion_reason": "VERSION_COMPLETE_FAILURE"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "17"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.complete_failed"}], "previous_version_id": "15", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/17/operations/jobs.complete_failed", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/17/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} + +=== A remote state upload failure fails the unbind version +>>> fault.py POST /api/2.0/workspace-files/import-file/**resources.json 400 0 1 + +>>> musterr [CLI] bundle deployment unbind upload_failed +Error: Fault injected by test. (400 INJECTED) + +Endpoint: POST [DATABRICKS_URL]/api/2.0/workspace-files/import-file/Workspace%2FUsers%2F[USERNAME]%2F.bundle%2Fdms-unbind-history-failures-[UNIQUE_NAME]%2Fdefault%2Fstate%2Fresources.json?overwrite=true +HTTP Status: 400 Bad Request +API error_code: INJECTED +API message: Fault injected by test. + + +>>> print_requests.py --dms //versions --oneline +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "19"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.upload_failed"}], "previous_version_id": "17", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/19/operations/jobs.upload_failed", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/19/complete", "body": {"completion_reason": "VERSION_COMPLETE_FAILURE"}} + +=== Remote state still contains the binding whose upload failed +>>> [CLI] bundle summary -o json +{ + "complete_failed": null, + "create_failed": null, + "record_failed": null, + "upload_failed": "[JOB_ID]" +} diff --git a/acceptance/bundle/dms/unbind-history-failures/script b/acceptance/bundle/dms/unbind-history-failures/script new file mode 100644 index 00000000000..ef0101567b2 --- /dev/null +++ b/acceptance/bundle/dms/unbind-history-failures/script @@ -0,0 +1,37 @@ +envsubst < databricks.yml.tmpl > databricks.yml +job_id=$($CLI jobs create --json '{"name": "unbind-target-'$UNIQUE_NAME'"}' | jq -r '.job_id') +add_repl "$job_id" JOB_ID +for key in create_failed record_failed complete_failed upload_failed; do + $CLI bundle deployment bind "$key" "$job_id" --auto-approve >> LOG.setup 2>&1 +done +rm -f "$OUT_REQUESTS" + +title "A version creation failure does not prevent unbinding" +trace fault.py "POST /api/2.0/bundle/deployments/*/versions" 400 0 1 +trace $CLI bundle deployment unbind create_failed +rm -rf .databricks + +title "An operation recording failure does not prevent unbinding" +trace fault.py "PATCH /api/2.0/bundle/**" 400 0 1 +trace $CLI bundle deployment unbind record_failed +rm -rf .databricks + +title "A version completion failure does not prevent unbinding" +trace fault.py "POST /api/2.0/bundle/deployments/*/versions/*/complete" 400 0 1 +trace $CLI bundle deployment unbind complete_failed + +title "Successful unbindings survived the remote state round trips and the job still exists" +trace jq '.state' .databricks/bundle/default/resources.json +trace $CLI jobs get "$job_id" | jq '{job_id, name: .settings.name}' +trace print_requests.py --dms //versions --oneline + + +title "A remote state upload failure fails the unbind version" +trace fault.py "POST /api/2.0/workspace-files/import-file/**resources.json" 400 0 1 +trace musterr $CLI bundle deployment unbind upload_failed +trace print_requests.py --dms //versions --oneline + +rm -rf .databricks +title "Remote state still contains the binding whose upload failed" +trace $CLI bundle summary -o json | jq '.resources.jobs | map_values(.id // null)' +rm -f "$OUT_REQUESTS" diff --git a/acceptance/bundle/dms/unbind-history-failures/test.toml b/acceptance/bundle/dms/unbind-history-failures/test.toml new file mode 100644 index 00000000000..0e4c9337799 --- /dev/null +++ b/acceptance/bundle/dms/unbind-history-failures/test.toml @@ -0,0 +1 @@ +Cloud = false # Inject failures into the deployment history service. diff --git a/bundle/direct/bind.go b/bundle/direct/bind.go index 558d02e1ece..c3bd5bd2e8e 100644 --- a/bundle/direct/bind.go +++ b/bundle/direct/bind.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "slices" "strings" "github.com/databricks/cli/bundle/config" @@ -42,6 +43,12 @@ type DMSBindMetadata struct { ResourceState dstate.ResourceEntry } +// DMSUnbindMetadata holds the removed resource keys and serial to publish after unbind is committed. +type DMSUnbindMetadata struct { + Serial int + ResourceKeys []string +} + // BindResult contains the result of a bind operation including any detected changes. type BindResult struct { // DMSMetadata is nil when deployment history is disabled. @@ -260,18 +267,30 @@ func (result *BindResult) Cancel() { // Unbind removes a resource from direct engine state without deleting // the workspace resource. Also removes associated permissions/grants entries. -func (b *DeploymentBundle) Unbind(ctx context.Context, statePath, resourceKey string) error { +func (b *DeploymentBundle) Unbind(ctx context.Context, configRoot *config.Root, statePath, resourceKey string) (*DMSUnbindMetadata, error) { if err := b.StateDB.Open(ctx, statePath, dstate.ReadingFile, dstate.WithRecovery(true), dstate.OpenDmsArgs{}); err != nil { - return err + return nil, err } if err := b.StateDB.UpgradeToWrite(); err != nil { - return err + return nil, err + } + + var metadata *DMSUnbindMetadata + if b.dmsMetadataEnabled(ctx, configRoot) { + metadata = &DMSUnbindMetadata{Serial: b.StateDB.Data.Serial} + // Finalize commits the removals below as one WAL transaction. + if b.StateDB.Data.State != nil { + metadata.Serial++ + } + if b.StateDB.GetResourceID(resourceKey) != "" { + metadata.ResourceKeys = append(metadata.ResourceKeys, resourceKey) + } } // Delete the main resource err := b.StateDB.DeleteState(ctx, resourceKey, false) if err != nil { - return err + return nil, err } log.Infof(ctx, "Unbound %s", resourceKey) @@ -285,12 +304,18 @@ func (b *DeploymentBundle) Unbind(ctx context.Context, statePath, resourceKey st if key == permissionsKey || key == grantsKey || strings.HasPrefix(key, resourceKey+".") { err = b.StateDB.DeleteState(ctx, key, false) if err != nil { - return err + return nil, err + } + if metadata != nil { + metadata.ResourceKeys = append(metadata.ResourceKeys, key) } log.Infof(ctx, "Unbound %s", key) } } + if metadata != nil { + slices.Sort(metadata.ResourceKeys) + } _, err = b.StateDB.Finalize(ctx) - return err + return metadata, err } diff --git a/bundle/direct/bind_test.go b/bundle/direct/bind_test.go new file mode 100644 index 00000000000..7bc78a2e5e2 --- /dev/null +++ b/bundle/direct/bind_test.go @@ -0,0 +1,58 @@ +package direct_test + +import ( + "path/filepath" + "testing" + + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/direct" + "github.com/databricks/cli/bundle/direct/dstate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnbindReturnsRemovedKeys(t *testing.T) { + for _, tc := range []struct { + name string + keys []string + history bool + }{ + {"resource", []string{"resources.jobs.foo"}, true}, + {"children", []string{"resources.jobs.foo", "resources.jobs.foo.permissions", "resources.jobs.foo.grants"}, true}, + {"children_without_parent", []string{"resources.jobs.foo.permissions"}, true}, + {"missing", nil, true}, + {"history_disabled", []string{"resources.jobs.foo"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + statePath := filepath.Join(t.TempDir(), "resources.json") + var b direct.DeploymentBundle + require.NoError(t, b.StateDB.Open(ctx, statePath, dstate.ReadingFile, false, dstate.OpenDmsArgs{})) + require.NoError(t, b.StateDB.UpgradeToWrite()) + for _, key := range append(tc.keys, "resources.jobs.foobar") { + require.NoError(t, b.StateDB.SaveState(ctx, key, "id", map[string]string{"name": key}, nil)) + } + _, err := b.StateDB.Finalize(ctx) + require.NoError(t, err) + + root := &config.Root{Experimental: &config.Experimental{DeploymentHistory: &tc.history}} + metadata, err := b.Unbind(ctx, root, statePath, "resources.jobs.foo") + require.NoError(t, err) + if tc.history { + require.NotNil(t, metadata) + assert.ElementsMatch(t, tc.keys, metadata.ResourceKeys) + } else { + assert.Nil(t, metadata) + } + + require.NoError(t, b.StateDB.Open(ctx, statePath, dstate.ReadingFile, false, dstate.OpenDmsArgs{})) + assert.Len(t, b.StateDB.Data.State, 1) + if metadata != nil { + assert.Equal(t, b.StateDB.Data.Serial, metadata.Serial) + } + assert.Equal(t, "id", b.StateDB.GetResourceID("resources.jobs.foobar")) + _, err = b.StateDB.Finalize(ctx) + require.NoError(t, err) + }) + } +} diff --git a/bundle/phases/bind.go b/bundle/phases/bind.go index 2e935e06be2..7cc73ba05bf 100644 --- a/bundle/phases/bind.go +++ b/bundle/phases/bind.go @@ -179,7 +179,10 @@ func Unbind(ctx context.Context, b *bundle.Bundle, group, resourceKey string, en return } + var historyVersion string + var historyErr error defer func() { + completeBindingHistory(ctx, b, historyVersion, historyErr == nil && !logdiag.HasError(ctx)) bundle.ApplyContext(ctx, b, lock.Release(lock.GoalUnbind)) }() @@ -192,12 +195,17 @@ func Unbind(ctx context.Context, b *bundle.Bundle, group, resourceKey string, en fullResourceKey := fmt.Sprintf("resources.%s.%s", group, resourceKey) _, statePath := b.StateFilenameDirect(ctx) - err := b.DeploymentBundle.Unbind(ctx, statePath, fullResourceKey) + metadata, err := b.DeploymentBundle.Unbind(ctx, &b.Config, statePath, fullResourceKey) if err != nil { logdiag.LogError(ctx, err) return } + historyVersion, historyErr = recordUnbindHistory(ctx, b, metadata) + if historyErr != nil { + log.Warnf(ctx, "Failed to record unbind history: %s", historyErr) + } + statemgmt.PushResourcesState(ctx, b) } @@ -216,6 +224,29 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string return versionName, recordBindingOperation(ctx, b, versionName, resourceKey, &metadata.ResourceState) } +// recordUnbindHistory publishes committed removals when DMS metadata was requested. +func recordUnbindHistory(ctx context.Context, b *bundle.Bundle, metadata *direct.DMSUnbindMetadata) (string, error) { + if metadata == nil || len(metadata.ResourceKeys) == 0 { + return "", nil + } + staged := make([]bundledeployments.StagedOperation, 0, len(metadata.ResourceKeys)) + for _, key := range metadata.ResourceKeys { + staged = append(staged, bundledeployments.StagedOperation{ + ResourceKey: strings.TrimPrefix(key, dms.StatePrefix), + ActionType: bundledeployments.OperationActionTypeOperationActionTypeUnbind, + }) + } + versionName, err := createBindingVersion(ctx, b, metadata.Serial, staged) + if err != nil { + return "", err + } + var recordErr error + for _, key := range metadata.ResourceKeys { + recordErr = errors.Join(recordErr, recordBindingOperation(ctx, b, versionName, key, nil)) + } + return versionName, recordErr +} + // createBindingVersion claims a history version for the staged binding operations. func createBindingVersion(ctx context.Context, b *bundle.Bundle, serial int, staged []bundledeployments.StagedOperation) (string, error) { deploymentID, current, err := dms.LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) From 23edf834f454507488ace4621f545e50b3719c96 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 09:21:24 +0000 Subject: [PATCH 2/6] Address unbind history self-review findings --- .nextchanges/bundles/record-unbind-history.md | 2 +- .../feature/{out.unbind.false.txt => out.unbind.plain.txt} | 0 .../{out.unbind.true.txt => out.unbind.recorded.txt} | 0 acceptance/bundle/deployment/bind/feature/script | 2 +- acceptance/bundle/dms/bind-unbind/output.txt | 7 ++++--- acceptance/bundle/dms/bind-unbind/script | 5 +++-- bundle/direct/bind.go | 1 + 7 files changed, 10 insertions(+), 7 deletions(-) rename acceptance/bundle/deployment/bind/feature/{out.unbind.false.txt => out.unbind.plain.txt} (100%) rename acceptance/bundle/deployment/bind/feature/{out.unbind.true.txt => out.unbind.recorded.txt} (100%) diff --git a/.nextchanges/bundles/record-unbind-history.md b/.nextchanges/bundles/record-unbind-history.md index f615c93c5a3..dc603090da6 100644 --- a/.nextchanges/bundles/record-unbind-history.md +++ b/.nextchanges/bundles/record-unbind-history.md @@ -1 +1 @@ -* Record deployment history when unbinding resources with `bundle deployment unbind`. ([#7401](https://github.com/databricks/cli/pull/7401)) +* Record deployment history when unbinding resources with `bundle deployment unbind` when deployment history is enabled. ([#7401](https://github.com/databricks/cli/pull/7401)) diff --git a/acceptance/bundle/deployment/bind/feature/out.unbind.false.txt b/acceptance/bundle/deployment/bind/feature/out.unbind.plain.txt similarity index 100% rename from acceptance/bundle/deployment/bind/feature/out.unbind.false.txt rename to acceptance/bundle/deployment/bind/feature/out.unbind.plain.txt diff --git a/acceptance/bundle/deployment/bind/feature/out.unbind.true.txt b/acceptance/bundle/deployment/bind/feature/out.unbind.recorded.txt similarity index 100% rename from acceptance/bundle/deployment/bind/feature/out.unbind.true.txt rename to acceptance/bundle/deployment/bind/feature/out.unbind.recorded.txt diff --git a/acceptance/bundle/deployment/bind/feature/script b/acceptance/bundle/deployment/bind/feature/script index 019816f7499..7cb00fef219 100644 --- a/acceptance/bundle/deployment/bind/feature/script +++ b/acceptance/bundle/deployment/bind/feature/script @@ -1,5 +1,5 @@ trace $CLI bundle deployment bind feature1 main.myschema.boundfeature --auto-approve trace $CLI bundle summary -trace $CLI bundle deployment unbind feature1 2> out.unbind.$DMS.txt +trace $CLI bundle deployment unbind feature1 2> out.unbind.$OUTPUT_MODE.txt trace $CLI bundle summary diff --git a/acceptance/bundle/dms/bind-unbind/output.txt b/acceptance/bundle/dms/bind-unbind/output.txt index 3afd7f718d3..ba3c7688c96 100644 --- a/acceptance/bundle/dms/bind-unbind/output.txt +++ b/acceptance/bundle/dms/bind-unbind/output.txt @@ -57,14 +57,12 @@ Resources: 1 created, 1 changed, 0 deleted, 0 unchanged === Unbind preserves the shadow history marker and leaves the job intact >>> [CLI] bundle deployment unbind foo ->>> print_requests.py --dms //versions --oneline --keep +>>> print_requests.py --dms //versions //jobs //permissions --oneline {"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "6"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.foo"}, {"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.foo.permissions"}], "previous_version_id": "4", "version_type": "VERSION_TYPE_DEPLOY"}} {"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/operations/jobs.foo", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} {"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/operations/jobs.foo.permissions", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} {"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} ->>> print_requests.py //jobs //permissions - >>> jq {features, state} .databricks/bundle/default/resources.json { "features": { @@ -78,6 +76,9 @@ Resources: 1 created, 1 changed, 0 deleted, 0 unchanged "job_id": [JOB_ID] } +>>> MSYS_NO_PATHCONV=1 [CLI] bundle-deployments list-resources deployments/[DEPLOYMENT_ID] -o json +[] + === Unbinding an already unbound resource creates no history version >>> [CLI] bundle deployment unbind foo diff --git a/acceptance/bundle/dms/bind-unbind/script b/acceptance/bundle/dms/bind-unbind/script index 8d0f34a0b11..1d973b8fe05 100644 --- a/acceptance/bundle/dms/bind-unbind/script +++ b/acceptance/bundle/dms/bind-unbind/script @@ -27,10 +27,11 @@ rm -f "$OUT_REQUESTS" title "Unbind preserves the shadow history marker and leaves the job intact" trace $CLI bundle deployment unbind foo -trace print_requests.py --dms //versions --oneline --keep -trace print_requests.py //jobs //permissions +trace print_requests.py --dms //versions //jobs //permissions --oneline trace jq '{features, state}' .databricks/bundle/default/resources.json trace $CLI jobs get "$job_id" | jq '{job_id}' +trace MSYS_NO_PATHCONV=1 $CLI bundle-deployments list-resources "deployments/${deployment_id}" -o json | jq -S 'map({resource_key, resource_id, last_action_type})' + rm -f "$OUT_REQUESTS" diff --git a/bundle/direct/bind.go b/bundle/direct/bind.go index c3bd5bd2e8e..7c0fdd70a99 100644 --- a/bundle/direct/bind.go +++ b/bundle/direct/bind.go @@ -267,6 +267,7 @@ func (result *BindResult) Cancel() { // Unbind removes a resource from direct engine state without deleting // the workspace resource. Also removes associated permissions/grants entries. +// It returns nil DMS metadata when deployment history is disabled. func (b *DeploymentBundle) Unbind(ctx context.Context, configRoot *config.Root, statePath, resourceKey string) (*DMSUnbindMetadata, error) { if err := b.StateDB.Open(ctx, statePath, dstate.ReadingFile, dstate.WithRecovery(true), dstate.OpenDmsArgs{}); err != nil { return nil, err From c89c7ad7f1f95c03e2ee0b94882860cad384f7ad Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 13:01:06 +0000 Subject: [PATCH 3/6] Update unbind history expectations without duplicate state writes --- acceptance/bundle/dms/bind-unbind/output.txt | 14 ++++++------- .../dms/unbind-history-failures/output.txt | 20 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/acceptance/bundle/dms/bind-unbind/output.txt b/acceptance/bundle/dms/bind-unbind/output.txt index ba3c7688c96..84bd2d98082 100644 --- a/acceptance/bundle/dms/bind-unbind/output.txt +++ b/acceptance/bundle/dms/bind-unbind/output.txt @@ -58,10 +58,10 @@ Resources: 1 created, 1 changed, 0 deleted, 0 unchanged >>> [CLI] bundle deployment unbind foo >>> print_requests.py --dms //versions //jobs //permissions --oneline -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "6"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.foo"}, {"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.foo.permissions"}], "previous_version_id": "4", "version_type": "VERSION_TYPE_DEPLOY"}} -{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/operations/jobs.foo", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} -{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/operations/jobs.foo.permissions", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "5"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.foo"}, {"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.foo.permissions"}], "previous_version_id": "4", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/5/operations/jobs.foo", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/5/operations/jobs.foo.permissions", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/5/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} >>> jq {features, state} .databricks/bundle/default/resources.json { @@ -98,9 +98,9 @@ Run 'bundle deploy' to deploy changes to your workspace } >>> print_requests.py --dms //versions --oneline -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "10"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.foo"}], "previous_version_id": "6", "version_type": "VERSION_TYPE_DEPLOY"}} -{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/10/operations/jobs.foo", "q": {"update_mask": "state,error_message,resource_id,status"}, "body": {"error_message": "", "resource_id": "[JOB_ID]", "sequence_id": 0, "state": "{\"state\":{\"deployment\":{\"kind\":\"BUNDLE\",\"metadata_file_path\":\"/Workspace/Users/[USERNAME]/.bundle/dms-bind-unbind-[UNIQUE_NAME]/default/state/metadata.json\"},\"edit_mode\":\"UI_LOCKED\",\"format\":\"MULTI_TASK\",\"max_concurrent_runs\":1,\"name\":\"foo\",\"queue\":{\"enabled\":true}}}", "status": "OPERATION_STATUS_SUCCEEDED"}} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/10/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "8"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.foo"}], "previous_version_id": "5", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/8/operations/jobs.foo", "q": {"update_mask": "state,error_message,resource_id,status"}, "body": {"error_message": "", "resource_id": "[JOB_ID]", "sequence_id": 0, "state": "{\"state\":{\"deployment\":{\"kind\":\"BUNDLE\",\"metadata_file_path\":\"/Workspace/Users/[USERNAME]/.bundle/dms-bind-unbind-[UNIQUE_NAME]/default/state/metadata.json\"},\"edit_mode\":\"UI_LOCKED\",\"format\":\"MULTI_TASK\",\"max_concurrent_runs\":1,\"name\":\"foo\",\"queue\":{\"enabled\":true}}}", "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/8/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} === A subsequent deploy continues managing the same job >>> [CLI] bundle deploy diff --git a/acceptance/bundle/dms/unbind-history-failures/output.txt b/acceptance/bundle/dms/unbind-history-failures/output.txt index 782f59dd225..b7219857f28 100644 --- a/acceptance/bundle/dms/unbind-history-failures/output.txt +++ b/acceptance/bundle/dms/unbind-history-failures/output.txt @@ -45,13 +45,13 @@ Warn: Failed to complete deployment history version: Fault injected by test. } >>> print_requests.py --dms //versions --oneline -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "14"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.create_failed"}], "previous_version_id": "12", "version_type": "VERSION_TYPE_DEPLOY"}} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "15"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.record_failed"}], "previous_version_id": "12", "version_type": "VERSION_TYPE_DEPLOY"}} -{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/15/operations/jobs.record_failed", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/15/complete", "body": {"completion_reason": "VERSION_COMPLETE_FAILURE"}} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "17"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.complete_failed"}], "previous_version_id": "15", "version_type": "VERSION_TYPE_DEPLOY"}} -{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/17/operations/jobs.complete_failed", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/17/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "10"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.create_failed"}], "previous_version_id": "9", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "11"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.record_failed"}], "previous_version_id": "9", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/11/operations/jobs.record_failed", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/11/complete", "body": {"completion_reason": "VERSION_COMPLETE_FAILURE"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "12"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.complete_failed"}], "previous_version_id": "11", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/12/operations/jobs.complete_failed", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/12/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} === A remote state upload failure fails the unbind version >>> fault.py POST /api/2.0/workspace-files/import-file/**resources.json 400 0 1 @@ -66,9 +66,9 @@ API message: Fault injected by test. >>> print_requests.py --dms //versions --oneline -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "19"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.upload_failed"}], "previous_version_id": "17", "version_type": "VERSION_TYPE_DEPLOY"}} -{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/19/operations/jobs.upload_failed", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/19/complete", "body": {"completion_reason": "VERSION_COMPLETE_FAILURE"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "13"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UNBIND", "resource_key": "jobs.upload_failed"}], "previous_version_id": "12", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/13/operations/jobs.upload_failed", "q": {"update_mask": "state,error_message,status"}, "body": {"error_message": "", "sequence_id": 0, "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/13/complete", "body": {"completion_reason": "VERSION_COMPLETE_FAILURE"}} === Remote state still contains the binding whose upload failed >>> [CLI] bundle summary -o json From cd34de85356e19a8f7036695aa0675e707024464 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 13:27:26 +0000 Subject: [PATCH 4/6] Use common feature bind expectations with DMS support --- acceptance/bundle/deployment/bind/feature/out.unbind.plain.txt | 2 -- .../bundle/deployment/bind/feature/out.unbind.recorded.txt | 3 --- acceptance/bundle/deployment/bind/feature/output.txt | 2 ++ acceptance/bundle/deployment/bind/feature/script | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) delete mode 100644 acceptance/bundle/deployment/bind/feature/out.unbind.plain.txt delete mode 100644 acceptance/bundle/deployment/bind/feature/out.unbind.recorded.txt diff --git a/acceptance/bundle/deployment/bind/feature/out.unbind.plain.txt b/acceptance/bundle/deployment/bind/feature/out.unbind.plain.txt deleted file mode 100644 index 6779f487c1f..00000000000 --- a/acceptance/bundle/deployment/bind/feature/out.unbind.plain.txt +++ /dev/null @@ -1,2 +0,0 @@ - ->>> [CLI] bundle deployment unbind feature1 diff --git a/acceptance/bundle/deployment/bind/feature/out.unbind.recorded.txt b/acceptance/bundle/deployment/bind/feature/out.unbind.recorded.txt deleted file mode 100644 index 2dace068795..00000000000 --- a/acceptance/bundle/deployment/bind/feature/out.unbind.recorded.txt +++ /dev/null @@ -1,3 +0,0 @@ - ->>> [CLI] bundle deployment unbind feature1 -Warn: Failed to record unbind history: failed to create deployment version: operations.resource_key must have a known resource type prefix (e.g. 'jobs.', 'pipelines.'): features.feature1 diff --git a/acceptance/bundle/deployment/bind/feature/output.txt b/acceptance/bundle/deployment/bind/feature/output.txt index e0bca28a059..d4d47f6a660 100644 --- a/acceptance/bundle/deployment/bind/feature/output.txt +++ b/acceptance/bundle/deployment/bind/feature/output.txt @@ -15,6 +15,8 @@ Resources: Name: main.myschema.boundfeature URL: [DATABRICKS_URL]/explore/data/features/main/myschema/boundfeature?w=[WORKSPACE_ID] +>>> [CLI] bundle deployment unbind feature1 + >>> [CLI] bundle summary Name: test-bundle Target: default diff --git a/acceptance/bundle/deployment/bind/feature/script b/acceptance/bundle/deployment/bind/feature/script index 7cb00fef219..8e27f69056c 100644 --- a/acceptance/bundle/deployment/bind/feature/script +++ b/acceptance/bundle/deployment/bind/feature/script @@ -1,5 +1,5 @@ trace $CLI bundle deployment bind feature1 main.myschema.boundfeature --auto-approve trace $CLI bundle summary -trace $CLI bundle deployment unbind feature1 2> out.unbind.$OUTPUT_MODE.txt +trace $CLI bundle deployment unbind feature1 trace $CLI bundle summary From 04839fdf04270fcf9137befbb19815cae16f01e3 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 17:53:10 +0000 Subject: [PATCH 5/6] Update failure expectations for direct SDK history recording --- acceptance/bundle/dms/unbind-history-failures/output.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/bundle/dms/unbind-history-failures/output.txt b/acceptance/bundle/dms/unbind-history-failures/output.txt index b7219857f28..16c1a8f26e7 100644 --- a/acceptance/bundle/dms/unbind-history-failures/output.txt +++ b/acceptance/bundle/dms/unbind-history-failures/output.txt @@ -9,7 +9,7 @@ Warn: Failed to record unbind history: failed to create deployment version: Faul >>> fault.py PATCH /api/2.0/bundle/** 400 0 1 >>> [CLI] bundle deployment unbind record_failed -Warn: Failed to record deployment history: recording operation for resources.jobs.record_failed: Fault injected by test. +Warn: Failed to record unbind history: Fault injected by test. === A version completion failure does not prevent unbinding >>> fault.py POST /api/2.0/bundle/deployments/*/versions/*/complete 400 0 1 From 6f52001eb5a0dd5aef4743945626972a99825b78 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 10 Nov 2026 11:10:09 +0000 Subject: [PATCH 6/6] Inline unbind history metadata selection --- bundle/direct/bind.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bundle/direct/bind.go b/bundle/direct/bind.go index 7c0fdd70a99..f35a77d4cb1 100644 --- a/bundle/direct/bind.go +++ b/bundle/direct/bind.go @@ -276,8 +276,12 @@ func (b *DeploymentBundle) Unbind(ctx context.Context, configRoot *config.Root, return nil, err } + var configured *bool + if configRoot.Experimental != nil { + configured = configRoot.Experimental.DeploymentHistory + } var metadata *DMSUnbindMetadata - if b.dmsMetadataEnabled(ctx, configRoot) { + if b.StateDB.RecordsHistory() || bundleenv.RecordsDeploymentHistory(ctx, configured) { metadata = &DMSUnbindMetadata{Serial: b.StateDB.Data.Serial} // Finalize commits the removals below as one WAL transaction. if b.StateDB.Data.State != nil {