diff --git a/Dockerfile b/Dockerfile index e95aba07ee..6ea97d8b59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,8 @@ FROM golang:1.8.3 RUN apt-get update && apt-get install -y --no-install-recommends \ openssh-client \ rsync \ + fuse \ + sshfs \ && rm -rf /var/lib/apt/lists/* RUN go get github.com/golang/lint/golint \ diff --git a/commands/commands.go b/commands/commands.go index 40d7dd90f5..7da4f2e320 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -361,6 +361,18 @@ var Commands = []cli.Command{ }, }, }, + { + Name: "mount", + Usage: "Mount or unmount a directory from a machine with SSHFS.", + Description: "Arguments are [machine:][path] [mountpoint]", + Action: runCommand(cmdMount), + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "unmount, u", + Usage: "Unmount instead of mount", + }, + }, + }, { Name: "start", Usage: "Start a machine", diff --git a/commands/mount.go b/commands/mount.go new file mode 100644 index 0000000000..947195ad3e --- /dev/null +++ b/commands/mount.go @@ -0,0 +1,136 @@ +package commands + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/docker/machine/libmachine" + "github.com/docker/machine/libmachine/log" +) + +var ( + // TODO: possibly move this to ssh package + baseSSHFSArgs = []string{ + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=quiet", // suppress "Warning: Permanently added '[localhost]:2022' (ECDSA) to the list of known hosts." + } +) + +func cmdMount(c CommandLine, api libmachine.API) error { + args := c.Args() + if len(args) < 1 || len(args) > 2 { + c.ShowHelp() + return errWrongNumberArguments + } + + src := args[0] + dest := "" + if len(args) > 1 { + dest = args[1] + } + + hostInfoLoader := &storeHostInfoLoader{api} + + cmd, err := getMountCmd(src, dest, c.Bool("unmount"), hostInfoLoader) + if err != nil { + return err + } + + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +func getMountCmd(src, dest string, unmount bool, hostInfoLoader HostInfoLoader) (*exec.Cmd, error) { + var cmdPath string + var err error + if !unmount { + cmdPath, err = exec.LookPath("sshfs") + if err != nil { + return nil, errors.New("You must have a copy of the sshfs binary locally to use the mount feature") + } + } else { + cmdPath, err = exec.LookPath("fusermount") + if err != nil { + return nil, errors.New("You must have a copy of the fusermount binary locally to use the unmount option") + } + } + + srcHost, srcUser, srcPath, srcOpts, err := getInfoForSshfsArg(src, hostInfoLoader) + if err != nil { + return nil, err + } + + if dest == "" { + dest = srcPath + } + + sshArgs := baseSSHFSArgs + if srcHost.GetSSHKeyPath() != "" { + sshArgs = append(sshArgs, "-o", "IdentitiesOnly=yes") + } + + // Append needed -i / private key flags to command. + sshArgs = append(sshArgs, srcOpts...) + + // Append actual arguments for the sshfs command (i.e. docker@:/path) + locationArg, err := generateLocationArg(srcHost, srcUser, srcPath) + if err != nil { + return nil, err + } + + if !unmount { + sshArgs = append(sshArgs, locationArg) + sshArgs = append(sshArgs, dest) + } else { + sshArgs = []string{"-u"} + sshArgs = append(sshArgs, dest) + } + + cmd := exec.Command(cmdPath, sshArgs...) + log.Debug(*cmd) + return cmd, nil +} + +func getInfoForSshfsArg(hostAndPath string, hostInfoLoader HostInfoLoader) (h HostInfo, user string, path string, args []string, err error) { + // Path with hostname. e.g. "hostname:/usr/bin/cmatrix" + var hostName string + if parts := strings.SplitN(hostAndPath, ":", 2); len(parts) < 2 { + hostName = defaultMachineName + path = parts[0] + } else { + hostName = parts[0] + path = parts[1] + } + if hParts := strings.SplitN(hostName, "@", 2); len(hParts) == 2 { + user, hostName = hParts[0], hParts[1] + } + + // Remote path + h, err = hostInfoLoader.load(hostName) + if err != nil { + return nil, "", "", nil, fmt.Errorf("Error loading host: %s", err) + } + + args = []string{} + port, err := h.GetSSHPort() + if err == nil && port > 0 { + args = append(args, "-o", fmt.Sprintf("Port=%v", port)) + } + + if h.GetSSHKeyPath() != "" { + args = append(args, "-o", fmt.Sprintf("IdentityFile=%s", h.GetSSHKeyPath())) + } + + if user == "" { + user = h.GetSSHUsername() + } + + return +} diff --git a/commands/mount_test.go b/commands/mount_test.go new file mode 100644 index 0000000000..8f27c3d53c --- /dev/null +++ b/commands/mount_test.go @@ -0,0 +1,84 @@ +package commands + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetMountCmd(t *testing.T) { + hostInfoLoader := MockHostInfoLoader{MockHostInfo{ + ip: "12.34.56.78", + sshPort: 234, + sshUsername: "root", + sshKeyPath: "/fake/keypath/id_rsa", + }} + + path, err := exec.LookPath("sshfs") + if err != nil { + t.Skip("sshfs not found (install sshfs ?)") + } + cmd, err := getMountCmd("myfunhost:/home/docker/foo", "/tmp/foo", false, &hostInfoLoader) + + expectedArgs := append( + baseSSHFSArgs, + "-o", + "IdentitiesOnly=yes", + "-o", + "Port=234", + "-o", + "IdentityFile=/fake/keypath/id_rsa", + "root@12.34.56.78:/home/docker/foo", + "/tmp/foo", + ) + expectedCmd := exec.Command(path, expectedArgs...) + + assert.Equal(t, expectedCmd, cmd) + assert.NoError(t, err) +} + +func TestGetMountCmdWithoutSshKey(t *testing.T) { + hostInfoLoader := MockHostInfoLoader{MockHostInfo{ + ip: "1.2.3.4", + sshUsername: "user", + }} + + path, err := exec.LookPath("sshfs") + if err != nil { + t.Skip("sshfs not found (install sshfs ?)") + } + cmd, err := getMountCmd("myfunhost:/home/docker/foo", "", false, &hostInfoLoader) + + expectedArgs := append( + baseSSHFSArgs, + "user@1.2.3.4:/home/docker/foo", + "/home/docker/foo", + ) + expectedCmd := exec.Command(path, expectedArgs...) + + assert.Equal(t, expectedCmd, cmd) + assert.NoError(t, err) +} + +func TestGetMountCmdUnmount(t *testing.T) { + hostInfoLoader := MockHostInfoLoader{MockHostInfo{ + ip: "1.2.3.4", + sshUsername: "user", + }} + + path, err := exec.LookPath("fusermount") + if err != nil { + t.Skip("fusermount not found (install fuse ?)") + } + cmd, err := getMountCmd("myfunhost:/home/docker/foo", "/tmp/foo", true, &hostInfoLoader) + + expectedArgs := []string{ + "-u", + "/tmp/foo", + } + expectedCmd := exec.Command(path, expectedArgs...) + + assert.Equal(t, expectedCmd, cmd) + assert.NoError(t, err) +}