diff --git a/cmd/limactl/copy.go b/cmd/limactl/copy.go index 63072fc3943..14904101cf1 100644 --- a/cmd/limactl/copy.go +++ b/cmd/limactl/copy.go @@ -4,23 +4,10 @@ package main import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - - "github.com/coreos/go-semver/semver" "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "github.com/lima-vm/lima/v2/pkg/ioutilx" - "github.com/lima-vm/lima/v2/pkg/limatype" - "github.com/lima-vm/lima/v2/pkg/sshutil" - "github.com/lima-vm/lima/v2/pkg/store" + "github.com/lima-vm/lima/v2/pkg/copytool" ) const copyHelp = `Copy files between host and guest @@ -52,21 +39,6 @@ const copyExample = ` limactl copy file1.txt file2.txt default:/tmp/ ` -type copyTool string - -const ( - rsync copyTool = "rsync" - scp copyTool = "scp" - auto copyTool = "auto" -) - -type copyPath struct { - instanceName string - path string - isRemote bool - instance *limatype.Instance -} - func newCopyCommand() *cobra.Command { copyCommand := &cobra.Command{ Use: "copy SOURCE ... TARGET", @@ -107,32 +79,21 @@ func copyAction(cmd *cobra.Command, args []string) error { verbose = true } - copyPaths, err := parseCopyArgs(ctx, args) - if err != nil { - return err - } - backend, err := cmd.Flags().GetString("backend") if err != nil { return err } - cpTool, toolPath, err := selectCopyTool(ctx, copyPaths, backend) + cpTool, err := copytool.New(ctx, backend, args, ©tool.Options{ + Recursive: recursive, + Verbose: verbose, + }) if err != nil { return err } + logrus.Debugf("using copy tool %q", cpTool.Name()) - logrus.Debugf("using copy tool %q", toolPath) - - var copyCmd *exec.Cmd - switch cpTool { - case scp: - copyCmd, err = scpCommand(ctx, toolPath, copyPaths, verbose, recursive) - case rsync: - copyCmd, err = rsyncCommand(ctx, toolPath, copyPaths, verbose, recursive) - default: - err = fmt.Errorf("invalid copy tool %q", cpTool) - } + copyCmd, err := cpTool.Command(ctx, args, nil) if err != nil { return err } @@ -145,282 +106,3 @@ func copyAction(cmd *cobra.Command, args []string) error { // TODO: use syscall.Exec directly (results in losing tty?) return copyCmd.Run() } - -func parseCopyArgs(ctx context.Context, args []string) ([]*copyPath, error) { - var copyPaths []*copyPath - - for _, arg := range args { - cp := ©Path{} - - if runtime.GOOS == "windows" { - if filepath.IsAbs(arg) { - var err error - arg, err = ioutilx.WindowsSubsystemPath(ctx, arg) - if err != nil { - return nil, err - } - } else { - arg = filepath.ToSlash(arg) - } - } - - parts := strings.SplitN(arg, ":", 2) - switch len(parts) { - case 1: - cp.path = arg - cp.isRemote = false - case 2: - cp.instanceName = parts[0] - cp.path = parts[1] - cp.isRemote = true - - inst, err := store.Inspect(ctx, cp.instanceName) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("instance %q does not exist, run `limactl create %s` to create a new instance", cp.instanceName, cp.instanceName) - } - return nil, err - } - if inst.Status == limatype.StatusStopped { - return nil, fmt.Errorf("instance %q is stopped, run `limactl start %s` to start the instance", cp.instanceName, cp.instanceName) - } - cp.instance = inst - default: - return nil, fmt.Errorf("path %q contains multiple colons", arg) - } - - copyPaths = append(copyPaths, cp) - } - - return copyPaths, nil -} - -func selectCopyTool(ctx context.Context, copyPaths []*copyPath, backend string) (copyTool, string, error) { - switch copyTool(backend) { - case scp: - scpPath, err := exec.LookPath("scp") - if err != nil { - return "", "", fmt.Errorf("scp not found on host: %w", err) - } - return scp, scpPath, nil - case rsync: - rsyncPath, err := exec.LookPath("rsync") - if err != nil { - return "", "", fmt.Errorf("rsync not found on host: %w", err) - } - if !rsyncAvailableOnGuests(ctx, copyPaths) { - return "", "", errors.New("rsync not available on guest(s)") - } - return rsync, rsyncPath, nil - case auto: - if rsyncPath, err := exec.LookPath("rsync"); err == nil { - if rsyncAvailableOnGuests(ctx, copyPaths) { - return rsync, rsyncPath, nil - } - logrus.Debugf("rsync not available on guest(s), falling back to scp") - } else { - logrus.Debugf("rsync not found on host, falling back to scp: %v", err) - } - - scpPath, err := exec.LookPath("scp") - if err != nil { - return "", "", fmt.Errorf("neither rsync nor scp found on host: %w", err) - } - return scp, scpPath, nil - default: - return "", "", fmt.Errorf("invalid backend %q, must be one of: scp, rsync, auto", backend) - } -} - -func rsyncAvailableOnGuests(ctx context.Context, copyPaths []*copyPath) bool { - instances := make(map[string]*limatype.Instance) - - for _, cp := range copyPaths { - if cp.isRemote { - instances[cp.instanceName] = cp.instance - } - } - - for instName, inst := range instances { - if !checkRsyncOnGuest(ctx, inst) { - logrus.Debugf("rsync not available on instance %q", instName) - return false - } - } - - return true -} - -func checkRsyncOnGuest(ctx context.Context, inst *limatype.Instance) bool { - sshExe, err := sshutil.NewSSHExe() - if err != nil { - logrus.Debugf("failed to create SSH executable: %v", err) - return false - } - sshOpts, err := sshutil.SSHOpts(ctx, sshExe, inst.Dir, *inst.Config.User.Name, false, false, false, false) - if err != nil { - logrus.Debugf("failed to get SSH options for rsync check: %v", err) - return false - } - - sshArgs := sshutil.SSHArgsFromOpts(sshOpts) - checkCmd := exec.CommandContext(ctx, "ssh") - checkCmd.Args = append(checkCmd.Args, sshArgs...) - checkCmd.Args = append(checkCmd.Args, - "-p", fmt.Sprintf("%d", inst.SSHLocalPort), - fmt.Sprintf("%s@127.0.0.1", *inst.Config.User.Name), - "command -v rsync >/dev/null 2>&1", - ) - - err = checkCmd.Run() - return err == nil -} - -func scpCommand(ctx context.Context, command string, copyPaths []*copyPath, verbose, recursive bool) (*exec.Cmd, error) { - instances := make(map[string]*limatype.Instance) - scpFlags := []string{} - scpArgs := []string{} - - if verbose { - scpFlags = append(scpFlags, "-v") - } else { - scpFlags = append(scpFlags, "-q") - } - - if recursive { - scpFlags = append(scpFlags, "-r") - } - - // this assumes that ssh and scp come from the same place, but scp has no -V - sshExeForVersion, err := sshutil.NewSSHExe() - if err != nil { - return nil, err - } - legacySSH := sshutil.DetectOpenSSHVersion(ctx, sshExeForVersion).LessThan(*semver.New("8.0.0")) - - for _, cp := range copyPaths { - if cp.isRemote { - if legacySSH { - scpFlags = append(scpFlags, "-P", fmt.Sprintf("%d", cp.instance.SSHLocalPort)) - scpArgs = append(scpArgs, fmt.Sprintf("%s@127.0.0.1:%s", *cp.instance.Config.User.Name, cp.path)) - } else { - scpArgs = append(scpArgs, fmt.Sprintf("scp://%s@127.0.0.1:%d/%s", *cp.instance.Config.User.Name, cp.instance.SSHLocalPort, cp.path)) - } - instances[cp.instanceName] = cp.instance - } else { - scpArgs = append(scpArgs, cp.path) - } - } - - if legacySSH && len(instances) > 1 { - return nil, errors.New("more than one (instance) host is involved in this command, this is only supported for openSSH v8.0 or higher") - } - - scpFlags = append(scpFlags, "-3", "--") - scpArgs = append(scpFlags, scpArgs...) - - var sshOpts []string - if len(instances) == 1 { - // Only one (instance) host is involved; we can use the instance-specific - // arguments such as ControlPath. This is preferred as we can multiplex - // sessions without re-authenticating (MaxSessions permitting). - for _, inst := range instances { - sshExe, err := sshutil.NewSSHExe() - if err != nil { - return nil, err - } - sshOpts, err = sshutil.SSHOpts(ctx, sshExe, inst.Dir, *inst.Config.User.Name, false, false, false, false) - if err != nil { - return nil, err - } - } - } else { - // Copying among multiple hosts; we can't pass in host-specific options. - sshExe, err := sshutil.NewSSHExe() - if err != nil { - return nil, err - } - sshOpts, err = sshutil.CommonOpts(ctx, sshExe, false) - if err != nil { - return nil, err - } - } - sshArgs := sshutil.SSHArgsFromOpts(sshOpts) - - return exec.CommandContext(ctx, command, append(sshArgs, scpArgs...)...), nil -} - -func rsyncCommand(ctx context.Context, command string, copyPaths []*copyPath, verbose, recursive bool) (*exec.Cmd, error) { - rsyncFlags := []string{"-a"} - - if verbose { - rsyncFlags = append(rsyncFlags, "-v", "--progress") - } else { - rsyncFlags = append(rsyncFlags, "-q") - } - - if recursive { - rsyncFlags = append(rsyncFlags, "-r") - } - - rsyncArgs := make([]string, 0, len(rsyncFlags)+len(copyPaths)) - rsyncArgs = append(rsyncArgs, rsyncFlags...) - - var sshCmd string - var remoteInstance *limatype.Instance - - for _, cp := range copyPaths { - if cp.isRemote { - if remoteInstance == nil { - remoteInstance = cp.instance - sshExe, err := sshutil.NewSSHExe() - if err != nil { - return nil, err - } - sshOpts, err := sshutil.SSHOpts(ctx, sshExe, cp.instance.Dir, *cp.instance.Config.User.Name, false, false, false, false) - if err != nil { - return nil, err - } - - sshArgs := sshutil.SSHArgsFromOpts(sshOpts) - sshCmd = fmt.Sprintf("ssh -p %d %s", cp.instance.SSHLocalPort, strings.Join(sshArgs, " ")) - } - } - } - - if sshCmd != "" { - rsyncArgs = append(rsyncArgs, "-e", sshCmd) - } - - // Handle trailing slash for directory copies to keep consistent behavior with scp, - // which was the original implementation of `limactl copy -r`. - // https://github.com/lima-vm/lima/issues/4468 - if recursive { - for i, cp := range copyPaths { - //nolint:modernize // stringscutprefix: HasSuffix + TrimSuffix can be simplified to CutSuffix - if strings.HasSuffix(cp.path, "/") { - if cp.isRemote { - for j, cp2 := range copyPaths { - if i != j { - cp2.path = strings.TrimSuffix(cp2.path, "/") - } - } - } else { - cp.path = strings.TrimSuffix(cp.path, "/") - } - } else { - cp.path += "/" - } - } - } - - for _, cp := range copyPaths { - if cp.isRemote { - rsyncArgs = append(rsyncArgs, fmt.Sprintf("%s@127.0.0.1:%s", *cp.instance.Config.User.Name, cp.path)) - } else { - rsyncArgs = append(rsyncArgs, cp.path) - } - } - - return exec.CommandContext(ctx, command, rsyncArgs...), nil -} diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go index c34f64a58f6..dc9878870e0 100644 --- a/cmd/limactl/shell.go +++ b/cmd/limactl/shell.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "github.com/lima-vm/lima/v2/pkg/autostart" + "github.com/lima-vm/lima/v2/pkg/copytool" "github.com/lima-vm/lima/v2/pkg/envutil" "github.com/lima-vm/lima/v2/pkg/instance" "github.com/lima-vm/lima/v2/pkg/ioutilx" @@ -198,7 +199,7 @@ func shellAction(cmd *cobra.Command, args []string) error { logrus.WithError(err).Warn("failed to get the current directory") } if syncHostWorkdir { - if _, err := exec.LookPath("rsync"); err != nil { + if _, err := exec.LookPath(string(copytool.BackendRsync)); err != nil { return fmt.Errorf("rsync is required for `--sync` but not found: %w", err) } @@ -318,7 +319,10 @@ func shellAction(cmd *cobra.Command, args []string) error { sshArgs := append([]string{}, sshExe.Args...) sshArgs = append(sshArgs, sshutil.SSHArgsFromOpts(sshOpts)...) - var sshExecForRsync *exec.Cmd + var ( + sshExecForRsync *exec.Cmd + rsync copytool.CopyTool + ) if syncHostWorkdir { logrus.Infof("Syncing host current directory(%s) to guest instance...", hostCurrentDir) sshExecForRsync = exec.CommandContext(ctx, sshExe.Exe, sshArgs...) @@ -339,8 +343,24 @@ func shellAction(cmd *cobra.Command, args []string) error { destRsyncDir = shellescape.Quote(destRsyncDir) } - if err := rsyncDirectory(ctx, cmd, sshExecForRsync, hostCurrentDir+"/", fmt.Sprintf("%s:%s", *inst.Config.User.Name+"@"+inst.SSHAddress, destRsyncDir)); err != nil { - return fmt.Errorf("failed to sync host working directory to guest instance: %w", err) + paths := []string{ + hostCurrentDir, + fmt.Sprintf("%s:%s", inst.Name, destRsyncDir), + } + rsync, err = copytool.New(ctx, string(copytool.BackendRsync), paths, ©tool.Options{ + Recursive: true, + Verbose: false, + AdditionalArgs: []string{ + "--delete", + }, + }) + if err != nil { + return err + } + logrus.Debugf("using copy tool %q", rsync.Name()) + + if err := rsyncDirectory(ctx, cmd, rsync, paths); err != nil { + return fmt.Errorf("failed to rsync to the guest %w", err) } logrus.Infof("Successfully synced host current directory to guest(%s) instance.", destRsyncDir) } @@ -386,13 +406,13 @@ func shellAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - return askUserForRsyncBack(ctx, cmd, inst, sshExecForRsync, hostCurrentDir, destRsyncDir, tty) + return askUserForRsyncBack(ctx, cmd, inst, sshExecForRsync, hostCurrentDir, destRsyncDir, rsync, tty) } return nil } -func askUserForRsyncBack(ctx context.Context, cmd *cobra.Command, inst *limatype.Instance, sshCmd *exec.Cmd, hostCurrentDir, destRsyncDir string, tty bool) error { - remoteSource := fmt.Sprintf("%s:%s", *inst.Config.User.Name+"@"+inst.SSHAddress, destRsyncDir) +func askUserForRsyncBack(ctx context.Context, cmd *cobra.Command, inst *limatype.Instance, sshCmd *exec.Cmd, hostCurrentDir, destRsyncDir string, rsync copytool.CopyTool, tty bool) error { + remoteSource := fmt.Sprintf("%s:%s", inst.Name, destRsyncDir) clean := filepath.Clean(hostCurrentDir) parts := strings.Split(clean, string(filepath.Separator)) if len(parts) < 2 { @@ -401,7 +421,12 @@ func askUserForRsyncBack(ctx context.Context, cmd *cobra.Command, inst *limatype dirForCleanup := shellescape.Quote(fmt.Sprintf("%s/", *inst.Config.User.Home) + parts[1]) rsyncBackAndCleanup := func() error { - if err := rsyncDirectory(ctx, cmd, sshCmd, remoteSource, filepath.Dir(hostCurrentDir)); err != nil { + paths := []string{ + remoteSource, + hostCurrentDir, + } + + if err := rsyncDirectory(ctx, cmd, rsync, paths); err != nil { return fmt.Errorf("failed to sync back the changes from guest instance to host: %w", err) } @@ -418,7 +443,7 @@ func askUserForRsyncBack(ctx context.Context, cmd *cobra.Command, inst *limatype return rsyncBackAndCleanup() } - stats, err := getRsyncStats(ctx, sshCmd, remoteSource, filepath.Dir(hostCurrentDir)) + stats, err := getRsyncStats(ctx, remoteSource, filepath.Dir(hostCurrentDir)) if err != nil { logrus.WithError(err).Warn("failed to get rsync stats") } @@ -436,15 +461,21 @@ func askUserForRsyncBack(ctx context.Context, cmd *cobra.Command, inst *limatype "View the changed contents", } - hostTmpDest, err := os.MkdirTemp("", "lima-guest-synced-*") + baseDir, err := os.MkdirTemp("", "lima-guest-synced-*") if err != nil { return err } defer func() { - if err := os.RemoveAll(hostTmpDest); err != nil { - logrus.WithError(err).Warnf("Failed to clean up temporary directory %s", hostTmpDest) + if err := os.RemoveAll(baseDir); err != nil { + logrus.WithError(err).Warnf("Failed to clean up temporary directory %s", baseDir) } }() + hostTmpDest := filepath.Join(baseDir, filepath.Base(hostCurrentDir)) + err = os.MkdirAll(hostTmpDest, 0o755) + if err != nil { + return err + } + rsyncToTempDir := false for { @@ -465,12 +496,17 @@ func askUserForRsyncBack(ctx context.Context, cmd *cobra.Command, inst *limatype return nil case 2: // View the changed contents if !rsyncToTempDir { - if err := rsyncDirectory(ctx, cmd, sshCmd, remoteSource, hostTmpDest); err != nil { + paths := []string{ + remoteSource, + hostTmpDest, + } + + if err := rsyncDirectory(ctx, cmd, rsync, paths); err != nil { return fmt.Errorf("failed to sync back the changes from guest instance to host temporary directory: %w", err) } rsyncToTempDir = true } - diffCmd := exec.CommandContext(ctx, "diff", "-ruN", "--color=always", hostCurrentDir, filepath.Join(hostTmpDest, filepath.Base(hostCurrentDir))) + diffCmd := exec.CommandContext(ctx, "diff", "-ruN", "--color=always", hostCurrentDir, hostTmpDest) pager := os.Getenv("PAGER") pager = strings.TrimSpace(pager) if pager == "" { @@ -531,7 +567,7 @@ func hostCurrentDirectory(ctx context.Context, inst *limatype.Instance) (string, } func rsyncVersion(ctx context.Context) (*semver.Version, error) { - out, err := exec.CommandContext(ctx, "rsync", "--version").Output() + out, err := exec.CommandContext(ctx, string(copytool.BackendRsync), "--version").Output() if err != nil { return nil, err } @@ -545,25 +581,13 @@ func rsyncVersion(ctx context.Context) (*semver.Version, error) { return semver.NewVersion(string(matches[1])) } -// Syncs a directory from host to guest and vice-versa. It creates a directory -// named "synced-workdir" in the guest's home directory and copies the contents -// of the host's current working directory into it. SSHArgs should not contain -// the port and address, rsync handles it separately. -func rsyncDirectory(ctx context.Context, cmd *cobra.Command, sshCmd *exec.Cmd, source, destination string) error { - sshCmdParts := make([]string, len(sshCmd.Args)) - for i, arg := range sshCmd.Args { - sshCmdParts[i] = shellescape.Quote(arg) - } - sshCmdStr := strings.Join(sshCmdParts, " ") - - rsyncArgs := []string{ - "-ah", - "--delete", - "-e", sshCmdStr, - source, - destination, - } - rsyncCmd := exec.CommandContext(ctx, "rsync", rsyncArgs...) +// Syncs a directory from host to guest and vice-versa. It creates a directory in the guest's home directory and copies the contents of the host's +// current working directory into it. The guest directory paths should be prefixed with `:` followed by the path. +func rsyncDirectory(ctx context.Context, cmd *cobra.Command, rsync copytool.CopyTool, paths []string) error { + rsyncCmd, err := rsync.Command(ctx, paths, nil) + if err != nil { + return err + } rsyncCmd.Stdout = cmd.OutOrStdout() rsyncCmd.Stderr = cmd.OutOrStderr() logrus.Debugf("executing rsync: %+v", rsyncCmd.Args) @@ -605,24 +629,27 @@ func (s *rsyncStats) String() string { return fmt.Sprintf("added: %d, deleted: %d, modified: %d", s.Added, s.Deleted, s.Modified) } -func getRsyncStats(ctx context.Context, sshCmd *exec.Cmd, source, destination string) (*rsyncStats, error) { - sshCmdParts := make([]string, len(sshCmd.Args)) - for i, arg := range sshCmd.Args { - sshCmdParts[i] = shellescape.Quote(arg) +func getRsyncStats(ctx context.Context, source, destination string) (*rsyncStats, error) { + paths := []string{source, destination} + rsync, err := copytool.New(ctx, string(copytool.BackendRsync), paths, ©tool.Options{ + Verbose: true, + AdditionalArgs: []string{ + "--dry-run", + "--itemize-changes", + "-ah", + "--delete", + }, + }) + if err != nil { + return nil, err } - sshCmdStr := strings.Join(sshCmdParts, " ") - rsyncArgs := []string{ - "--dry-run", - "--itemize-changes", - "-ah", - "--delete", - "-e", - sshCmdStr, - source, - destination, + rsyncCmd, err := rsync.Command(ctx, paths, nil) + if err != nil { + return nil, err } - rsyncCmd := exec.CommandContext(ctx, "rsync", rsyncArgs...) + logrus.Debugf("executing rsync for stats: %+v", rsyncCmd.Args) + out, err := rsyncCmd.Output() if err != nil { return nil, err diff --git a/pkg/copytool/copytool.go b/pkg/copytool/copytool.go new file mode 100644 index 00000000000..7704add2ec9 --- /dev/null +++ b/pkg/copytool/copytool.go @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package copytool + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/sirupsen/logrus" + + "github.com/lima-vm/lima/v2/pkg/ioutilx" + "github.com/lima-vm/lima/v2/pkg/limatype" + "github.com/lima-vm/lima/v2/pkg/store" +) + +type Backend string + +const ( + BackendAuto Backend = "auto" + BackendRsync Backend = "rsync" + BackendSCP Backend = "scp" +) + +type Path struct { + InstanceName string + Path string + IsRemote bool + Instance *limatype.Instance +} + +// Options contains common options for copy operations. This might not be a complete list; more options can be added as needed. +type Options struct { + Recursive bool + Verbose bool + AdditionalArgs []string // Make sure that the additional args are valid for a specific tool and escaped before passing them here. +} + +// CopyTool is the interface for copy tool implementations. +type CopyTool interface { + // Name returns the name of the copy tool. + Name() string + // Command builds and returns the exec.Cmd for the copy operation. If opts is set, it completely overrides the tool's Options which were set during initialization. + Command(ctx context.Context, paths []string, opts *Options) (*exec.Cmd, error) + // IsAvailableOnGuest checks if the tool is available on the specified guest instance. + IsAvailableOnGuest(ctx context.Context, paths []string) bool +} + +// New creates a new CopyTool based on the specified backend. +func New(ctx context.Context, backend string, paths []string, opts *Options) (CopyTool, error) { + switch Backend(backend) { + case BackendSCP: + return newSCPTool(opts) + case BackendRsync: + rsync, err := newRsyncTool(opts) + if err != nil { + return nil, err + } + + if !rsync.IsAvailableOnGuest(ctx, paths) { + return nil, errors.New("rsync not available on guest(s)") + } + return rsync, nil + case BackendAuto: + var ( + tool CopyTool + err error + ) + tool, err = newRsyncTool(opts) + if err == nil { + if tool.IsAvailableOnGuest(ctx, paths) { + return tool, nil + } + logrus.Debugf("rsync not available on guest(s), falling back to scp") + } else { + logrus.Debugf("rsync not found on host, falling back to scp: %v", err) + } + + tool, err = newSCPTool(opts) + if err != nil { + return nil, fmt.Errorf("neither rsync nor scp found on host: %w", err) + } + return tool, nil + default: + return nil, fmt.Errorf("invalid backend %q, must be one of: scp, rsync, auto", backend) + } +} + +func parseCopyPaths(ctx context.Context, paths []string) ([]*Path, error) { + var copyPaths []*Path + + for _, path := range paths { + cp := &Path{} + if runtime.GOOS == "windows" { + if filepath.IsAbs(path) { + var err error + path, err = ioutilx.WindowsSubsystemPath(ctx, path) + if err != nil { + return nil, err + } + } else { + path = filepath.ToSlash(path) + } + } + + parts := strings.SplitN(path, ":", 2) + switch len(parts) { + case 1: + cp.Path = path + cp.IsRemote = false + case 2: + cp.InstanceName = parts[0] + cp.Path = parts[1] + cp.IsRemote = true + + inst, err := store.Inspect(ctx, cp.InstanceName) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("instance %q does not exist, run `limactl create %s` to create a new instance", cp.InstanceName, cp.InstanceName) + } + return nil, err + } + if inst.Status == limatype.StatusStopped { + return nil, fmt.Errorf("instance %q is stopped, run `limactl start %s` to start the instance", cp.InstanceName, cp.InstanceName) + } + cp.Instance = inst + default: + return nil, fmt.Errorf("path %q contains multiple colons", path) + } + + copyPaths = append(copyPaths, cp) + } + + return copyPaths, nil +} diff --git a/pkg/copytool/rsync.go b/pkg/copytool/rsync.go new file mode 100644 index 00000000000..bececc6c625 --- /dev/null +++ b/pkg/copytool/rsync.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package copytool + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/sirupsen/logrus" + + "github.com/lima-vm/lima/v2/pkg/limatype" + "github.com/lima-vm/lima/v2/pkg/sshutil" +) + +type rsyncTool struct { + toolPath string + Options *Options +} + +func newRsyncTool(opts *Options) (*rsyncTool, error) { + toolPath, err := exec.LookPath("rsync") + if err != nil { + return nil, fmt.Errorf("rsync not found on host: %w", err) + } + return &rsyncTool{toolPath: toolPath, Options: opts}, nil +} + +func (t *rsyncTool) Name() string { + return t.toolPath +} + +func (t *rsyncTool) IsAvailableOnGuest(ctx context.Context, paths []string) bool { + copyPaths, err := parseCopyPaths(ctx, paths) + if err != nil { + logrus.Debugf("failed to parse copy paths for rsync availability check: %v", err) + return false + } + instances := make(map[string]*limatype.Instance) + + for _, cp := range copyPaths { + if cp.IsRemote { + instances[cp.InstanceName] = cp.Instance + } + } + + for instName, inst := range instances { + if !checkRsyncOnGuest(ctx, inst) { + logrus.Debugf("rsync not available on instance %q", instName) + return false + } + } + + return true +} + +func checkRsyncOnGuest(ctx context.Context, inst *limatype.Instance) bool { + sshExe, err := sshutil.NewSSHExe() + if err != nil { + logrus.Debugf("failed to create SSH executable: %v", err) + return false + } + sshOpts, err := sshutil.SSHOpts(ctx, sshExe, inst.Dir, *inst.Config.User.Name, false, false, false, false) + if err != nil { + logrus.Debugf("failed to get SSH options for rsync check: %v", err) + return false + } + + sshArgs := sshutil.SSHArgsFromOpts(sshOpts) + checkCmd := exec.CommandContext(ctx, "ssh") + checkCmd.Args = append(checkCmd.Args, sshArgs...) + checkCmd.Args = append(checkCmd.Args, + "-p", fmt.Sprintf("%d", inst.SSHLocalPort), + *inst.Config.User.Name+"@"+inst.SSHAddress, + "command -v rsync >/dev/null 2>&1", + ) + + err = checkCmd.Run() + return err == nil +} + +func (t *rsyncTool) Command(ctx context.Context, paths []string, opts *Options) (*exec.Cmd, error) { + copyPaths, err := parseCopyPaths(ctx, paths) + if err != nil { + return nil, err + } + + if opts != nil { + t.Options = opts + } + + rsyncFlags := []string{"-a"} + + if t.Options.Verbose { + rsyncFlags = append(rsyncFlags, "-v", "--progress") + } else { + rsyncFlags = append(rsyncFlags, "-q") + } + + if t.Options.Recursive { + rsyncFlags = append(rsyncFlags, "-r") + } + + if t.Options.AdditionalArgs != nil { + rsyncFlags = append(rsyncFlags, t.Options.AdditionalArgs...) + } + + rsyncArgs := make([]string, 0, len(rsyncFlags)+len(copyPaths)) + rsyncArgs = append(rsyncArgs, rsyncFlags...) + + var sshCmd string + var remoteInstance *limatype.Instance + + for _, cp := range copyPaths { + if cp.IsRemote { + if remoteInstance == nil { + remoteInstance = cp.Instance + sshExe, err := sshutil.NewSSHExe() + if err != nil { + return nil, err + } + sshOpts, err := sshutil.SSHOpts(ctx, sshExe, cp.Instance.Dir, *cp.Instance.Config.User.Name, false, false, false, false) + if err != nil { + return nil, err + } + + sshArgs := sshutil.SSHArgsFromOpts(sshOpts) + sshCmd = fmt.Sprintf("ssh -p %d %s", cp.Instance.SSHLocalPort, strings.Join(sshArgs, " ")) + } + } + } + + if sshCmd != "" { + rsyncArgs = append(rsyncArgs, "-e", sshCmd) + } + + // Handle trailing slash for directory copies to keep consistent behavior with scp, + // which was the original implementation of `limactl copy -r`. + // https://github.com/lima-vm/lima/issues/4468 + if t.Options.Recursive { + for i, cp := range copyPaths { + //nolint:modernize // stringscutprefix: HasSuffix + TrimSuffix can be simplified to CutSuffix + if strings.HasSuffix(cp.Path, "/") { + if cp.IsRemote { + for j, cp2 := range copyPaths { + if i != j { + cp2.Path = strings.TrimSuffix(cp2.Path, "/") + } + } + } else { + cp.Path = strings.TrimSuffix(cp.Path, "/") + } + } else { + cp.Path += "/" + } + } + } + + for _, cp := range copyPaths { + if cp.IsRemote { + rsyncArgs = append(rsyncArgs, fmt.Sprintf("%s:%s", *cp.Instance.Config.User.Name+"@"+cp.Instance.SSHAddress, cp.Path)) + } else { + rsyncArgs = append(rsyncArgs, cp.Path) + } + } + + return exec.CommandContext(ctx, t.toolPath, rsyncArgs...), nil +} diff --git a/pkg/copytool/scp.go b/pkg/copytool/scp.go new file mode 100644 index 00000000000..9731deedd31 --- /dev/null +++ b/pkg/copytool/scp.go @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package copytool + +import ( + "context" + "errors" + "fmt" + "os/exec" + + "github.com/coreos/go-semver/semver" + + "github.com/lima-vm/lima/v2/pkg/limatype" + "github.com/lima-vm/lima/v2/pkg/sshutil" +) + +type scpTool struct { + toolPath string + Options *Options +} + +func newSCPTool(opts *Options) (*scpTool, error) { + path, err := exec.LookPath("scp") + if err != nil { + return nil, fmt.Errorf("scp not found on host: %w", err) + } + return &scpTool{toolPath: path, Options: opts}, nil +} + +func (t *scpTool) Name() string { + return t.toolPath +} + +func (t *scpTool) IsAvailableOnGuest(_ context.Context, _ []string) bool { + // scp is typically available on all systems with SSH + return true +} + +func (t *scpTool) Command(ctx context.Context, paths []string, opts *Options) (*exec.Cmd, error) { + copyPaths, err := parseCopyPaths(ctx, paths) + if err != nil { + return nil, err + } + + if opts != nil { + t.Options = opts + } + + instances := make(map[string]*limatype.Instance) + scpFlags := []string{} + scpArgs := []string{} + + if t.Options.Verbose { + scpFlags = append(scpFlags, "-v") + } else { + scpFlags = append(scpFlags, "-q") + } + + if t.Options.Recursive { + scpFlags = append(scpFlags, "-r") + } + + if t.Options.AdditionalArgs != nil { + scpFlags = append(scpFlags, t.Options.AdditionalArgs...) + } + + // this assumes that ssh and scp come from the same place, but scp has no -V + sshExeForVersion, err := sshutil.NewSSHExe() + if err != nil { + return nil, err + } + legacySSH := sshutil.DetectOpenSSHVersion(ctx, sshExeForVersion).LessThan(*semver.New("8.0.0")) + + for _, cp := range copyPaths { + if cp.IsRemote { + if legacySSH { + scpFlags = append(scpFlags, "-P", fmt.Sprintf("%d", cp.Instance.SSHLocalPort)) + scpArgs = append(scpArgs, fmt.Sprintf("%s:%s", *cp.Instance.Config.User.Name+"@"+cp.Instance.SSHAddress, cp.Path)) + } else { + scpArgs = append(scpArgs, fmt.Sprintf("scp://%s:%d/%s", *cp.Instance.Config.User.Name+"@"+cp.Instance.SSHAddress, cp.Instance.SSHLocalPort, cp.Path)) + } + instances[cp.InstanceName] = cp.Instance + } else { + scpArgs = append(scpArgs, cp.Path) + } + } + + if legacySSH && len(instances) > 1 { + return nil, errors.New("more than one (instance) host is involved in this command, this is only supported for openSSH v8.0 or higher") + } + + scpFlags = append(scpFlags, "-3", "--") + scpArgs = append(scpFlags, scpArgs...) + + var sshOpts []string + if len(instances) == 1 { + // Only one (instance) host is involved; we can use the instance-specific + // arguments such as ControlPath. This is preferred as we can multiplex + // sessions without re-authenticating (MaxSessions permitting). + for _, inst := range instances { + sshExe, err := sshutil.NewSSHExe() + if err != nil { + return nil, err + } + sshOpts, err = sshutil.SSHOpts(ctx, sshExe, inst.Dir, *inst.Config.User.Name, false, false, false, false) + if err != nil { + return nil, err + } + } + } else { + // Copying among multiple hosts; we can't pass in host-specific options. + sshExe, err := sshutil.NewSSHExe() + if err != nil { + return nil, err + } + sshOpts, err = sshutil.CommonOpts(ctx, sshExe, false) + if err != nil { + return nil, err + } + } + sshArgs := sshutil.SSHArgsFromOpts(sshOpts) + + return exec.CommandContext(ctx, t.toolPath, append(sshArgs, scpArgs...)...), nil +}