diff --git a/Makefile b/Makefile index b65b6f500..042c46f2f 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ GOPATH=$(shell go env GOPATH) # gofmt UNFORMATTED_FILES=$(shell find . -not -path "./vendor/*" -name "*.go" | xargs gofmt -s -l) -EXECUTABLE_FILES=$(shell find . -type f -executable | egrep -v '^\./(website/[vendor|tmp]|vendor/|\.git|bin/|scripts/|pkg/)' | egrep -v '.*(\.sh|\.bats|\.git)' | egrep -v './provisioner/ansible/test-fixtures/exit1') +EXECUTABLE_FILES=$(shell find . -type f -executable | egrep -v '^\./(website/[vendor|tmp]|vendor/|\.git|bin/|scripts/|pkg/)' | egrep -v '.*(\.sh|\.bats|\.git)' | egrep -v './provisioner/(ansible|inspec)/test-fixtures/exit1') # Get the git commit GIT_DIRTY=$(shell test -n "`git status --porcelain`" && echo "+CHANGES" || true) diff --git a/command/plugin.go b/command/plugin.go index 2d2272640..1db800893 100644 --- a/command/plugin.go +++ b/command/plugin.go @@ -71,6 +71,7 @@ import ( chefsoloprovisioner "github.com/hashicorp/packer/provisioner/chef-solo" convergeprovisioner "github.com/hashicorp/packer/provisioner/converge" fileprovisioner "github.com/hashicorp/packer/provisioner/file" + inspecprovisioner "github.com/hashicorp/packer/provisioner/inspec" powershellprovisioner "github.com/hashicorp/packer/provisioner/powershell" puppetmasterlessprovisioner "github.com/hashicorp/packer/provisioner/puppet-masterless" puppetserverprovisioner "github.com/hashicorp/packer/provisioner/puppet-server" @@ -130,6 +131,7 @@ var Provisioners = map[string]packer.Provisioner{ "chef-solo": new(chefsoloprovisioner.Provisioner), "converge": new(convergeprovisioner.Provisioner), "file": new(fileprovisioner.Provisioner), + "inspec": new(inspecprovisioner.Provisioner), "powershell": new(powershellprovisioner.Provisioner), "puppet-masterless": new(puppetmasterlessprovisioner.Provisioner), "puppet-server": new(puppetserverprovisioner.Provisioner), diff --git a/provisioner/ansible/adapter.go b/common/adapter/adapter.go similarity index 93% rename from provisioner/ansible/adapter.go rename to common/adapter/adapter.go index c3dfd3495..510ae40bb 100644 --- a/provisioner/ansible/adapter.go +++ b/common/adapter/adapter.go @@ -1,4 +1,4 @@ -package ansible +package adapter import ( "bytes" @@ -17,7 +17,7 @@ import ( // An adapter satisfies SSH requests (from an Ansible client) by delegating SSH // exec and subsystem commands to a packer.Communicator. -type adapter struct { +type Adapter struct { done <-chan struct{} l net.Listener config *ssh.ServerConfig @@ -26,8 +26,8 @@ type adapter struct { 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{ +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, @@ -37,7 +37,7 @@ func newAdapter(done <-chan struct{}, l net.Listener, config *ssh.ServerConfig, } } -func (c *adapter) Serve() { +func (c *Adapter) Serve() { log.Printf("SSH proxy: serving on %s", c.l.Addr()) for { @@ -62,7 +62,7 @@ func (c *adapter) Serve() { } } -func (c *adapter) Handle(conn net.Conn, ui packer.Ui) error { +func (c *Adapter) Handle(conn net.Conn, ui packer.Ui) error { log.Print("SSH proxy: accepted connection") _, chans, reqs, err := ssh.NewServerConn(conn, c.config) if err != nil { @@ -89,7 +89,7 @@ func (c *adapter) Handle(conn net.Conn, ui packer.Ui) error { return nil } -func (c *adapter) handleSession(newChannel ssh.NewChannel) error { +func (c *Adapter) handleSession(newChannel ssh.NewChannel) error { channel, requests, err := newChannel.Accept() if err != nil { return err @@ -182,11 +182,11 @@ func (c *adapter) handleSession(newChannel ssh.NewChannel) error { return nil } -func (c *adapter) Shutdown() { +func (c *Adapter) Shutdown() { c.l.Close() } -func (c *adapter) exec(command string, in io.Reader, out io.Writer, err io.Writer) int { +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:]): @@ -206,7 +206,7 @@ func serveSCP(args string) bool { return bytes.IndexAny(opts, "tf") >= 0 } -func (c *adapter) scpExec(args string, in io.Reader, out io.Writer) error { +func (c *Adapter) scpExec(args string, in io.Reader, out io.Writer) error { opts, rest := scpOptions(args) // remove the quoting that ansible added to rest for shell safety. @@ -226,7 +226,7 @@ func (c *adapter) scpExec(args string, in io.Reader, out io.Writer) error { return errors.New("no scp mode specified") } -func (c *adapter) remoteExec(command string, in io.Reader, out io.Writer, err io.Writer) int { +func (c *Adapter) remoteExec(command string, in io.Reader, out io.Writer, err io.Writer) int { cmd := &packer.RemoteCmd{ Stdin: in, Stdout: out, diff --git a/provisioner/ansible/adapter_test.go b/common/adapter/adapter_test.go similarity index 95% rename from provisioner/ansible/adapter_test.go rename to common/adapter/adapter_test.go index 29667cbe2..a43b3bedc 100644 --- a/provisioner/ansible/adapter_test.go +++ b/common/adapter/adapter_test.go @@ -1,4 +1,4 @@ -package ansible +package adapter import ( "errors" @@ -26,7 +26,7 @@ func TestAdapter_Serve(t *testing.T) { ui := new(packer.NoopUi) - sut := newAdapter(done, &l, config, "", newUi(ui), communicator{}) + sut := NewAdapter(done, &l, config, "", ui, communicator{}) go func() { i := 0 for range acceptC { diff --git a/provisioner/ansible/scp.go b/common/adapter/scp.go similarity index 99% rename from provisioner/ansible/scp.go rename to common/adapter/scp.go index ca029605d..06043ff20 100644 --- a/provisioner/ansible/scp.go +++ b/common/adapter/scp.go @@ -1,4 +1,4 @@ -package ansible +package adapter import ( "bufio" diff --git a/packer/ui.go b/packer/ui.go index 2a0f5e275..e42e08139 100644 --- a/packer/ui.go +++ b/packer/ui.go @@ -343,7 +343,7 @@ func (u *MachineReadableUi) ProgressBar() ProgressBar { return new(NoopProgressBar) } -// TimestampedUi is a UI that wraps another UI implementation and prefixes +// TimestampedUi is a UI that wraps another UI implementation and // prefixes each message with an RFC3339 timestamp type TimestampedUi struct { Ui Ui @@ -376,3 +376,48 @@ func (u *TimestampedUi) ProgressBar() ProgressBar { return u.Ui.ProgressBar() } func (u *TimestampedUi) timestampLine(string string) string { return fmt.Sprintf("%v: %v", time.Now().Format(time.RFC3339), string) } + +// Safe is a UI that wraps another UI implementation and +// provides concurrency-safe access +type SafeUi struct { + Sem chan int + Ui Ui +} + +var _ Ui = new(SafeUi) + +func (u *SafeUi) Ask(s string) (string, error) { + u.Sem <- 1 + ret, err := u.Ui.Ask(s) + <-u.Sem + + return ret, err +} + +func (u *SafeUi) Say(s string) { + u.Sem <- 1 + u.Ui.Say(s) + <-u.Sem +} + +func (u *SafeUi) Message(s string) { + u.Sem <- 1 + u.Ui.Message(s) + <-u.Sem +} + +func (u *SafeUi) Error(s string) { + u.Sem <- 1 + u.Ui.Error(s) + <-u.Sem +} + +func (u *SafeUi) Machine(t string, args ...string) { + u.Sem <- 1 + u.Ui.Machine(t, args...) + <-u.Sem +} + +func (u *SafeUi) ProgressBar() ProgressBar { + return new(NoopProgressBar) +} diff --git a/provisioner/ansible/provisioner.go b/provisioner/ansible/provisioner.go index 9979fbd2a..e836eb894 100644 --- a/provisioner/ansible/provisioner.go +++ b/provisioner/ansible/provisioner.go @@ -26,6 +26,7 @@ import ( "golang.org/x/crypto/ssh" "github.com/hashicorp/packer/common" + "github.com/hashicorp/packer/common/adapter" commonhelper "github.com/hashicorp/packer/helper/common" "github.com/hashicorp/packer/helper/config" "github.com/hashicorp/packer/packer" @@ -63,7 +64,7 @@ type Config struct { type Provisioner struct { config Config - adapter *adapter + adapter *adapter.Adapter done chan struct{} ansibleVersion string ansibleMajVersion uint @@ -285,8 +286,11 @@ func (p *Provisioner) Provision(ui packer.Ui, comm packer.Communicator) error { return err } - ui = newUi(ui) - p.adapter = newAdapter(p.done, localListener, config, p.config.SFTPCmd, ui, comm) + ui = &packer.SafeUi{ + Sem: make(chan int, 1), + Ui: ui, + } + p.adapter = adapter.NewAdapter(p.done, localListener, config, p.config.SFTPCmd, ui, comm) defer func() { log.Print("shutting down the SSH proxy") @@ -556,49 +560,3 @@ func getWinRMPassword(buildName string) string { packer.LogSecretFilter.Set(winRMPass) return winRMPass } - -// Ui provides concurrency-safe access to packer.Ui. -type Ui struct { - sem chan int - ui packer.Ui -} - -func newUi(ui packer.Ui) packer.Ui { - return &Ui{sem: make(chan int, 1), ui: ui} -} - -func (ui *Ui) Ask(s string) (string, error) { - ui.sem <- 1 - ret, err := ui.ui.Ask(s) - <-ui.sem - - return ret, err -} - -func (ui *Ui) Say(s string) { - ui.sem <- 1 - ui.ui.Say(s) - <-ui.sem -} - -func (ui *Ui) Message(s string) { - ui.sem <- 1 - ui.ui.Message(s) - <-ui.sem -} - -func (ui *Ui) Error(s string) { - ui.sem <- 1 - ui.ui.Error(s) - <-ui.sem -} - -func (ui *Ui) Machine(t string, args ...string) { - ui.sem <- 1 - ui.ui.Machine(t, args...) - <-ui.sem -} - -func (ui *Ui) ProgressBar() packer.ProgressBar { - return new(packer.NoopProgressBar) -} diff --git a/provisioner/inspec/provisioner.go b/provisioner/inspec/provisioner.go new file mode 100644 index 000000000..fab92ee49 --- /dev/null +++ b/provisioner/inspec/provisioner.go @@ -0,0 +1,524 @@ +package inspec + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "net" + "os" + "os/exec" + "os/user" + "regexp" + "strconv" + "strings" + "sync" + "unicode" + + "golang.org/x/crypto/ssh" + + "github.com/hashicorp/packer/common" + "github.com/hashicorp/packer/common/adapter" + "github.com/hashicorp/packer/helper/config" + "github.com/hashicorp/packer/packer" + "github.com/hashicorp/packer/template/interpolate" +) + +var SupportedBackends = map[string]bool{"docker": true, "local": true, "ssh": true, "winrm": true} + +type Config struct { + common.PackerConfig `mapstructure:",squash"` + ctx interpolate.Context + + // The command to run inspec + Command string + SubCommand string + + // Extra options to pass to the inspec command + ExtraArguments []string `mapstructure:"extra_arguments"` + InspecEnvVars []string `mapstructure:"inspec_env_vars"` + + // The profile to execute. + Profile string `mapstructure:"profile"` + AttributesDirectory string `mapstructure:"attributes_directory"` + AttributesFiles []string `mapstructure:"attributes"` + Backend string `mapstructure:"backend"` + User string `mapstructure:"user"` + Host string `mapstructure:"host"` + LocalPort uint `mapstructure:"local_port"` + SSHHostKeyFile string `mapstructure:"ssh_host_key_file"` + SSHAuthorizedKeyFile string `mapstructure:"ssh_authorized_key_file"` +} + +type Provisioner struct { + config Config + adapter *adapter.Adapter + done chan struct{} + inspecVersion string + inspecMajVersion uint +} + +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 = "inspec" + } + + if p.config.SubCommand == "" { + p.config.SubCommand = "exec" + } + + var errs *packer.MultiError + err = validateProfileConfig(p.config.Profile) + if err != nil { + errs = packer.MultiErrorAppend(errs, err) + } + + // Check that the authorized key file exists + if len(p.config.SSHAuthorizedKeyFile) > 0 { + err = validateFileConfig(p.config.SSHAuthorizedKeyFile, "ssh_authorized_key_file", true) + if err != nil { + log.Println(p.config.SSHAuthorizedKeyFile, "does not exist") + errs = packer.MultiErrorAppend(errs, err) + } + } + 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 p.config.Backend == "" { + p.config.Backend = "ssh" + } + + if _, ok := SupportedBackends[p.config.Backend]; !ok { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("backend: %s must be a valid backend", p.config.Backend)) + } + + if p.config.Backend == "docker" && p.config.Host == "" { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("backend: host must be specified for docker backend")) + } + + if p.config.Host == "" { + p.config.Host = "127.0.0.1" + } + + if p.config.LocalPort > 65535 { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("local_port: %d must be a valid port", p.config.LocalPort)) + } + + if len(p.config.AttributesDirectory) > 0 { + err = validateDirectoryConfig(p.config.AttributesDirectory, "attrs") + if err != nil { + log.Println(p.config.AttributesDirectory, "does not exist") + errs = packer.MultiErrorAppend(errs, err) + } + } + + if p.config.User == "" { + usr, err := user.Current() + if err != nil { + errs = packer.MultiErrorAppend(errs, err) + } else { + p.config.User = usr.Username + } + } + if p.config.User == "" { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("user: could not determine current user from environment.")) + } + + if errs != nil && len(errs.Errors) > 0 { + return errs + } + return nil +} + +func (p *Provisioner) getVersion() error { + out, err := exec.Command(p.config.Command, "version").Output() + if err != nil { + return fmt.Errorf( + "Error running \"%s version\": %s", p.config.Command, err.Error()) + } + + versionRe := regexp.MustCompile(`\w (\d+\.\d+[.\d+]*)`) + matches := versionRe.FindStringSubmatch(string(out)) + if matches == nil { + return fmt.Errorf( + "Could not find %s version in output:\n%s", p.config.Command, string(out)) + } + + version := matches[1] + log.Printf("%s version: %s", p.config.Command, version) + p.inspecVersion = version + + majVer, err := strconv.ParseUint(strings.Split(version, ".")[0], 10, 0) + if err != nil { + return fmt.Errorf("Could not parse major version from \"%s\".", version) + } + p.inspecMajVersion = uint(majVer) + + return nil +} + +func (p *Provisioner) Provision(ui packer.Ui, comm packer.Communicator) error { + ui.Say("Provisioning with Inspec...") + + for i, envVar := range p.config.InspecEnvVars { + envVar, err := interpolate.Render(envVar, &p.config.ctx) + if err != nil { + return fmt.Errorf("Could not interpolate inspec env vars: %s", err) + } + p.config.InspecEnvVars[i] = envVar + } + + for i, arg := range p.config.ExtraArguments { + arg, err := interpolate.Render(arg, &p.config.ctx) + if err != nil { + return fmt.Errorf("Could not interpolate inspec extra arguments: %s", err) + } + p.config.ExtraArguments[i] = arg + } + + for i, arg := range p.config.AttributesFiles { + arg, err := interpolate.Render(arg, &p.config.ctx) + if err != nil { + return fmt.Errorf("Could not interpolate inspec attributes: %s", err) + } + p.config.AttributesFiles[i] = arg + } + + k, err := newUserKey(p.config.SSHAuthorizedKeyFile) + if err != nil { + return err + } + + hostSigner, err := newSigner(p.config.SSHHostKeyFile) + // Remove the private key file + if len(k.privKeyFile) > 0 { + defer os.Remove(k.privKeyFile) + } + + keyChecker := ssh.CertChecker{ + UserKeyFallback: func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { + if user := conn.User(); user != p.config.User { + return nil, errors.New(fmt.Sprintf("authentication failed: %s is not a valid user", user)) + } + + if !bytes.Equal(k.Marshal(), pubKey.Marshal()) { + return nil, errors.New("authentication failed: unauthorized key") + } + + return nil, nil + }, + } + + config := &ssh.ServerConfig{ + AuthLogCallback: func(conn ssh.ConnMetadata, method string, err error) { + log.Printf("authentication attempt from %s to %s as %s using %s", conn.RemoteAddr(), conn.LocalAddr(), conn.User(), method) + }, + PublicKeyCallback: keyChecker.Authenticate, + //NoClientAuth: true, + } + + config.AddHostKey(hostSigner) + + localListener, err := func() (net.Listener, error) { + + port := p.config.LocalPort + tries := 1 + if port != 0 { + tries = 10 + } + for i := 0; i < tries; i++ { + l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + port++ + if err != nil { + ui.Say(err.Error()) + continue + } + _, portStr, err := net.SplitHostPort(l.Addr().String()) + if err != nil { + ui.Say(err.Error()) + continue + } + portUint64, err := strconv.ParseUint(portStr, 10, 0) + if err != nil { + ui.Say(err.Error()) + continue + } + p.config.LocalPort = uint(portUint64) + return l, nil + } + return nil, errors.New("Error setting up SSH proxy connection") + }() + + if err != nil { + return err + } + + ui = &packer.SafeUi{ + Sem: make(chan int, 1), + Ui: ui, + } + p.adapter = adapter.NewAdapter(p.done, localListener, config, "", ui, comm) + + defer func() { + log.Print("shutting down the SSH proxy") + close(p.done) + p.adapter.Shutdown() + }() + + go p.adapter.Serve() + + tf, err := ioutil.TempFile(p.config.AttributesDirectory, "packer-provisioner-inspec.*.yml") + if err != nil { + return fmt.Errorf("Error preparing packer attributes file: %s", err) + } + defer os.Remove(tf.Name()) + + w := bufio.NewWriter(tf) + w.WriteString(fmt.Sprintf("packer_build_name: %s\n", p.config.PackerBuildName)) + w.WriteString(fmt.Sprintf("packer_builder_type: %s\n", p.config.PackerBuilderType)) + + if err := w.Flush(); err != nil { + tf.Close() + return fmt.Errorf("Error preparing packer attributes file: %s", err) + } + tf.Close() + p.config.AttributesFiles = append(p.config.AttributesFiles, tf.Name()) + + if err := p.executeInspec(ui, comm, k.privKeyFile); err != nil { + return fmt.Errorf("Error executing Inspec: %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) executeInspec(ui packer.Ui, comm packer.Communicator, privKeyFile string) error { + var envvars []string + + args := []string{p.config.SubCommand, p.config.Profile} + args = append(args, "--backend", p.config.Backend) + args = append(args, "--host", p.config.Host) + + if p.config.Backend == "ssh" { + if len(privKeyFile) > 0 { + args = append(args, "--key-files", privKeyFile) + } + args = append(args, "--user", p.config.User) + args = append(args, "--port", strconv.FormatUint(uint64(p.config.LocalPort), 10)) + } + + args = append(args, "--attrs") + args = append(args, p.config.AttributesFiles...) + args = append(args, p.config.ExtraArguments...) + + if len(p.config.InspecEnvVars) > 0 { + envvars = append(envvars, p.config.InspecEnvVars...) + } + + cmd := exec.Command(p.config.Command, args...) + + cmd.Env = os.Environ() + if len(envvars) > 0 { + cmd.Env = append(cmd.Env, envvars...) + } + + 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) { + reader := bufio.NewReader(r) + for { + line, err := reader.ReadString('\n') + if line != "" { + line = strings.TrimRightFunc(line, unicode.IsSpace) + ui.Message(line) + } + if err != nil { + if err == io.EOF { + break + } else { + ui.Error(err.Error()) + break + } + } + } + wg.Done() + } + wg.Add(2) + go repeat(stdout) + go repeat(stderr) + + ui.Say(fmt.Sprintf("Executing Inspec: %s", strings.Join(cmd.Args, " "))) + if err := cmd.Start(); err != nil { + return err + } + 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 +} + +func validateProfileConfig(name string) error { + if name == "" { + return fmt.Errorf("profile must be specified.") + } + return nil +} + +func validateDirectoryConfig(name string, config string) error { + 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 directory", config, name) + } + return nil +} + +type userKey struct { + ssh.PublicKey + privKeyFile string +} + +func newUserKey(pubKeyFile string) (*userKey, error) { + userKey := new(userKey) + if len(pubKeyFile) > 0 { + pubKeyBytes, err := ioutil.ReadFile(pubKeyFile) + if err != nil { + return nil, errors.New("Failed to read public key") + } + userKey.PublicKey, _, _, _, err = ssh.ParseAuthorizedKey(pubKeyBytes) + if err != nil { + return nil, errors.New("Failed to parse authorized key") + } + + return userKey, nil + } + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, errors.New("Failed to generate key pair") + } + userKey.PublicKey, err = ssh.NewPublicKey(key.Public()) + if err != nil { + return nil, errors.New("Failed to extract public key from generated key pair") + } + + // To support Inspec calling back to us we need to write + // this file down + privateKeyDer := x509.MarshalPKCS1PrivateKey(key) + privateKeyBlock := pem.Block{ + Type: "RSA PRIVATE KEY", + Headers: nil, + Bytes: privateKeyDer, + } + tf, err := ioutil.TempFile("", "packer-provisioner-inspec.*.key") + if err != nil { + return nil, errors.New("failed to create temp file for generated key") + } + _, err = tf.Write(pem.EncodeToMemory(&privateKeyBlock)) + if err != nil { + return nil, errors.New("failed to write private key to temp file") + } + + err = tf.Close() + if err != nil { + return nil, errors.New("failed to close private key temp file") + } + userKey.privKeyFile = tf.Name() + + return userKey, nil +} + +type signer struct { + ssh.Signer +} + +func newSigner(privKeyFile string) (*signer, error) { + signer := new(signer) + + if len(privKeyFile) > 0 { + privateBytes, err := ioutil.ReadFile(privKeyFile) + if err != nil { + return nil, errors.New("Failed to load private host key") + } + + signer.Signer, err = ssh.ParsePrivateKey(privateBytes) + if err != nil { + return nil, errors.New("Failed to parse private host key") + } + + return signer, nil + } + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, errors.New("Failed to generate server key pair") + } + + signer.Signer, err = ssh.NewSignerFromKey(key) + if err != nil { + return nil, errors.New("Failed to extract private key from generated key pair") + } + + return signer, nil +} diff --git a/provisioner/inspec/provisioner_test.go b/provisioner/inspec/provisioner_test.go new file mode 100644 index 000000000..6857f7004 --- /dev/null +++ b/provisioner/inspec/provisioner_test.go @@ -0,0 +1,293 @@ +package inspec + +import ( + "crypto/rand" + "fmt" + "io" + "io/ioutil" + "os" + "path" + "strings" + "testing" + + "github.com/hashicorp/packer/packer" +) + +// Be sure to remove the InSpec stub file in each test with: +// defer os.Remove(config["command"].(string)) +func testConfig(t *testing.T) map[string]interface{} { + m := make(map[string]interface{}) + wd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + inspec_stub := path.Join(wd, "packer-inspec-stub.sh") + + err = ioutil.WriteFile(inspec_stub, []byte("#!/usr/bin/env bash\necho 2.2.16"), 0777) + if err != nil { + t.Fatalf("err: %s", err) + } + m["command"] = inspec_stub + + 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(t) + defer os.Remove(config["command"].(string)) + + 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()) + + profile_file, err := ioutil.TempFile("", "test") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(profile_file.Name()) + + config["ssh_host_key_file"] = hostkey_file.Name() + config["ssh_authorized_key_file"] = publickey_file.Name() + config["profile"] = profile_file.Name() + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(profile_file.Name()) + + err = os.Unsetenv("USER") + if err != nil { + t.Fatalf("err: %s", err) + } + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvisionerPrepare_ProfileFile(t *testing.T) { + var p Provisioner + config := testConfig(t) + defer os.Remove(config["command"].(string)) + + 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_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") + } + + profile_file, err := ioutil.TempFile("", "test") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(profile_file.Name()) + + config["profile"] = profile_file.Name() + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } + + test_dir, err := ioutil.TempDir("", "test") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(test_dir) + + config["profile"] = test_dir + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvisionerPrepare_HostKeyFile(t *testing.T) { + var p Provisioner + config := testConfig(t) + defer os.Remove(config["command"].(string)) + + publickey_file, err := ioutil.TempFile("", "publickey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(publickey_file.Name()) + + profile_file, err := ioutil.TempFile("", "test") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(profile_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_host_key_file"] = fmt.Sprintf("%x", filename) + config["ssh_authorized_key_file"] = publickey_file.Name() + config["profile"] = profile_file.Name() + + err = p.Prepare(config) + if err == nil { + t.Fatal("should error if ssh_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_host_key_file"] = hostkey_file.Name() + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvisionerPrepare_AuthorizedKeyFiles(t *testing.T) { + var p Provisioner + config := testConfig(t) + defer os.Remove(config["command"].(string)) + + hostkey_file, err := ioutil.TempFile("", "hostkey") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(hostkey_file.Name()) + + profile_file, err := ioutil.TempFile("", "test") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(profile_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_host_key_file"] = hostkey_file.Name() + config["profile"] = profile_file.Name() + config["ssh_authorized_key_file"] = fmt.Sprintf("%x", filename) + + err = p.Prepare(config) + if err == nil { + t.Errorf("should error if ssh_authorized_key_file does not exist") + } + + 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.Errorf("err: %s", err) + } +} + +func TestProvisionerPrepare_LocalPort(t *testing.T) { + var p Provisioner + config := testConfig(t) + defer os.Remove(config["command"].(string)) + + 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()) + + profile_file, err := ioutil.TempFile("", "test") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(profile_file.Name()) + + config["ssh_host_key_file"] = hostkey_file.Name() + config["ssh_authorized_key_file"] = publickey_file.Name() + config["profile"] = profile_file.Name() + + config["local_port"] = uint(65537) + err = p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + config["local_port"] = uint(22222) + err = p.Prepare(config) + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestInspecGetVersion(t *testing.T) { + if os.Getenv("PACKER_ACC") == "" { + t.Skip("This test is only run with PACKER_ACC=1 and it requires InSpec to be installed") + } + + var p Provisioner + p.config.Command = "inspec exec" + err := p.getVersion() + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestInspecGetVersionError(t *testing.T) { + var p Provisioner + p.config.Command = "./test-fixtures/exit1" + err := p.getVersion() + if err == nil { + t.Fatal("Should return error") + } + if !strings.Contains(err.Error(), "./test-fixtures/exit1 version") { + t.Fatal("Error message should include command name") + } +} diff --git a/provisioner/inspec/test-fixtures/exit1 b/provisioner/inspec/test-fixtures/exit1 new file mode 100755 index 000000000..2bb8d868b --- /dev/null +++ b/provisioner/inspec/test-fixtures/exit1 @@ -0,0 +1,3 @@ +#!/bin/sh + +exit 1 diff --git a/website/source/community-plugins.html.md b/website/source/community-plugins.html.md index bb0cb2490..537cdc3ca 100644 --- a/website/source/community-plugins.html.md +++ b/website/source/community-plugins.html.md @@ -25,6 +25,7 @@ still distributed with Packer. ## Provisioners - File +- InSpec - PowerShell - Shell - Windows Restart diff --git a/website/source/docs/provisioners/inspec.html.md b/website/source/docs/provisioners/inspec.html.md new file mode 100644 index 000000000..56809050d --- /dev/null +++ b/website/source/docs/provisioners/inspec.html.md @@ -0,0 +1,131 @@ +--- +description: | + The inspec Packer provisioner allows inspec profiles to be run to test the + machine. +layout: docs +page_title: 'InSpec - Provisioners' +sidebar_current: 'docs-provisioners-inspec' +--- + +# InSpec Provisioner + +Type: `inspec` + +The `inspec` Packer provisioner runs InSpec profiles. It dynamically creates a +target configured to use SSH, runs an SSH server, executes `inspec exec`, and +marshals InSpec tests through the SSH server to the machine being provisioned +by Packer. + +## Basic Example + +This is a fully functional template that will test an image on DigitalOcean. +Replace the mock `api_token` value with your own. + +``` json +{ + "provisioners": [ + { + "type": "inspec", + "profile": "https://github.com/dev-sec/linux-baseline" + } + ], + + "builders": [ + { + "type": "digitalocean", + "api_token": "", + "image": "ubuntu-14-04-x64", + "region": "sfo1" + } + ] +} +``` + +## Configuration Reference + +Required Parameters: + +- `profile` (string) - The profile to be executed by InSpec. + +Optional Parameters: + +- `inspec_env_vars` (array of strings) - Environment variables to set before + running InSpec. Usage example: + + ``` json + "inspec_env_vars": [ "FOO=bar" ] + ``` + +- `command` (string) - The command to invoke InSpec. Defaults to `inspec`. + +- `extra_arguments` (array of strings) - Extra arguments to pass to InSpec. + These arguments *will not* be passed through a shell and arguments should + not be quoted. Usage example: + + ``` json + "extra_arguments": [ "--sudo", "--reporter", "json" ] + ``` + +- `attributes` (array of strings) - Attribute Files used by InSpec which will + be passed to the `--attrs` argument of the `inspec` command when this + provisioner runs InSpec. Specify this if you want a different location. + Note using also `"--attrs"` in `extra_arguments` will override this + setting. + +- `attributes_directory` (string) - The directory in which to place the + temporary generated InSpec Attributes file. By default, this is the + system-specific temporary file location. The fully-qualified name of this + temporary file will be passed to the `--attrs` argument of the `inspec` + command when this provisioner runs InSpec. Specify this if you want a + different location. + +- `backend` (string) - Backend used by InSpec for connection. Defaults to + SSH. + +- `host` (string) - Host used for by InSpec for connection. Defaults to + localhost. + +- `local_port` (uint) - The port on which to attempt to listen for SSH + connections. This value is a starting point. The provisioner will attempt to + listen for SSH connections on the first available of ten ports, starting at + `local_port`. A system-chosen port is used when `local_port` is missing or + empty. + +- `ssh_host_key_file` (string) - The SSH key that will be used to run the SSH + server on the host machine to forward commands to the target machine. + InSpec connects to this server and will validate the identity of the server + using the system known\_hosts. The default behavior is to generate and use + a onetime key. + +- `ssh_authorized_key_file` (string) - The SSH public key of the InSpec + `ssh_user`. The default behavior is to generate and use a onetime key. If + this key is generated, the corresponding private key is passed to `inspec` + command with the `-i inspec_ssh_private_key_file` option. + +- `user` (string) - The `--user` to use. Defaults to the user running Packer. + +## Default Extra Variables + +In addition to being able to specify extra arguments using the +`extra_arguments` configuration, the provisioner automatically defines certain +commonly useful InSpec Attributes: + +- `packer_build_name` is set to the name of the build that Packer is running. + This is most useful when Packer is making multiple builds and you want to + distinguish them slightly when using a common profile. + +- `packer_builder_type` is the type of the builder that was used to create + the machine that the script is running on. This is useful if you want to + run only certain parts of the profile on systems built with certain + builders. + +## Debugging + +To debug underlying issues with InSpec, add `"-l"` to `"extra_arguments"` to +enable verbose logging. + +``` json +{ + "extra_arguments": [ "-l", "debug" ] +} +``` diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index 63f65f109..68bde9065 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -219,6 +219,9 @@ > File + > + InSpec + > PowerShell