Introduce podman machine cp command

Add a new `podman machine cp` subcommand to allow users to copy files or
directories between a running Podman Machine and their host.

Tests cover the following cases:
- Copy a file from the host machine to the VM
- Copy a directory from the host machine to the VM
- Copy a file from the VM to the host machine
- Copy a directory from the VM to the host machine
- Copy a file to a directory
- Copy a directory to a file

Signed-off-by: Jake Correnti <jakecorrenti+github@proton.me>
This commit is contained in:
Jake Correnti
2025-02-13 09:12:13 -05:00
parent 350429cc3c
commit 42fb942a6f
9 changed files with 485 additions and 22 deletions

158
cmd/podman/machine/cp.go Normal file
View File

@ -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 <volume>:\<path> 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)
}

View File

@ -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 {

View File

@ -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 <jcorrent@redhat.com>

View File

@ -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

View File

@ -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
}

172
pkg/machine/e2e/cp_test.go Normal file
View File

@ -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
// ~/<ginkgo_tmp>
// * 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)))
}
})
})

View File

@ -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="}
}

View File

@ -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

View File

@ -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)
}