diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 95acdbc46d..653d779b3a 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -7,6 +7,7 @@ ### Dependency updates ### CLI +* Added dry-run flag support to sync command ([#2657](https://github.com/databricks/cli/pull/2657)) ### Bundles * Do not exit early when checking incompatible tasks for specified DBR ([#2692](https://github.com/databricks/cli/pull/2692)) diff --git a/acceptance/bundle/help/bundle-sync/output.txt b/acceptance/bundle/help/bundle-sync/output.txt index 992138a206..f9bcc40cfa 100644 --- a/acceptance/bundle/help/bundle-sync/output.txt +++ b/acceptance/bundle/help/bundle-sync/output.txt @@ -6,6 +6,7 @@ Usage: databricks bundle sync [flags] Flags: + --dry-run simulate sync execution without making actual changes --full perform full synchronization (default is incremental) -h, --help help for sync --interval duration file system polling interval (for --watch) (default 1s) diff --git a/acceptance/bundle/sync/dryrun/databricks.yml b/acceptance/bundle/sync/dryrun/databricks.yml new file mode 100644 index 0000000000..c164f486a0 --- /dev/null +++ b/acceptance/bundle/sync/dryrun/databricks.yml @@ -0,0 +1,7 @@ +bundle: + name: bundle-sync-test + +resources: + dashboards: + dashboard1: + display_name: My dashboard diff --git a/acceptance/bundle/sync/dryrun/output.txt b/acceptance/bundle/sync/dryrun/output.txt new file mode 100644 index 0000000000..765f2f8c6d --- /dev/null +++ b/acceptance/bundle/sync/dryrun/output.txt @@ -0,0 +1,10 @@ + +>>> [CLI] bundle sync --dry-run --output text +Warn: Running in dry-run mode. No actual changes will be made. +Initial Sync Complete +Uploaded .gitignore +Uploaded databricks.yml +Uploaded project-folder +Uploaded project-folder/app.py +Uploaded project-folder/app.yaml +Uploaded project-folder/query.sql diff --git a/acceptance/bundle/sync/dryrun/script b/acceptance/bundle/sync/dryrun/script new file mode 100644 index 0000000000..66f1bb6de3 --- /dev/null +++ b/acceptance/bundle/sync/dryrun/script @@ -0,0 +1,18 @@ +mkdir "project-folder" "ignored-folder" "ignored-folder/folder1" +touch "project-folder/app.yaml" "project-folder/app.py" "project-folder/query.sql" +touch "ignored-folder/script.py" "ignored-folder/folder1/script.py" +cat > .gitignore << EOF +ignored-folder/ +script +output.txt +repls.json +EOF + +cleanup() { + rm .gitignore + rm -rf project-folder ignored-folder .git .databricks +} +trap cleanup EXIT + +# Note: output line starting with "Action: " lists files in non-deterministic order so we filter it out +trace $CLI bundle sync --dry-run --output text | grep -v "^Action: " | sort diff --git a/acceptance/bundle/sync/output.txt b/acceptance/bundle/sync/output.txt index f23e07dc8d..cd140e4fbe 100644 --- a/acceptance/bundle/sync/output.txt +++ b/acceptance/bundle/sync/output.txt @@ -3,6 +3,8 @@ Initial Sync Complete Uploaded .gitignore Uploaded databricks.yml +Uploaded dryrun +Uploaded dryrun/databricks.yml Uploaded ignored-folder/folder1 Uploaded ignored-folder/folder1/script.py Uploaded ignored-folder/script.py diff --git a/acceptance/cmd/sync/dryrun/output.txt b/acceptance/cmd/sync/dryrun/output.txt new file mode 100644 index 0000000000..091b812701 --- /dev/null +++ b/acceptance/cmd/sync/dryrun/output.txt @@ -0,0 +1,49 @@ + +>>> [CLI] sync . /Users/[USERNAME] --dry-run +Warn: Running in dry-run mode. No actual changes will be made. +Initial Sync Complete +Uploaded .gitignore +Uploaded project-folder +Uploaded project-folder/app.py +Uploaded project-folder/app.yaml +Uploaded project-folder/query.sql + +>>> [CLI] sync . /Users/[USERNAME] --dry-run --exclude project-folder/app.* +Warn: Running in dry-run mode. No actual changes will be made. +Initial Sync Complete +Uploaded .gitignore +Uploaded project-folder +Uploaded project-folder/query.sql + +>>> [CLI] sync . /Users/[USERNAME] --dry-run --exclude project-folder/app.* --exclude project-folder/query.sql +Warn: Running in dry-run mode. No actual changes will be made. +Initial Sync Complete +Uploaded .gitignore + +>>> [CLI] sync . /Users/[USERNAME] --dry-run --exclude project-folder/app.* --exclude project-folder/query.sql --include ignored-folder/*.py +Warn: Running in dry-run mode. No actual changes will be made. +Initial Sync Complete +Uploaded .gitignore +Uploaded ignored-folder +Uploaded ignored-folder/script.py + +>>> [CLI] sync . /Users/[USERNAME] --dry-run --exclude project-folder/app.* --exclude project-folder/query.sql --include ignored-folder/**/*.py +Warn: Running in dry-run mode. No actual changes will be made. +Initial Sync Complete +Uploaded .gitignore +Uploaded ignored-folder/folder1 +Uploaded ignored-folder/folder1/script.py +Uploaded ignored-folder/script.py + +>>> [CLI] sync . /Users/[USERNAME] --dry-run --include ignored-folder/** --include !ignored-folder/folder1/big-blob +Warn: Running in dry-run mode. No actual changes will be made. +Initial Sync Complete +Uploaded .gitignore +Uploaded ignored-folder/folder1 +Uploaded ignored-folder/folder1/script.py +Uploaded ignored-folder/folder1/script.yaml +Uploaded ignored-folder/script.py +Uploaded project-folder +Uploaded project-folder/app.py +Uploaded project-folder/app.yaml +Uploaded project-folder/query.sql diff --git a/acceptance/cmd/sync/dryrun/script b/acceptance/cmd/sync/dryrun/script new file mode 100644 index 0000000000..75e217400e --- /dev/null +++ b/acceptance/cmd/sync/dryrun/script @@ -0,0 +1,33 @@ +mkdir "project-folder" "ignored-folder" "ignored-folder/folder1" ".git" +touch "project-folder/app.yaml" "project-folder/app.py" "project-folder/query.sql" +touch "ignored-folder/script.py" "ignored-folder/folder1/script.py" "ignored-folder/folder1/script.yaml" "ignored-folder/folder1/big-blob" +cat > .gitignore << EOF +ignored-folder/ +script +output.txt +repls.json +EOF + +cleanup() { + rm .gitignore + rm -rf project-folder ignored-folder .git +} +trap cleanup EXIT + +# Note: output line starting with Action lists files in non-deterministic order so we filter it out +trace $CLI sync . /Users/$CURRENT_USER_NAME --dry-run | grep -v "^Action" | sort + +# excluding by mask: +trace $CLI sync . /Users/$CURRENT_USER_NAME --dry-run --exclude 'project-folder/app.*' | grep -v "^Action" | sort + +# combining excludes: +trace $CLI sync . /Users/$CURRENT_USER_NAME --dry-run --exclude 'project-folder/app.*' --exclude 'project-folder/query.sql' | grep -v "^Action" | sort + +# combining excludes and includes: +trace $CLI sync . /Users/$CURRENT_USER_NAME --dry-run --exclude 'project-folder/app.*' --exclude 'project-folder/query.sql' --include 'ignored-folder/*.py' | grep -v "^Action" | sort + +# include sub-folders: +trace $CLI sync . /Users/$CURRENT_USER_NAME --dry-run --exclude 'project-folder/app.*' --exclude 'project-folder/query.sql' --include 'ignored-folder/**/*.py' | grep -v "^Action" | sort + +# use negated include to exclude files from syncing: +trace $CLI sync . /Users/$CURRENT_USER_NAME --dry-run --include 'ignored-folder/**' --include '!ignored-folder/folder1/big-blob' | grep -v "^Action" | sort diff --git a/acceptance/cmd/sync/script b/acceptance/cmd/sync/script index 7a4624912f..5b9bcb2a3e 100644 --- a/acceptance/cmd/sync/script +++ b/acceptance/cmd/sync/script @@ -9,6 +9,7 @@ repls.json EOF cleanup() { + rm .gitignore rm -rf project-folder ignored-folder .git } trap cleanup EXIT diff --git a/cmd/bundle/sync.go b/cmd/bundle/sync.go index 25475206d0..65cda6d6c9 100644 --- a/cmd/bundle/sync.go +++ b/cmd/bundle/sync.go @@ -22,6 +22,7 @@ type syncFlags struct { full bool watch bool output flags.Output + dryRun bool } func (f *syncFlags) syncOptionsFromBundle(cmd *cobra.Command, b *bundle.Bundle) (*sync.SyncOptions, error) { @@ -47,6 +48,7 @@ func (f *syncFlags) syncOptionsFromBundle(cmd *cobra.Command, b *bundle.Bundle) opts.Full = f.full opts.PollInterval = f.interval + opts.DryRun = f.dryRun return opts, nil } @@ -62,6 +64,7 @@ func newSyncCommand() *cobra.Command { cmd.Flags().BoolVar(&f.full, "full", false, "perform full synchronization (default is incremental)") cmd.Flags().BoolVar(&f.watch, "watch", false, "watch local file system for changes") cmd.Flags().Var(&f.output, "output", "type of the output format") + cmd.Flags().BoolVar(&f.dryRun, "dry-run", false, "simulate sync execution without making actual changes") cmd.RunE = func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -89,6 +92,10 @@ func newSyncCommand() *cobra.Command { log.Infof(ctx, "Remote file sync location: %v", opts.RemotePath) + if opts.DryRun { + log.Warnf(ctx, "Running in dry-run mode. No actual changes will be made.") + } + if f.watch { return s.RunContinuous(ctx) } diff --git a/cmd/sync/sync.go b/cmd/sync/sync.go index 4646288663..3ccae8cbfe 100644 --- a/cmd/sync/sync.go +++ b/cmd/sync/sync.go @@ -29,6 +29,7 @@ type syncFlags struct { output flags.Output exclude []string include []string + dryRun bool } func (f *syncFlags) syncOptionsFromBundle(cmd *cobra.Command, args []string, b *bundle.Bundle) (*sync.SyncOptions, error) { @@ -46,6 +47,7 @@ func (f *syncFlags) syncOptionsFromBundle(cmd *cobra.Command, args []string, b * opts.WorktreeRoot = b.WorktreeRoot opts.Exclude = append(opts.Exclude, f.exclude...) opts.Include = append(opts.Include, f.include...) + opts.DryRun = f.dryRun return opts, nil } @@ -72,6 +74,10 @@ func (f *syncFlags) syncOptionsFromArgs(cmd *cobra.Command, args []string) (*syn ctx := cmd.Context() client := cmdctx.WorkspaceClient(ctx) + if f.dryRun { + log.Warnf(ctx, "Running in dry-run mode. No actual changes will be made.") + } + localRoot := vfs.MustNew(args[0]) info, err := git.FetchRepositoryInfo(ctx, localRoot.Native(), client) if err != nil { @@ -105,6 +111,7 @@ func (f *syncFlags) syncOptionsFromArgs(cmd *cobra.Command, args []string) (*syn WorkspaceClient: client, OutputHandler: outputHandler, + DryRun: f.dryRun, } return &opts, nil } @@ -126,6 +133,7 @@ func New() *cobra.Command { cmd.Flags().Var(&f.output, "output", "type of output format") cmd.Flags().StringSliceVar(&f.exclude, "exclude", nil, "patterns to exclude from sync (can be specified multiple times)") cmd.Flags().StringSliceVar(&f.include, "include", nil, "patterns to include in sync (can be specified multiple times)") + cmd.Flags().BoolVar(&f.dryRun, "dry-run", false, "simulate sync execution without making actual changes") // Wrapper for [root.MustWorkspaceClient] that disables loading authentication configuration from a bundle. mustWorkspaceClient := func(cmd *cobra.Command, args []string) error { diff --git a/libs/sync/event.go b/libs/sync/event.go index 510a019546..4b488f4ede 100644 --- a/libs/sync/event.go +++ b/libs/sync/event.go @@ -30,13 +30,15 @@ type EventBase struct { Timestamp time.Time `json:"timestamp"` Seq int `json:"seq"` Type EventType `json:"type"` + DryRun bool `json:"dry_run,omitempty"` } -func newEventBase(seq int, typ EventType) *EventBase { +func newEventBase(seq int, typ EventType, dryRun bool) *EventBase { return &EventBase{ Timestamp: time.Now(), Seq: seq, Type: typ, + DryRun: dryRun, } } @@ -73,9 +75,9 @@ func (e *EventStart) String() string { return "Action: " + e.EventChanges.String() } -func newEventStart(seq int, put, delete []string) Event { +func newEventStart(seq int, put, delete []string, dryRun bool) Event { return &EventStart{ - EventBase: newEventBase(seq, EventTypeStart), + EventBase: newEventBase(seq, EventTypeStart, dryRun), EventChanges: &EventChanges{Put: put, Delete: delete}, } } @@ -106,9 +108,9 @@ func (e *EventSyncProgress) String() string { } } -func newEventProgress(seq int, action EventAction, path string, progress float32) Event { +func newEventProgress(seq int, action EventAction, path string, progress float32, dryRun bool) Event { return &EventSyncProgress{ - EventBase: newEventBase(seq, EventTypeProgress), + EventBase: newEventBase(seq, EventTypeProgress, dryRun), Action: action, Path: path, @@ -133,9 +135,9 @@ func (e *EventSyncComplete) String() string { return "Complete" } -func newEventComplete(seq int, put, delete []string) Event { +func newEventComplete(seq int, put, delete []string, dryRun bool) Event { return &EventSyncComplete{ - EventBase: newEventBase(seq, EventTypeComplete), + EventBase: newEventBase(seq, EventTypeComplete, dryRun), EventChanges: &EventChanges{Put: put, Delete: delete}, } } diff --git a/libs/sync/event_test.go b/libs/sync/event_test.go index 3fcb0709bc..343bbb21ec 100644 --- a/libs/sync/event_test.go +++ b/libs/sync/event_test.go @@ -26,105 +26,553 @@ func jsonEqual(t *testing.T, expected string, e Event) { } func TestEventStart(t *testing.T) { - var e Event - - e = newEventStart(0, []string{"put"}, []string{"delete"}) - assert.Equal(t, "Action: PUT: put, DELETE: delete", e.String()) - - e = newEventStart(1, []string{"put"}, []string{}) - assert.Equal(t, "Action: PUT: put", e.String()) - - e = newEventStart(2, []string{}, []string{"delete"}) - assert.Equal(t, "Action: DELETE: delete", e.String()) - - e = newEventStart(3, []string{}, []string{}) - assert.Equal(t, "", e.String()) + tests := []struct { + name string + seq int + put []string + delete []string + dryRun bool + expected string + }{ + { + name: "put and delete without dry run", + seq: 0, + put: []string{"put"}, + delete: []string{"delete"}, + dryRun: false, + expected: "Action: PUT: put, DELETE: delete", + }, + { + name: "put and delete with dry run", + seq: 0, + put: []string{"put"}, + delete: []string{"delete"}, + dryRun: true, + expected: "Action: PUT: put, DELETE: delete", + }, + { + name: "only put without dry run", + seq: 1, + put: []string{"put"}, + delete: []string{}, + dryRun: false, + expected: "Action: PUT: put", + }, + { + name: "only put with dry run", + seq: 1, + put: []string{"put"}, + delete: []string{}, + dryRun: true, + expected: "Action: PUT: put", + }, + { + name: "only delete without dry run", + seq: 2, + put: []string{}, + delete: []string{"delete"}, + dryRun: false, + expected: "Action: DELETE: delete", + }, + { + name: "only delete with dry run", + seq: 2, + put: []string{}, + delete: []string{"delete"}, + dryRun: true, + expected: "Action: DELETE: delete", + }, + { + name: "empty without dry run", + seq: 3, + put: []string{}, + delete: []string{}, + dryRun: false, + expected: "", + }, + { + name: "empty with dry run", + seq: 3, + put: []string{}, + delete: []string{}, + dryRun: true, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := newEventStart(tt.seq, tt.put, tt.delete, tt.dryRun) + assert.Equal(t, tt.expected, e.String()) + }) + } } func TestEventStartJSON(t *testing.T) { - var e Event - - e = newEventStart(0, []string{"put"}, []string{"delete"}) - jsonEqual(t, `{"seq": 0, "type": "start", "put": ["put"], "delete": ["delete"]}`, e) - - e = newEventStart(1, []string{"put"}, []string{}) - jsonEqual(t, `{"seq": 1, "type": "start", "put": ["put"]}`, e) - - e = newEventStart(2, []string{}, []string{"delete"}) - jsonEqual(t, `{"seq": 2, "type": "start", "delete": ["delete"]}`, e) - - e = newEventStart(3, []string{}, []string{}) - jsonEqual(t, `{"seq": 3, "type": "start"}`, e) + tests := []struct { + name string + seq int + put []string + delete []string + dryRun bool + expected string + }{ + { + name: "put and delete without dry run", + seq: 0, + put: []string{"put"}, + delete: []string{"delete"}, + dryRun: false, + expected: `{"seq": 0, "type": "start", "put": ["put"], "delete": ["delete"]}`, + }, + { + name: "put and delete with dry run", + seq: 0, + put: []string{"put"}, + delete: []string{"delete"}, + dryRun: true, + expected: `{"seq": 0, "type": "start", "dry_run": true, "put": ["put"], "delete": ["delete"]}`, + }, + { + name: "only put without dry run", + seq: 1, + put: []string{"put"}, + delete: []string{}, + dryRun: false, + expected: `{"seq": 1, "type": "start", "put": ["put"]}`, + }, + { + name: "only put with dry run", + seq: 1, + put: []string{"put"}, + delete: []string{}, + dryRun: true, + expected: `{"seq": 1, "type": "start", "dry_run": true, "put": ["put"]}`, + }, + { + name: "only delete without dry run", + seq: 2, + put: []string{}, + delete: []string{"delete"}, + dryRun: false, + expected: `{"seq": 2, "type": "start", "delete": ["delete"]}`, + }, + { + name: "only delete with dry run", + seq: 2, + put: []string{}, + delete: []string{"delete"}, + dryRun: true, + expected: `{"seq": 2, "type": "start", "dry_run": true, "delete": ["delete"]}`, + }, + { + name: "empty without dry run", + seq: 3, + put: []string{}, + delete: []string{}, + dryRun: false, + expected: `{"seq": 3, "type": "start"}`, + }, + { + name: "empty with dry run", + seq: 3, + put: []string{}, + delete: []string{}, + dryRun: true, + expected: `{"seq": 3, "type": "start", "dry_run": true}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := newEventStart(tt.seq, tt.put, tt.delete, tt.dryRun) + jsonEqual(t, tt.expected, e) + }) + } } func TestEventProgress(t *testing.T) { - var e Event - - // Empty string if no progress has been made. - e = newEventProgress(0, EventActionPut, "path", 0.0) - assert.Equal(t, "", e.String()) - - e = newEventProgress(1, EventActionPut, "path", 1.0) - assert.Equal(t, "Uploaded path", e.String()) - - // Empty string if no progress has been made. - e = newEventProgress(2, EventActionDelete, "path", 0.0) - assert.Equal(t, "", e.String()) - - e = newEventProgress(3, EventActionDelete, "path", 1.0) - assert.Equal(t, "Deleted path", e.String()) + tests := []struct { + name string + seq int + action EventAction + path string + progress float32 + dryRun bool + expected string + }{ + { + name: "put no progress without dry run", + seq: 0, + action: EventActionPut, + path: "path", + progress: 0.0, + dryRun: false, + expected: "", + }, + { + name: "put no progress with dry run", + seq: 0, + action: EventActionPut, + path: "path", + progress: 0.0, + dryRun: true, + expected: "", + }, + { + name: "put completed without dry run", + seq: 1, + action: EventActionPut, + path: "path", + progress: 1.0, + dryRun: false, + expected: "Uploaded path", + }, + { + name: "put completed with dry run", + seq: 1, + action: EventActionPut, + path: "path", + progress: 1.0, + dryRun: true, + expected: "Uploaded path", + }, + { + name: "delete no progress without dry run", + seq: 2, + action: EventActionDelete, + path: "path", + progress: 0.0, + dryRun: false, + expected: "", + }, + { + name: "delete no progress with dry run", + seq: 2, + action: EventActionDelete, + path: "path", + progress: 0.0, + dryRun: true, + expected: "", + }, + { + name: "delete completed without dry run", + seq: 3, + action: EventActionDelete, + path: "path", + progress: 1.0, + dryRun: false, + expected: "Deleted path", + }, + { + name: "delete completed with dry run", + seq: 3, + action: EventActionDelete, + path: "path", + progress: 1.0, + dryRun: true, + expected: "Deleted path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := newEventProgress(tt.seq, tt.action, tt.path, tt.progress, tt.dryRun) + assert.Equal(t, tt.expected, e.String()) + }) + } } func TestEventProgressJSON(t *testing.T) { - var e Event - - e = newEventProgress(0, EventActionPut, "path", 0.0) - jsonEqual(t, `{"seq": 0, "type": "progress", "action": "put", "path": "path", "progress": 0.0}`, e) - - e = newEventProgress(0, EventActionPut, "path", 0.5) - jsonEqual(t, `{"seq": 0, "type": "progress", "action": "put", "path": "path", "progress": 0.5}`, e) - - e = newEventProgress(1, EventActionPut, "path", 1.0) - jsonEqual(t, `{"seq": 1, "type": "progress", "action": "put", "path": "path", "progress": 1.0}`, e) - - e = newEventProgress(2, EventActionDelete, "path", 0.0) - jsonEqual(t, `{"seq": 2, "type": "progress", "action": "delete", "path": "path", "progress": 0.0}`, e) - - e = newEventProgress(2, EventActionDelete, "path", 0.5) - jsonEqual(t, `{"seq": 2, "type": "progress", "action": "delete", "path": "path", "progress": 0.5}`, e) - - e = newEventProgress(3, EventActionDelete, "path", 1.0) - jsonEqual(t, `{"seq": 3, "type": "progress", "action": "delete", "path": "path", "progress": 1.0}`, e) + tests := []struct { + name string + seq int + action EventAction + path string + progress float32 + dryRun bool + expected string + }{ + { + name: "put no progress without dry run", + seq: 0, + action: EventActionPut, + path: "path", + progress: 0.0, + dryRun: false, + expected: `{"seq": 0, "type": "progress", "action": "put", "path": "path", "progress": 0.0}`, + }, + { + name: "put no progress with dry run", + seq: 0, + action: EventActionPut, + path: "path", + progress: 0.0, + dryRun: true, + expected: `{"seq": 0, "type": "progress", "dry_run": true, "action": "put", "path": "path", "progress": 0.0}`, + }, + { + name: "put half progress without dry run", + seq: 0, + action: EventActionPut, + path: "path", + progress: 0.5, + dryRun: false, + expected: `{"seq": 0, "type": "progress", "action": "put", "path": "path", "progress": 0.5}`, + }, + { + name: "put half progress with dry run", + seq: 0, + action: EventActionPut, + path: "path", + progress: 0.5, + dryRun: true, + expected: `{"seq": 0, "type": "progress", "dry_run": true, "action": "put", "path": "path", "progress": 0.5}`, + }, + { + name: "put completed without dry run", + seq: 1, + action: EventActionPut, + path: "path", + progress: 1.0, + dryRun: false, + expected: `{"seq": 1, "type": "progress", "action": "put", "path": "path", "progress": 1.0}`, + }, + { + name: "put completed with dry run", + seq: 1, + action: EventActionPut, + path: "path", + progress: 1.0, + dryRun: true, + expected: `{"seq": 1, "type": "progress", "dry_run": true, "action": "put", "path": "path", "progress": 1.0}`, + }, + { + name: "delete no progress without dry run", + seq: 2, + action: EventActionDelete, + path: "path", + progress: 0.0, + dryRun: false, + expected: `{"seq": 2, "type": "progress", "action": "delete", "path": "path", "progress": 0.0}`, + }, + { + name: "delete no progress with dry run", + seq: 2, + action: EventActionDelete, + path: "path", + progress: 0.0, + dryRun: true, + expected: `{"seq": 2, "type": "progress", "dry_run": true, "action": "delete", "path": "path", "progress": 0.0}`, + }, + { + name: "delete half progress without dry run", + seq: 2, + action: EventActionDelete, + path: "path", + progress: 0.5, + dryRun: false, + expected: `{"seq": 2, "type": "progress", "action": "delete", "path": "path", "progress": 0.5}`, + }, + { + name: "delete half progress with dry run", + seq: 2, + action: EventActionDelete, + path: "path", + progress: 0.5, + dryRun: true, + expected: `{"seq": 2, "type": "progress", "dry_run": true, "action": "delete", "path": "path", "progress": 0.5}`, + }, + { + name: "delete completed without dry run", + seq: 3, + action: EventActionDelete, + path: "path", + progress: 1.0, + dryRun: false, + expected: `{"seq": 3, "type": "progress", "action": "delete", "path": "path", "progress": 1.0}`, + }, + { + name: "delete completed with dry run", + seq: 3, + action: EventActionDelete, + path: "path", + progress: 1.0, + dryRun: true, + expected: `{"seq": 3, "type": "progress", "dry_run": true, "action": "delete", "path": "path", "progress": 1.0}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := newEventProgress(tt.seq, tt.action, tt.path, tt.progress, tt.dryRun) + jsonEqual(t, tt.expected, e) + }) + } } func TestEventComplete(t *testing.T) { - var e Event - - e = newEventComplete(0, []string{"put"}, []string{"delete"}) - assert.Equal(t, "Initial Sync Complete", e.String()) - - e = newEventComplete(1, []string{"put"}, []string{}) - assert.Equal(t, "Complete", e.String()) - - e = newEventComplete(2, []string{}, []string{"delete"}) - assert.Equal(t, "Complete", e.String()) - - e = newEventComplete(3, []string{}, []string{}) - assert.Equal(t, "", e.String()) + tests := []struct { + name string + seq int + put []string + delete []string + dryRun bool + expected string + }{ + { + name: "initial sync without dry run", + seq: 0, + put: []string{"put"}, + delete: []string{"delete"}, + dryRun: false, + expected: "Initial Sync Complete", + }, + { + name: "initial sync with dry run", + seq: 0, + put: []string{"put"}, + delete: []string{"delete"}, + dryRun: true, + expected: "Initial Sync Complete", + }, + { + name: "only put without dry run", + seq: 1, + put: []string{"put"}, + delete: []string{}, + dryRun: false, + expected: "Complete", + }, + { + name: "only put with dry run", + seq: 1, + put: []string{"put"}, + delete: []string{}, + dryRun: true, + expected: "Complete", + }, + { + name: "only delete without dry run", + seq: 2, + put: []string{}, + delete: []string{"delete"}, + dryRun: false, + expected: "Complete", + }, + { + name: "only delete with dry run", + seq: 2, + put: []string{}, + delete: []string{"delete"}, + dryRun: true, + expected: "Complete", + }, + { + name: "empty without dry run", + seq: 3, + put: []string{}, + delete: []string{}, + dryRun: false, + expected: "", + }, + { + name: "empty with dry run", + seq: 3, + put: []string{}, + delete: []string{}, + dryRun: true, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := newEventComplete(tt.seq, tt.put, tt.delete, tt.dryRun) + assert.Equal(t, tt.expected, e.String()) + }) + } } func TestEventCompleteJSON(t *testing.T) { - var e Event - - e = newEventComplete(0, []string{"put"}, []string{"delete"}) - jsonEqual(t, `{"seq": 0, "type": "complete", "put": ["put"], "delete": ["delete"]}`, e) - - e = newEventComplete(1, []string{"put"}, []string{}) - jsonEqual(t, `{"seq": 1, "type": "complete", "put": ["put"]}`, e) - - e = newEventComplete(2, []string{}, []string{"delete"}) - jsonEqual(t, `{"seq": 2, "type": "complete", "delete": ["delete"]}`, e) - - e = newEventComplete(3, []string{}, []string{}) - jsonEqual(t, `{"seq": 3, "type": "complete"}`, e) + tests := []struct { + name string + seq int + put []string + delete []string + dryRun bool + expected string + }{ + { + name: "put and delete without dry run", + seq: 0, + put: []string{"put"}, + delete: []string{"delete"}, + dryRun: false, + expected: `{"seq": 0, "type": "complete", "put": ["put"], "delete": ["delete"]}`, + }, + { + name: "put and delete with dry run", + seq: 0, + put: []string{"put"}, + delete: []string{"delete"}, + dryRun: true, + expected: `{"seq": 0, "type": "complete", "dry_run": true, "put": ["put"], "delete": ["delete"]}`, + }, + { + name: "only put without dry run", + seq: 1, + put: []string{"put"}, + delete: []string{}, + dryRun: false, + expected: `{"seq": 1, "type": "complete", "put": ["put"]}`, + }, + { + name: "only put with dry run", + seq: 1, + put: []string{"put"}, + delete: []string{}, + dryRun: true, + expected: `{"seq": 1, "type": "complete", "dry_run": true, "put": ["put"]}`, + }, + { + name: "only delete without dry run", + seq: 2, + put: []string{}, + delete: []string{"delete"}, + dryRun: false, + expected: `{"seq": 2, "type": "complete", "delete": ["delete"]}`, + }, + { + name: "only delete with dry run", + seq: 2, + put: []string{}, + delete: []string{"delete"}, + dryRun: true, + expected: `{"seq": 2, "type": "complete", "dry_run": true, "delete": ["delete"]}`, + }, + { + name: "empty without dry run", + seq: 3, + put: []string{}, + delete: []string{}, + dryRun: false, + expected: `{"seq": 3, "type": "complete"}`, + }, + { + name: "empty with dry run", + seq: 3, + put: []string{}, + delete: []string{}, + dryRun: true, + expected: `{"seq": 3, "type": "complete", "dry_run": true}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := newEventComplete(tt.seq, tt.put, tt.delete, tt.dryRun) + jsonEqual(t, tt.expected, e) + }) + } } diff --git a/libs/sync/sync.go b/libs/sync/sync.go index 4d14f745a9..dae65c7da3 100644 --- a/libs/sync/sync.go +++ b/libs/sync/sync.go @@ -41,6 +41,8 @@ type SyncOptions struct { Host string OutputHandler OutputHandler + + DryRun bool } type Sync struct { @@ -156,11 +158,11 @@ func (s *Sync) notifyStart(ctx context.Context, d diff) { if s.seq > 0 && d.IsEmpty() { return } - s.notifier.Notify(ctx, newEventStart(s.seq, d.put, d.delete)) + s.notifier.Notify(ctx, newEventStart(s.seq, d.put, d.delete, s.DryRun)) } func (s *Sync) notifyProgress(ctx context.Context, action EventAction, path string, progress float32) { - s.notifier.Notify(ctx, newEventProgress(s.seq, action, path, progress)) + s.notifier.Notify(ctx, newEventProgress(s.seq, action, path, progress, s.DryRun)) } func (s *Sync) notifyComplete(ctx context.Context, d diff) { @@ -168,7 +170,7 @@ func (s *Sync) notifyComplete(ctx context.Context, d diff) { if s.seq > 0 && d.IsEmpty() { return } - s.notifier.Notify(ctx, newEventComplete(s.seq, d.put, d.delete)) + s.notifier.Notify(ctx, newEventComplete(s.seq, d.put, d.delete, s.DryRun)) s.seq++ } @@ -199,10 +201,12 @@ func (s *Sync) RunOnce(ctx context.Context) ([]fileset.File, error) { return files, err } - err = s.snapshot.Save(ctx) - if err != nil { - log.Errorf(ctx, "cannot store snapshot: %s", err) - return files, err + if !s.DryRun { + err = s.snapshot.Save(ctx) + if err != nil { + log.Errorf(ctx, "cannot store snapshot: %s", err) + return files, err + } } s.notifyComplete(ctx, change) diff --git a/libs/sync/watchdog.go b/libs/sync/watchdog.go index cc2ca83c56..4a47acfb83 100644 --- a/libs/sync/watchdog.go +++ b/libs/sync/watchdog.go @@ -17,9 +17,11 @@ const MaxRequestsInFlight = 20 func (s *Sync) applyDelete(ctx context.Context, remoteName string) error { s.notifyProgress(ctx, EventActionDelete, remoteName, 0.0) - err := s.filer.Delete(ctx, remoteName) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - return err + if !s.DryRun { + err := s.filer.Delete(ctx, remoteName) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } } s.notifyProgress(ctx, EventActionDelete, remoteName, 1.0) @@ -30,10 +32,12 @@ func (s *Sync) applyDelete(ctx context.Context, remoteName string) error { func (s *Sync) applyRmdir(ctx context.Context, remoteName string) error { s.notifyProgress(ctx, EventActionDelete, remoteName, 0.0) - err := s.filer.Delete(ctx, remoteName) - if err != nil { - // Directory deletion is opportunistic, so we ignore errors. - log.Debugf(ctx, "error removing directory %s: %s", remoteName, err) + if !s.DryRun { + err := s.filer.Delete(ctx, remoteName) + if err != nil { + // Directory deletion is opportunistic, so we ignore errors. + log.Debugf(ctx, "error removing directory %s: %s", remoteName, err) + } } s.notifyProgress(ctx, EventActionDelete, remoteName, 1.0) @@ -44,9 +48,11 @@ func (s *Sync) applyRmdir(ctx context.Context, remoteName string) error { func (s *Sync) applyMkdir(ctx context.Context, localName string) error { s.notifyProgress(ctx, EventActionPut, localName, 0.0) - err := s.filer.Mkdir(ctx, localName) - if err != nil { - return err + if !s.DryRun { + err := s.filer.Mkdir(ctx, localName) + if err != nil { + return err + } } s.notifyProgress(ctx, EventActionPut, localName, 1.0) @@ -64,10 +70,12 @@ func (s *Sync) applyPut(ctx context.Context, localName string) error { defer localFile.Close() - opts := []filer.WriteMode{filer.CreateParentDirectories, filer.OverwriteIfExists} - err = s.filer.Write(ctx, localName, localFile, opts...) - if err != nil { - return err + if !s.DryRun { + opts := []filer.WriteMode{filer.CreateParentDirectories, filer.OverwriteIfExists} + err = s.filer.Write(ctx, localName, localFile, opts...) + if err != nil { + return err + } } s.notifyProgress(ctx, EventActionPut, localName, 1.0)