diff --git a/provisioner/powershell/provisioner.go b/provisioner/powershell/provisioner.go index aae71f9fb..0f2719275 100644 --- a/provisioner/powershell/provisioner.go +++ b/provisioner/powershell/provisioner.go @@ -8,7 +8,6 @@ package powershell import ( - "bufio" "context" "errors" "fmt" @@ -38,6 +37,33 @@ var psEscape = strings.NewReplacer( "'", "`'", ) +// wraps the content in try catch block and exits with a status. +const wrapPowershellString string = ` + if (Test-Path variable:global:ProgressPreference) { + set-variable -name variable:global:ProgressPreference -value 'SilentlyContinue' + } + {{if .DebugMode}} + Set-PsDebug -Trace {{.DebugMode}} + {{- end}} + $exitCode = 0 + try { + {{.Vars}} + {{.Payload}} + $exitCode = 0 + } catch { + Write-Error "An error occurred: $_" + $exitCode = 1 + } + + if ($LASTEXITCODE -ne $null -and $LASTEXITCODE -ne 0) { + $exitCode = $LASTEXITCODE + } + + Write-Host $result + exit $exitCode + +` + type Config struct { shell.Provisioner `mapstructure:",squash"` @@ -105,23 +131,15 @@ type Provisioner struct { } 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}}` } if p.config.UsePwsh { - return fmt.Sprintf(`pwsh -executionpolicy %s -command "%s"`, p.config.ExecutionPolicy, baseCmd) + return fmt.Sprintf(`pwsh -executionpolicy %s -file {{.Path}}`, p.config.ExecutionPolicy) } else { - return fmt.Sprintf(`powershell -executionpolicy %s "%s"`, p.config.ExecutionPolicy, baseCmd) + return fmt.Sprintf(`powershell -executionpolicy %s -file {{.Path}}`, p.config.ExecutionPolicy) } } @@ -247,24 +265,41 @@ func (p *Provisioner) Prepare(raws ...interface{}) error { return nil } -// Takes the inline scripts, concatenates them into a temporary file and +// Takes the inline scripts, adds a wrapper around 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) + + var commandBuilder strings.Builder + + // we concatenate all the inline commands 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 := commandBuilder.WriteString(command); err != nil { + return "", fmt.Errorf("failed to wrap script contents: %w", err) } } - if err := writer.Flush(); err != nil { - return "", fmt.Errorf("Error preparing powershell script: %s", err) + // injecting all the variables in the string + ctxData := p.generatedData + ctxData["Vars"] = p.createFlattenedEnvVars(p.config.ElevatedUser != "") + ctxData["Payload"] = commandBuilder.String() + ctxData["DebugMode"] = p.config.DebugMode + p.config.ctx.Data = ctxData + + data, err := interpolate.Render(wrapPowershellString, &p.config.ctx) + if err != nil { + return "", fmt.Errorf("Error building powershell wrapper: %w", err) + } + + log.Printf("Writing PowerShell script to file: %s", temp.Name()) + if _, err := temp.WriteString(data); err != nil { + return "", fmt.Errorf("Error writing PowerShell script: %w", err) } return temp.Name(), nil diff --git a/provisioner/powershell/provisioner_acc_test.go b/provisioner/powershell/provisioner_acc_test.go index 8073d0113..90c16ddef 100644 --- a/provisioner/powershell/provisioner_acc_test.go +++ b/provisioner/powershell/provisioner_acc_test.go @@ -111,3 +111,26 @@ func TestAccPowershellProvisioner_Script(t *testing.T) { provisioneracc.TestProvisionersAgainstBuilders(testCase, t) } + +func TestAccPowershellProvisioner_ExitCodes(t *testing.T) { + templateString, err := LoadProvisionerFragment("powershell-exit_codes-provisioner.txt") + if err != nil { + t.Fatalf("Couldn't load test fixture; %s", err.Error()) + } + testCase := &provisioneracc.ProvisionerTestCase{ + IsCompatible: powershellIsCompatible, + Name: "powershell-provisioner-script", + Template: templateString, + Type: TestProvisionerType, + Check: func(buildcommand *exec.Cmd, logfile string) error { + if buildcommand.ProcessState != nil { + if buildcommand.ProcessState.ExitCode() != 0 { + return fmt.Errorf("Bad exit code. Logfile: %s", logfile) + } + } + return nil + }, + } + provisioneracc.TestProvisionersAgainstBuilders(testCase, t) + +} diff --git a/provisioner/powershell/provisioner_test.go b/provisioner/powershell/provisioner_test.go index 024b0585f..b858700b9 100644 --- a/provisioner/powershell/provisioner_test.go +++ b/provisioner/powershell/provisioner_test.go @@ -23,6 +23,7 @@ func TestProvisionerPrepare_extractScript(t *testing.T) { config := testConfig() p := new(Provisioner) _ = p.Prepare(config) + p.generatedData = generatedData() file, err := extractScript(p) defer os.Remove(file) if err != nil { @@ -35,13 +36,15 @@ func TestProvisionerPrepare_extractScript(t *testing.T) { // File contents should contain 2 lines concatenated by newlines: foo\nbar readFile, err := os.ReadFile(file) - expectedContents := "foo\nbar\n" + expectedContents := "if (Test-Path variable:global:ProgressPreference) {\n set-variable -name variable:global:ProgressPreference -value 'SilentlyContinue'\n }\n \n $exitCode = 0\n try {\n $env:PACKER_BUILDER_TYPE=\"\"; $env:PACKER_BUILD_NAME=\"\"; \n foobar\n $exitCode = 0\n } catch {\n Write-Error \"An error occurred: $_\"\n $exitCode = 1\n }\n \n if ($LASTEXITCODE -ne $null -and $LASTEXITCODE -ne 0) {\n $exitCode = $LASTEXITCODE\n }\n \n Write-Host $result\n exit $exitCode" + normalizedExpectedContent := normalizeWhiteSpace(expectedContents) if err != nil { t.Fatalf("Should not be error: %s", err) } s := string(readFile[:]) - if s != expectedContents { - t.Fatalf("Expected generated inlineScript to equal '%s', got '%s'", expectedContents, s) + normalizedString := normalizeWhiteSpace(s) + if normalizedString != normalizedExpectedContent { + t.Fatalf("Expected generated inlineScript to equal '%s', got '%s'", normalizedExpectedContent, normalizedString) } } @@ -74,12 +77,12 @@ func TestProvisionerPrepare_Defaults(t *testing.T) { 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) + if p.config.ExecuteCommand != `powershell -executionpolicy bypass -file {{.Path}}` { + t.Fatalf(`Default command should be 'powershell -executionpolicy bypass -file {{.Path}}', but got '%s'`, p.config.ExecuteCommand) } - if p.config.ElevatedExecuteCommand != `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.ElevatedExecuteCommand) + if p.config.ElevatedExecuteCommand != `powershell -executionpolicy bypass -file {{.Path}}` { + t.Fatalf(`Default command should be 'powershell -executionpolicy bypass -file {{.Path}}', but got '%s'`, p.config.ElevatedExecuteCommand) } if p.config.ElevatedEnvVarFormat != `$env:%s="%s"; ` { @@ -120,7 +123,7 @@ func TestProvisionerPrepare_DebugMode(t *testing.T) { t.Fatalf("err: %s", err) } - command := `powershell -executionpolicy bypass "& { if (Test-Path variable:global:ProgressPreference){set-variable -name variable:global:ProgressPreference -value 'SilentlyContinue'};Set-PsDebug -Trace 1;. {{.Vars}}; &'{{.Path}}'; exit $LastExitCode }"` + command := `powershell -executionpolicy bypass -file {{.Path}}` if p.config.ExecuteCommand != command { t.Fatalf(fmt.Sprintf(`Expected command should be '%s' but got '%s'`, command, p.config.ExecuteCommand)) } @@ -483,7 +486,8 @@ func TestProvisionerProvision_Inline(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/inlineScript.ps1'; exit \$LastExitCode }"`) + re := regexp.MustCompile(`powershell -executionpolicy bypass -file c:/Windows/Temp/inlineScript.ps1`) + matched := re.MatchString(cmd) if !matched { t.Fatalf("Got unexpected command: %s", cmd) @@ -503,7 +507,7 @@ func TestProvisionerProvision_Inline(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/inlineScript.ps1'; exit \$LastExitCode }"`) + re = regexp.MustCompile(`powershell -executionpolicy bypass -file c:/Windows/Temp/inlineScript.ps1`) matched = re.MatchString(cmd) if !matched { t.Fatalf("Got unexpected command: %s", cmd) @@ -533,7 +537,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 -executionpolicy bypass -file c:/Windows/Temp/script.ps1`) matched := re.MatchString(cmd) if !matched { t.Fatalf("Got unexpected command: %s", cmd) @@ -570,7 +574,7 @@ func TestProvisionerProvision_ScriptsWithEnvVars(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 -executionpolicy bypass -file c:/Windows/Temp/script.ps1`) matched := re.MatchString(cmd) if !matched { t.Fatalf("Got unexpected command: %s", cmd) @@ -595,11 +599,11 @@ func TestProvisionerProvision_SkipClean(t *testing.T) { }{ { SkipClean: true, - LastExecutedCommandRegex: `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 }"`, + LastExecutedCommandRegex: `powershell -executionpolicy bypass -file c:/Windows/Temp/script.ps1`, }, { SkipClean: false, - LastExecutedCommandRegex: `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/packer-cleanup-[[:alnum:]]{8}-[[:alnum:]]{4}-[[:alnum:]]{4}-[[:alnum:]]{4}-[[:alnum:]]{12}\.ps1'; exit \$LastExitCode }"`, + LastExecutedCommandRegex: `powershell -executionpolicy bypass -file c:/Windows/Temp/packer-cleanup-[[:alnum:]]{8}-[[:alnum:]]{4}-[[:alnum:]]{4}-[[:alnum:]]{4}-[[:alnum:]]{12}\.ps1`, }, } @@ -917,7 +921,7 @@ func TestProvision_createCommandText(t *testing.T) { p.generatedData = make(map[string]interface{}) cmd, _ := p.createCommandText() - 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 -executionpolicy bypass -file c:/Windows/Temp/script.ps1`) matched := re.MatchString(cmd) if !matched { t.Fatalf("Got unexpected command: %s", cmd) @@ -934,6 +938,28 @@ func TestProvision_createCommandText(t *testing.T) { } } +func TestProvision_createCommandTextNoneExecutionPolicy(t *testing.T) { + config := testConfig() + config["remote_path"] = "c:/Windows/Temp/script.ps1" + p := new(Provisioner) + + comm := new(packersdk.MockCommunicator) + p.communicator = comm + config["execution_policy"] = ExecutionPolicyNone + _ = p.Prepare(config) + + // Non-elevated + p.generatedData = make(map[string]interface{}) + + cmd, _ := p.createCommandText() + re := regexp.MustCompile(`-file c:/Windows/Temp/script.ps1`) + matched := re.MatchString(cmd) + if !matched { + t.Fatalf("Got unexpected command: %s", cmd) + } + +} + func TestProvision_uploadEnvVars(t *testing.T) { p := new(Provisioner) comm := new(packersdk.MockCommunicator) @@ -976,3 +1002,18 @@ func generatedData() map[string]interface{} { "PackerHTTPPort": commonsteps.HttpPortNotImplemented, } } + +func normalizeWhiteSpace(s string) string { + // Replace multiple spaces/tabs with a single space + re := regexp.MustCompile(`[\t ]+`) + s = re.ReplaceAllString(s, " ") + + // Trim leading/trailing spaces and newlines + s = strings.TrimSpace(s) + + // Normalize line breaks (remove excessive empty lines) + s = strings.ReplaceAll(s, "\r\n", "\n") // Convert Windows line endings to Unix + s = strings.ReplaceAll(s, "\r", "\n") // Convert old Mac line endings to Unix + + return s +} diff --git a/provisioner/powershell/test-fixtures/powershell-exit_codes-provisioner.txt b/provisioner/powershell/test-fixtures/powershell-exit_codes-provisioner.txt new file mode 100644 index 000000000..f0445951d --- /dev/null +++ b/provisioner/powershell/test-fixtures/powershell-exit_codes-provisioner.txt @@ -0,0 +1,76 @@ +{ + "type": "powershell", + "inline": ["invalid-cmdlet"], + "valid_exit_codes": ["1"] +}, +{ + "type": "powershell", + "inline": ["#Requires -Version 10.0"], + "valid_exit_codes": ["1"] +}, +{ + "type": "powershell", + "inline": ["exit 1"], + "valid_exit_codes": ["1"] +}, +{ + "type": "powershell", + "inline": ["}}"], + "valid_exit_codes": ["1"] +}, +{ + "type": "powershell", + "inline": ["$LASTEXITCODE=1"], + "valid_exit_codes": ["1"] +}, +{ + "type": "powershell", + "inline": ["throw 'XXX'"], + "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": "ping invalidhost", + "valid_exit_codes": ["1"] +}, +{ + "type": "powershell", + "inline": "sc.exe start command", + "valid_exit_codes": ["1060"] +}, +{ + "type": "powershell", + "inline": "echo 'Hi testing echo'; invalid command!; echo 'Another valid command';", + "valid_exit_codes": ["1"] +}, +{ + "type": "powershell", + "inline": ["$ErrorActionPreference='Stop'", "Get-Item 'C:\\nonexistent.txt'"], + "valid_exit_codes": ["1"] +}, +{ + "type": "powershell", + "inline": [ + "try {", + " invalid command", + "} catch {", + " exit 1", + "}" + ], + "valid_exit_codes": ["1"] +} + + + diff --git a/provisioner/powershell/test-fixtures/scripts/bootstrap_win.txt b/provisioner/powershell/test-fixtures/scripts/bootstrap_win.txt new file mode 100644 index 000000000..ea3aedf70 --- /dev/null +++ b/provisioner/powershell/test-fixtures/scripts/bootstrap_win.txt @@ -0,0 +1,40 @@ + +# Set administrator password +net user Administrator SuperS3cr3t!!!! +wmic useraccount where "name='Administrator'" set PasswordExpires=FALSE + +# First, make sure WinRM can't be connected to +netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new enable=yes action=block + +# Delete any existing WinRM listeners +winrm delete winrm/config/listener?Address=*+Transport=HTTP 2>$Null +winrm delete winrm/config/listener?Address=*+Transport=HTTPS 2>$Null + +# Disable group policies which block basic authentication and unencrypted login + +Set-ItemProperty -Path HKLM:\Software\Policies\Microsoft\Windows\WinRM\Client -Name AllowBasic -Value 1 +Set-ItemProperty -Path HKLM:\Software\Policies\Microsoft\Windows\WinRM\Client -Name AllowUnencryptedTraffic -Value 1 +Set-ItemProperty -Path HKLM:\Software\Policies\Microsoft\Windows\WinRM\Service -Name AllowBasic -Value 1 +Set-ItemProperty -Path HKLM:\Software\Policies\Microsoft\Windows\WinRM\Service -Name AllowUnencryptedTraffic -Value 1 + + +# Create a new WinRM listener and configure +winrm create winrm/config/listener?Address=*+Transport=HTTP +winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="0"}' +winrm set winrm/config '@{MaxTimeoutms="7200000"}' +winrm set winrm/config/service '@{AllowUnencrypted="true"}' +winrm set winrm/config/service '@{MaxConcurrentOperationsPerUser="12000"}' +winrm set winrm/config/service/auth '@{Basic="true"}' +winrm set winrm/config/client/auth '@{Basic="true"}' + +# Configure UAC to allow privilege elevation in remote shells +$Key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' +$Setting = 'LocalAccountTokenFilterPolicy' +Set-ItemProperty -Path $Key -Name $Setting -Value 1 -Force + +# Configure and restart the WinRM Service; Enable the required firewall exception +Stop-Service -Name WinRM +Set-Service -Name WinRM -StartupType Automatic +netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new action=allow localip=any remoteip=any +Start-Service -Name WinRM + \ No newline at end of file diff --git a/provisioner/powershell/test-fixtures/scripts/set_version_latest.ps1 b/provisioner/powershell/test-fixtures/scripts/set_version_latest.ps1 new file mode 100644 index 000000000..f548cea7a --- /dev/null +++ b/provisioner/powershell/test-fixtures/scripts/set_version_latest.ps1 @@ -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" +} \ No newline at end of file