From 4e246b22c775a2bddcc39ca88c05d0e320f94d14 Mon Sep 17 00:00:00 2001 From: Ilya Kuznetsov Date: Mon, 5 Oct 2026 16:27:55 +0000 Subject: [PATCH 1/4] config-remote-sync: fail when a selected resource is not in the configuration Co-authored-by: Isaac --- .../json_status_missing_state/output.txt | 1 + .../json_status_selectors/output.txt | 2 + .../select_not_in_config/databricks.yml.tmpl | 19 ++++++++ .../select_not_in_config/out.test.toml | 3 ++ .../select_not_in_config/output.txt | 28 ++++++++++++ .../select_not_in_config/script | 30 +++++++++++++ .../select_not_in_config/test.toml | 6 +++ .../databricks.yml | 11 +++++ .../out.test.toml | 3 ++ .../output.txt | 21 +++++++++ .../config-remote-sync-not-in-config/script | 13 ++++++ .../test.toml | 8 ++++ .../output.txt | 1 + bundle/configsync/output.go | 12 +++-- bundle/configsync/select.go | 45 ++++++++++++++++--- bundle/configsync/select_test.go | 19 ++++++++ cmd/bundle/config_remote_sync.go | 4 ++ .../protos/bundle_config_remote_sync.go | 1 + 18 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 acceptance/bundle/config-remote-sync/select_not_in_config/databricks.yml.tmpl create mode 100644 acceptance/bundle/config-remote-sync/select_not_in_config/out.test.toml create mode 100644 acceptance/bundle/config-remote-sync/select_not_in_config/output.txt create mode 100644 acceptance/bundle/config-remote-sync/select_not_in_config/script create mode 100644 acceptance/bundle/config-remote-sync/select_not_in_config/test.toml create mode 100644 acceptance/bundle/telemetry/config-remote-sync-not-in-config/databricks.yml create mode 100644 acceptance/bundle/telemetry/config-remote-sync-not-in-config/out.test.toml create mode 100644 acceptance/bundle/telemetry/config-remote-sync-not-in-config/output.txt create mode 100644 acceptance/bundle/telemetry/config-remote-sync-not-in-config/script create mode 100644 acceptance/bundle/telemetry/config-remote-sync-not-in-config/test.toml diff --git a/acceptance/bundle/config-remote-sync/json_status_missing_state/output.txt b/acceptance/bundle/config-remote-sync/json_status_missing_state/output.txt index bce70b3874a..52a70fca957 100644 --- a/acceptance/bundle/config-remote-sync/json_status_missing_state/output.txt +++ b/acceptance/bundle/config-remote-sync/json_status_missing_state/output.txt @@ -3,6 +3,7 @@ { "status": "failed", "error": "the Terraform deployment engine has been removed in Databricks CLI v1.20.0\n\nRemove the \"bundle.engine\" setting (or set it to \"direct\") to deploy with the direct engine; existing Terraform state is migrated automatically. To keep using Terraform, revert to Databricks CLI v1.19.x.\nSee https://docs.databricks.com/dev-tools/bundles/direct for details", + "errorCode": "BUNDLE_LOAD_FAILED", "files": null, "changes": {} } diff --git a/acceptance/bundle/config-remote-sync/json_status_selectors/output.txt b/acceptance/bundle/config-remote-sync/json_status_selectors/output.txt index 2059b95c814..972383d49f6 100644 --- a/acceptance/bundle/config-remote-sync/json_status_selectors/output.txt +++ b/acceptance/bundle/config-remote-sync/json_status_selectors/output.txt @@ -7,6 +7,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged { "status": "skipped", "error": "no deployed jobs resource with id no-such-id-999; deployed jobs ids in state: [TEST_JOB_ID]", + "errorCode": "BUNDLE_LOAD_FAILED", "files": null, "changes": {} } @@ -15,6 +16,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged { "status": "failed", "error": "invalid --select-ids value \"no-such-id-999\", expected \u003ctype\u003e:\u003cid\u003e (e.g. jobs:[NUMID])", + "errorCode": "BUNDLE_LOAD_FAILED", "files": null, "changes": {} } diff --git a/acceptance/bundle/config-remote-sync/select_not_in_config/databricks.yml.tmpl b/acceptance/bundle/config-remote-sync/select_not_in_config/databricks.yml.tmpl new file mode 100644 index 00000000000..16d5646d970 --- /dev/null +++ b/acceptance/bundle/config-remote-sync/select_not_in_config/databricks.yml.tmpl @@ -0,0 +1,19 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + jobs: + test_job: + max_concurrent_runs: 1 + tasks: + - task_key: main + notebook_task: + notebook_path: /Users/{{workspace_user_name}}/notebook + new_cluster: + spark_version: $DEFAULT_SPARK_VERSION + node_type_id: $NODE_TYPE_ID + num_workers: 1 + +targets: + default: + mode: development diff --git a/acceptance/bundle/config-remote-sync/select_not_in_config/out.test.toml b/acceptance/bundle/config-remote-sync/select_not_in_config/out.test.toml new file mode 100644 index 00000000000..a4f8dd5ef74 --- /dev/null +++ b/acceptance/bundle/config-remote-sync/select_not_in_config/out.test.toml @@ -0,0 +1,3 @@ +Cloud = true +GOOS.windows = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/config-remote-sync/select_not_in_config/output.txt b/acceptance/bundle/config-remote-sync/select_not_in_config/output.txt new file mode 100644 index 00000000000..3e0b3a59b57 --- /dev/null +++ b/acceptance/bundle/config-remote-sync/select_not_in_config/output.txt @@ -0,0 +1,28 @@ +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Created jobs.test_job +Files: 2 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged + +=== Rename the job key locally without deploying + +=== Selecting the deployed job fails with RESOURCE_NOT_IN_CONFIG, exit 0 +{ + "status": "failed", + "error": "deployed resource resources.jobs.test_job not found in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", + "errorCode": "RESOURCE_NOT_IN_CONFIG", + "files": null, + "changes": {} +} + +=== Text mode fails the command +Error: deployed resource resources.jobs.test_job not found in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced + +Exit code: 1 + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.jobs.test_job + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default + +Destroy: 1 deleted diff --git a/acceptance/bundle/config-remote-sync/select_not_in_config/script b/acceptance/bundle/config-remote-sync/select_not_in_config/script new file mode 100644 index 00000000000..cea47d2da94 --- /dev/null +++ b/acceptance/bundle/config-remote-sync/select_not_in_config/script @@ -0,0 +1,30 @@ +#!/bin/bash + +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +$CLI bundle deploy +job_id="$(read_id.py test_job)" + +edit_resource.py jobs $job_id <>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/config-remote-sync-not-in-config/default/files... +Created jobs.foo +Files: 1 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged + +>>> [CLI] bundle config-remote-sync --select-ids jobs:[FOO_ID] -o json +{ + "status": "failed", + "error": "deployed resource resources.jobs.foo not found in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", + "errorCode": "RESOURCE_NOT_IN_CONFIG", + "files": null, + "changes": {} +} + +>>> cat [OUTPUT_DIR]/out.requests.txt +{ + "error_message": "deployed resource resources.jobs.foo not found in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", + "error_category": "RESOURCE_NOT_IN_CONFIG" +} diff --git a/acceptance/bundle/telemetry/config-remote-sync-not-in-config/script b/acceptance/bundle/telemetry/config-remote-sync-not-in-config/script new file mode 100644 index 00000000000..eb26f526e2d --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-not-in-config/script @@ -0,0 +1,13 @@ +trace $CLI bundle deploy +job_id="$(read_id.py foo)" +rm "$OUT_REQUESTS" + +# The deployed job's key is renamed locally, so --select-ids resolves to a key +# the configuration no longer has. +update_file.py databricks.yml " foo:" " bar:" + +trace $CLI bundle config-remote-sync --select-ids "jobs:$job_id" -o json + +trace cat "$OUT_REQUESTS" | jq 'select(has("path") and .path == "/telemetry-ext") | .body.protoLogs[] | fromjson | .entry.databricks_cli_log.bundle_config_remote_sync_event | select(. != null) | {error_message, error_category}' + +rm "$OUT_REQUESTS" diff --git a/acceptance/bundle/telemetry/config-remote-sync-not-in-config/test.toml b/acceptance/bundle/telemetry/config-remote-sync-not-in-config/test.toml new file mode 100644 index 00000000000..9f16a3927fe --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-not-in-config/test.toml @@ -0,0 +1,8 @@ +# config-remote-sync is not supported on Windows. +GOOS.windows = false + +# Pinned to the direct engine. The terraform path of config-remote-sync reads +# the experimental yaml-sync state snapshot, which only the cloud test harness +# supports (all terraform config-remote-sync coverage lives in the Cloud=true +# acceptance/bundle/config-remote-sync tests). Direct is the default engine for +# new bundles, and the telemetry is engine-agnostic above the state layer. diff --git a/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt index 2992b05daf1..7a6816be821 100644 --- a/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt +++ b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt @@ -3,6 +3,7 @@ { "status": "failed", "error": "the Terraform deployment engine has been removed in Databricks CLI v1.20.0\n\nRemove the \"bundle.engine\" setting (or set it to \"direct\") to deploy with the direct engine; existing Terraform state is migrated automatically. To keep using Terraform, revert to Databricks CLI v1.19.x.\nSee https://docs.databricks.com/dev-tools/bundles/direct for details", + "errorCode": "BUNDLE_LOAD_FAILED", "files": null, "changes": {} } diff --git a/bundle/configsync/output.go b/bundle/configsync/output.go index ac6e81bd66c..d41bd97da49 100644 --- a/bundle/configsync/output.go +++ b/bundle/configsync/output.go @@ -33,10 +33,13 @@ const ( // DiffOutput represents the complete output of the config-remote-sync command type DiffOutput struct { - Status SyncStatus `json:"status"` - Error string `json:"error,omitempty"` - Files []FileChange `json:"files"` - Changes Changes `json:"changes"` + Status SyncStatus `json:"status"` + Error string `json:"error,omitempty"` + // ErrorCode is the telemetry error category, so callers can branch on the + // failure kind without parsing Error. + ErrorCode string `json:"errorCode,omitempty"` + Files []FileChange `json:"files"` + Changes Changes `json:"changes"` } // WriteResult renders the result and records the error payload on stats for @@ -74,6 +77,7 @@ func WriteResult(out io.Writer, jsonOutput bool, stats *Stats, files []FileChang } if err != nil { output.Error = err.Error() + output.ErrorCode = string(stats.ErrorCategory) } result, marshalErr := json.MarshalIndent(output, "", " ") if marshalErr != nil { diff --git a/bundle/configsync/select.go b/bundle/configsync/select.go index 269150ed8b5..95b68e4fb70 100644 --- a/bundle/configsync/select.go +++ b/bundle/configsync/select.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/databricks/cli/bundle/direct/dstate" + "github.com/databricks/cli/libs/dyn" "github.com/databricks/cli/libs/log" ) @@ -16,13 +17,22 @@ import ( // is drift the caller reports as skipped, the latter is a failure. var ErrNoMatchingSelector = errors.New("no matching selector") -// noMatchingSelectorError keeps the descriptive message while matching -// ErrNoMatchingSelector via errors.Is, so the sentinel text never leaks into it. -type noMatchingSelectorError struct{ msg string } +// ErrResourceNotInConfig marks a selected resource that is deployed but whose +// key is no longer in the configuration (renamed or removed locally since the +// last deploy). Unlike a stale selector this is a failure: the resource still +// exists remotely and its changes have nowhere to be written back. +var ErrResourceNotInConfig = errors.New("resource not found in configuration") + +// selectorError keeps the descriptive message while matching its sentinel via +// errors.Is, so the sentinel text never leaks into it. +type selectorError struct { + msg string + sentinel error +} -func (e *noMatchingSelectorError) Error() string { return e.msg } +func (e *selectorError) Error() string { return e.msg } -func (e *noMatchingSelectorError) Unwrap() error { return ErrNoMatchingSelector } +func (e *selectorError) Unwrap() error { return e.sentinel } // IndexDeployedResources indexes the deployed resources by ":", // mapping to the plan key ("resources.."). Indexing by the @@ -96,8 +106,9 @@ func ResolveResourceSelectors(ctx context.Context, state *dstate.DeploymentState // nothing, so the caller does not report a spurious success. if len(keys) == 0 { resourceType, id, _ := strings.Cut(missing[0], ":") - return nil, &noMatchingSelectorError{ - msg: fmt.Sprintf("no deployed %s resource with id %s; %s", resourceType, id, describeStateIDs(byTypeID, resourceType)), + return nil, &selectorError{ + msg: fmt.Sprintf("no deployed %s resource with id %s; %s", resourceType, id, describeStateIDs(byTypeID, resourceType)), + sentinel: ErrNoMatchingSelector, } } @@ -109,6 +120,26 @@ func ResolveResourceSelectors(ctx context.Context, state *dstate.DeploymentState return keys, nil } +// CheckSelectedInConfig fails with ErrResourceNotInConfig when a selected plan +// key is absent from the configuration. Such a resource is planned as a delete, +// which carries no changes, so without this check the run would report a +// success while its remote edits were silently dropped. +func CheckSelectedInConfig(root dyn.Value, selected []string) error { + var missing []string + for _, key := range selected { + if _, err := dyn.Get(root, key); err != nil { + missing = append(missing, key) + } + } + if len(missing) == 0 { + return nil + } + return &selectorError{ + msg: fmt.Sprintf("deployed resource %s not found in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", strings.Join(missing, ", ")), + sentinel: ErrResourceNotInConfig, + } +} + // maxReportedIDs bounds how many ids describeStateIDs lists, so the message stays // readable for bundles with many resources of one type, and fits the limit // telemetry.ScrubErrorMessage truncates error_message to. diff --git a/bundle/configsync/select_test.go b/bundle/configsync/select_test.go index 8fb1c787050..fc1292099d5 100644 --- a/bundle/configsync/select_test.go +++ b/bundle/configsync/select_test.go @@ -6,6 +6,7 @@ import ( "slices" "testing" + "github.com/databricks/cli/libs/dyn" "github.com/stretchr/testify/assert" ) @@ -94,6 +95,24 @@ func TestFilterChanges(t *testing.T) { assert.Len(t, changes, 7) } +func TestCheckSelectedInConfig(t *testing.T) { + root := dyn.V(map[string]dyn.Value{ + "resources": dyn.V(map[string]dyn.Value{ + "jobs": dyn.V(map[string]dyn.Value{ + "foo": dyn.V(map[string]dyn.Value{"name": dyn.V("foo")}), + }), + }), + }) + + assert.NoError(t, CheckSelectedInConfig(root, []string{"resources.jobs.foo"})) + assert.NoError(t, CheckSelectedInConfig(root, nil)) + + err := CheckSelectedInConfig(root, []string{"resources.jobs.foo", "resources.jobs.bar", "resources.pipelines.baz"}) + assert.ErrorIs(t, err, ErrResourceNotInConfig) + assert.NotErrorIs(t, err, ErrNoMatchingSelector) + assert.Equal(t, "deployed resource resources.jobs.bar, resources.pipelines.baz not found in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", err.Error()) +} + func TestDescribeStateIDs(t *testing.T) { tests := []struct { name string diff --git a/cmd/bundle/config_remote_sync.go b/cmd/bundle/config_remote_sync.go index 51f418f9180..de93fc027d7 100644 --- a/cmd/bundle/config_remote_sync.go +++ b/cmd/bundle/config_remote_sync.go @@ -121,6 +121,10 @@ Examples: if err != nil { return err } + if err := configsync.CheckSelectedInConfig(b.Config.Value(), selected); err != nil { + stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryResourceNotInConfig + return err + } detected = configsync.FilterChanges(detected, selected) } diff --git a/libs/telemetry/protos/bundle_config_remote_sync.go b/libs/telemetry/protos/bundle_config_remote_sync.go index 40ff54ddd98..c6feadf798d 100644 --- a/libs/telemetry/protos/bundle_config_remote_sync.go +++ b/libs/telemetry/protos/bundle_config_remote_sync.go @@ -11,6 +11,7 @@ const ( BundleConfigRemoteSyncErrorCategoryYamlApplyFailed BundleConfigRemoteSyncErrorCategory = "YAML_APPLY_FAILED" BundleConfigRemoteSyncErrorCategorySaveFailed BundleConfigRemoteSyncErrorCategory = "SAVE_FAILED" BundleConfigRemoteSyncErrorCategoryOutputFailed BundleConfigRemoteSyncErrorCategory = "OUTPUT_FAILED" + BundleConfigRemoteSyncErrorCategoryResourceNotInConfig BundleConfigRemoteSyncErrorCategory = "RESOURCE_NOT_IN_CONFIG" ) // BundleConfigRemoteSyncEvent is emitted on every execution of the From 6b5f6dad4f3d86b495a6a6f130c1ff591853c809 Mon Sep 17 00:00:00 2001 From: Ilya Kuznetsov Date: Mon, 5 Oct 2026 16:45:45 +0000 Subject: [PATCH 2/4] Simplify per review: drop sentinel and extra tests, only emit classified error codes Co-authored-by: Isaac --- .../json_status_missing_state/output.txt | 1 - .../json_status_selectors/output.txt | 2 - .../select_not_in_config/databricks.yml.tmpl | 19 -------- .../select_not_in_config/out.test.toml | 3 -- .../select_not_in_config/output.txt | 28 ------------ .../select_not_in_config/script | 30 ------------- .../select_not_in_config/test.toml | 6 --- .../output.txt | 4 +- .../config-remote-sync-not-in-config/script | 1 - .../test.toml | 6 --- .../output.txt | 1 - bundle/configsync/output.go | 7 +-- bundle/configsync/select.go | 44 ++++++------------- bundle/configsync/select_test.go | 19 -------- 14 files changed, 20 insertions(+), 151 deletions(-) delete mode 100644 acceptance/bundle/config-remote-sync/select_not_in_config/databricks.yml.tmpl delete mode 100644 acceptance/bundle/config-remote-sync/select_not_in_config/out.test.toml delete mode 100644 acceptance/bundle/config-remote-sync/select_not_in_config/output.txt delete mode 100644 acceptance/bundle/config-remote-sync/select_not_in_config/script delete mode 100644 acceptance/bundle/config-remote-sync/select_not_in_config/test.toml diff --git a/acceptance/bundle/config-remote-sync/json_status_missing_state/output.txt b/acceptance/bundle/config-remote-sync/json_status_missing_state/output.txt index 52a70fca957..bce70b3874a 100644 --- a/acceptance/bundle/config-remote-sync/json_status_missing_state/output.txt +++ b/acceptance/bundle/config-remote-sync/json_status_missing_state/output.txt @@ -3,7 +3,6 @@ { "status": "failed", "error": "the Terraform deployment engine has been removed in Databricks CLI v1.20.0\n\nRemove the \"bundle.engine\" setting (or set it to \"direct\") to deploy with the direct engine; existing Terraform state is migrated automatically. To keep using Terraform, revert to Databricks CLI v1.19.x.\nSee https://docs.databricks.com/dev-tools/bundles/direct for details", - "errorCode": "BUNDLE_LOAD_FAILED", "files": null, "changes": {} } diff --git a/acceptance/bundle/config-remote-sync/json_status_selectors/output.txt b/acceptance/bundle/config-remote-sync/json_status_selectors/output.txt index 972383d49f6..2059b95c814 100644 --- a/acceptance/bundle/config-remote-sync/json_status_selectors/output.txt +++ b/acceptance/bundle/config-remote-sync/json_status_selectors/output.txt @@ -7,7 +7,6 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged { "status": "skipped", "error": "no deployed jobs resource with id no-such-id-999; deployed jobs ids in state: [TEST_JOB_ID]", - "errorCode": "BUNDLE_LOAD_FAILED", "files": null, "changes": {} } @@ -16,7 +15,6 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged { "status": "failed", "error": "invalid --select-ids value \"no-such-id-999\", expected \u003ctype\u003e:\u003cid\u003e (e.g. jobs:[NUMID])", - "errorCode": "BUNDLE_LOAD_FAILED", "files": null, "changes": {} } diff --git a/acceptance/bundle/config-remote-sync/select_not_in_config/databricks.yml.tmpl b/acceptance/bundle/config-remote-sync/select_not_in_config/databricks.yml.tmpl deleted file mode 100644 index 16d5646d970..00000000000 --- a/acceptance/bundle/config-remote-sync/select_not_in_config/databricks.yml.tmpl +++ /dev/null @@ -1,19 +0,0 @@ -bundle: - name: test-bundle-$UNIQUE_NAME - -resources: - jobs: - test_job: - max_concurrent_runs: 1 - tasks: - - task_key: main - notebook_task: - notebook_path: /Users/{{workspace_user_name}}/notebook - new_cluster: - spark_version: $DEFAULT_SPARK_VERSION - node_type_id: $NODE_TYPE_ID - num_workers: 1 - -targets: - default: - mode: development diff --git a/acceptance/bundle/config-remote-sync/select_not_in_config/out.test.toml b/acceptance/bundle/config-remote-sync/select_not_in_config/out.test.toml deleted file mode 100644 index a4f8dd5ef74..00000000000 --- a/acceptance/bundle/config-remote-sync/select_not_in_config/out.test.toml +++ /dev/null @@ -1,3 +0,0 @@ -Cloud = true -GOOS.windows = false -EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/config-remote-sync/select_not_in_config/output.txt b/acceptance/bundle/config-remote-sync/select_not_in_config/output.txt deleted file mode 100644 index 3e0b3a59b57..00000000000 --- a/acceptance/bundle/config-remote-sync/select_not_in_config/output.txt +++ /dev/null @@ -1,28 +0,0 @@ -Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... -Created jobs.test_job -Files: 2 uploaded, 0 deleted -Resources: 1 created, 0 changed, 0 deleted, 0 unchanged - -=== Rename the job key locally without deploying - -=== Selecting the deployed job fails with RESOURCE_NOT_IN_CONFIG, exit 0 -{ - "status": "failed", - "error": "deployed resource resources.jobs.test_job not found in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", - "errorCode": "RESOURCE_NOT_IN_CONFIG", - "files": null, - "changes": {} -} - -=== Text mode fails the command -Error: deployed resource resources.jobs.test_job not found in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced - -Exit code: 1 - ->>> [CLI] bundle destroy --auto-approve -The following resources will be deleted: - delete resources.jobs.test_job - -All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default - -Destroy: 1 deleted diff --git a/acceptance/bundle/config-remote-sync/select_not_in_config/script b/acceptance/bundle/config-remote-sync/select_not_in_config/script deleted file mode 100644 index cea47d2da94..00000000000 --- a/acceptance/bundle/config-remote-sync/select_not_in_config/script +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash - -envsubst < databricks.yml.tmpl > databricks.yml - -cleanup() { - trace $CLI bundle destroy --auto-approve -} -trap cleanup EXIT - -$CLI bundle deploy -job_id="$(read_id.py test_job)" - -edit_resource.py jobs $job_id <>> [CLI] bundle config-remote-sync --select-ids jobs:[FOO_ID] -o json { "status": "failed", - "error": "deployed resource resources.jobs.foo not found in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", + "error": "deployed resource resources.jobs.foo is not in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", "errorCode": "RESOURCE_NOT_IN_CONFIG", "files": null, "changes": {} @@ -16,6 +16,6 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged >>> cat [OUTPUT_DIR]/out.requests.txt { - "error_message": "deployed resource resources.jobs.foo not found in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", + "error_message": "deployed resource resources.jobs.foo is not in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", "error_category": "RESOURCE_NOT_IN_CONFIG" } diff --git a/acceptance/bundle/telemetry/config-remote-sync-not-in-config/script b/acceptance/bundle/telemetry/config-remote-sync-not-in-config/script index eb26f526e2d..b02638387d4 100644 --- a/acceptance/bundle/telemetry/config-remote-sync-not-in-config/script +++ b/acceptance/bundle/telemetry/config-remote-sync-not-in-config/script @@ -1,6 +1,5 @@ trace $CLI bundle deploy job_id="$(read_id.py foo)" -rm "$OUT_REQUESTS" # The deployed job's key is renamed locally, so --select-ids resolves to a key # the configuration no longer has. diff --git a/acceptance/bundle/telemetry/config-remote-sync-not-in-config/test.toml b/acceptance/bundle/telemetry/config-remote-sync-not-in-config/test.toml index 9f16a3927fe..97b2a8e4d49 100644 --- a/acceptance/bundle/telemetry/config-remote-sync-not-in-config/test.toml +++ b/acceptance/bundle/telemetry/config-remote-sync-not-in-config/test.toml @@ -1,8 +1,2 @@ # config-remote-sync is not supported on Windows. GOOS.windows = false - -# Pinned to the direct engine. The terraform path of config-remote-sync reads -# the experimental yaml-sync state snapshot, which only the cloud test harness -# supports (all terraform config-remote-sync coverage lives in the Cloud=true -# acceptance/bundle/config-remote-sync tests). Direct is the default engine for -# new bundles, and the telemetry is engine-agnostic above the state layer. diff --git a/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt index 7a6816be821..2992b05daf1 100644 --- a/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt +++ b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt @@ -3,7 +3,6 @@ { "status": "failed", "error": "the Terraform deployment engine has been removed in Databricks CLI v1.20.0\n\nRemove the \"bundle.engine\" setting (or set it to \"direct\") to deploy with the direct engine; existing Terraform state is migrated automatically. To keep using Terraform, revert to Databricks CLI v1.19.x.\nSee https://docs.databricks.com/dev-tools/bundles/direct for details", - "errorCode": "BUNDLE_LOAD_FAILED", "files": null, "changes": {} } diff --git a/bundle/configsync/output.go b/bundle/configsync/output.go index d41bd97da49..41f5f36632f 100644 --- a/bundle/configsync/output.go +++ b/bundle/configsync/output.go @@ -35,8 +35,8 @@ const ( type DiffOutput struct { Status SyncStatus `json:"status"` Error string `json:"error,omitempty"` - // ErrorCode is the telemetry error category, so callers can branch on the - // failure kind without parsing Error. + // ErrorCode is the telemetry error category, set only when the failure was + // explicitly classified. ErrorCode string `json:"errorCode,omitempty"` Files []FileChange `json:"files"` Changes Changes `json:"changes"` @@ -48,6 +48,7 @@ type DiffOutput struct { // returns the error so the CLI still fails loudly for humans. func WriteResult(out io.Writer, jsonOutput bool, stats *Stats, files []FileChange, changes Changes, err error) error { status := StatusSuccess + errorCode := stats.ErrorCategory if err != nil { if stats.ErrorCategory == "" { stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryBundleLoadFailed @@ -77,7 +78,7 @@ func WriteResult(out io.Writer, jsonOutput bool, stats *Stats, files []FileChang } if err != nil { output.Error = err.Error() - output.ErrorCode = string(stats.ErrorCategory) + output.ErrorCode = string(errorCode) } result, marshalErr := json.MarshalIndent(output, "", " ") if marshalErr != nil { diff --git a/bundle/configsync/select.go b/bundle/configsync/select.go index 95b68e4fb70..b071d9d06ac 100644 --- a/bundle/configsync/select.go +++ b/bundle/configsync/select.go @@ -17,22 +17,13 @@ import ( // is drift the caller reports as skipped, the latter is a failure. var ErrNoMatchingSelector = errors.New("no matching selector") -// ErrResourceNotInConfig marks a selected resource that is deployed but whose -// key is no longer in the configuration (renamed or removed locally since the -// last deploy). Unlike a stale selector this is a failure: the resource still -// exists remotely and its changes have nowhere to be written back. -var ErrResourceNotInConfig = errors.New("resource not found in configuration") - -// selectorError keeps the descriptive message while matching its sentinel via -// errors.Is, so the sentinel text never leaks into it. -type selectorError struct { - msg string - sentinel error -} +// noMatchingSelectorError keeps the descriptive message while matching +// ErrNoMatchingSelector via errors.Is, so the sentinel text never leaks into it. +type noMatchingSelectorError struct{ msg string } -func (e *selectorError) Error() string { return e.msg } +func (e *noMatchingSelectorError) Error() string { return e.msg } -func (e *selectorError) Unwrap() error { return e.sentinel } +func (e *noMatchingSelectorError) Unwrap() error { return ErrNoMatchingSelector } // IndexDeployedResources indexes the deployed resources by ":", // mapping to the plan key ("resources.."). Indexing by the @@ -106,9 +97,8 @@ func ResolveResourceSelectors(ctx context.Context, state *dstate.DeploymentState // nothing, so the caller does not report a spurious success. if len(keys) == 0 { resourceType, id, _ := strings.Cut(missing[0], ":") - return nil, &selectorError{ - msg: fmt.Sprintf("no deployed %s resource with id %s; %s", resourceType, id, describeStateIDs(byTypeID, resourceType)), - sentinel: ErrNoMatchingSelector, + return nil, &noMatchingSelectorError{ + msg: fmt.Sprintf("no deployed %s resource with id %s; %s", resourceType, id, describeStateIDs(byTypeID, resourceType)), } } @@ -120,24 +110,18 @@ func ResolveResourceSelectors(ctx context.Context, state *dstate.DeploymentState return keys, nil } -// CheckSelectedInConfig fails with ErrResourceNotInConfig when a selected plan -// key is absent from the configuration. Such a resource is planned as a delete, -// which carries no changes, so without this check the run would report a -// success while its remote edits were silently dropped. +// CheckSelectedInConfig fails when a selected resource is deployed but its key +// is no longer in the configuration. Such a resource is planned as a delete with +// no changes, so the run would otherwise report success and drop its remote +// edits. Unlike a stale selector this fails the whole batch: the edits still +// exist remotely and have nowhere to be written back. func CheckSelectedInConfig(root dyn.Value, selected []string) error { - var missing []string for _, key := range selected { if _, err := dyn.Get(root, key); err != nil { - missing = append(missing, key) + return fmt.Errorf("deployed resource %s is not in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", key) } } - if len(missing) == 0 { - return nil - } - return &selectorError{ - msg: fmt.Sprintf("deployed resource %s not found in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", strings.Join(missing, ", ")), - sentinel: ErrResourceNotInConfig, - } + return nil } // maxReportedIDs bounds how many ids describeStateIDs lists, so the message stays diff --git a/bundle/configsync/select_test.go b/bundle/configsync/select_test.go index fc1292099d5..8fb1c787050 100644 --- a/bundle/configsync/select_test.go +++ b/bundle/configsync/select_test.go @@ -6,7 +6,6 @@ import ( "slices" "testing" - "github.com/databricks/cli/libs/dyn" "github.com/stretchr/testify/assert" ) @@ -95,24 +94,6 @@ func TestFilterChanges(t *testing.T) { assert.Len(t, changes, 7) } -func TestCheckSelectedInConfig(t *testing.T) { - root := dyn.V(map[string]dyn.Value{ - "resources": dyn.V(map[string]dyn.Value{ - "jobs": dyn.V(map[string]dyn.Value{ - "foo": dyn.V(map[string]dyn.Value{"name": dyn.V("foo")}), - }), - }), - }) - - assert.NoError(t, CheckSelectedInConfig(root, []string{"resources.jobs.foo"})) - assert.NoError(t, CheckSelectedInConfig(root, nil)) - - err := CheckSelectedInConfig(root, []string{"resources.jobs.foo", "resources.jobs.bar", "resources.pipelines.baz"}) - assert.ErrorIs(t, err, ErrResourceNotInConfig) - assert.NotErrorIs(t, err, ErrNoMatchingSelector) - assert.Equal(t, "deployed resource resources.jobs.bar, resources.pipelines.baz not found in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", err.Error()) -} - func TestDescribeStateIDs(t *testing.T) { tests := []struct { name string From aae3b7cf993c0cbf7c5ae8526c84e0f63fb918b0 Mon Sep 17 00:00:00 2001 From: Ilya Kuznetsov Date: Mon, 5 Oct 2026 19:11:37 +0000 Subject: [PATCH 3/4] List all missing resources and trim comments Co-authored-by: Isaac --- .../config-remote-sync-not-in-config/output.txt | 4 ++-- bundle/configsync/output.go | 15 +++++++-------- bundle/configsync/select.go | 13 +++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/acceptance/bundle/telemetry/config-remote-sync-not-in-config/output.txt b/acceptance/bundle/telemetry/config-remote-sync-not-in-config/output.txt index 82651773136..66f437595af 100644 --- a/acceptance/bundle/telemetry/config-remote-sync-not-in-config/output.txt +++ b/acceptance/bundle/telemetry/config-remote-sync-not-in-config/output.txt @@ -8,7 +8,7 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged >>> [CLI] bundle config-remote-sync --select-ids jobs:[FOO_ID] -o json { "status": "failed", - "error": "deployed resource resources.jobs.foo is not in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", + "error": "deployed resources missing from the bundle configuration (renamed or removed since the last deploy): resources.jobs.foo", "errorCode": "RESOURCE_NOT_IN_CONFIG", "files": null, "changes": {} @@ -16,6 +16,6 @@ Resources: 1 created, 0 changed, 0 deleted, 0 unchanged >>> cat [OUTPUT_DIR]/out.requests.txt { - "error_message": "deployed resource resources.jobs.foo is not in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", + "error_message": "deployed resources missing from the bundle configuration (renamed or removed since the last deploy): resources.jobs.foo", "error_category": "RESOURCE_NOT_IN_CONFIG" } diff --git a/bundle/configsync/output.go b/bundle/configsync/output.go index 41f5f36632f..52fbeb4ab40 100644 --- a/bundle/configsync/output.go +++ b/bundle/configsync/output.go @@ -33,13 +33,11 @@ const ( // DiffOutput represents the complete output of the config-remote-sync command type DiffOutput struct { - Status SyncStatus `json:"status"` - Error string `json:"error,omitempty"` - // ErrorCode is the telemetry error category, set only when the failure was - // explicitly classified. - ErrorCode string `json:"errorCode,omitempty"` - Files []FileChange `json:"files"` - Changes Changes `json:"changes"` + Status SyncStatus `json:"status"` + Error string `json:"error,omitempty"` + ErrorCode protos.BundleConfigRemoteSyncErrorCategory `json:"errorCode,omitempty"` + Files []FileChange `json:"files"` + Changes Changes `json:"changes"` } // WriteResult renders the result and records the error payload on stats for @@ -48,6 +46,7 @@ type DiffOutput struct { // returns the error so the CLI still fails loudly for humans. func WriteResult(out io.Writer, jsonOutput bool, stats *Stats, files []FileChange, changes Changes, err error) error { status := StatusSuccess + // Read before the fallback below so unclassified errors carry no code. errorCode := stats.ErrorCategory if err != nil { if stats.ErrorCategory == "" { @@ -78,7 +77,7 @@ func WriteResult(out io.Writer, jsonOutput bool, stats *Stats, files []FileChang } if err != nil { output.Error = err.Error() - output.ErrorCode = string(errorCode) + output.ErrorCode = errorCode } result, marshalErr := json.MarshalIndent(output, "", " ") if marshalErr != nil { diff --git a/bundle/configsync/select.go b/bundle/configsync/select.go index b071d9d06ac..99898eb5109 100644 --- a/bundle/configsync/select.go +++ b/bundle/configsync/select.go @@ -110,17 +110,18 @@ func ResolveResourceSelectors(ctx context.Context, state *dstate.DeploymentState return keys, nil } -// CheckSelectedInConfig fails when a selected resource is deployed but its key -// is no longer in the configuration. Such a resource is planned as a delete with -// no changes, so the run would otherwise report success and drop its remote -// edits. Unlike a stale selector this fails the whole batch: the edits still -// exist remotely and have nowhere to be written back. +// CheckSelectedInConfig fails when selected resources are deployed but no longer +// in the configuration. Unlike a stale selector, this fails the whole batch. func CheckSelectedInConfig(root dyn.Value, selected []string) error { + var missing []string for _, key := range selected { if _, err := dyn.Get(root, key); err != nil { - return fmt.Errorf("deployed resource %s is not in the bundle configuration; it was renamed or removed since the last deploy, so its remote changes cannot be synced", key) + missing = append(missing, key) } } + if len(missing) > 0 { + return fmt.Errorf("deployed resources missing from the bundle configuration (renamed or removed since the last deploy): %s", strings.Join(missing, ", ")) + } return nil } From 3609192378d10f9b106071f37c90163295250b85 Mon Sep 17 00:00:00 2001 From: Ilya Kuznetsov Date: Tue, 6 Oct 2026 10:43:13 +0000 Subject: [PATCH 4/4] Check the plan instead of config paths for missing resources Co-authored-by: Isaac --- .../config-remote-sync-not-in-config/script | 3 +-- bundle/configsync/select.go | 16 ---------------- cmd/bundle/config_remote_sync.go | 14 ++++++++++++-- .../protos/bundle_config_remote_sync.go | 2 +- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/acceptance/bundle/telemetry/config-remote-sync-not-in-config/script b/acceptance/bundle/telemetry/config-remote-sync-not-in-config/script index b02638387d4..cfb98fa135a 100644 --- a/acceptance/bundle/telemetry/config-remote-sync-not-in-config/script +++ b/acceptance/bundle/telemetry/config-remote-sync-not-in-config/script @@ -1,8 +1,7 @@ trace $CLI bundle deploy job_id="$(read_id.py foo)" -# The deployed job's key is renamed locally, so --select-ids resolves to a key -# the configuration no longer has. +# The deployed job's key is renamed locally, so the config no longer has it. update_file.py databricks.yml " foo:" " bar:" trace $CLI bundle config-remote-sync --select-ids "jobs:$job_id" -o json diff --git a/bundle/configsync/select.go b/bundle/configsync/select.go index 99898eb5109..269150ed8b5 100644 --- a/bundle/configsync/select.go +++ b/bundle/configsync/select.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/databricks/cli/bundle/direct/dstate" - "github.com/databricks/cli/libs/dyn" "github.com/databricks/cli/libs/log" ) @@ -110,21 +109,6 @@ func ResolveResourceSelectors(ctx context.Context, state *dstate.DeploymentState return keys, nil } -// CheckSelectedInConfig fails when selected resources are deployed but no longer -// in the configuration. Unlike a stale selector, this fails the whole batch. -func CheckSelectedInConfig(root dyn.Value, selected []string) error { - var missing []string - for _, key := range selected { - if _, err := dyn.Get(root, key); err != nil { - missing = append(missing, key) - } - } - if len(missing) > 0 { - return fmt.Errorf("deployed resources missing from the bundle configuration (renamed or removed since the last deploy): %s", strings.Join(missing, ", ")) - } - return nil -} - // maxReportedIDs bounds how many ids describeStateIDs lists, so the message stays // readable for bundles with many resources of one type, and fits the limit // telemetry.ScrubErrorMessage truncates error_message to. diff --git a/cmd/bundle/config_remote_sync.go b/cmd/bundle/config_remote_sync.go index de93fc027d7..b49750ea5d7 100644 --- a/cmd/bundle/config_remote_sync.go +++ b/cmd/bundle/config_remote_sync.go @@ -7,9 +7,11 @@ import ( "maps" "runtime" "slices" + "strings" "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/configsync" + "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/cmd/bundle/utils" "github.com/databricks/cli/cmd/root" @@ -121,9 +123,17 @@ Examples: if err != nil { return err } - if err := configsync.CheckSelectedInConfig(b.Config.Value(), selected); err != nil { + // A selected resource planned as a delete was renamed or removed in the + // config, so its remote changes have nowhere to be written back. + var missing []string + for _, key := range selected { + if plan.Plan[key].Action == deployplan.Delete { + missing = append(missing, key) + } + } + if len(missing) > 0 { stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryResourceNotInConfig - return err + return fmt.Errorf("deployed resources missing from the bundle configuration (renamed or removed since the last deploy): %s", strings.Join(missing, ", ")) } detected = configsync.FilterChanges(detected, selected) } diff --git a/libs/telemetry/protos/bundle_config_remote_sync.go b/libs/telemetry/protos/bundle_config_remote_sync.go index c6feadf798d..678198514bc 100644 --- a/libs/telemetry/protos/bundle_config_remote_sync.go +++ b/libs/telemetry/protos/bundle_config_remote_sync.go @@ -19,7 +19,7 @@ const ( // // All fields are aggregate counts, booleans, or system-defined categories. // No resource names, keys, field paths, file paths, or configuration values -// are logged. +// are logged outside ErrorMessage. type BundleConfigRemoteSyncEvent struct { // Whether the command was invoked with --save (config files written to // disk) as opposed to diff-only mode.