diff --git a/pkg/cli/rsync/copy_rsync.go b/pkg/cli/rsync/copy_rsync.go index 91c7ce3bae..4ac773424e 100644 --- a/pkg/cli/rsync/copy_rsync.go +++ b/pkg/cli/rsync/copy_rsync.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -27,6 +28,9 @@ type rsyncStrategy struct { LocalExecutor executor RemoteExecutor executor podChecker podChecker + + Last uint + fileDiscovery fileDiscoverer } // DefaultRsyncRemoteShellToUse generates an command to create a remote shell. @@ -75,15 +79,44 @@ func NewRsyncStrategy(o *RsyncOptions) CopyStrategy { RemoteExecutor: newRemoteExecutor(o), LocalExecutor: newLocalExecutor(), podChecker: podAPIChecker{o.Client, o.Namespace, podName, o.ContainerName, o.Quiet, o.ErrOut}, + Last: o.Last, + fileDiscovery: o.fileDiscovery, } } func (r *rsyncStrategy) Copy(source, destination *PathSpec, out, errOut io.Writer) error { klog.V(3).Infof("Copying files with rsync") + + // In case --last is specified, discover the right files and pass them to rsync as an explicit list. + var ( + in io.Reader + dst = destination.RsyncPath() + ) + if r.Last > 0 { + filenames, err := r.fileDiscovery.DiscoverFiles(source.Path, r.Last) + if err != nil { + klog.Infof("Warning: failed to apply --last filtering: %v", err) + } else { + var b bytes.Buffer + for _, filename := range filenames { + b.WriteString(filename) + b.WriteRune('\n') + } + in = &b + klog.V(3).Infof("Applied --last=%d to rsync strategy: using %d files", r.Last, len(filenames)) + } + + // Make dst compatible with what rsync does without --last. + dst = filepath.Join(dst, filepath.Base(source.Path)) + } + cmd := append([]string{"rsync"}, r.Flags...) - cmd = append(cmd, "-e", r.RshCommand, source.RsyncPath(), destination.RsyncPath()) + if in != nil { + cmd = append(cmd, "--files-from", "-") + } + cmd = append(cmd, "-e", r.RshCommand, source.RsyncPath(), dst) errBuf := &bytes.Buffer{} - err := r.LocalExecutor.Execute(cmd, nil, out, errBuf) + err := r.LocalExecutor.Execute(cmd, in, out, errBuf) if isExitError(err) { // Check if pod exists if podCheckErr := r.podChecker.CheckPod(); podCheckErr != nil { diff --git a/pkg/cli/rsync/copy_rsyncd.go b/pkg/cli/rsync/copy_rsyncd.go index ce9b739e61..a6740c1e52 100644 --- a/pkg/cli/rsync/copy_rsyncd.go +++ b/pkg/cli/rsync/copy_rsyncd.go @@ -7,6 +7,7 @@ import ( "io" "math/rand" "net" + "path/filepath" "strconv" "strings" "time" @@ -67,6 +68,9 @@ type rsyncDaemonStrategy struct { PortForwarder forwarder LocalExecutor executor + Last uint + fileDiscovery fileDiscoverer + daemonPIDFile string daemonPort int localPort int @@ -220,7 +224,40 @@ func (s *rsyncDaemonStrategy) stopPortForward() { func (s *rsyncDaemonStrategy) copyUsingDaemon(source, destination *PathSpec, out, errOut io.Writer) error { klog.V(3).Infof("Copying files with rsync daemon") + + // In case --last is specified, discover the right files and pass them to rsync as an explicit list. + var ( + in io.Reader + dst string + ) + if destination.Local() { + dst = destination.RsyncPath() + } else { + dst = destination.Path + } + if s.Last > 0 { + filenames, err := s.fileDiscovery.DiscoverFiles(source.Path, s.Last) + if err != nil { + klog.Infof("Warning: failed to apply --last filtering: %v", err) + } else { + var b bytes.Buffer + for _, filename := range filenames { + b.WriteString(filename) + b.WriteRune('\n') + } + in = &b + klog.V(3).Infof("Applied --last=%d to rsync-daemon strategy: using %d files", s.Last, len(filenames)) + } + + // Make dst compatible with what rsync does without --last. + dst = filepath.Join(dst, filepath.Base(source.Path)) + } + cmd := append([]string{"rsync"}, s.Flags...) + if in != nil { + cmd = append(cmd, "--files-from", "-") + } + var sourceArg, destinationArg string if source.Local() { sourceArg = source.RsyncPath() @@ -228,12 +265,12 @@ func (s *rsyncDaemonStrategy) copyUsingDaemon(source, destination *PathSpec, out sourceArg = localRsyncURL(s.localPort, remoteLabel, source.Path) } if destination.Local() { - destinationArg = destination.RsyncPath() + destinationArg = dst } else { - destinationArg = localRsyncURL(s.localPort, remoteLabel, destination.Path) + destinationArg = localRsyncURL(s.localPort, remoteLabel, dst) } cmd = append(cmd, sourceArg, destinationArg) - err := s.LocalExecutor.Execute(cmd, nil, out, errOut) + err := s.LocalExecutor.Execute(cmd, in, out, errOut) if err != nil { // Determine whether rsync is present in the pod container testRsyncErr := executeWithLogging(s.RemoteExecutor, testRsyncCommand) @@ -297,6 +334,8 @@ func NewRsyncDaemonStrategy(o *RsyncOptions) CopyStrategy { RemoteExecutor: remoteExec, LocalExecutor: newLocalExecutor(), PortForwarder: forwarder, + Last: o.Last, + fileDiscovery: o.fileDiscovery, } } diff --git a/pkg/cli/rsync/copy_tar.go b/pkg/cli/rsync/copy_tar.go index ea0708393d..931f6722fc 100644 --- a/pkg/cli/rsync/copy_tar.go +++ b/pkg/cli/rsync/copy_tar.go @@ -30,6 +30,7 @@ type tarStrategy struct { RemoteExecutor executor Includes []string Excludes []string + Last uint IgnoredFlags []string Flags []string } @@ -44,15 +45,35 @@ func NewTarStrategy(o *RsyncOptions) CopyStrategy { remoteExec := newRemoteExecutor(o) + // Handle --last option by discovering N most recently modified files. + includes := append([]string(nil), o.RsyncInclude...) + if o.Last > 0 { + if o.Source.Local() { + klog.Info("Warning: --last flag is ignored when creating a local tar file") + } else { + filenames, err := o.fileDiscovery.DiscoverFiles(o.Source.Path, o.Last) + if err != nil { + klog.Infof("Warning: failed to apply --last filtering: %v", err) + } else { + // Replace any existing includes with our filtered list. + if len(filenames) > 0 { + includes = filenames + klog.V(3).Infof("Applied --last=%d to tar strategy: using %d files", o.Last, len(filenames)) + } + } + } + } + return &tarStrategy{ Quiet: o.Quiet, Delete: o.Delete, - Includes: o.RsyncInclude, + Includes: includes, Excludes: o.RsyncExclude, Tar: tarHelper, RemoteExecutor: remoteExec, IgnoredFlags: ignoredFlags, Flags: tarFlagsFromOptions(o), + Last: o.Last, } } @@ -145,7 +166,7 @@ func (r *tarStrategy) Copy(source, destination *PathSpec, out, errOut io.Writer) } else { klog.V(4).Infof("Creating local tar file %s from remote path %s", tmp.Name(), source.Path) errBuf := &bytes.Buffer{} - err = tarRemote(r.RemoteExecutor, source.Path, r.Includes, r.Excludes, tmp, errBuf) + err = tarRemote(r.RemoteExecutor, source.Path, r.Includes, r.Excludes, r.Last > 0, tmp, errBuf) if err != nil { if checkTar(r.RemoteExecutor) != nil { return strategySetupError("tar not available in container") @@ -198,7 +219,7 @@ func (r *tarStrategy) String() string { return "tar" } -func tarRemote(exec executor, sourceDir string, includes, excludes []string, out, errOut io.Writer) error { +func tarRemote(exec executor, sourceDir string, includes, excludes []string, noRecursion bool, out, errOut io.Writer) error { klog.V(4).Infof("Tarring %s remotely", sourceDir) exclude := []string{} @@ -220,7 +241,11 @@ func tarRemote(exec executor, sourceDir string, includes, excludes []string, out include = append(include, path.Join(path.Base(sourceDir), pattern)) } - cmd = []string{"tar", "-C", path.Dir(sourceDir), "-c", path.Base(sourceDir)} + cmd = []string{"tar", "-C", path.Dir(sourceDir)} + if noRecursion { + cmd = append(cmd, "--no-recursion") + } + cmd = append(cmd, "-c", path.Base(sourceDir)) cmd = append(cmd, append(include, exclude...)...) } klog.V(4).Infof("Remote tar command: %s", strings.Join(cmd, " ")) diff --git a/pkg/cli/rsync/copy_tar_test.go b/pkg/cli/rsync/copy_tar_test.go new file mode 100644 index 0000000000..b472887620 --- /dev/null +++ b/pkg/cli/rsync/copy_tar_test.go @@ -0,0 +1,77 @@ +package rsync + +import ( + "errors" + "sort" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// TestNewTarStrategy_FileDiscovery tests the specific file discovery logic in NewTarStrategy. +func TestNewTarStrategy_FileDiscovery(t *testing.T) { + testCases := []struct { + name string + originalIncludes []string + discoveredFiles []string + discoveryError error + expectedIncludes []string + }{ + { + name: "discovery finds files - replaces original includes", + originalIncludes: []string{"*.log", "*.txt"}, + discoveredFiles: []string{"newest.log", "middle.log", "oldest.log"}, + expectedIncludes: []string{"newest.log", "middle.log", "oldest.log"}, + }, + { + name: "discovery finds no files - keeps original includes", + originalIncludes: []string{"*.log", "*.txt"}, + discoveredFiles: []string{}, + expectedIncludes: []string{"*.log", "*.txt"}, + }, + { + name: "discovery fails - keeps original includes", + originalIncludes: []string{"*.log", "*.txt"}, + discoveryError: errors.New("command failed"), + expectedIncludes: []string{"*.log", "*.txt"}, + }, + { + name: "no original includes but discovery finds files", + originalIncludes: []string{}, + discoveredFiles: []string{"file1.txt", "file2.txt"}, + expectedIncludes: []string{"file1.txt", "file2.txt"}, + }, + { + name: "no original includes and no discovery", + originalIncludes: []string{}, + discoveredFiles: []string{}, + expectedIncludes: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Init the strategy. + options := &RsyncOptions{ + RsyncInclude: tc.originalIncludes, + Last: 3, // Enable file discovery + Source: &PathSpec{PodName: "test-pod", Path: "/test/path"}, + Destination: &PathSpec{Path: "/local/path"}, + fileDiscovery: &mockFileDiscoverer{ + files: tc.discoveredFiles, + err: tc.discoveryError, + }, + } + + strategy := NewTarStrategy(options).(*tarStrategy) + + // Verify the result matches expectations. + sort.Strings(strategy.Includes) + sort.Strings(tc.expectedIncludes) + if !cmp.Equal(strategy.Includes, tc.expectedIncludes) { + t.Errorf("expected includes mismatch: \n%s\n", + cmp.Diff(tc.expectedIncludes, strategy.Includes)) + } + }) + } +} diff --git a/pkg/cli/rsync/discovery.go b/pkg/cli/rsync/discovery.go new file mode 100644 index 0000000000..d979c5c77a --- /dev/null +++ b/pkg/cli/rsync/discovery.go @@ -0,0 +1,7 @@ +package rsync + +// fileDiscoverer discovers files at the given path, +// limiting the list to lastN most recently modified files. +type fileDiscoverer interface { + DiscoverFiles(basePath string, lastN uint) ([]string, error) +} diff --git a/pkg/cli/rsync/discovery_local.go b/pkg/cli/rsync/discovery_local.go new file mode 100644 index 0000000000..b43ead3731 --- /dev/null +++ b/pkg/cli/rsync/discovery_local.go @@ -0,0 +1,65 @@ +package rsync + +import ( + "fmt" + "os" + "sort" + "time" + + "k8s.io/klog/v2" +) + +// localFileDiscoverer implements fileDiscoverer interface for local directories. +type localFileDiscoverer struct{} + +func newLocalFileDiscoverer() localFileDiscoverer { + return localFileDiscoverer{} +} + +func (discoverer localFileDiscoverer) DiscoverFiles(basePath string, last uint) ([]string, error) { + klog.V(4).Infof("Discovering files in local directory %s (last = %d)", basePath, last) + + entries, err := os.ReadDir(basePath) + if err != nil { + return nil, fmt.Errorf("failed to read directory %s: %w", basePath, err) + } + + type fileInfo struct { + name string + modTime time.Time + } + + files := make([]fileInfo, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue // Skip directories, only process regular files. + } + + info, err := entry.Info() + if err != nil { + return nil, fmt.Errorf("failed to get file info for %s: %w", entry.Name(), err) + } + + files = append(files, fileInfo{ + name: entry.Name(), + modTime: info.ModTime(), + }) + } + + // Sort by modification time (newest first). + sort.Slice(files, func(i, j int) bool { + return files[i].modTime.After(files[j].modTime) + }) + + // Limit to the latest N files. + if len(files) > int(last) { + files = files[:last] + } + + // Extract just the file names (relative paths). + result := make([]string, len(files)) + for i, file := range files { + result[i] = file.name + } + return result, nil +} diff --git a/pkg/cli/rsync/discovery_local_test.go b/pkg/cli/rsync/discovery_local_test.go new file mode 100644 index 0000000000..0473f8ea88 --- /dev/null +++ b/pkg/cli/rsync/discovery_local_test.go @@ -0,0 +1,98 @@ +package rsync + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +// TestLocalFileDiscoverer tests the local file discovery with temporary files. +func TestLocalFileDiscoverer(t *testing.T) { + // Create temporary directory with test files. + tempDir, err := os.MkdirTemp("", "oc-rsync-test-") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create test files with different modification times. + baseTime := time.Now() + testFiles := []struct { + name string + modTime time.Time + }{ + {"03_newest.log", baseTime.Add(-1 * time.Minute)}, // newest + {"02_middle.log", baseTime.Add(-5 * time.Minute)}, // middle + {"01_oldest.log", baseTime.Add(-10 * time.Minute)}, // oldest + } + + for _, file := range testFiles { + // Create the file. + filePath := filepath.Join(tempDir, file.name) + f, err := os.Create(filePath) + if err != nil { + t.Fatalf("failed to create test file %s: %v", filePath, err) + } + f.Close() + + // Set modification time. + if err := os.Chtimes(filePath, file.modTime, file.modTime); err != nil { + t.Fatalf("failed to set mtime for %s: %v", filePath, err) + } + } + + // Create a subdirectory (which should be ignored). + subDir := filepath.Join(tempDir, "subdir") + err = os.Mkdir(subDir, 0755) + if err != nil { + t.Fatalf("failed to create subdirectory: %v", err) + } + + testCases := []struct { + name string + last uint + expectedFiles []string + }{ + { + name: "limit to 2 files", + last: 2, + expectedFiles: []string{ + "03_newest.log", + "02_middle.log", + }, + }, + { + name: "limit higher than available files", + last: 5, + expectedFiles: []string{ + "03_newest.log", + "02_middle.log", + "01_oldest.log", + }, + }, + { + name: "limit to 1 file", + last: 1, + expectedFiles: []string{ + "03_newest.log", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + files, err := newLocalFileDiscoverer().DiscoverFiles(tempDir, tc.last) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if !cmp.Equal(files, tc.expectedFiles) { + t.Errorf("expected files mismatch: \n%s\n", cmp.Diff(tc.expectedFiles, files)) + } + }) + } +} diff --git a/pkg/cli/rsync/discovery_remote.go b/pkg/cli/rsync/discovery_remote.go new file mode 100644 index 0000000000..972d7770d1 --- /dev/null +++ b/pkg/cli/rsync/discovery_remote.go @@ -0,0 +1,53 @@ +package rsync + +import ( + "bufio" + "bytes" + "fmt" + "path/filepath" + "strings" +) + +// remoteFileDiscoverer implements fileDiscoverer interface for remote directories. +type remoteFileDiscoverer struct { + exec executor +} + +func newRemoteFileDiscoverer(remoteExec executor) remoteFileDiscoverer { + return remoteFileDiscoverer{ + exec: remoteExec, + } +} + +func (discoverer remoteFileDiscoverer) DiscoverFiles(basePath string, lastN uint) ([]string, error) { + // Use find + sort + head to get only the latest N files directly. + var ( + output bytes.Buffer + errOutput bytes.Buffer + ) + cmd := []string{"sh", "-c", fmt.Sprintf("find '%s' -maxdepth 1 -type f -printf '%%T@ %%p\\n' | sort -rn | head -n %d", basePath, lastN)} + if err := discoverer.exec.Execute(cmd, nil, &output, &errOutput); err != nil { + return nil, fmt.Errorf("failed to execute remote find+sort+head command: %w, stderr: %s", err, errOutput.String()) + } + + // Extract file paths from the output. + scanner := bufio.NewScanner(&output) + filenames := make([]string, 0) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + parts := strings.SplitN(line, " ", 2) + if len(parts) == 2 { + // parts[1] is the full file path. We need filename only. + fullPath := parts[1] + filename := filepath.Base(fullPath) + filenames = append(filenames, filename) + } else { + return nil, fmt.Errorf("failed to parse remote command output line: %s", line) + } + } + return filenames, nil +} diff --git a/pkg/cli/rsync/discovery_remote_test.go b/pkg/cli/rsync/discovery_remote_test.go new file mode 100644 index 0000000000..6d2901bd86 --- /dev/null +++ b/pkg/cli/rsync/discovery_remote_test.go @@ -0,0 +1,88 @@ +package rsync + +import ( + "fmt" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// TestRemoteFileDiscoverer tests the remote file discovery with mocked executor. +func TestRemoteFileDiscoverer(t *testing.T) { + testCases := []struct { + name string + basePath string + last uint + mockOutput string + mockError error + expectedFiles []string + expectedError bool + }{ + { + name: "successful discovery with 3 files", + basePath: "/test/path", + last: 3, + mockOutput: `12345.123 /test/path/03_newest.log +12345.456 /test/path/02_middle.log +12345.789 /test/path/01_oldest.log`, + expectedFiles: []string{ + "03_newest.log", + "02_middle.log", + "01_oldest.log", + }, + }, + { + name: "discovery with fewer files than limit", + basePath: "/test/path", + last: 5, + mockOutput: `12345.123 /test/path/01_file.log +12345.456 /test/path/02_file.log`, + expectedFiles: []string{ + "01_file.log", + "02_file.log", + }, + }, + { + name: "empty directory", + basePath: "/test/empty", + last: 3, + mockOutput: "", + expectedFiles: []string{}, + }, + { + name: "command execution error", + basePath: "/test/error", + last: 3, + mockError: fmt.Errorf("command failed"), + expectedError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + executor := &mockExecutor{ + t: t, + expectedCommand: []string{ + "sh", "-c", + `find '` + tc.basePath + `' -maxdepth 1 -type f -printf '%T@ %p\n' | sort -rn | head -n ` + strconv.Itoa(int(tc.last)), + }, + output: tc.mockOutput, + err: tc.mockError, + } + + files, err := newRemoteFileDiscoverer(executor).DiscoverFiles(tc.basePath, tc.last) + if tc.expectedError { + if err == nil { + t.Fatal("expected error but got none") + } + } else if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !cmp.Equal(files, tc.expectedFiles) { + t.Errorf("expected files mismatch: \n%s\n", cmp.Diff(tc.expectedFiles, files)) + } + }) + } +} diff --git a/pkg/cli/rsync/discovery_test.go b/pkg/cli/rsync/discovery_test.go new file mode 100644 index 0000000000..030162b730 --- /dev/null +++ b/pkg/cli/rsync/discovery_test.go @@ -0,0 +1,14 @@ +package rsync + +// mockFileDiscoverer implements the fileDiscoverer interface for testing. +type mockFileDiscoverer struct { + files []string + err error +} + +func (m *mockFileDiscoverer) DiscoverFiles(basePath string, lastN uint) ([]string, error) { + if m.err != nil { + return nil, m.err + } + return m.files, nil +} diff --git a/pkg/cli/rsync/exec_test.go b/pkg/cli/rsync/exec_test.go new file mode 100644 index 0000000000..aa64749bcf --- /dev/null +++ b/pkg/cli/rsync/exec_test.go @@ -0,0 +1,27 @@ +package rsync + +import ( + "io" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// mockExecutor implements the executor interface for testing. +type mockExecutor struct { + t *testing.T + expectedCommand []string + output string + err error +} + +func (m *mockExecutor) Execute(cmd []string, in io.Reader, out, errOut io.Writer) error { + if !cmp.Equal(m.expectedCommand, cmd) { + m.t.Errorf("unexpected remote command invoked: \n%s\n", cmp.Diff(m.expectedCommand, cmd)) + } + if m.err != nil { + return m.err + } + out.Write([]byte(m.output)) + return nil +} diff --git a/pkg/cli/rsync/rsync.go b/pkg/cli/rsync/rsync.go index 4b89bc3ead..9a6628ebe9 100644 --- a/pkg/cli/rsync/rsync.go +++ b/pkg/cli/rsync/rsync.go @@ -41,6 +41,9 @@ var ( If no container is specified, the first container of the pod is used for the copy. + The --last flag can be used to limit the number of files copied based + on modification time, copying only the N most recently modified files. + The following flags are passed to rsync by default: --archive --no-owner --no-group --omit-dir-times --numeric-ids `) @@ -51,6 +54,9 @@ var ( # Synchronize a pod directory with a local directory oc rsync POD:/remote/dir/ ./local/dir + + # Copy only the 10 most recently modified files from a pod directory + oc rsync --last=10 POD:/remote/dir/ ./local/dir `) rsyncDefaultFlags = []string{"--archive", "--no-owner", "--no-group", "--omit-dir-times", "--numeric-ids"} @@ -86,6 +92,7 @@ type RsyncOptions struct { Destination *PathSpec Strategy CopyStrategy StrategyName string + Last uint Quiet bool Delete bool Watch bool @@ -101,6 +108,10 @@ type RsyncOptions struct { Config *rest.Config Client kubernetes.Interface genericiooptions.IOStreams + + // fileDiscovery is used when --last is specified. + // It is present in RsyncOptions so that it can be mocked for tests easily. + fileDiscovery fileDiscoverer } func NewRsyncOptions(streams genericiooptions.IOStreams) *RsyncOptions { @@ -128,6 +139,8 @@ func NewCmdRsync(f kcmdutil.Factory, streams genericiooptions.IOStreams) *cobra. cmd.Flags().StringVarP(&o.ContainerName, "container", "c", "", "Container within the pod") cmd.Flags().StringVar(&o.StrategyName, "strategy", "", "Specify which strategy to use for copy: rsync, rsync-daemon, or tar") + cmd.Flags().UintVar(&o.Last, "last", 0, "Copy only N most recently modified files") + // Flags for rsync options, Must match rsync flag names cmd.Flags().BoolVarP(&o.Quiet, "quiet", "q", false, "Suppress non-error messages") cmd.Flags().BoolVar(&o.Delete, "delete", false, "If true, delete files not present in source") @@ -221,11 +234,20 @@ func (o *RsyncOptions) Complete(f kcmdutil.Factory, cmd *cobra.Command, args []s o.EnableSuggestedCmdUsage = len(fullCmdName) > 0 && kcmdutil.IsSiblingCommandExists(cmd, "describe") o.RshCmd = DefaultRsyncRemoteShellToUse(cmd) + // Set up file discovery in case --last is specified. + // This must happen before the strategy is initialized. + if o.Last > 0 { + if o.Source.Local() { + o.fileDiscovery = newLocalFileDiscoverer() + } else { + o.fileDiscovery = newRemoteFileDiscoverer(newRemoteExecutor(o)) + } + } + o.Strategy, err = o.GetCopyStrategy(o.StrategyName) if err != nil { return err } - return nil } @@ -251,6 +273,18 @@ func (o *RsyncOptions) Validate() error { if o.Destination.Local() && o.Watch { return errors.New("\"--watch\" can only be used with a local source directory") } + if o.Last > 0 { + switch { + case o.Watch: + return errors.New("\"--last\" cannot be used with \"--watch\"") + case len(o.RsyncInclude) > 0: + return errors.New("\"--include\" cannot be used with \"--last\"") + case len(o.RsyncExclude) > 0: + return errors.New("\"--exclude\" cannot be used with \"--last\"") + case o.Delete: + return errors.New("\"--delete\" cannot be used with \"--last\"") + } + } if err := o.Strategy.Validate(); err != nil { return err }