diff --git a/plugin/provisioner-windows-shell/main.go b/plugin/provisioner-windows-shell/main.go new file mode 100644 index 000000000..342a8ed9b --- /dev/null +++ b/plugin/provisioner-windows-shell/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "github.com/mitchellh/packer/packer/plugin" + "github.com/mitchellh/packer/provisioner/windows-shell" +) + +func main() { + server, err := plugin.Server() + if err != nil { + panic(err) + } + server.RegisterProvisioner(new(shell.Provisioner)) + server.Serve() +} diff --git a/provisioner/windows-shell/provisioner.go b/provisioner/windows-shell/provisioner.go new file mode 100644 index 000000000..50c0aaeb1 --- /dev/null +++ b/provisioner/windows-shell/provisioner.go @@ -0,0 +1,324 @@ +// This package implements a provisioner for Packer that executes +// shell scripts within the remote machine. +package shell + +import ( + "bufio" + "errors" + "fmt" + "io/ioutil" + "log" + "os" + "sort" + "strings" + "time" + + "github.com/mitchellh/packer/common" + "github.com/mitchellh/packer/helper/config" + "github.com/mitchellh/packer/packer" + "github.com/mitchellh/packer/template/interpolate" +) + +const DefaultRemotePath = "c:/Windows/Temp/script.bat" + +var retryableSleep = 2 * time.Second + +type Config struct { + common.PackerConfig `mapstructure:",squash"` + + // If true, the script contains binary and line endings will not be + // converted from Windows to Unix-style. + Binary bool + + // An inline script to execute. Multiple strings are all executed + // in the context of a single shell. + Inline []string + + // The local path of the shell script to upload and execute. + Script string + + // An array of multiple scripts to run. + Scripts []string + + // An array of environment variables that will be injected before + // your command(s) are executed. + Vars []string `mapstructure:"environment_vars"` + + // The remote path where the local shell script will be uploaded to. + // This should be set to a writable file that is in a pre-existing directory. + RemotePath string `mapstructure:"remote_path"` + + // The command used to execute the script. The '{{ .Path }}' variable + // should be used to specify where the script goes, {{ .Vars }} + // can be used to inject the environment_vars into the environment. + ExecuteCommand string `mapstructure:"execute_command"` + + // The timeout for retrying to start the process. Until this timeout + // is reached, if the provisioner can't start a process, it retries. + // This can be set high to allow for reboots. + StartRetryTimeout time.Duration `mapstructure:"start_retry_timeout"` + + // This is used in the template generation to format environment variables + // inside the `ExecuteCommand` template. + EnvVarFormat string + + ctx interpolate.Context +} + +type Provisioner struct { + config Config +} + +type ExecuteCommandTemplate struct { + Vars string + Path string +} + +func (p *Provisioner) Prepare(raws ...interface{}) error { + err := config.Decode(&p.config, &config.DecodeOpts{ + Interpolate: true, + InterpolateFilter: &interpolate.RenderFilter{ + Exclude: []string{ + "execute_command", + }, + }, + }, raws...) + if err != nil { + return err + } + + if p.config.EnvVarFormat == "" { + p.config.EnvVarFormat = `set "%s=%s" && ` + } + + if p.config.ExecuteCommand == "" { + p.config.ExecuteCommand = `{{.Vars}}"{{.Path}}"` + } + + if p.config.Inline != nil && len(p.config.Inline) == 0 { + p.config.Inline = nil + } + + if p.config.StartRetryTimeout == 0 { + p.config.StartRetryTimeout = 5 * time.Minute + } + + if p.config.RemotePath == "" { + p.config.RemotePath = DefaultRemotePath + } + + if p.config.Scripts == nil { + p.config.Scripts = make([]string, 0) + } + + if p.config.Vars == nil { + p.config.Vars = make([]string, 0) + } + + var errs error + if p.config.Script != "" && len(p.config.Scripts) > 0 { + errs = packer.MultiErrorAppend(errs, + errors.New("Only one of script or scripts can be specified.")) + } + + if p.config.Script != "" { + p.config.Scripts = []string{p.config.Script} + } + + if len(p.config.Scripts) == 0 && p.config.Inline == nil { + errs = packer.MultiErrorAppend(errs, + errors.New("Either a script file or inline script must be specified.")) + } else if len(p.config.Scripts) > 0 && p.config.Inline != nil { + errs = packer.MultiErrorAppend(errs, + errors.New("Only a script file or an inline script can be specified, not both.")) + } + + for _, path := range p.config.Scripts { + if _, err := os.Stat(path); err != nil { + errs = packer.MultiErrorAppend(errs, + fmt.Errorf("Bad script '%s': %s", path, err)) + } + } + + // Do a check for bad environment variables, such as '=foo', 'foobar' + for _, kv := range p.config.Vars { + vs := strings.SplitN(kv, "=", 2) + if len(vs) != 2 || vs[0] == "" { + errs = packer.MultiErrorAppend(errs, + fmt.Errorf("Environment variable not in format 'key=value': %s", kv)) + } + } + + if errs != nil { + return errs + } + + return nil +} + +// This function takes the inline scripts, concatenates them +// into a temporary file and returns a string containing the location +// of said file. +func extractScript(p *Provisioner) (string, error) { + temp, err := ioutil.TempFile(os.TempDir(), "packer-windows-shell-provisioner") + if err != nil { + log.Printf("Unable to create temporary file for inline scripts: %s", err) + return "", err + } + writer := bufio.NewWriter(temp) + for _, command := range p.config.Inline { + log.Printf("Found command: %s", command) + if _, err := writer.WriteString(command + "\n"); err != nil { + return "", fmt.Errorf("Error preparing shell script: %s", err) + } + } + + if err := writer.Flush(); err != nil { + return "", fmt.Errorf("Error preparing shell script: %s", err) + } + + temp.Close() + + return temp.Name(), nil +} + +func (p *Provisioner) Provision(ui packer.Ui, comm packer.Communicator) error { + ui.Say(fmt.Sprintf("Provisioning with windows-shell...")) + scripts := make([]string, len(p.config.Scripts)) + copy(scripts, p.config.Scripts) + + // Build our variables up by adding in the build name and builder type + envVars := make([]string, len(p.config.Vars)+2) + envVars[0] = "PACKER_BUILD_NAME=" + p.config.PackerBuildName + envVars[1] = "PACKER_BUILDER_TYPE=" + p.config.PackerBuilderType + + copy(envVars, p.config.Vars) + + if p.config.Inline != nil { + temp, err := extractScript(p) + if err != nil { + ui.Error(fmt.Sprintf("Unable to extract inline scripts into a file: %s", err)) + } + scripts = append(scripts, temp) + } + + for _, path := range scripts { + ui.Say(fmt.Sprintf("Provisioning with shell script: %s", path)) + + log.Printf("Opening %s for reading", path) + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("Error opening shell script: %s", err) + } + defer f.Close() + + // Create environment variables to set before executing the command + flattendVars, err := p.createFlattenedEnvVars() + if err != nil { + return err + } + + // Compile the command + p.config.ctx.Data = &ExecuteCommandTemplate{ + Vars: flattendVars, + Path: p.config.RemotePath, + } + command, err := interpolate.Render(p.config.ExecuteCommand, &p.config.ctx) + if err != nil { + return fmt.Errorf("Error processing command: %s", err) + } + + // Upload the file and run the command. Do this in the context of + // a single retryable function so that we don't end up with + // the case that the upload succeeded, a restart is initiated, + // and then the command is executed but the file doesn't exist + // any longer. + var cmd *packer.RemoteCmd + err = p.retryable(func() error { + if _, err := f.Seek(0, 0); err != nil { + return err + } + + if err := comm.Upload(p.config.RemotePath, f, nil); err != nil { + return fmt.Errorf("Error uploading script: %s", err) + } + + cmd = &packer.RemoteCmd{Command: command} + return cmd.StartWithUi(comm, ui) + }) + if err != nil { + return err + } + + // Close the original file since we copied it + f.Close() + + if cmd.ExitStatus != 0 { + return fmt.Errorf("Script exited with non-zero exit status: %d", cmd.ExitStatus) + } + } + + return nil +} + +func (p *Provisioner) Cancel() { + // Just hard quit. It isn't a big deal if what we're doing keeps + // running on the other side. + os.Exit(0) +} + +// retryable will retry the given function over and over until a +// non-error is returned. +func (p *Provisioner) retryable(f func() error) error { + startTimeout := time.After(p.config.StartRetryTimeout) + for { + var err error + if err = f(); err == nil { + return nil + } + + // Create an error and log it + err = fmt.Errorf("Retryable error: %s", err) + log.Printf(err.Error()) + + // Check if we timed out, otherwise we retry. It is safe to + // retry since the only error case above is if the command + // failed to START. + select { + case <-startTimeout: + return err + default: + time.Sleep(retryableSleep) + } + } +} + +func (p *Provisioner) createFlattenedEnvVars() (flattened string, err error) { + flattened = "" + envVars := make(map[string]string) + + // Always available Packer provided env vars + envVars["PACKER_BUILD_NAME"] = p.config.PackerBuildName + envVars["PACKER_BUILDER_TYPE"] = p.config.PackerBuilderType + + // Split vars into key/value components + for _, envVar := range p.config.Vars { + keyValue := strings.Split(envVar, "=") + if len(keyValue) != 2 { + err = errors.New("Shell provisioner environment variables must be in key=value format") + return + } + envVars[keyValue[0]] = keyValue[1] + } + // Create a list of env var keys in sorted order + var keys []string + for k := range envVars { + keys = append(keys, k) + } + sort.Strings(keys) + // Re-assemble vars using OS specific format pattern and flatten + for _, key := range keys { + flattened += fmt.Sprintf(p.config.EnvVarFormat, key, envVars[key]) + } + return +} diff --git a/provisioner/windows-shell/provisioner_test.go b/provisioner/windows-shell/provisioner_test.go new file mode 100644 index 000000000..5c4dddd90 --- /dev/null +++ b/provisioner/windows-shell/provisioner_test.go @@ -0,0 +1,441 @@ +package shell + +import ( + "bytes" + "errors" + "fmt" + "github.com/mitchellh/packer/packer" + "io/ioutil" + "log" + "os" + "strings" + "testing" + "time" +) + +func testConfig() map[string]interface{} { + return map[string]interface{}{ + "inline": []interface{}{"foo", "bar"}, + } +} + +func TestProvisionerPrepare_extractScript(t *testing.T) { + config := testConfig() + p := new(Provisioner) + _ = p.Prepare(config) + file, err := extractScript(p) + if err != nil { + t.Fatalf("Should not be error: %s", err) + } + log.Printf("File: %s", file) + if strings.Index(file, os.TempDir()) != 0 { + t.Fatalf("Temp file should reside in %s. File location: %s", os.TempDir(), file) + } + + // File contents should contain 2 lines concatenated by newlines: foo\nbar + readFile, err := ioutil.ReadFile(file) + expectedContents := "foo\nbar\n" + s := string(readFile[:]) + if s != expectedContents { + t.Fatalf("Expected generated inlineScript to equal '%s', got '%s'", expectedContents, s) + } +} + +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("err: %s", err) + } + + if p.config.RemotePath != DefaultRemotePath { + t.Errorf("unexpected remote path: %s", p.config.RemotePath) + } + + if p.config.ExecuteCommand != "{{.Vars}}\"{{.Path}}\"" { + t.Fatalf("Default command should be powershell {{.Vars}}\"{{.Path}}\", but got %s", p.config.ExecuteCommand) + } +} + +func TestProvisionerPrepare_Config(t *testing.T) { + +} + +func TestProvisionerPrepare_InvalidKey(t *testing.T) { + var p Provisioner + config := testConfig() + + // Add a random key + config["i_should_not_be_valid"] = true + err := p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } +} + +func TestProvisionerPrepare_Script(t *testing.T) { + config := testConfig() + delete(config, "inline") + + config["script"] = "/this/should/not/exist" + p := new(Provisioner) + err := p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + // Test with a good one + tf, err := ioutil.TempFile("", "packer") + if err != nil { + t.Fatalf("error tempfile: %s", err) + } + defer os.Remove(tf.Name()) + + config["script"] = tf.Name() + p = new(Provisioner) + err = p.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } +} + +func TestProvisionerPrepare_ScriptAndInline(t *testing.T) { + var p Provisioner + config := testConfig() + + delete(config, "inline") + delete(config, "script") + err := p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + // Test with both + tf, err := ioutil.TempFile("", "packer") + if err != nil { + t.Fatalf("error tempfile: %s", err) + } + defer os.Remove(tf.Name()) + + config["inline"] = []interface{}{"foo"} + config["script"] = tf.Name() + err = p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } +} + +func TestProvisionerPrepare_ScriptAndScripts(t *testing.T) { + var p Provisioner + config := testConfig() + + // Test with both + tf, err := ioutil.TempFile("", "packer") + if err != nil { + t.Fatalf("error tempfile: %s", err) + } + defer os.Remove(tf.Name()) + + config["inline"] = []interface{}{"foo"} + config["scripts"] = []string{tf.Name()} + err = p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } +} + +func TestProvisionerPrepare_Scripts(t *testing.T) { + config := testConfig() + delete(config, "inline") + + config["scripts"] = []string{} + p := new(Provisioner) + err := p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + // Test with a good one + tf, err := ioutil.TempFile("", "packer") + if err != nil { + t.Fatalf("error tempfile: %s", err) + } + defer os.Remove(tf.Name()) + + config["scripts"] = []string{tf.Name()} + p = new(Provisioner) + err = p.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } +} + +func TestProvisionerPrepare_EnvironmentVars(t *testing.T) { + config := testConfig() + + // Test with a bad case + config["environment_vars"] = []string{"badvar", "good=var"} + p := new(Provisioner) + err := p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + // Test with a trickier case + config["environment_vars"] = []string{"=bad"} + p = new(Provisioner) + err = p.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + // Test with a good case + // Note: baz= is a real env variable, just empty + config["environment_vars"] = []string{"FOO=bar", "baz="} + p = new(Provisioner) + err = p.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } +} + +func TestProvisionerQuote_EnvironmentVars(t *testing.T) { + config := testConfig() + + config["environment_vars"] = []string{"keyone=valueone", "keytwo=value\ntwo", "keythree='valuethree'", "keyfour='value\nfour'"} + p := new(Provisioner) + p.Prepare(config) + + expectedValue := "keyone=valueone" + if p.config.Vars[0] != expectedValue { + t.Fatalf("%s should be equal to %s", p.config.Vars[0], expectedValue) + } + + expectedValue = "keytwo=value\ntwo" + if p.config.Vars[1] != expectedValue { + t.Fatalf("%s should be equal to %s", p.config.Vars[1], expectedValue) + } + + expectedValue = "keythree='valuethree'" + if p.config.Vars[2] != expectedValue { + t.Fatalf("%s should be equal to %s", p.config.Vars[2], expectedValue) + } + + expectedValue = "keyfour='value\nfour'" + if p.config.Vars[3] != expectedValue { + t.Fatalf("%s should be equal to %s", p.config.Vars[3], expectedValue) + } +} + +func testUi() *packer.BasicUi { + return &packer.BasicUi{ + Reader: new(bytes.Buffer), + Writer: new(bytes.Buffer), + ErrorWriter: new(bytes.Buffer), + } +} + +func testObjects() (packer.Ui, packer.Communicator) { + ui := testUi() + return ui, new(packer.MockCommunicator) +} + +func TestProvisionerProvision_Inline(t *testing.T) { + config := testConfig() + delete(config, "inline") + + // Defaults provided by Packer + config["remote_path"] = "c:/Windows/Temp/inlineScript.bat" + config["inline"] = []string{"whoami"} + ui := testUi() + p := new(Provisioner) + + // Defaults provided by Packer + p.config.PackerBuildName = "vmware" + p.config.PackerBuilderType = "iso" + comm := new(packer.MockCommunicator) + p.Prepare(config) + err := p.Provision(ui, comm) + if err != nil { + t.Fatal("should not have error") + } + + expectedCommand := `set "PACKER_BUILDER_TYPE=iso" && set "PACKER_BUILD_NAME=vmware" && "c:/Windows/Temp/inlineScript.bat"` + + // Should run the command without alteration + if comm.StartCmd.Command != expectedCommand { + t.Fatalf("Expect command to be: %s, got %s", expectedCommand, comm.StartCmd.Command) + } + + envVars := make([]string, 2) + envVars[0] = "FOO=BAR" + envVars[1] = "BAR=BAZ" + config["environment_vars"] = envVars + config["remote_path"] = "c:/Windows/Temp/inlineScript.bat" + + p.Prepare(config) + err = p.Provision(ui, comm) + if err != nil { + t.Fatal("should not have error") + } + + expectedCommand = `set "BAR=BAZ" && set "FOO=BAR" && set "PACKER_BUILDER_TYPE=iso" && set "PACKER_BUILD_NAME=vmware" && "c:/Windows/Temp/inlineScript.bat"` + + // Should run the command without alteration + if comm.StartCmd.Command != expectedCommand { + t.Fatalf("Expect command to be: %s, got: %s", expectedCommand, comm.StartCmd.Command) + } +} + +func TestProvisionerProvision_Scripts(t *testing.T) { + tempFile, _ := ioutil.TempFile("", "packer") + defer os.Remove(tempFile.Name()) + config := testConfig() + delete(config, "inline") + config["scripts"] = []string{tempFile.Name()} + config["packer_build_name"] = "foobuild" + config["packer_builder_type"] = "footype" + ui := testUi() + + p := new(Provisioner) + comm := new(packer.MockCommunicator) + p.Prepare(config) + err := p.Provision(ui, comm) + if err != nil { + t.Fatal("should not have error") + } + + //powershell -Command "$env:PACKER_BUILDER_TYPE=''"; powershell -Command "$env:PACKER_BUILD_NAME='foobuild'"; powershell -Command c:/Windows/Temp/script.ps1 + expectedCommand := `set "PACKER_BUILDER_TYPE=footype" && set "PACKER_BUILD_NAME=foobuild" && "c:/Windows/Temp/script.bat"` + + // Should run the command without alteration + if comm.StartCmd.Command != expectedCommand { + t.Fatalf("Expect command to be %s NOT %s", expectedCommand, comm.StartCmd.Command) + } +} + +func TestProvisionerProvision_ScriptsWithEnvVars(t *testing.T) { + tempFile, _ := ioutil.TempFile("", "packer") + config := testConfig() + ui := testUi() + defer os.Remove(tempFile.Name()) + delete(config, "inline") + + config["scripts"] = []string{tempFile.Name()} + config["packer_build_name"] = "foobuild" + config["packer_builder_type"] = "footype" + + // Env vars - currently should not effect them + envVars := make([]string, 2) + envVars[0] = "FOO=BAR" + envVars[1] = "BAR=BAZ" + config["environment_vars"] = envVars + + p := new(Provisioner) + comm := new(packer.MockCommunicator) + p.Prepare(config) + err := p.Provision(ui, comm) + if err != nil { + t.Fatal("should not have error") + } + + expectedCommand := `set "BAR=BAZ" && set "FOO=BAR" && set "PACKER_BUILDER_TYPE=footype" && set "PACKER_BUILD_NAME=foobuild" && "c:/Windows/Temp/script.bat"` + + // Should run the command without alteration + if comm.StartCmd.Command != expectedCommand { + t.Fatalf("Expect command to be %s NOT %s", expectedCommand, comm.StartCmd.Command) + } +} + +func TestProvisioner_createFlattenedEnvVars_windows(t *testing.T) { + config := testConfig() + + p := new(Provisioner) + err := p.Prepare(config) + if err != nil { + t.Fatalf("should not have error preparing config: %s", err) + } + + // Defaults provided by Packer + p.config.PackerBuildName = "vmware" + p.config.PackerBuilderType = "iso" + + // no user env var + flattenedEnvVars, err := p.createFlattenedEnvVars() + if err != nil { + t.Fatalf("should not have error creating flattened env vars: %s", err) + } + expectedEnvVars := `set "PACKER_BUILDER_TYPE=iso" && set "PACKER_BUILD_NAME=vmware" && ` + if flattenedEnvVars != expectedEnvVars { + t.Fatalf("expected flattened env vars to be: %s, got: %s", expectedEnvVars, flattenedEnvVars) + } + + // single user env var + p.config.Vars = []string{"FOO=bar"} + + flattenedEnvVars, err = p.createFlattenedEnvVars() + if err != nil { + t.Fatalf("should not have error creating flattened env vars: %s", err) + } + expectedEnvVars = `set "FOO=bar" && set "PACKER_BUILDER_TYPE=iso" && set "PACKER_BUILD_NAME=vmware" && ` + if flattenedEnvVars != expectedEnvVars { + t.Fatalf("expected flattened env vars to be: %s, got: %s", expectedEnvVars, flattenedEnvVars) + } + + // multiple user env vars + p.config.Vars = []string{"FOO=bar", "BAZ=qux"} + + flattenedEnvVars, err = p.createFlattenedEnvVars() + if err != nil { + t.Fatalf("should not have error creating flattened env vars: %s", err) + } + expectedEnvVars = `set "BAZ=qux" && set "FOO=bar" && set "PACKER_BUILDER_TYPE=iso" && set "PACKER_BUILD_NAME=vmware" && ` + if flattenedEnvVars != expectedEnvVars { + t.Fatalf("expected flattened env vars to be: %s, got: %s", expectedEnvVars, flattenedEnvVars) + } +} + +func TestRetryable(t *testing.T) { + config := testConfig() + + count := 0 + retryMe := func() error { + log.Printf("RetryMe, attempt number %d", count) + if count == 2 { + return nil + } + count++ + return errors.New(fmt.Sprintf("Still waiting %d more times...", 2-count)) + } + retryableSleep = 50 * time.Millisecond + p := new(Provisioner) + p.config.StartRetryTimeout = 155 * time.Millisecond + err := p.Prepare(config) + err = p.retryable(retryMe) + if err != nil { + t.Fatalf("should not have error retrying funuction") + } + + count = 0 + p.config.StartRetryTimeout = 10 * time.Millisecond + err = p.Prepare(config) + err = p.retryable(retryMe) + if err == nil { + t.Fatalf("should have error retrying funuction") + } +} + +func TestCancel(t *testing.T) { + // Don't actually call Cancel() as it performs an os.Exit(0) + // which kills the 'go test' tool +}