Merge pull request #3861 from bhcleek/ansible-scp

add scp support to ansible provisioner
This commit is contained in:
Rickard von Essen 2016-09-12 14:10:37 +02:00 committed by GitHub
commit 3a709fcdc0
10 changed files with 490 additions and 52 deletions

View File

@ -8,11 +8,14 @@ import (
"io"
"log"
"net"
"strings"
"github.com/mitchellh/packer/packer"
"golang.org/x/crypto/ssh"
)
// An adapter satisfies SSH requests (from an Ansible client) by delegating SSH
// exec and subsystem commands to a packer.Communicator.
type adapter struct {
done <-chan struct{}
l net.Listener
@ -132,29 +135,15 @@ func (c *adapter) handleSession(newChannel ssh.NewChannel) error {
return
}
cmd := &packer.RemoteCmd{
Stdin: channel,
Stdout: channel,
Stderr: channel.Stderr(),
Command: string(req.Payload),
}
go func(channel ssh.Channel) {
exit := c.exec(string(req.Payload), channel, channel, channel.Stderr())
if err := c.comm.Start(cmd); err != nil {
c.ui.Error(err.Error())
req.Reply(false, nil)
close(done)
return
}
go func(cmd *packer.RemoteCmd, channel ssh.Channel) {
cmd.Wait()
exitStatus := make([]byte, 4)
binary.BigEndian.PutUint32(exitStatus, uint32(cmd.ExitStatus))
binary.BigEndian.PutUint32(exitStatus, uint32(exit))
channel.SendRequest("exit-status", false, exitStatus)
close(done)
}(cmd, channel)
}(channel)
req.Reply(true, nil)
case "subsystem":
req, err := newSubsystemRequest(req)
if err != nil {
@ -170,31 +159,16 @@ func (c *adapter) handleSession(newChannel ssh.NewChannel) error {
if len(sftpCmd) == 0 {
sftpCmd = "/usr/lib/sftp-server -e"
}
cmd := &packer.RemoteCmd{
Stdin: channel,
Stdout: channel,
Stderr: channel.Stderr(),
Command: sftpCmd,
}
c.ui.Say("starting sftp subsystem")
if err := c.comm.Start(cmd); err != nil {
c.ui.Error(err.Error())
req.Reply(false, nil)
close(done)
return
}
req.Reply(true, nil)
go func() {
cmd.Wait()
_ = c.remoteExec(sftpCmd, channel, channel, channel.Stderr())
close(done)
}()
req.Reply(true, nil)
default:
c.ui.Error(fmt.Sprintf("unsupported subsystem requested: %s", req.Payload))
req.Reply(false, nil)
}
default:
c.ui.Message(fmt.Sprintf("rejecting %s request", req.Type))
@ -211,6 +185,57 @@ func (c *adapter) Shutdown() {
c.l.Close()
}
func (c *adapter) exec(command string, in io.Reader, out io.Writer, err io.Writer) int {
var exitStatus int
switch {
case strings.HasPrefix(command, "scp ") && serveSCP(command[4:]):
err := c.scpExec(command[4:], in, out, err)
if err != nil {
log.Println(err)
exitStatus = 1
}
default:
exitStatus = c.remoteExec(command, in, out, err)
}
return exitStatus
}
func serveSCP(args string) bool {
opts, _ := scpOptions(args)
return bytes.IndexAny(opts, "tf") >= 0
}
func (c *adapter) scpExec(args string, in io.Reader, out io.Writer, err io.Writer) error {
opts, rest := scpOptions(args)
if i := bytes.IndexByte(opts, 't'); i >= 0 {
return scpUploadSession(opts, rest, in, out, c.comm)
}
if i := bytes.IndexByte(opts, 'f'); i >= 0 {
return scpDownloadSession(opts, rest, in, out, c.comm)
}
return errors.New("no scp mode specified")
}
func (c *adapter) remoteExec(command string, in io.Reader, out io.Writer, err io.Writer) int {
cmd := &packer.RemoteCmd{
Stdin: in,
Stdout: out,
Stderr: err,
Command: command,
}
if err := c.comm.Start(cmd); err != nil {
c.ui.Error(err.Error())
return cmd.ExitStatus
}
cmd.Wait()
return cmd.ExitStatus
}
type envRequest struct {
*ssh.Request
Payload envRequestPayload

View File

@ -52,6 +52,7 @@ type Config struct {
SSHHostKeyFile string `mapstructure:"ssh_host_key_file"`
SSHAuthorizedKeyFile string `mapstructure:"ssh_authorized_key_file"`
SFTPCmd string `mapstructure:"sftp_command"`
UseSFTP bool `mapstructure:"use_sftp"`
inventoryFile string
}
@ -106,6 +107,12 @@ func (p *Provisioner) Prepare(raws ...interface{}) error {
log.Println(p.config.SSHHostKeyFile, "does not exist")
errs = packer.MultiErrorAppend(errs, err)
}
} else {
p.config.AnsibleEnvVars = append(p.config.AnsibleEnvVars, "ANSIBLE_HOST_KEY_CHECKING=False")
}
if !p.config.UseSFTP {
p.config.AnsibleEnvVars = append(p.config.AnsibleEnvVars, "ANSIBLE_SCP_IF_SSH=True")
}
if len(p.config.LocalPort) > 0 {
@ -277,7 +284,7 @@ func (p *Provisioner) Provision(ui packer.Ui, comm packer.Communicator) error {
}()
}
if err := p.executeAnsible(ui, comm, k.privKeyFile, !hostSigner.generated); err != nil {
if err := p.executeAnsible(ui, comm, k.privKeyFile); err != nil {
return fmt.Errorf("Error executing Ansible: %s", err)
}
@ -294,7 +301,7 @@ func (p *Provisioner) Cancel() {
os.Exit(0)
}
func (p *Provisioner) executeAnsible(ui packer.Ui, comm packer.Communicator, privKeyFile string, checkHostKey bool) error {
func (p *Provisioner) executeAnsible(ui packer.Ui, comm packer.Communicator, privKeyFile string) error {
playbook, _ := filepath.Abs(p.config.PlaybookFile)
inventory := p.config.inventoryFile
var envvars []string
@ -315,10 +322,6 @@ func (p *Provisioner) executeAnsible(ui packer.Ui, comm packer.Communicator, pri
cmd.Env = append(cmd.Env, envvars...)
}
if !checkHostKey {
cmd.Env = append(cmd.Env, "ANSIBLE_HOST_KEY_CHECKING=False")
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
@ -435,7 +438,6 @@ func newUserKey(pubKeyFile string) (*userKey, error) {
type signer struct {
ssh.Signer
generated bool
}
func newSigner(privKeyFile string) (*signer, error) {
@ -464,7 +466,6 @@ func newSigner(privKeyFile string) (*signer, error) {
if err != nil {
return nil, errors.New("Failed to extract private key from generated key pair")
}
signer.generated = true
return signer, nil
}

338
provisioner/ansible/scp.go Normal file
View File

@ -0,0 +1,338 @@
package ansible
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/mitchellh/packer/packer"
)
const (
scpOK = "\x00"
scpEmptyError = "\x02\n"
)
/*
scp is a simple, but poorly documented, protocol. Thankfully, its source is
freely available, and there is at least one page that describes it reasonably
well.
* https://raw.githubusercontent.com/openssh/openssh-portable/master/scp.c
* https://opensource.apple.com/source/OpenSSH/OpenSSH-7.1/openssh/scp.c
* https://blogs.oracle.com/janp/entry/how_the_scp_protocol_works is a great
resource, but has some bad information. Its first problem is that it doesn't
correctly describe why the producer has to read more responses than messages
it sends (because it has to read the 0 sent by the sink to start the
transfer). The second problem is that it omits that the producer needs to
send a 0 byte after file contents.
*/
func scpUploadSession(opts []byte, rest string, in io.Reader, out io.Writer, comm packer.Communicator) error {
rest = strings.TrimSpace(rest)
if len(rest) == 0 {
fmt.Fprintf(out, scpEmptyError)
return errors.New("no scp target specified")
}
d, err := ioutil.TempDir("", "packer-ansible-upload")
if err != nil {
fmt.Fprintf(out, scpEmptyError)
return err
}
defer os.RemoveAll(d)
state := &scpUploadState{destRoot: rest, srcRoot: d, comm: comm}
fmt.Fprintf(out, scpOK) // signal the client to start the transfer.
return state.Protocol(bufio.NewReader(in), out)
}
func scpDownloadSession(opts []byte, rest string, in io.Reader, out io.Writer, comm packer.Communicator) error {
rest = strings.TrimSpace(rest)
if len(rest) == 0 {
fmt.Fprintf(out, scpEmptyError)
return errors.New("no scp source specified")
}
d, err := ioutil.TempDir("", "packer-ansible-download")
if err != nil {
fmt.Fprintf(out, scpEmptyError)
return err
}
defer os.RemoveAll(d)
if bytes.Contains([]byte{'d'}, opts) {
// the only ansible module that supports downloading via scp is fetch,
// fetch only supports file downloads as of Ansible 2.1.
fmt.Fprintf(out, scpEmptyError)
return errors.New("directory downloads not supported")
}
f, err := os.Create(filepath.Join(d, filepath.Base(rest)))
if err != nil {
fmt.Fprintf(out, scpEmptyError)
return err
}
defer f.Close()
err = comm.Download(rest, f)
if err != nil {
fmt.Fprintf(out, scpEmptyError)
return err
}
state := &scpDownloadState{srcRoot: d}
return state.Protocol(bufio.NewReader(in), out)
}
func (state *scpDownloadState) FileProtocol(path string, info os.FileInfo, in *bufio.Reader, out io.Writer) error {
size := info.Size()
perms := fmt.Sprintf("C%04o", info.Mode().Perm())
fmt.Fprintln(out, perms, size, info.Name())
err := scpResponse(in)
if err != nil {
return err
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
io.CopyN(out, f, size)
fmt.Fprintf(out, scpOK)
return scpResponse(in)
}
type scpUploadState struct {
comm packer.Communicator
destRoot string // destRoot is the directory on the target
srcRoot string // srcRoot is the directory on the host
mtime time.Time
atime time.Time
dir string // dir is a path relative to the roots
}
func (scp scpUploadState) DestPath() string {
return filepath.Join(scp.destRoot, scp.dir)
}
func (scp scpUploadState) SrcPath() string {
return filepath.Join(scp.srcRoot, scp.dir)
}
func (state *scpUploadState) Protocol(in *bufio.Reader, out io.Writer) error {
for {
b, err := in.ReadByte()
if err != nil {
return err
}
switch b {
case 'T':
err := state.TimeProtocol(in, out)
if err != nil {
return err
}
case 'C':
return state.FileProtocol(in, out)
case 'E':
state.dir = filepath.Dir(state.dir)
fmt.Fprintf(out, scpOK)
return nil
case 'D':
return state.DirProtocol(in, out)
default:
fmt.Fprintf(out, scpEmptyError)
return fmt.Errorf("unexpected message: %c", b)
}
}
}
func (state *scpUploadState) FileProtocol(in *bufio.Reader, out io.Writer) error {
defer func() {
state.mtime = time.Time{}
}()
var mode os.FileMode
var size int64
var name string
_, err := fmt.Fscanf(in, "%04o %d %s\n", &mode, &size, &name)
if err != nil {
fmt.Fprintf(out, scpEmptyError)
return fmt.Errorf("invalid file message: %v", err)
}
fmt.Fprintf(out, scpOK)
var fi os.FileInfo = fileInfo{name: name, size: size, mode: mode, mtime: state.mtime}
err = state.comm.Upload(filepath.Join(state.DestPath(), fi.Name()), io.LimitReader(in, fi.Size()), &fi)
if err != nil {
fmt.Fprintf(out, scpEmptyError)
return err
}
err = scpResponse(in)
if err != nil {
return err
}
fmt.Fprintf(out, scpOK)
return nil
}
func (state *scpUploadState) TimeProtocol(in *bufio.Reader, out io.Writer) error {
var m, a int64
if _, err := fmt.Fscanf(in, "%d 0 %d 0\n", &m, &a); err != nil {
fmt.Fprintf(out, scpEmptyError)
return err
}
fmt.Fprintf(out, scpOK)
state.atime = time.Unix(a, 0)
state.mtime = time.Unix(m, 0)
return nil
}
func (state *scpUploadState) DirProtocol(in *bufio.Reader, out io.Writer) error {
var mode os.FileMode
var length uint
var name string
if _, err := fmt.Fscanf(in, "%04o %d %s\n", &mode, &length, &name); err != nil {
fmt.Fprintf(out, scpEmptyError)
return fmt.Errorf("invalid directory message: %v", err)
}
fmt.Fprintf(out, scpOK)
path := filepath.Join(state.dir, name)
if err := os.Mkdir(path, mode); err != nil {
return err
}
state.dir = path
if state.atime.IsZero() {
state.atime = time.Now()
}
if state.mtime.IsZero() {
state.mtime = time.Now()
}
if err := os.Chtimes(path, state.atime, state.mtime); err != nil {
return err
}
if err := state.comm.UploadDir(filepath.Dir(state.DestPath()), state.SrcPath(), nil); err != nil {
return err
}
state.mtime = time.Time{}
state.atime = time.Time{}
return state.Protocol(in, out)
}
type scpDownloadState struct {
srcRoot string // srcRoot is the directory on the host
}
func (state *scpDownloadState) Protocol(in *bufio.Reader, out io.Writer) error {
r := bufio.NewReader(in)
// read the byte sent by the other side to start the transfer
scpResponse(r)
return filepath.Walk(state.srcRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if path == state.srcRoot {
return nil
}
if info.IsDir() {
// no need to get fancy; srcRoot should only contain one file, because
// Ansible only allows fetching a single file.
return errors.New("unexpected directory")
}
return state.FileProtocol(path, info, r, out)
})
}
func scpOptions(s string) (opts []byte, rest string) {
end := 0
opt := false
Loop:
for i := 0; i < len(s); i++ {
b := s[i]
switch {
case b == ' ':
opt = false
end++
case b == '-':
opt = true
end++
case opt:
opts = append(opts, b)
end++
default:
break Loop
}
}
rest = s[end:]
return
}
func scpResponse(r *bufio.Reader) error {
code, err := r.ReadByte()
if err != nil {
return err
}
if code != 0 {
message, err := r.ReadString('\n')
if err != nil {
return fmt.Errorf("Error reading error message: %s", err)
}
// 1 is a warning. Anything higher (really just 2) is an error.
if code > 1 {
return errors.New(string(message))
}
log.Println("WARNING:", err)
}
return nil
}
type fileInfo struct {
name string
size int64
mode os.FileMode
mtime time.Time
}
func (fi fileInfo) Name() string { return fi.name }
func (fi fileInfo) Size() int64 { return fi.size }
func (fi fileInfo) Mode() os.FileMode { return fi.mode }
func (fi fileInfo) ModTime() time.Time {
if fi.mtime.IsZero() {
return time.Now()
}
return fi.mtime
}
func (fi fileInfo) IsDir() bool { return fi.mode.IsDir() }
func (fi fileInfo) Sys() interface{} { return nil }

View File

@ -3,7 +3,7 @@
"provisioners": [
{
"type": "shell-local",
"command": "echo 'TODO(bc): write the public key to $HOME/.ssh/known_hosts and stop using ANSIBLE_HOST_KEY_CHECKING=False'"
"command": "echo 'TODO(bhcleek): write the public key to $HOME/.ssh/known_hosts and stop using ANSIBLE_HOST_KEY_CHECKING=False'"
}, {
"type": "shell",
"inline": [
@ -18,11 +18,12 @@
"-vvvv", "--private-key", "ansible-test-id"
],
"sftp_command": "/usr/lib/sftp-server -e -l INFO",
"use_sftp": true,
"ansible_env_vars": ["PACKER_ANSIBLE_TEST=1", "ANSIBLE_HOST_KEY_CHECKING=False"],
"groups": ["PACKER_TEST"],
"empty_groups": ["PACKER_EMPTY_GROUP"],
"host_alias": "packer-test",
"user": "packer",
"user": "packer",
"local_port": 2222,
"ssh_host_key_file": "ansible-server.key",
"ssh_authorized_key_file": "ansible-test-id.pub"

View File

@ -11,7 +11,6 @@
"type": "googlecompute",
"account_file": "{{user `account_file`}}",
"project_id": "{{user `project_id`}}",
"image_name": "packerbats-minimal-{{timestamp}}",
"source_image": "debian-7-wheezy-v20141108",
"zone": "us-central1-a"

View File

@ -1,12 +1,12 @@
---
- hosts: default
- hosts: default:packer-test
gather_facts: no
tasks:
- raw: touch /root/ansible-raw-test
- raw: date
- command: echo "the command module"
- command: mkdir /tmp/remote-dir
args:
args:
creates: /tmp/remote-dir
- copy: src=dir/file.txt dest=/tmp/remote-dir/file.txt
- fetch: src=/tmp/remote-dir/file.txt dest=fetched-dir validate=yes fail_on_missing=yes

View File

@ -0,0 +1,23 @@
{
"variables": {},
"provisioners": [
{
"type": "ansible",
"playbook_file": "./playbook.yml",
"extra_arguments": [
"-vvvv"
],
"sftp_command": "/usr/bin/false"
}
],
"builders": [
{
"type": "googlecompute",
"account_file": "{{user `account_file`}}",
"project_id": "{{user `project_id`}}",
"image_name": "packerbats-scp-{{timestamp}}",
"source_image": "debian-7-wheezy-v20141108",
"zone": "us-central1-a"
}
]
}

View File

@ -0,0 +1,30 @@
{
"variables": {},
"provisioners": [
{
"type": "shell",
"inline": [
"apt-get update",
"apt-get -y install python openssh-sftp-server",
"ls -l /usr/lib",
"#/usr/lib/sftp-server -?"
]
}, {
"type": "ansible",
"playbook_file": "./playbook.yml",
"sftp_command": "/usr/lib/sftp-server -e -l INFO",
"use_sftp": true
}
],
"builders": [
{
"type": "googlecompute",
"account_file": "{{user `account_file`}}",
"project_id": "{{user `project_id`}}",
"image_name": "packerbats-sftp-{{timestamp}}",
"source_image": "debian-7-wheezy-v20141108",
"zone": "us-central1-a"
}
]
}

View File

@ -48,6 +48,7 @@ teardown() {
run packer build ${USER_VARS} $FIXTURE_ROOT/minimal.json
[ "$status" -eq 0 ]
[ "$(gc_has_image "packerbats-minimal")" -eq 1 ]
diff -r dir fetched-dir/default/tmp/remote-dir > /dev/null
}
@test "ansible provisioner: build all_options.json" {
@ -55,4 +56,22 @@ teardown() {
run packer build ${USER_VARS} $FIXTURE_ROOT/all_options.json
[ "$status" -eq 0 ]
[ "$(gc_has_image "packerbats-alloptions")" -eq 1 ]
diff -r dir fetched-dir/packer-test/tmp/remote-dir > /dev/null
}
@test "ansible provisioner: build scp.json" {
cd $FIXTURE_ROOT
run packer build ${USER_VARS} $FIXTURE_ROOT/scp.json
[ "$status" -eq 0 ]
[ "$(gc_has_image "packerbats-scp")" -eq 1 ]
diff -r dir fetched-dir/default/tmp/remote-dir > /dev/null
}
@test "ansible provisioner: build sftp.json" {
cd $FIXTURE_ROOT
run packer build ${USER_VARS} $FIXTURE_ROOT/sftp.json
[ "$status" -eq 0 ]
[ "$(gc_has_image "packerbats-sftp")" -eq 1 ]
diff -r dir fetched-dir/default/tmp/remote-dir > /dev/null
}

View File

@ -80,6 +80,10 @@ Optional Parameters:
files. The command should read and write on stdin and stdout, respectively.
Defaults to `/usr/lib/sftp-server -e`.
- `use_sftp` (boolean) - Whether to use SFTP. When false,
`ANSIBLE_SCP_IF_SSH=True` will be automatically added to `ansible_env_vars`.
Defaults to false.
- `extra_arguments` (array of strings) - Extra arguments to pass to Ansible.
Usage example:
@ -87,8 +91,8 @@ Optional Parameters:
"extra_arguments": [ "--extra-vars", "Region={{user `Region`}} Stage={{user `Stage`}}" ]
```
- `ansible_env_vars` (array of strings) - Environment variables to set before running Ansible.
If unset, defaults to `ANSIBLE_HOST_KEY_CHECKING=False`.
- `ansible_env_vars` (array of strings) - Environment variables to set before
running Ansible.
Usage example:
```
@ -100,8 +104,6 @@ Optional Parameters:
## Limitations
- The `ansible` provisioner does not support SCP to transfer files.
- Redhat / CentOS builds have been known to fail with the following error due to `sftp_command`, which should be set to `/usr/libexec/openssh/sftp-server -e`:
```