Move code to use SSH "backends"

Default to shelling out to SSH when available.

Signed-off-by: Nathan LeClaire <nathan.leclaire@gmail.com>
This commit is contained in:
Nathan LeClaire 2015-05-14 15:02:23 -07:00
parent 15e022219f
commit 2f78b7f92a
15 changed files with 309 additions and 165 deletions

View File

@ -1,29 +1,24 @@
package commands
import (
"io"
"os"
"fmt"
"strings"
"github.com/docker/machine/log"
"github.com/docker/machine/state"
"github.com/codegangsta/cli"
"github.com/docker/machine/drivers"
"github.com/docker/machine/ssh"
)
func cmdSsh(c *cli.Context) {
var (
output ssh.Output
err error
)
args := c.Args()
name := args.First()
cmd := ""
if len(c.Args()) == 0 {
if name == "" {
log.Fatal("Error: Please specify a machine name.")
}
name := c.Args().First()
certInfo := getCertPathInfo(c)
defaultStore, err := getDefaultStore(
c.GlobalString("storage-path"),
@ -44,40 +39,50 @@ func cmdSsh(c *cli.Context) {
log.Fatal(err)
}
_, err = host.GetURL()
if err != nil {
if err == drivers.ErrHostIsNotRunning {
log.Fatalf("%s is not running. Please start this with docker-machine start %s", host.Name, host.Name)
} else {
log.Fatalf("Unexpected error getting machine url: %s", err)
}
}
if len(c.Args()) == 1 {
err = host.CreateSSHShell()
} else {
var (
cmd string
args []string = c.Args()
)
for i, arg := range args {
if arg == "--" {
i++
cmd = strings.Join(args[i:], " ")
break
}
}
if len(cmd) == 0 {
cmd = strings.Join(args[1:], " ")
}
output, err = host.RunSSHCommand(cmd)
io.Copy(os.Stderr, output.Stderr)
io.Copy(os.Stdout, output.Stdout)
}
currentState, err := host.Driver.GetState()
if err != nil {
log.Fatal(err)
}
if currentState != state.Running {
log.Fatalf("Error: Cannot run SSH command: Host %q is not running", host.Name)
}
// Loop through the arguments and parse out a command which relies on
// flags if it exists, for instance an invocation of the form
// `docker-machine ssh dev -- df -h` would mandate this, otherwise we
// will accidentally trigger the codegangsta/cli help text because it
// thinks we are trying to specify codegangsta flags.
//
// TODO: I thought codegangsta/cli supported the flag parsing
// terminator manually, which would mitigate the need for this kind of
// hack. We should investigate.
for i, arg := range args {
if arg == "--" {
cmd = strings.Join(args[i+1:], " ")
break
}
}
// It is possible that the user has specified an appended command which
// does not rely on the flag parsing terminator, such as
// `docker-machine ssh dev ls`, so this block accounts for that case.
if len(cmd) == 0 {
cmd = strings.Join(args[1:], " ")
}
if len(c.Args()) == 1 {
err := host.CreateSSHShell()
if err != nil {
log.Fatal(err)
}
} else {
output, err := host.RunSSHCommand(cmd)
if err != nil {
log.Fatal(err)
}
fmt.Print(output)
}
}

View File

@ -913,6 +913,30 @@ cgroup 499.8M 0 499.8M 0% /sys/fs/cgroup
/mnt/sda1/var/lib/docker/aufs
```
##### Different types of SSH
When Docker Machine is invoked, it will check to see if you have the venerable
`ssh` binary around locally and will attempt to use that for the SSH commands it
needs to run, whether they are a part of an operation such as creation or have
been requested by the user directly. If it does not find an external `ssh`
binary locally, it will default to using a native Go implementation from
[crypto/ssh](https://godoc.org/golang.org/x/crypto/ssh). This is useful in
situations where you may not have access to traditional UNIX tools, such as if
you are using Docker Machine on Windows without having msysgit installed
alongside of it.
In most situations, you will not have to worry about this implementation detail
and Docker Machine will act sensibly out of the box. However, if you
deliberately want to use the Go native version, you can do so with a global
command line flag / environment variable like so:
```
$ docker-machine --native-ssh ssh dev
```
There are some variations in behavior between the two methods, so please report
any issues or inconsistencies if you come across them.
#### scp
Copy files from your local host to a machine, from machine to machine, or from a

View File

@ -256,7 +256,7 @@ func (c *ComputeUtil) executeCommands(commands []string, ip, sshKeyPath string)
return err
}
if _, err := client.Run(command); err != nil {
if _, err := client.Output(command); err != nil {
return err
}
}

View File

@ -8,17 +8,15 @@ import (
"github.com/docker/machine/utils"
)
func RunSSHCommandFromDriver(d Driver, command string) (ssh.Output, error) {
var output ssh.Output
func RunSSHCommandFromDriver(d Driver, command string) (string, error) {
addr, err := d.GetSSHHostname()
if err != nil {
return output, err
return "", err
}
port, err := d.GetSSHPort()
if err != nil {
return output, err
return "", err
}
auth := &ssh.Auth{
@ -27,11 +25,11 @@ func RunSSHCommandFromDriver(d Driver, command string) (ssh.Output, error) {
client, err := ssh.NewClient(d.GetSSHUsername(), addr, port, auth)
if err != nil {
return output, err
return "", err
}
log.Debugf("About to run SSH command:\n%s", command)
output, err = client.Run(command)
output, err := client.Output(command)
log.Debugf("SSH cmd err, output: %v: %s", err, output)
return output, err
}

View File

@ -503,20 +503,15 @@ func (d *Driver) GetIP() (string, error) {
if s != state.Running {
return "", drivers.ErrHostIsNotRunning
}
output, err := drivers.RunSSHCommandFromDriver(d, "ip addr show dev eth1")
if err != nil {
return "", err
}
var buf bytes.Buffer
if _, err := buf.ReadFrom(output.Stdout); err != nil {
return "", err
}
out := buf.String()
log.Debugf("SSH returned: %s\nEND SSH\n", out)
log.Debugf("SSH returned: %s\nEND SSH\n", output)
// parse to find: inet 192.168.59.103/24 brd 192.168.59.255 scope global eth1
lines := strings.Split(out, "\n")
lines := strings.Split(output, "\n")
for _, line := range lines {
vals := strings.Split(strings.TrimSpace(line), " ")
if len(vals) >= 2 && vals[0] == "inet" {
@ -524,7 +519,7 @@ func (d *Driver) GetIP() (string, error) {
}
}
return "", fmt.Errorf("No IP address found %s", out)
return "", fmt.Errorf("No IP address found %s", output)
}
func (d *Driver) publicSSHKeyPath() string {

View File

@ -2,10 +2,18 @@ package libmachine
import (
"errors"
"fmt"
)
var (
ErrHostDoesNotExist = errors.New("Host does not exist")
ErrInvalidHostname = errors.New("Invalid hostname specified")
ErrUnknownProviderType = errors.New("Unknown hypervisor type")
)
type ErrHostDoesNotExist struct {
Name string
}
func (e ErrHostDoesNotExist) Error() string {
return fmt.Sprintf("Error: Host does not exist: %s", e.Name)
}

View File

@ -25,7 +25,9 @@ func NewFilestore(rootPath string, caCert string, privateKey string) *Filestore
func (s Filestore) loadHost(name string) (*Host, error) {
hostPath := filepath.Join(utils.GetMachineDir(), name)
if _, err := os.Stat(hostPath); os.IsNotExist(err) {
return nil, ErrHostDoesNotExist
return nil, ErrHostDoesNotExist{
Name: name,
}
}
host := &Host{Name: name, StorePath: hostPath}

View File

@ -137,26 +137,30 @@ func (h *Host) Create(name string) error {
return nil
}
func (h *Host) RunSSHCommand(command string) (ssh.Output, error) {
func (h *Host) RunSSHCommand(command string) (string, error) {
return drivers.RunSSHCommandFromDriver(h.Driver, command)
}
func (h *Host) CreateSSHShell() error {
func (h *Host) CreateSSHClient() (ssh.Client, error) {
addr, err := h.Driver.GetSSHHostname()
if err != nil {
return err
return ssh.ExternalClient{}, err
}
port, err := h.Driver.GetSSHPort()
if err != nil {
return err
return ssh.ExternalClient{}, err
}
auth := &ssh.Auth{
Keys: []string{h.Driver.GetSSHKeyPath()},
}
client, err := ssh.NewClient(h.Driver.GetSSHUsername(), addr, port, auth)
return ssh.NewClient(h.Driver.GetSSHUsername(), addr, port, auth)
}
func (h *Host) CreateSSHShell() error {
client, err := h.CreateSSHClient()
if err != nil {
return err
}

View File

@ -13,7 +13,6 @@ import (
"github.com/docker/machine/libmachine/provision/pkgaction"
"github.com/docker/machine/libmachine/swarm"
"github.com/docker/machine/log"
"github.com/docker/machine/ssh"
"github.com/docker/machine/state"
"github.com/docker/machine/utils"
)
@ -102,17 +101,7 @@ func (provisioner *Boot2DockerProvisioner) Package(name string, action pkgaction
}
func (provisioner *Boot2DockerProvisioner) Hostname() (string, error) {
output, err := provisioner.SSHCommand(fmt.Sprintf("hostname"))
if err != nil {
return "", err
}
var so bytes.Buffer
if _, err := so.ReadFrom(output.Stdout); err != nil {
return "", err
}
return so.String(), nil
return provisioner.SSHCommand("hostname")
}
func (provisioner *Boot2DockerProvisioner) SetHostname(hostname string) error {
@ -231,7 +220,7 @@ func (provisioner *Boot2DockerProvisioner) Provision(swarmOptions swarm.SwarmOpt
return nil
}
func (provisioner *Boot2DockerProvisioner) SSHCommand(args string) (ssh.Output, error) {
func (provisioner *Boot2DockerProvisioner) SSHCommand(args string) (string, error) {
return drivers.RunSSHCommandFromDriver(provisioner.Driver, args)
}

View File

@ -9,7 +9,6 @@ import (
"github.com/docker/machine/libmachine/auth"
"github.com/docker/machine/libmachine/engine"
"github.com/docker/machine/libmachine/swarm"
"github.com/docker/machine/ssh"
)
type GenericProvisioner struct {
@ -25,17 +24,7 @@ type GenericProvisioner struct {
}
func (provisioner *GenericProvisioner) Hostname() (string, error) {
output, err := provisioner.SSHCommand("hostname")
if err != nil {
return "", err
}
var so bytes.Buffer
if _, err := so.ReadFrom(output.Stdout); err != nil {
return "", err
}
return so.String(), nil
return provisioner.SSHCommand("hostname")
}
func (provisioner *GenericProvisioner) SetHostname(hostname string) error {
@ -63,7 +52,7 @@ func (provisioner *GenericProvisioner) GetDockerOptionsDir() string {
return provisioner.DockerOptionsDir
}
func (provisioner *GenericProvisioner) SSHCommand(args string) (ssh.Output, error) {
func (provisioner *GenericProvisioner) SSHCommand(args string) (string, error) {
return drivers.RunSSHCommandFromDriver(provisioner.Driver, args)
}

View File

@ -1,7 +1,6 @@
package provision
import (
"bytes"
"fmt"
"github.com/docker/machine/drivers"
@ -9,7 +8,6 @@ import (
"github.com/docker/machine/libmachine/engine"
"github.com/docker/machine/libmachine/provision/pkgaction"
"github.com/docker/machine/libmachine/swarm"
"github.com/docker/machine/ssh"
)
var provisioners = make(map[string]*RegisteredProvisioner)
@ -52,7 +50,7 @@ type Provisioner interface {
GetDriver() drivers.Driver
// Short-hand for accessing an SSH command from the driver.
SSHCommand(args string) (ssh.Output, error)
SSHCommand(args string) (string, error)
// Set the OS Release info depending on how it's represented
// internally
@ -69,19 +67,12 @@ func Register(name string, p *RegisteredProvisioner) {
}
func DetectProvisioner(d drivers.Driver) (Provisioner, error) {
var (
osReleaseOut bytes.Buffer
)
catOsReleaseOutput, err := drivers.RunSSHCommandFromDriver(d, "cat /etc/os-release")
osReleaseOut, err := drivers.RunSSHCommandFromDriver(d, "cat /etc/os-release")
if err != nil {
return nil, fmt.Errorf("Error getting SSH command: %s", err)
}
if _, err := osReleaseOut.ReadFrom(catOsReleaseOutput.Stdout); err != nil {
return nil, err
}
osReleaseInfo, err := NewOsRelease(osReleaseOut.Bytes())
osReleaseInfo, err := NewOsRelease([]byte(osReleaseOut))
if err != nil {
return nil, fmt.Errorf("Error parsing /etc/os-release file: %s", err)
}

View File

@ -1,7 +1,6 @@
package provision
import (
"bytes"
"fmt"
"io/ioutil"
"net/url"
@ -26,12 +25,7 @@ func installDockerGeneric(p Provisioner) error {
// install docker - until cloudinit we use ubuntu everywhere so we
// just install it using the docker repos
if output, err := p.SSHCommand("if ! type docker; then curl -sSL https://get.docker.com | sh -; fi"); err != nil {
var buf bytes.Buffer
if _, err := buf.ReadFrom(output.Stderr); err != nil {
return err
}
return fmt.Errorf("error installing docker: %s\n", buf.String())
return fmt.Errorf("error installing docker: %s\n", output)
}
return nil

View File

@ -8,6 +8,7 @@ import (
"github.com/docker/machine/commands"
"github.com/docker/machine/log"
"github.com/docker/machine/ssh"
"github.com/docker/machine/utils"
"github.com/docker/machine/version"
)
@ -58,6 +59,9 @@ func main() {
app.Email = "https://github.com/docker/machine"
app.Before = func(c *cli.Context) error {
os.Setenv("MACHINE_STORAGE_PATH", c.GlobalString("storage-path"))
if c.GlobalBool("native-ssh") {
ssh.SetDefaultClient(ssh.Native)
}
return nil
}
app.Commands = commands.Commands
@ -100,6 +104,11 @@ func main() {
Usage: "Private key used in client TLS auth",
Value: "",
},
cli.BoolFlag{
EnvVar: "MACHINE_NATIVE_SSH",
Name: "native-ssh",
Usage: "Use the native (Go-based) SSH implementation.",
},
}
app.Run(os.Args)

View File

@ -1,11 +1,10 @@
package ssh
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"github.com/docker/docker/pkg/term"
"github.com/docker/machine/log"
@ -13,41 +12,108 @@ import (
"golang.org/x/crypto/ssh"
)
type Client struct {
Config *ssh.ClientConfig
type Client interface {
Output(command string) (string, error)
Shell() error
}
type ExternalClient struct {
BaseArgs []string
BinaryPath string
}
type NativeClient struct {
Config ssh.ClientConfig
Hostname string
Port int
}
type Auth struct {
Passwords []string
Keys []string
}
type SSHClientType string
const (
maxDialAttempts = 10
)
func NewClient(user string, host string, port int, auth *Auth) (*Client, error) {
config, err := NewConfig(user, auth)
const (
External SSHClientType = "external"
Native SSHClientType = "native"
)
var (
baseSSHArgs = []string{
"-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=quiet", // suppress "Warning: Permanently added '[localhost]:2022' (ECDSA) to the list of known hosts."
"-o", "ConnectionAttempts=3", // retry 3 times if SSH connection fails
"-o", "ConnectTimeout=10", // timeout after 10 seconds
}
defaultClientType SSHClientType = External
)
func SetDefaultClient(clientType SSHClientType) {
// Allow over-riding of default client type, so that even if ssh binary
// is found in PATH we can still use the Go native implementation if
// desired.
switch clientType {
case External:
defaultClientType = External
case Native:
defaultClientType = Native
}
}
func NewClient(user string, host string, port int, auth *Auth) (Client, error) {
sshBinaryPath, err := exec.LookPath("ssh")
if err != nil {
return nil, err
if defaultClientType == External {
log.Fatal("Requested shellout SSH client type but no ssh binary available")
}
log.Debug("ssh binary not found, using native Go implementation")
return NewNativeClient(user, host, port, auth)
}
return &Client{
if defaultClientType == Native {
log.Debug("Using SSH client type: native")
return NewNativeClient(user, host, port, auth)
}
log.Debug("Using SSH client type: external")
return NewExternalClient(sshBinaryPath, user, host, port, auth)
}
func NewNativeClient(user, host string, port int, auth *Auth) (Client, error) {
config, err := NewNativeConfig(user, auth)
if err != nil {
return nil, fmt.Errorf("Error getting config for native Go SSH: %s", err)
}
return NativeClient{
Config: config,
Hostname: host,
Port: port,
}, nil
}
func NewConfig(user string, auth *Auth) (*ssh.ClientConfig, error) {
var authMethods []ssh.AuthMethod
func NewNativeConfig(user string, auth *Auth) (ssh.ClientConfig, error) {
var (
authMethods []ssh.AuthMethod
)
for _, k := range auth.Keys {
key, err := ioutil.ReadFile(k)
if err != nil {
return nil, err
return ssh.ClientConfig{}, err
}
privateKey, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, err
return ssh.ClientConfig{}, err
}
authMethods = append(authMethods, ssh.PublicKeys(privateKey))
@ -57,57 +123,47 @@ func NewConfig(user string, auth *Auth) (*ssh.ClientConfig, error) {
authMethods = append(authMethods, ssh.Password(p))
}
return &ssh.ClientConfig{
return ssh.ClientConfig{
User: user,
Auth: authMethods,
}, nil
}
func dialSuccess(client *Client) func() bool {
return func() bool {
if _, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", client.Hostname, client.Port), client.Config); err != nil {
func (client NativeClient) dialSuccess() bool {
if _, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", client.Hostname, client.Port), &client.Config); err != nil {
log.Debugf("Error dialing TCP: %s", err)
return false
}
return true
}
}
func (client *Client) Run(command string) (Output, error) {
var (
output Output
stdout, stderr bytes.Buffer
)
if err := utils.WaitFor(dialSuccess(client)); err != nil {
return output, fmt.Errorf("Error attempting SSH client dial: %s", err)
func (client NativeClient) Output(command string) (string, error) {
if err := utils.WaitFor(client.dialSuccess); err != nil {
return "", fmt.Errorf("Error attempting SSH client dial: %s", err)
}
conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", client.Hostname, client.Port), client.Config)
conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", client.Hostname, client.Port), &client.Config)
if err != nil {
return output, fmt.Errorf("Mysterious error dialing TCP for SSH (we already succeeded at least once) : %s", err)
return "", fmt.Errorf("Mysterious error dialing TCP for SSH (we already succeeded at least once) : %s", err)
}
session, err := conn.NewSession()
if err != nil {
return output, fmt.Errorf("Error getting new session: %s", err)
return "", fmt.Errorf("Error getting new session: %s", err)
}
defer session.Close()
session.Stdout = &stdout
session.Stderr = &stderr
output, err := session.CombinedOutput(command)
output = Output{
Stdout: &stdout,
Stderr: &stderr,
}
return output, session.Run(command)
return string(output), err
}
func (client *Client) Shell() error {
conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", client.Hostname, client.Port), client.Config)
func (client NativeClient) Shell() error {
var (
termWidth, termHeight int
)
conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", client.Hostname, client.Port), &client.Config)
if err != nil {
return err
}
@ -127,14 +183,10 @@ func (client *Client) Shell() error {
ssh.ECHO: 1,
}
var termWidth, termHeight int
fd := os.Stdin.Fd()
if term.IsTerminal(fd) {
var oldState *term.State
oldState, err = term.MakeRaw(fd)
oldState, err := term.MakeRaw(fd)
if err != nil {
return err
}
@ -164,12 +216,51 @@ func (client *Client) Shell() error {
return nil
}
type Auth struct {
Passwords []string
Keys []string
func NewExternalClient(sshBinaryPath, user, host string, port int, auth *Auth) (ExternalClient, error) {
client := ExternalClient{
BinaryPath: sshBinaryPath,
}
// Base args take care of settings some options for us, e.g. don't use
// the authorized hosts file.
args := baseSSHArgs
// Specify which private keys to use to authorize the SSH request.
for _, privateKeyPath := range auth.Keys {
args = append(args, "-i", privateKeyPath)
}
// Set which port to use for SSH.
args = append(args, "-p", fmt.Sprintf("%d", port))
// Set the user and hostname, e.g. ubuntu@12.34.56.78
args = append(args, fmt.Sprintf("%s@%s", user, host))
client.BaseArgs = args
return client, nil
}
type Output struct {
Stdout io.Reader
Stderr io.Reader
func (client ExternalClient) Output(command string) (string, error) {
args := append(client.BaseArgs, command)
cmd := exec.Command(client.BinaryPath, args...)
log.Debug(cmd)
// Allow piping of local things to remote commands.
cmd.Stdin = os.Stdin
output, err := cmd.CombinedOutput()
return string(output), err
}
func (client ExternalClient) Shell() error {
cmd := exec.Command(client.BinaryPath, client.BaseArgs...)
log.Debug(cmd)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

View File

@ -0,0 +1,45 @@
#!/usr/bin/env bats
load helpers
export DRIVER=virtualbox
export NAME="bats-$DRIVER-test"
export MACHINE_STORAGE_PATH=/tmp/machine-bats-test-$DRIVER
# Basic smoke test for SSH backends
@test "$DRIVER: create SSH test box" {
run machine create -d $DRIVER $NAME
[[ "$status" -eq 0 ]]
}
@test "$DRIVER: test external ssh backend" {
run machine ssh $NAME -- df -h
[[ "$status" -eq 0 ]]
}
@test "$DRIVER: test command did what it purported to -- external ssh" {
run machine ssh $NAME echo foo
[[ "$output" == "foo" ]]
}
@test "$DRIVER: test native ssh backend" {
run machine --native-ssh ssh $NAME -- df -h
[[ "$status" -eq 0 ]]
}
@test "$DRIVER: test command did what it purported to -- native ssh" {
run machine --native-ssh ssh $NAME echo foo
[[ "$output" == "foo" ]]
}
@test "$DRIVER: remove machine after ssh backend test" {
run machine rm -f $NAME
}
# Cleanup of machine store should always be the last 'test'
@test "$DRIVER: cleanup" {
run rm -rf $MACHINE_STORAGE_PATH
[ "$status" -eq 0 ]
}