diff --git a/provisioner/powershell/provisioner.go b/provisioner/powershell/provisioner.go index 70b7105dacf..5cc1d6c2dae 100644 --- a/provisioner/powershell/provisioner.go +++ b/provisioner/powershell/provisioner.go @@ -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)) - } - - if errs != nil { - return errs + 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)) } - 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) + } + + // 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) + } - p.config.ctx.Data = data - return interpolate.Render(p.config.ExecuteCommand, &p.config.ctx) + 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 } diff --git a/provisioner/powershell/provisioner.hcl2spec.go b/provisioner/powershell/provisioner.hcl2spec.go index 410955b9cb9..f7773f1e748 100644 --- a/provisioner/powershell/provisioner.hcl2spec.go +++ b/provisioner/powershell/provisioner.hcl2spec.go @@ -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 } diff --git a/provisioner/powershell/provisioner_acc_test.go b/provisioner/powershell/provisioner_acc_test.go index 846ab0c3d8f..3a5cbef75c2 100644 --- a/provisioner/powershell/provisioner_acc_test.go +++ b/provisioner/powershell/provisioner_acc_test.go @@ -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 } diff --git a/provisioner/powershell/provisioner_test.go b/provisioner/powershell/provisioner_test.go index 4def2785cd2..36e908931a7 100644 --- a/provisioner/powershell/provisioner_test.go +++ b/provisioner/powershell/provisioner_test.go @@ -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 { 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 00000000000..fd2e4286008 --- /dev/null +++ b/provisioner/powershell/test-fixtures/powershell-exit_codes-provisioner.txt @@ -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"] +} 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 00000000000..e6483b2fae1 --- /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" +}