From 10ee51d413d2e713c5de38fddc6510a06009a336 Mon Sep 17 00:00:00 2001 From: Ilya Kuznetsov Date: Tue, 25 Aug 2026 23:05:10 +0000 Subject: [PATCH 1/2] config-remote-sync: report skipped/failed status in JSON output and exit gracefully Co-authored-by: Isaac --- .../databricks.yml.tmpl | 19 ++++++ .../json_status_missing_state/out.test.toml | 3 + .../json_status_missing_state/output.txt | 8 +++ .../json_status_missing_state/script | 9 +++ .../json_status_missing_state/test.toml | 11 ++++ .../json_status_selectors/databricks.yml.tmpl | 19 ++++++ .../json_status_selectors/out.test.toml | 3 + .../json_status_selectors/output.txt | 28 +++++++++ .../json_status_selectors/script | 23 +++++++ .../json_status_selectors/test.toml | 8 +++ .../config-remote-sync/output_json/output.txt | 1 + .../output_no_changes/output.txt | 1 + .../databricks.yml | 11 ++++ .../out.test.toml | 3 + .../output.txt | 17 +++++ .../config-remote-sync-skipped-state/script | 8 +++ .../test.toml | 7 +++ bundle/configsync/output.go | 63 +++++++++++++++++++ bundle/configsync/select.go | 18 +++++- cmd/bundle/config_remote_sync.go | 54 ++++++---------- 20 files changed, 278 insertions(+), 36 deletions(-) create mode 100644 acceptance/bundle/config-remote-sync/json_status_missing_state/databricks.yml.tmpl create mode 100644 acceptance/bundle/config-remote-sync/json_status_missing_state/out.test.toml create mode 100644 acceptance/bundle/config-remote-sync/json_status_missing_state/output.txt create mode 100755 acceptance/bundle/config-remote-sync/json_status_missing_state/script create mode 100644 acceptance/bundle/config-remote-sync/json_status_missing_state/test.toml create mode 100644 acceptance/bundle/config-remote-sync/json_status_selectors/databricks.yml.tmpl create mode 100644 acceptance/bundle/config-remote-sync/json_status_selectors/out.test.toml create mode 100644 acceptance/bundle/config-remote-sync/json_status_selectors/output.txt create mode 100755 acceptance/bundle/config-remote-sync/json_status_selectors/script create mode 100644 acceptance/bundle/config-remote-sync/json_status_selectors/test.toml create mode 100644 acceptance/bundle/telemetry/config-remote-sync-skipped-state/databricks.yml create mode 100644 acceptance/bundle/telemetry/config-remote-sync-skipped-state/out.test.toml create mode 100644 acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt create mode 100644 acceptance/bundle/telemetry/config-remote-sync-skipped-state/script create mode 100644 acceptance/bundle/telemetry/config-remote-sync-skipped-state/test.toml diff --git a/acceptance/bundle/config-remote-sync/json_status_missing_state/databricks.yml.tmpl b/acceptance/bundle/config-remote-sync/json_status_missing_state/databricks.yml.tmpl new file mode 100644 index 00000000000..16d5646d970 --- /dev/null +++ b/acceptance/bundle/config-remote-sync/json_status_missing_state/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/json_status_missing_state/out.test.toml b/acceptance/bundle/config-remote-sync/json_status_missing_state/out.test.toml new file mode 100644 index 00000000000..74cedab4513 --- /dev/null +++ b/acceptance/bundle/config-remote-sync/json_status_missing_state/out.test.toml @@ -0,0 +1,3 @@ +Cloud = true +GOOS.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform"] 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 new file mode 100644 index 00000000000..05c076a1f71 --- /dev/null +++ b/acceptance/bundle/config-remote-sync/json_status_missing_state/output.txt @@ -0,0 +1,8 @@ + +=== Missing tf state is reported as skipped, exit 0 +{ + "status": "skipped", + "error": "state snapshot not available: resources state snapshot not found remotely at resources-config-sync-snapshot.json: state snapshot not found", + "files": null, + "changes": null +} diff --git a/acceptance/bundle/config-remote-sync/json_status_missing_state/script b/acceptance/bundle/config-remote-sync/json_status_missing_state/script new file mode 100755 index 00000000000..00baec1b20e --- /dev/null +++ b/acceptance/bundle/config-remote-sync/json_status_missing_state/script @@ -0,0 +1,9 @@ +#!/bin/bash + +envsubst < databricks.yml.tmpl > databricks.yml + +# No deploy, so the terraform state snapshot does not exist: in JSON mode the +# sync is reported as skipped with exit 0, not a hard failure. +title "Missing tf state is reported as skipped, exit 0" +echo +errcode $CLI bundle config-remote-sync -o json diff --git a/acceptance/bundle/config-remote-sync/json_status_missing_state/test.toml b/acceptance/bundle/config-remote-sync/json_status_missing_state/test.toml new file mode 100644 index 00000000000..3c4907c9d8e --- /dev/null +++ b/acceptance/bundle/config-remote-sync/json_status_missing_state/test.toml @@ -0,0 +1,11 @@ +Cloud = true + +RecordRequests = false +Ignore = [".databricks", "dummy.whl", "databricks.yml"] + +Env.DATABRICKS_BUNDLE_ENABLE_EXPERIMENTAL_YAML_SYNC = "true" + +# "Missing tf state" is terraform-specific: the direct engine has no separate +# state snapshot to be absent, so ErrStateSnapshotNotFound (and the skipped +# outcome it drives) only arises on terraform. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform"] diff --git a/acceptance/bundle/config-remote-sync/json_status_selectors/databricks.yml.tmpl b/acceptance/bundle/config-remote-sync/json_status_selectors/databricks.yml.tmpl new file mode 100644 index 00000000000..16d5646d970 --- /dev/null +++ b/acceptance/bundle/config-remote-sync/json_status_selectors/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/json_status_selectors/out.test.toml b/acceptance/bundle/config-remote-sync/json_status_selectors/out.test.toml new file mode 100644 index 00000000000..f8e8fe2844e --- /dev/null +++ b/acceptance/bundle/config-remote-sync/json_status_selectors/out.test.toml @@ -0,0 +1,3 @@ +Cloud = true +GOOS.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/config-remote-sync/json_status_selectors/output.txt b/acceptance/bundle/config-remote-sync/json_status_selectors/output.txt new file mode 100644 index 00000000000..fb27765a7f3 --- /dev/null +++ b/acceptance/bundle/config-remote-sync/json_status_selectors/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: 6 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged + +=== A selector that matches nothing is reported as skipped, exit 0 +{ + "status": "skipped", + "error": "no deployed jobs resource with id no-such-id-999; deployed jobs ids in state: [TEST_JOB_ID]", + "files": null, + "changes": null +} + +=== A malformed selector is reported as failed, exit 0 +{ + "status": "failed", + "error": "invalid --select-ids value \"no-such-id-999\", expected \u003ctype\u003e:\u003cid\u003e (e.g. jobs:[NUMID])", + "files": null, + "changes": null +} + +>>> [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/json_status_selectors/script b/acceptance/bundle/config-remote-sync/json_status_selectors/script new file mode 100755 index 00000000000..a375fd79213 --- /dev/null +++ b/acceptance/bundle/config-remote-sync/json_status_selectors/script @@ -0,0 +1,23 @@ +#!/bin/bash + +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +touch dummy.whl +$CLI bundle deploy +job_id="$(read_id.py test_job)" + +# In JSON mode the command never fails the process: the outcome is carried in +# "status" so the workspace caller can tell a benign no-op apart from an error. + +title "A selector that matches nothing is reported as skipped, exit 0" +echo +errcode $CLI bundle config-remote-sync --select-ids "jobs:no-such-id-999" -o json + +title "A malformed selector is reported as failed, exit 0" +echo +errcode $CLI bundle config-remote-sync --select-ids "no-such-id-999" -o json diff --git a/acceptance/bundle/config-remote-sync/json_status_selectors/test.toml b/acceptance/bundle/config-remote-sync/json_status_selectors/test.toml new file mode 100644 index 00000000000..b721843242f --- /dev/null +++ b/acceptance/bundle/config-remote-sync/json_status_selectors/test.toml @@ -0,0 +1,8 @@ +Cloud = true + +RecordRequests = false +Ignore = [".databricks", "dummy.whl", "databricks.yml"] + +Env.DATABRICKS_BUNDLE_ENABLE_EXPERIMENTAL_YAML_SYNC = "true" + +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"] diff --git a/acceptance/bundle/config-remote-sync/output_json/output.txt b/acceptance/bundle/config-remote-sync/output_json/output.txt index 32aa28536e7..41522af37e3 100644 --- a/acceptance/bundle/config-remote-sync/output_json/output.txt +++ b/acceptance/bundle/config-remote-sync/output_json/output.txt @@ -4,6 +4,7 @@ Files: 6 uploaded, 0 deleted Resources: 1 created, 0 changed, 0 deleted, 0 unchanged === JSON output format{ + "status": "success", "files": [ { "path": "[TEST_TMP_DIR]/databricks.yml", diff --git a/acceptance/bundle/config-remote-sync/output_no_changes/output.txt b/acceptance/bundle/config-remote-sync/output_no_changes/output.txt index 684b3f1bc4a..5b479cba596 100644 --- a/acceptance/bundle/config-remote-sync/output_no_changes/output.txt +++ b/acceptance/bundle/config-remote-sync/output_no_changes/output.txt @@ -13,6 +13,7 @@ No changes detected. === JSON output { + "status": "success", "files": null, "changes": {} } diff --git a/acceptance/bundle/telemetry/config-remote-sync-skipped-state/databricks.yml b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/databricks.yml new file mode 100644 index 00000000000..5d666546ce2 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/databricks.yml @@ -0,0 +1,11 @@ +bundle: + name: config-remote-sync-skipped-state + +resources: + jobs: + foo: + name: test job + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/Users/tester@databricks.com/notebook diff --git a/acceptance/bundle/telemetry/config-remote-sync-skipped-state/out.test.toml b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/out.test.toml new file mode 100644 index 00000000000..14bca3c36ed --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/out.test.toml @@ -0,0 +1,3 @@ +Cloud = false +GOOS.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform"] diff --git a/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt new file mode 100644 index 00000000000..26f85e37a5e --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt @@ -0,0 +1,17 @@ + +>>> errcode [CLI] bundle config-remote-sync -o json +{ + "status": "skipped", + "error": "state snapshot not available: resources state snapshot not found remotely at resources-config-sync-snapshot.json: state snapshot not found", + "files": null, + "changes": null +} + +>>> cat out.requests.txt +{ + "engine": "terraform", + "state_source": "local", + "states_available_count": 0, + "error_message": "state snapshot not available: resources state snapshot not found remotely at resources-config-sync-snapshot.json: state snapshot not found", + "error_category": "STATE_NOT_FOUND" +} diff --git a/acceptance/bundle/telemetry/config-remote-sync-skipped-state/script b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/script new file mode 100644 index 00000000000..5b87bc029ec --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/script @@ -0,0 +1,8 @@ +# Running config-remote-sync -o json without a prior deploy: the state snapshot +# does not exist. In JSON mode the command exits 0 and reports the outcome as +# skipped, but telemetry still records the STATE_NOT_FOUND error payload. +trace errcode $CLI bundle config-remote-sync -o json + +trace cat out.requests.txt | jq 'select(has("path") and .path == "/telemetry-ext") | .body.protoLogs[] | fromjson | .entry.databricks_cli_log.bundle_config_remote_sync_event | select(. != null)' + +rm out.requests.txt diff --git a/acceptance/bundle/telemetry/config-remote-sync-skipped-state/test.toml b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/test.toml new file mode 100644 index 00000000000..6936e889127 --- /dev/null +++ b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/test.toml @@ -0,0 +1,7 @@ +# config-remote-sync is not supported on Windows. +GOOS.windows = false + +# STATE_NOT_FOUND is a terraform-only path: the terraform engine pulls a config +# snapshot that doesn't exist without a prior deploy, while the direct engine +# does not. Pin the engine so the test exercises that specific outcome. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform"] diff --git a/bundle/configsync/output.go b/bundle/configsync/output.go index 83de12c402b..aac9bb6f7c1 100644 --- a/bundle/configsync/output.go +++ b/bundle/configsync/output.go @@ -2,10 +2,16 @@ package configsync import ( "context" + "encoding/json" + "errors" + "fmt" + "io" "os" "path/filepath" "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/telemetry" + "github.com/databricks/cli/libs/telemetry/protos" ) // FileChange represents a change to a bundle configuration file @@ -15,12 +21,69 @@ type FileChange struct { ModifiedContent string `json:"modifiedContent"` } +// SyncStatus is the outcome the JSON caller (DABs in the Workspace) branches on. +type SyncStatus string + +const ( + StatusSuccess SyncStatus = "success" + // StatusSkipped: nothing to sync against (state missing, or no selector + // matched a deployed resource). Not an error for the caller. + StatusSkipped SyncStatus = "skipped" + StatusFailed SyncStatus = "failed" +) + // 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"` } +// WriteResult renders the result and records the error payload on stats for +// telemetry. In JSON mode the process never fails: the outcome is carried in +// DiffOutput.Status so the caller branches on it, not the exit code. Text mode +// 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 + if err != nil { + if stats.ErrorCategory == "" { + stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryBundleLoadFailed + } + stats.ErrorMessage = telemetry.ScrubErrorMessage(err.Error()) + // Missing state or a selector that matched nothing: nothing to sync, not a failure. + if errors.Is(err, ErrStateSnapshotNotFound) || errors.Is(err, ErrNoMatchingSelector) { + status = StatusSkipped + } else { + status = StatusFailed + } + } + + if !jsonOutput { + if err != nil { + return err + } + _, _ = io.WriteString(out, FormatTextOutput(changes)) + _, _ = out.Write([]byte{'\n'}) + return nil + } + + output := DiffOutput{Status: status, Files: files, Changes: changes} + if err != nil { + output.Error = err.Error() + } + result, marshalErr := json.MarshalIndent(output, "", " ") + if marshalErr != nil { + // Cannot produce JSON: fail rather than emit a partial contract. + stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryOutputFailed + stats.ErrorMessage = telemetry.ScrubErrorMessage(marshalErr.Error()) + return fmt.Errorf("failed to marshal output: %w", marshalErr) + } + _, _ = out.Write(result) + _, _ = out.Write([]byte{'\n'}) + return nil +} + // SaveFiles writes all file changes to disk. func SaveFiles(ctx context.Context, b *bundle.Bundle, files []FileChange) error { for _, file := range files { diff --git a/bundle/configsync/select.go b/bundle/configsync/select.go index 42310d887df..269150ed8b5 100644 --- a/bundle/configsync/select.go +++ b/bundle/configsync/select.go @@ -2,6 +2,7 @@ package configsync import ( "context" + "errors" "fmt" "slices" "strings" @@ -10,6 +11,19 @@ import ( "github.com/databricks/cli/libs/log" ) +// ErrNoMatchingSelector marks an all-stale --select-ids batch (no selector +// matched a deployed resource), distinct from a malformed selector: the former +// 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 } + +func (e *noMatchingSelectorError) Error() string { return e.msg } + +func (e *noMatchingSelectorError) Unwrap() error { return ErrNoMatchingSelector } + // IndexDeployedResources indexes the deployed resources by ":", // mapping to the plan key ("resources.."). Indexing by the // component means a selector can only ever match a resource of that exact type, @@ -82,7 +96,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, fmt.Errorf("no deployed %s resource with id %s; %s", resourceType, id, describeStateIDs(byTypeID, resourceType)) + return nil, &noMatchingSelectorError{ + msg: fmt.Sprintf("no deployed %s resource with id %s; %s", resourceType, id, describeStateIDs(byTypeID, resourceType)), + } } // Some selectors matched: skip the stale ones so the matched resources still diff --git a/cmd/bundle/config_remote_sync.go b/cmd/bundle/config_remote_sync.go index f6c6e5cbe7f..1f3e6777298 100644 --- a/cmd/bundle/config_remote_sync.go +++ b/cmd/bundle/config_remote_sync.go @@ -2,7 +2,6 @@ package bundle import ( "context" - "encoding/json" "errors" "fmt" "maps" @@ -17,7 +16,6 @@ import ( "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/flags" "github.com/databricks/cli/libs/log" - "github.com/databricks/cli/libs/telemetry" "github.com/databricks/cli/libs/telemetry/protos" "github.com/spf13/cobra" ) @@ -68,6 +66,13 @@ Examples: } }() + // Populated by PostStateFunc on success; rendered after the run so an + // error raised anywhere in ProcessBundleRet takes the same output path. + var ( + files []configsync.FileChange + changes configsync.Changes + ) + _, _, err := utils.ProcessBundleRet(cmd, utils.ProcessOptions{ ReadState: true, Build: true, @@ -96,12 +101,12 @@ Examples: return fmt.Errorf("failed to detect changes: %w", err) } - changes, err := configsync.ExtractChanges(ctx, b, plan, stateDesc.Engine) + detected, err := configsync.ExtractChanges(ctx, b, plan, stateDesc.Engine) if err != nil { stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryDetectChangesFailed return fmt.Errorf("failed to extract changes: %w", err) } - stats.CollectChangeStats(ctx, changes) + stats.CollectChangeStats(ctx, detected) // Record the ids present in state and the ids requested: on failure // they are what classifies the miss. @@ -116,14 +121,14 @@ Examples: if err != nil { return err } - changes = configsync.FilterChanges(changes, selected) + detected = configsync.FilterChanges(detected, selected) } // Loaded once and shared: ResolveChanges uses it to skip changes whose // parent is a variable reference, RestoreVariableReferences to restore refs. preResolved := configsync.LoadPreResolvedConfig(ctx, b) - fieldChanges, skipped, err := configsync.ResolveChanges(ctx, b, changes, preResolved) + fieldChanges, skipped, err := configsync.ResolveChanges(ctx, b, detected, preResolved) if err != nil { stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryResolveFailed return fmt.Errorf("failed to resolve field changes: %w", err) @@ -134,49 +139,28 @@ Examples: log.Warnf(ctx, "variable restoration skipped: %v", err) } - files, err := configsync.ApplyChangesToYAML(ctx, b, fieldChanges) + applied, err := configsync.ApplyChangesToYAML(ctx, b, fieldChanges) if err != nil { stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryYamlApplyFailed return fmt.Errorf("failed to generate YAML files: %w", err) } - stats.FilesChangedCount = int64(len(files)) + stats.FilesChangedCount = int64(len(applied)) if save { - if err := configsync.SaveFiles(ctx, b, files); err != nil { + if err := configsync.SaveFiles(ctx, b, applied); err != nil { stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategorySaveFailed return fmt.Errorf("failed to save files: %w", err) } - stats.FilesWrittenCount = int64(len(files)) + stats.FilesWrittenCount = int64(len(applied)) } - var result []byte - if root.OutputType(cmd) == flags.OutputJSON { - diffOutput := &configsync.DiffOutput{ - Files: files, - Changes: changes, - } - result, err = json.MarshalIndent(diffOutput, "", " ") - if err != nil { - stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryOutputFailed - return fmt.Errorf("failed to marshal output: %w", err) - } - } else if root.OutputType(cmd) == flags.OutputText { - result = []byte(configsync.FormatTextOutput(changes)) - } - - out := cmd.OutOrStdout() - _, _ = out.Write(result) - _, _ = out.Write([]byte{'\n'}) + files = applied + changes = detected return nil }, }) - if err != nil { - if stats.ErrorCategory == "" { - stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryBundleLoadFailed - } - stats.ErrorMessage = telemetry.ScrubErrorMessage(err.Error()) - } - return err + + return configsync.WriteResult(cmd.OutOrStdout(), root.OutputType(cmd) == flags.OutputJSON, &stats, files, changes, err) } return cmd From 9cc10ec1c3eccf32e0ef5762fa011c1cf4e61fc6 Mon Sep 17 00:00:00 2001 From: Ilya Kuznetsov Date: Wed, 26 Aug 2026 07:45:16 +0000 Subject: [PATCH 2/2] config-remote-sync: serialize empty changes as {} not null in JSON output Co-authored-by: Isaac --- .../config-remote-sync/json_status_missing_state/output.txt | 2 +- .../config-remote-sync/json_status_selectors/output.txt | 4 ++-- .../telemetry/config-remote-sync-skipped-state/output.txt | 2 +- bundle/configsync/output.go | 4 ++++ 4 files changed, 8 insertions(+), 4 deletions(-) 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 05c076a1f71..069d2960882 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 @@ -4,5 +4,5 @@ "status": "skipped", "error": "state snapshot not available: resources state snapshot not found remotely at resources-config-sync-snapshot.json: state snapshot not found", "files": null, - "changes": 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 fb27765a7f3..a6c6cd28529 100644 --- a/acceptance/bundle/config-remote-sync/json_status_selectors/output.txt +++ b/acceptance/bundle/config-remote-sync/json_status_selectors/output.txt @@ -8,7 +8,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]", "files": null, - "changes": null + "changes": {} } === A malformed selector is reported as failed, exit 0 @@ -16,7 +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])", "files": null, - "changes": null + "changes": {} } >>> [CLI] bundle destroy --auto-approve 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 26f85e37a5e..9cc4a5a3b45 100644 --- a/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt +++ b/acceptance/bundle/telemetry/config-remote-sync-skipped-state/output.txt @@ -4,7 +4,7 @@ "status": "skipped", "error": "state snapshot not available: resources state snapshot not found remotely at resources-config-sync-snapshot.json: state snapshot not found", "files": null, - "changes": null + "changes": {} } >>> cat out.requests.txt diff --git a/bundle/configsync/output.go b/bundle/configsync/output.go index aac9bb6f7c1..5f77220fec3 100644 --- a/bundle/configsync/output.go +++ b/bundle/configsync/output.go @@ -69,6 +69,10 @@ func WriteResult(out io.Writer, jsonOutput bool, stats *Stats, files []FileChang } output := DiffOutput{Status: status, Files: files, Changes: changes} + if output.Changes == nil { + // Serialize as {} rather than null so a consumer can iterate it unconditionally. + output.Changes = Changes{} + } if err != nil { output.Error = err.Error() }