diff --git a/builtin/provisioners/file/resource_provisioner.go b/builtin/provisioners/file/resource_provisioner.go index 3b7b790250c6..9484d3f18f4f 100644 --- a/builtin/provisioners/file/resource_provisioner.go +++ b/builtin/provisioners/file/resource_provisioner.go @@ -6,25 +6,22 @@ import ( "os" "time" + "github.com/hashicorp/terraform/communicator" "github.com/hashicorp/terraform/helper/config" - helper "github.com/hashicorp/terraform/helper/ssh" "github.com/hashicorp/terraform/terraform" "github.com/mitchellh/go-homedir" ) +// ResourceProvisioner represents a file provisioner type ResourceProvisioner struct{} +// Apply executes the file provisioner func (p *ResourceProvisioner) Apply( o terraform.UIOutput, s *terraform.InstanceState, c *terraform.ResourceConfig) error { - // Ensure the connection type is SSH - if err := helper.VerifySSH(s); err != nil { - return err - } - - // Get the SSH configuration - conf, err := helper.ParseSSHConfig(s) + // Get a new communicator + comm, err := communicator.New(s) if err != nil { return err } @@ -46,9 +43,10 @@ func (p *ResourceProvisioner) Apply( if !ok { return fmt.Errorf("Unsupported 'destination' type! Must be string.") } - return p.copyFiles(conf, src, dst) + return p.copyFiles(comm, src, dst) } +// Validate checks if the required arguments are configured func (p *ResourceProvisioner) Validate(c *terraform.ResourceConfig) (ws []string, es []error) { v := &config.Validator{ Required: []string{ @@ -60,24 +58,16 @@ func (p *ResourceProvisioner) Validate(c *terraform.ResourceConfig) (ws []string } // copyFiles is used to copy the files from a source to a destination -func (p *ResourceProvisioner) copyFiles(conf *helper.SSHConfig, src, dst string) error { - // Get the SSH client config - config, err := helper.PrepareConfig(conf) - if err != nil { - return err - } - defer config.CleanupConfig() - - // Wait and retry until we establish the SSH connection - var comm *helper.SSHCommunicator - err = retryFunc(conf.TimeoutVal, func() error { - host := fmt.Sprintf("%s:%d", conf.Host, conf.Port) - comm, err = helper.New(host, config) +func (p *ResourceProvisioner) copyFiles(comm communicator.Communicator, src, dst string) error { + // Wait and retry until we establish the connection + err := retryFunc(comm.Timeout(), func() error { + err := comm.Connect(nil) return err }) if err != nil { return err } + defer comm.Disconnect() info, err := os.Stat(src) if err != nil { @@ -86,7 +76,7 @@ func (p *ResourceProvisioner) copyFiles(conf *helper.SSHConfig, src, dst string) // If we're uploading a directory, short circuit and do that if info.IsDir() { - if err := comm.UploadDir(dst, src, nil); err != nil { + if err := comm.UploadDir(dst, src); err != nil { return fmt.Errorf("Upload failed: %v", err) } return nil diff --git a/builtin/provisioners/remote-exec/resource_provisioner.go b/builtin/provisioners/remote-exec/resource_provisioner.go index a4c169b2274f..f0905b4ef96c 100644 --- a/builtin/provisioners/remote-exec/resource_provisioner.go +++ b/builtin/provisioners/remote-exec/resource_provisioner.go @@ -10,29 +10,22 @@ import ( "strings" "time" - helper "github.com/hashicorp/terraform/helper/ssh" + "github.com/hashicorp/terraform/communicator" + "github.com/hashicorp/terraform/communicator/remote" "github.com/hashicorp/terraform/terraform" "github.com/mitchellh/go-linereader" ) -const ( - // DefaultShebang is added at the top of the script file - DefaultShebang = "#!/bin/sh" -) - +// ResourceProvisioner represents a remote exec provisioner type ResourceProvisioner struct{} +// Apply executes the remote exec provisioner func (p *ResourceProvisioner) Apply( o terraform.UIOutput, s *terraform.InstanceState, c *terraform.ResourceConfig) error { - // Ensure the connection type is SSH - if err := helper.VerifySSH(s); err != nil { - return err - } - - // Get the SSH configuration - conf, err := helper.ParseSSHConfig(s) + // Get a new communicator + comm, err := communicator.New(s) if err != nil { return err } @@ -47,12 +40,13 @@ func (p *ResourceProvisioner) Apply( } // Copy and execute each script - if err := p.runScripts(o, conf, scripts); err != nil { + if err := p.runScripts(o, comm, scripts); err != nil { return err } return nil } +// Validate checks if the required arguments are configured func (p *ResourceProvisioner) Validate(c *terraform.ResourceConfig) (ws []string, es []error) { num := 0 for name := range c.Raw { @@ -76,7 +70,7 @@ func (p *ResourceProvisioner) Validate(c *terraform.ResourceConfig) (ws []string // generateScript takes the configuration and creates a script to be executed // from the inline configs func (p *ResourceProvisioner) generateScript(c *terraform.ResourceConfig) (string, error) { - lines := []string{DefaultShebang} + var lines []string command, ok := c.Config["inline"] if ok { switch cmd := command.(type) { @@ -165,46 +159,20 @@ func (p *ResourceProvisioner) collectScripts(c *terraform.ResourceConfig) ([]io. // runScripts is used to copy and execute a set of scripts func (p *ResourceProvisioner) runScripts( o terraform.UIOutput, - conf *helper.SSHConfig, + comm communicator.Communicator, scripts []io.ReadCloser) error { - // Get the SSH client config - config, err := helper.PrepareConfig(conf) - if err != nil { - return err - } - defer config.CleanupConfig() - - o.Output(fmt.Sprintf( - "Connecting to remote host via SSH...\n"+ - " Host: %s\n"+ - " User: %s\n"+ - " Password: %v\n"+ - " Private key: %v"+ - " SSH Agent: %v", - conf.Host, conf.User, - conf.Password != "", - conf.KeyFile != "", - conf.Agent, - )) - - // Wait and retry until we establish the SSH connection - var comm *helper.SSHCommunicator - err = retryFunc(conf.TimeoutVal, func() error { - host := fmt.Sprintf("%s:%d", conf.Host, conf.Port) - comm, err = helper.New(host, config) - if err != nil { - o.Output(fmt.Sprintf("Connection error, will retry: %s", err)) - } - + // Wait and retry until we establish the connection + err := retryFunc(comm.Timeout(), func() error { + err := comm.Connect(o) return err }) if err != nil { return err } + defer comm.Disconnect() - o.Output("Connected! Executing scripts...") for _, script := range scripts { - var cmd *helper.RemoteCmd + var cmd *remote.Cmd outR, outW := io.Pipe() errR, errW := io.Pipe() outDoneCh := make(chan struct{}) @@ -212,23 +180,14 @@ func (p *ResourceProvisioner) runScripts( go p.copyOutput(o, outR, outDoneCh) go p.copyOutput(o, errR, errDoneCh) - err := retryFunc(conf.TimeoutVal, func() error { - remotePath := conf.RemotePath() + err = retryFunc(comm.Timeout(), func() error { + remotePath := comm.ScriptPath() - if err := comm.Upload(remotePath, script); err != nil { + if err := comm.UploadScript(remotePath, script); err != nil { return fmt.Errorf("Failed to upload script: %v", err) } - cmd = &helper.RemoteCmd{ - Command: fmt.Sprintf("chmod 0777 %s", remotePath), - } - if err := comm.Start(cmd); err != nil { - return fmt.Errorf( - "Error chmodding script file to 0777 in remote "+ - "machine: %s", err) - } - cmd.Wait() - cmd = &helper.RemoteCmd{ + cmd = &remote.Cmd{ Command: remotePath, Stdout: outW, Stderr: errW, @@ -236,6 +195,7 @@ func (p *ResourceProvisioner) runScripts( if err := comm.Start(cmd); err != nil { return fmt.Errorf("Error starting script: %v", err) } + return nil }) if err == nil { diff --git a/builtin/provisioners/remote-exec/resource_provisioner_test.go b/builtin/provisioners/remote-exec/resource_provisioner_test.go index 47a723947419..a10520fb816c 100644 --- a/builtin/provisioners/remote-exec/resource_provisioner_test.go +++ b/builtin/provisioners/remote-exec/resource_provisioner_test.go @@ -41,6 +41,11 @@ func TestResourceProvider_Validate_bad(t *testing.T) { } } +var expectedScriptOut = `cd /tmp +wget http://foobar +exit 0 +` + func TestResourceProvider_generateScript(t *testing.T) { p := new(ResourceProvisioner) conf := testConfig(t, map[string]interface{}{ @@ -60,12 +65,6 @@ func TestResourceProvider_generateScript(t *testing.T) { } } -var expectedScriptOut = `#!/bin/sh -cd /tmp -wget http://foobar -exit 0 -` - func TestResourceProvider_CollectScripts_inline(t *testing.T) { p := new(ResourceProvisioner) conf := testConfig(t, map[string]interface{}{ @@ -91,8 +90,8 @@ func TestResourceProvider_CollectScripts_inline(t *testing.T) { t.Fatalf("err: %v", err) } - if string(out.Bytes()) != expectedScriptOut { - t.Fatalf("bad: %v", out.Bytes()) + if out.String() != expectedScriptOut { + t.Fatalf("bad: %v", out.String()) } } @@ -117,8 +116,8 @@ func TestResourceProvider_CollectScripts_script(t *testing.T) { t.Fatalf("err: %v", err) } - if string(out.Bytes()) != expectedScriptOut { - t.Fatalf("bad: %v", out.Bytes()) + if out.String() != expectedScriptOut { + t.Fatalf("bad: %v", out.String()) } } @@ -148,8 +147,8 @@ func TestResourceProvider_CollectScripts_scripts(t *testing.T) { t.Fatalf("err: %v", err) } - if string(out.Bytes()) != expectedScriptOut { - t.Fatalf("bad: %v", out.Bytes()) + if out.String() != expectedScriptOut { + t.Fatalf("bad: %v", out.String()) } } } diff --git a/builtin/provisioners/remote-exec/test-fixtures/script1.sh b/builtin/provisioners/remote-exec/test-fixtures/script1.sh index cd22f3e63003..81b3d5af86a2 100755 --- a/builtin/provisioners/remote-exec/test-fixtures/script1.sh +++ b/builtin/provisioners/remote-exec/test-fixtures/script1.sh @@ -1,4 +1,3 @@ -#!/bin/sh cd /tmp wget http://foobar exit 0 diff --git a/communicator/communicator.go b/communicator/communicator.go new file mode 100644 index 000000000000..5fa2631a49ff --- /dev/null +++ b/communicator/communicator.go @@ -0,0 +1,53 @@ +package communicator + +import ( + "fmt" + "io" + "time" + + "github.com/hashicorp/terraform/communicator/remote" + "github.com/hashicorp/terraform/communicator/ssh" + "github.com/hashicorp/terraform/communicator/winrm" + "github.com/hashicorp/terraform/terraform" +) + +// Communicator is an interface that must be implemented by all communicators +// used for any of the provisioners +type Communicator interface { + // Connect is used to setup the connection + Connect(terraform.UIOutput) error + + // Disconnect is used to terminate the connection + Disconnect() error + + // Timeout returns the configured connection timeout + Timeout() time.Duration + + // ScriptPath returns the configured script path + ScriptPath() string + + // Start executes a remote command in a new session + Start(*remote.Cmd) error + + // Upload is used to upload a single file + Upload(string, io.Reader) error + + // UploadScript is used to upload a file as a executable script + UploadScript(string, io.Reader) error + + // UploadDir is used to upload a directory + UploadDir(string, string) error +} + +// New returns a configured Communicator or an error if the connection type is not supported +func New(s *terraform.InstanceState) (Communicator, error) { + connType := s.Ephemeral.ConnInfo["type"] + switch connType { + case "ssh", "": // The default connection type is ssh, so if connType is empty use ssh + return ssh.New(s) + case "winrm": + return winrm.New(s) + default: + return nil, fmt.Errorf("connection type '%s' not supported", connType) + } +} diff --git a/communicator/communicator_test.go b/communicator/communicator_test.go new file mode 100644 index 000000000000..33a91cd6f365 --- /dev/null +++ b/communicator/communicator_test.go @@ -0,0 +1,30 @@ +package communicator + +import ( + "testing" + + "github.com/hashicorp/terraform/terraform" +) + +func TestCommunicator_new(t *testing.T) { + r := &terraform.InstanceState{ + Ephemeral: terraform.EphemeralState{ + ConnInfo: map[string]string{ + "type": "telnet", + }, + }, + } + if _, err := New(r); err == nil { + t.Fatalf("expected error with telnet") + } + + r.Ephemeral.ConnInfo["type"] = "ssh" + if _, err := New(r); err != nil { + t.Fatalf("err: %v", err) + } + + r.Ephemeral.ConnInfo["type"] = "winrm" + if _, err := New(r); err != nil { + t.Fatalf("err: %v", err) + } +} diff --git a/communicator/remote/command.go b/communicator/remote/command.go new file mode 100644 index 000000000000..ae16dfa882a3 --- /dev/null +++ b/communicator/remote/command.go @@ -0,0 +1,67 @@ +package remote + +import ( + "io" + "sync" +) + +// Cmd represents a remote command being prepared or run. +type Cmd struct { + // Command is the command to run remotely. This is executed as if + // it were a shell command, so you are expected to do any shell escaping + // necessary. + Command string + + // Stdin specifies the process's standard input. If Stdin is + // nil, the process reads from an empty bytes.Buffer. + Stdin io.Reader + + // Stdout and Stderr represent the process's standard output and + // error. + // + // If either is nil, it will be set to ioutil.Discard. + Stdout io.Writer + Stderr io.Writer + + // This will be set to true when the remote command has exited. It + // shouldn't be set manually by the user, but there is no harm in + // doing so. + Exited bool + + // Once Exited is true, this will contain the exit code of the process. + ExitStatus int + + // Internal fields + exitCh chan struct{} + + // This thing is a mutex, lock when making modifications concurrently + sync.Mutex +} + +// SetExited is a helper for setting that this process is exited. This +// should be called by communicators who are running a remote command in +// order to set that the command is done. +func (r *Cmd) SetExited(status int) { + r.Lock() + defer r.Unlock() + + if r.exitCh == nil { + r.exitCh = make(chan struct{}) + } + + r.Exited = true + r.ExitStatus = status + close(r.exitCh) +} + +// Wait waits for the remote command to complete. +func (r *Cmd) Wait() { + // Make sure our condition variable is initialized. + r.Lock() + if r.exitCh == nil { + r.exitCh = make(chan struct{}) + } + r.Unlock() + + <-r.exitCh +} diff --git a/communicator/remote/command_test.go b/communicator/remote/command_test.go new file mode 100644 index 000000000000..fbe5b64eba1d --- /dev/null +++ b/communicator/remote/command_test.go @@ -0,0 +1 @@ +package remote diff --git a/helper/ssh/communicator.go b/communicator/ssh/communicator.go similarity index 71% rename from helper/ssh/communicator.go rename to communicator/ssh/communicator.go index f908de97dfa3..c8534c16da4b 100644 --- a/helper/ssh/communicator.go +++ b/communicator/ssh/communicator.go @@ -8,122 +8,153 @@ import ( "io" "io/ioutil" "log" + "math/rand" "net" "os" "path/filepath" - "sync" + "strconv" + "strings" "time" + "github.com/hashicorp/terraform/communicator/remote" + "github.com/hashicorp/terraform/terraform" "golang.org/x/crypto/ssh" ) -// RemoteCmd represents a remote command being prepared or run. -type RemoteCmd struct { - // Command is the command to run remotely. This is executed as if - // it were a shell command, so you are expected to do any shell escaping - // necessary. - Command string - - // Stdin specifies the process's standard input. If Stdin is - // nil, the process reads from an empty bytes.Buffer. - Stdin io.Reader - - // Stdout and Stderr represent the process's standard output and - // error. - // - // If either is nil, it will be set to ioutil.Discard. - Stdout io.Writer - Stderr io.Writer - - // This will be set to true when the remote command has exited. It - // shouldn't be set manually by the user, but there is no harm in - // doing so. - Exited bool - - // Once Exited is true, this will contain the exit code of the process. - ExitStatus int - - // Internal fields - exitCh chan struct{} - - // This thing is a mutex, lock when making modifications concurrently - sync.Mutex +const ( + // DefaultShebang is added at the top of a SSH script file + DefaultShebang = "#!/bin/sh\n" +) + +// Communicator represents the SSH communicator +type Communicator struct { + connInfo *connectionInfo + client *ssh.Client + config *sshConfig + conn net.Conn + address string } -// SetExited is a helper for setting that this process is exited. This -// should be called by communicators who are running a remote command in -// order to set that the command is done. -func (r *RemoteCmd) SetExited(status int) { - r.Lock() - defer r.Unlock() +type sshConfig struct { + // The configuration of the Go SSH connection + config *ssh.ClientConfig - if r.exitCh == nil { - r.exitCh = make(chan struct{}) - } + // connection returns a new connection. The current connection + // in use will be closed as part of the Close method, or in the + // case an error occurs. + connection func() (net.Conn, error) - r.Exited = true - r.ExitStatus = status - close(r.exitCh) + // noPty, if true, will not request a pty from the remote end. + noPty bool + + // sshAgentConn is a pointer to the UNIX connection for talking with the + // ssh-agent. + sshAgentConn net.Conn } -// Wait waits for the remote command to complete. -func (r *RemoteCmd) Wait() { - // Make sure our condition variable is initialized. - r.Lock() - if r.exitCh == nil { - r.exitCh = make(chan struct{}) +// New creates a new communicator implementation over SSH. +func New(s *terraform.InstanceState) (*Communicator, error) { + connInfo, err := parseConnectionInfo(s) + if err != nil { + return nil, err } - r.Unlock() - <-r.exitCh -} + config, err := prepareSSHConfig(connInfo) + if err != nil { + return nil, err + } -type SSHCommunicator struct { - client *ssh.Client - config *Config - conn net.Conn - address string + comm := &Communicator{ + connInfo: connInfo, + config: config, + } + + return comm, nil } -// Config is the structure used to configure the SSH communicator. -type Config struct { - // The configuration of the Go SSH connection - SSHConfig *ssh.ClientConfig +// Connect implementation of communicator.Communicator interface +func (c *Communicator) Connect(o terraform.UIOutput) (err error) { + if c.conn != nil { + c.conn.Close() + } - // Connection returns a new connection. The current connection - // in use will be closed as part of the Close method, or in the - // case an error occurs. - Connection func() (net.Conn, error) + // Set the conn and client to nil since we'll recreate it + c.conn = nil + c.client = nil - // NoPty, if true, will not request a pty from the remote end. - NoPty bool + if o != nil { + o.Output(fmt.Sprintf( + "Connecting to remote host via SSH...\n"+ + " Host: %s\n"+ + " User: %s\n"+ + " Password: %t\n"+ + " Private key: %t\n"+ + " SSH Agent: %t", + c.connInfo.Host, c.connInfo.User, + c.connInfo.Password != "", + c.connInfo.KeyFile != "", + c.connInfo.Agent, + )) + } + + log.Printf("connecting to TCP connection for SSH") + c.conn, err = c.config.connection() + if err != nil { + // Explicitly set this to the REAL nil. Connection() can return + // a nil implementation of net.Conn which will make the + // "if c.conn == nil" check fail above. Read here for more information + // on this psychotic language feature: + // + // http://golang.org/doc/faq#nil_error + c.conn = nil - // SSHAgentConn is a pointer to the UNIX connection for talking with the - // ssh-agent. - SSHAgentConn net.Conn -} + log.Printf("connection error: %s", err) + return err + } -// New creates a new packer.Communicator implementation over SSH. This takes -// an already existing TCP connection and SSH configuration. -func New(address string, config *Config) (result *SSHCommunicator, err error) { - // Establish an initial connection and connect - result = &SSHCommunicator{ - config: config, - address: address, + log.Printf("handshaking with SSH") + host := fmt.Sprintf("%s:%d", c.connInfo.Host, c.connInfo.Port) + sshConn, sshChan, req, err := ssh.NewClientConn(c.conn, host, c.config.config) + if err != nil { + log.Printf("handshake error: %s", err) + return err } - if err = result.reconnect(); err != nil { - result = nil - return + c.client = ssh.NewClient(sshConn, sshChan, req) + + if o != nil { + o.Output("Connected!") } - return + return err +} + +// Disconnect implementation of communicator.Communicator interface +func (c *Communicator) Disconnect() error { + if c.config.sshAgentConn != nil { + return c.config.sshAgentConn.Close() + } + + return nil +} + +// Timeout implementation of communicator.Communicator interface +func (c *Communicator) Timeout() time.Duration { + return c.connInfo.TimeoutVal +} + +// ScriptPath implementation of communicator.Communicator interface +func (c *Communicator) ScriptPath() string { + return strings.Replace( + c.connInfo.ScriptPath, "%RAND%", + strconv.FormatInt(int64(rand.Int31()), 10), -1) } -func (c *SSHCommunicator) Start(cmd *RemoteCmd) (err error) { +// Start implementation of communicator.Communicator interface +func (c *Communicator) Start(cmd *remote.Cmd) error { session, err := c.newSession() if err != nil { - return + return err } // Setup our session @@ -131,7 +162,7 @@ func (c *SSHCommunicator) Start(cmd *RemoteCmd) (err error) { session.Stdout = cmd.Stdout session.Stderr = cmd.Stderr - if !c.config.NoPty { + if !c.config.noPty { // Request a PTY termModes := ssh.TerminalModes{ ssh.ECHO: 0, // do not echo @@ -139,15 +170,15 @@ func (c *SSHCommunicator) Start(cmd *RemoteCmd) (err error) { ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud } - if err = session.RequestPty("xterm", 80, 40, termModes); err != nil { - return + if err := session.RequestPty("xterm", 80, 40, termModes); err != nil { + return err } } log.Printf("starting remote command: %s", cmd.Command) err = session.Start(cmd.Command + "\n") if err != nil { - return + return err } // Start a goroutine to wait for the session to end and set the @@ -168,10 +199,11 @@ func (c *SSHCommunicator) Start(cmd *RemoteCmd) (err error) { cmd.SetExited(exitStatus) }() - return + return nil } -func (c *SSHCommunicator) Upload(path string, input io.Reader) error { +// Upload implementation of communicator.Communicator interface +func (c *Communicator) Upload(path string, input io.Reader) error { // The target directory and file for talking the SCP protocol targetDir := filepath.Dir(path) targetFile := filepath.Base(path) @@ -188,7 +220,30 @@ func (c *SSHCommunicator) Upload(path string, input io.Reader) error { return c.scpSession("scp -vt "+targetDir, scpFunc) } -func (c *SSHCommunicator) UploadDir(dst string, src string, excl []string) error { +// UploadScript implementation of communicator.Communicator interface +func (c *Communicator) UploadScript(path string, input io.Reader) error { + script := bytes.NewBufferString(DefaultShebang) + script.ReadFrom(input) + + if err := c.Upload(path, script); err != nil { + return err + } + + cmd := &remote.Cmd{ + Command: fmt.Sprintf("chmod 0777 %s", c.connInfo.ScriptPath), + } + if err := c.Start(cmd); err != nil { + return fmt.Errorf( + "Error chmodding script file to 0777 in remote "+ + "machine: %s", err) + } + cmd.Wait() + + return nil +} + +// UploadDir implementation of communicator.Communicator interface +func (c *Communicator) UploadDir(dst string, src string) error { log.Printf("Upload dir '%s' to '%s'", src, dst) scpFunc := func(w io.Writer, r *bufio.Reader) error { uploadEntries := func() error { @@ -217,11 +272,7 @@ func (c *SSHCommunicator) UploadDir(dst string, src string, excl []string) error return c.scpSession("scp -rvt "+dst, scpFunc) } -func (c *SSHCommunicator) Download(string, io.Writer) error { - panic("not implemented yet") -} - -func (c *SSHCommunicator) newSession() (session *ssh.Session, err error) { +func (c *Communicator) newSession() (session *ssh.Session, err error) { log.Println("opening new ssh session") if c.client == nil { err = errors.New("client not available") @@ -231,7 +282,7 @@ func (c *SSHCommunicator) newSession() (session *ssh.Session, err error) { if err != nil { log.Printf("ssh session open error: '%s', attempting reconnect", err) - if err := c.reconnect(); err != nil { + if err := c.Connect(nil); err != nil { return nil, err } @@ -241,43 +292,7 @@ func (c *SSHCommunicator) newSession() (session *ssh.Session, err error) { return session, nil } -func (c *SSHCommunicator) reconnect() (err error) { - if c.conn != nil { - c.conn.Close() - } - - // Set the conn and client to nil since we'll recreate it - c.conn = nil - c.client = nil - - log.Printf("reconnecting to TCP connection for SSH") - c.conn, err = c.config.Connection() - if err != nil { - // Explicitly set this to the REAL nil. Connection() can return - // a nil implementation of net.Conn which will make the - // "if c.conn == nil" check fail above. Read here for more information - // on this psychotic language feature: - // - // http://golang.org/doc/faq#nil_error - c.conn = nil - - log.Printf("reconnection error: %s", err) - return - } - - log.Printf("handshaking with SSH") - sshConn, sshChan, req, err := ssh.NewClientConn(c.conn, c.address, c.config.SSHConfig) - if err != nil { - log.Printf("handshake error: %s", err) - } - if sshConn != nil { - c.client = ssh.NewClient(sshConn, sshChan, req) - } - - return -} - -func (c *SSHCommunicator) scpSession(scpCommand string, f func(io.Writer, *bufio.Reader) error) error { +func (c *Communicator) scpSession(scpCommand string, f func(io.Writer, *bufio.Reader) error) error { session, err := c.newSession() if err != nil { return err @@ -382,7 +397,7 @@ func checkSCPStatus(r *bufio.Reader) error { func scpUploadFile(dst string, src io.Reader, w io.Writer, r *bufio.Reader) error { // Create a temporary file where we can copy the contents of the src // so that we can determine the length, since SCP is length-prefixed. - tf, err := ioutil.TempFile("", "packer-upload") + tf, err := ioutil.TempFile("", "terraform-upload") if err != nil { return fmt.Errorf("Error creating temporary file for upload: %s", err) } diff --git a/helper/ssh/communicator_test.go b/communicator/ssh/communicator_test.go similarity index 71% rename from helper/ssh/communicator_test.go rename to communicator/ssh/communicator_test.go index b71321701070..24571f0af53e 100644 --- a/helper/ssh/communicator_test.go +++ b/communicator/ssh/communicator_test.go @@ -6,8 +6,12 @@ import ( "bytes" "fmt" "net" + "regexp" + "strings" "testing" + "github.com/hashicorp/terraform/communicator/remote" + "github.com/hashicorp/terraform/terraform" "golang.org/x/crypto/ssh" ) @@ -105,67 +109,91 @@ func newMockLineServer(t *testing.T) string { } func TestNew_Invalid(t *testing.T) { - clientConfig := &ssh.ClientConfig{ - User: "user", - Auth: []ssh.AuthMethod{ - ssh.Password("i-am-invalid"), - }, - } - address := newMockLineServer(t) - conn := func() (net.Conn, error) { - conn, err := net.Dial("tcp", address) - if err != nil { - t.Errorf("Unable to accept incoming connection: %v", err) - } - return conn, err + parts := strings.Split(address, ":") + + r := &terraform.InstanceState{ + Ephemeral: terraform.EphemeralState{ + ConnInfo: map[string]string{ + "type": "ssh", + "user": "user", + "password": "i-am-invalid", + "host": parts[0], + "port": parts[1], + "timeout": "30s", + }, + }, } - config := &Config{ - Connection: conn, - SSHConfig: clientConfig, + c, err := New(r) + if err != nil { + t.Fatalf("error creating communicator: %s", err) } - _, err := New(address, config) + err = c.Connect(nil) if err == nil { t.Fatal("should have had an error connecting") } } func TestStart(t *testing.T) { - clientConfig := &ssh.ClientConfig{ - User: "user", - Auth: []ssh.AuthMethod{ - ssh.Password("pass"), - }, - } - address := newMockLineServer(t) - conn := func() (net.Conn, error) { - conn, err := net.Dial("tcp", address) - if err != nil { - t.Fatalf("unable to dial to remote side: %s", err) - } - return conn, err - } - - config := &Config{ - Connection: conn, - SSHConfig: clientConfig, + parts := strings.Split(address, ":") + + r := &terraform.InstanceState{ + Ephemeral: terraform.EphemeralState{ + ConnInfo: map[string]string{ + "type": "ssh", + "user": "user", + "password": "pass", + "host": parts[0], + "port": parts[1], + "timeout": "30s", + }, + }, } - client, err := New(address, config) + c, err := New(r) if err != nil { - t.Fatalf("error connecting to SSH: %s", err) + t.Fatalf("error creating communicator: %s", err) } - var cmd RemoteCmd + var cmd remote.Cmd stdout := new(bytes.Buffer) cmd.Command = "echo foo" cmd.Stdout = stdout - err = client.Start(&cmd) + err = c.Start(&cmd) if err != nil { - t.Fatalf("error executing command: %s", err) + t.Fatalf("error executing remote command: %s", err) + } +} + +func TestScriptPath(t *testing.T) { + cases := []struct { + Input string + Pattern string + }{ + { + "/tmp/script.sh", + `^/tmp/script\.sh$`, + }, + { + "/tmp/script_%RAND%.sh", + `^/tmp/script_(\d+)\.sh$`, + }, + } + + for _, tc := range cases { + comm := &Communicator{connInfo: &connectionInfo{ScriptPath: tc.Input}} + output := comm.ScriptPath() + + match, err := regexp.Match(tc.Pattern, []byte(output)) + if err != nil { + t.Fatalf("bad: %s\n\nerr: %s", tc.Input, err) + } + if !match { + t.Fatalf("bad: %s\n\n%s", tc.Input, output) + } } } diff --git a/helper/ssh/password.go b/communicator/ssh/password.go similarity index 100% rename from helper/ssh/password.go rename to communicator/ssh/password.go diff --git a/helper/ssh/password_test.go b/communicator/ssh/password_test.go similarity index 100% rename from helper/ssh/password_test.go rename to communicator/ssh/password_test.go diff --git a/helper/ssh/provisioner.go b/communicator/ssh/provisioner.go similarity index 57% rename from helper/ssh/provisioner.go rename to communicator/ssh/provisioner.go index 69468de7dbaf..12d7048e72c3 100644 --- a/helper/ssh/provisioner.go +++ b/communicator/ssh/provisioner.go @@ -5,11 +5,8 @@ import ( "fmt" "io/ioutil" "log" - "math/rand" "net" "os" - "strconv" - "strings" "time" "github.com/hashicorp/terraform/terraform" @@ -20,7 +17,7 @@ import ( ) const ( - // DefaultUser is used if there is no default user given + // DefaultUser is used if there is no user given DefaultUser = "root" // DefaultPort is used if there is no port given @@ -28,16 +25,16 @@ const ( // DefaultScriptPath is used as the path to copy the file to // for remote execution if not provided otherwise. - DefaultScriptPath = "/tmp/script_%RAND%.sh" + DefaultScriptPath = "/tmp/terraform_%RAND%.sh" // DefaultTimeout is used if there is no timeout given DefaultTimeout = 5 * time.Minute ) -// SSHConfig is decoded from the ConnInfo of the resource. These -// are the only keys we look at. If a KeyFile is given, that is used -// instead of a password. -type SSHConfig struct { +// connectionInfo is decoded from the ConnInfo of the resource. These are the +// only keys we look at. If a KeyFile is given, that is used instead +// of a password. +type connectionInfo struct { User string Password string KeyFile string `mapstructure:"key_file"` @@ -49,31 +46,13 @@ type SSHConfig struct { TimeoutVal time.Duration `mapstructure:"-"` } -func (c *SSHConfig) RemotePath() string { - return strings.Replace( - c.ScriptPath, "%RAND%", - strconv.FormatInt(int64(rand.Int31()), 10), -1) -} - -// VerifySSH is used to verify the ConnInfo is usable by remote-exec -func VerifySSH(s *terraform.InstanceState) error { - connType := s.Ephemeral.ConnInfo["type"] - switch connType { - case "": - case "ssh": - default: - return fmt.Errorf("Connection type '%s' not supported", connType) - } - return nil -} - -// ParseSSHConfig is used to convert the ConnInfo of the InstanceState into -// a SSHConfig struct -func ParseSSHConfig(s *terraform.InstanceState) (*SSHConfig, error) { - sshConf := &SSHConfig{} +// parseConnectionInfo is used to convert the ConnInfo of the InstanceState into +// a ConnectionInfo struct +func parseConnectionInfo(s *terraform.InstanceState) (*connectionInfo, error) { + connInfo := &connectionInfo{} decConf := &mapstructure.DecoderConfig{ WeaklyTypedInput: true, - Result: sshConf, + Result: connInfo, } dec, err := mapstructure.NewDecoder(decConf) if err != nil { @@ -82,21 +61,23 @@ func ParseSSHConfig(s *terraform.InstanceState) (*SSHConfig, error) { if err := dec.Decode(s.Ephemeral.ConnInfo); err != nil { return nil, err } - if sshConf.User == "" { - sshConf.User = DefaultUser + + if connInfo.User == "" { + connInfo.User = DefaultUser } - if sshConf.Port == 0 { - sshConf.Port = DefaultPort + if connInfo.Port == 0 { + connInfo.Port = DefaultPort } - if sshConf.ScriptPath == "" { - sshConf.ScriptPath = DefaultScriptPath + if connInfo.ScriptPath == "" { + connInfo.ScriptPath = DefaultScriptPath } - if sshConf.Timeout != "" { - sshConf.TimeoutVal = safeDuration(sshConf.Timeout, DefaultTimeout) + if connInfo.Timeout != "" { + connInfo.TimeoutVal = safeDuration(connInfo.Timeout, DefaultTimeout) } else { - sshConf.TimeoutVal = DefaultTimeout + connInfo.TimeoutVal = DefaultTimeout } - return sshConf, nil + + return connInfo, nil } // safeDuration returns either the parsed duration or a default value @@ -109,16 +90,16 @@ func safeDuration(dur string, defaultDur time.Duration) time.Duration { return d } -// PrepareConfig is used to turn the *SSHConfig provided into a -// usable *Config for client initialization. -func PrepareConfig(conf *SSHConfig) (*Config, error) { +// prepareSSHConfig is used to turn the *ConnectionInfo provided into a +// usable *SSHConfig for client initialization. +func prepareSSHConfig(connInfo *connectionInfo) (*sshConfig, error) { var conn net.Conn var err error sshConf := &ssh.ClientConfig{ - User: conf.User, + User: connInfo.User, } - if conf.Agent { + if connInfo.Agent { sshAuthSock := os.Getenv("SSH_AUTH_SOCK") if sshAuthSock == "" { @@ -138,14 +119,14 @@ func PrepareConfig(conf *SSHConfig) (*Config, error) { sshConf.Auth = append(sshConf.Auth, ssh.PublicKeys(signers...)) } - if conf.KeyFile != "" { - fullPath, err := homedir.Expand(conf.KeyFile) + if connInfo.KeyFile != "" { + fullPath, err := homedir.Expand(connInfo.KeyFile) if err != nil { return nil, fmt.Errorf("Failed to expand home directory: %v", err) } key, err := ioutil.ReadFile(fullPath) if err != nil { - return nil, fmt.Errorf("Failed to read key file '%s': %v", conf.KeyFile, err) + return nil, fmt.Errorf("Failed to read key file '%s': %v", connInfo.KeyFile, err) } // We parse the private key on our own first so that we can @@ -153,40 +134,32 @@ func PrepareConfig(conf *SSHConfig) (*Config, error) { block, _ := pem.Decode(key) if block == nil { return nil, fmt.Errorf( - "Failed to read key '%s': no key found", conf.KeyFile) + "Failed to read key '%s': no key found", connInfo.KeyFile) } if block.Headers["Proc-Type"] == "4,ENCRYPTED" { return nil, fmt.Errorf( "Failed to read key '%s': password protected keys are\n"+ - "not supported. Please decrypt the key prior to use.", conf.KeyFile) + "not supported. Please decrypt the key prior to use.", connInfo.KeyFile) } signer, err := ssh.ParsePrivateKey(key) if err != nil { - return nil, fmt.Errorf("Failed to parse key file '%s': %v", conf.KeyFile, err) + return nil, fmt.Errorf("Failed to parse key file '%s': %v", connInfo.KeyFile, err) } sshConf.Auth = append(sshConf.Auth, ssh.PublicKeys(signer)) } - if conf.Password != "" { + if connInfo.Password != "" { sshConf.Auth = append(sshConf.Auth, - ssh.Password(conf.Password)) + ssh.Password(connInfo.Password)) sshConf.Auth = append(sshConf.Auth, - ssh.KeyboardInteractive(PasswordKeyboardInteractive(conf.Password))) + ssh.KeyboardInteractive(PasswordKeyboardInteractive(connInfo.Password))) } - host := fmt.Sprintf("%s:%d", conf.Host, conf.Port) - config := &Config{ - SSHConfig: sshConf, - Connection: ConnectFunc("tcp", host), - SSHAgentConn: conn, + host := fmt.Sprintf("%s:%d", connInfo.Host, connInfo.Port) + config := &sshConfig{ + config: sshConf, + connection: ConnectFunc("tcp", host), + sshAgentConn: conn, } return config, nil } - -func (c *Config) CleanupConfig() error { - if c.SSHAgentConn != nil { - return c.SSHAgentConn.Close() - } - - return nil -} diff --git a/communicator/ssh/provisioner_test.go b/communicator/ssh/provisioner_test.go new file mode 100644 index 000000000000..33c2b7b7b9cd --- /dev/null +++ b/communicator/ssh/provisioner_test.go @@ -0,0 +1,50 @@ +package ssh + +import ( + "testing" + + "github.com/hashicorp/terraform/terraform" +) + +func TestProvisioner_connInfo(t *testing.T) { + r := &terraform.InstanceState{ + Ephemeral: terraform.EphemeralState{ + ConnInfo: map[string]string{ + "type": "ssh", + "user": "root", + "password": "supersecret", + "key_file": "/my/key/file.pem", + "host": "127.0.0.1", + "port": "22", + "timeout": "30s", + }, + }, + } + + conf, err := parseConnectionInfo(r) + if err != nil { + t.Fatalf("err: %v", err) + } + + if conf.User != "root" { + t.Fatalf("bad: %v", conf) + } + if conf.Password != "supersecret" { + t.Fatalf("bad: %v", conf) + } + if conf.KeyFile != "/my/key/file.pem" { + t.Fatalf("bad: %v", conf) + } + if conf.Host != "127.0.0.1" { + t.Fatalf("bad: %v", conf) + } + if conf.Port != 22 { + t.Fatalf("bad: %v", conf) + } + if conf.Timeout != "30s" { + t.Fatalf("bad: %v", conf) + } + if conf.ScriptPath != DefaultScriptPath { + t.Fatalf("bad: %v", conf) + } +} diff --git a/communicator/winrm/communicator.go b/communicator/winrm/communicator.go new file mode 100644 index 000000000000..ad1d1d30e820 --- /dev/null +++ b/communicator/winrm/communicator.go @@ -0,0 +1,193 @@ +package winrm + +import ( + "fmt" + "io" + "log" + "math/rand" + "strconv" + "strings" + "time" + + "github.com/hashicorp/terraform/communicator/remote" + "github.com/hashicorp/terraform/terraform" + "github.com/masterzen/winrm/winrm" + "github.com/packer-community/winrmcp/winrmcp" + + // This import is a bit strange, but it's needed so `make updatedeps` can see and download it + _ "github.com/dylanmei/winrmtest" +) + +// Communicator represents the WinRM communicator +type Communicator struct { + connInfo *connectionInfo + client *winrm.Client + endpoint *winrm.Endpoint +} + +// New creates a new communicator implementation over WinRM. +func New(s *terraform.InstanceState) (*Communicator, error) { + connInfo, err := parseConnectionInfo(s) + if err != nil { + return nil, err + } + + endpoint := &winrm.Endpoint{ + Host: connInfo.Host, + Port: connInfo.Port, + HTTPS: connInfo.HTTPS, + Insecure: connInfo.Insecure, + CACert: connInfo.CACert, + } + + comm := &Communicator{ + connInfo: connInfo, + endpoint: endpoint, + } + + return comm, nil +} + +// Connect implementation of communicator.Communicator interface +func (c *Communicator) Connect(o terraform.UIOutput) error { + if c.client != nil { + return nil + } + + params := winrm.DefaultParameters() + params.Timeout = formatDuration(c.Timeout()) + + client, err := winrm.NewClientWithParameters( + c.endpoint, c.connInfo.User, c.connInfo.Password, params) + if err != nil { + return err + } + + if o != nil { + o.Output(fmt.Sprintf( + "Connecting to remote host via WinRM...\n"+ + " Host: %s\n"+ + " Port: %d\n"+ + " User: %s\n"+ + " Password: %t\n"+ + " HTTPS: %t\n"+ + " Insecure: %t\n"+ + " CACert: %t", + c.connInfo.Host, + c.connInfo.Port, + c.connInfo.User, + c.connInfo.Password != "", + c.connInfo.HTTPS, + c.connInfo.Insecure, + c.connInfo.CACert != nil, + )) + } + + log.Printf("connecting to remote shell using WinRM") + shell, err := client.CreateShell() + if err != nil { + log.Printf("connection error: %s", err) + return err + } + + err = shell.Close() + if err != nil { + log.Printf("error closing connection: %s", err) + return err + } + + if o != nil { + o.Output("Connected!") + } + + c.client = client + + return nil +} + +// Disconnect implementation of communicator.Communicator interface +func (c *Communicator) Disconnect() error { + c.client = nil + return nil +} + +// Timeout implementation of communicator.Communicator interface +func (c *Communicator) Timeout() time.Duration { + return c.connInfo.TimeoutVal +} + +// ScriptPath implementation of communicator.Communicator interface +func (c *Communicator) ScriptPath() string { + return strings.Replace( + c.connInfo.ScriptPath, "%RAND%", + strconv.FormatInt(int64(rand.Int31()), 10), -1) +} + +// Start implementation of communicator.Communicator interface +func (c *Communicator) Start(rc *remote.Cmd) error { + log.Printf("starting remote command: %s", rc.Command) + + err := c.Connect(nil) + if err != nil { + return err + } + + shell, err := c.client.CreateShell() + if err != nil { + return err + } + + cmd, err := shell.Execute(rc.Command) + if err != nil { + return err + } + + go runCommand(shell, cmd, rc) + return nil +} + +func runCommand(shell *winrm.Shell, cmd *winrm.Command, rc *remote.Cmd) { + defer shell.Close() + + go io.Copy(rc.Stdout, cmd.Stdout) + go io.Copy(rc.Stderr, cmd.Stderr) + + cmd.Wait() + rc.SetExited(cmd.ExitCode()) +} + +// Upload implementation of communicator.Communicator interface +func (c *Communicator) Upload(path string, input io.Reader) error { + wcp, err := c.newCopyClient() + if err != nil { + return err + } + return wcp.Write(path, input) +} + +// UploadScript implementation of communicator.Communicator interface +func (c *Communicator) UploadScript(path string, input io.Reader) error { + return c.Upload(path, input) +} + +// UploadDir implementation of communicator.Communicator interface +func (c *Communicator) UploadDir(dst string, src string) error { + log.Printf("Upload dir '%s' to '%s'", src, dst) + wcp, err := c.newCopyClient() + if err != nil { + return err + } + return wcp.Copy(src, dst) +} + +func (c *Communicator) newCopyClient() (*winrmcp.Winrmcp, error) { + addr := fmt.Sprintf("%s:%d", c.endpoint.Host, c.endpoint.Port) + return winrmcp.New(addr, &winrmcp.Config{ + Auth: winrmcp.Auth{ + User: c.connInfo.User, + Password: c.connInfo.Password, + }, + OperationTimeout: c.Timeout(), + MaxOperationsPerShell: 15, // lowest common denominator + }) +} diff --git a/communicator/winrm/communicator_test.go b/communicator/winrm/communicator_test.go new file mode 100644 index 000000000000..cf3a94ee8976 --- /dev/null +++ b/communicator/winrm/communicator_test.go @@ -0,0 +1,145 @@ +package winrm + +import ( + "bytes" + "io" + "regexp" + "strconv" + "testing" + + "github.com/dylanmei/winrmtest" + "github.com/hashicorp/terraform/communicator/remote" + "github.com/hashicorp/terraform/terraform" +) + +func newMockWinRMServer(t *testing.T) *winrmtest.Remote { + wrm := winrmtest.NewRemote() + + wrm.CommandFunc( + winrmtest.MatchText("echo foo"), + func(out, err io.Writer) int { + out.Write([]byte("foo")) + return 0 + }) + + wrm.CommandFunc( + winrmtest.MatchPattern(`^echo c29tZXRoaW5n >> ".*"$`), + func(out, err io.Writer) int { + return 0 + }) + + wrm.CommandFunc( + winrmtest.MatchPattern(`^powershell.exe -EncodedCommand .*$`), + func(out, err io.Writer) int { + return 0 + }) + + wrm.CommandFunc( + winrmtest.MatchText("powershell"), + func(out, err io.Writer) int { + return 0 + }) + + return wrm +} + +func TestStart(t *testing.T) { + wrm := newMockWinRMServer(t) + defer wrm.Close() + + r := &terraform.InstanceState{ + Ephemeral: terraform.EphemeralState{ + ConnInfo: map[string]string{ + "type": "winrm", + "user": "user", + "password": "pass", + "host": wrm.Host, + "port": strconv.Itoa(wrm.Port), + "timeout": "30s", + }, + }, + } + + c, err := New(r) + if err != nil { + t.Fatalf("error creating communicator: %s", err) + } + + var cmd remote.Cmd + stdout := new(bytes.Buffer) + cmd.Command = "echo foo" + cmd.Stdout = stdout + + err = c.Start(&cmd) + if err != nil { + t.Fatalf("error executing remote command: %s", err) + } + cmd.Wait() + + if stdout.String() != "foo" { + t.Fatalf("bad command response: expected %q, got %q", "foo", stdout.String()) + } +} + +func TestUpload(t *testing.T) { + wrm := newMockWinRMServer(t) + defer wrm.Close() + + r := &terraform.InstanceState{ + Ephemeral: terraform.EphemeralState{ + ConnInfo: map[string]string{ + "type": "winrm", + "user": "user", + "password": "pass", + "host": wrm.Host, + "port": strconv.Itoa(wrm.Port), + "timeout": "30s", + }, + }, + } + + c, err := New(r) + if err != nil { + t.Fatalf("error creating communicator: %s", err) + } + + err = c.Connect(nil) + if err != nil { + t.Fatalf("error connecting communicator: %s", err) + } + defer c.Disconnect() + + err = c.Upload("C:/Temp/terraform.cmd", bytes.NewReader([]byte("something"))) + if err != nil { + t.Fatalf("error uploading file: %s", err) + } +} + +func TestScriptPath(t *testing.T) { + cases := []struct { + Input string + Pattern string + }{ + { + "/tmp/script.sh", + `^/tmp/script\.sh$`, + }, + { + "/tmp/script_%RAND%.sh", + `^/tmp/script_(\d+)\.sh$`, + }, + } + + for _, tc := range cases { + comm := &Communicator{connInfo: &connectionInfo{ScriptPath: tc.Input}} + output := comm.ScriptPath() + + match, err := regexp.Match(tc.Pattern, []byte(output)) + if err != nil { + t.Fatalf("bad: %s\n\nerr: %s", tc.Input, err) + } + if !match { + t.Fatalf("bad: %s\n\n%s", tc.Input, output) + } + } +} diff --git a/communicator/winrm/provisioner.go b/communicator/winrm/provisioner.go new file mode 100644 index 000000000000..59c0ba7dde75 --- /dev/null +++ b/communicator/winrm/provisioner.go @@ -0,0 +1,117 @@ +package winrm + +import ( + "fmt" + "log" + "path/filepath" + "strings" + "time" + + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/mapstructure" +) + +const ( + // DefaultUser is used if there is no user given + DefaultUser = "Administrator" + + // DefaultPort is used if there is no port given + DefaultPort = 5985 + + // DefaultScriptPath is used as the path to copy the file to + // for remote execution if not provided otherwise. + DefaultScriptPath = "C:/Temp/terraform_%RAND%.cmd" + + // DefaultTimeout is used if there is no timeout given + DefaultTimeout = 5 * time.Minute +) + +// connectionInfo is decoded from the ConnInfo of the resource. These are the +// only keys we look at. If a KeyFile is given, that is used instead +// of a password. +type connectionInfo struct { + User string + Password string + Host string + Port int + HTTPS bool + Insecure bool + CACert *[]byte `mapstructure:"ca_cert"` + Timeout string + ScriptPath string `mapstructure:"script_path"` + TimeoutVal time.Duration `mapstructure:"-"` +} + +// parseConnectionInfo is used to convert the ConnInfo of the InstanceState into +// a ConnectionInfo struct +func parseConnectionInfo(s *terraform.InstanceState) (*connectionInfo, error) { + connInfo := &connectionInfo{} + decConf := &mapstructure.DecoderConfig{ + WeaklyTypedInput: true, + Result: connInfo, + } + dec, err := mapstructure.NewDecoder(decConf) + if err != nil { + return nil, err + } + if err := dec.Decode(s.Ephemeral.ConnInfo); err != nil { + return nil, err + } + + // Check on script paths which point to the default Windows TEMP folder because files + // which are put in there very early in the boot process could get cleaned/deleted + // before you had the change to execute them. + // + // TODO (SvH) Needs some more debugging to fully understand the exact sequence of events + // causing this... + if strings.HasPrefix(filepath.ToSlash(connInfo.ScriptPath), "C:/Windows/Temp") { + return nil, fmt.Errorf( + `Using the C:\Windows\Temp folder is not supported. Please use a different 'script_path'.`) + } + + if connInfo.User == "" { + connInfo.User = DefaultUser + } + if connInfo.Port == 0 { + connInfo.Port = DefaultPort + } + if connInfo.ScriptPath == "" { + connInfo.ScriptPath = DefaultScriptPath + } + if connInfo.Timeout != "" { + connInfo.TimeoutVal = safeDuration(connInfo.Timeout, DefaultTimeout) + } else { + connInfo.TimeoutVal = DefaultTimeout + } + + return connInfo, nil +} + +// safeDuration returns either the parsed duration or a default value +func safeDuration(dur string, defaultDur time.Duration) time.Duration { + d, err := time.ParseDuration(dur) + if err != nil { + log.Printf("Invalid duration '%s', using default of %s", dur, defaultDur) + return defaultDur + } + return d +} + +func formatDuration(duration time.Duration) string { + h := int(duration.Hours()) + m := int(duration.Minutes()) - (h * 60) + s := int(duration.Seconds()) - (h*3600 + m*60) + + res := "PT" + if h > 0 { + res = fmt.Sprintf("%s%dH", res, h) + } + if m > 0 { + res = fmt.Sprintf("%s%dM", res, m) + } + if s > 0 { + res = fmt.Sprintf("%s%dS", res, s) + } + + return res +} diff --git a/communicator/winrm/provisioner_test.go b/communicator/winrm/provisioner_test.go new file mode 100644 index 000000000000..9a271ae59ef9 --- /dev/null +++ b/communicator/winrm/provisioner_test.go @@ -0,0 +1,103 @@ +package winrm + +import ( + "testing" + + "github.com/hashicorp/terraform/terraform" +) + +func TestProvisioner_connInfo(t *testing.T) { + r := &terraform.InstanceState{ + Ephemeral: terraform.EphemeralState{ + ConnInfo: map[string]string{ + "type": "winrm", + "user": "Administrator", + "password": "supersecret", + "host": "127.0.0.1", + "port": "5985", + "https": "true", + "timeout": "30s", + }, + }, + } + + conf, err := parseConnectionInfo(r) + if err != nil { + t.Fatalf("err: %v", err) + } + + if conf.User != "Administrator" { + t.Fatalf("expected: %v: got: %v", "Administrator", conf) + } + if conf.Password != "supersecret" { + t.Fatalf("expected: %v: got: %v", "supersecret", conf) + } + if conf.Host != "127.0.0.1" { + t.Fatalf("expected: %v: got: %v", "127.0.0.1", conf) + } + if conf.Port != 5985 { + t.Fatalf("expected: %v: got: %v", 5985, conf) + } + if conf.HTTPS != true { + t.Fatalf("expected: %v: got: %v", true, conf) + } + if conf.Timeout != "30s" { + t.Fatalf("expected: %v: got: %v", "30s", conf) + } + if conf.ScriptPath != DefaultScriptPath { + t.Fatalf("expected: %v: got: %v", DefaultScriptPath, conf) + } +} + +func TestProvisioner_formatDuration(t *testing.T) { + cases := map[string]struct { + InstanceState *terraform.InstanceState + Result string + }{ + "testSeconds": { + InstanceState: &terraform.InstanceState{ + Ephemeral: terraform.EphemeralState{ + ConnInfo: map[string]string{ + "timeout": "90s", + }, + }, + }, + + Result: "PT1M30S", + }, + "testMinutes": { + InstanceState: &terraform.InstanceState{ + Ephemeral: terraform.EphemeralState{ + ConnInfo: map[string]string{ + "timeout": "5m", + }, + }, + }, + + Result: "PT5M", + }, + "testHours": { + InstanceState: &terraform.InstanceState{ + Ephemeral: terraform.EphemeralState{ + ConnInfo: map[string]string{ + "timeout": "1h", + }, + }, + }, + + Result: "PT1H", + }, + } + + for name, tc := range cases { + conf, err := parseConnectionInfo(tc.InstanceState) + if err != nil { + t.Fatalf("err: %v", err) + } + + result := formatDuration(conf.TimeoutVal) + if result != tc.Result { + t.Fatalf("%s: expected: %s got: %s", name, tc.Result, result) + } + } +} diff --git a/helper/ssh/provisioner_test.go b/helper/ssh/provisioner_test.go deleted file mode 100644 index 4559a4f20e00..000000000000 --- a/helper/ssh/provisioner_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package ssh - -import ( - "regexp" - "testing" - - "github.com/hashicorp/terraform/terraform" -) - -func TestSSHConfig_RemotePath(t *testing.T) { - cases := []struct { - Input string - Pattern string - }{ - { - "/tmp/script.sh", - `^/tmp/script\.sh$`, - }, - { - "/tmp/script_%RAND%.sh", - `^/tmp/script_(\d+)\.sh$`, - }, - } - - for _, tc := range cases { - config := &SSHConfig{ScriptPath: tc.Input} - output := config.RemotePath() - - match, err := regexp.Match(tc.Pattern, []byte(output)) - if err != nil { - t.Fatalf("bad: %s\n\nerr: %s", tc.Input, err) - } - if !match { - t.Fatalf("bad: %s\n\n%s", tc.Input, output) - } - } -} - -func TestResourceProvider_verifySSH(t *testing.T) { - r := &terraform.InstanceState{ - Ephemeral: terraform.EphemeralState{ - ConnInfo: map[string]string{ - "type": "telnet", - }, - }, - } - if err := VerifySSH(r); err == nil { - t.Fatalf("expected error with telnet") - } - r.Ephemeral.ConnInfo["type"] = "ssh" - if err := VerifySSH(r); err != nil { - t.Fatalf("err: %v", err) - } -} - -func TestResourceProvider_sshConfig(t *testing.T) { - r := &terraform.InstanceState{ - Ephemeral: terraform.EphemeralState{ - ConnInfo: map[string]string{ - "type": "ssh", - "user": "root", - "password": "supersecret", - "key_file": "/my/key/file.pem", - "host": "127.0.0.1", - "port": "22", - "timeout": "30s", - }, - }, - } - - conf, err := ParseSSHConfig(r) - if err != nil { - t.Fatalf("err: %v", err) - } - - if conf.User != "root" { - t.Fatalf("bad: %v", conf) - } - if conf.Password != "supersecret" { - t.Fatalf("bad: %v", conf) - } - if conf.KeyFile != "/my/key/file.pem" { - t.Fatalf("bad: %v", conf) - } - if conf.Host != "127.0.0.1" { - t.Fatalf("bad: %v", conf) - } - if conf.Port != 22 { - t.Fatalf("bad: %v", conf) - } - if conf.Timeout != "30s" { - t.Fatalf("bad: %v", conf) - } - if conf.ScriptPath != DefaultScriptPath { - t.Fatalf("bad: %v", conf) - } -} diff --git a/plugin/client_test.go b/plugin/client_test.go index d558b4912ebd..68b995c13972 100644 --- a/plugin/client_test.go +++ b/plugin/client_test.go @@ -99,7 +99,7 @@ func TestClient_Stderr(t *testing.T) { func TestClient_Stdin(t *testing.T) { // Overwrite stdin for this test with a temporary file - tf, err := ioutil.TempFile("", "packer") + tf, err := ioutil.TempFile("", "terraform") if err != nil { t.Fatalf("err: %s", err) } diff --git a/website/source/docs/provisioners/connection.html.markdown b/website/source/docs/provisioners/connection.html.markdown index 6d289c6dadb7..8012b2c86967 100644 --- a/website/source/docs/provisioners/connection.html.markdown +++ b/website/source/docs/provisioners/connection.html.markdown @@ -3,13 +3,13 @@ layout: "docs" page_title: "Provisioner Connections" sidebar_current: "docs-provisioners-connection" description: |- - Many provisioners require access to the remote resource. For example, a provisioner may need to use ssh to connect to the resource. + Many provisioners require access to the remote resource. For example, a provisioner may need to use SSH or WinRM to connect to the resource. --- # Provisioner Connections Many provisioners require access to the remote resource. For example, -a provisioner may need to use ssh to connect to the resource. +a provisioner may need to use SSH or WinRM to connect to the resource. Terraform uses a number of defaults when connecting to a resource, but these can be overridden using `connection` block in either a `resource` or `provisioner`. @@ -21,7 +21,7 @@ subsequent provisioners connect as a user with more limited permissions. ## Example usage ``` -# Copies the file as the root user using a password +# Copies the file as the root user using SSH provisioner "file" { source = "conf/myapp.conf" destination = "/etc/myapp.conf" @@ -30,28 +30,53 @@ provisioner "file" { password = "${var.root_password}" } } + +# Copies the file as the Administrator user using WinRM +provisioner "file" { + source = "conf/myapp.conf" + destination = "C:/App/myapp.conf" + connection { + type = "winrm" + user = "Administrator" + password = "${var.admin_password}" + } +} ``` ## Argument Reference -The following arguments are supported: +**The following arguments are supported by all connection types:** -* `type` - The connection type that should be used. This defaults to "ssh". The type - of connection supported depends on the provisioner. +* `type` - The connection type that should be used. Valid types are "ssh" and "winrm" + This defaults to "ssh". -* `user` - The user that we should use for the connection. This defaults to "root". +* `user` - The user that we should use for the connection. Defaults to "root" when + using type "ssh" and defaults to "Administrator" when using type "winrm". -* `password` - The password we should use for the connection. +* `password` - The password we should use for the connection. In some cases this is + provided by the provider. + +* `host` - The address of the resource to connect to. This is provided by the provider. + +* `port` - The port to connect to. Defaults to 22 when using type "ssh" and defaults + to 5985 when using type "winrm". + +* `timeout` - The timeout to wait for the connection to become available. This defaults + to 5 minutes. Should be provided as a string like "30s" or "5m". + +* `script_path` - The path used to copy scripts to meant for remote execution. + +**Additional arguments only supported by the "ssh" connection type:** * `key_file` - The SSH key to use for the connection. This takes preference over the - password if provided. + password if provided. * `agent` - Set to true to enable using ssh-agent to authenticate. -* `host` - The address of the resource to connect to. This is provided by the provider. +**Additional arguments only supported by the "winrm" connection type:** -* `port` - The port to connect to. This defaults to 22. +* `https` - Set to true to connect using HTTPS instead of HTTP. -* `timeout` - The timeout to wait for the connection to become available. This defaults - to 5 minutes. Should be provided as a string like "30s" or "5m". +* `insecure` - Set to true to not validate the HTTPS certificate chain. +* `cacert` - The CA certificate to validate against. diff --git a/website/source/docs/provisioners/file.html.markdown b/website/source/docs/provisioners/file.html.markdown index 692ce8e5739c..70a266c3b8dc 100644 --- a/website/source/docs/provisioners/file.html.markdown +++ b/website/source/docs/provisioners/file.html.markdown @@ -9,8 +9,8 @@ description: |- # File Provisioner The `file` provisioner is used to copy files or directories from the machine -executing Terraform to the newly created resource. The `file` provisioner only -supports `ssh` type [connections](/docs/provisioners/connection.html). +executing Terraform to the newly created resource. The `file` provisioner +supports both `ssh` and `winrm` type [connections](/docs/provisioners/connection.html). ## Example usage @@ -29,6 +29,12 @@ resource "aws_instance" "web" { source = "conf/configs.d" destination = "/etc" } + + # Copies all files and folders in apps/app1 to D:/IIS/webapp1 + provisioner "file" { + source = "apps/app1/" + destination = "D:/IIS/webapp1" + } } ``` @@ -47,8 +53,10 @@ The following arguments are supported: The file provisioner is also able to upload a complete directory to the remote machine. When uploading a directory, there are a few important things you should know. -First, the destination directory must already exist. If you need to create it, -use a remote-exec provisioner just prior to the file provisioner in order to create the directory. +First, when using the `ssh` connection type the destination directory must already exist. +If you need to create it, use a remote-exec provisioner just prior to the file provisioner +in order to create the directory. When using the `winrm` connection type the destination +directory will be created for you if it doesn't already exist. Next, the existence of a trailing slash on the source path will determine whether the directory name will be embedded within the destination, or whether the destination will @@ -63,4 +71,3 @@ If the source, however, is `/foo/` (a trailing slash is present), and the destin This behavior was adopted from the standard behavior of rsync. Note that under the covers, rsync may or may not be used. - diff --git a/website/source/docs/provisioners/remote-exec.html.markdown b/website/source/docs/provisioners/remote-exec.html.markdown index 181907b69125..79b7de9c0eb2 100644 --- a/website/source/docs/provisioners/remote-exec.html.markdown +++ b/website/source/docs/provisioners/remote-exec.html.markdown @@ -12,7 +12,7 @@ The `remote-exec` provisioner invokes a script on a remote resource after it is created. This can be used to run a configuration management tool, bootstrap into a cluster, etc. To invoke a local process, see the `local-exec` [provisioner](/docs/provisioners/local-exec.html) instead. The `remote-exec` -provisioner only supports `ssh` type [connections](/docs/provisioners/connection.html). +provisioner supports both `ssh` and `winrm` type [connections](/docs/provisioners/connection.html). ## Example usage