Skip to content

Commit 10391bd

Browse files
committed
Report config-remote-sync changes that were not written back
Co-authored-by: Isaac
1 parent 13a950a commit 10391bd

10 files changed

Lines changed: 106 additions & 10 deletions

File tree

‎acceptance/bundle/config-remote-sync/split/keyed_rename/output.txt‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,20 @@ Resource: resources.jobs.shift_job
7575

7676
=== Rename the two-block task AND edit one of its fields in the same run
7777
=== Sync
78+
Warn: not written back: resources.jobs.rename_job.tasks[task_key='a_shared']: a split element cannot be removed and recreated in one run
79+
Warn: not written back: resources.jobs.rename_job.tasks[task_key='b_shared']: a split element cannot be removed and recreated in one run
7880
Detected changes in 1 resource(s):
7981

8082
Resource: resources.jobs.rename_job
8183
tasks[task_key='a_shared']: remove
8284
tasks[task_key='b_shared']: add
8385
tasks[task_key='z_solo']: replace
8486

87+
Not written back (2 change(s)):
88+
89+
resources.jobs.rename_job.tasks[task_key='a_shared']: a split element cannot be removed and recreated in one run
90+
resources.jobs.rename_job.tasks[task_key='b_shared']: a split element cannot be removed and recreated in one run
91+
8592

8693

8794
=== Left unapplied: the split is intact and timeout_seconds stays target-scoped
@@ -104,6 +111,18 @@ Resource: resources.jobs.rename_job
104111
>>> grep -c timeout_seconds: 45 databricks.yml
105112
1
106113

114+
=== The same run in JSON reports what it did not write
115+
Warn: not written back: resources.jobs.rename_job.tasks[task_key='a_shared']: a split element cannot be removed and recreated in one run
116+
Warn: not written back: resources.jobs.rename_job.tasks[task_key='b_shared']: a split element cannot be removed and recreated in one run
117+
118+
>>> gron.py
119+
json.skipped[0].resourceKey = "resources.jobs.rename_job";
120+
json.skipped[0].fieldPath = "tasks[task_key='a_shared']";
121+
json.skipped[0].reason = "a split element cannot be removed and recreated in one run";
122+
json.skipped[1].resourceKey = "resources.jobs.rename_job";
123+
json.skipped[1].fieldPath = "tasks[task_key='b_shared']";
124+
json.skipped[1].reason = "a split element cannot be removed and recreated in one run";
125+
107126
>>> [CLI] bundle destroy --auto-approve -t dev
108127
The following resources will be deleted:
109128
delete resources.jobs.rename_job

‎acceptance/bundle/config-remote-sync/split/keyed_rename/script‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ rm databricks.yml.backup
6565
# rename, so the halves would route apart: the removal deletes the element from
6666
# both blocks while the addition recreates it in one, collapsing the split and
6767
# moving timeout_seconds out of the target scope. It has to be left for a later run.
68+
#
69+
# The command still reports the change, so both halves have to appear under
70+
# "Not written back" -- and in the JSON output, which the in-workspace UI reads --
71+
# or the caller cannot tell an unapplied change from an applied one.
6872
title "Rename the two-block task AND edit one of its fields in the same run"
6973
edit_resource.py jobs $job_id <<EOF
7074
for task in r["tasks"]:
@@ -84,3 +88,9 @@ trace diff.py databricks.yml.backup databricks.yml
8488
errcode trace grep -c "task_key: a_shared" databricks.yml
8589
errcode trace grep -c "timeout_seconds: 45" databricks.yml
8690
rm databricks.yml.backup
91+
92+
title "The same run in JSON reports what it did not write"
93+
echo
94+
$CLI bundle config-remote-sync -t dev --select-ids "jobs:$job_id" -o json > tmp.json
95+
trace gron.py < tmp.json | grep skipped
96+
rm tmp.json

‎acceptance/bundle/config-remote-sync/split/rename_ambiguous_pairing/output.txt‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ Deployment complete!
55

66
=== alpha (top-level) -> zzz and beta (target block) -> aaa, bodies identical
77
=== Sync
8+
Warn: not written back: resources.jobs.ambiguous_rename_job.tasks[task_key='alpha']: several elements with the same contents were renamed in one run, so the new keys cannot be matched to them
9+
Warn: not written back: resources.jobs.ambiguous_rename_job.tasks[task_key='beta']: several elements with the same contents were renamed in one run, so the new keys cannot be matched to them
10+
Warn: not written back: resources.jobs.ambiguous_rename_job.tasks[task_key='aaa']: several elements with the same contents were renamed in one run, so the new keys cannot be matched to them
11+
Warn: not written back: resources.jobs.ambiguous_rename_job.tasks[task_key='zzz']: several elements with the same contents were renamed in one run, so the new keys cannot be matched to them
812
Detected changes in 1 resource(s):
913

1014
Resource: resources.jobs.ambiguous_rename_job
@@ -13,6 +17,13 @@ Resource: resources.jobs.ambiguous_rename_job
1317
tasks[task_key='beta']: remove
1418
tasks[task_key='zzz']: add
1519

20+
Not written back (4 change(s)):
21+
22+
resources.jobs.ambiguous_rename_job.tasks[task_key='alpha']: several elements with the same contents were renamed in one run, so the new keys cannot be matched to them
23+
resources.jobs.ambiguous_rename_job.tasks[task_key='beta']: several elements with the same contents were renamed in one run, so the new keys cannot be matched to them
24+
resources.jobs.ambiguous_rename_job.tasks[task_key='aaa']: several elements with the same contents were renamed in one run, so the new keys cannot be matched to them
25+
resources.jobs.ambiguous_rename_job.tasks[task_key='zzz']: several elements with the same contents were renamed in one run, so the new keys cannot be matched to them
26+
1627

1728

1829
=== Neither rename is applied, and both blocks keep their own task

‎bundle/configsync/format.go‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import (
77
"strings"
88
)
99

10-
// FormatTextOutput formats the config changes as human-readable text. Useful for debugging
11-
func FormatTextOutput(changes Changes) string {
10+
// FormatTextOutput formats the config changes as human-readable text. Useful for debugging.
11+
// Changes that were detected but not written back are listed separately, so a run that
12+
// reported changes without applying them is distinguishable from one that applied them.
13+
func FormatTextOutput(changes Changes, skipped []SkippedChange) string {
1214
var output strings.Builder
1315

1416
if len(changes) == 0 {
@@ -34,5 +36,13 @@ func FormatTextOutput(changes Changes) string {
3436
output.WriteString("\n")
3537
}
3638

39+
if len(skipped) > 0 {
40+
fmt.Fprintf(&output, "Not written back (%d change(s)):\n\n", len(skipped))
41+
for _, s := range skipped {
42+
fmt.Fprintf(&output, " %s.%s: %s\n", s.ResourceKey, s.FieldPath, s.Reason)
43+
}
44+
output.WriteString("\n")
45+
}
46+
3747
return output.String()
3848
}

‎bundle/configsync/output.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ type FileChange struct {
1919
type DiffOutput struct {
2020
Files []FileChange `json:"files"`
2121
Changes Changes `json:"changes"`
22+
// Skipped lists changes present in Changes that were not written back, so a
23+
// consumer of this output does not report them as applied.
24+
Skipped []SkippedChange `json:"skipped,omitempty"`
2225
}
2326

2427
// SaveFiles writes all file changes to disk.

‎bundle/configsync/patch_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ resources:
5050
},
5151
}
5252

53-
fieldChanges, err := ResolveChanges(ctx, b, changes)
53+
fieldChanges, skipped, err := ResolveChanges(ctx, b, changes)
5454
require.NoError(t, err)
55+
require.Empty(t, skipped)
5556

5657
fileChanges, err := ApplyChangesToYAML(ctx, b, fieldChanges)
5758
require.NoError(t, err)

‎bundle/configsync/resolve.go‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ type FieldChange struct {
2929
mergedPath string
3030
}
3131

32+
// SkippedChange is a detected change that was not written back, because doing so
33+
// would have meant guessing where it belongs.
34+
type SkippedChange struct {
35+
// ResourceKey and FieldPath together identify the change, e.g.
36+
// "resources.jobs.foo" and "tasks[task_key='main'].max_retries".
37+
ResourceKey string `json:"resourceKey"`
38+
FieldPath string `json:"fieldPath"`
39+
// Reason is a short explanation suitable for the command's output.
40+
Reason string `json:"reason"`
41+
}
42+
3243
// sequenceStep records a sequence element that a change path navigated through.
3344
// The element's index inside a physical block is only known once the block has
3445
// been chosen, so the merged position is kept until then.
@@ -200,8 +211,14 @@ func adjustArrayIndex(path *structpath.PatternNode, scope string, operations map
200211
}
201212

202213
// ResolveChanges resolves selectors and computes field path candidates for each change.
203-
func ResolveChanges(ctx context.Context, b *bundle.Bundle, configChanges Changes) ([]FieldChange, error) {
214+
//
215+
// A change that cannot be attributed to a single source location is left unapplied
216+
// rather than written to a guessed one, and is returned in skipped. The caller is
217+
// expected to report those: the command runs unattended, so a change that silently
218+
// disappears looks identical to one that was written.
219+
func ResolveChanges(ctx context.Context, b *bundle.Bundle, configChanges Changes) ([]FieldChange, []SkippedChange, error) {
204220
var result []FieldChange
221+
var skipped []SkippedChange
205222
targetName := b.Config.Bundle.Target
206223
blocks := newBlockResolver(ctx, b)
207224

@@ -264,13 +281,14 @@ func ResolveChanges(ctx context.Context, b *bundle.Bundle, configChanges Changes
264281
}
265282
if reason, ok := renames.unpairedPaths[fieldPath]; ok {
266283
log.Debugf(ctx, "config-remote-sync: skipping %s: %s", fullPath, reason)
284+
skipped = append(skipped, SkippedChange{ResourceKey: resourceKey, FieldPath: fieldPath, Reason: reason})
267285
continue
268286
}
269287
rename, isRename := renames.byRemovePath[fieldPath]
270288

271289
resolved, err := resolveSelectors(fullPath, b, configChange.Operation)
272290
if err != nil {
273-
return nil, fmt.Errorf("failed to resolve selectors in path %s: %w", fullPath, err)
291+
return nil, nil, fmt.Errorf("failed to resolve selectors in path %s: %w", fullPath, err)
274292
}
275293
resolvedPath := resolved.path
276294

@@ -288,6 +306,9 @@ func ResolveChanges(ctx context.Context, b *bundle.Bundle, configChanges Changes
288306
if routeErr == nil && len(destinations) == 0 {
289307
// The element could not be attributed to a block; leave the
290308
// whole pair for a later run rather than half-applying it.
309+
const reason = "the renamed element cannot be attributed to a source location"
310+
log.Debugf(ctx, "config-remote-sync: skipping %s: %s", fullPath, reason)
311+
skipped = append(skipped, SkippedChange{ResourceKey: resourceKey, FieldPath: fieldPath, Reason: reason})
291312
continue
292313
}
293314
} else {
@@ -298,9 +319,10 @@ func ResolveChanges(ctx context.Context, b *bundle.Bundle, configChanges Changes
298319
// Applying this change would mean guessing a location.
299320
// Leave it unapplied; a later run can pick it up.
300321
log.Debugf(ctx, "config-remote-sync: skipping %s: %v", fullPath, routeErr)
322+
skipped = append(skipped, SkippedChange{ResourceKey: resourceKey, FieldPath: fieldPath, Reason: routeErr.Error()})
301323
continue
302324
}
303-
return nil, fmt.Errorf("failed to route change %s: %w", fullPath, routeErr)
325+
return nil, nil, fmt.Errorf("failed to route change %s: %w", fullPath, routeErr)
304326
}
305327
routed = true
306328
}
@@ -421,7 +443,7 @@ func ResolveChanges(ctx context.Context, b *bundle.Bundle, configChanges Changes
421443
filePath = resourceLocation.File
422444
}
423445
if filePath == "" {
424-
return nil, fmt.Errorf("failed to find location for resource %s for a field %s", resourceKey, fieldPath)
446+
return nil, nil, fmt.Errorf("failed to find location for resource %s for a field %s", resourceKey, fieldPath)
425447
}
426448

427449
log.Debugf(ctx, "Field %s has no location, using %s", fullPath, filePath)
@@ -444,7 +466,7 @@ func ResolveChanges(ctx context.Context, b *bundle.Bundle, configChanges Changes
444466
}
445467
}
446468

447-
return result, nil
469+
return result, skipped, nil
448470
}
449471

450472
// translateWorkspacePaths recursively converts absolute workspace paths to relative

‎bundle/configsync/telemetry.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ type Stats struct {
4949
FilesChangedCount int64
5050
FilesWrittenCount int64
5151

52+
// Changes that were detected and reported but not written back, because they
53+
// could not be attributed to a single source location.
54+
SkippedChangesCount int64
55+
5256
Restore RestoreStats
5357

5458
ErrorMessage string
@@ -178,6 +182,7 @@ func (s *Stats) LogTelemetry(ctx context.Context) {
178182
ResourceChanges: resourceChanges,
179183
FilesChangedCount: s.FilesChangedCount,
180184
FilesWrittenCount: s.FilesWrittenCount,
185+
SkippedChangesCount: s.SkippedChangesCount,
181186
RefsRetargeted: s.Restore.Retargeted,
182187
RefsFromSiblings: s.Restore.FromSiblings,
183188
ErrorMessage: s.ErrorMessage,

‎cmd/bundle/config_remote_sync.go‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,17 @@ Examples:
111111
changes = configsync.FilterChanges(changes, selected)
112112
}
113113

114-
fieldChanges, err := configsync.ResolveChanges(ctx, b, changes)
114+
fieldChanges, skipped, err := configsync.ResolveChanges(ctx, b, changes)
115115
if err != nil {
116116
stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryResolveFailed
117117
return fmt.Errorf("failed to resolve field changes: %w", err)
118118
}
119+
stats.SkippedChangesCount = int64(len(skipped))
120+
for _, s := range skipped {
121+
// The command runs unattended, so a dropped change needs a signal
122+
// that survives without debug logging enabled.
123+
log.Warnf(ctx, "not written back: %s.%s: %s", s.ResourceKey, s.FieldPath, s.Reason)
124+
}
119125

120126
if err := configsync.RestoreVariableReferences(ctx, b, fieldChanges, &stats.Restore); err != nil {
121127
log.Warnf(ctx, "variable restoration skipped: %v", err)
@@ -141,14 +147,15 @@ Examples:
141147
diffOutput := &configsync.DiffOutput{
142148
Files: files,
143149
Changes: changes,
150+
Skipped: skipped,
144151
}
145152
result, err = json.MarshalIndent(diffOutput, "", " ")
146153
if err != nil {
147154
stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryOutputFailed
148155
return fmt.Errorf("failed to marshal output: %w", err)
149156
}
150157
} else if root.OutputType(cmd) == flags.OutputText {
151-
result = []byte(configsync.FormatTextOutput(changes))
158+
result = []byte(configsync.FormatTextOutput(changes, skipped))
152159
}
153160

154161
out := cmd.OutOrStdout()

‎libs/telemetry/protos/bundle_config_remote_sync.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ type BundleConfigRemoteSyncEvent struct {
5454
FilesChangedCount int64 `json:"files_changed_count,omitempty"`
5555
FilesWrittenCount int64 `json:"files_written_count,omitempty"`
5656

57+
// Number of detected changes that were not written back because they could
58+
// not be attributed to a single source location (e.g. a sequence element
59+
// defined in both a top-level block and a target override). These are
60+
// counted in ChangesTotal: a nonzero value means the command reported
61+
// changes it did not apply. Distinct from the "skip" operation filtered out
62+
// during change detection, which never reaches ChangesTotal.
63+
SkippedChangesCount int64 `json:"skipped_changes_count,omitempty"`
64+
5765
// Variable-reference restoration counts for the two mechanisms that can
5866
// write a current-target-scoped reference into a shared file (the source of
5967
// the cross-target "reference does not exist" failures).

0 commit comments

Comments
 (0)