From a647df569e96fdba123123cf87d4a250e9bb5cee Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Fri, 23 Oct 2026 14:45:47 +0000 Subject: [PATCH 1/2] Use enforced file-backed history for the YAML opt-in --- acceptance/bin/print_state.py | 39 +++- .../databricks.yml.tmpl | 3 - .../bundle/dms/declined-deploy/output.txt | 30 +-- acceptance/bundle/dms/depends-on/output.txt | 55 ++++- .../dms/deployment-metadata-change/output.txt | 4 +- acceptance/bundle/dms/empty-grants/output.txt | 14 +- .../bundle/dms/failed-delete/output.txt | 56 ++++- acceptance/bundle/dms/failed-delete/script | 4 +- .../bundle/dms/failed-recreate/output.txt | 17 +- .../dms/failed-update-permissions/output.txt | 94 ++++++++- .../bundle/dms/multiple-resources/output.txt | 194 +++++++++++++++++- acceptance/bundle/dms/no-drift/output.txt | 101 ++++++++- acceptance/bundle/dms/no-drift/script | 1 + acceptance/bundle/dms/no-resources/output.txt | 24 +-- .../operation-upload-fails-once/output.txt | 39 ++-- .../dms/operation-upload-fails-once/script | 2 +- .../bundle/dms/plan-without-dms/output.txt | 2 +- acceptance/bundle/dms/plan-without-dms/script | 6 +- acceptance/bundle/dms/provenance/output.txt | 28 ++- .../bundle/dms/record-failure/output.txt | 4 +- .../dms/stale-local-state/databricks.yml.tmpl | 3 - .../bundle/dms/stale-local-state/script | 9 +- acceptance/bundle/dms/stale-plan/output.txt | 30 ++- .../bundle/dms/state-features/output.txt | 30 ++- acceptance/bundle/dms/state-features/script | 7 +- .../bundle/dms/successful-recreate/output.txt | 30 ++- .../dms/version-never-created/output.txt | 45 ++-- .../bundle/dms/version-never-created/script | 9 +- bundle/config/experimental.go | 5 +- bundle/direct/apply.go | 4 +- bundle/direct/dstate/state.go | 14 +- bundle/direct/dstate/state_test.go | 2 +- bundle/phases/deploy.go | 32 +-- bundle/phases/destroy.go | 39 ++-- bundle/phases/dms.go | 72 ++++++- bundle/phases/telemetry.go | 2 +- bundle/statemgmt/state_pull.go | 6 + cmd/bundle/utils/process.go | 68 +++++- cmd/bundle/utils/process_test.go | 17 +- 39 files changed, 894 insertions(+), 247 deletions(-) diff --git a/acceptance/bin/print_state.py b/acceptance/bin/print_state.py index 6ad674342c3..c41c070212c 100755 --- a/acceptance/bin/print_state.py +++ b/acceptance/bin/print_state.py @@ -13,6 +13,7 @@ import os import posixpath import subprocess +from pathlib import Path from nostamp import scrub @@ -123,9 +124,23 @@ def get_deployment_id(target): def get_resources(target): """Map every recorded resource key ("jobs.foo") to its {"id", "state"}. - Empty when the bundle has no deployment recorded yet. Cached because a lookup costs three - round trips and a script asks for one resource at a time. + File-history state is read locally. Legacy DMS state is read from the service, + or is empty when no deployment has been recorded. Cached for repeated resource lookups. """ + filename = get_state_file(target, False) + if filename.endswith("resources.json") and os.path.exists(filename): + data = json.loads(Path(filename).read_text()) + if "deployment_history_best_effort" in (data.get("features") or {}): + # DMS can miss history updates. The file remains authoritative in this mode. + return { + key.removeprefix("resources."): { + "id": entry.get("__id__"), + "state": entry.get("state") or {}, + "depends_on": entry.get("depends_on") or [], + } + for key, entry in data.get("state", {}).items() + } + deployment_id = get_deployment_id(target) if not deployment_id: return {} @@ -174,10 +189,9 @@ def get_last_version_id(target): def print_recorded_state(filename, target): - """Print the state file with its resources filled in from the deployment metadata service. + """Normalize history state for comparisons with ordinary file-backed deployments. - While recording, the file itself carries only the header - the service holds the resources - so - printing it raw would show an empty state and differ from the same test's non-recording run. + get_resources uses the file for best-effort history and the service for legacy DMS state. """ data = json.loads(open(filename).read()) # Recording stamps each resource payload with the deployment and version; drop it here so the @@ -186,11 +200,18 @@ def print_recorded_state(filename, target): # The file's serial is a copy of the DMS version to ensure outdated resource state is not read. # Rebuild in header order with the authoritative serial from the deployment. - last_version_id = get_last_version_id(target) - serial = int(last_version_id) if last_version_id else 0 + if "deployment_history_best_effort" in (data.get("features") or {}): + serial = data.get("serial", 0) + else: + last_version_id = get_last_version_id(target) + serial = int(last_version_id) if last_version_id else 0 # Recording itself is not what these tests assert, so drop the feature that marks it. - features = {k: v for k, v in (data.get("features") or {}).items() if k != "deployment_history"} + features = { + k: v + for k, v in (data.get("features") or {}).items() + if k not in {"deployment_history", "deployment_history_best_effort"} + } rebuilt = {} for key in ("state_version", "cli_version", "lineage"): @@ -213,7 +234,7 @@ def main(): "--no-dms", action="store_true", help="Print resources.json as-is without fetching state from DMS. " - "Use in bundle/dms tests to assert the tombstone content of the file.", + "Use in bundle/dms tests to assert the file state and its feature markers.", ) args = parser.parse_args() diff --git a/acceptance/bundle/dms/bind-unbind-unsupported/databricks.yml.tmpl b/acceptance/bundle/dms/bind-unbind-unsupported/databricks.yml.tmpl index e60f4605610..92a70710626 100644 --- a/acceptance/bundle/dms/bind-unbind-unsupported/databricks.yml.tmpl +++ b/acceptance/bundle/dms/bind-unbind-unsupported/databricks.yml.tmpl @@ -1,8 +1,5 @@ bundle: name: dms-bind-unbind-$UNIQUE_NAME -experimental: - deployment_history: true - resources: jobs: foo: diff --git a/acceptance/bundle/dms/declined-deploy/output.txt b/acceptance/bundle/dms/declined-deploy/output.txt index 3ef52219df5..b3e2931c756 100644 --- a/acceptance/bundle/dms/declined-deploy/output.txt +++ b/acceptance/bundle/dms/declined-deploy/output.txt @@ -67,7 +67,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -79,18 +79,10 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]" } -{ - "method": "GET", - "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources" -} { "method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]" } -{ - "method": "GET", - "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources" -} >>> print_state.py --no-dms { @@ -99,9 +91,17 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.schemas.foo": { + "__id__": "main.dms_declined_deploy_schema_[UNIQUE_NAME]", + "state": { + "catalog_name": "main", + "name": "dms_declined_deploy_schema_[UNIQUE_NAME]" + } + } + } } === A destructive change without --auto-approve is declined: this console cannot prompt @@ -124,10 +124,6 @@ Files: 2 uploaded, 0 deleted "method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]" } -{ - "method": "GET", - "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources" -} === The recorded resources are unchanged by the decline: the schema is still at version 1 >>> MSYS_NO_PATHCONV=1 [CLI] bundle-deployments list-resources deployments/[DEPLOYMENT_ID] -o json @@ -163,10 +159,6 @@ false "method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]" } -{ - "method": "GET", - "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources" -} { "method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", diff --git a/acceptance/bundle/dms/depends-on/output.txt b/acceptance/bundle/dms/depends-on/output.txt index c1b9beb1dec..6f7e1e62208 100644 --- a/acceptance/bundle/dms/depends-on/output.txt +++ b/acceptance/bundle/dms/depends-on/output.txt @@ -3,10 +3,7 @@ >>> [CLI] bundle plan -o json { "plan_version": [PLAN_VERSION], - "cli_version": "[CLI_VERSION]", - "features": { - "deployment_history": {} - } + "cli_version": "[CLI_VERSION]" } >>> [CLI] bundle plan @@ -61,7 +58,7 @@ Resources: 2 created, 0 changed, 0 deleted, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -91,9 +88,53 @@ Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.jobs.child": { + "__id__": "[CHILD_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-depends-on-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "description": "depends on [PARENT_ID]", + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "child", + "queue": { + "enabled": true + } + }, + "depends_on": [ + { + "node": "resources.jobs.parent", + "label": "${resources.jobs.parent.id}" + } + ] + }, + "resources.jobs.parent": { + "__id__": "[PARENT_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-depends-on-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "parent", + "queue": { + "enabled": true + } + } + } + } } >>> [CLI] bundle destroy --auto-approve diff --git a/acceptance/bundle/dms/deployment-metadata-change/output.txt b/acceptance/bundle/dms/deployment-metadata-change/output.txt index 675d23ca70d..1f14fe5797e 100644 --- a/acceptance/bundle/dms/deployment-metadata-change/output.txt +++ b/acceptance/bundle/dms/deployment-metadata-change/output.txt @@ -67,7 +67,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -128,7 +128,7 @@ A common practice is to use a username or principal name in this path, i.e. use "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } diff --git a/acceptance/bundle/dms/empty-grants/output.txt b/acceptance/bundle/dms/empty-grants/output.txt index 0a6cbabcbce..ed3836a9c80 100644 --- a/acceptance/bundle/dms/empty-grants/output.txt +++ b/acceptance/bundle/dms/empty-grants/output.txt @@ -138,7 +138,7 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "lineage": "[UUID]", "serial": 2, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -163,9 +163,17 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "lineage": "[UUID]", "serial": 2, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.schemas.foo": { + "__id__": "main.dms_empty_grants_[UNIQUE_NAME]", + "state": { + "catalog_name": "main", + "name": "dms_empty_grants_[UNIQUE_NAME]" + } + } + } } >>> [CLI] bundle destroy --auto-approve diff --git a/acceptance/bundle/dms/failed-delete/output.txt b/acceptance/bundle/dms/failed-delete/output.txt index e3241b3d66a..9cd4b49e3dc 100644 --- a/acceptance/bundle/dms/failed-delete/output.txt +++ b/acceptance/bundle/dms/failed-delete/output.txt @@ -67,7 +67,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -92,9 +92,28 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.jobs.stuck": { + "__id__": "[STUCK_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-failed-delete-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "stuck", + "queue": { + "enabled": true + } + } + } + } } === A resource that fails to delete is recorded as a failed operation carrying the error, so the history says why rather than showing the resource gone @@ -176,22 +195,41 @@ API message: Fault injected by test. } ] -=== The local state file records failed version 2 +=== The local state file keeps the resource and its serial after the failed delete >>> print_state.py --no-dms { "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", - "serial": 2, + "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.jobs.stuck": { + "__id__": "[STUCK_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-failed-delete-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "stuck", + "queue": { + "enabled": true + } + } + } + } } === The remote checkpoint stays at serial 1 while DMS advances to version 2 >>> MSYS_NO_PATHCONV=1 [CLI] workspace export /Workspace/Users/[USERNAME]/.bundle/dms-failed-delete-[UNIQUE_NAME]/default/state/resources.json --format RAW {"serial":1} ->>> print_state.py -{"serial":2} +>>> MSYS_NO_PATHCONV=1 [CLI] bundle-deployments get-deployment deployments/[DEPLOYMENT_ID] -o json +{"last_version_id":"2"} diff --git a/acceptance/bundle/dms/failed-delete/script b/acceptance/bundle/dms/failed-delete/script index 2b733207f0a..5552fd18bad 100644 --- a/acceptance/bundle/dms/failed-delete/script +++ b/acceptance/bundle/dms/failed-delete/script @@ -29,12 +29,12 @@ trace MSYS_NO_PATHCONV=1 $CLI bundle-deployments list-resources "deployments/${d title "The failed operation records the error message" trace MSYS_NO_PATHCONV=1 $CLI bundle-deployments list-operations "deployments/${deployment_id}/versions/2" -o json | jq -S 'map({resource_key, action_type, status, error_message})' -title "The local state file records failed version 2" +title "The local state file keeps the resource and its serial after the failed delete" trace print_state.py --no-dms title "The remote checkpoint stays at serial 1 while DMS advances to version 2" trace MSYS_NO_PATHCONV=1 $CLI workspace export "/Workspace/Users/${CURRENT_USER_NAME}/.bundle/dms-failed-delete-${UNIQUE_NAME}/default/state/resources.json" --format RAW | jq -c '{serial}' -trace print_state.py | jq -c '{serial}' +trace MSYS_NO_PATHCONV=1 $CLI bundle-deployments get-deployment "deployments/${deployment_id}" -o json | jq -c '{last_version_id}' # Drain any accumulated requests (plan, state reads) so the harness has nothing to diff. rm -f "$OUT_REQUESTS" diff --git a/acceptance/bundle/dms/failed-recreate/output.txt b/acceptance/bundle/dms/failed-recreate/output.txt index 860460c3375..1fcd6aef684 100644 --- a/acceptance/bundle/dms/failed-recreate/output.txt +++ b/acceptance/bundle/dms/failed-recreate/output.txt @@ -67,7 +67,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -92,9 +92,18 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.schemas.foo": { + "__id__": "main.dms_failed_recreate_schema_[UNIQUE_NAME]", + "state": { + "catalog_name": "main", + "comment": "v1", + "name": "dms_failed_recreate_schema_[UNIQUE_NAME]" + } + } + } } === A recreate deletes and then fails to create. The delete went through, so the resource really is gone: the failed operation clears its state (self-contained, not assuming the delete's own write reached the service), rather than describing the resource as it was before the deploy @@ -179,7 +188,7 @@ Files: 2 uploaded, 0 deleted "lineage": "[UUID]", "serial": 2, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, "state": {} } diff --git a/acceptance/bundle/dms/failed-update-permissions/output.txt b/acceptance/bundle/dms/failed-update-permissions/output.txt index 47ef6cd6eac..7ab42c6e531 100644 --- a/acceptance/bundle/dms/failed-update-permissions/output.txt +++ b/acceptance/bundle/dms/failed-update-permissions/output.txt @@ -15,7 +15,7 @@ Resources: 2 created, 0 changed, 0 deleted, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -45,9 +45,50 @@ Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.jobs.foo": { + "__id__": "[FOO_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-failed-update-permissions-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "foo", + "queue": { + "enabled": true + } + } + }, + "resources.jobs.foo.permissions": { + "__id__": "/jobs/[FOO_ID]", + "state": { + "object_id": "/jobs/[FOO_ID]", + "__embed__": [ + { + "level": "CAN_VIEW", + "user_name": "viewer@example.com" + }, + { + "level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + }, + "depends_on": [ + { + "node": "resources.jobs.foo", + "label": "${resources.jobs.foo.id}" + } + ] + } + } } === A permissions update that fails records the failure with the permissions it still has. The resource ID here is a path rather than a number, and the state is the embedded permission list, so this covers a shape the resource tests do not @@ -98,11 +139,52 @@ Files: 1 uploaded, 0 deleted "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", - "serial": 2, + "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.jobs.foo": { + "__id__": "[FOO_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-failed-update-permissions-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "foo", + "queue": { + "enabled": true + } + } + }, + "resources.jobs.foo.permissions": { + "__id__": "/jobs/[FOO_ID]", + "state": { + "object_id": "/jobs/[FOO_ID]", + "__embed__": [ + { + "level": "CAN_VIEW", + "user_name": "viewer@example.com" + }, + { + "level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + }, + "depends_on": [ + { + "node": "resources.jobs.foo", + "label": "${resources.jobs.foo.id}" + } + ] + } + } } >>> print_requests.py --dms //api/2.0/bundle --oneline diff --git a/acceptance/bundle/dms/multiple-resources/output.txt b/acceptance/bundle/dms/multiple-resources/output.txt index 10c8a496c2b..e045271320b 100644 --- a/acceptance/bundle/dms/multiple-resources/output.txt +++ b/acceptance/bundle/dms/multiple-resources/output.txt @@ -25,7 +25,7 @@ Resources: 5 created, 0 changed, 0 deleted, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -70,9 +70,100 @@ Plan: 0 to add, 0 to change, 0 to delete, 5 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.jobs.five": { + "__id__": "[FIVE_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-multiple-resources-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "five", + "queue": { + "enabled": true + } + } + }, + "resources.jobs.four": { + "__id__": "[FOUR_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-multiple-resources-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "four", + "queue": { + "enabled": true + } + } + }, + "resources.jobs.one": { + "__id__": "[ONE_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-multiple-resources-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "one", + "queue": { + "enabled": true + } + } + }, + "resources.jobs.three": { + "__id__": "[THREE_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-multiple-resources-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "three", + "queue": { + "enabled": true + } + } + }, + "resources.jobs.two": { + "__id__": "[TWO_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-multiple-resources-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "two", + "queue": { + "enabled": true + } + } + } + } } === Redeploy with no changes: nothing is applied, so no version is created at all - the deployment's version tracks state changes, like serial @@ -91,7 +182,7 @@ Resources: 0 created, 0 changed, 0 deleted, 5 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -136,7 +227,98 @@ Plan: 0 to add, 0 to change, 0 to delete, 5 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.jobs.five": { + "__id__": "[FIVE_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-multiple-resources-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "five", + "queue": { + "enabled": true + } + } + }, + "resources.jobs.four": { + "__id__": "[FOUR_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-multiple-resources-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "four", + "queue": { + "enabled": true + } + } + }, + "resources.jobs.one": { + "__id__": "[ONE_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-multiple-resources-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "one", + "queue": { + "enabled": true + } + } + }, + "resources.jobs.three": { + "__id__": "[THREE_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-multiple-resources-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "three", + "queue": { + "enabled": true + } + } + }, + "resources.jobs.two": { + "__id__": "[TWO_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-multiple-resources-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "two", + "queue": { + "enabled": true + } + } + } + } } diff --git a/acceptance/bundle/dms/no-drift/output.txt b/acceptance/bundle/dms/no-drift/output.txt index c2fe06a747e..f19d44a65f0 100644 --- a/acceptance/bundle/dms/no-drift/output.txt +++ b/acceptance/bundle/dms/no-drift/output.txt @@ -35,7 +35,7 @@ Resources: 2 created, 0 changed, 0 deleted, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -50,9 +50,52 @@ Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.jobs.foo": { + "__id__": "[FOO_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-no-drift-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "foo", + "queue": { + "enabled": true + } + } + }, + "resources.pipelines.bar": { + "__id__": "[BAR_ID]", + "state": { + "catalog": "main", + "channel": "CURRENT", + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-no-drift-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edition": "ADVANCED", + "libraries": [ + { + "file": { + "path": "/Workspace/Users/[USERNAME]/.bundle/dms-no-drift-[UNIQUE_NAME]/default/files/transform.py" + } + } + ], + "name": "bar-[UNIQUE_NAME]", + "schema": "default", + "serverless": true + } + } + } } === Plan without touching anything: the deployment stamp must not show as drift @@ -120,7 +163,7 @@ Resources: 0 created, 1 changed, 0 deleted, 1 unchanged } { "method": "PUT", - "path": "/api/2.0/pipelines/[UUID]", + "path": "/api/2.0/pipelines/[BAR_ID]", "body": { "catalog": "main", "channel": "CURRENT", @@ -181,7 +224,7 @@ Warning: The "development" property of pipelines is deprecated. Use "mode: devel "lineage": "[UUID]", "serial": 2, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -200,9 +243,53 @@ Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged "lineage": "[UUID]", "serial": 2, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.jobs.foo": { + "__id__": "[FOO_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-no-drift-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "foo", + "queue": { + "enabled": true + } + } + }, + "resources.pipelines.bar": { + "__id__": "[BAR_ID]", + "state": { + "catalog": "main", + "channel": "CURRENT", + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-no-drift-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "2" + }, + "development": true, + "edition": "ADVANCED", + "libraries": [ + { + "file": { + "path": "/Workspace/Users/[USERNAME]/.bundle/dms-no-drift-[UNIQUE_NAME]/default/files/transform.py" + } + } + ], + "name": "bar-[UNIQUE_NAME]", + "schema": "default", + "serverless": true + } + } + } } === Plan again: no residual drift, and the version stamps are ignored diff --git a/acceptance/bundle/dms/no-drift/script b/acceptance/bundle/dms/no-drift/script index 66898d04cf9..503931ed646 100644 --- a/acceptance/bundle/dms/no-drift/script +++ b/acceptance/bundle/dms/no-drift/script @@ -2,6 +2,7 @@ envsubst < databricks.yml.tmpl > databricks.yml title "Deploy job foo and pipeline bar; both are recorded at version 1" trace $CLI bundle deploy +replace_ids.py deployment_id=$(MSYS_NO_PATHCONV=1 $CLI workspace get-status "/Workspace/Users/${CURRENT_USER_NAME}/.bundle/dms-no-drift-${UNIQUE_NAME}/default/state/resources.deployment.json" | python3 -c 'import sys, json; print(json.load(sys.stdin)["object_id"])') add_repl "$deployment_id" DEPLOYMENT_ID diff --git a/acceptance/bundle/dms/no-resources/output.txt b/acceptance/bundle/dms/no-resources/output.txt index 884c22c6583..0899a983e1c 100644 --- a/acceptance/bundle/dms/no-resources/output.txt +++ b/acceptance/bundle/dms/no-resources/output.txt @@ -35,8 +35,9 @@ deployment_history_enabled true "plan_version": [PLAN_VERSION], "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", + "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -53,8 +54,9 @@ Plan: 0 to add, 0 to change, 0 to delete, 0 unchanged "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", + "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, "state": {} } @@ -70,10 +72,6 @@ Resources: 0 created, 0 changed, 0 deleted, 0 unchanged "method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]" } -{ - "method": "GET", - "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources" -} { "method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]" @@ -82,18 +80,10 @@ Resources: 0 created, 0 changed, 0 deleted, 0 unchanged "method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources" } -{ - "method": "GET", - "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources" -} { "method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]" } -{ - "method": "GET", - "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources" -} === The plan records the deployment_history feature >>> [CLI] bundle plan -o json @@ -101,8 +91,9 @@ Resources: 0 created, 0 changed, 0 deleted, 0 unchanged "plan_version": [PLAN_VERSION], "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", + "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -119,8 +110,9 @@ Plan: 0 to add, 0 to change, 0 to delete, 0 unchanged "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", + "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, "state": {} } diff --git a/acceptance/bundle/dms/operation-upload-fails-once/output.txt b/acceptance/bundle/dms/operation-upload-fails-once/output.txt index dc976dadead..45f837d01b0 100644 --- a/acceptance/bundle/dms/operation-upload-fails-once/output.txt +++ b/acceptance/bundle/dms/operation-upload-fails-once/output.txt @@ -14,7 +14,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -39,9 +39,18 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.schemas.foo": { + "__id__": "main.dms_upload_fails_once_schema_[UNIQUE_NAME]", + "state": { + "catalog_name": "main", + "comment": "v1", + "name": "dms_upload_fails_once_schema_[UNIQUE_NAME]" + } + } + } } === A recreate records twice, and the first one fails: the second must still carry the sequence id staging left, because a rejected update earned no new one @@ -49,20 +58,15 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged >>> update_file.py databricks.yml catalog_name: main catalog_name: dms_other_[UNIQUE_NAME] ->>> musterr [CLI] bundle deploy --auto-approve +>>> [CLI] bundle deploy --auto-approve Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-upload-fails-once-[UNIQUE_NAME]/default/files... This action will result in the deletion or recreation of the following UC schemas. Any underlying data may be lost: recreate resources.schemas.foo Recreated schemas.foo -Error: recording operation for resources.schemas.foo: Fault injected by test. (500 INJECTED) - -Endpoint: PATCH [DATABRICKS_URL]/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/2/operations/schemas.foo?update_mask=state%2Cerror_message%2Cstatus -HTTP Status: 500 Internal Server Error -API error_code: INJECTED -API message: Fault injected by test. - +Warn: Failed to record deployment history: recording operation for resources.schemas.foo: Fault injected by test. Files: 2 uploaded, 0 deleted +Resources: 1 created, 0 changed, 1 deleted, 0 unchanged === The service lists the schema with the failure >>> MSYS_NO_PATHCONV=1 [CLI] bundle-deployments list-resources deployments/[DEPLOYMENT_ID] -o json @@ -82,9 +86,18 @@ Files: 2 uploaded, 0 deleted "lineage": "[UUID]", "serial": 2, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.schemas.foo": { + "__id__": "dms_other_[UNIQUE_NAME].dms_upload_fails_once_schema_[UNIQUE_NAME]", + "state": { + "catalog_name": "dms_other_[UNIQUE_NAME]", + "comment": "v1", + "name": "dms_upload_fails_once_schema_[UNIQUE_NAME]" + } + } + } } >>> print_requests.py --dms //api/2.0/bundle diff --git a/acceptance/bundle/dms/operation-upload-fails-once/script b/acceptance/bundle/dms/operation-upload-fails-once/script index 2b539134571..cac26557cc4 100644 --- a/acceptance/bundle/dms/operation-upload-fails-once/script +++ b/acceptance/bundle/dms/operation-upload-fails-once/script @@ -22,7 +22,7 @@ other_catalog="dms_other_${UNIQUE_NAME}" $CLI catalogs create "$other_catalog" &> LOG.catalog trap '$CLI catalogs delete "$other_catalog" --force >> LOG.catalog 2>&1' EXIT trace update_file.py databricks.yml "catalog_name: main" "catalog_name: ${other_catalog}" -trace musterr $CLI bundle deploy --auto-approve +trace $CLI bundle deploy --auto-approve title "The service lists the schema with the failure" deployment_id=$(MSYS_NO_PATHCONV=1 $CLI workspace get-status "/Workspace/Users/${CURRENT_USER_NAME}/.bundle/dms-upload-fails-once-${UNIQUE_NAME}/default/state/resources.deployment.json" | python3 -c 'import sys, json; print(json.load(sys.stdin)["object_id"])') diff --git a/acceptance/bundle/dms/plan-without-dms/output.txt b/acceptance/bundle/dms/plan-without-dms/output.txt index ce1135f624d..db3e622639d 100644 --- a/acceptance/bundle/dms/plan-without-dms/output.txt +++ b/acceptance/bundle/dms/plan-without-dms/output.txt @@ -6,7 +6,7 @@ } === Enable DMS recording and try to deploy the non-DMS plan ->>> musterr [CLI] bundle deploy --plan tmp.plan.json +>>> musterr env DATABRICKS_BUNDLE_DEPLOYMENT_HISTORY=true [CLI] bundle deploy --plan tmp.plan.json Error: this plan was created for a different set of state features than the target now has; run 'bundle plan' again diff --git a/acceptance/bundle/dms/plan-without-dms/script b/acceptance/bundle/dms/plan-without-dms/script index de35616b74e..8008168e6d9 100644 --- a/acceptance/bundle/dms/plan-without-dms/script +++ b/acceptance/bundle/dms/plan-without-dms/script @@ -5,11 +5,7 @@ $CLI bundle plan -o json > tmp.plan.json trace jq '{features}' < tmp.plan.json title "Enable DMS recording and try to deploy the non-DMS plan" -update_file.py databricks.yml 'bundle:' 'experimental: - deployment_history: true - -bundle:' -trace musterr $CLI bundle deploy --plan tmp.plan.json +trace musterr env DATABRICKS_BUNDLE_DEPLOYMENT_HISTORY=true $CLI bundle deploy --plan tmp.plan.json trace print_requests.py //api/2.0/bundle --get rm -f tmp.plan.json "$OUT_REQUESTS" diff --git a/acceptance/bundle/dms/provenance/output.txt b/acceptance/bundle/dms/provenance/output.txt index 4decd10218b..71383501895 100644 --- a/acceptance/bundle/dms/provenance/output.txt +++ b/acceptance/bundle/dms/provenance/output.txt @@ -59,7 +59,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -100,7 +100,29 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.jobs.foo": { + "__id__": "[FOO_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-provenance-[UNIQUE_NAME]/dev/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 4, + "name": "[dev [USERNAME]] foo", + "queue": { + "enabled": true + }, + "tags": { + "dev": "[USERNAME]" + } + } + } + } } diff --git a/acceptance/bundle/dms/record-failure/output.txt b/acceptance/bundle/dms/record-failure/output.txt index 9f61f4a9274..0fbf9b7ac4c 100644 --- a/acceptance/bundle/dms/record-failure/output.txt +++ b/acceptance/bundle/dms/record-failure/output.txt @@ -18,7 +18,7 @@ Files: 2 uploaded, 0 deleted "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -92,7 +92,7 @@ Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, "state": {} } diff --git a/acceptance/bundle/dms/stale-local-state/databricks.yml.tmpl b/acceptance/bundle/dms/stale-local-state/databricks.yml.tmpl index a9b1eada1f9..564b0d03c87 100644 --- a/acceptance/bundle/dms/stale-local-state/databricks.yml.tmpl +++ b/acceptance/bundle/dms/stale-local-state/databricks.yml.tmpl @@ -1,9 +1,6 @@ bundle: name: dms-stale-local-state-$UNIQUE_NAME -experimental: - deployment_history: true - resources: jobs: foo: diff --git a/acceptance/bundle/dms/stale-local-state/script b/acceptance/bundle/dms/stale-local-state/script index 38d0a3725b4..340eb2b4315 100644 --- a/acceptance/bundle/dms/stale-local-state/script +++ b/acceptance/bundle/dms/stale-local-state/script @@ -2,15 +2,20 @@ envsubst < databricks.yml.tmpl > databricks.yml title "Setup (LOG.setup): reconstruct full version 1 without a history marker, then deploy version 2" { - $CLI bundle deploy + DATABRICKS_BUNDLE_DEPLOYMENT_HISTORY=true $CLI bundle deploy DATABRICKS_BUNDLE_DEPLOYMENT_HISTORY=true print_state.py > .databricks/resources.v1.json DATABRICKS_BUNDLE_DEPLOYMENT_HISTORY=true replace_ids.py update_file.py databricks.yml 'name: foo' 'name: updated' - $CLI bundle deploy + DATABRICKS_BUNDLE_DEPLOYMENT_HISTORY=true $CLI bundle deploy cp .databricks/bundle/default/resources.json .databricks/resources.v2.json deployment_id=$(MSYS_NO_PATHCONV=1 $CLI workspace get-status "/Workspace/Users/${CURRENT_USER_NAME}/.bundle/dms-stale-local-state-${UNIQUE_NAME}/default/state/resources.deployment.json" -o json | jq -r '.object_id') add_repl "$deployment_id" DEPLOYMENT_ID } &> LOG.setup +# Start from legacy DMS state, then exercise explicit YAML overrides below. +update_file.py databricks.yml 'bundle:' 'experimental: + deployment_history: true + +bundle:' trace jq -c '{serial, features}' .databricks/resources.v1.json trace jq -c '{serial, features}' .databricks/resources.v2.json diff --git a/acceptance/bundle/dms/stale-plan/output.txt b/acceptance/bundle/dms/stale-plan/output.txt index e9fbc70f832..74707ebc853 100644 --- a/acceptance/bundle/dms/stale-plan/output.txt +++ b/acceptance/bundle/dms/stale-plan/output.txt @@ -3,10 +3,7 @@ >>> [CLI] bundle plan -o json { "plan_version": [PLAN_VERSION], - "cli_version": "[CLI_VERSION]", - "features": { - "deployment_history": {} - } + "cli_version": "[CLI_VERSION]" } >>> [CLI] bundle plan @@ -35,9 +32,28 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.jobs.foo": { + "__id__": "[FOO_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-stale-plan-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "foo", + "queue": { + "enabled": true + } + } + } + } } === A saved plan carries the version it was built against @@ -48,7 +64,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged === Replay: re-deploying the same plan is rejected - it predates the recorded version >>> musterr [CLI] bundle deploy --plan tmp.plan.json -Error: plan lineage "" does not match state lineage "[UUID]"; the state may have been modified by another process +Error: this plan was created for a different set of state features than the target now has; run 'bundle plan' again === Only the first deploy recorded a version; the rejected replay created none diff --git a/acceptance/bundle/dms/state-features/output.txt b/acceptance/bundle/dms/state-features/output.txt index 342fbfe6a44..fd0dc18c143 100644 --- a/acceptance/bundle/dms/state-features/output.txt +++ b/acceptance/bundle/dms/state-features/output.txt @@ -1,5 +1,5 @@ -=== Deploy records version 3 and the deployment_history feature, with resources held in DMS +=== Deploy keeps resource state in the file and enforces best-effort history >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-state-features-[UNIQUE_NAME]/default/files... Created jobs.foo @@ -10,19 +10,37 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged { "state_version": 3, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.jobs.foo": { + "__id__": "[FOO_ID]", + "state": { + "deployment": { + "deployment_id": "[DEPLOYMENT_ID]", + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-state-features-[UNIQUE_NAME]/default/state/metadata.json", + "version_id": "1" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "state-features", + "queue": { + "enabled": true + } + } + } + } } -=== Reopening version 3 reads resources from DMS and produces a no-op plan +=== Reopening reads resources from the file and produces a no-op plan >>> [CLI] bundle plan Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged >>> print_requests.py --dms //resources --get --oneline -{"method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources"} -=== Disabling deployment history is refused by plan and deploy, while destroy keeps using DMS from state +=== Disabling deployment history is refused by plan and deploy, while destroy follows the recorded history setting >>> env DATABRICKS_BUNDLE_DEPLOYMENT_HISTORY=false [CLI] bundle plan Error: deployment history setting (false) does not match the existing state (true) diff --git a/acceptance/bundle/dms/state-features/script b/acceptance/bundle/dms/state-features/script index 349845853cc..bc80d736515 100644 --- a/acceptance/bundle/dms/state-features/script +++ b/acceptance/bundle/dms/state-features/script @@ -1,17 +1,18 @@ envsubst < databricks.yml.tmpl > databricks.yml -title "Deploy records version 3 and the deployment_history feature, with resources held in DMS" +title "Deploy keeps resource state in the file and enforces best-effort history" trace $CLI bundle deploy +replace_ids.py trace jq '{state_version, features, state}' .databricks/bundle/default/resources.json deployment_id=$(MSYS_NO_PATHCONV=1 $CLI workspace get-status "/Workspace/Users/${CURRENT_USER_NAME}/.bundle/dms-state-features-${UNIQUE_NAME}/default/state/resources.deployment.json" | python3 -c 'import sys, json; print(json.load(sys.stdin)["object_id"])') add_repl "$deployment_id" DEPLOYMENT_ID rm -f "$OUT_REQUESTS" -title "Reopening version 3 reads resources from DMS and produces a no-op plan" +title "Reopening reads resources from the file and produces a no-op plan" trace $CLI bundle plan trace print_requests.py --dms //resources --get --oneline -title "Disabling deployment history is refused by plan and deploy, while destroy keeps using DMS from state" +title "Disabling deployment history is refused by plan and deploy, while destroy follows the recorded history setting" update_file.py databricks.yml 'deployment_history: true' 'deployment_history: false' for command in plan deploy; do musterr trace env DATABRICKS_BUNDLE_DEPLOYMENT_HISTORY=false $CLI bundle "$command" diff --git a/acceptance/bundle/dms/successful-recreate/output.txt b/acceptance/bundle/dms/successful-recreate/output.txt index f2ecc3f8cbc..016b2cc5eba 100644 --- a/acceptance/bundle/dms/successful-recreate/output.txt +++ b/acceptance/bundle/dms/successful-recreate/output.txt @@ -67,7 +67,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -92,9 +92,18 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "lineage": "[UUID]", "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.schemas.foo": { + "__id__": "main.dms_successful_recreate_schema_[UNIQUE_NAME]", + "state": { + "catalog_name": "main", + "comment": "v1", + "name": "dms_successful_recreate_schema_[UNIQUE_NAME]" + } + } + } } === Recreate writes state twice - the entry is dropped, then the new resource is saved @@ -170,7 +179,7 @@ Resources: 1 created, 0 changed, 1 deleted, 0 unchanged "lineage": "[UUID]", "serial": 2, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } @@ -195,9 +204,18 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged "lineage": "[UUID]", "serial": 2, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} }, - "state": {} + "state": { + "resources.schemas.foo": { + "__id__": "dms_other_[UNIQUE_NAME].dms_successful_recreate_schema_[UNIQUE_NAME]", + "state": { + "catalog_name": "dms_other_[UNIQUE_NAME]", + "comment": "v1", + "name": "dms_successful_recreate_schema_[UNIQUE_NAME]" + } + } + } } === Destroy: the delete is recorded with the id and no state diff --git a/acceptance/bundle/dms/version-never-created/output.txt b/acceptance/bundle/dms/version-never-created/output.txt index 989744df818..53bc531b5ab 100644 --- a/acceptance/bundle/dms/version-never-created/output.txt +++ b/acceptance/bundle/dms/version-never-created/output.txt @@ -1,29 +1,25 @@ -=== The first version fails, so no deployment record exists - only the node naming its ID ->>> musterr [CLI] bundle deploy +=== Version creation fails, but file-backed deployment still succeeds +>>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-version-never-created/default/files... -Error: failed to create deployment version: Internal error (500 INTERNAL_ERROR) - -Endpoint: POST [DATABRICKS_URL]/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions?version_id=1 -HTTP Status: 500 Internal Server Error -API error_code: INTERNAL_ERROR -API message: Internal error - +Warn: Could not record deployment history; continuing with file state: failed to create deployment version: Internal error +Created jobs.foo Files: 1 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged >>> [CLI] bundle plan -o json { "plan_version": [PLAN_VERSION], "cli_version": "[CLI_VERSION]", + "lineage": "[UUID]", + "serial": 1, "features": { - "deployment_history": {} + "deployment_history_best_effort": {} } } >>> [CLI] bundle plan -create jobs.foo - -Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged >>> MSYS_NO_PATHCONV=1 [CLI] workspace get-status /Workspace/Users/[USERNAME]/.bundle/dms-version-never-created/default/state/resources.deployment.json { @@ -34,27 +30,22 @@ Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged {"method": "POST", "path": "/api/2.0/bundle/deployments", "body": {"display_name": "dms-version-never-created", "initial_parent_path": "/Workspace/Users/[USERNAME]/.bundle/dms-version-never-created/default/state", "target_name": "default", "workspace_info": {"file_path": "/Workspace/Users/[USERNAME]/.bundle/dms-version-never-created/default/files", "root_path": "/Workspace/Users/[USERNAME]/.bundle/dms-version-never-created/default"}}} {"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "1"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_CREATE", "resource_key": "jobs.foo"}], "version_type": "VERSION_TYPE_DEPLOY"}} {"method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]"} -{"method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources"} {"method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]"} -{"method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources"} >>> MSYS_NO_PATHCONV=1 [CLI] bundle-deployments list-resources deployments/[DEPLOYMENT_ID] -o json [] -=== The next deploy reuses the ID that node names and retries version 1, rather than creating a second deployment ->>> musterr [CLI] bundle deploy -Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-version-never-created/default/files... -Error: failed to create deployment version: Internal error (500 INTERNAL_ERROR) +=== The next resource update also succeeds while history version creation fails +>>> update_file.py databricks.yml name: foo name: renamed -Endpoint: POST [DATABRICKS_URL]/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions?version_id=1 -HTTP Status: 500 Internal Server Error -API error_code: INTERNAL_ERROR -API message: Internal error - -Files: 0 uploaded, 0 deleted +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-version-never-created/default/files... +Warn: Could not record deployment history; continuing with file state: failed to create deployment version: Internal error +Updated jobs.foo +Files: 1 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 0 unchanged >>> print_requests.py --dms //api/2.0/bundle --get --oneline {"method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources"} {"method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]"} -{"method": "GET", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/resources"} -{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "1"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_CREATE", "resource_key": "jobs.foo"}], "version_type": "VERSION_TYPE_DEPLOY"}} +{"method": "POST", "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", "q": {"version_id": "2"}, "body": {"cli_version": "[CLI_VERSION]", "operations": [{"action_type": "OPERATION_ACTION_TYPE_UPDATE", "resource_key": "jobs.foo"}], "version_type": "VERSION_TYPE_DEPLOY"}} diff --git a/acceptance/bundle/dms/version-never-created/script b/acceptance/bundle/dms/version-never-created/script index 9498fa05eff..5601cb2129b 100644 --- a/acceptance/bundle/dms/version-never-created/script +++ b/acceptance/bundle/dms/version-never-created/script @@ -1,5 +1,5 @@ -title "The first version fails, so no deployment record exists - only the node naming its ID" -trace musterr $CLI bundle deploy +title "Version creation fails, but file-backed deployment still succeeds" +trace $CLI bundle deploy dms_plan trace MSYS_NO_PATHCONV=1 $CLI workspace get-status "/Workspace/Users/${CURRENT_USER_NAME}/.bundle/dms-version-never-created/default/state/resources.deployment.json" | jq '{object_type}' trace print_requests.py --dms //api/2.0/bundle --get --oneline @@ -8,6 +8,7 @@ deployment_id=$(MSYS_NO_PATHCONV=1 $CLI workspace get-status "/Workspace/Users/$ add_repl "$deployment_id" DEPLOYMENT_ID trace MSYS_NO_PATHCONV=1 $CLI bundle-deployments list-resources "deployments/${deployment_id}" -o json | jq -S 'map({resource_key, last_version_id, last_action_type})' -title "The next deploy reuses the ID that node names and retries version 1, rather than creating a second deployment" -trace musterr $CLI bundle deploy +title "The next resource update also succeeds while history version creation fails" +trace update_file.py databricks.yml 'name: foo' 'name: renamed' +trace $CLI bundle deploy trace print_requests.py --dms //api/2.0/bundle --get --oneline diff --git a/bundle/config/experimental.go b/bundle/config/experimental.go index 6fd5511b79b..2df01b7e226 100644 --- a/bundle/config/experimental.go +++ b/bundle/config/experimental.go @@ -54,8 +54,9 @@ type Experimental struct { // service (DMS), which records deployment history and tracks what changed // across deployments. // - // Only for bundles with no deployed resources yet: DMS becomes the source of - // truth for their state. + // Resource state remains in resources.json. History uploads are best effort: + // DMS errors do not fail file-backed deployments. + // Existing DMS-authoritative deployments keep their current state source. // An explicit value takes priority over the environment setting. DeploymentHistory *bool `json:"deployment_history,omitempty"` } diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index d4f4a2ffb41..24f6fbaed80 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -265,8 +265,8 @@ func (d *DeploymentUnit) Delete(ctx context.Context, db *dstate.DeploymentState, log.Warnf(ctx, "Ignoring permission error when deleting %s id=%s: %s", d.ResourceKey, oldID, err) } else if d.deleteConfirmedGone(ctx, oldID) { log.Warnf(ctx, "Treating %s id=%s as already deleted despite delete error: %s", d.ResourceKey, oldID, err) - } else if db.IsDeploymentMetadataService() { - // When using the deployment metadata service, record the error. + } else if db.RecordsHistory() { + // A resource API failure stays fatal; only history-service failures are best effort. err = fmt.Errorf("deleting id=%s: %w", oldID, err) db.RecordFailure(d.ResourceKey, oldID, err) return err diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index 411eeb2af3d..6ceab2cf8a8 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -456,7 +456,7 @@ func (db *DeploymentState) GetSerial() int { switch db.mode { case WritingDMS: return db.VersionID - case WritingFileWithHistory, WritingFileWithHistoryEnforced: + case ReadingFile, WritingFileWithHistory, WritingFileWithHistoryEnforced: return max(db.VersionID, db.Data.Serial) default: return db.Data.Serial @@ -898,6 +898,16 @@ func (db *DeploymentState) Finalize(ctx context.Context) (resourcestate.Exported // UpgradeToWrite opens a WAL for file state without re-reading resources. // DMS and file-history modes are already writable from Open, so upgrading them is a no-op. func (db *DeploymentState) UpgradeToWrite() error { + return db.upgradeToWrite(WritingFile) +} + +// UpgradeToWriteWithHistory enables enforced file-backed history after approval. +// Reads still use the same file state; only the write policy changes. +func (db *DeploymentState) UpgradeToWriteWithHistory() error { + return db.upgradeToWrite(WritingFileWithHistoryEnforced) +} + +func (db *DeploymentState) upgradeToWrite(mode Mode) error { db.mu.Lock() defer db.mu.Unlock() @@ -910,7 +920,7 @@ func (db *DeploymentState) UpgradeToWrite() error { case WritingFile: return errors.New("internal error: DeploymentState is already open for write") case ReadingFile: - db.mode = WritingFile + db.mode = mode if err := db.setupWal(); err != nil { db.mode = ReadingFile return err diff --git a/bundle/direct/dstate/state_test.go b/bundle/direct/dstate/state_test.go index a44afae8fab..3e0080688f4 100644 --- a/bundle/direct/dstate/state_test.go +++ b/bundle/direct/dstate/state_test.go @@ -423,7 +423,7 @@ func TestOpenFailureLeavesStateClosed(t *testing.T) { } func TestGetSerialWithHistoryUsesLatestCounter(t *testing.T) { - for _, mode := range []Mode{WritingFileWithHistory, WritingFileWithHistoryEnforced} { + for _, mode := range []Mode{ReadingFile, WritingFileWithHistory, WritingFileWithHistoryEnforced} { db := DeploymentState{mode: mode, Data: NewDatabase("", 4), VersionID: 7} assert.Equal(t, 7, db.GetSerial(), "mode=%d: service version is ahead", mode) db.Data.Serial = 9 diff --git a/bundle/phases/deploy.go b/bundle/phases/deploy.go index 13c5121f569..22caac5b4ec 100644 --- a/bundle/phases/deploy.go +++ b/bundle/phases/deploy.go @@ -22,7 +22,6 @@ import ( "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/libs/agent" "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/sync" @@ -170,7 +169,7 @@ func Deploy(ctx context.Context, b *bundle.Bundle, outputHandler sync.OutputHand // The version is created only after approval; CompleteVersion is deferred before // lock.Release and no-ops until then. defer func() { - if b.DeploymentBundle.StateDB.IsDeploymentMetadataService() { + if b.DeploymentBundle.StateDB.RecordsHistory() { if _, err := b.DeploymentBundle.StateDB.CompleteVersion(ctx, !logdiag.HasError(ctx)); err != nil { logdiag.LogError(ctx, err) } @@ -297,7 +296,7 @@ func Deploy(ctx context.Context, b *bundle.Bundle, outputHandler sync.OutputHand // Upgrade from read (opened by process.go, or by Migrate) to write mode. After approval // and the migration commit above, so a declined deploy never opens a WAL it must discard, // and the migration's tf+1 push lands before this advances the WAL header to tf+2. - if err := b.DeploymentBundle.StateDB.UpgradeToWrite(); err != nil { + if err := upgradeStateForWrite(b); err != nil { logdiag.LogError(ctx, err) return } @@ -320,32 +319,13 @@ func Deploy(ctx context.Context, b *bundle.Bundle, outputHandler sync.OutputHand // Create the deployment now that the plan is approved, so a declined deploy leaves none behind. // A first deploy's id did not exist at plan time - the version and any existing id were stamped // then - so stamp the one just created into the plan the apply reads. - if b.DeploymentBundle.StateDB.IsDeploymentMetadataService() { - firstDeploy := b.DeploymentBundle.StateDB.DeploymentID == "" - createOrUpdateDeployment(ctx, b, dmsDeployment) - if logdiag.HasError(ctx) { - return - } - if firstDeploy { - if err := b.DeploymentBundle.StampDeploymentIdForFirstVersion(b.DeploymentBundle.StateDB.DeploymentID); err != nil { - logdiag.LogError(ctx, err) - return - } - } - - // Only create a version when the plan has at least one operation to record, so it moves - // with serial. Done after the prompt, so a declined deploy never claims a number. - staged, err := stagedOperations(plan) - if err != nil { - logdiag.LogError(ctx, err) - return - } - if len(staged) > 0 { - if err := startVersion(ctx, b, dms.VersionTypeDeploy, staged); err != nil { + if b.DeploymentBundle.StateDB.RecordsHistory() { + if err := prepareDeploymentHistory(ctx, b, plan, dmsDeployment); err != nil { + if b.DeploymentBundle.StateDB.IsDeploymentMetadataService() { logdiag.LogError(ctx, err) return } - logDeploymentVersion(ctx, b) + log.Warnf(ctx, "Could not record deployment history; continuing with file state: %s", err) } } diff --git a/bundle/phases/destroy.go b/bundle/phases/destroy.go index 07bbbbca385..d6cd8dadb95 100644 --- a/bundle/phases/destroy.go +++ b/bundle/phases/destroy.go @@ -16,7 +16,6 @@ import ( "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/diag" - "github.com/databricks/cli/libs/dms" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" "github.com/databricks/databricks-sdk-go/apierr" @@ -156,7 +155,7 @@ func destroyCore(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan) { return } - if b.DeploymentBundle.StateDB.IsDeploymentMetadataService() { + if b.DeploymentBundle.StateDB.RecordsHistory() { // Complete version before deleting remote files; the deployment node is under statePath. completed, err := b.DeploymentBundle.StateDB.CompleteVersion(ctx, true) if err != nil { @@ -168,8 +167,11 @@ func destroyCore(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan) { deploymentID := b.DeploymentBundle.StateDB.DeploymentID w := b.WorkspaceClient(ctx) if err := deleteDeployment(ctx, w, deploymentID); err != nil { - logdiag.LogError(ctx, fmt.Errorf("failed to delete deployment: %w", err)) - return + if b.DeploymentBundle.StateDB.IsDeploymentMetadataService() { + logdiag.LogError(ctx, fmt.Errorf("failed to delete deployment: %w", err)) + return + } + log.Warnf(ctx, "Could not delete deployment history: %s", err) } } } @@ -293,7 +295,7 @@ func Destroy(ctx context.Context, b *bundle.Bundle) { // destroy records nothing. Deferred before lock.Release to hold the lock; a no-op once // destroyCore has completed the version. defer func() { - if b.DeploymentBundle.StateDB.IsDeploymentMetadataService() { + if b.DeploymentBundle.StateDB.RecordsHistory() { completed, err := b.DeploymentBundle.StateDB.CompleteVersion(ctx, !logdiag.HasError(ctx)) if err != nil { logdiag.LogError(ctx, err) @@ -301,7 +303,11 @@ func Destroy(ctx context.Context, b *bundle.Bundle) { deploymentID := b.DeploymentBundle.StateDB.DeploymentID w := b.WorkspaceClient(ctx) if err := deleteDeployment(ctx, w, deploymentID); err != nil { - logdiag.LogError(ctx, fmt.Errorf("failed to delete deployment: %w", err)) + if b.DeploymentBundle.StateDB.IsDeploymentMetadataService() { + logdiag.LogError(ctx, fmt.Errorf("failed to delete deployment: %w", err)) + } else { + log.Warnf(ctx, "Could not delete deployment history: %s", err) + } } } } @@ -347,22 +353,19 @@ func Destroy(ctx context.Context, b *bundle.Bundle) { } // Upgrade from read (opened by process.go, or by Migrate) to write mode - if err := b.DeploymentBundle.StateDB.UpgradeToWrite(); err != nil { + if err := upgradeStateForWrite(b); err != nil { logdiag.LogError(ctx, err) return } - // Start the version for this destroy, now that it is approved. Everything else recording - // does follows from the buffer this opens. - if b.DeploymentBundle.StateDB.IsDeploymentMetadataService() { - staged, err := stagedOperations(plan) - if err != nil { - logdiag.LogError(ctx, err) - return - } - if err := startVersion(ctx, b, dms.VersionTypeDestroy, staged); err != nil { - logdiag.LogError(ctx, err) - return + // Record the approved destroy; file-backed history errors only warn. + if b.DeploymentBundle.StateDB.RecordsHistory() { + if err := prepareDestroyHistory(ctx, b, plan); err != nil { + if b.DeploymentBundle.StateDB.IsDeploymentMetadataService() { + logdiag.LogError(ctx, err) + return + } + log.Warnf(ctx, "Could not record deployment history; continuing with file state: %s", err) } } destroyCore(ctx, b, plan) diff --git a/bundle/phases/dms.go b/bundle/phases/dms.go index e2d7ea958c1..2ff489a0f7d 100644 --- a/bundle/phases/dms.go +++ b/bundle/phases/dms.go @@ -12,11 +12,11 @@ import ( "github.com/databricks/cli/bundle/config" "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/bundle/deployplan" + "github.com/databricks/cli/bundle/direct/dstate" "github.com/databricks/cli/internal/build" "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/cli/libs/workspaceurls" "github.com/databricks/databricks-sdk-go" @@ -73,13 +73,12 @@ func actionToSDK(a deployplan.ActionType) (bundledeployments.OperationActionType // run changed. current is the record the service holds (nil before the first recorded deploy), // diffed to mask the update down to what changed. Runs after approval, so a declined deploy leaves // no deployment behind; a first deploy's new id is then stamped into the plan (StampDeploymentID). -func createOrUpdateDeployment(ctx context.Context, b *bundle.Bundle, current *bundledeployments.Deployment) { +func createOrUpdateDeployment(ctx context.Context, b *bundle.Bundle, current *bundledeployments.Deployment) error { db := &b.DeploymentBundle w := b.WorkspaceClient(ctx) metadata, err := deploymentMetadata(b) if err != nil { - logdiag.LogError(ctx, fmt.Errorf("failed to compute deployment metadata: %w", err)) - return + return fmt.Errorf("failed to compute deployment metadata: %w", err) } deploymentID := db.StateDB.DeploymentID if deploymentID == "" { @@ -87,14 +86,12 @@ func createOrUpdateDeployment(ctx context.Context, b *bundle.Bundle, current *bu dep.InitialParentPath = b.Config.Workspace.StatePath created, err := w.BundleDeployments.CreateDeployment(ctx, bundledeployments.CreateDeploymentRequest{Deployment: dep}) if err != nil { - logdiag.LogError(ctx, fmt.Errorf("failed to create deployment: %w", err)) - return + 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 { - logdiag.LogError(ctx, fmt.Errorf("failed to create deployment: %w", err)) - return + return fmt.Errorf("failed to create deployment: %w", err) } db.StateDB.DeploymentID = deploymentID } else if mask := metadata.StaleFields(current); mask != "" { @@ -106,8 +103,7 @@ func createOrUpdateDeployment(ctx context.Context, b *bundle.Bundle, current *bu }) }) if err != nil { - logdiag.LogError(ctx, fmt.Errorf("failed to update deployment: %w", err)) - return + return fmt.Errorf("failed to update deployment: %w", err) } } @@ -115,6 +111,7 @@ func createOrUpdateDeployment(ctx context.Context, b *bundle.Bundle, current *bu bundle.ApplyFuncContext(ctx, b, func(_ context.Context, b *bundle.Bundle) { b.Config.Bundle.Deployment.DeploymentID = deploymentID }) + return nil } // deleteDeployment deletes the deployment record. A retry that finds it missing succeeds: the @@ -132,12 +129,62 @@ func deleteDeployment(ctx context.Context, w *databricks.WorkspaceClient, deploy return err } +// upgradeStateForWrite delays the history WAL until the operation is approved. +func upgradeStateForWrite(b *bundle.Bundle) error { + db := &b.DeploymentBundle.StateDB + _, marked := db.StateFeatures()[dstate.FeatureDeploymentHistoryBestEffort] + configured := b.Config.Experimental != nil && b.Config.Experimental.DeploymentHistory != nil && *b.Config.Experimental.DeploymentHistory + if !db.IsDeploymentMetadataService() && (configured || marked) { + return db.UpgradeToWriteWithHistory() + } + return db.UpgradeToWrite() +} + +// prepareDeploymentHistory creates history metadata and a version after approval. +func prepareDeploymentHistory(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan, current *bundledeployments.Deployment) error { + firstDeploy := b.DeploymentBundle.StateDB.DeploymentID == "" + if err := createOrUpdateDeployment(ctx, b, current); err != nil { + return err + } + if firstDeploy { + if err := b.DeploymentBundle.StampDeploymentIdForFirstVersion(b.DeploymentBundle.StateDB.DeploymentID); err != nil { + return err + } + } + staged, err := stagedOperations(plan) + if err != nil { + return err + } + if len(staged) == 0 { + return nil + } + if err := startVersion(ctx, b, dms.VersionTypeDeploy, staged); err != nil { + return err + } + logDeploymentVersion(ctx, b) + return nil +} + +// prepareDestroyHistory records the approved destroy without changing its state source. +func prepareDestroyHistory(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan) error { + if !b.DeploymentBundle.StateDB.IsDeploymentMetadataService() && b.DeploymentBundle.StateDB.DeploymentID == "" { + if err := createOrUpdateDeployment(ctx, b, nil); err != nil { + return err + } + } + staged, err := stagedOperations(plan) + if err != nil { + return err + } + return startVersion(ctx, b, dms.VersionTypeDestroy, staged) +} + // startVersion claims the version the run settled on and opens the buffer that records // each state write under it. Called after approval, so a declined deploy never claims a number. // A no-op when the bundle does not record deployment history. func startVersion(ctx context.Context, b *bundle.Bundle, versionType dms.VersionType, staged []bundledeployments.StagedOperation) error { db := &b.DeploymentBundle - if !db.StateDB.IsDeploymentMetadataService() { + if !db.StateDB.RecordsHistory() { return nil } w := b.WorkspaceClient(ctx) @@ -147,6 +194,9 @@ func startVersion(ctx context.Context, b *bundle.Bundle, versionType dms.Version // version straight off the state. previous := db.StateDB.VersionID versionID := previous + 1 + if !db.StateDB.IsDeploymentMetadataService() { + versionID = max(previous, db.StateDB.Data.Serial) + 1 + } previousVersionID := "" if previous > 0 { previousVersionID = strconv.Itoa(previous) diff --git a/bundle/phases/telemetry.go b/bundle/phases/telemetry.go index 99464f3ef24..102a004aa1a 100644 --- a/bundle/phases/telemetry.go +++ b/bundle/phases/telemetry.go @@ -198,7 +198,7 @@ func aiRuntimeTaskMetrics(jobs map[string]*resources.Job) (present, scheduled, m // LogDeployTelemetry logs a telemetry event for a bundle deploy command. func LogDeployTelemetry(ctx context.Context, b *bundle.Bundle, errMsg string) { errMsg = telemetry.ScrubErrorMessage(errMsg) - b.Metrics.SetBoolValue(metrics.DeploymentHistoryEnabled, b.DeploymentBundle.StateDB.IsDeploymentMetadataService()) + b.Metrics.SetBoolValue(metrics.DeploymentHistoryEnabled, b.DeploymentBundle.StateDB.RecordsHistory()) resourcesCount := int64(0) _, err := dyn.MapByPattern(b.Config.Value(), dyn.NewPattern(dyn.Key("resources"), dyn.AnyKey(), dyn.AnyKey()), func(p dyn.Path, v dyn.Value) (dyn.Value, error) { diff --git a/bundle/statemgmt/state_pull.go b/bundle/statemgmt/state_pull.go index 616cd8af197..4d8a06d5d1e 100644 --- a/bundle/statemgmt/state_pull.go +++ b/bundle/statemgmt/state_pull.go @@ -77,6 +77,12 @@ func (s *StateDesc) IsDMS() bool { return ok } +// RecordsHistory includes both DMS-authoritative state and file-backed history. +func (s *StateDesc) RecordsHistory() bool { + _, bestEffort := s.Features["deployment_history_best_effort"] + return s.IsDMS() || bestEffort +} + func localRead(ctx context.Context, fullPath string, engine engine.EngineType) *StateDesc { content, err := os.ReadFile(fullPath) if err != nil { diff --git a/cmd/bundle/utils/process.go b/cmd/bundle/utils/process.go index 8fa68f32f5e..959b5b94081 100644 --- a/cmd/bundle/utils/process.go +++ b/cmd/bundle/utils/process.go @@ -372,6 +372,17 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle } } + if needDirectState && usesFileHistory(b, stateDesc) { + if !cmdctx.HasWorkspaceClient(ctx) { + ctx = cmdctx.SetWorkspaceClient(ctx, b.WorkspaceClient(ctx)) + } + var err error + dmsDeployment, err = prepareFileHistory(ctx, b) + if err != nil { + log.Warnf(ctx, "Could not read deployment history; continuing with file state: %s", err) + } + } + if needDirectState && stateDesc.IsDMS() { var err error dmsDeploymentID, dmsDeployment, err = fetchDeploymentFromStatePath(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) @@ -638,11 +649,20 @@ func parseLastVersionID(dmsDeployment *bundledeployments.Deployment) (int, error // read from the deployment metadata service instead. func OpenDirectStateForRead(ctx context.Context, b *bundle.Bundle, stateDesc *statemgmt.StateDesc) error { _, localPath := b.StateFilenameDirect(ctx) - if !resolveDeploymentHistory(ctx, b, stateDesc) { + resolveDeploymentHistory(ctx, b, stateDesc) + if !stateDesc.IsDMS() { if err := b.DeploymentBundle.StateDB.Open(ctx, localPath, dstate.ReadingFile, dstate.WithRecovery(true), dstate.OpenDmsArgs{}); err != nil { return err } - return enforceDeploymentHistorySetting(ctx, b, stateDesc, false) + if err := enforceDeploymentHistorySetting(ctx, b, stateDesc, false); err != nil { + return err + } + if usesFileHistory(b, stateDesc) { + if _, err := prepareFileHistory(ctx, b); err != nil { + log.Warnf(ctx, "Could not read deployment history; continuing with file state: %s", err) + } + } + return nil } dmsDeploymentID, dmsDeployment, err := fetchDeploymentFromStatePath(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) @@ -667,12 +687,16 @@ func resolveDeploymentHistory(ctx context.Context, b *bundle.Bundle, stateDesc * configured := configuresDeploymentHistory(ctx, b) if stateDesc.SourcePath == "" { if configured { - stateDesc.Features = map[string]struct{}{dstate.FeatureDeploymentHistory: {}} + feature := dstate.FeatureDeploymentHistory + if usesFileHistory(b, stateDesc) { + feature = dstate.FeatureDeploymentHistoryBestEffort + } + stateDesc.Features = map[string]struct{}{feature: {}} } return configured } - return stateDesc.IsDMS() + return stateDesc.RecordsHistory() || usesFileHistory(b, stateDesc) } func enforceDeploymentHistorySetting(ctx context.Context, b *bundle.Bundle, stateDesc *statemgmt.StateDesc, requireMatch bool) error { @@ -680,8 +704,8 @@ func enforceDeploymentHistorySetting(ctx context.Context, b *bundle.Bundle, stat return nil } configured := configuresDeploymentHistory(ctx, b) - recorded := stateDesc.IsDMS() - if configured == recorded { + recorded := stateDesc.RecordsHistory() + if configured == recorded || (configured && usesFileHistory(b, stateDesc)) { return nil } if requireMatch { @@ -698,6 +722,38 @@ Run "databricks bundle destroy" first, then deploy again with deployment history return nil } +// usesFileHistory preserves existing DMS state and the env-only DMS test mode. +// An explicit YAML opt-in records history while keeping resources.json authoritative. +func usesFileHistory(b *bundle.Bundle, state *statemgmt.StateDesc) bool { + if state.IsDMS() { + return false + } + return state.RecordsHistory() || (b.Config.Experimental != nil && b.Config.Experimental.DeploymentHistory != nil && *b.Config.Experimental.DeploymentHistory) +} + +// prepareFileHistory reads version metadata, never resource state, from DMS. +func prepareFileHistory(ctx context.Context, b *bundle.Bundle) (*bundledeployments.Deployment, error) { + id, deployment, err := fetchDeploymentFromStatePath(ctx, b.WorkspaceClient(ctx), b.Config.Workspace.StatePath) + if err != nil { + return nil, err + } + lastVersion, err := parseLastVersionID(deployment) + if err != nil { + return nil, err + } + db := &b.DeploymentBundle.StateDB + db.DeploymentID, db.VersionID = id, lastVersion + bundle.ApplyFuncContext(ctx, b, func(_ context.Context, b *bundle.Bundle) { + b.Config.Bundle.Deployment.DeploymentID = id + b.Config.Bundle.Deployment.LatestVersionID = lastVersion + }) + bundle.ApplyContext(ctx, b, metadata.AnnotateDeploymentVersion(max(db.Data.Serial, lastVersion)+1)) + if id != "" { + bundle.ApplyContext(ctx, b, metadata.AnnotateDeployment(id)) + } + return deployment, nil +} + func configuresDeploymentHistory(ctx context.Context, b *bundle.Bundle) bool { var configured *bool if b.Config.Experimental != nil { diff --git a/cmd/bundle/utils/process_test.go b/cmd/bundle/utils/process_test.go index b216f1102d2..46768b4d3de 100644 --- a/cmd/bundle/utils/process_test.go +++ b/cmd/bundle/utils/process_test.go @@ -6,7 +6,9 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config" "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/env" "github.com/databricks/databricks-sdk-go/service/bundledeployments" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,13 +21,24 @@ func TestResolveDeploymentHistory(t *testing.T) { newState := &statemgmt.StateDesc{} assert.True(t, resolveDeploymentHistory(t.Context(), b, newState)) - assert.True(t, newState.IsDMS()) - assert.False(t, resolveDeploymentHistory(t.Context(), b, &statemgmt.StateDesc{SourcePath: "resources.json"})) + assert.False(t, newState.IsDMS()) + assert.True(t, newState.RecordsHistory()) + assert.Contains(t, newState.Features, dstate.FeatureDeploymentHistoryBestEffort) + assert.True(t, usesFileHistory(b, newState)) + assert.True(t, resolveDeploymentHistory(t.Context(), b, &statemgmt.StateDesc{SourcePath: "resources.json"})) b.Config.Experimental.DeploymentHistory = new(false) assert.True(t, resolveDeploymentHistory(t.Context(), b, &statemgmt.StateDesc{ SourcePath: "resources.json", Features: map[string]struct{}{dstate.FeatureDeploymentHistory: {}}, })) + + // The internal env-only switch continues to exercise DMS-authoritative state. + b.Config.Experimental.DeploymentHistory = nil + ctx := env.Set(t.Context(), bundleenv.DeploymentHistoryVariable, "true") + legacy := &statemgmt.StateDesc{} + assert.True(t, resolveDeploymentHistory(ctx, b, legacy)) + assert.True(t, legacy.IsDMS()) + assert.False(t, usesFileHistory(b, legacy)) } func TestIsNewerVersion(t *testing.T) { From 4835105579df41ec3331b47c7a7fe2d28484cba8 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Fri, 23 Oct 2026 14:52:36 +0000 Subject: [PATCH 2/2] Use recovered file history markers when selecting recording --- cmd/bundle/utils/process.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/bundle/utils/process.go b/cmd/bundle/utils/process.go index 959b5b94081..4040d976ca1 100644 --- a/cmd/bundle/utils/process.go +++ b/cmd/bundle/utils/process.go @@ -362,6 +362,8 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle logdiag.LogError(ctx, err) return b, stateDesc, root.ErrAlreadyPrinted } + // WAL recovery can introduce the enforced-history marker. + stateDesc.Features = b.DeploymentBundle.StateDB.StateFeatures() } } @@ -654,6 +656,7 @@ func OpenDirectStateForRead(ctx context.Context, b *bundle.Bundle, stateDesc *st if err := b.DeploymentBundle.StateDB.Open(ctx, localPath, dstate.ReadingFile, dstate.WithRecovery(true), dstate.OpenDmsArgs{}); err != nil { return err } + stateDesc.Features = b.DeploymentBundle.StateDB.StateFeatures() if err := enforceDeploymentHistorySetting(ctx, b, stateDesc, false); err != nil { return err }