From b3c84ccaae912049ad6c8d4812bfae9e7db2a342 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Fri, 15 Aug 2025 13:04:51 +0200 Subject: [PATCH] oc rsync: Add --last flag The flag can be used to only sync N most recently modified files. The flag is mutually exclusive to: * --include * --exclude * --delete * --watch It is also ignored when the source is a local directory when using tar, because the implementation doesn't allow to select particular files. Generally when there is any problem when using --last, the error is ignored and sync happens as if the flag was not specified. Regarding implementation details, oc rsync performs an extras step when --last is specified, and that is discovering relevant files to select. This is done using manual directory walking when local, for remote the remote executor is used to invoke a shell using find+sort+head. The resulting filenames are then passed to --files-from for rsync, for tar they are simply passed to the command as arguments. Tests were added for testing the discovery mechanism, the rest has been tested manually. oc rsync is poorly unit-tested in general. Assisted-by: Claude Code --- pkg/cli/rsync/copy_rsync.go | 37 +++++++++- pkg/cli/rsync/copy_rsyncd.go | 45 +++++++++++- pkg/cli/rsync/copy_tar.go | 33 +++++++-- pkg/cli/rsync/copy_tar_test.go | 77 ++++++++++++++++++++ pkg/cli/rsync/discovery.go | 7 ++ pkg/cli/rsync/discovery_local.go | 65 +++++++++++++++++ pkg/cli/rsync/discovery_local_test.go | 98 ++++++++++++++++++++++++++ pkg/cli/rsync/discovery_remote.go | 53 ++++++++++++++ pkg/cli/rsync/discovery_remote_test.go | 88 +++++++++++++++++++++++ pkg/cli/rsync/discovery_test.go | 14 ++++ pkg/cli/rsync/exec_test.go | 27 +++++++ pkg/cli/rsync/rsync.go | 36 +++++++++- 12 files changed, 570 insertions(+), 10 deletions(-) create mode 100644 pkg/cli/rsync/copy_tar_test.go create mode 100644 pkg/cli/rsync/discovery.go create mode 100644 pkg/cli/rsync/discovery_local.go create mode 100644 pkg/cli/rsync/discovery_local_test.go create mode 100644 pkg/cli/rsync/discovery_remote.go create mode 100644 pkg/cli/rsync/discovery_remote_test.go create mode 100644 pkg/cli/rsync/discovery_test.go create mode 100644 pkg/cli/rsync/exec_test.go 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 }