diff --git a/cmd/podman/machine/cp.go b/cmd/podman/machine/cp.go new file mode 100644 index 0000000000..6368168298 --- /dev/null +++ b/cmd/podman/machine/cp.go @@ -0,0 +1,158 @@ +//go:build amd64 || arm64 + +package machine + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strconv" + + "github.com/containers/podman/v5/cmd/podman/registry" + "github.com/containers/podman/v5/libpod/events" + "github.com/containers/podman/v5/pkg/copy" + "github.com/containers/podman/v5/pkg/machine" + "github.com/containers/podman/v5/pkg/machine/define" + "github.com/containers/podman/v5/pkg/machine/env" + "github.com/containers/podman/v5/pkg/machine/vmconfigs" + "github.com/containers/podman/v5/pkg/specgen" + "github.com/spf13/cobra" +) + +type cpOptions struct { + Quiet bool + Machine *vmconfigs.MachineConfig + IsSrc bool + SrcPath string + DestPath string +} + +var ( + cpCmd = &cobra.Command{ + Use: "cp [options] SRC_PATH DEST_PATH", + Short: "Securely copy contents between the virtual machine", + Long: "Securely copy files or directories between the virtual machine and your host", + PersistentPreRunE: machinePreRunE, + RunE: cp, + Args: cobra.ExactArgs(2), + Example: `podman machine cp ~/ca.crt podman-machine-default:/etc/containers/certs.d/ca.crt`, + ValidArgsFunction: autocompleteMachineCp, + } + + cpOpts = cpOptions{} +) + +func init() { + registry.Commands = append(registry.Commands, registry.CliCommand{ + Command: cpCmd, + Parent: machineCmd, + }) + + flags := cpCmd.Flags() + quietFlagName := "quiet" + flags.BoolVarP(&cpOpts.Quiet, quietFlagName, "q", false, "Suppress copy status output") +} + +func cp(_ *cobra.Command, args []string) error { + var err error + + srcMachine, srcPath, destMachine, destPath, err := copy.ParseSourceAndDestination(args[0], args[1]) + if err != nil { + return err + } + + // NOTE: This will most likely break hyperv or wsl machines with single-letter + // names. It is most likely similar to https://github.com/containers/podman/issues/25218 + // + // Passing an absolute windows path of the format :\ will cause + // `copy.ParseSourceAndDestination` to think the volume is a Machine. Check + // if the raw cmdline argument is a Windows host path. + if specgen.IsHostWinPath(args[0]) { + srcMachine = "" + srcPath = args[0] + } + + if specgen.IsHostWinPath(args[1]) { + destMachine = "" + destPath = args[1] + } + + mc, err := resolveMachine(srcMachine, destMachine) + if err != nil { + return err + } + + state, err := provider.State(mc, false) + if err != nil { + return err + } + if state != define.Running { + return fmt.Errorf("vm %q is not running", mc.Name) + } + + cpOpts.Machine = mc + cpOpts.SrcPath = srcPath + cpOpts.DestPath = destPath + + err = secureCopy(&cpOpts) + if err != nil { + return fmt.Errorf("copy failed: %s", err.Error()) + } + + fmt.Println("Copy successful") + newMachineEvent(events.Copy, events.Event{Name: mc.Name}) + return nil +} + +func secureCopy(opts *cpOptions) error { + srcPath := opts.SrcPath + destPath := opts.DestPath + sshConfig := opts.Machine.SSH + + username := sshConfig.RemoteUsername + if cpOpts.Machine.HostUser.Rootful { + username = "root" + } + username += "@localhost:" + + if opts.IsSrc { + srcPath = username + srcPath + } else { + destPath = username + destPath + } + + args := []string{"-r", "-i", sshConfig.IdentityPath, "-P", strconv.Itoa(sshConfig.Port)} + args = append(args, machine.CommonSSHArgs()...) + args = append(args, []string{srcPath, destPath}...) + + cmd := exec.Command("scp", args...) + if !opts.Quiet { + cmd.Stdout = os.Stdout + } + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func resolveMachine(srcMachine, destMachine string) (*vmconfigs.MachineConfig, error) { + if len(srcMachine) > 0 && len(destMachine) > 0 { + return nil, errors.New("copying between two machines is unsupported") + } + + if len(srcMachine) == 0 && len(destMachine) == 0 { + return nil, errors.New("a machine name must prefix either the source path or destination path") + } + + dirs, err := env.GetMachineDirs(provider.VMType()) + if err != nil { + return nil, err + } + + name := destMachine + if len(srcMachine) > 0 { + cpOpts.IsSrc = true + name = srcMachine + } + + return vmconfigs.LoadMachineByName(name, dirs) +} diff --git a/cmd/podman/machine/machine.go b/cmd/podman/machine/machine.go index 1dd65c1ec8..6f1910c0c7 100644 --- a/cmd/podman/machine/machine.go +++ b/cmd/podman/machine/machine.go @@ -68,6 +68,33 @@ func autocompleteMachineSSH(cmd *cobra.Command, args []string, toComplete string return nil, cobra.ShellCompDirectiveDefault } +// autocompleteMachineCp - Autocomplete machine cp command. +func autocompleteMachineCp(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) < 2 { + if i := strings.IndexByte(toComplete, ':'); i > -1 { + // TODO: offer virtual machine path completion + + // the user already set the machine name, so don't use the host file autocompletion + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // suggest machine when they match the input otherwise normal shell completion is used + machines, _ := getMachines(toComplete) + for _, machine := range machines { + if strings.HasPrefix(machine, toComplete) { + for i := range machines { + machines[i] += ":" + } + return machines, cobra.ShellCompDirectiveNoSpace + } + } + + return nil, cobra.ShellCompDirectiveNoSpace + } + // don't complete more than 2 args + return nil, cobra.ShellCompDirectiveNoFileComp +} + // autocompleteMachine - Autocomplete machines. func autocompleteMachine(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { diff --git a/docs/source/markdown/podman-machine-cp.1.md b/docs/source/markdown/podman-machine-cp.1.md new file mode 100644 index 0000000000..e12c2eb240 --- /dev/null +++ b/docs/source/markdown/podman-machine-cp.1.md @@ -0,0 +1,63 @@ +% podman-machine-cp 1 + +## NAME +podman\-machine\-cp - Securely copy contents between the host and the virtual machine + +## SYNOPSIS +**podman machine cp** [*options*] *src_path* *dest_path* + +## DESCRIPTION + +Use secure copy (scp) to copy files or directories between the virtual machine +and your host machine. + +`podman machine cp` does not support copying between two virtual machines, +which would require two machines running simultaneously. + +Additionally, `podman machine cp` will automatically do a recursive copy of +files and directories. + +## OPTIONS + +#### **--help** + +Print usage statement. + +#### **--quiet**, **-q** + +Suppress copy status output. + +## EXAMPLES +Copy a file from your host to the running Podman Machine. +``` +$ podman machine cp ~/configuration.txt podman-machine-default:~/configuration.txt +... +Copy Successful +``` + +Copy a file from the running Podman Machine to your host. +``` +$ podman machine cp podman-machine-default:~/logs/log.txt ~/logs/podman-machine-default.txt +... +Copy Successful +``` + +Copy a directory from your host to the running Podman Machine. +``` +$ podman machine cp ~/.config podman-machine-default:~/.config +... +Copy Successful +``` + +Copy a directory from the running Podman Machine to your host. +``` +$ podman machine cp podman-machine-default:~/.config ~/podman-machine-default.config +... +Copy Successful +``` + +## SEE ALSO +**[podman(1)](podman.1.md)**, **[podman-machine(1)](podman-machine.1.md)** + +## HISTORY +February 2025, Originally compiled by Jake Correnti diff --git a/docs/source/markdown/podman-machine.1.md b/docs/source/markdown/podman-machine.1.md index fa4cd95c54..5e15f6db8d 100644 --- a/docs/source/markdown/podman-machine.1.md +++ b/docs/source/markdown/podman-machine.1.md @@ -24,22 +24,23 @@ Podman machine behaviour can be modified via the [machine] section in the contai ## SUBCOMMANDS -| Command | Man Page | Description | -|---------|----------------------------------------------------------|---------------------------------------| -| info | [podman-machine-info(1)](podman-machine-info.1.md) | Display machine host info | -| init | [podman-machine-init(1)](podman-machine-init.1.md) | Initialize a new virtual machine | -| inspect | [podman-machine-inspect(1)](podman-machine-inspect.1.md) | Inspect one or more virtual machines | -| list | [podman-machine-list(1)](podman-machine-list.1.md) | List virtual machines | -| os | [podman-machine-os(1)](podman-machine-os.1.md) | Manage a Podman virtual machine's OS | -| reset | [podman-machine-reset(1)](podman-machine-reset.1.md) | Reset Podman machines and environment | -| rm | [podman-machine-rm(1)](podman-machine-rm.1.md) | Remove a virtual machine | -| set | [podman-machine-set(1)](podman-machine-set.1.md) | Set a virtual machine setting | -| ssh | [podman-machine-ssh(1)](podman-machine-ssh.1.md) | SSH into a virtual machine | -| start | [podman-machine-start(1)](podman-machine-start.1.md) | Start a virtual machine | -| stop | [podman-machine-stop(1)](podman-machine-stop.1.md) | Stop a virtual machine | +| Command | Man Page | Description | +|---------|----------------------------------------------------------|-----------------------------------------------------------------| +| cp | [podman-machine-cp(1)](podman-machine-cp.1.md) | Securely copy contents between the host and the virtual machine | +| info | [podman-machine-info(1)](podman-machine-info.1.md) | Display machine host info | +| init | [podman-machine-init(1)](podman-machine-init.1.md) | Initialize a new virtual machine | +| inspect | [podman-machine-inspect(1)](podman-machine-inspect.1.md) | Inspect one or more virtual machines | +| list | [podman-machine-list(1)](podman-machine-list.1.md) | List virtual machines | +| os | [podman-machine-os(1)](podman-machine-os.1.md) | Manage a Podman virtual machine's OS | +| reset | [podman-machine-reset(1)](podman-machine-reset.1.md) | Reset Podman machines and environment | +| rm | [podman-machine-rm(1)](podman-machine-rm.1.md) | Remove a virtual machine | +| set | [podman-machine-set(1)](podman-machine-set.1.md) | Set a virtual machine setting | +| ssh | [podman-machine-ssh(1)](podman-machine-ssh.1.md) | SSH into a virtual machine | +| start | [podman-machine-start(1)](podman-machine-start.1.md) | Start a virtual machine | +| stop | [podman-machine-stop(1)](podman-machine-stop.1.md) | Stop a virtual machine | ## SEE ALSO -**[podman(1)](podman.1.md)**, **[podman-machine-info(1)](podman-machine-info.1.md)**, **[podman-machine-init(1)](podman-machine-init.1.md)**, **[podman-machine-list(1)](podman-machine-list.1.md)**, **[podman-machine-os(1)](podman-machine-os.1.md)**, **[podman-machine-rm(1)](podman-machine-rm.1.md)**, **[podman-machine-ssh(1)](podman-machine-ssh.1.md)**, **[podman-machine-start(1)](podman-machine-start.1.md)**, **[podman-machine-stop(1)](podman-machine-stop.1.md)**, **[podman-machine-inspect(1)](podman-machine-inspect.1.md)**, **[podman-machine-reset(1)](podman-machine-reset.1.md)**, **containers.conf(5)** +**[podman(1)](podman.1.md)**, **[podman-machine-cp(1)](podman-machine-cp.1.md)**, **[podman-machine-info(1)](podman-machine-info.1.md)**, **[podman-machine-init(1)](podman-machine-init.1.md)**, **[podman-machine-list(1)](podman-machine-list.1.md)**, **[podman-machine-os(1)](podman-machine-os.1.md)**, **[podman-machine-rm(1)](podman-machine-rm.1.md)**, **[podman-machine-ssh(1)](podman-machine-ssh.1.md)**, **[podman-machine-start(1)](podman-machine-start.1.md)**, **[podman-machine-stop(1)](podman-machine-stop.1.md)**, **[podman-machine-inspect(1)](podman-machine-inspect.1.md)**, **[podman-machine-reset(1)](podman-machine-reset.1.md)**, **containers.conf(5)** ### Troubleshooting diff --git a/pkg/machine/e2e/config_cp_test.go b/pkg/machine/e2e/config_cp_test.go new file mode 100644 index 0000000000..58cbb35d0f --- /dev/null +++ b/pkg/machine/e2e/config_cp_test.go @@ -0,0 +1,37 @@ +package e2e_test + +type cpMachine struct { + quiet bool + src string + dest string + + cmd []string +} + +func (c *cpMachine) buildCmd(m *machineTestBuilder) []string { + cmd := []string{"machine", "cp"} + + if c.quiet { + cmd = append(cmd, "--quiet") + } + + cmd = append(cmd, c.src, c.dest) + + c.cmd = cmd + return cmd +} + +func (c *cpMachine) withQuiet() *cpMachine { + c.quiet = true + return c +} + +func (c *cpMachine) withSrc(src string) *cpMachine { + c.src = src + return c +} + +func (c *cpMachine) withDest(dest string) *cpMachine { + c.dest = dest + return c +} diff --git a/pkg/machine/e2e/cp_test.go b/pkg/machine/e2e/cp_test.go new file mode 100644 index 0000000000..103dd25d04 --- /dev/null +++ b/pkg/machine/e2e/cp_test.go @@ -0,0 +1,172 @@ +package e2e_test + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gexec" +) + +var _ = Describe("podman machine cp", func() { + It("all tests", func() { + // HOST FILE SYSTEM + // ~/ + // * foo.txt + // - foo-dir + // * bar.txt + // + // * guest-foo.txt + // - guest-foo-dir + // * bar.txt + + // GUEST FILE SYSTEM + // ~/ + // * foo.txt + // - foo-dir + // * bar.txt + + var ( + file = "foo.txt" + filePath = filepath.Join(GinkgoT().TempDir(), file) + + directory = "foo-dir" + directoryPath = filepath.Join(GinkgoT().TempDir(), directory) + + fileInDirectory = "bar.txt" + fileInDirectoryPath = filepath.Join(directoryPath, fileInDirectory) + + guestToHostFile = "guest-foo.txt" + guestToHostDir = "guest-foo-dir" + ) + + _, err := os.Create(filePath) + Expect(err).ToNot(HaveOccurred()) + + err = os.MkdirAll(directoryPath, 0755) + Expect(err).ToNot(HaveOccurred()) + + _, err = os.Create(fileInDirectoryPath) + Expect(err).ToNot(HaveOccurred()) + + name := randomString() + initMachine := initMachine{} + cp := cpMachine{} + sshMachine := sshMachine{} + + By("host file to guest") + session, err := mb.setName(name).setCmd(initMachine.withImage(mb.imagePath).withNow()).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(0)) + + // copy the file into the guest + session, err = mb.setCmd(cp.withQuiet().withSrc(filePath).withDest(name + ":~/" + file)).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(0)) + + // verify the guest has the file + session, err = mb.setName(name).setCmd(sshMachine.withSSHCommand([]string{"ls"})).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(0)) + Expect(session.outputToString()).To(Equal(file)) + + // try to copy the file to a location in the guest where permission will get denied + session, err = mb.setCmd(cp.withQuiet().withSrc(filePath).withDest(name + ":/etc/tmp.txt")).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(125)) + Expect(session.errorToString()).To(ContainSubstring("scp: dest open \"/etc/tmp.txt\": Permission denied")) + + By("host directory to guest") + // copy contents into the guest + session, err = mb.setCmd(cp.withQuiet().withSrc(directoryPath).withDest(name + ":~/" + directory)).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(0)) + + // verify the content is in the guest + session, err = mb.setName(name).setCmd(sshMachine.withSSHCommand([]string{"ls", directory})).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(0)) + Expect(session.outputToString()).To(Equal(fileInDirectory)) + + By("guest file to host") + // copy contents to the host + guestToHostFilePath := filepath.Join(GinkgoT().TempDir(), guestToHostFile) + session, err = mb.setCmd(cp.withQuiet().withSrc(name + ":~/" + file).withDest(guestToHostFilePath)).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(0)) + // check the contents are on the host + Expect(guestToHostFilePath).To(BeARegularFile()) + + // this test can only be run on Unix systems. Windows does not + // strictly enforce read-only permissions specified by `os.MkdirAll()`, + // so we cannot easily make a read-only directory. + if runtime.GOOS != "windows" { + // try to copy the file to a location on the host where permission will get denied + hostDirPath := filepath.Join(GinkgoT().TempDir(), "test-guest-copy-dir") + err = os.MkdirAll(hostDirPath, 0444) + Expect(err).ToNot(HaveOccurred()) + hostFileInDirPath := filepath.Join(hostDirPath, file) + session, err = mb.setCmd(cp.withQuiet().withSrc(name + ":~/" + file).withDest(hostFileInDirPath)).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(125)) + Expect(session.errorToString()).To(ContainSubstring(fmt.Sprintf("scp: open local \"%s\": Permission denied", hostFileInDirPath))) + } + + By("guest directory to host") + // copy contents to the host + guestToHostDirPath := filepath.Join(GinkgoT().TempDir(), guestToHostDir) + session, err = mb.setCmd(cp.withQuiet().withSrc(name + ":~/" + directory).withDest(guestToHostDirPath)).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(0)) + // check the contents are on the host + Expect(filepath.Join(guestToHostDirPath, fileInDirectory)).To(BeARegularFile()) + + By("attempt copying file to a new directory") + // copy the file to a guest directory + session, err = mb.setCmd(cp.withQuiet().withSrc(filePath).withDest(name + ":~/directory/")).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(125)) + Expect(session.errorToString()).To(ContainSubstring("scp: dest open \"directory/\": Failure")) + + // try copying a guest file to a host directory + hostDirPath := filepath.Join(GinkgoT().TempDir(), "directory") + string(filepath.Separator) + session, err = mb.setCmd(cp.withQuiet().withSrc(name + ":~/" + file).withDest(hostDirPath)).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(125)) + switch runtime.GOOS { + case "windows": + hostDirPath = filepath.ToSlash(hostDirPath) + fallthrough + case "darwin": + Expect(session.errorToString()).To(ContainSubstring(fmt.Sprintf("scp: open local \"%s\": No such file or directory", hostDirPath))) + case "linux": + Expect(session.errorToString()).To(ContainSubstring(fmt.Sprintf("scp: open local \"%s\": Is a directory", hostDirPath))) + } + + By("attempt copying directory to a file") + // try copying a local directory to a guest file + session, err = mb.setCmd(cp.withQuiet().withSrc(GinkgoT().TempDir() + string(filepath.Separator)).withDest(name + ":~/" + directory + "/" + fileInDirectory + "/")).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(125)) + Expect(session.errorToString()).To(ContainSubstring("/foo-dir/bar.txt\" exists but is not a directory")) + + // try copying the guest directory to a local file + session, err = mb.setCmd(cp.withQuiet().withSrc(name + ":~/" + directory + "/").withDest(filePath + string(filepath.Separator))).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(125)) + + hostLocalFilePath := filePath + string(filepath.Separator) + switch runtime.GOOS { + case "windows": + hostLocalFilePath = filepath.ToSlash(hostLocalFilePath) + Expect(session.errorToString()).To(ContainSubstring(fmt.Sprintf("scp: open local \"%s\": No such file or directory", hostLocalFilePath+fileInDirectory))) + case "darwin": + Expect(session.errorToString()).To(ContainSubstring(fmt.Sprintf("scp: mkdir %s: Not a directory", hostLocalFilePath))) + case "linux": + Expect(session.errorToString()).To(ContainSubstring(fmt.Sprintf("scp: open local \"%s\": Not a directory", hostLocalFilePath+fileInDirectory))) + } + }) +}) diff --git a/pkg/machine/ssh.go b/pkg/machine/ssh.go index eee60c5326..6702ce00d5 100644 --- a/pkg/machine/ssh.go +++ b/pkg/machine/ssh.go @@ -114,12 +114,7 @@ func commonNativeSSH(username, identityPath, name string, sshPort int, inputArgs port := strconv.Itoa(sshPort) interactive := true - args := []string{"-i", identityPath, "-p", port, sshDestination, - "-o", "IdentitiesOnly=yes", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=" + os.DevNull, - "-o", "CheckHostIP=no", - "-o", "LogLevel=ERROR", "-o", "SetEnv=LC_ALL="} + args := append([]string{"-i", identityPath, "-p", port, sshDestination}, CommonSSHArgs()...) if len(inputArgs) > 0 { interactive = false args = append(args, inputArgs...) @@ -138,3 +133,13 @@ func commonNativeSSH(username, identityPath, name string, sshPort int, inputArgs return cmd.Run() } + +func CommonSSHArgs() []string { + return []string{ + "-o", "IdentitiesOnly=yes", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=" + os.DevNull, + "-o", "CheckHostIP=no", + "-o", "LogLevel=ERROR", + "-o", "SetEnv=LC_ALL="} +} diff --git a/pkg/specgen/volumes.go b/pkg/specgen/volumes.go index d2c1e54876..77c89f8ce4 100644 --- a/pkg/specgen/volumes.go +++ b/pkg/specgen/volumes.go @@ -115,7 +115,7 @@ func GenVolumeMounts(volumeFlag []string) (map[string]spec.Mount, map[string]*Na } } - if strings.HasPrefix(src, "/") || strings.HasPrefix(src, ".") || isHostWinPath(src) { + if strings.HasPrefix(src, "/") || strings.HasPrefix(src, ".") || IsHostWinPath(src) { // This is not a named volume overlayFlag := false chownFlag := false diff --git a/pkg/specgen/winpath.go b/pkg/specgen/winpath.go index 5c19aeb4b2..714bbc0571 100644 --- a/pkg/specgen/winpath.go +++ b/pkg/specgen/winpath.go @@ -7,7 +7,7 @@ import ( "unicode" ) -func isHostWinPath(path string) bool { +func IsHostWinPath(path string) bool { return shouldResolveWinPaths() && strings.HasPrefix(path, `\\`) || hasWinDriveScheme(path, 0) || winPathExists(path) }