diff --git a/plugin/provisioner-ansible/main.go b/plugin/provisioner-ansible/main.go new file mode 100644 index 000000000..789339591 --- /dev/null +++ b/plugin/provisioner-ansible/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "github.com/bhcleek/packer-provisioner-ansible/provisioner/ansible" + "github.com/mitchellh/packer/packer/plugin" +) + +func main() { + server, err := plugin.Server() + if err != nil { + panic(err) + } + server.RegisterProvisioner(new(ansible.Provisioner)) + server.Serve() +} diff --git a/plugin/provisioner-ansible/main_test.go b/plugin/provisioner-ansible/main_test.go new file mode 100644 index 000000000..06ab7d0f9 --- /dev/null +++ b/plugin/provisioner-ansible/main_test.go @@ -0,0 +1 @@ +package main diff --git a/provisioner/ansible/adapter.go b/provisioner/ansible/adapter.go new file mode 100644 index 000000000..62cf54c60 --- /dev/null +++ b/provisioner/ansible/adapter.go @@ -0,0 +1,283 @@ +package ansible + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + + "github.com/mitchellh/packer/packer" + "golang.org/x/crypto/ssh" +) + +type adapter struct { + done <-chan struct{} + l net.Listener + config *ssh.ServerConfig + sftpCmd string + ui packer.Ui + comm packer.Communicator +} + +func newAdapter(done <-chan struct{}, l net.Listener, config *ssh.ServerConfig, sftpCmd string, ui packer.Ui, comm packer.Communicator) *adapter { + return &adapter{ + done: done, + l: l, + config: config, + sftpCmd: sftpCmd, + ui: ui, + comm: comm, + } +} + +func (c *adapter) Serve() { + c.ui.Say(fmt.Sprintf("SSH proxy: serving on %s", c.l.Addr())) + + errc := make(chan error, 1) + + go func(errc chan error) { + for err := range errc { + if err != nil { + c.ui.Error(err.Error()) + } + } + }(errc) + + for { + // Accept will return if either the underlying connection is closed or if a connection is made. + // after returning, check to see if c.done can be received. If so, then Accept() returned because + // the connection has been closed. + conn, err := c.l.Accept() + select { + case <-c.done: + return + default: + if err != nil { + c.ui.Error(fmt.Sprintf("listen.Accept failed: %v", err)) + } + go func(conn net.Conn) { + errc <- c.Handle(conn, errc) + }(conn) + } + } + + close(errc) +} + +func (c *adapter) Handle(conn net.Conn, errc chan<- error) error { + c.ui.Say("SSH proxy: accepted connection") + _, chans, reqs, err := ssh.NewServerConn(conn, c.config) + if err != nil { + return errors.New("failed to handshake") + } + + // discard all global requests + go ssh.DiscardRequests(reqs) + + // Service the incoming NewChannels + for newChannel := range chans { + if newChannel.ChannelType() != "session" { + newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") + continue + } + + go func(errc chan<- error) { + errc <- c.handleSession(newChannel) + }(errc) + } + + return nil +} + +func (c *adapter) handleSession(newChannel ssh.NewChannel) error { + channel, requests, err := newChannel.Accept() + if err != nil { + return err + } + defer channel.Close() + + done := make(chan struct{}) + + // Sessions have requests such as "pty-req", "shell", "env", and "exec". + // see RFC 4254, section 6 + go func(in <-chan *ssh.Request) { + env := make([]envRequestPayload, 4) + for req := range in { + switch req.Type { + case "pty-req": + // accept pty-req requests, but don't actually do anything. Necessary for OpenSSH and sudo. + req.Reply(true, nil) + + case "env": + req.Reply(true, nil) + + req, err := newEnvRequest(req) + if err != nil { + c.ui.Error(err.Error()) + continue + } + env = append(env, req.Payload) + case "exec": + req.Reply(true, nil) + + req, err := newExecRequest(req) + if err != nil { + c.ui.Error(err.Error()) + close(done) + continue + } + + if len(req.Payload) > 0 { + cmd := &packer.RemoteCmd{ + Stdin: channel, + Stdout: channel, + Stderr: channel.Stderr(), + Command: string(req.Payload), + } + + if err := c.comm.Start(cmd); err != nil { + c.ui.Error(err.Error()) + close(done) + return + } + go func(cmd *packer.RemoteCmd, channel ssh.Channel) { + cmd.Wait() + + exitStatus := make([]byte, 4) + binary.BigEndian.PutUint32(exitStatus, uint32(cmd.ExitStatus)) + channel.SendRequest("exit-status", false, exitStatus) + close(done) + }(cmd, channel) + } + + case "subsystem": + req, err := newSubsystemRequest(req) + if err != nil { + c.ui.Error(err.Error()) + continue + } + + switch req.Payload { + case "sftp": + c.ui.Say("starting sftp subsystem") + req.Reply(true, nil) + sftpCmd := c.sftpCmd + if len(sftpCmd) == 0 { + sftpCmd = "/usr/lib/sftp-server -e" + } + cmd := &packer.RemoteCmd{ + Stdin: channel, + Stdout: channel, + Stderr: channel.Stderr(), + Command: sftpCmd, + } + + if err := c.comm.Start(cmd); err != nil { + c.ui.Error(err.Error()) + } + + go func() { + cmd.Wait() + close(done) + }() + + default: + req.Reply(false, nil) + + } + default: + c.ui.Message(fmt.Sprintf("rejecting %s request", req.Type)) + req.Reply(false, nil) + } + } + }(requests) + + <-done + return nil +} + +func (c *adapter) Shutdown() { + c.l.Close() +} + +type envRequest struct { + *ssh.Request + Payload envRequestPayload +} + +type envRequestPayload struct { + Name string + Value string +} + +func newEnvRequest(raw *ssh.Request) (*envRequest, error) { + r := new(envRequest) + r.Request = raw + + if err := ssh.Unmarshal(raw.Payload, &r.Payload); err != nil { + return nil, err + } + + return r, nil +} + +func sshString(buf io.Reader) (string, error) { + var size uint32 + err := binary.Read(buf, binary.BigEndian, &size) + if err != nil { + return "", err + } + + b := make([]byte, size) + err = binary.Read(buf, binary.BigEndian, b) + if err != nil { + return "", err + } + return string(b), nil +} + +type execRequest struct { + *ssh.Request + Payload execRequestPayload +} + +type execRequestPayload string + +func newExecRequest(raw *ssh.Request) (*execRequest, error) { + r := new(execRequest) + r.Request = raw + buf := bytes.NewReader(r.Request.Payload) + + var err error + var payload string + if payload, err = sshString(buf); err != nil { + return nil, err + } + + r.Payload = execRequestPayload(payload) + return r, nil +} + +type subsystemRequest struct { + *ssh.Request + Payload subsystemRequestPayload +} + +type subsystemRequestPayload string + +func newSubsystemRequest(raw *ssh.Request) (*subsystemRequest, error) { + r := new(subsystemRequest) + r.Request = raw + buf := bytes.NewReader(r.Request.Payload) + + var err error + var payload string + if payload, err = sshString(buf); err != nil { + return nil, err + } + + r.Payload = subsystemRequestPayload(payload) + return r, nil +} diff --git a/provisioner/ansible/provisioner.go b/provisioner/ansible/provisioner.go new file mode 100644 index 000000000..063c9334e --- /dev/null +++ b/provisioner/ansible/provisioner.go @@ -0,0 +1,277 @@ +package ansible + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + + "golang.org/x/crypto/ssh" + + "github.com/mitchellh/packer/common" + "github.com/mitchellh/packer/helper/config" + "github.com/mitchellh/packer/packer" + "github.com/mitchellh/packer/template/interpolate" +) + +type Config struct { + common.PackerConfig `mapstructure:",squash"` + ctx interpolate.Context + + // The command to run ansible + Command string + + // Extra options to pass to the ansible command + ExtraArguments []string `mapstructure:"extra_arguments"` + + // The main playbook file to execute. + PlaybookFile string `mapstructure:"playbook_file"` + LocalPort string `mapstructure:"local_port"` + SSHHostKeyFile string `mapstructure:"ssh_host_key_file"` + SSHAuthorizedKeyFile string `mapstructure:"ssh_authorized_key_file"` + SFTPCmd string `mapstructure:"sftp_command"` + inventoryFile string +} + +type Provisioner struct { + config Config + adapter *adapter + done chan struct{} +} + +func (p *Provisioner) Prepare(raws ...interface{}) error { + p.done = make(chan struct{}) + + err := config.Decode(&p.config, &config.DecodeOpts{ + Interpolate: true, + InterpolateContext: &p.config.ctx, + InterpolateFilter: &interpolate.RenderFilter{ + Exclude: []string{}, + }, + }, raws...) + if err != nil { + return err + } + + // Defaults + if p.config.Command == "" { + p.config.Command = "ansible-playbook" + } + + var errs *packer.MultiError + err = validateFileConfig(p.config.PlaybookFile, "playbook_file", true) + if err != nil { + errs = packer.MultiErrorAppend(errs, err) + } + + err = validateFileConfig(p.config.SSHAuthorizedKeyFile, "ssh_authorized_key_file", true) + if err != nil { + errs = packer.MultiErrorAppend(errs, err) + } + + // Check that the host key file exists, if configured + if len(p.config.SSHHostKeyFile) > 0 { + err = validateFileConfig(p.config.SSHHostKeyFile, "ssh_host_key_file", true) + if err != nil { + log.Println(p.config.SSHHostKeyFile, "does not exist") + errs = packer.MultiErrorAppend(errs, err) + } + } + + if len(p.config.LocalPort) > 0 { + if _, err := strconv.ParseUint(p.config.LocalPort, 10, 16); err != nil { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("local_port: %s must be a valid port", p.config.LocalPort)) + } + } + + if errs != nil && len(errs.Errors) > 0 { + return errs + } + return nil +} + +func (p *Provisioner) Provision(ui packer.Ui, comm packer.Communicator) error { + ui.Say("Provisioning with Ansible...") + + pubKeyBytes, err := ioutil.ReadFile(p.config.SSHAuthorizedKeyFile) + if err != nil { + return errors.New("Failed to load authorized key file") + } + + public, _, _, _, err := ssh.ParseAuthorizedKey(pubKeyBytes) + if err != nil { + return errors.New("Failed to parse authorized key") + } + + keyChecker := ssh.CertChecker{ + UserKeyFallback: func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { + if conn.User() != "packer-ansible" { + ui.Say(fmt.Sprintf("%s is not a valid user")) + return nil, errors.New("authentication failed") + } + + if !bytes.Equal(public.Marshal(), pubKey.Marshal()) { + ui.Say("unauthorized key") + return nil, errors.New("authentication failed") + } + + return nil, nil + }, + } + config := &ssh.ServerConfig{ + AuthLogCallback: func(conn ssh.ConnMetadata, method string, err error) { + ui.Say(fmt.Sprintf("authentication attempt from %s to %s as %s using %s", conn.RemoteAddr(), conn.LocalAddr(), conn.User(), method)) + }, + PublicKeyCallback: keyChecker.Authenticate, + //NoClientAuth: true, + } + + privateBytes, err := ioutil.ReadFile(p.config.SSHHostKeyFile) + if err != nil { + return errors.New("Failed to load private host key") + } + + private, err := ssh.ParsePrivateKey(privateBytes) + if err != nil { + return errors.New("Failed to parse private host key") + } + + config.AddHostKey(private) + + localListener, err := func() (net.Listener, error) { + port, _ := strconv.ParseUint(p.config.LocalPort, 10, 16) + if port == 0 { + port = 2200 + } + for i := 0; i < 10; i++ { + port++ + l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err == nil { + p.config.LocalPort = strconv.FormatUint(port, 10) + return l, nil + } + + ui.Say(err.Error()) + } + return nil, errors.New("Error setting up SSH proxy connection") + }() + + if err != nil { + return err + } + + p.adapter = newAdapter(p.done, localListener, config, p.config.SFTPCmd, ui, comm) + + defer func() { + ui.Say("shutting down the SSH proxy") + close(p.done) + p.adapter.Shutdown() + }() + + go p.adapter.Serve() + + if len(p.config.inventoryFile) == 0 { + tf, err := ioutil.TempFile("", "packer-provisioner-ansible") + if err != nil { + return fmt.Errorf("Error preparing inventory file: %s", err) + } + defer os.Remove(tf.Name()) + inv := fmt.Sprintf("default ansible_ssh_host=127.0.0.1 ansible_ssh_user=packer-ansible ansible_ssh_port=%s", p.config.LocalPort) + _, err = tf.Write([]byte(inv)) + if err != nil { + tf.Close() + return fmt.Errorf("Error preparing inventory file: %s", err) + } + tf.Close() + p.config.inventoryFile = tf.Name() + defer func() { + p.config.inventoryFile = "" + }() + } + + if err := p.executeAnsible(ui, comm); err != nil { + return fmt.Errorf("Error executing Ansible: %s", err) + } + + return nil + +} + +func (p *Provisioner) Cancel() { + if p.done != nil { + close(p.done) + } + if p.adapter != nil { + p.adapter.Shutdown() + } + os.Exit(0) +} + +func (p *Provisioner) executeAnsible(ui packer.Ui, comm packer.Communicator) error { + playbook, _ := filepath.Abs(p.config.PlaybookFile) + inventory := p.config.inventoryFile + + args := []string{playbook, "-i", inventory} + args = append(args, p.config.ExtraArguments...) + + cmd := exec.Command(p.config.Command, args...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + + wg := sync.WaitGroup{} + repeat := func(r io.ReadCloser) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + ui.Message(scanner.Text()) + } + if err := scanner.Err(); err != nil { + ui.Error(err.Error()) + } + wg.Done() + } + wg.Add(2) + go repeat(stdout) + go repeat(stderr) + + ui.Say(fmt.Sprintf("Executing Ansible: %s", strings.Join(cmd.Args, " "))) + cmd.Start() + wg.Wait() + err = cmd.Wait() + if err != nil { + return fmt.Errorf("Non-zero exit status: %s", err) + } + + return nil +} + +func validateFileConfig(name string, config string, req bool) error { + if req { + if name == "" { + return fmt.Errorf("%s must be specified.", config) + } + } + info, err := os.Stat(name) + if err != nil { + return fmt.Errorf("%s: %s is invalid: %s", config, name, err) + } else if info.IsDir() { + return fmt.Errorf("%s: %s must point to a file", config, name) + } + return nil +} diff --git a/provisioner/ansible/provisioner_test.go b/provisioner/ansible/provisioner_test.go new file mode 100644 index 000000000..0b52e47c2 --- /dev/null +++ b/provisioner/ansible/provisioner_test.go @@ -0,0 +1,218 @@ +package ansible + +import ( + "crypto/rand" + "fmt" + "io" + "io/ioutil" + "os" + "testing" + + "github.com/mitchellh/packer/packer" +) + +func testConfig() map[string]interface{} { + m := make(map[string]interface{}) + return m +} + +func TestProvisioner_Impl(t *testing.T) { + var raw interface{} + raw = &Provisioner{} + if _, ok := raw.(packer.Provisioner); !ok { + t.Fatalf("must be a Provisioner") + } +} + +func TestProvisionerPrepare_Defaults(t *testing.T) { + var p Provisioner + config := testConfig() + + err := p.Prepare(config) + if err == nil { + t.Fatalf("should have error") + } + + hostkey_file, err := ioutil.TempFile("", "hostkey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(hostkey_file.Name()) + + publickey_file, err := ioutil.TempFile("", "publickey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(publickey_file.Name()) + + playbook_file, err := ioutil.TempFile("", "playbook") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(playbook_file.Name()) + + config["ssh_private_host_key_file"] = hostkey_file.Name() + config["ssh_authorized_key_file"] = publickey_file.Name() + config["playbook_file"] = playbook_file.Name() + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvisionerPrepare_PlaybookFile(t *testing.T) { + var p Provisioner + config := testConfig() + + hostkey_file, err := ioutil.TempFile("", "hostkey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(hostkey_file.Name()) + + publickey_file, err := ioutil.TempFile("", "publickey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(publickey_file.Name()) + + config["ssh_private_host_key_file"] = hostkey_file.Name() + config["ssh_authorized_key_file"] = publickey_file.Name() + + err = p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + playbook_file, err := ioutil.TempFile("", "playbook") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(playbook_file.Name()) + + config["playbook_file"] = playbook_file.Name() + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvisionerPrepare_HostKeyFile(t *testing.T) { + var p Provisioner + config := testConfig() + + publickey_file, err := ioutil.TempFile("", "publickey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(publickey_file.Name()) + + playbook_file, err := ioutil.TempFile("", "playbook") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(playbook_file.Name()) + + filename := make([]byte, 10) + n, err := io.ReadFull(rand.Reader, filename) + if n != len(filename) || err != nil { + t.Fatal("could not create random file name") + } + + config["ssh_private_host_key_file"] = fmt.Sprintf("%x", filename) + config["ssh_authorized_key_file"] = publickey_file.Name() + config["playbook_file"] = playbook_file.Name() + + err = p.Prepare(config) + if err == nil { + t.Fatal("should error if ssh_private_host_key_file does not exist") + } + + hostkey_file, err := ioutil.TempFile("", "hostkey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(hostkey_file.Name()) + + config["ssh_private_host_key_file"] = hostkey_file.Name() + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvisionerPrepare_AuthorizedKeyFile(t *testing.T) { + var p Provisioner + config := testConfig() + + hostkey_file, err := ioutil.TempFile("", "hostkey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(hostkey_file.Name()) + + playbook_file, err := ioutil.TempFile("", "playbook") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(playbook_file.Name()) + + config["ssh_private_host_key_file"] = hostkey_file.Name() + config["playbook_file"] = playbook_file.Name() + + err = p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + publickey_file, err := ioutil.TempFile("", "publickey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(publickey_file.Name()) + + config["ssh_authorized_key_file"] = publickey_file.Name() + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvisionerPrepare_LocalPort(t *testing.T) { + var p Provisioner + config := testConfig() + + hostkey_file, err := ioutil.TempFile("", "hostkey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(hostkey_file.Name()) + + publickey_file, err := ioutil.TempFile("", "publickey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(publickey_file.Name()) + + playbook_file, err := ioutil.TempFile("", "playbook") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(playbook_file.Name()) + + config["ssh_private_host_key_file"] = hostkey_file.Name() + config["ssh_authorized_key_file"] = publickey_file.Name() + config["playbook_file"] = playbook_file.Name() + + config["local_port"] = "65537" + err = p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + config["local_port"] = "22222" + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} diff --git a/provisioner/shell/provisioner.go b/provisioner/shell/provisioner.go index 3c32b3fc2..37bbf421b 100644 --- a/provisioner/shell/provisioner.go +++ b/provisioner/shell/provisioner.go @@ -75,11 +75,6 @@ func (p *Provisioner) Prepare(raws ...interface{}) error { err := config.Decode(&p.config, &config.DecodeOpts{ Interpolate: true, InterpolateContext: &p.config.ctx, - InterpolateFilter: &interpolate.RenderFilter{ - Exclude: []string{ - "execute_command", - }, - }, }, raws...) if err != nil { return err diff --git a/website/source/docs/provisioners/ansible.html.markdown b/website/source/docs/provisioners/ansible.html.markdown new file mode 100644 index 000000000..e91f55f7a --- /dev/null +++ b/website/source/docs/provisioners/ansible.html.markdown @@ -0,0 +1,66 @@ +--- +layout: "docs" +page_title: "Ansible Provisioner" +description: |- + The `ansible` Packer provisioner allows Ansible playbooks to be run to provision the machine. +--- + +# Ansible Provisioner + +Type: `ansible` + +The `ansible` Packer provisioner allows Ansible playbooks to be run to provision the machine. + +## Basic Example + +This is a fully functional template that will provision an image on +DigitalOcean. Replace the mock `api_token` value with your own. + +```json +{ + "provisioners": [ + { + "type": "ansible", + "playbook_file": "./playbook.yml", + "extra_arguments": ["--private-key", "./id_packer-ansible", "-v", "-c", "paramiko"], + "ssh_authorized_key_file": "./id_packer-ansible.pub", + "ssh_host_key_file": "./packer_host_private_key" + } + ], + + "builders": [ + { + "type": "digitalocean", + "api_token": "6a561151587389c7cf8faa2d83e94150a4202da0e2bad34dd2bf236018ffaeeb", + "image": "ubuntu-14-04-x64", + "region": "sfo1" + }, + ] +} +``` + +## Configuration Reference + +Required Parameters: + +- `playbook_file` - The playbook file to be run by Ansible. + +- `ssh_host_key_file` - The SSH key that will be used to run the SSH server to which Ansible connects. + +- `ssh_authorized_key_file` - The SSH public key of the Ansible `ssh_user`. + +Optional Parameters: + +- `local_port` (string) - The port on which to + attempt to listen for SSH connections. This value is a starting point. + The provisioner will attempt listen for SSH connections on the first + available of ten ports, starting at `local_port`. The default value is 2200. + +- `sftp_command` (string) - The command to run on the machine to handle the + SFTP protocol that Ansible will use to transfer files. The command should + read and write on stdin and stdout, respectively. Defaults to + `/usr/lib/sftp-server -e`. + +## Limitations + +The `ansible` provisioner does not support SCP to transfer files.