From 2c7cf79f4364f47298ad486d1c8a42c93c3cf4bc Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 07:52:41 +0000 Subject: [PATCH 01/27] Record deployment history for bundle resource bindings --- .nextchanges/bundles/record-bind-history.md | 1 + .../recreation/out.state_after_bind.json | 1 - .../bind/dashboard/recreation/script | 2 +- .../bind/feature/out.bind.false.txt | 4 + .../deployment/bind/feature/out.bind.true.txt | 5 ++ .../deployment/bind/feature/out.test.toml | 2 +- .../bundle/deployment/bind/feature/output.txt | 4 - .../bundle/deployment/bind/feature/script | 2 +- .../bundle/deployment/bind/feature/test.toml | 2 + .../deployment/bind/pipelines/recreate/script | 2 +- .../deployment/bind/pipelines/update/script | 2 +- .../bind-history-failures/databricks.yml.tmpl | 13 ++++ .../dms/bind-history-failures/out.test.toml | 2 + .../dms/bind-history-failures/output.txt | 73 ++++++++++++++++++ .../bundle/dms/bind-history-failures/script | 33 ++++++++ .../dms/bind-history-failures/test.toml | 1 + acceptance/bundle/dms/bind-unbind/output.txt | 44 ++++++++++- acceptance/bundle/dms/bind-unbind/script | 16 +++- acceptance/bundle/dms/bind-unbind/test.toml | 1 - bundle/phases/bind.go | 76 +++++++++++++++++++ bundle/phases/dms.go | 38 ++++++++++ cmd/bundle/utils/process.go | 46 +---------- 22 files changed, 314 insertions(+), 56 deletions(-) create mode 100644 .nextchanges/bundles/record-bind-history.md create mode 100644 acceptance/bundle/deployment/bind/feature/out.bind.false.txt create mode 100644 acceptance/bundle/deployment/bind/feature/out.bind.true.txt create mode 100644 acceptance/bundle/dms/bind-history-failures/databricks.yml.tmpl create mode 100644 acceptance/bundle/dms/bind-history-failures/out.test.toml create mode 100644 acceptance/bundle/dms/bind-history-failures/output.txt create mode 100644 acceptance/bundle/dms/bind-history-failures/script create mode 100644 acceptance/bundle/dms/bind-history-failures/test.toml diff --git a/.nextchanges/bundles/record-bind-history.md b/.nextchanges/bundles/record-bind-history.md new file mode 100644 index 00000000000..7fce080e9fc --- /dev/null +++ b/.nextchanges/bundles/record-bind-history.md @@ -0,0 +1 @@ +* Record deployment history when binding existing resources with `bundle deployment bind`. ([#7400](https://github.com/databricks/cli/pull/7400)) diff --git a/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.json b/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.json index 05614d798bf..4ba78767d69 100644 --- a/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.json +++ b/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.json @@ -2,7 +2,6 @@ "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", - "serial": 2, "state": { "resources.dashboards.dashboard1": { "__id__": "[DASHBOARD_ID]", diff --git a/acceptance/bundle/deployment/bind/dashboard/recreation/script b/acceptance/bundle/deployment/bind/dashboard/recreation/script index c9656879b7e..1ef7b6071a5 100644 --- a/acceptance/bundle/deployment/bind/dashboard/recreation/script +++ b/acceptance/bundle/deployment/bind/dashboard/recreation/script @@ -13,7 +13,7 @@ cleanupRemoveDashboard() { trap cleanupRemoveDashboard EXIT trace $CLI bundle deployment bind dashboard1 "${DASHBOARD_ID}" --auto-approve -trace print_state.py --no-dms > out.state_after_bind.json +trace print_state.py --no-dms | jq 'del(.serial, .features)' | nostamp.py --indent 1 > out.state_after_bind.json ETAG=$(jq '.state["resources.dashboards.dashboard1"].state.etag // .resources[0].instances[0].attributes.etag // "no-etag"' < out.state_after_bind.json) echo $ETAG add_repl $ETAG ETAG diff --git a/acceptance/bundle/deployment/bind/feature/out.bind.false.txt b/acceptance/bundle/deployment/bind/feature/out.bind.false.txt new file mode 100644 index 00000000000..2155184a00f --- /dev/null +++ b/acceptance/bundle/deployment/bind/feature/out.bind.false.txt @@ -0,0 +1,4 @@ + +>>> [CLI] bundle deployment bind feature1 main.myschema.boundfeature --auto-approve +Successfully bound feature with an id 'main.myschema.boundfeature' +Run 'bundle deploy' to deploy changes to your workspace diff --git a/acceptance/bundle/deployment/bind/feature/out.bind.true.txt b/acceptance/bundle/deployment/bind/feature/out.bind.true.txt new file mode 100644 index 00000000000..e91d2b70ab7 --- /dev/null +++ b/acceptance/bundle/deployment/bind/feature/out.bind.true.txt @@ -0,0 +1,5 @@ + +>>> [CLI] bundle deployment bind feature1 main.myschema.boundfeature --auto-approve +Warn: Failed to record bind history: failed to create deployment version: operations.resource_key must have a known resource type prefix (e.g. 'jobs.', 'pipelines.'): features.feature1 +Successfully bound feature with an id 'main.myschema.boundfeature' +Run 'bundle deploy' to deploy changes to your workspace diff --git a/acceptance/bundle/deployment/bind/feature/out.test.toml b/acceptance/bundle/deployment/bind/feature/out.test.toml index a927a5fbc06..943f1dc5575 100644 --- a/acceptance/bundle/deployment/bind/feature/out.test.toml +++ b/acceptance/bundle/deployment/bind/feature/out.test.toml @@ -1,2 +1,2 @@ Cloud = false -EnvMatrix.DMS = ["", "true"] +EnvMatrix.DMS = ["false", "true"] diff --git a/acceptance/bundle/deployment/bind/feature/output.txt b/acceptance/bundle/deployment/bind/feature/output.txt index d4d47f6a660..8466bb0eaa5 100644 --- a/acceptance/bundle/deployment/bind/feature/output.txt +++ b/acceptance/bundle/deployment/bind/feature/output.txt @@ -1,8 +1,4 @@ ->>> [CLI] bundle deployment bind feature1 main.myschema.boundfeature --auto-approve -Successfully bound feature with an id 'main.myschema.boundfeature' -Run 'bundle deploy' to deploy changes to your workspace - >>> [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..debd1951c68 100644 --- a/acceptance/bundle/deployment/bind/feature/script +++ b/acceptance/bundle/deployment/bind/feature/script @@ -1,4 +1,4 @@ -trace $CLI bundle deployment bind feature1 main.myschema.boundfeature --auto-approve +trace $CLI bundle deployment bind feature1 main.myschema.boundfeature --auto-approve 2> out.bind.$DMS.txt trace $CLI bundle summary trace $CLI bundle deployment unbind feature1 diff --git a/acceptance/bundle/deployment/bind/feature/test.toml b/acceptance/bundle/deployment/bind/feature/test.toml index 84e8bf9b8f4..5bb3b67ab85 100644 --- a/acceptance/bundle/deployment/bind/feature/test.toml +++ b/acceptance/bundle/deployment/bind/feature/test.toml @@ -1,3 +1,5 @@ +EnvMatrix.DMS = ["false", "true"] +EnvVaryOutput = "DMS" Cloud = false Ignore = [ diff --git a/acceptance/bundle/deployment/bind/pipelines/recreate/script b/acceptance/bundle/deployment/bind/pipelines/recreate/script index a9aa3403a35..0796fa0e9e6 100644 --- a/acceptance/bundle/deployment/bind/pipelines/recreate/script +++ b/acceptance/bundle/deployment/bind/pipelines/recreate/script @@ -12,7 +12,7 @@ rm -f "$OUT_REQUESTS" trace $CLI bundle deployment bind foo $NEW_PIPELINE_ID --auto-approve &> out.bind-success.txt trace print_requests.py '^//import-file/' '^//workspace/' -trace $CLI bundle summary -o json | jq .resources > out.summary.json +trace $CLI bundle summary -o json | jq .resources | nostamp.py > out.summary.json trace $CLI bundle plan trace musterr $CLI bundle deploy diff --git a/acceptance/bundle/deployment/bind/pipelines/update/script b/acceptance/bundle/deployment/bind/pipelines/update/script index 6bafa319765..d1a67c9b960 100644 --- a/acceptance/bundle/deployment/bind/pipelines/update/script +++ b/acceptance/bundle/deployment/bind/pipelines/update/script @@ -9,7 +9,7 @@ rm -f "$OUT_REQUESTS" trace $CLI bundle deployment bind foo $NEW_PIPELINE_ID --auto-approve &> out.bind-success.txt print_requests.py '^//import-file/' '^//workspace/delete' -trace $CLI bundle summary -o json > out.summary.json +trace $CLI bundle summary -o json | jq 'del(.bundle.deployment)' | nostamp.py > out.summary.json trace $CLI bundle plan rm -f "$OUT_REQUESTS" diff --git a/acceptance/bundle/dms/bind-history-failures/databricks.yml.tmpl b/acceptance/bundle/dms/bind-history-failures/databricks.yml.tmpl new file mode 100644 index 00000000000..6f6a7fba6a3 --- /dev/null +++ b/acceptance/bundle/dms/bind-history-failures/databricks.yml.tmpl @@ -0,0 +1,13 @@ +bundle: + name: dms-bind-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/bind-history-failures/out.test.toml b/acceptance/bundle/dms/bind-history-failures/out.test.toml new file mode 100644 index 00000000000..88e6fbd847c --- /dev/null +++ b/acceptance/bundle/dms/bind-history-failures/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["true"] diff --git a/acceptance/bundle/dms/bind-history-failures/output.txt b/acceptance/bundle/dms/bind-history-failures/output.txt new file mode 100644 index 00000000000..47cc75bae27 --- /dev/null +++ b/acceptance/bundle/dms/bind-history-failures/output.txt @@ -0,0 +1,73 @@ + +=== A version creation failure does not prevent binding +>>> fault.py POST /api/2.0/bundle/deployments/*/versions 400 0 1 + +>>> [CLI] bundle deployment bind create_failed [JOB_ID] --auto-approve +Warn: Failed to record bind history: failed to create deployment version: Fault injected by test. +Successfully bound job with an id '[JOB_ID]' +Run 'bundle deploy' to deploy changes to your workspace + +=== An operation recording failure does not prevent binding +>>> fault.py PATCH /api/2.0/bundle/** 400 0 1 + +>>> [CLI] bundle deployment bind record_failed [JOB_ID] --auto-approve +Warn: Failed to record deployment history: recording operation for resources.jobs.record_failed: Fault injected by test. +Successfully bound job with an id '[JOB_ID]' +Run 'bundle deploy' to deploy changes to your workspace + +=== A version completion failure does not prevent binding +>>> fault.py POST /api/2.0/bundle/deployments/*/versions/*/complete 400 0 1 + +>>> [CLI] bundle deployment bind complete_failed [JOB_ID] --auto-approve +Warn: Failed to complete deployment history version: Fault injected by test. +Successfully bound job with an id '[JOB_ID]' +Run 'bundle deploy' to deploy changes to your workspace + +=== All bindings survived the remote state round trips +>>> jq .state | map_values(.__id__) .databricks/bundle/default/resources.json +{ + "resources.jobs.complete_failed": "[JOB_ID]", + "resources.jobs.create_failed": "[JOB_ID]", + "resources.jobs.record_failed": "[JOB_ID]" +} + +>>> [CLI] jobs get [JOB_ID] +{ + "job_id": [JOB_ID], + "name": "bind-target-[UNIQUE_NAME]" +} + +>>> print_requests.py --dms //versions --oneline +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "3"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.create_failed"}], "version_type": "VERSION_TYPE_DEPLOY"}} +{"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_BIND", "resource_key": "jobs.record_failed"}], "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/operations/jobs.record_failed", "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-history-failures-[UNIQUE_NAME]/default/state/metadata.json\"},\"edit_mode\":\"UI_LOCKED\",\"format\":\"MULTI_TASK\",\"max_concurrent_runs\":1,\"name\":\"record_failed\",\"queue\":{\"enabled\":true}}}", "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/complete", "body": {"completion_reason": "VERSION_COMPLETE_FAILURE"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "9"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.complete_failed"}], "previous_version_id": "6", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/9/operations/jobs.complete_failed", "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-history-failures-[UNIQUE_NAME]/default/state/metadata.json\"},\"edit_mode\":\"UI_LOCKED\",\"format\":\"MULTI_TASK\",\"max_concurrent_runs\":1,\"name\":\"complete_failed\",\"queue\":{\"enabled\":true}}}", "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/9/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} + +=== A remote state upload failure fails the bind version +>>> fault.py POST /api/2.0/workspace-files/import-file/**resources.json 400 0 1 + +>>> musterr [CLI] bundle deployment bind upload_failed [JOB_ID] --auto-approve +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-bind-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": "12"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.upload_failed"}], "previous_version_id": "9", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/12/operations/jobs.upload_failed", "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-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}}}", "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/12/complete", "body": {"completion_reason": "VERSION_COMPLETE_FAILURE"}} + +=== Remote state still contains only the successful bindings +>>> [CLI] bundle summary -o json +{ + "complete_failed": "[JOB_ID]", + "create_failed": "[JOB_ID]", + "record_failed": "[JOB_ID]", + "upload_failed": null +} diff --git a/acceptance/bundle/dms/bind-history-failures/script b/acceptance/bundle/dms/bind-history-failures/script new file mode 100644 index 00000000000..eecbabcd3e2 --- /dev/null +++ b/acceptance/bundle/dms/bind-history-failures/script @@ -0,0 +1,33 @@ +envsubst < databricks.yml.tmpl > databricks.yml +job_id=$($CLI jobs create --json '{"name": "bind-target-'$UNIQUE_NAME'"}' | jq -r '.job_id') +add_repl "$job_id" JOB_ID + +title "A version creation failure does not prevent binding" +trace fault.py "POST /api/2.0/bundle/deployments/*/versions" 400 0 1 +trace $CLI bundle deployment bind create_failed "$job_id" --auto-approve +rm -rf .databricks + +title "An operation recording failure does not prevent binding" +trace fault.py "PATCH /api/2.0/bundle/**" 400 0 1 +trace $CLI bundle deployment bind record_failed "$job_id" --auto-approve +rm -rf .databricks + +title "A version completion failure does not prevent binding" +trace fault.py "POST /api/2.0/bundle/deployments/*/versions/*/complete" 400 0 1 +trace $CLI bundle deployment bind complete_failed "$job_id" --auto-approve + +title "All bindings survived the remote state round trips" +trace jq '.state | map_values(.__id__)' .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 bind version" +trace fault.py "POST /api/2.0/workspace-files/import-file/**resources.json" 400 0 1 +trace musterr $CLI bundle deployment bind upload_failed "$job_id" --auto-approve +trace print_requests.py --dms //versions --oneline + +rm -rf .databricks +title "Remote state still contains only the successful bindings" +trace $CLI bundle summary -o json | jq '.resources.jobs | map_values(.id // null)' +rm -f "$OUT_REQUESTS" diff --git a/acceptance/bundle/dms/bind-history-failures/test.toml b/acceptance/bundle/dms/bind-history-failures/test.toml new file mode 100644 index 00000000000..0e4c9337799 --- /dev/null +++ b/acceptance/bundle/dms/bind-history-failures/test.toml @@ -0,0 +1 @@ +Cloud = false # Inject failures into the deployment history service. diff --git a/acceptance/bundle/dms/bind-unbind/output.txt b/acceptance/bundle/dms/bind-unbind/output.txt index d6d3fcd36e6..a5fa3b20dbe 100644 --- a/acceptance/bundle/dms/bind-unbind/output.txt +++ b/acceptance/bundle/dms/bind-unbind/output.txt @@ -1,10 +1,38 @@ === Bind an existing job with deployment history enabled +>>> musterr [CLI] bundle deployment bind foo [JOB_ID] +Plan: update resources.jobs.foo + +Changes detected: + ~ deployment: null -> {"kind":"BUNDLE","metadata_file_path":"/Workspace/Users/[USERNAME]/.bundle/dms-bind-unbind-[UNIQUE_NAME]/default/state/metadata.json"} + ~ edit_mode: null -> "UI_LOCKED" + ~ format: null -> "MULTI_TASK" + ~ max_concurrent_runs: null -> 1 + ~ name: "bind-target-[UNIQUE_NAME]" -> "foo" + ~ queue: null -> {"enabled":true} + +Error: this bind operation requires user confirmation, but the current console does not support prompting. +To proceed, use --auto-approve after reviewing the plan above. + + +>>> print_requests.py --dms //api/2.0/bundle + >>> [CLI] bundle deployment bind foo [JOB_ID] --auto-approve Successfully bound job with an id '[JOB_ID]' Run 'bundle deploy' to deploy changes to your workspace -=== Deploy keeps the bound job ID and enables shadow history +>>> print_requests.py --dms //versions --oneline +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "3"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.foo"}], "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/3/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/3/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} + +>>> [CLI] jobs get [JOB_ID] +{ + "job_id": [JOB_ID], + "name": "bind-target-[UNIQUE_NAME]" +} + +=== 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... Updated jobs.foo @@ -48,6 +76,11 @@ Run 'bundle deploy' to deploy changes to your workspace "resource_id": "[JOB_ID]" } +>>> print_requests.py --dms //versions --oneline +{"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": "4", "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 Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-bind-unbind-[UNIQUE_NAME]/default/files... @@ -61,3 +94,12 @@ Resources: 0 created, 0 changed, 0 deleted, 1 unchanged }, "resource_id": "[JOB_ID]" } + +=== A rejected duplicate bind creates no history version +>>> musterr [CLI] bundle deployment bind foo [JOB_ID] --auto-approve +Error: Resource already managed + +The bundle is already managing resource for resources.jobs.foo and it is bound to the requested ID [JOB_ID]. + + +>>> print_requests.py --dms //versions diff --git a/acceptance/bundle/dms/bind-unbind/script b/acceptance/bundle/dms/bind-unbind/script index 32fab5aa0e8..f52e37e6d07 100644 --- a/acceptance/bundle/dms/bind-unbind/script +++ b/acceptance/bundle/dms/bind-unbind/script @@ -3,9 +3,13 @@ envsubst < databricks.yml.tmpl > databricks.yml title "Bind an existing job with deployment history enabled" job_id=$($CLI jobs create --json '{"name": "bind-target-'$UNIQUE_NAME'"}' | jq -r '.job_id') add_repl "$job_id" JOB_ID +trace musterr $CLI bundle deployment bind foo "$job_id" +trace print_requests.py --dms //api/2.0/bundle trace $CLI bundle deployment bind foo "$job_id" --auto-approve +trace print_requests.py --dms //versions --oneline +trace $CLI jobs get "$job_id" | jq '{job_id, name: .settings.name}' -title "Deploy keeps the bound job ID and enables shadow history" +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 @@ -14,10 +18,20 @@ trace $CLI bundle deployment unbind foo trace jq '{features, state}' .databricks/bundle/default/resources.json trace $CLI jobs get "$job_id" | jq '{job_id}' +rm -f "$OUT_REQUESTS" + 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 +trace print_requests.py --dms //versions --oneline + title "A subsequent deploy continues managing the same job" trace $CLI bundle deploy trace jq '{features, resource_id: .state["resources.jobs.foo"].__id__}' .databricks/bundle/default/resources.json + +rm -f "$OUT_REQUESTS" + +title "A rejected duplicate bind creates no history version" +trace musterr $CLI bundle deployment bind foo "$job_id" --auto-approve +trace print_requests.py --dms //versions diff --git a/acceptance/bundle/dms/bind-unbind/test.toml b/acceptance/bundle/dms/bind-unbind/test.toml index f620acda97e..353bd89e0a9 100644 --- a/acceptance/bundle/dms/bind-unbind/test.toml +++ b/acceptance/bundle/dms/bind-unbind/test.toml @@ -1,3 +1,2 @@ -RecordRequests = false # Verify shadow history markers and resource IDs across bind, deploy, unbind, and rebind. Cloud = false diff --git a/bundle/phases/bind.go b/bundle/phases/bind.go index 36c4fcf56b4..ad82587a303 100644 --- a/bundle/phases/bind.go +++ b/bundle/phases/bind.go @@ -7,16 +7,22 @@ import ( "fmt" "maps" "slices" + "strings" "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config/engine" "github.com/databricks/cli/bundle/deploy/lock" "github.com/databricks/cli/bundle/deployplan" + "github.com/databricks/cli/bundle/direct/dstate" + bundleenv "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/libs/agent" + "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/dms" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" + "github.com/databricks/databricks-sdk-go/service/bundledeployments" ) type BindOptions struct { @@ -36,7 +42,14 @@ func Bind(ctx context.Context, b *bundle.Bundle, opts *BindOptions, stateDesc *s return } + historySucceeded := true defer func() { + if b.DeploymentBundle.StateDB.RecordsHistory() { + completionCtx := cmdctx.SetWorkspaceClient(ctx, b.WorkspaceClient(ctx)) + if _, err := b.DeploymentBundle.StateDB.CompleteVersion(completionCtx, historySucceeded && !logdiag.HasError(ctx)); err != nil { + log.Warnf(ctx, "Failed to complete deployment history version: %s", err) + } + } bundle.ApplyContext(ctx, b, lock.Release(lock.GoalBind)) }() @@ -102,6 +115,11 @@ func Bind(ctx context.Context, b *bundle.Bundle, opts *BindOptions, stateDesc *s return } + if err := recordBindHistory(ctx, b, resourceKey); err != nil { + historySucceeded = false + log.Warnf(ctx, "Failed to record bind history: %s", err) + } + statemgmt.PushResourcesState(ctx, b) } @@ -143,3 +161,61 @@ func Unbind(ctx context.Context, b *bundle.Bundle, group, resourceKey string, en statemgmt.PushResourcesState(ctx, b) } + +// recordBindHistory records the committed binding without applying the resource's plan. +func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string) (err error) { + db := &b.DeploymentBundle.StateDB + _, statePath := b.StateFilenameDirect(ctx) + if err := db.Open(ctx, statePath, dstate.ReadingFile, dstate.WithRecovery(true), dstate.OpenDmsArgs{}); err != nil { + return err + } + defer func() { + _, finalizeErr := db.Finalize(ctx) + err = errors.Join(err, finalizeErr) + }() + + var configured *bool + if b.Config.Experimental != nil { + configured = b.Config.Experimental.DeploymentHistory + } + if !db.RecordsHistory() && !bundleenv.RecordsDeploymentHistory(ctx, configured) { + return nil + } + + deploymentID, current, err := LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) + if err != nil { + return err + } + db.DeploymentID = deploymentID + if current != nil { + next, err := dms.NextVersion(current.LastVersionId) + if err != nil { + return err + } + db.VersionID = next - 1 + } + if db.Data.Features == nil { + db.Data.Features = make(map[string]struct{}) + } + db.Data.Features[dstate.FeatureDeploymentHistoryShadow] = struct{}{} + if err := db.UpgradeToWrite(); err != nil { + return err + } + if !cmdctx.HasWorkspaceClient(ctx) { + ctx = cmdctx.SetWorkspaceClient(ctx, b.WorkspaceClient(ctx)) + } + if err := createOrUpdateDeployment(ctx, b, current); err != nil { + return err + } + if err := startVersion(ctx, b, dms.VersionTypeDeploy, []bundledeployments.StagedOperation{{ + ResourceKey: strings.TrimPrefix(resourceKey, dms.StatePrefix), + ActionType: bundledeployments.OperationActionTypeOperationActionTypeBind, + }}); err != nil { + return err + } + entry, ok := db.GetResourceEntry(resourceKey) + if !ok { + return fmt.Errorf("bound resource %s is missing from deployment state", resourceKey) + } + return db.SaveState(ctx, resourceKey, entry.ID, entry.State, entry.DependsOn) +} diff --git a/bundle/phases/dms.go b/bundle/phases/dms.go index 38210913ce8..fd9c117cc25 100644 --- a/bundle/phases/dms.go +++ b/bundle/phases/dms.go @@ -2,6 +2,7 @@ package phases import ( "context" + "errors" "fmt" "net/url" "path" @@ -22,6 +23,7 @@ import ( "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/common/types/fieldmask" "github.com/databricks/databricks-sdk-go/service/bundledeployments" + "github.com/databricks/databricks-sdk-go/service/workspace" ) // stagedOperations lists the resources the plan will touch, for CreateVersion to stage an @@ -292,3 +294,39 @@ func deploymentModeToSDK(mode config.Mode) bundledeployments.DeploymentMode { return "" } } + +// LookupDeployment reads the deployment metadata for a bundle. +// +// Lookup and return the deployment object from ${workspace.state_path}/resources.deployment.json +// +// TODO: a deployment is only usable when both the node and the service's record exist, and a +// half-created one - node present, record missing - blocks the bundle here even though +// CreateDeployment already recovers from it. Move this behind a dms.ReadDeployment(ctx, statePath) +// that returns an empty id and version unless both halves are there, leaving the caller to call +// CreateDeployment to create or finalize it. +// +// TODO: ask the service for a lookup by state path, so this is one round trip rather than two - a +// workspace lookup to turn the node into an id, then a get by that id. +func LookupDeployment(ctx context.Context, w *databricks.WorkspaceClient, statePath string) (string, *bundledeployments.Deployment, error) { + nodePath := path.Join(statePath, dms.DeploymentNodeName) + + obj, err := retry.With(ctx, retry.IsTransientRead, func() (*workspace.ObjectInfo, error) { + return w.Workspace.GetStatusByPath(ctx, nodePath) + }) + if errors.Is(err, apierr.ErrNotFound) || errors.Is(err, apierr.ErrResourceDoesNotExist) { + return "", nil, nil + } + if err != nil { + return "", nil, fmt.Errorf("looking up deployment at %s: %w", nodePath, err) + } + deploymentID := strconv.FormatInt(obj.ObjectId, 10) + deployment, err := retry.With(ctx, retry.IsTransientRead, func() (*bundledeployments.Deployment, error) { + return w.BundleDeployments.GetDeployment(ctx, bundledeployments.GetDeploymentRequest{ + Name: dms.DeploymentName(deploymentID), + }) + }) + if err != nil { + return "", nil, err + } + return deploymentID, deployment, nil +} diff --git a/cmd/bundle/utils/process.go b/cmd/bundle/utils/process.go index 68798a4bb01..9bdad2c6e04 100644 --- a/cmd/bundle/utils/process.go +++ b/cmd/bundle/utils/process.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "path" "path/filepath" "slices" "strconv" @@ -28,18 +27,13 @@ import ( "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/diag" - "github.com/databricks/cli/libs/dms" "github.com/databricks/cli/libs/dyn" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" - "github.com/databricks/cli/libs/retry" "github.com/databricks/cli/libs/sync" "github.com/databricks/cli/libs/telemetry" "github.com/databricks/cli/libs/telemetry/protos" - "github.com/databricks/databricks-sdk-go" - "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/service/bundledeployments" - "github.com/databricks/databricks-sdk-go/service/workspace" "github.com/databricks/databricks-sdk-go/useragent" "github.com/spf13/cobra" "golang.org/x/mod/semver" @@ -411,7 +405,7 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle if needDirectState && stateMode == dstate.WritingDeploymentHistoryOnly { var err error - dmsDeploymentID, dmsDeployment, err = fetchDeploymentFromStatePath(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) + dmsDeploymentID, dmsDeployment, err = phases.LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) if err != nil { logdiag.LogError(ctx, err) return b, stateDesc, root.ErrAlreadyPrinted @@ -637,40 +631,6 @@ func ValidateEngineSetting(ctx context.Context, b *bundle.Bundle) error { return nil } -// Lookup and return the deployment object from ${workspace.state_path}/resources.deployment.json -// -// TODO: a deployment is only usable when both the node and the service's record exist, and a -// half-created one - node present, record missing - blocks the bundle here even though -// CreateDeployment already recovers from it. Move this behind a dms.ReadDeployment(ctx, statePath) -// that returns an empty id and version unless both halves are there, leaving the caller to call -// CreateDeployment to create or finalize it. -// -// TODO: ask the service for a lookup by state path, so this is one round trip rather than two - a -// workspace lookup to turn the node into an id, then a get by that id. -func fetchDeploymentFromStatePath(ctx context.Context, w *databricks.WorkspaceClient, statePath string) (string, *bundledeployments.Deployment, error) { - nodePath := path.Join(statePath, dms.DeploymentNodeName) - - obj, err := retry.With(ctx, retry.IsTransientRead, func() (*workspace.ObjectInfo, error) { - return w.Workspace.GetStatusByPath(ctx, nodePath) - }) - if errors.Is(err, apierr.ErrNotFound) || errors.Is(err, apierr.ErrResourceDoesNotExist) { - return "", nil, nil - } - if err != nil { - return "", nil, fmt.Errorf("looking up deployment at %s: %w", nodePath, err) - } - deploymentID := strconv.FormatInt(obj.ObjectId, 10) - deployment, err := retry.With(ctx, retry.IsTransientRead, func() (*bundledeployments.Deployment, error) { - return w.BundleDeployments.GetDeployment(ctx, bundledeployments.GetDeploymentRequest{ - Name: dms.DeploymentName(deploymentID), - }) - }) - if err != nil { - return "", nil, err - } - return deploymentID, deployment, nil -} - // parseLastVersionID parses the deployment's last recorded version, which the service reports // as a string. It returns 0 when the deployment does not exist yet or has no recorded version. func parseLastVersionID(dmsDeployment *bundledeployments.Deployment) (int, error) { @@ -696,7 +656,7 @@ func OpenDirectStateForRead(ctx context.Context, b *bundle.Bundle, stateDesc *st return nil } - dmsDeploymentID, dmsDeployment, err := fetchDeploymentFromStatePath(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) + dmsDeploymentID, dmsDeployment, err := phases.LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) if err != nil { return err } @@ -716,7 +676,7 @@ func OpenDirectStateForRead(ctx context.Context, b *bundle.Bundle, stateDesc *st // prepareDeploymentHistoryShadow reads version metadata, never resource state, from DMS. func prepareDeploymentHistoryShadow(ctx context.Context, b *bundle.Bundle) (*bundledeployments.Deployment, error) { - id, deployment, err := fetchDeploymentFromStatePath(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) + id, deployment, err := phases.LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) if err != nil { return nil, err } From 7401db722ca365a678b8341fb9327c5e5a912114 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 09:01:10 +0000 Subject: [PATCH 02/27] Move shared deployment lookup into DMS library --- bundle/phases/bind.go | 2 +- bundle/phases/dms.go | 38 -------------------------------- cmd/bundle/utils/process.go | 7 +++--- libs/dms/metadata.go | 43 +++++++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 42 deletions(-) diff --git a/bundle/phases/bind.go b/bundle/phases/bind.go index ad82587a303..a1f3fab8539 100644 --- a/bundle/phases/bind.go +++ b/bundle/phases/bind.go @@ -182,7 +182,7 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string return nil } - deploymentID, current, err := LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) + deploymentID, current, err := dms.LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) if err != nil { return err } diff --git a/bundle/phases/dms.go b/bundle/phases/dms.go index fd9c117cc25..38210913ce8 100644 --- a/bundle/phases/dms.go +++ b/bundle/phases/dms.go @@ -2,7 +2,6 @@ package phases import ( "context" - "errors" "fmt" "net/url" "path" @@ -23,7 +22,6 @@ import ( "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/common/types/fieldmask" "github.com/databricks/databricks-sdk-go/service/bundledeployments" - "github.com/databricks/databricks-sdk-go/service/workspace" ) // stagedOperations lists the resources the plan will touch, for CreateVersion to stage an @@ -294,39 +292,3 @@ func deploymentModeToSDK(mode config.Mode) bundledeployments.DeploymentMode { return "" } } - -// LookupDeployment reads the deployment metadata for a bundle. -// -// Lookup and return the deployment object from ${workspace.state_path}/resources.deployment.json -// -// TODO: a deployment is only usable when both the node and the service's record exist, and a -// half-created one - node present, record missing - blocks the bundle here even though -// CreateDeployment already recovers from it. Move this behind a dms.ReadDeployment(ctx, statePath) -// that returns an empty id and version unless both halves are there, leaving the caller to call -// CreateDeployment to create or finalize it. -// -// TODO: ask the service for a lookup by state path, so this is one round trip rather than two - a -// workspace lookup to turn the node into an id, then a get by that id. -func LookupDeployment(ctx context.Context, w *databricks.WorkspaceClient, statePath string) (string, *bundledeployments.Deployment, error) { - nodePath := path.Join(statePath, dms.DeploymentNodeName) - - obj, err := retry.With(ctx, retry.IsTransientRead, func() (*workspace.ObjectInfo, error) { - return w.Workspace.GetStatusByPath(ctx, nodePath) - }) - if errors.Is(err, apierr.ErrNotFound) || errors.Is(err, apierr.ErrResourceDoesNotExist) { - return "", nil, nil - } - if err != nil { - return "", nil, fmt.Errorf("looking up deployment at %s: %w", nodePath, err) - } - deploymentID := strconv.FormatInt(obj.ObjectId, 10) - deployment, err := retry.With(ctx, retry.IsTransientRead, func() (*bundledeployments.Deployment, error) { - return w.BundleDeployments.GetDeployment(ctx, bundledeployments.GetDeploymentRequest{ - Name: dms.DeploymentName(deploymentID), - }) - }) - if err != nil { - return "", nil, err - } - return deploymentID, deployment, nil -} diff --git a/cmd/bundle/utils/process.go b/cmd/bundle/utils/process.go index 9bdad2c6e04..33d53a1fed1 100644 --- a/cmd/bundle/utils/process.go +++ b/cmd/bundle/utils/process.go @@ -27,6 +27,7 @@ import ( "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/diag" + "github.com/databricks/cli/libs/dms" "github.com/databricks/cli/libs/dyn" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" @@ -405,7 +406,7 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle if needDirectState && stateMode == dstate.WritingDeploymentHistoryOnly { var err error - dmsDeploymentID, dmsDeployment, err = phases.LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) + dmsDeploymentID, dmsDeployment, err = dms.LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) if err != nil { logdiag.LogError(ctx, err) return b, stateDesc, root.ErrAlreadyPrinted @@ -656,7 +657,7 @@ func OpenDirectStateForRead(ctx context.Context, b *bundle.Bundle, stateDesc *st return nil } - dmsDeploymentID, dmsDeployment, err := phases.LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) + dmsDeploymentID, dmsDeployment, err := dms.LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) if err != nil { return err } @@ -676,7 +677,7 @@ func OpenDirectStateForRead(ctx context.Context, b *bundle.Bundle, stateDesc *st // prepareDeploymentHistoryShadow reads version metadata, never resource state, from DMS. func prepareDeploymentHistoryShadow(ctx context.Context, b *bundle.Bundle) (*bundledeployments.Deployment, error) { - id, deployment, err := phases.LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) + id, deployment, err := dms.LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) if err != nil { return nil, err } diff --git a/libs/dms/metadata.go b/libs/dms/metadata.go index f9e23a67db0..3a5dc5158d7 100644 --- a/libs/dms/metadata.go +++ b/libs/dms/metadata.go @@ -1,12 +1,19 @@ package dms import ( + "context" + "errors" "fmt" + "path" "strconv" "strings" + "github.com/databricks/cli/libs/retry" "github.com/databricks/cli/libs/structs/structdiff" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/service/bundledeployments" + "github.com/databricks/databricks-sdk-go/service/workspace" ) // VersionType identifies the kind of deployment a version records. @@ -132,3 +139,39 @@ func DeploymentUpdate(metadata Metadata, mask string) bundledeployments.Deployme } return dep } + +// LookupDeployment reads the deployment metadata for a bundle. +// +// Lookup and return the deployment object from ${workspace.state_path}/resources.deployment.json +// +// TODO: a deployment is only usable when both the node and the service's record exist, and a +// half-created one - node present, record missing - blocks the bundle here even though +// CreateDeployment already recovers from it. Move this behind a dms.ReadDeployment(ctx, statePath) +// that returns an empty id and version unless both halves are there, leaving the caller to call +// CreateDeployment to create or finalize it. +// +// TODO: ask the service for a lookup by state path, so this is one round trip rather than two - a +// workspace lookup to turn the node into an id, then a get by that id. +func LookupDeployment(ctx context.Context, w *databricks.WorkspaceClient, statePath string) (string, *bundledeployments.Deployment, error) { + nodePath := path.Join(statePath, DeploymentNodeName) + + obj, err := retry.With(ctx, retry.IsTransientRead, func() (*workspace.ObjectInfo, error) { + return w.Workspace.GetStatusByPath(ctx, nodePath) + }) + if errors.Is(err, apierr.ErrNotFound) || errors.Is(err, apierr.ErrResourceDoesNotExist) { + return "", nil, nil + } + if err != nil { + return "", nil, fmt.Errorf("looking up deployment at %s: %w", nodePath, err) + } + deploymentID := strconv.FormatInt(obj.ObjectId, 10) + deployment, err := retry.With(ctx, retry.IsTransientRead, func() (*bundledeployments.Deployment, error) { + return w.BundleDeployments.GetDeployment(ctx, bundledeployments.GetDeploymentRequest{ + Name: DeploymentName(deploymentID), + }) + }) + if err != nil { + return "", nil, err + } + return deploymentID, deployment, nil +} From 39a7809917fdeb0e3cf69e03baa65b6e90f45a4a Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 09:14:04 +0000 Subject: [PATCH 03/27] Address bind history self-review findings --- .nextchanges/bundles/record-bind-history.md | 2 +- ...{out.bind.false.txt => out.bind.plain.txt} | 0 ...ut.bind.true.txt => out.bind.recorded.txt} | 0 .../deployment/bind/feature/out.test.toml | 3 +- .../bundle/deployment/bind/feature/script | 2 +- .../bundle/deployment/bind/feature/test.toml | 8 +++- .../dms/bind-immutable/databricks.yml.tmpl | 9 ++++ .../bundle/dms/bind-immutable/extra.yml | 4 ++ .../bundle/dms/bind-immutable/out.test.toml | 2 + .../bundle/dms/bind-immutable/output.txt | 35 ++++++++++++++ acceptance/bundle/dms/bind-immutable/script | 21 +++++++++ .../bundle/dms/bind-immutable/test.toml | 1 + .../bundle/dms/bind-unbind/out.test.toml | 2 +- acceptance/bundle/dms/bind-unbind/output.txt | 9 ++++ acceptance/bundle/dms/bind-unbind/script | 9 ++++ acceptance/bundle/dms/bind-unbind/test.toml | 2 +- bundle/phases/bind.go | 22 +++++---- bundle/phases/dms.go | 47 +++++++++++++++---- libs/dms/metadata.go | 3 +- 19 files changed, 156 insertions(+), 25 deletions(-) rename acceptance/bundle/deployment/bind/feature/{out.bind.false.txt => out.bind.plain.txt} (100%) rename acceptance/bundle/deployment/bind/feature/{out.bind.true.txt => out.bind.recorded.txt} (100%) create mode 100644 acceptance/bundle/dms/bind-immutable/databricks.yml.tmpl create mode 100644 acceptance/bundle/dms/bind-immutable/extra.yml create mode 100644 acceptance/bundle/dms/bind-immutable/out.test.toml create mode 100644 acceptance/bundle/dms/bind-immutable/output.txt create mode 100644 acceptance/bundle/dms/bind-immutable/script create mode 100644 acceptance/bundle/dms/bind-immutable/test.toml diff --git a/.nextchanges/bundles/record-bind-history.md b/.nextchanges/bundles/record-bind-history.md index 7fce080e9fc..f1ae02d88be 100644 --- a/.nextchanges/bundles/record-bind-history.md +++ b/.nextchanges/bundles/record-bind-history.md @@ -1 +1 @@ -* Record deployment history when binding existing resources with `bundle deployment bind`. ([#7400](https://github.com/databricks/cli/pull/7400)) +* Record deployment history when binding existing resources with `bundle deployment bind` when deployment history is enabled. ([#7400](https://github.com/databricks/cli/pull/7400)) diff --git a/acceptance/bundle/deployment/bind/feature/out.bind.false.txt b/acceptance/bundle/deployment/bind/feature/out.bind.plain.txt similarity index 100% rename from acceptance/bundle/deployment/bind/feature/out.bind.false.txt rename to acceptance/bundle/deployment/bind/feature/out.bind.plain.txt diff --git a/acceptance/bundle/deployment/bind/feature/out.bind.true.txt b/acceptance/bundle/deployment/bind/feature/out.bind.recorded.txt similarity index 100% rename from acceptance/bundle/deployment/bind/feature/out.bind.true.txt rename to acceptance/bundle/deployment/bind/feature/out.bind.recorded.txt diff --git a/acceptance/bundle/deployment/bind/feature/out.test.toml b/acceptance/bundle/deployment/bind/feature/out.test.toml index 943f1dc5575..8a45520420b 100644 --- a/acceptance/bundle/deployment/bind/feature/out.test.toml +++ b/acceptance/bundle/deployment/bind/feature/out.test.toml @@ -1,2 +1,3 @@ Cloud = false -EnvMatrix.DMS = ["false", "true"] +EnvMatrix.DMS = ["", "true"] +EnvMatrix.OUTPUT_MODE = ["plain", "recorded"] diff --git a/acceptance/bundle/deployment/bind/feature/script b/acceptance/bundle/deployment/bind/feature/script index debd1951c68..ed49b8938a5 100644 --- a/acceptance/bundle/deployment/bind/feature/script +++ b/acceptance/bundle/deployment/bind/feature/script @@ -1,4 +1,4 @@ -trace $CLI bundle deployment bind feature1 main.myschema.boundfeature --auto-approve 2> out.bind.$DMS.txt +trace $CLI bundle deployment bind feature1 main.myschema.boundfeature --auto-approve 2> out.bind.$OUTPUT_MODE.txt trace $CLI bundle summary trace $CLI bundle deployment unbind feature1 diff --git a/acceptance/bundle/deployment/bind/feature/test.toml b/acceptance/bundle/deployment/bind/feature/test.toml index 5bb3b67ab85..97da8412e45 100644 --- a/acceptance/bundle/deployment/bind/feature/test.toml +++ b/acceptance/bundle/deployment/bind/feature/test.toml @@ -1,5 +1,9 @@ -EnvMatrix.DMS = ["false", "true"] -EnvVaryOutput = "DMS" +# Keep CI's DMS= variant while using non-empty labels for per-variant output files. +EnvMatrix.OUTPUT_MODE = ["plain", "recorded"] +EnvMatrixExclude.plain_with_history = ["DMS=true", "OUTPUT_MODE=plain"] +EnvMatrixExclude.recorded_without_history = ["DMS=", "OUTPUT_MODE=recorded"] +EnvVaryOutput = "OUTPUT_MODE" +EnvRepl.OUTPUT_MODE = false Cloud = false Ignore = [ diff --git a/acceptance/bundle/dms/bind-immutable/databricks.yml.tmpl b/acceptance/bundle/dms/bind-immutable/databricks.yml.tmpl new file mode 100644 index 00000000000..300a5d3a8e6 --- /dev/null +++ b/acceptance/bundle/dms/bind-immutable/databricks.yml.tmpl @@ -0,0 +1,9 @@ +bundle: + name: dms-bind-immutable-$UNIQUE_NAME +include: [] +workspace: + sync_mode: immutable +resources: + jobs: + foo: + name: foo diff --git a/acceptance/bundle/dms/bind-immutable/extra.yml b/acceptance/bundle/dms/bind-immutable/extra.yml new file mode 100644 index 00000000000..cd35a4be7e8 --- /dev/null +++ b/acceptance/bundle/dms/bind-immutable/extra.yml @@ -0,0 +1,4 @@ +resources: + jobs: + bar: + name: bar diff --git a/acceptance/bundle/dms/bind-immutable/out.test.toml b/acceptance/bundle/dms/bind-immutable/out.test.toml new file mode 100644 index 00000000000..88e6fbd847c --- /dev/null +++ b/acceptance/bundle/dms/bind-immutable/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["true"] diff --git a/acceptance/bundle/dms/bind-immutable/output.txt b/acceptance/bundle/dms/bind-immutable/output.txt new file mode 100644 index 00000000000..debd86616cf --- /dev/null +++ b/acceptance/bundle/dms/bind-immutable/output.txt @@ -0,0 +1,35 @@ + +=== An immutable bundle can record its first bind before any snapshot exists +>>> [CLI] bundle deployment bind foo [JOB_ID] --auto-approve +Successfully bound job with an id '[JOB_ID]' +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": "3"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.foo"}], "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/3/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-immutable-[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/3/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} + +=== Deploy creates the immutable snapshot +>>> [CLI] bundle deploy +Created internal_immutable_snapshots.immutable +Updated jobs.foo +Files: 0 uploaded, 0 deleted +Resources: 1 created, 1 changed, 0 deleted, 0 unchanged + +=== Binding another resource preserves the last deployed snapshot metadata +>>> update_file.py databricks.yml include: [] include: [extra.yml] + +>>> [CLI] bundle deployment bind bar [JOB_ID] --auto-approve +Successfully bound job with an id '[JOB_ID]' +Run 'bundle deploy' to deploy changes to your workspace + +>>> print_requests.py --dms //api/2.0/bundle --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.bar"}], "previous_version_id": "4", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/7/operations/jobs.bar", "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-immutable-[UNIQUE_NAME]/default/state/metadata.json\"},\"edit_mode\":\"UI_LOCKED\",\"format\":\"MULTI_TASK\",\"max_concurrent_runs\":1,\"name\":\"bar\",\"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"}} + +>>> MSYS_NO_PATHCONV=1 [CLI] api get /api/2.0/bundle/deployments/[DEPLOYMENT_ID] +{ + "file_path": "[DEPLOYED_FILE_PATH]", + "preserved": true +} diff --git a/acceptance/bundle/dms/bind-immutable/script b/acceptance/bundle/dms/bind-immutable/script new file mode 100644 index 00000000000..cfb1102a4fc --- /dev/null +++ b/acceptance/bundle/dms/bind-immutable/script @@ -0,0 +1,21 @@ +envsubst < databricks.yml.tmpl > databricks.yml +job_id=$($CLI jobs create --json '{"name": "bind-target-'$UNIQUE_NAME'"}' | jq -r '.job_id') +add_repl "$job_id" JOB_ID + +title "An immutable bundle can record its first bind before any snapshot exists" +trace $CLI bundle deployment bind foo "$job_id" --auto-approve +trace print_requests.py --dms //versions --oneline | contains.py OPERATION_ACTION_TYPE_BIND VERSION_COMPLETE_SUCCESS + +title "Deploy creates the immutable snapshot" +trace $CLI bundle deploy +deployment_id=$(MSYS_NO_PATHCONV=1 $CLI workspace get-status "/Workspace/Users/${CURRENT_USER_NAME}/.bundle/dms-bind-immutable-${UNIQUE_NAME}/default/state/resources.deployment.json" -o json | jq -r '.object_id') +file_path=$(MSYS_NO_PATHCONV=1 $CLI api get "/api/2.0/bundle/deployments/${deployment_id}" | jq -r '.workspace_info.file_path') +add_repl "$file_path" DEPLOYED_FILE_PATH +rm -f "$OUT_REQUESTS" + +title "Binding another resource preserves the last deployed snapshot metadata" +trace update_file.py databricks.yml 'include: []' 'include: [extra.yml]' +trace $CLI bundle deployment bind bar "$job_id" --auto-approve +trace print_requests.py --dms //api/2.0/bundle --oneline | contains.py OPERATION_ACTION_TYPE_BIND VERSION_COMPLETE_SUCCESS +trace MSYS_NO_PATHCONV=1 $CLI api get "/api/2.0/bundle/deployments/${deployment_id}" | jq --arg expected "$file_path" '{file_path: .workspace_info.file_path, preserved: (.workspace_info.file_path == $expected)}' +rm -f "$OUT_REQUESTS" diff --git a/acceptance/bundle/dms/bind-immutable/test.toml b/acceptance/bundle/dms/bind-immutable/test.toml new file mode 100644 index 00000000000..b7e0c59ed1b --- /dev/null +++ b/acceptance/bundle/dms/bind-immutable/test.toml @@ -0,0 +1 @@ +Cloud = false # Assert history metadata without requiring snapshot permissions on a real workspace. diff --git a/acceptance/bundle/dms/bind-unbind/out.test.toml b/acceptance/bundle/dms/bind-unbind/out.test.toml index 88e6fbd847c..84b22ba6735 100644 --- a/acceptance/bundle/dms/bind-unbind/out.test.toml +++ b/acceptance/bundle/dms/bind-unbind/out.test.toml @@ -1,2 +1,2 @@ -Cloud = false +Cloud = true EnvMatrix.DMS = ["true"] diff --git a/acceptance/bundle/dms/bind-unbind/output.txt b/acceptance/bundle/dms/bind-unbind/output.txt index a5fa3b20dbe..e07ca28f859 100644 --- a/acceptance/bundle/dms/bind-unbind/output.txt +++ b/acceptance/bundle/dms/bind-unbind/output.txt @@ -32,6 +32,15 @@ Run 'bundle deploy' to deploy changes to your workspace "name": "bind-target-[UNIQUE_NAME]" } +>>> MSYS_NO_PATHCONV=1 [CLI] bundle-deployments list-resources deployments/[DEPLOYMENT_ID] -o json +[ + { + "last_action_type": "OPERATION_ACTION_TYPE_BIND", + "resource_id": "[JOB_ID]", + "resource_key": "jobs.foo" + } +] + === 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... diff --git a/acceptance/bundle/dms/bind-unbind/script b/acceptance/bundle/dms/bind-unbind/script index f52e37e6d07..36c97bf8c3d 100644 --- a/acceptance/bundle/dms/bind-unbind/script +++ b/acceptance/bundle/dms/bind-unbind/script @@ -3,11 +3,20 @@ envsubst < databricks.yml.tmpl > databricks.yml title "Bind an existing job with deployment history enabled" job_id=$($CLI jobs create --json '{"name": "bind-target-'$UNIQUE_NAME'"}' | jq -r '.job_id') add_repl "$job_id" JOB_ID +cleanup_bind_target() { + errcode $CLI bundle destroy --auto-approve >> LOG.cleanup 2>&1 + errcode $CLI jobs delete "$job_id" >> LOG.cleanup 2>&1 +} +trap cleanup_bind_target EXIT + trace musterr $CLI bundle deployment bind foo "$job_id" trace print_requests.py --dms //api/2.0/bundle trace $CLI bundle deployment bind foo "$job_id" --auto-approve trace print_requests.py --dms //versions --oneline trace $CLI jobs get "$job_id" | jq '{job_id, name: .settings.name}' +deployment_id=$(MSYS_NO_PATHCONV=1 $CLI workspace get-status "/Workspace/Users/${CURRENT_USER_NAME}/.bundle/dms-bind-unbind-${UNIQUE_NAME}/default/state/resources.deployment.json" -o json | jq -r '.object_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})' + title "Deploy keeps the bound job ID with recorded history" trace $CLI bundle deploy diff --git a/acceptance/bundle/dms/bind-unbind/test.toml b/acceptance/bundle/dms/bind-unbind/test.toml index 353bd89e0a9..b7cf7fdd9a0 100644 --- a/acceptance/bundle/dms/bind-unbind/test.toml +++ b/acceptance/bundle/dms/bind-unbind/test.toml @@ -1,2 +1,2 @@ # Verify shadow history markers and resource IDs across bind, deploy, unbind, and rebind. -Cloud = false +Cloud = true diff --git a/bundle/phases/bind.go b/bundle/phases/bind.go index a1f3fab8539..9a5cbcf3fa0 100644 --- a/bundle/phases/bind.go +++ b/bundle/phases/bind.go @@ -44,12 +44,7 @@ func Bind(ctx context.Context, b *bundle.Bundle, opts *BindOptions, stateDesc *s historySucceeded := true defer func() { - if b.DeploymentBundle.StateDB.RecordsHistory() { - completionCtx := cmdctx.SetWorkspaceClient(ctx, b.WorkspaceClient(ctx)) - if _, err := b.DeploymentBundle.StateDB.CompleteVersion(completionCtx, historySucceeded && !logdiag.HasError(ctx)); err != nil { - log.Warnf(ctx, "Failed to complete deployment history version: %s", err) - } - } + completeBindingHistory(ctx, b, historySucceeded && !logdiag.HasError(ctx)) bundle.ApplyContext(ctx, b, lock.Release(lock.GoalBind)) }() @@ -123,6 +118,17 @@ func Bind(ctx context.Context, b *bundle.Bundle, opts *BindOptions, stateDesc *s statemgmt.PushResourcesState(ctx, b) } +// completeBindingHistory completes the recorded version after the remote state upload. +func completeBindingHistory(ctx context.Context, b *bundle.Bundle, success bool) { + if !b.DeploymentBundle.StateDB.RecordsHistory() { + return + } + completionCtx := cmdctx.SetWorkspaceClient(ctx, b.WorkspaceClient(ctx)) + if _, err := b.DeploymentBundle.StateDB.CompleteVersion(completionCtx, success); err != nil { + log.Warnf(ctx, "Failed to complete deployment history version: %s", err) + } +} + func jsonDump(ctx context.Context, v any, field string) string { b, err := json.Marshal(v) if err != nil { @@ -162,7 +168,7 @@ func Unbind(ctx context.Context, b *bundle.Bundle, group, resourceKey string, en statemgmt.PushResourcesState(ctx, b) } -// recordBindHistory records the committed binding without applying the resource's plan. +// recordBindHistory records a committed binding's saved state when history recording is enabled. func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string) (err error) { db := &b.DeploymentBundle.StateDB _, statePath := b.StateFilenameDirect(ctx) @@ -204,7 +210,7 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string if !cmdctx.HasWorkspaceClient(ctx) { ctx = cmdctx.SetWorkspaceClient(ctx, b.WorkspaceClient(ctx)) } - if err := createOrUpdateDeployment(ctx, b, current); err != nil { + if err := createBindingDeployment(ctx, b, current); err != nil { return err } if err := startVersion(ctx, b, dms.VersionTypeDeploy, []bundledeployments.StagedOperation{{ diff --git a/bundle/phases/dms.go b/bundle/phases/dms.go index 38210913ce8..f70f249c079 100644 --- a/bundle/phases/dms.go +++ b/bundle/phases/dms.go @@ -81,16 +81,9 @@ func createOrUpdateDeployment(ctx context.Context, b *bundle.Bundle, current *bu } deploymentID := db.StateDB.DeploymentID if deploymentID == "" { - dep := metadata.Deployment() - dep.InitialParentPath = b.Config.Workspace.StatePath - created, err := w.BundleDeployments.CreateDeployment(ctx, bundledeployments.CreateDeploymentRequest{Deployment: dep}) + deploymentID, err = createDeployment(ctx, b, metadata) if err != nil { - return fmt.Errorf("failed to create deployment: %w", err) - } - // The id is the workspace node the server makes under the parent path. - deploymentID, err = dms.DeploymentIDFromName(created.Name) - if err != nil { - return fmt.Errorf("failed to create deployment: %w", err) + return err } db.StateDB.DeploymentID = deploymentID } else if mask := metadata.StaleFields(current); mask != "" { @@ -113,6 +106,42 @@ func createOrUpdateDeployment(ctx context.Context, b *bundle.Bundle, current *bu return nil } +// createDeployment registers metadata under the bundle's state path and returns its server-minted ID. +func createDeployment(ctx context.Context, b *bundle.Bundle, metadata dms.Metadata) (string, error) { + dep := metadata.Deployment() + dep.InitialParentPath = b.Config.Workspace.StatePath + created, err := b.WorkspaceClient(ctx).BundleDeployments.CreateDeployment(ctx, bundledeployments.CreateDeploymentRequest{Deployment: dep}) + if err != nil { + return "", fmt.Errorf("failed to create deployment: %w", err) + } + // The id is the workspace node the server makes under the parent path. + deploymentID, err := dms.DeploymentIDFromName(created.Name) + if err != nil { + return "", fmt.Errorf("failed to create deployment: %w", err) + } + return deploymentID, nil +} + +// createBindingDeployment registers a first binding without changing metadata from an existing deploy. +func createBindingDeployment(ctx context.Context, b *bundle.Bundle, current *bundledeployments.Deployment) error { + if current != nil { + return nil + } + // Bind does not upload files or stage an immutable snapshot, so it cannot report a deployed file path. + metadata := dms.Metadata{ + DisplayName: b.Config.Bundle.Name, + TargetName: b.Config.Bundle.Target, + Mode: deploymentModeToSDK(b.Config.Bundle.Mode), + Workspace: &bundledeployments.WorkspaceInfo{RootPath: b.Config.Workspace.RootPath}, + } + deploymentID, err := createDeployment(ctx, b, metadata) + if err != nil { + return err + } + b.DeploymentBundle.StateDB.DeploymentID = deploymentID + return nil +} + // deleteDeployment deletes the deployment record. A retry that finds it missing succeeds: the // earlier attempt may have deleted it before its response was lost. func deleteDeployment(ctx context.Context, w *databricks.WorkspaceClient, deploymentID string) error { diff --git a/libs/dms/metadata.go b/libs/dms/metadata.go index 3a5dc5158d7..240c85a35c3 100644 --- a/libs/dms/metadata.go +++ b/libs/dms/metadata.go @@ -140,7 +140,8 @@ func DeploymentUpdate(metadata Metadata, mask string) bundledeployments.Deployme return dep } -// LookupDeployment reads the deployment metadata for a bundle. +// LookupDeployment resolves the deployment node under statePath and reads its DMS record. +// A missing workspace node returns no deployment. // // Lookup and return the deployment object from ${workspace.state_path}/resources.deployment.json // From 78c8f31ced9cfc443466daaacbe8638ff3a8672a Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 10:05:12 +0000 Subject: [PATCH 04/27] Keep binding registration separate from existing deploy code --- bundle/phases/dms.go | 47 +++++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/bundle/phases/dms.go b/bundle/phases/dms.go index f70f249c079..53407c843f5 100644 --- a/bundle/phases/dms.go +++ b/bundle/phases/dms.go @@ -81,9 +81,16 @@ func createOrUpdateDeployment(ctx context.Context, b *bundle.Bundle, current *bu } deploymentID := db.StateDB.DeploymentID if deploymentID == "" { - deploymentID, err = createDeployment(ctx, b, metadata) + dep := metadata.Deployment() + dep.InitialParentPath = b.Config.Workspace.StatePath + created, err := w.BundleDeployments.CreateDeployment(ctx, bundledeployments.CreateDeploymentRequest{Deployment: dep}) if err != nil { - return err + return fmt.Errorf("failed to create deployment: %w", err) + } + // The id is the workspace node the server makes under the parent path. + deploymentID, err = dms.DeploymentIDFromName(created.Name) + if err != nil { + return fmt.Errorf("failed to create deployment: %w", err) } db.StateDB.DeploymentID = deploymentID } else if mask := metadata.StaleFields(current); mask != "" { @@ -106,37 +113,27 @@ func createOrUpdateDeployment(ctx context.Context, b *bundle.Bundle, current *bu return nil } -// createDeployment registers metadata under the bundle's state path and returns its server-minted ID. -func createDeployment(ctx context.Context, b *bundle.Bundle, metadata dms.Metadata) (string, error) { - dep := metadata.Deployment() - dep.InitialParentPath = b.Config.Workspace.StatePath - created, err := b.WorkspaceClient(ctx).BundleDeployments.CreateDeployment(ctx, bundledeployments.CreateDeploymentRequest{Deployment: dep}) - if err != nil { - return "", fmt.Errorf("failed to create deployment: %w", err) - } - // The id is the workspace node the server makes under the parent path. - deploymentID, err := dms.DeploymentIDFromName(created.Name) - if err != nil { - return "", fmt.Errorf("failed to create deployment: %w", err) - } - return deploymentID, nil -} - // createBindingDeployment registers a first binding without changing metadata from an existing deploy. func createBindingDeployment(ctx context.Context, b *bundle.Bundle, current *bundledeployments.Deployment) error { if current != nil { return nil } // Bind does not upload files or stage an immutable snapshot, so it cannot report a deployed file path. - metadata := dms.Metadata{ - DisplayName: b.Config.Bundle.Name, - TargetName: b.Config.Bundle.Target, - Mode: deploymentModeToSDK(b.Config.Bundle.Mode), - Workspace: &bundledeployments.WorkspaceInfo{RootPath: b.Config.Workspace.RootPath}, + created, err := b.WorkspaceClient(ctx).BundleDeployments.CreateDeployment(ctx, bundledeployments.CreateDeploymentRequest{ + Deployment: bundledeployments.Deployment{ + DisplayName: b.Config.Bundle.Name, + TargetName: b.Config.Bundle.Target, + DeploymentMode: deploymentModeToSDK(b.Config.Bundle.Mode), + WorkspaceInfo: &bundledeployments.WorkspaceInfo{RootPath: b.Config.Workspace.RootPath}, + InitialParentPath: b.Config.Workspace.StatePath, + }, + }) + if err != nil { + return fmt.Errorf("failed to create deployment: %w", err) } - deploymentID, err := createDeployment(ctx, b, metadata) + deploymentID, err := dms.DeploymentIDFromName(created.Name) if err != nil { - return err + return fmt.Errorf("failed to create deployment: %w", err) } b.DeploymentBundle.StateDB.DeploymentID = deploymentID return nil From 73aa9141d4340489c393765d0e048a9ff3c7c435 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 10:22:10 +0000 Subject: [PATCH 05/27] Move binding deployment registration into bind phase --- bundle/phases/bind.go | 26 ++++++++++++++++++++++++++ bundle/phases/dms.go | 26 -------------------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/bundle/phases/bind.go b/bundle/phases/bind.go index 9a5cbcf3fa0..fe77e0f7ed7 100644 --- a/bundle/phases/bind.go +++ b/bundle/phases/bind.go @@ -33,6 +33,32 @@ type BindOptions struct { ResourceId string } +// createBindingDeployment registers a first binding without changing metadata from an existing deploy. +func createBindingDeployment(ctx context.Context, b *bundle.Bundle, current *bundledeployments.Deployment) error { + if current != nil { + return nil + } + // Bind does not upload files or stage an immutable snapshot, so it cannot report a deployed file path. + created, err := b.WorkspaceClient(ctx).BundleDeployments.CreateDeployment(ctx, bundledeployments.CreateDeploymentRequest{ + Deployment: bundledeployments.Deployment{ + DisplayName: b.Config.Bundle.Name, + TargetName: b.Config.Bundle.Target, + DeploymentMode: deploymentModeToSDK(b.Config.Bundle.Mode), + WorkspaceInfo: &bundledeployments.WorkspaceInfo{RootPath: b.Config.Workspace.RootPath}, + InitialParentPath: b.Config.Workspace.StatePath, + }, + }) + if err != nil { + return fmt.Errorf("failed to create deployment: %w", err) + } + deploymentID, err := dms.DeploymentIDFromName(created.Name) + if err != nil { + return fmt.Errorf("failed to create deployment: %w", err) + } + b.DeploymentBundle.StateDB.DeploymentID = deploymentID + return nil +} + func Bind(ctx context.Context, b *bundle.Bundle, opts *BindOptions, stateDesc *statemgmt.StateDesc) { log.Info(ctx, "Phase: bind") engine := stateDesc.Engine diff --git a/bundle/phases/dms.go b/bundle/phases/dms.go index 53407c843f5..38210913ce8 100644 --- a/bundle/phases/dms.go +++ b/bundle/phases/dms.go @@ -113,32 +113,6 @@ func createOrUpdateDeployment(ctx context.Context, b *bundle.Bundle, current *bu return nil } -// createBindingDeployment registers a first binding without changing metadata from an existing deploy. -func createBindingDeployment(ctx context.Context, b *bundle.Bundle, current *bundledeployments.Deployment) error { - if current != nil { - return nil - } - // Bind does not upload files or stage an immutable snapshot, so it cannot report a deployed file path. - created, err := b.WorkspaceClient(ctx).BundleDeployments.CreateDeployment(ctx, bundledeployments.CreateDeploymentRequest{ - Deployment: bundledeployments.Deployment{ - DisplayName: b.Config.Bundle.Name, - TargetName: b.Config.Bundle.Target, - DeploymentMode: deploymentModeToSDK(b.Config.Bundle.Mode), - WorkspaceInfo: &bundledeployments.WorkspaceInfo{RootPath: b.Config.Workspace.RootPath}, - InitialParentPath: b.Config.Workspace.StatePath, - }, - }) - if err != nil { - return fmt.Errorf("failed to create deployment: %w", err) - } - deploymentID, err := dms.DeploymentIDFromName(created.Name) - if err != nil { - return fmt.Errorf("failed to create deployment: %w", err) - } - b.DeploymentBundle.StateDB.DeploymentID = deploymentID - return nil -} - // deleteDeployment deletes the deployment record. A retry that finds it missing succeeds: the // earlier attempt may have deleted it before its response was lost. func deleteDeployment(ctx context.Context, w *databricks.WorkspaceClient, deploymentID string) error { From 5a98189842261c21a9aefd3979f81c576f0ea304 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 10:50:16 +0000 Subject: [PATCH 06/27] Explain the immutable binding regression test --- acceptance/bundle/dms/bind-immutable/test.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/acceptance/bundle/dms/bind-immutable/test.toml b/acceptance/bundle/dms/bind-immutable/test.toml index b7e0c59ed1b..ee7c307c0a3 100644 --- a/acceptance/bundle/dms/bind-immutable/test.toml +++ b/acceptance/bundle/dms/bind-immutable/test.toml @@ -1 +1,4 @@ +# Dedicated regression: bind previews diffs without staging an immutable snapshot. +# History must still be recorded before a snapshot exists, and preserve the last +# deployed snapshot's file path when one already exists. Cloud = false # Assert history metadata without requiring snapshot permissions on a real workspace. From dc4054da0e98975402ea09c3c157ffb5a078ee2a Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 10:52:08 +0000 Subject: [PATCH 07/27] Simplify the immutable binding test comment --- acceptance/bundle/dms/bind-immutable/test.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/acceptance/bundle/dms/bind-immutable/test.toml b/acceptance/bundle/dms/bind-immutable/test.toml index ee7c307c0a3..86e459c7c2b 100644 --- a/acceptance/bundle/dms/bind-immutable/test.toml +++ b/acceptance/bundle/dms/bind-immutable/test.toml @@ -1,4 +1,2 @@ -# Dedicated regression: bind previews diffs without staging an immutable snapshot. -# History must still be recorded before a snapshot exists, and preserve the last -# deployed snapshot's file path when one already exists. +# Reusing bundle deploy code breaks immutable snapshots because bind does not upload or stage files. Cloud = false # Assert history metadata without requiring snapshot permissions on a real workspace. From 036ce6bb29a6451e973432f6ae679be349e03fd0 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 10:53:46 +0000 Subject: [PATCH 08/27] Clarify immutable binding test coverage --- acceptance/bundle/dms/bind-immutable/test.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/acceptance/bundle/dms/bind-immutable/test.toml b/acceptance/bundle/dms/bind-immutable/test.toml index 86e459c7c2b..6ed9b96e388 100644 --- a/acceptance/bundle/dms/bind-immutable/test.toml +++ b/acceptance/bundle/dms/bind-immutable/test.toml @@ -1,2 +1,3 @@ -# Reusing bundle deploy code breaks immutable snapshots because bind does not upload or stage files. +# Ensure bind works with immutable folders and deployment history enabled. +# Reusing bundle deploy code fails here because bind does not upload or stage files. Cloud = false # Assert history metadata without requiring snapshot permissions on a real workspace. From 0587466fe21842ae404152fe83a08874c4afe970 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 10:56:42 +0000 Subject: [PATCH 09/27] Explain the observed immutable bind regression --- acceptance/bundle/dms/bind-immutable/test.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/acceptance/bundle/dms/bind-immutable/test.toml b/acceptance/bundle/dms/bind-immutable/test.toml index 6ed9b96e388..172266cc6a6 100644 --- a/acceptance/bundle/dms/bind-immutable/test.toml +++ b/acceptance/bundle/dms/bind-immutable/test.toml @@ -1,3 +1,4 @@ -# Ensure bind works with immutable folders and deployment history enabled. -# Reusing bundle deploy code fails here because bind does not upload or stage files. +# We saw bind succeed without recording deployment history for immutable folders. +# It reused deploy code that expected staged files, but bind does not stage files. +# This test prevents that regression. Cloud = false # Assert history metadata without requiring snapshot permissions on a real workspace. From 03a39de0b5ebfb3958c5e920082130449b3168f2 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 10:59:44 +0000 Subject: [PATCH 10/27] Clarify the immutable bind regression assertion --- acceptance/bundle/dms/bind-immutable/test.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/bundle/dms/bind-immutable/test.toml b/acceptance/bundle/dms/bind-immutable/test.toml index 172266cc6a6..51e648da9bd 100644 --- a/acceptance/bundle/dms/bind-immutable/test.toml +++ b/acceptance/bundle/dms/bind-immutable/test.toml @@ -1,4 +1,4 @@ # We saw bind succeed without recording deployment history for immutable folders. # It reused deploy code that expected staged files, but bind does not stage files. -# This test prevents that regression. +# This test prevents that regression, ensuring DMS works for bind with immutable folders enabled. Cloud = false # Assert history metadata without requiring snapshot permissions on a real workspace. From 0cf893aa24a609b49086fd7607dbba80a5bacc93 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 12:31:38 +0000 Subject: [PATCH 11/27] Use requested wording for immutable bind test comment --- acceptance/bundle/dms/bind-immutable/test.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/acceptance/bundle/dms/bind-immutable/test.toml b/acceptance/bundle/dms/bind-immutable/test.toml index 51e648da9bd..ee99e628c60 100644 --- a/acceptance/bundle/dms/bind-immutable/test.toml +++ b/acceptance/bundle/dms/bind-immutable/test.toml @@ -1,4 +1,4 @@ -# We saw bind succeed without recording deployment history for immutable folders. -# It reused deploy code that expected staged files, but bind does not stage files. -# This test prevents that regression, ensuring DMS works for bind with immutable folders enabled. -Cloud = false # Assert history metadata without requiring snapshot permissions on a real workspace. +# This test ensures that deployment history works with bind and immutable folders. This case is interesting +# because by default we record the snapshot path in deploy, but that is not available during bind. So it's important +# to ensure that we don't do that during deployment bind, and recording history continues to work. +Cloud = false From e1bee142ee812946eb6e8acd9396d1bde63838a3 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 12:37:30 +0000 Subject: [PATCH 12/27] Limit immutable history test to successful binding --- .../dms/bind-immutable/databricks.yml.tmpl | 1 - .../bundle/dms/bind-immutable/extra.yml | 4 -- .../bundle/dms/bind-immutable/output.txt | 38 ++++--------------- acceptance/bundle/dms/bind-immutable/script | 15 +------- 4 files changed, 9 insertions(+), 49 deletions(-) delete mode 100644 acceptance/bundle/dms/bind-immutable/extra.yml diff --git a/acceptance/bundle/dms/bind-immutable/databricks.yml.tmpl b/acceptance/bundle/dms/bind-immutable/databricks.yml.tmpl index 300a5d3a8e6..4d6fc71467e 100644 --- a/acceptance/bundle/dms/bind-immutable/databricks.yml.tmpl +++ b/acceptance/bundle/dms/bind-immutable/databricks.yml.tmpl @@ -1,6 +1,5 @@ bundle: name: dms-bind-immutable-$UNIQUE_NAME -include: [] workspace: sync_mode: immutable resources: diff --git a/acceptance/bundle/dms/bind-immutable/extra.yml b/acceptance/bundle/dms/bind-immutable/extra.yml deleted file mode 100644 index cd35a4be7e8..00000000000 --- a/acceptance/bundle/dms/bind-immutable/extra.yml +++ /dev/null @@ -1,4 +0,0 @@ -resources: - jobs: - bar: - name: bar diff --git a/acceptance/bundle/dms/bind-immutable/output.txt b/acceptance/bundle/dms/bind-immutable/output.txt index debd86616cf..99e62a0e129 100644 --- a/acceptance/bundle/dms/bind-immutable/output.txt +++ b/acceptance/bundle/dms/bind-immutable/output.txt @@ -1,35 +1,13 @@ -=== An immutable bundle can record its first bind before any snapshot exists >>> [CLI] bundle deployment bind foo [JOB_ID] --auto-approve Successfully bound job with an id '[JOB_ID]' 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": "3"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.foo"}], "version_type": "VERSION_TYPE_DEPLOY"}} -{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/3/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-immutable-[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/3/complete", "body": {"completion_reason": "VERSION_COMPLETE_SUCCESS"}} - -=== Deploy creates the immutable snapshot ->>> [CLI] bundle deploy -Created internal_immutable_snapshots.immutable -Updated jobs.foo -Files: 0 uploaded, 0 deleted -Resources: 1 created, 1 changed, 0 deleted, 0 unchanged - -=== Binding another resource preserves the last deployed snapshot metadata ->>> update_file.py databricks.yml include: [] include: [extra.yml] - ->>> [CLI] bundle deployment bind bar [JOB_ID] --auto-approve -Successfully bound job with an id '[JOB_ID]' -Run 'bundle deploy' to deploy changes to your workspace - ->>> print_requests.py --dms //api/2.0/bundle --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.bar"}], "previous_version_id": "4", "version_type": "VERSION_TYPE_DEPLOY"}} -{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/7/operations/jobs.bar", "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-immutable-[UNIQUE_NAME]/default/state/metadata.json\"},\"edit_mode\":\"UI_LOCKED\",\"format\":\"MULTI_TASK\",\"max_concurrent_runs\":1,\"name\":\"bar\",\"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"}} - ->>> MSYS_NO_PATHCONV=1 [CLI] api get /api/2.0/bundle/deployments/[DEPLOYMENT_ID] -{ - "file_path": "[DEPLOYED_FILE_PATH]", - "preserved": true -} +>>> MSYS_NO_PATHCONV=1 [CLI] bundle-deployments list-resources deployments/[DEPLOYMENT_ID] -o json +[ + { + "last_action_type": "OPERATION_ACTION_TYPE_BIND", + "resource_id": "[JOB_ID]", + "resource_key": "jobs.foo" + } +] diff --git a/acceptance/bundle/dms/bind-immutable/script b/acceptance/bundle/dms/bind-immutable/script index cfb1102a4fc..929a499ddd6 100644 --- a/acceptance/bundle/dms/bind-immutable/script +++ b/acceptance/bundle/dms/bind-immutable/script @@ -2,20 +2,7 @@ envsubst < databricks.yml.tmpl > databricks.yml job_id=$($CLI jobs create --json '{"name": "bind-target-'$UNIQUE_NAME'"}' | jq -r '.job_id') add_repl "$job_id" JOB_ID -title "An immutable bundle can record its first bind before any snapshot exists" trace $CLI bundle deployment bind foo "$job_id" --auto-approve -trace print_requests.py --dms //versions --oneline | contains.py OPERATION_ACTION_TYPE_BIND VERSION_COMPLETE_SUCCESS - -title "Deploy creates the immutable snapshot" -trace $CLI bundle deploy deployment_id=$(MSYS_NO_PATHCONV=1 $CLI workspace get-status "/Workspace/Users/${CURRENT_USER_NAME}/.bundle/dms-bind-immutable-${UNIQUE_NAME}/default/state/resources.deployment.json" -o json | jq -r '.object_id') -file_path=$(MSYS_NO_PATHCONV=1 $CLI api get "/api/2.0/bundle/deployments/${deployment_id}" | jq -r '.workspace_info.file_path') -add_repl "$file_path" DEPLOYED_FILE_PATH -rm -f "$OUT_REQUESTS" - -title "Binding another resource preserves the last deployed snapshot metadata" -trace update_file.py databricks.yml 'include: []' 'include: [extra.yml]' -trace $CLI bundle deployment bind bar "$job_id" --auto-approve -trace print_requests.py --dms //api/2.0/bundle --oneline | contains.py OPERATION_ACTION_TYPE_BIND VERSION_COMPLETE_SUCCESS -trace MSYS_NO_PATHCONV=1 $CLI api get "/api/2.0/bundle/deployments/${deployment_id}" | jq --arg expected "$file_path" '{file_path: .workspace_info.file_path, preserved: (.workspace_info.file_path == $expected)}' +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" From 9bacffc7ed4977a6efa79ba814a2219402c7602e Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 12:55:57 +0000 Subject: [PATCH 13/27] Record bind history without rewriting resource state --- .../recreation/out.state_after_bind.json | 1 + .../bind/dashboard/recreation/script | 2 +- .../dms/bind-history-failures/output.txt | 18 ++++++------- acceptance/bundle/dms/bind-unbind/output.txt | 6 ++--- bundle/direct/bind.go | 26 +++++++++++++++---- bundle/direct/dstate/state.go | 21 +++++++++++++++ bundle/phases/bind.go | 14 +++------- 7 files changed, 59 insertions(+), 29 deletions(-) diff --git a/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.json b/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.json index 4ba78767d69..05614d798bf 100644 --- a/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.json +++ b/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.json @@ -2,6 +2,7 @@ "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", + "serial": 2, "state": { "resources.dashboards.dashboard1": { "__id__": "[DASHBOARD_ID]", diff --git a/acceptance/bundle/deployment/bind/dashboard/recreation/script b/acceptance/bundle/deployment/bind/dashboard/recreation/script index 1ef7b6071a5..6d8f12f1821 100644 --- a/acceptance/bundle/deployment/bind/dashboard/recreation/script +++ b/acceptance/bundle/deployment/bind/dashboard/recreation/script @@ -13,7 +13,7 @@ cleanupRemoveDashboard() { trap cleanupRemoveDashboard EXIT trace $CLI bundle deployment bind dashboard1 "${DASHBOARD_ID}" --auto-approve -trace print_state.py --no-dms | jq 'del(.serial, .features)' | nostamp.py --indent 1 > out.state_after_bind.json +trace print_state.py --no-dms | nostamp.py --indent 1 > out.state_after_bind.json ETAG=$(jq '.state["resources.dashboards.dashboard1"].state.etag // .resources[0].instances[0].attributes.etag // "no-etag"' < out.state_after_bind.json) echo $ETAG add_repl $ETAG ETAG diff --git a/acceptance/bundle/dms/bind-history-failures/output.txt b/acceptance/bundle/dms/bind-history-failures/output.txt index 47cc75bae27..ec0c5636ff2 100644 --- a/acceptance/bundle/dms/bind-history-failures/output.txt +++ b/acceptance/bundle/dms/bind-history-failures/output.txt @@ -39,12 +39,12 @@ 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": "3"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.create_failed"}], "version_type": "VERSION_TYPE_DEPLOY"}} -{"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_BIND", "resource_key": "jobs.record_failed"}], "version_type": "VERSION_TYPE_DEPLOY"}} -{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/operations/jobs.record_failed", "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-history-failures-[UNIQUE_NAME]/default/state/metadata.json\"},\"edit_mode\":\"UI_LOCKED\",\"format\":\"MULTI_TASK\",\"max_concurrent_runs\":1,\"name\":\"record_failed\",\"queue\":{\"enabled\":true}}}", "status": "OPERATION_STATUS_SUCCEEDED"}} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/6/complete", "body": {"completion_reason": "VERSION_COMPLETE_FAILURE"}} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "9"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.complete_failed"}], "previous_version_id": "6", "version_type": "VERSION_TYPE_DEPLOY"}} -{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/9/operations/jobs.complete_failed", "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-history-failures-[UNIQUE_NAME]/default/state/metadata.json\"},\"edit_mode\":\"UI_LOCKED\",\"format\":\"MULTI_TASK\",\"max_concurrent_runs\":1,\"name\":\"complete_failed\",\"queue\":{\"enabled\":true}}}", "status": "OPERATION_STATUS_SUCCEEDED"}} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/9/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_BIND", "resource_key": "jobs.record_failed"}], "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/5/operations/jobs.record_failed", "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-history-failures-[UNIQUE_NAME]/default/state/metadata.json\"},\"edit_mode\":\"UI_LOCKED\",\"format\":\"MULTI_TASK\",\"max_concurrent_runs\":1,\"name\":\"record_failed\",\"queue\":{\"enabled\":true}}}", "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/5/complete", "body": {"completion_reason": "VERSION_COMPLETE_FAILURE"}} +{"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.complete_failed"}], "previous_version_id": "5", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/7/operations/jobs.complete_failed", "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-history-failures-[UNIQUE_NAME]/default/state/metadata.json\"},\"edit_mode\":\"UI_LOCKED\",\"format\":\"MULTI_TASK\",\"max_concurrent_runs\":1,\"name\":\"complete_failed\",\"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"}} === A remote state upload failure fails the bind version >>> fault.py POST /api/2.0/workspace-files/import-file/**resources.json 400 0 1 @@ -59,9 +59,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": "12"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.upload_failed"}], "previous_version_id": "9", "version_type": "VERSION_TYPE_DEPLOY"}} -{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/12/operations/jobs.upload_failed", "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-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}}}", "status": "OPERATION_STATUS_SUCCEEDED"}} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/12/complete", "body": {"completion_reason": "VERSION_COMPLETE_FAILURE"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "9"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_BIND", "resource_key": "jobs.upload_failed"}], "previous_version_id": "7", "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "PATCH", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/9/operations/jobs.upload_failed", "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-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}}}", "status": "OPERATION_STATUS_SUCCEEDED"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/9/complete", "body": {"completion_reason": "VERSION_COMPLETE_FAILURE"}} === Remote state still contains only the successful bindings >>> [CLI] bundle summary -o json diff --git a/acceptance/bundle/dms/bind-unbind/output.txt b/acceptance/bundle/dms/bind-unbind/output.txt index e07ca28f859..38d79d119fb 100644 --- a/acceptance/bundle/dms/bind-unbind/output.txt +++ b/acceptance/bundle/dms/bind-unbind/output.txt @@ -86,9 +86,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": "8"}, "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/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"}} +{"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"}} === A subsequent deploy continues managing the same job >>> [CLI] bundle deploy diff --git a/bundle/direct/bind.go b/bundle/direct/bind.go index 7fbf7ede662..bc5bc9b5d2e 100644 --- a/bundle/direct/bind.go +++ b/bundle/direct/bind.go @@ -10,6 +10,7 @@ import ( "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct/dresources" "github.com/databricks/cli/bundle/direct/dstate" + bundleenv "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/structs/structaccess" "github.com/databricks/cli/libs/structs/structpath" @@ -92,15 +93,11 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac } // Open temp state - err := b.StateDB.Open(ctx, tmpStatePath, dstate.ReadingFile, dstate.WithRecovery(false), dstate.OpenDmsArgs{}) + err := b.openBindingState(ctx, tmpStatePath, configRoot, false) if err != nil { os.Remove(tmpStatePath) return nil, err } - if err := b.StateDB.UpgradeToWrite(); err != nil { - os.Remove(tmpStatePath) - return nil, err - } // Save state with ID and empty state (like migrate does) err = b.StateDB.SaveState(ctx, resourceKey, resourceID, struct{}{}, nil) @@ -239,6 +236,25 @@ func (result *BindResult) Cancel() { } } +// openBindingState selects history mode before the binding write so recording needs no extra state commit. +func (b *DeploymentBundle) openBindingState(ctx context.Context, statePath string, configRoot *config.Root, withRecovery dstate.WithRecovery) error { + var configured *bool + if configRoot.Experimental != nil { + configured = configRoot.Experimental.DeploymentHistory + } + mode := dstate.ReadingFile + if bundleenv.RecordsDeploymentHistory(ctx, configured) { + mode = dstate.WritingDeploymentHistoryShadow + } + if err := b.StateDB.Open(ctx, statePath, mode, withRecovery, dstate.OpenDmsArgs{}); err != nil { + return err + } + if mode == dstate.ReadingFile { + return b.StateDB.UpgradeToWrite() + } + return nil +} + // 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 { diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index 384246913f4..de224c0c468 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -261,6 +261,27 @@ func (db *DeploymentState) CompleteVersion(ctx context.Context, success bool) (b return success, nil } +// RecordResourceHistory publishes an operation's state (nil for removal) without writing local state. +func (db *DeploymentState) RecordResourceHistory(ctx context.Context, resourceKey string, entry *ResourceEntry) error { + db.AssertOpenedForWrite() + buf := db.getOperationBuffer() + if buf == nil { + return nil + } + var recorded json.RawMessage + var resourceID string + if entry != nil { + var err error + recorded, err = json.Marshal(RecordedState{State: entry.State, DependsOn: entry.DependsOn}) + if err != nil { + return err + } + resourceID = entry.ID + } + buf.RecordOperation(ctx, resourceKey, false, resourceID, recorded) + return nil +} + // RecordFailure records that a resource did not apply, so the history says why rather than // leaving the resource out. resourceID is the id it had before the failure. func (db *DeploymentState) RecordFailure(resourceKey, resourceID string, cause error) { diff --git a/bundle/phases/bind.go b/bundle/phases/bind.go index fe77e0f7ed7..e2f19772327 100644 --- a/bundle/phases/bind.go +++ b/bundle/phases/bind.go @@ -14,7 +14,6 @@ import ( "github.com/databricks/cli/bundle/deploy/lock" "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct/dstate" - bundleenv "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/libs/agent" "github.com/databricks/cli/libs/cmdctx" @@ -206,11 +205,7 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string err = errors.Join(err, finalizeErr) }() - var configured *bool - if b.Config.Experimental != nil { - configured = b.Config.Experimental.DeploymentHistory - } - if !db.RecordsHistory() && !bundleenv.RecordsDeploymentHistory(ctx, configured) { + if !db.RecordsHistory() { return nil } @@ -226,10 +221,7 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string } db.VersionID = next - 1 } - if db.Data.Features == nil { - db.Data.Features = make(map[string]struct{}) - } - db.Data.Features[dstate.FeatureDeploymentHistoryShadow] = struct{}{} + if err := db.UpgradeToWrite(); err != nil { return err } @@ -249,5 +241,5 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string if !ok { return fmt.Errorf("bound resource %s is missing from deployment state", resourceKey) } - return db.SaveState(ctx, resourceKey, entry.ID, entry.State, entry.DependsOn) + return db.RecordResourceHistory(ctx, resourceKey, &entry) } From 304c76dc21d7b63cad5a0525ef66de08ec1ae148 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 13:22:55 +0000 Subject: [PATCH 14/27] Parse the last recorded deployment version directly --- bundle/phases/bind.go | 9 +++------ cmd/bundle/utils/process.go | 20 +++----------------- libs/dms/metadata.go | 13 +++++++++++++ libs/dms/metadata_test.go | 21 +++++++++++++++++++++ 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/bundle/phases/bind.go b/bundle/phases/bind.go index e2f19772327..ee0a31ad17c 100644 --- a/bundle/phases/bind.go +++ b/bundle/phases/bind.go @@ -214,12 +214,9 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string return err } db.DeploymentID = deploymentID - if current != nil { - next, err := dms.NextVersion(current.LastVersionId) - if err != nil { - return err - } - db.VersionID = next - 1 + db.VersionID, err = dms.ParseLastVersionID(current) + if err != nil { + return err } if err := db.UpgradeToWrite(); err != nil { diff --git a/cmd/bundle/utils/process.go b/cmd/bundle/utils/process.go index 33d53a1fed1..0f866174802 100644 --- a/cmd/bundle/utils/process.go +++ b/cmd/bundle/utils/process.go @@ -6,7 +6,6 @@ import ( "fmt" "path/filepath" "slices" - "strconv" "time" "github.com/databricks/cli/bundle" @@ -416,7 +415,7 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle // the plan carries them. version_id is always known (last recorded + 1); deployment_id // does not exist until a first deploy creates it, so it is left off here and the deploy // phase stamps the created id. - lastVersionID, err := parseLastVersionID(dmsDeployment) + lastVersionID, err := dms.ParseLastVersionID(dmsDeployment) if err != nil { logdiag.LogError(ctx, err) return b, stateDesc, root.ErrAlreadyPrinted @@ -632,19 +631,6 @@ func ValidateEngineSetting(ctx context.Context, b *bundle.Bundle) error { return nil } -// parseLastVersionID parses the deployment's last recorded version, which the service reports -// as a string. It returns 0 when the deployment does not exist yet or has no recorded version. -func parseLastVersionID(dmsDeployment *bundledeployments.Deployment) (int, error) { - if dmsDeployment == nil || dmsDeployment.LastVersionId == "" { - return 0, nil - } - v, err := strconv.Atoi(dmsDeployment.LastVersionId) - if err != nil { - return 0, fmt.Errorf("failed to parse last_version_id %q: %w", dmsDeployment.LastVersionId, err) - } - return v, nil -} - // OpenDirectStateForRead opens the direct-engine state database read-only. When the bundle // records deployment history the local state file is only a tombstone, so the resources are // read from the deployment metadata service instead. @@ -661,7 +647,7 @@ func OpenDirectStateForRead(ctx context.Context, b *bundle.Bundle, stateDesc *st if err != nil { return err } - lastVersionID, err := parseLastVersionID(dmsDeployment) + lastVersionID, err := dms.ParseLastVersionID(dmsDeployment) if err != nil { return err } @@ -681,7 +667,7 @@ func prepareDeploymentHistoryShadow(ctx context.Context, b *bundle.Bundle) (*bun if err != nil { return nil, err } - lastVersion, err := parseLastVersionID(deployment) + lastVersion, err := dms.ParseLastVersionID(deployment) if err != nil { return nil, err } diff --git a/libs/dms/metadata.go b/libs/dms/metadata.go index 240c85a35c3..98010ac7b6f 100644 --- a/libs/dms/metadata.go +++ b/libs/dms/metadata.go @@ -75,6 +75,19 @@ func (m Metadata) StaleFields(current *bundledeployments.Deployment) string { return strings.Join(stale, ",") } +// ParseLastVersionID parses the deployment's last recorded version, which the service reports +// as a string. It returns 0 when the deployment does not exist yet or has no recorded version. +func ParseLastVersionID(dmsDeployment *bundledeployments.Deployment) (int, error) { + if dmsDeployment == nil || dmsDeployment.LastVersionId == "" { + return 0, nil + } + v, err := strconv.Atoi(dmsDeployment.LastVersionId) + if err != nil { + return 0, fmt.Errorf("failed to parse last_version_id %q: %w", dmsDeployment.LastVersionId, err) + } + return v, nil +} + // NextVersion is the version number this run will create, given the deployment's most recent // one. lastVersionID is empty before the deployment has any version. func NextVersion(lastVersionID string) (int, error) { diff --git a/libs/dms/metadata_test.go b/libs/dms/metadata_test.go index 5122533dafc..799a910b273 100644 --- a/libs/dms/metadata_test.go +++ b/libs/dms/metadata_test.go @@ -3,6 +3,7 @@ package dms import ( "testing" + "github.com/databricks/databricks-sdk-go/service/bundledeployments" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -41,3 +42,23 @@ func TestDeploymentIDFromName(t *testing.T) { _, err = DeploymentIDFromName("deployments/") assert.Error(t, err) } + +func TestParseLastVersionID(t *testing.T) { + for _, tc := range []struct { + name string + deployment *bundledeployments.Deployment + want int + }{ + {"no deployment", nil, 0}, + {"no version", &bundledeployments.Deployment{}, 0}, + {"existing version", &bundledeployments.Deployment{LastVersionId: "4"}, 4}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseLastVersionID(tc.deployment) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } + _, err := ParseLastVersionID(&bundledeployments.Deployment{LastVersionId: "invalid"}) + assert.ErrorContains(t, err, "last_version_id") +} From b47b6f724f3ba18b6393bccd94fc3dfcb5922279 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 13:22:55 +0000 Subject: [PATCH 15/27] testserver: accept Feature Store resources in DMS versions --- libs/testserver/bundledeployments.go | 1 + libs/testserver/bundledeployments_test.go | 43 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 libs/testserver/bundledeployments_test.go diff --git a/libs/testserver/bundledeployments.go b/libs/testserver/bundledeployments.go index 0eec1a4e463..e341789fb2a 100644 --- a/libs/testserver/bundledeployments.go +++ b/libs/testserver/bundledeployments.go @@ -50,6 +50,7 @@ var dmsResourceTypes = []string{ "database_instances", "experiments", "external_locations", + "features", "genie_spaces", "instance_pools", "internal_immutable_folders", diff --git a/libs/testserver/bundledeployments_test.go b/libs/testserver/bundledeployments_test.go new file mode 100644 index 00000000000..41444971767 --- /dev/null +++ b/libs/testserver/bundledeployments_test.go @@ -0,0 +1,43 @@ +package testserver_test + +import ( + "testing" + + "github.com/databricks/cli/libs/testserver" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/service/bundledeployments" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateVersionAcceptsFeatureResource(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + client, err := databricks.NewWorkspaceClient(&databricks.Config{Host: server.URL, Token: "testtoken"}) + require.NoError(t, err) + ctx := t.Context() + const statePath = "/Workspace/dms-feature-test" + require.NoError(t, client.Workspace.MkdirsByPath(ctx, statePath)) + deployment, err := client.BundleDeployments.CreateDeployment(ctx, bundledeployments.CreateDeploymentRequest{ + Deployment: bundledeployments.Deployment{InitialParentPath: statePath}, + }) + require.NoError(t, err) + + version, err := client.BundleDeployments.CreateVersion(ctx, bundledeployments.CreateVersionRequest{ + Parent: deployment.Name, + VersionId: "1", + Version: bundledeployments.Version{ + VersionType: bundledeployments.VersionTypeVersionTypeDeploy, + Operations: []bundledeployments.StagedOperation{{ + ResourceKey: "features.feature1", + ActionType: bundledeployments.OperationActionTypeOperationActionTypeBind, + }}, + }, + }) + require.NoError(t, err) + operations, err := client.BundleDeployments.ListOperationsAll(ctx, bundledeployments.ListOperationsRequest{Parent: version.Name}) + require.NoError(t, err) + require.Len(t, operations, 1) + assert.Equal(t, "features.feature1", operations[0].ResourceKey) + assert.Equal(t, bundledeployments.OperationActionTypeOperationActionTypeBind, operations[0].ActionType) +} From d13542523e859577d70c2f85740eb51eeccb02a4 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 13:26:06 +0000 Subject: [PATCH 16/27] Remove feature warning workarounds after fixing the DMS stub --- .../bundle/deployment/bind/feature/out.bind.plain.txt | 4 ---- .../bundle/deployment/bind/feature/out.bind.recorded.txt | 5 ----- acceptance/bundle/deployment/bind/feature/out.test.toml | 1 - acceptance/bundle/deployment/bind/feature/output.txt | 4 ++++ acceptance/bundle/deployment/bind/feature/script | 2 +- acceptance/bundle/deployment/bind/feature/test.toml | 6 ------ 6 files changed, 5 insertions(+), 17 deletions(-) delete mode 100644 acceptance/bundle/deployment/bind/feature/out.bind.plain.txt delete mode 100644 acceptance/bundle/deployment/bind/feature/out.bind.recorded.txt diff --git a/acceptance/bundle/deployment/bind/feature/out.bind.plain.txt b/acceptance/bundle/deployment/bind/feature/out.bind.plain.txt deleted file mode 100644 index 2155184a00f..00000000000 --- a/acceptance/bundle/deployment/bind/feature/out.bind.plain.txt +++ /dev/null @@ -1,4 +0,0 @@ - ->>> [CLI] bundle deployment bind feature1 main.myschema.boundfeature --auto-approve -Successfully bound feature with an id 'main.myschema.boundfeature' -Run 'bundle deploy' to deploy changes to your workspace diff --git a/acceptance/bundle/deployment/bind/feature/out.bind.recorded.txt b/acceptance/bundle/deployment/bind/feature/out.bind.recorded.txt deleted file mode 100644 index e91d2b70ab7..00000000000 --- a/acceptance/bundle/deployment/bind/feature/out.bind.recorded.txt +++ /dev/null @@ -1,5 +0,0 @@ - ->>> [CLI] bundle deployment bind feature1 main.myschema.boundfeature --auto-approve -Warn: Failed to record bind history: failed to create deployment version: operations.resource_key must have a known resource type prefix (e.g. 'jobs.', 'pipelines.'): features.feature1 -Successfully bound feature with an id 'main.myschema.boundfeature' -Run 'bundle deploy' to deploy changes to your workspace diff --git a/acceptance/bundle/deployment/bind/feature/out.test.toml b/acceptance/bundle/deployment/bind/feature/out.test.toml index 8a45520420b..a927a5fbc06 100644 --- a/acceptance/bundle/deployment/bind/feature/out.test.toml +++ b/acceptance/bundle/deployment/bind/feature/out.test.toml @@ -1,3 +1,2 @@ Cloud = false EnvMatrix.DMS = ["", "true"] -EnvMatrix.OUTPUT_MODE = ["plain", "recorded"] diff --git a/acceptance/bundle/deployment/bind/feature/output.txt b/acceptance/bundle/deployment/bind/feature/output.txt index 8466bb0eaa5..d4d47f6a660 100644 --- a/acceptance/bundle/deployment/bind/feature/output.txt +++ b/acceptance/bundle/deployment/bind/feature/output.txt @@ -1,4 +1,8 @@ +>>> [CLI] bundle deployment bind feature1 main.myschema.boundfeature --auto-approve +Successfully bound feature with an id 'main.myschema.boundfeature' +Run 'bundle deploy' to deploy changes to your workspace + >>> [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 ed49b8938a5..8e27f69056c 100644 --- a/acceptance/bundle/deployment/bind/feature/script +++ b/acceptance/bundle/deployment/bind/feature/script @@ -1,4 +1,4 @@ -trace $CLI bundle deployment bind feature1 main.myschema.boundfeature --auto-approve 2> out.bind.$OUTPUT_MODE.txt +trace $CLI bundle deployment bind feature1 main.myschema.boundfeature --auto-approve trace $CLI bundle summary trace $CLI bundle deployment unbind feature1 diff --git a/acceptance/bundle/deployment/bind/feature/test.toml b/acceptance/bundle/deployment/bind/feature/test.toml index 97da8412e45..84e8bf9b8f4 100644 --- a/acceptance/bundle/deployment/bind/feature/test.toml +++ b/acceptance/bundle/deployment/bind/feature/test.toml @@ -1,9 +1,3 @@ -# Keep CI's DMS= variant while using non-empty labels for per-variant output files. -EnvMatrix.OUTPUT_MODE = ["plain", "recorded"] -EnvMatrixExclude.plain_with_history = ["DMS=true", "OUTPUT_MODE=plain"] -EnvMatrixExclude.recorded_without_history = ["DMS=", "OUTPUT_MODE=recorded"] -EnvVaryOutput = "OUTPUT_MODE" -EnvRepl.OUTPUT_MODE = false Cloud = false Ignore = [ From a16e43563b68f866318bea02b630890efad0c986 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 14:24:54 +0000 Subject: [PATCH 17/27] Normalize summary deployment stamps with nostamp --- acceptance/bin/nostamp.py | 24 ++++++++++++++++++- .../deployment/bind/pipelines/update/script | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/acceptance/bin/nostamp.py b/acceptance/bin/nostamp.py index 8a0c222630c..db503035870 100755 --- a/acceptance/bin/nostamp.py +++ b/acceptance/bin/nostamp.py @@ -7,13 +7,14 @@ is a 2-space indent, keys in input order, <>& left unescaped, and integers at full precision. Tests under bundle/dms assert the stamp itself and must not use it. -Removed in three shapes plus the plan header: +Removed in these shapes: 1. deployment_id/version_id nested in a deployment block, recognized by the neighbouring "kind" and "metadata_file_path", which are kept; version_id == "" is kept (a Terraform state dump carries that for an unstamped job). 2. changes entries keyed "deployment.deployment_id" / "deployment.version_id". 3. a changes object emptied by (2), or already empty. 4. the recorded plan header field features. + 5. deployment_id/latest_version_id in bundle.deployment; other settings are kept. """ import argparse @@ -23,6 +24,7 @@ _STAMP_KEYS = ("deployment_id", "version_id") _CHANGE_KEYS = ("deployment.deployment_id", "deployment.version_id") _PLAN_HEADER_KEYS = ("features",) +_BUNDLE_STAMP_KEYS = ("deployment_id", "latest_version_id") def scrub(node): @@ -48,10 +50,30 @@ def scrub(node): def render(data, indent): + """ + >>> data = {"bundle": {"deployment": {"deployment_id": "42", "latest_version_id": 7}}} + >>> json.loads(render(data, 2)) + {'bundle': {}} + >>> deployment = {"deployment_id": "42", "latest_version_id": 7, "lock": {"enabled": False}} + >>> json.loads(render({"bundle": {"deployment": deployment}}, 2)) + {'bundle': {'deployment': {'lock': {'enabled': False}}}} + >>> json.loads(render({"bundle": {"deployment": {"lock": {"enabled": False}}}}, 2)) + {'bundle': {'deployment': {'lock': {'enabled': False}}}} + >>> json.loads(render({"bundle": {"deployment": {}}}, 2)) + {'bundle': {'deployment': {}}} + """ data = scrub(data) if isinstance(data, dict): for k in _PLAN_HEADER_KEYS: data.pop(k, None) # 4. plan header, present only at the root + bundle = data.get("bundle") + if isinstance(bundle, dict): + deployment = bundle.get("deployment") + if isinstance(deployment, dict) and any(k in deployment for k in _BUNDLE_STAMP_KEYS): + for k in _BUNDLE_STAMP_KEYS: + deployment.pop(k, None) + if not deployment: + bundle.pop("deployment") return json.dumps(data, indent=indent, ensure_ascii=False, separators=(",", ": ")) diff --git a/acceptance/bundle/deployment/bind/pipelines/update/script b/acceptance/bundle/deployment/bind/pipelines/update/script index d1a67c9b960..77f95d4d3ed 100644 --- a/acceptance/bundle/deployment/bind/pipelines/update/script +++ b/acceptance/bundle/deployment/bind/pipelines/update/script @@ -9,7 +9,7 @@ rm -f "$OUT_REQUESTS" trace $CLI bundle deployment bind foo $NEW_PIPELINE_ID --auto-approve &> out.bind-success.txt print_requests.py '^//import-file/' '^//workspace/delete' -trace $CLI bundle summary -o json | jq 'del(.bundle.deployment)' | nostamp.py > out.summary.json +trace $CLI bundle summary -o json | nostamp.py > out.summary.json trace $CLI bundle plan rm -f "$OUT_REQUESTS" From f7049e7b9f442c9d8a3da575ceece5fde8dfb5c4 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 14:49:10 +0000 Subject: [PATCH 18/27] Remove nostamp inline doctests --- acceptance/bin/nostamp.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/acceptance/bin/nostamp.py b/acceptance/bin/nostamp.py index db503035870..aefb1dd8e02 100755 --- a/acceptance/bin/nostamp.py +++ b/acceptance/bin/nostamp.py @@ -50,18 +50,6 @@ def scrub(node): def render(data, indent): - """ - >>> data = {"bundle": {"deployment": {"deployment_id": "42", "latest_version_id": 7}}} - >>> json.loads(render(data, 2)) - {'bundle': {}} - >>> deployment = {"deployment_id": "42", "latest_version_id": 7, "lock": {"enabled": False}} - >>> json.loads(render({"bundle": {"deployment": deployment}}, 2)) - {'bundle': {'deployment': {'lock': {'enabled': False}}}} - >>> json.loads(render({"bundle": {"deployment": {"lock": {"enabled": False}}}}, 2)) - {'bundle': {'deployment': {'lock': {'enabled': False}}}} - >>> json.loads(render({"bundle": {"deployment": {}}}, 2)) - {'bundle': {'deployment': {}}} - """ data = scrub(data) if isinstance(data, dict): for k in _PLAN_HEADER_KEYS: From d5dd84d066f2374e7c85d5f6eacfee1471f30f1d Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 14:49:37 +0000 Subject: [PATCH 19/27] Explain summary DMS stamp normalization --- acceptance/bin/nostamp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/acceptance/bin/nostamp.py b/acceptance/bin/nostamp.py index aefb1dd8e02..fedaa280a88 100755 --- a/acceptance/bin/nostamp.py +++ b/acceptance/bin/nostamp.py @@ -54,6 +54,7 @@ def render(data, indent): if isinstance(data, dict): for k in _PLAN_HEADER_KEYS: data.pop(k, None) # 4. plan header, present only at the root + # Strip the summary's DMS IDs, keeping deployment settings such as locks. bundle = data.get("bundle") if isinstance(bundle, dict): deployment = bundle.get("deployment") From 63bcdf116f2544e24ee1307dd1b3ec58e71d4094 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 14:50:17 +0000 Subject: [PATCH 20/27] Remove the DMS test-server regression test --- libs/testserver/bundledeployments_test.go | 43 ----------------------- 1 file changed, 43 deletions(-) delete mode 100644 libs/testserver/bundledeployments_test.go diff --git a/libs/testserver/bundledeployments_test.go b/libs/testserver/bundledeployments_test.go deleted file mode 100644 index 41444971767..00000000000 --- a/libs/testserver/bundledeployments_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package testserver_test - -import ( - "testing" - - "github.com/databricks/cli/libs/testserver" - "github.com/databricks/databricks-sdk-go" - "github.com/databricks/databricks-sdk-go/service/bundledeployments" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCreateVersionAcceptsFeatureResource(t *testing.T) { - server := testserver.New(t) - testserver.AddDefaultHandlers(server) - client, err := databricks.NewWorkspaceClient(&databricks.Config{Host: server.URL, Token: "testtoken"}) - require.NoError(t, err) - ctx := t.Context() - const statePath = "/Workspace/dms-feature-test" - require.NoError(t, client.Workspace.MkdirsByPath(ctx, statePath)) - deployment, err := client.BundleDeployments.CreateDeployment(ctx, bundledeployments.CreateDeploymentRequest{ - Deployment: bundledeployments.Deployment{InitialParentPath: statePath}, - }) - require.NoError(t, err) - - version, err := client.BundleDeployments.CreateVersion(ctx, bundledeployments.CreateVersionRequest{ - Parent: deployment.Name, - VersionId: "1", - Version: bundledeployments.Version{ - VersionType: bundledeployments.VersionTypeVersionTypeDeploy, - Operations: []bundledeployments.StagedOperation{{ - ResourceKey: "features.feature1", - ActionType: bundledeployments.OperationActionTypeOperationActionTypeBind, - }}, - }, - }) - require.NoError(t, err) - operations, err := client.BundleDeployments.ListOperationsAll(ctx, bundledeployments.ListOperationsRequest{Parent: version.Name}) - require.NoError(t, err) - require.Len(t, operations, 1) - assert.Equal(t, "features.feature1", operations[0].ResourceKey) - assert.Equal(t, bundledeployments.OperationActionTypeOperationActionTypeBind, operations[0].ActionType) -} From 896ea027a19398137a28bfbf804cfaa83eaef814 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 15:09:50 +0000 Subject: [PATCH 21/27] Number the summary stamp normalization comment --- acceptance/bin/nostamp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/bin/nostamp.py b/acceptance/bin/nostamp.py index fedaa280a88..8f370edc657 100755 --- a/acceptance/bin/nostamp.py +++ b/acceptance/bin/nostamp.py @@ -54,7 +54,7 @@ def render(data, indent): if isinstance(data, dict): for k in _PLAN_HEADER_KEYS: data.pop(k, None) # 4. plan header, present only at the root - # Strip the summary's DMS IDs, keeping deployment settings such as locks. + # 5. Strip the summary's DMS IDs, keeping deployment settings such as locks. bundle = data.get("bundle") if isinstance(bundle, dict): deployment = bundle.get("deployment") From 29956cc7fd4b3611e43db288817b9dd3f0c3eeb3 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 9 Nov 2026 17:49:00 +0000 Subject: [PATCH 22/27] Publish bind history directly through the DMS SDK --- .../dms/bind-history-failures/output.txt | 2 +- bundle/direct/bind.go | 26 +--- bundle/direct/dstate/state.go | 21 --- bundle/phases/bind.go | 143 +++++++++++++----- 4 files changed, 108 insertions(+), 84 deletions(-) diff --git a/acceptance/bundle/dms/bind-history-failures/output.txt b/acceptance/bundle/dms/bind-history-failures/output.txt index ec0c5636ff2..8a2cf5d20f8 100644 --- a/acceptance/bundle/dms/bind-history-failures/output.txt +++ b/acceptance/bundle/dms/bind-history-failures/output.txt @@ -11,7 +11,7 @@ Run 'bundle deploy' to deploy changes to your workspace >>> fault.py PATCH /api/2.0/bundle/** 400 0 1 >>> [CLI] bundle deployment bind record_failed [JOB_ID] --auto-approve -Warn: Failed to record deployment history: recording operation for resources.jobs.record_failed: Fault injected by test. +Warn: Failed to record bind history: Fault injected by test. Successfully bound job with an id '[JOB_ID]' Run 'bundle deploy' to deploy changes to your workspace diff --git a/bundle/direct/bind.go b/bundle/direct/bind.go index bc5bc9b5d2e..7fbf7ede662 100644 --- a/bundle/direct/bind.go +++ b/bundle/direct/bind.go @@ -10,7 +10,6 @@ import ( "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct/dresources" "github.com/databricks/cli/bundle/direct/dstate" - bundleenv "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/structs/structaccess" "github.com/databricks/cli/libs/structs/structpath" @@ -93,11 +92,15 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac } // Open temp state - err := b.openBindingState(ctx, tmpStatePath, configRoot, false) + err := b.StateDB.Open(ctx, tmpStatePath, dstate.ReadingFile, dstate.WithRecovery(false), dstate.OpenDmsArgs{}) if err != nil { os.Remove(tmpStatePath) return nil, err } + if err := b.StateDB.UpgradeToWrite(); err != nil { + os.Remove(tmpStatePath) + return nil, err + } // Save state with ID and empty state (like migrate does) err = b.StateDB.SaveState(ctx, resourceKey, resourceID, struct{}{}, nil) @@ -236,25 +239,6 @@ func (result *BindResult) Cancel() { } } -// openBindingState selects history mode before the binding write so recording needs no extra state commit. -func (b *DeploymentBundle) openBindingState(ctx context.Context, statePath string, configRoot *config.Root, withRecovery dstate.WithRecovery) error { - var configured *bool - if configRoot.Experimental != nil { - configured = configRoot.Experimental.DeploymentHistory - } - mode := dstate.ReadingFile - if bundleenv.RecordsDeploymentHistory(ctx, configured) { - mode = dstate.WritingDeploymentHistoryShadow - } - if err := b.StateDB.Open(ctx, statePath, mode, withRecovery, dstate.OpenDmsArgs{}); err != nil { - return err - } - if mode == dstate.ReadingFile { - return b.StateDB.UpgradeToWrite() - } - return nil -} - // 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 { diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index de224c0c468..384246913f4 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -261,27 +261,6 @@ func (db *DeploymentState) CompleteVersion(ctx context.Context, success bool) (b return success, nil } -// RecordResourceHistory publishes an operation's state (nil for removal) without writing local state. -func (db *DeploymentState) RecordResourceHistory(ctx context.Context, resourceKey string, entry *ResourceEntry) error { - db.AssertOpenedForWrite() - buf := db.getOperationBuffer() - if buf == nil { - return nil - } - var recorded json.RawMessage - var resourceID string - if entry != nil { - var err error - recorded, err = json.Marshal(RecordedState{State: entry.State, DependsOn: entry.DependsOn}) - if err != nil { - return err - } - resourceID = entry.ID - } - buf.RecordOperation(ctx, resourceKey, false, resourceID, recorded) - return nil -} - // RecordFailure records that a resource did not apply, so the history says why rather than // leaving the resource out. resourceID is the id it had before the failure. func (db *DeploymentState) RecordFailure(resourceKey, resourceID string, cause error) { diff --git a/bundle/phases/bind.go b/bundle/phases/bind.go index ee0a31ad17c..8d01d5fa1af 100644 --- a/bundle/phases/bind.go +++ b/bundle/phases/bind.go @@ -7,20 +7,24 @@ import ( "fmt" "maps" "slices" + "strconv" "strings" + "time" "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config/engine" "github.com/databricks/cli/bundle/deploy/lock" "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct/dstate" + bundleenv "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/bundle/statemgmt" + "github.com/databricks/cli/internal/build" "github.com/databricks/cli/libs/agent" - "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/dms" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" + "github.com/databricks/cli/libs/retry" "github.com/databricks/databricks-sdk-go/service/bundledeployments" ) @@ -33,10 +37,7 @@ type BindOptions struct { } // createBindingDeployment registers a first binding without changing metadata from an existing deploy. -func createBindingDeployment(ctx context.Context, b *bundle.Bundle, current *bundledeployments.Deployment) error { - if current != nil { - return nil - } +func createBindingDeployment(ctx context.Context, b *bundle.Bundle) (string, error) { // Bind does not upload files or stage an immutable snapshot, so it cannot report a deployed file path. created, err := b.WorkspaceClient(ctx).BundleDeployments.CreateDeployment(ctx, bundledeployments.CreateDeploymentRequest{ Deployment: bundledeployments.Deployment{ @@ -48,14 +49,13 @@ func createBindingDeployment(ctx context.Context, b *bundle.Bundle, current *bun }, }) if err != nil { - return fmt.Errorf("failed to create deployment: %w", err) + return "", fmt.Errorf("failed to create deployment: %w", err) } deploymentID, err := dms.DeploymentIDFromName(created.Name) if err != nil { - return fmt.Errorf("failed to create deployment: %w", err) + return "", fmt.Errorf("failed to create deployment: %w", err) } - b.DeploymentBundle.StateDB.DeploymentID = deploymentID - return nil + return deploymentID, nil } func Bind(ctx context.Context, b *bundle.Bundle, opts *BindOptions, stateDesc *statemgmt.StateDesc) { @@ -67,9 +67,10 @@ func Bind(ctx context.Context, b *bundle.Bundle, opts *BindOptions, stateDesc *s return } - historySucceeded := true + var historyVersion string + var historyErr error defer func() { - completeBindingHistory(ctx, b, historySucceeded && !logdiag.HasError(ctx)) + completeBindingHistory(ctx, b, historyVersion, historyErr == nil && !logdiag.HasError(ctx)) bundle.ApplyContext(ctx, b, lock.Release(lock.GoalBind)) }() @@ -135,21 +136,28 @@ func Bind(ctx context.Context, b *bundle.Bundle, opts *BindOptions, stateDesc *s return } - if err := recordBindHistory(ctx, b, resourceKey); err != nil { - historySucceeded = false - log.Warnf(ctx, "Failed to record bind history: %s", err) + historyVersion, historyErr = recordBindHistory(ctx, b, resourceKey) + if historyErr != nil { + log.Warnf(ctx, "Failed to record bind history: %s", historyErr) } statemgmt.PushResourcesState(ctx, b) } // completeBindingHistory completes the recorded version after the remote state upload. -func completeBindingHistory(ctx context.Context, b *bundle.Bundle, success bool) { - if !b.DeploymentBundle.StateDB.RecordsHistory() { +func completeBindingHistory(ctx context.Context, b *bundle.Bundle, versionName string, success bool) { + if versionName == "" { return } - completionCtx := cmdctx.SetWorkspaceClient(ctx, b.WorkspaceClient(ctx)) - if _, err := b.DeploymentBundle.StateDB.CompleteVersion(completionCtx, success); err != nil { + reason := bundledeployments.VersionCompleteVersionCompleteFailure + if success { + reason = bundledeployments.VersionCompleteVersionCompleteSuccess + } + _, err := b.WorkspaceClient(ctx).BundleDeployments.CompleteVersion(ctx, bundledeployments.CompleteVersionRequest{ + Name: versionName, + CompletionReason: reason, + }) + if err != nil { log.Warnf(ctx, "Failed to complete deployment history version: %s", err) } } @@ -193,50 +201,103 @@ func Unbind(ctx context.Context, b *bundle.Bundle, group, resourceKey string, en statemgmt.PushResourcesState(ctx, b) } -// recordBindHistory records a committed binding's saved state when history recording is enabled. -func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string) (err error) { +// recordBindHistory publishes a committed binding to DMS without changing local state. +func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string) (versionName string, err error) { db := &b.DeploymentBundle.StateDB _, statePath := b.StateFilenameDirect(ctx) if err := db.Open(ctx, statePath, dstate.ReadingFile, dstate.WithRecovery(true), dstate.OpenDmsArgs{}); err != nil { - return err + return "", err } defer func() { _, finalizeErr := db.Finalize(ctx) err = errors.Join(err, finalizeErr) }() - if !db.RecordsHistory() { - return nil + var configured *bool + if b.Config.Experimental != nil { + configured = b.Config.Experimental.DeploymentHistory + } + if !db.RecordsHistory() && !bundleenv.RecordsDeploymentHistory(ctx, configured) { + return "", nil } deploymentID, current, err := dms.LookupDeployment(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) if err != nil { - return err + return "", err } - db.DeploymentID = deploymentID - db.VersionID, err = dms.ParseLastVersionID(current) + previous, err := dms.ParseLastVersionID(current) if err != nil { - return err + return "", err } - - if err := db.UpgradeToWrite(); err != nil { - return err + if current == nil { + deploymentID, err = createBindingDeployment(ctx, b) + if err != nil { + return "", err + } } - if !cmdctx.HasWorkspaceClient(ctx) { - ctx = cmdctx.SetWorkspaceClient(ctx, b.WorkspaceClient(ctx)) + versionID := max(previous, db.Data.Serial) + 1 + previousVersionID := "" + if previous > 0 { + previousVersionID = strconv.Itoa(previous) } - if err := createBindingDeployment(ctx, b, current); err != nil { - return err + var gitInfo *bundledeployments.GitInfo + if git := b.Config.Bundle.Git; git.Branch != "" || git.Commit != "" || git.OriginURL != "" { + gitInfo = &bundledeployments.GitInfo{Branch: git.Branch, Commit: git.Commit, OriginUrl: git.OriginURL} } - if err := startVersion(ctx, b, dms.VersionTypeDeploy, []bundledeployments.StagedOperation{{ - ResourceKey: strings.TrimPrefix(resourceKey, dms.StatePrefix), - ActionType: bundledeployments.OperationActionTypeOperationActionTypeBind, - }}); err != nil { - return err + version, err := b.WorkspaceClient(ctx).BundleDeployments.CreateVersion(ctx, bundledeployments.CreateVersionRequest{ + Parent: dms.DeploymentName(deploymentID), + VersionId: strconv.Itoa(versionID), + Version: bundledeployments.Version{ + CliVersion: build.GetInfo().Version, + VersionType: dms.VersionTypeDeploy, + PreviousVersionId: previousVersionID, + GitInfo: gitInfo, + Operations: []bundledeployments.StagedOperation{{ + ResourceKey: strings.TrimPrefix(resourceKey, dms.StatePrefix), + ActionType: bundledeployments.OperationActionTypeOperationActionTypeBind, + }}, + }, + }) + if err != nil { + return "", fmt.Errorf("failed to create deployment version: %w", err) } + versionName = version.Name entry, ok := db.GetResourceEntry(resourceKey) if !ok { - return fmt.Errorf("bound resource %s is missing from deployment state", resourceKey) + return versionName, fmt.Errorf("bound resource %s is missing from deployment state", resourceKey) } - return db.RecordResourceHistory(ctx, resourceKey, &entry) + return versionName, recordBindingOperation(ctx, b, versionName, resourceKey, &entry) +} + +// recordBindingOperation sends an operation's saved state, or its removal, directly to DMS. +func recordBindingOperation(ctx context.Context, b *bundle.Bundle, versionName, resourceKey string, entry *dstate.ResourceEntry) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var state json.RawMessage + var resourceID string + if entry != nil { + var err error + state, err = json.Marshal(dstate.RecordedState{State: entry.State, DependsOn: entry.DependsOn}) + if err != nil { + return err + } + resourceID = entry.ID + } + update, err := dms.NewStateUpdate(resourceID, state, false) + if err != nil { + return err + } + _, err = retry.With(ctx, retry.IsTransient, func() (*bundledeployments.Operation, error) { + return b.WorkspaceClient(ctx).BundleDeployments.UpdateOperation(ctx, bundledeployments.UpdateOperationRequest{ + Name: versionName + "/operations/" + strings.TrimPrefix(resourceKey, dms.StatePrefix), + Operation: bundledeployments.Operation{ + State: string(update.State), + ResourceId: update.ResourceID, + Status: update.Status, + ForceSendFields: []string{"SequenceId", "ErrorMessage"}, + }, + UpdateMask: update.Fields.Mask(), + }) + }) + return err } From 38d9b7d750f2ba6f0e3b93d9bb7ac12bbf702d70 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 10 Nov 2026 09:06:46 +0000 Subject: [PATCH 23/27] Remove redundant version parsing unit coverage --- libs/dms/metadata_test.go | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/libs/dms/metadata_test.go b/libs/dms/metadata_test.go index 799a910b273..5122533dafc 100644 --- a/libs/dms/metadata_test.go +++ b/libs/dms/metadata_test.go @@ -3,7 +3,6 @@ package dms import ( "testing" - "github.com/databricks/databricks-sdk-go/service/bundledeployments" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -42,23 +41,3 @@ func TestDeploymentIDFromName(t *testing.T) { _, err = DeploymentIDFromName("deployments/") assert.Error(t, err) } - -func TestParseLastVersionID(t *testing.T) { - for _, tc := range []struct { - name string - deployment *bundledeployments.Deployment - want int - }{ - {"no deployment", nil, 0}, - {"no version", &bundledeployments.Deployment{}, 0}, - {"existing version", &bundledeployments.Deployment{LastVersionId: "4"}, 4}, - } { - t.Run(tc.name, func(t *testing.T) { - got, err := ParseLastVersionID(tc.deployment) - require.NoError(t, err) - assert.Equal(t, tc.want, got) - }) - } - _, err := ParseLastVersionID(&bundledeployments.Deployment{LastVersionId: "invalid"}) - assert.ErrorContains(t, err, "last_version_id") -} From 5dd7adb72eaa1ea7663025bf0b10bbdc9875556f Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 10 Nov 2026 09:35:22 +0000 Subject: [PATCH 24/27] Pass binding state through the bind result for history --- bundle/direct/bind.go | 15 +++++++++++++++ bundle/phases/bind.go | 40 ++++++++++++++++++---------------------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/bundle/direct/bind.go b/bundle/direct/bind.go index 7fbf7ede662..ebe2dabd218 100644 --- a/bundle/direct/bind.go +++ b/bundle/direct/bind.go @@ -35,8 +35,16 @@ func (e ErrResourceAlreadyBound) Error() string { e.ResourceKey, e.ExistingID) } +// BindingState carries the resulting bindings and file metadata; nil entries represent removals. +type BindingState struct { + Serial int + RecordsHistory bool + Resources map[string]*dstate.ResourceEntry +} + // BindResult contains the result of a bind operation including any detected changes. type BindResult struct { + State BindingState // HasChanges is true if deploying after bind would make changes to the resource HasChanges bool // Action is the planned action for the bound resource (e.g., "skip", "update", "recreate") @@ -197,6 +205,12 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac os.Remove(tmpStatePath) return nil, err } + resourceState, _ := b.StateDB.GetResourceEntry(resourceKey) + state := BindingState{ + Serial: b.StateDB.Data.Serial, + RecordsHistory: b.StateDB.RecordsHistory(), + Resources: map[string]*dstate.ResourceEntry{resourceKey: &resourceState}, + } plan, err = b.CalculatePlan(ctx, client, configRoot) if _, ferr := b.StateDB.Finalize(ctx); ferr != nil { log.Warnf(ctx, "failed to finalize state: %v", ferr) @@ -208,6 +222,7 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac // Check if the bound resource has changes result := &BindResult{ + State: state, HasChanges: false, Action: deployplan.Skip, Plan: plan, diff --git a/bundle/phases/bind.go b/bundle/phases/bind.go index 8d01d5fa1af..0129299ed26 100644 --- a/bundle/phases/bind.go +++ b/bundle/phases/bind.go @@ -15,6 +15,7 @@ import ( "github.com/databricks/cli/bundle/config/engine" "github.com/databricks/cli/bundle/deploy/lock" "github.com/databricks/cli/bundle/deployplan" + "github.com/databricks/cli/bundle/direct" "github.com/databricks/cli/bundle/direct/dstate" bundleenv "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/bundle/statemgmt" @@ -136,7 +137,7 @@ func Bind(ctx context.Context, b *bundle.Bundle, opts *BindOptions, stateDesc *s return } - historyVersion, historyErr = recordBindHistory(ctx, b, resourceKey) + historyVersion, historyErr = recordBindHistory(ctx, b, result.State) if historyErr != nil { log.Warnf(ctx, "Failed to record bind history: %s", historyErr) } @@ -202,22 +203,12 @@ func Unbind(ctx context.Context, b *bundle.Bundle, group, resourceKey string, en } // recordBindHistory publishes a committed binding to DMS without changing local state. -func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string) (versionName string, err error) { - db := &b.DeploymentBundle.StateDB - _, statePath := b.StateFilenameDirect(ctx) - if err := db.Open(ctx, statePath, dstate.ReadingFile, dstate.WithRecovery(true), dstate.OpenDmsArgs{}); err != nil { - return "", err - } - defer func() { - _, finalizeErr := db.Finalize(ctx) - err = errors.Join(err, finalizeErr) - }() - +func recordBindHistory(ctx context.Context, b *bundle.Bundle, state direct.BindingState) (versionName string, err error) { var configured *bool if b.Config.Experimental != nil { configured = b.Config.Experimental.DeploymentHistory } - if !db.RecordsHistory() && !bundleenv.RecordsDeploymentHistory(ctx, configured) { + if !state.RecordsHistory && !bundleenv.RecordsDeploymentHistory(ctx, configured) { return "", nil } @@ -235,7 +226,7 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string return "", err } } - versionID := max(previous, db.Data.Serial) + 1 + versionID := max(previous, state.Serial) + 1 previousVersionID := "" if previous > 0 { previousVersionID = strconv.Itoa(previous) @@ -244,6 +235,14 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string if git := b.Config.Bundle.Git; git.Branch != "" || git.Commit != "" || git.OriginURL != "" { gitInfo = &bundledeployments.GitInfo{Branch: git.Branch, Commit: git.Commit, OriginUrl: git.OriginURL} } + keys := slices.Sorted(maps.Keys(state.Resources)) + staged := make([]bundledeployments.StagedOperation, 0, len(keys)) + for _, key := range keys { + staged = append(staged, bundledeployments.StagedOperation{ + ResourceKey: strings.TrimPrefix(key, dms.StatePrefix), + ActionType: bundledeployments.OperationActionTypeOperationActionTypeBind, + }) + } version, err := b.WorkspaceClient(ctx).BundleDeployments.CreateVersion(ctx, bundledeployments.CreateVersionRequest{ Parent: dms.DeploymentName(deploymentID), VersionId: strconv.Itoa(versionID), @@ -252,21 +251,18 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string VersionType: dms.VersionTypeDeploy, PreviousVersionId: previousVersionID, GitInfo: gitInfo, - Operations: []bundledeployments.StagedOperation{{ - ResourceKey: strings.TrimPrefix(resourceKey, dms.StatePrefix), - ActionType: bundledeployments.OperationActionTypeOperationActionTypeBind, - }}, + Operations: staged, }, }) if err != nil { return "", fmt.Errorf("failed to create deployment version: %w", err) } versionName = version.Name - entry, ok := db.GetResourceEntry(resourceKey) - if !ok { - return versionName, fmt.Errorf("bound resource %s is missing from deployment state", resourceKey) + var recordErr error + for _, key := range keys { + recordErr = errors.Join(recordErr, recordBindingOperation(ctx, b, versionName, key, state.Resources[key])) } - return versionName, recordBindingOperation(ctx, b, versionName, resourceKey, &entry) + return versionName, recordErr } // recordBindingOperation sends an operation's saved state, or its removal, directly to DMS. From 944ccdb5e31e5e54466cc6c481067df26a199909 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 10 Nov 2026 10:54:07 +0000 Subject: [PATCH 25/27] Make bind history metadata optional and DMS-specific --- bundle/direct/bind.go | 32 ++++++++++++++++++++------------ bundle/phases/bind.go | 42 ++++++++++++++++++------------------------ 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/bundle/direct/bind.go b/bundle/direct/bind.go index ebe2dabd218..2e8350eaf0f 100644 --- a/bundle/direct/bind.go +++ b/bundle/direct/bind.go @@ -10,6 +10,7 @@ import ( "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct/dresources" "github.com/databricks/cli/bundle/direct/dstate" + bundleenv "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/structs/structaccess" "github.com/databricks/cli/libs/structs/structpath" @@ -35,16 +36,16 @@ func (e ErrResourceAlreadyBound) Error() string { e.ResourceKey, e.ExistingID) } -// BindingState carries the resulting bindings and file metadata; nil entries represent removals. -type BindingState struct { - Serial int - RecordsHistory bool - Resources map[string]*dstate.ResourceEntry +// DMSBindMetadata holds the bound resource state and serial to publish after bind is committed. +type DMSBindMetadata struct { + Serial int + ResourceState dstate.ResourceEntry } // BindResult contains the result of a bind operation including any detected changes. type BindResult struct { - State BindingState + // DMSMetadata is nil when deployment history is disabled. + DMSMetadata *DMSBindMetadata // HasChanges is true if deploying after bind would make changes to the resource HasChanges bool // Action is the planned action for the bound resource (e.g., "skip", "update", "recreate") @@ -205,11 +206,10 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac os.Remove(tmpStatePath) return nil, err } - resourceState, _ := b.StateDB.GetResourceEntry(resourceKey) - state := BindingState{ - Serial: b.StateDB.Data.Serial, - RecordsHistory: b.StateDB.RecordsHistory(), - Resources: map[string]*dstate.ResourceEntry{resourceKey: &resourceState}, + var dmsMetadata *DMSBindMetadata + if b.dmsMetadataEnabled(ctx, configRoot) { + resourceState, _ := b.StateDB.GetResourceEntry(resourceKey) + dmsMetadata = &DMSBindMetadata{Serial: b.StateDB.Data.Serial, ResourceState: resourceState} } plan, err = b.CalculatePlan(ctx, client, configRoot) if _, ferr := b.StateDB.Finalize(ctx); ferr != nil { @@ -222,7 +222,7 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac // Check if the bound resource has changes result := &BindResult{ - State: state, + DMSMetadata: dmsMetadata, HasChanges: false, Action: deployplan.Skip, Plan: plan, @@ -254,6 +254,14 @@ func (result *BindResult) Cancel() { } } +func (b *DeploymentBundle) dmsMetadataEnabled(ctx context.Context, configRoot *config.Root) bool { + var configured *bool + if configRoot.Experimental != nil { + configured = configRoot.Experimental.DeploymentHistory + } + return b.StateDB.RecordsHistory() || bundleenv.RecordsDeploymentHistory(ctx, configured) +} + // 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 { diff --git a/bundle/phases/bind.go b/bundle/phases/bind.go index 0129299ed26..2e935e06be2 100644 --- a/bundle/phases/bind.go +++ b/bundle/phases/bind.go @@ -17,7 +17,6 @@ import ( "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct" "github.com/databricks/cli/bundle/direct/dstate" - bundleenv "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/internal/build" "github.com/databricks/cli/libs/agent" @@ -137,7 +136,7 @@ func Bind(ctx context.Context, b *bundle.Bundle, opts *BindOptions, stateDesc *s return } - historyVersion, historyErr = recordBindHistory(ctx, b, result.State) + historyVersion, historyErr = recordBindHistory(ctx, b, resourceKey, result.DMSMetadata) if historyErr != nil { log.Warnf(ctx, "Failed to record bind history: %s", historyErr) } @@ -202,16 +201,23 @@ func Unbind(ctx context.Context, b *bundle.Bundle, group, resourceKey string, en statemgmt.PushResourcesState(ctx, b) } -// recordBindHistory publishes a committed binding to DMS without changing local state. -func recordBindHistory(ctx context.Context, b *bundle.Bundle, state direct.BindingState) (versionName string, err error) { - var configured *bool - if b.Config.Experimental != nil { - configured = b.Config.Experimental.DeploymentHistory - } - if !state.RecordsHistory && !bundleenv.RecordsDeploymentHistory(ctx, configured) { +// recordBindHistory publishes a committed binding when DMS metadata was requested. +func recordBindHistory(ctx context.Context, b *bundle.Bundle, resourceKey string, metadata *direct.DMSBindMetadata) (string, error) { + if metadata == nil { return "", nil } + versionName, err := createBindingVersion(ctx, b, metadata.Serial, []bundledeployments.StagedOperation{{ + ResourceKey: strings.TrimPrefix(resourceKey, dms.StatePrefix), + ActionType: bundledeployments.OperationActionTypeOperationActionTypeBind, + }}) + if err != nil { + return "", err + } + return versionName, recordBindingOperation(ctx, b, versionName, resourceKey, &metadata.ResourceState) +} +// 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) if err != nil { return "", err @@ -226,7 +232,7 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, state direct.Bindi return "", err } } - versionID := max(previous, state.Serial) + 1 + versionID := max(previous, serial) + 1 previousVersionID := "" if previous > 0 { previousVersionID = strconv.Itoa(previous) @@ -235,14 +241,6 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, state direct.Bindi if git := b.Config.Bundle.Git; git.Branch != "" || git.Commit != "" || git.OriginURL != "" { gitInfo = &bundledeployments.GitInfo{Branch: git.Branch, Commit: git.Commit, OriginUrl: git.OriginURL} } - keys := slices.Sorted(maps.Keys(state.Resources)) - staged := make([]bundledeployments.StagedOperation, 0, len(keys)) - for _, key := range keys { - staged = append(staged, bundledeployments.StagedOperation{ - ResourceKey: strings.TrimPrefix(key, dms.StatePrefix), - ActionType: bundledeployments.OperationActionTypeOperationActionTypeBind, - }) - } version, err := b.WorkspaceClient(ctx).BundleDeployments.CreateVersion(ctx, bundledeployments.CreateVersionRequest{ Parent: dms.DeploymentName(deploymentID), VersionId: strconv.Itoa(versionID), @@ -257,12 +255,7 @@ func recordBindHistory(ctx context.Context, b *bundle.Bundle, state direct.Bindi if err != nil { return "", fmt.Errorf("failed to create deployment version: %w", err) } - versionName = version.Name - var recordErr error - for _, key := range keys { - recordErr = errors.Join(recordErr, recordBindingOperation(ctx, b, versionName, key, state.Resources[key])) - } - return versionName, recordErr + return version.Name, nil } // recordBindingOperation sends an operation's saved state, or its removal, directly to DMS. @@ -283,6 +276,7 @@ func recordBindingOperation(ctx context.Context, b *bundle.Bundle, versionName, if err != nil { return err } + // Newly staged operations start at sequence 0; DMS requires it even though Go omits zero values. _, err = retry.With(ctx, retry.IsTransient, func() (*bundledeployments.Operation, error) { return b.WorkspaceClient(ctx).BundleDeployments.UpdateOperation(ctx, bundledeployments.UpdateOperationRequest{ Name: versionName + "/operations/" + strings.TrimPrefix(resourceKey, dms.StatePrefix), From 97065f5b9d0f910c91f9df5d647496474413f4f1 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 10 Nov 2026 11:09:40 +0000 Subject: [PATCH 26/27] Inline bind history metadata selection --- bundle/direct/bind.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/bundle/direct/bind.go b/bundle/direct/bind.go index 2e8350eaf0f..558d02e1ece 100644 --- a/bundle/direct/bind.go +++ b/bundle/direct/bind.go @@ -206,8 +206,12 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac os.Remove(tmpStatePath) return nil, err } + var configured *bool + if configRoot.Experimental != nil { + configured = configRoot.Experimental.DeploymentHistory + } var dmsMetadata *DMSBindMetadata - if b.dmsMetadataEnabled(ctx, configRoot) { + if b.StateDB.RecordsHistory() || bundleenv.RecordsDeploymentHistory(ctx, configured) { resourceState, _ := b.StateDB.GetResourceEntry(resourceKey) dmsMetadata = &DMSBindMetadata{Serial: b.StateDB.Data.Serial, ResourceState: resourceState} } @@ -254,14 +258,6 @@ func (result *BindResult) Cancel() { } } -func (b *DeploymentBundle) dmsMetadataEnabled(ctx context.Context, configRoot *config.Root) bool { - var configured *bool - if configRoot.Experimental != nil { - configured = configRoot.Experimental.DeploymentHistory - } - return b.StateDB.RecordsHistory() || bundleenv.RecordsDeploymentHistory(ctx, configured) -} - // 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 { From 445c82f5924b6cad2065c19f952030963fb81fb4 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 10 Nov 2026 12:09:45 +0000 Subject: [PATCH 27/27] Fix moved parser test and stabilize cloud bind preview --- acceptance/bundle/dms/bind-unbind/output.txt | 3 --- acceptance/bundle/dms/bind-unbind/script | 3 ++- cmd/bundle/utils/process_test.go | 3 ++- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/acceptance/bundle/dms/bind-unbind/output.txt b/acceptance/bundle/dms/bind-unbind/output.txt index 38d79d119fb..922b2985955 100644 --- a/acceptance/bundle/dms/bind-unbind/output.txt +++ b/acceptance/bundle/dms/bind-unbind/output.txt @@ -6,10 +6,7 @@ Plan: update resources.jobs.foo Changes detected: ~ deployment: null -> {"kind":"BUNDLE","metadata_file_path":"/Workspace/Users/[USERNAME]/.bundle/dms-bind-unbind-[UNIQUE_NAME]/default/state/metadata.json"} ~ edit_mode: null -> "UI_LOCKED" - ~ format: null -> "MULTI_TASK" - ~ max_concurrent_runs: null -> 1 ~ name: "bind-target-[UNIQUE_NAME]" -> "foo" - ~ queue: null -> {"enabled":true} Error: this bind operation requires user confirmation, but the current console does not support prompting. To proceed, use --auto-approve after reviewing the plan above. diff --git a/acceptance/bundle/dms/bind-unbind/script b/acceptance/bundle/dms/bind-unbind/script index 36c97bf8c3d..8c7d75c3d43 100644 --- a/acceptance/bundle/dms/bind-unbind/script +++ b/acceptance/bundle/dms/bind-unbind/script @@ -1,7 +1,8 @@ envsubst < databricks.yml.tmpl > databricks.yml title "Bind an existing job with deployment history enabled" -job_id=$($CLI jobs create --json '{"name": "bind-target-'$UNIQUE_NAME'"}' | jq -r '.job_id') +# Match Jobs API defaults so the bind preview is identical locally and on cloud. +job_id=$($CLI jobs create --json '{"name": "bind-target-'$UNIQUE_NAME'", "format": "MULTI_TASK", "max_concurrent_runs": 1, "queue": {"enabled": true}}' | jq -r '.job_id') add_repl "$job_id" JOB_ID cleanup_bind_target() { errcode $CLI bundle destroy --auto-approve >> LOG.cleanup 2>&1 diff --git a/cmd/bundle/utils/process_test.go b/cmd/bundle/utils/process_test.go index 21910039344..b05fb9b0a84 100644 --- a/cmd/bundle/utils/process_test.go +++ b/cmd/bundle/utils/process_test.go @@ -3,6 +3,7 @@ package utils import ( "testing" + "github.com/databricks/cli/libs/dms" "github.com/databricks/databricks-sdk-go/service/bundledeployments" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -61,7 +62,7 @@ func TestParseLastVersionID(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := parseLastVersionID(tt.deployment) + got, err := dms.ParseLastVersionID(tt.deployment) if tt.wantErr { require.Error(t, err) return