Compare commits

...

6 Commits

Author SHA1 Message Date
Wilken Rivera
8de1eddcb2 Update to run exeternal commands via a script block 2020-09-03 09:08:49 -04:00
Wilken Rivera
9df4129d6d provisioner/powershell: Add wrapper script to default provisioner runs
This change adds a wrapper script to the provisioner for improved error
handling. The wrapper script is enabled by default, but will be disabled
when using a custom ExecuteCommand or ElevatedExecuteCommand.

The wrapper script will include the contents of any provided Inline
commands or Scripts as part of its payload and run as a single script
with environment variables loaded within the script. This changes the
existing behavior of uploading any defined script(s) unmodified to the
remote host.
2020-09-03 06:32:34 -04:00
Wilken Rivera
ab93bc8a5d Add Wrapper command with support for execute command 2020-09-02 10:23:17 -04:00
Wilken Rivera
e90913fcd2 Fix up LASTEXITCODE and script cleanup 2020-09-02 10:23:17 -04:00
Wilken Rivera
b1fec8f0bc Change script value 2020-09-02 10:23:17 -04:00
Wilken Rivera
ed1a2b1deb provisioner/powershell: Implement error handling 2020-09-02 10:23:17 -04:00
6 changed files with 283 additions and 129 deletions

View File

@ -9,6 +9,7 @@ import (
"context"
"errors"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
@ -37,6 +38,43 @@ var psEscape = strings.NewReplacer(
"'", "`'",
)
const PowershellWrapperScript string = `
if (Test-Path variable:global:ProgressPreference) {
set-variable -name variable:global:ProgressPreference -value 'SilentlyContinue'
}
set-variable -name variable:global:ErrorActionPreference -value 'Continue'
$global:LASTEXITCODE = 0
$global:lastcmdlet = $null
trap [Exception] {write-error ($_.Exception.Message);exit 1}
{{if .DebugMode}}
Set-PsDebug -Trace {{.DebugMode}}
{{- end}}
{{.Vars}}
$results = {
{{.Payload}}
$global:lastcmdlet = $?
}.invokereturnasis()
$exitstatus = 1
if ($lastcmdlet) {
$exitstatus = 0
}
if ( $LASTEXITCODE -ne $null -and $LASTEXITCODE -ne 0 ) {
$exitstatus = $LASTEXITCODE
}
Write-Host $results
exit $exitstatus
`
type Config struct {
shell.Provisioner `mapstructure:",squash"`
@ -76,8 +114,6 @@ type Config struct {
ExecutionPolicy ExecutionPolicy `mapstructure:"execution_policy"`
remoteCleanUpScriptPath string
// If set, sets PowerShell's [PSDebug mode](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/set-psdebug?view=powershell-7)
// in order to make script debugging easier. For instance, setting the
// value to 1 results in adding this to the execute command:
@ -87,30 +123,30 @@ type Config struct {
// ```
DebugMode int `mapstructure:"debug_mode"`
// If set, any Powershell provided `Inline` command(s) or `Script(s)` will
// get wrapped in a Packer error handling script to help with capturing
// non-zero exit codes or unexpected failures. Defaults to true.
// It is explicitly to false when using a custom ExecuteCommand or ElevatedExecuteCommand.
UseErrorWrapperScript bool `mapstructure:"use_error_wrapper"`
remoteCleanUpScriptPath string
ctx interpolate.Context
}
type Provisioner struct {
config Config
communicator packer.Communicator
generatedData map[string]interface{}
config Config
communicator packer.Communicator
generatedData map[string]interface{}
useWrappedCommmand bool
}
func (p *Provisioner) defaultExecuteCommand() string {
baseCmd := `& { if (Test-Path variable:global:ProgressPreference)` +
`{set-variable -name variable:global:ProgressPreference -value 'SilentlyContinue'};`
if p.config.DebugMode != 0 {
baseCmd += fmt.Sprintf(`Set-PsDebug -Trace %d;`, p.config.DebugMode)
}
baseCmd += `. {{.Vars}}; &'{{.Path}}'; exit $LastExitCode }`
if p.config.ExecutionPolicy == ExecutionPolicyNone {
return baseCmd
return `-file {{.Path}}`
}
return fmt.Sprintf(`powershell -executionpolicy %s "%s"`, p.config.ExecutionPolicy, baseCmd)
return fmt.Sprintf(`powershell -noninteractive -noprofile -executionpolicy %s -file {{.Path}}`, p.config.ExecutionPolicy)
}
func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec { return p.config.FlatMapstructure().HCL2Spec() }
@ -132,6 +168,9 @@ func (p *Provisioner) Prepare(raws ...interface{}) error {
return err
}
// Set Remote execution defaults
p.config.remoteCleanUpScriptPath = fmt.Sprintf(`c:/Windows/Temp/packer-cleanup-%s.ps1`, uuid.TimeOrderedUUID())
if p.config.EnvVarFormat == "" {
p.config.EnvVarFormat = `$env:%s="%s"; `
}
@ -142,10 +181,12 @@ func (p *Provisioner) Prepare(raws ...interface{}) error {
if p.config.ExecuteCommand == "" {
p.config.ExecuteCommand = p.defaultExecuteCommand()
p.config.UseErrorWrapperScript = true
}
if p.config.ElevatedExecuteCommand == "" {
p.config.ElevatedExecuteCommand = p.defaultExecuteCommand()
p.config.UseErrorWrapperScript = true
}
if p.config.Inline != nil && len(p.config.Inline) == 0 {
@ -157,13 +198,11 @@ func (p *Provisioner) Prepare(raws ...interface{}) error {
}
if p.config.RemotePath == "" {
uuid := uuid.TimeOrderedUUID()
p.config.RemotePath = fmt.Sprintf(`c:/Windows/Temp/script-%s.ps1`, uuid)
p.config.RemotePath = fmt.Sprintf(`c:/Windows/Temp/script-%s.ps1`, uuid.TimeOrderedUUID())
}
if p.config.RemoteEnvVarPath == "" {
uuid := uuid.TimeOrderedUUID()
p.config.RemoteEnvVarPath = fmt.Sprintf(`c:/Windows/Temp/packer-ps-env-vars-%s.ps1`, uuid)
p.config.RemoteEnvVarPath = fmt.Sprintf(`c:/Windows/Temp/packer-ps-env-vars-%s.ps1`, uuid.TimeOrderedUUID())
}
if p.config.Scripts == nil {
@ -174,8 +213,7 @@ func (p *Provisioner) Prepare(raws ...interface{}) error {
p.config.Vars = make([]string, 0)
}
p.config.remoteCleanUpScriptPath = fmt.Sprintf(`c:/Windows/Temp/packer-cleanup-%s.ps1`, uuid.TimeOrderedUUID())
// Validate parsed configuration data
var errs error
if p.config.Script != "" && len(p.config.Scripts) > 0 {
errs = packer.MultiErrorAppend(errs,
@ -223,37 +261,11 @@ func (p *Provisioner) Prepare(raws ...interface{}) error {
}
if !(p.config.DebugMode >= 0 && p.config.DebugMode <= 2) {
errs = packer.MultiErrorAppend(errs, fmt.Errorf("%d is an invalid Trace level for `debug_mode`; valid values are 0, 1, and 2", p.config.DebugMode))
s := "%d is an invalid Trace level for `debug_mode`; valid values are 0, 1, and 2"
errs = packer.MultiErrorAppend(errs, fmt.Errorf(s, p.config.DebugMode))
}
if errs != nil {
return errs
}
return nil
}
// 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 := tmp.File("powershell-provisioner")
if err != nil {
return "", err
}
defer temp.Close()
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 powershell script: %s", err)
}
}
if err := writer.Flush(); err != nil {
return "", fmt.Errorf("Error preparing powershell script: %s", err)
}
return temp.Name(), nil
return errs
}
func (p *Provisioner) Provision(ctx context.Context, ui packer.Ui, comm packer.Communicator, generatedData map[string]interface{}) error {
@ -284,17 +296,26 @@ func (p *Provisioner) Provision(ctx context.Context, ui packer.Ui, comm packer.C
if err != nil {
return fmt.Errorf("Error stating powershell script: %s", err)
}
if strings.HasSuffix(p.config.RemotePath, `\`) {
// path is a directory
p.config.RemotePath += filepath.Base((fi).Name())
}
f, err := os.Open(path)
payload, err := ioutil.ReadFile(path)
if err != nil {
return fmt.Errorf("Error opening powershell script: %s", err)
}
defer f.Close()
command, err := p.createCommandText()
data := string(payload)
if p.config.UseErrorWrapperScript {
data, err = p.WrapScriptContents(payload)
if err != nil {
return err
}
}
command, err := p.buildInterpolatedCommand()
if err != nil {
return fmt.Errorf("Error processing command: %s", err)
}
@ -305,10 +326,8 @@ func (p *Provisioner) Provision(ctx context.Context, ui packer.Ui, comm packer.C
// command is executed but the file doesn't exist any longer.
var cmd *packer.RemoteCmd
err = retry.Config{StartTimeout: p.config.StartRetryTimeout}.Run(ctx, func(ctx context.Context) error {
if _, err := f.Seek(0, 0); err != nil {
return err
}
if err := comm.Upload(p.config.RemotePath, f, &fi); err != nil {
if err := comm.Upload(p.config.RemotePath, strings.NewReader(data), nil); err != nil {
return fmt.Errorf("Error uploading script: %s", err)
}
@ -319,9 +338,6 @@ func (p *Provisioner) Provision(ctx context.Context, ui packer.Ui, comm packer.C
return err
}
// Close the original file since we copied it
f.Close()
// Record every other uploaded script file so we can clean it up later
uploadedScripts = append(uploadedScripts, p.config.RemotePath)
@ -371,13 +387,119 @@ func (p *Provisioner) createRemoteCleanUpCommand(remoteFiles []string) (string,
return "", fmt.Errorf("clean up script %q failed to upload: %s", remotePath, err)
}
data := p.generatedData
data["Path"] = remotePath
data["Vars"] = p.config.RemoteEnvVarPath
p.config.ctx.Data = data
ctxData := p.generatedData
ctxData["Path"] = remotePath
command, err := interpolate.Render(p.config.ExecuteCommand, &p.config.ctx)
if err != nil {
return "", fmt.Errorf("Error processing command: %s", err)
}
p.config.ctx.Data = data
return interpolate.Render(p.config.ExecuteCommand, &p.config.ctx)
// Return the interpolated command
return command, nil
}
// buildInterpolatedCommand returns the actual command to be executed at runtime.
func (p *Provisioner) buildInterpolatedCommand() (string, error) {
if p.config.ElevatedUser != "" {
return p.elevatedExecuteCommand()
}
return p.executeCommand()
}
// WrapScriptContents will generate a Powershell wrapper for executing p.config.Inline or p.config.Scripts
func (p *Provisioner) WrapScriptContents(payload []byte) (string, error) {
var b strings.Builder
if _, err := b.Write(payload); err != nil {
return "", fmt.Errorf("failed to wrap script contents: %s", err)
}
ctxData := p.generatedData
ctxData["Vars"] = p.createFlattenedEnvVars(p.config.ElevatedUser != "")
ctxData["Payload"] = b.String()
ctxData["DebugMode"] = p.config.DebugMode
p.config.ctx.Data = ctxData
data, err := interpolate.Render(PowershellWrapperScript, &p.config.ctx)
if err != nil {
return "", fmt.Errorf("Error building powershell wrapper: %s", err)
}
return data, nil
}
func (p *Provisioner) executeCommand() (string, error) {
// Prepare everything needed to enable the required env vars within the
// remote environment
err := p.prepareEnvVars(false)
if err != nil {
return "", err
}
ctxData := p.generatedData
ctxData["Path"] = p.config.RemotePath
ctxData["Vars"] = p.config.RemoteEnvVarPath
p.config.ctx.Data = ctxData
command, err := interpolate.Render(p.config.ExecuteCommand, &p.config.ctx)
if err != nil {
return "", fmt.Errorf("Error processing command: %s", err)
}
// Return the interpolated command
return command, nil
}
func (p *Provisioner) elevatedExecuteCommand() (command string, err error) {
// Prepare everything needed to enable the required env vars within the
// remote environment
err = p.prepareEnvVars(true)
if err != nil {
return "", err
}
ctxData := p.generatedData
ctxData["Path"] = p.config.RemotePath
ctxData["Vars"] = p.config.RemoteEnvVarPath
p.config.ctx.Data = ctxData
command, err = interpolate.Render(p.config.ElevatedExecuteCommand, &p.config.ctx)
if err != nil {
return "", fmt.Errorf("Error processing command: %s", err)
}
command, err = provisioner.GenerateElevatedRunner(command, p)
if err != nil {
return "", fmt.Errorf("Error generating elevated runner: %s", err)
}
return command, err
}
// 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 := tmp.File("powershell-provisioner")
if err != nil {
return "", err
}
defer temp.Close()
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 powershell script: %s", err)
}
}
if err := writer.Flush(); err != nil {
return "", fmt.Errorf("Error preparing powershell script: %s", err)
}
return temp.Name(), nil
}
// Environment variables required within the remote environment are uploaded
@ -472,63 +594,6 @@ func (p *Provisioner) uploadEnvVars(flattenedEnvVars string) (err error) {
return
}
func (p *Provisioner) createCommandText() (command string, err error) {
// Return the interpolated command
if p.config.ElevatedUser == "" {
return p.createCommandTextNonPrivileged()
} else {
return p.createCommandTextPrivileged()
}
}
func (p *Provisioner) createCommandTextNonPrivileged() (command string, err error) {
// Prepare everything needed to enable the required env vars within the
// remote environment
err = p.prepareEnvVars(false)
if err != nil {
return "", err
}
ctxData := p.generatedData
ctxData["Path"] = p.config.RemotePath
ctxData["Vars"] = p.config.RemoteEnvVarPath
p.config.ctx.Data = ctxData
command, err = interpolate.Render(p.config.ExecuteCommand, &p.config.ctx)
if err != nil {
return "", fmt.Errorf("Error processing command: %s", err)
}
// Return the interpolated command
return command, nil
}
func (p *Provisioner) createCommandTextPrivileged() (command string, err error) {
// Prepare everything needed to enable the required env vars within the
// remote environment
err = p.prepareEnvVars(true)
if err != nil {
return "", err
}
ctxData := p.generatedData
ctxData["Path"] = p.config.RemotePath
ctxData["Vars"] = p.config.RemoteEnvVarPath
p.config.ctx.Data = ctxData
command, err = interpolate.Render(p.config.ElevatedExecuteCommand, &p.config.ctx)
if err != nil {
return "", fmt.Errorf("Error processing command: %s", err)
}
command, err = provisioner.GenerateElevatedRunner(command, p)
if err != nil {
return "", fmt.Errorf("Error generating elevated runner: %s", err)
}
return command, err
}
func (p *Provisioner) Communicator() packer.Communicator {
return p.communicator
}

View File

@ -34,6 +34,7 @@ type FlatConfig struct {
ElevatedPassword *string `mapstructure:"elevated_password" cty:"elevated_password" hcl:"elevated_password"`
ExecutionPolicy *string `mapstructure:"execution_policy" cty:"execution_policy" hcl:"execution_policy"`
DebugMode *int `mapstructure:"debug_mode" cty:"debug_mode" hcl:"debug_mode"`
UseErrorWrapperScript *bool `mapstructure:"use_error_wrapper" cty:"use_error_wrapper" hcl:"use_error_wrapper"`
}
// FlatMapstructure returns a new FlatConfig.
@ -73,6 +74,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec {
"elevated_password": &hcldec.AttrSpec{Name: "elevated_password", Type: cty.String, Required: false},
"execution_policy": &hcldec.AttrSpec{Name: "execution_policy", Type: cty.String, Required: false},
"debug_mode": &hcldec.AttrSpec{Name: "debug_mode", Type: cty.Number, Required: false},
"use_error_wrapper": &hcldec.AttrSpec{Name: "use_error_wrapper", Type: cty.Bool, Required: false},
}
return s
}

View File

@ -39,6 +39,15 @@ func TestAccPowershellProvisioner_Script(t *testing.T) {
acc.TestProvisionersAgainstBuilders(&testProvisioner, t)
}
func TestAccPowershellProvisioner_ExitCodes(t *testing.T) {
acc.TestProvisionersPreCheck(TestProvisionerName, t)
// This provisioner should fail with an exit code of 1. To assert the failure the fixture
// uses the valid_exit_codes option to confirm a non-zero exit code
testProvisioner := PowershellProvisionerAccTest{"powershell-exit_codes-provisioner.txt"}
acc.TestProvisionersAgainstBuilders(&testProvisioner, t)
}
type PowershellProvisionerAccTest struct {
ConfigName string
}

View File

@ -71,6 +71,44 @@ func TestProvisionerPrepare_Defaults(t *testing.T) {
t.Error("expected elevated_password to be empty")
}
matched, _ = regexp.MatchString("powershell -noninteractive -noprofile -executionpolicy bypass -file {{.Path}}", p.config.ExecuteCommand)
if !matched {
t.Errorf("expected default execute command, but got : %s", p.config.ExecuteCommand)
}
matched, _ = regexp.MatchString("powershell -noninteractive -noprofile -executionpolicy bypass -file {{.Path}}", p.config.ElevatedExecuteCommand)
if !matched {
t.Errorf("expected default elevated execute command, but got : %s", p.config.ElevatedExecuteCommand)
}
if p.config.ElevatedEnvVarFormat != `$env:%s="%s"; ` {
t.Fatalf(`Default command should be powershell '$env:%%s="%%s"; ', but got %s`, p.config.ElevatedEnvVarFormat)
}
}
func TestProvisionerPrepare_CustomExecuteCommands(t *testing.T) {
var p Provisioner
config := testConfig()
config["execute_command"] = `powershell -executionpolicy bypass "& { if (Test-Path variable:global:ProgressPreference){set-variable -name variable:global:ProgressPreference -value 'SilentlyContinue'};. {{.Vars}}; &'{{.Path}}'; exit $LastExitCode }"`
config["elevated_execute_command"] = `powershell -executionpolicy bypass "& { if (Test-Path variable:global:ProgressPreference){set-variable -name variable:global:ProgressPreference -value 'SilentlyContinue'};. {{.Vars}}; &'{{.Path}}'; exit $LastExitCode }"`
err := p.Prepare(config)
if err != nil {
t.Fatalf("err: %s", err)
}
matched, _ := regexp.MatchString("c:/Windows/Temp/script-.*.ps1", p.config.RemotePath)
if !matched {
t.Errorf("unexpected remote path: %s", p.config.RemotePath)
}
if p.config.ElevatedUser != "" {
t.Error("expected elevated_user to be empty")
}
if p.config.ElevatedPassword != "" {
t.Error("expected elevated_password to be empty")
}
if p.config.ExecuteCommand != `powershell -executionpolicy bypass "& { if (Test-Path variable:global:ProgressPreference){set-variable -name variable:global:ProgressPreference -value 'SilentlyContinue'};. {{.Vars}}; &'{{.Path}}'; exit $LastExitCode }"` {
t.Fatalf(`Default command should be 'powershell -executionpolicy bypass "& { if (Test-Path variable:global:ProgressPreference){set-variable -name variable:global:ProgressPreference -value 'SilentlyContinue'};. {{.Vars}}; &'{{.Path}}'; exit $LastExitCode }"', but got '%s'`, p.config.ExecuteCommand)
}
@ -476,7 +514,7 @@ func TestProvisionerProvision_Scripts(t *testing.T) {
}
cmd := comm.StartCmd.Command
re := regexp.MustCompile(`powershell -executionpolicy bypass "& { if \(Test-Path variable:global:ProgressPreference\){set-variable -name variable:global:ProgressPreference -value 'SilentlyContinue'};\. c:/Windows/Temp/packer-ps-env-vars-[[:alnum:]]{8}-[[:alnum:]]{4}-[[:alnum:]]{4}-[[:alnum:]]{4}-[[:alnum:]]{12}\.ps1; &'c:/Windows/Temp/script.ps1'; exit \$LastExitCode }"`)
re := regexp.MustCompile(`powershell -noninteractive -noprofile -executionpolicy bypass -file c:/Windows/Temp/script.ps1'`)
matched := re.MatchString(cmd)
if !matched {
t.Fatalf("Got unexpected command: %s", cmd)
@ -786,7 +824,7 @@ func TestProvisioner_createFlattenedEnvVars_windows(t *testing.T) {
}
}
func TestProvision_createCommandText(t *testing.T) {
func TestProvision_buildInterpolatedCommand(t *testing.T) {
config := testConfig()
config["remote_path"] = "c:/Windows/Temp/script.ps1"
p := new(Provisioner)
@ -800,9 +838,9 @@ func TestProvision_createCommandText(t *testing.T) {
// Non-elevated
p.generatedData = make(map[string]interface{})
cmd, _ := p.createCommandText()
cmd, _ := p.buildInterpolatedCommand()
re := regexp.MustCompile(`powershell -executionpolicy bypass "& { if \(Test-Path variable:global:ProgressPreference\){set-variable -name variable:global:ProgressPreference -value 'SilentlyContinue'};\. c:/Windows/Temp/packer-ps-env-vars-[[:alnum:]]{8}-[[:alnum:]]{4}-[[:alnum:]]{4}-[[:alnum:]]{4}-[[:alnum:]]{12}\.ps1; &'c:/Windows/Temp/script.ps1'; exit \$LastExitCode }"`)
re := regexp.MustCompile(`powershell -noninteractive -noprofile -executionpolicy bypass -file c:/Windows/Temp/script.ps1`)
matched := re.MatchString(cmd)
if !matched {
t.Fatalf("Got unexpected command: %s", cmd)
@ -811,7 +849,7 @@ func TestProvision_createCommandText(t *testing.T) {
// Elevated
p.config.ElevatedUser = "vagrant"
p.config.ElevatedPassword = "vagrant"
cmd, _ = p.createCommandText()
cmd, _ = p.buildInterpolatedCommand()
re = regexp.MustCompile(`powershell -executionpolicy bypass -file "C:/Windows/Temp/packer-elevated-shell-[[:alnum:]]{8}-[[:alnum:]]{4}-[[:alnum:]]{4}-[[:alnum:]]{4}-[[:alnum:]]{12}\.ps1"`)
matched = re.MatchString(cmd)
if !matched {

View File

@ -0,0 +1,27 @@
{
"type": "powershell",
"inline": ["invalid-cmdlet"],
"valid_exit_codes": ["1"]
},
{
"type": "powershell",
"inline": ["#Requires -Version 10.0"],
"valid_exit_codes": ["1"]
},
{
"type": "powershell",
"script": "../../provisioner/powershell/test-fixtures/scripts/set_version_latest.ps1",
"valid_exit_codes": ["0"]
},
{
"type": "powershell",
"elevated_user": "Administrator",
"elevated_password": "{{.WinRMPassword}}",
"inline": "Get-ItemProperty -Path HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion",
"valid_exit_codes": ["0"]
},
{
"type": "powershell",
"inline": "sc.exe start Life",
"valid_exit_codes": ["1060"]
}

View File

@ -0,0 +1,13 @@
# Test fixture is a modified version of the example found at
# https://www.powershellmagazine.com/2012/10/23/pstip-set-strictmode-why-should-you-care/
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$myNumbersCollection = 1..5
if($myNumbersCollection -contains 3) {
"collection contains 3"
}
else {
"collection doesn't contain 3"
}