github.com/hashicorp/packer@v1.14.3/provisioner/shell/provisioner.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: BUSL-1.1 3 4 //go:generate packer-sdc mapstructure-to-hcl2 -type Config 5 6 // This package implements a provisioner for Packer that executes 7 // shell scripts within the remote machine. 8 package shell 9 10 import ( 11 "bufio" 12 "context" 13 "errors" 14 "fmt" 15 "io" 16 "log" 17 "math/rand" 18 "os" 19 "sort" 20 "strings" 21 "time" 22 23 "github.com/hashicorp/hcl/v2/hcldec" 24 "github.com/hashicorp/packer-plugin-sdk/multistep/commonsteps" 25 packersdk "github.com/hashicorp/packer-plugin-sdk/packer" 26 "github.com/hashicorp/packer-plugin-sdk/retry" 27 "github.com/hashicorp/packer-plugin-sdk/shell" 28 "github.com/hashicorp/packer-plugin-sdk/template/config" 29 "github.com/hashicorp/packer-plugin-sdk/template/interpolate" 30 "github.com/hashicorp/packer-plugin-sdk/tmp" 31 ) 32 33 type Config struct { 34 shell.Provisioner `mapstructure:",squash"` 35 36 shell.ProvisionerRemoteSpecific `mapstructure:",squash"` 37 38 // The shebang value used when running inline scripts. 39 InlineShebang string `mapstructure:"inline_shebang"` 40 41 // A duration of how long to pause after the provisioner 42 PauseAfter time.Duration `mapstructure:"pause_after"` 43 44 // Write the Vars to a file and source them from there rather than declaring 45 // inline 46 UseEnvVarFile bool `mapstructure:"use_env_var_file"` 47 48 // The remote folder where the local shell script will be uploaded to. 49 // This should be set to a pre-existing directory, it defaults to /tmp 50 RemoteFolder string `mapstructure:"remote_folder"` 51 52 // The remote file name of the local shell script. 53 // This defaults to script_nnn.sh 54 RemoteFile string `mapstructure:"remote_file"` 55 56 // The timeout for retrying to start the process. Until this timeout 57 // is reached, if the provisioner can't start a process, it retries. 58 // This can be set high to allow for reboots. 59 StartRetryTimeout time.Duration `mapstructure:"start_retry_timeout"` 60 61 // Whether to clean scripts up 62 SkipClean bool `mapstructure:"skip_clean"` 63 64 ExpectDisconnect bool `mapstructure:"expect_disconnect"` 65 66 // name of the tmp environment variable file, if UseEnvVarFile is true 67 envVarFile string 68 69 // Set true if user provided a shebang for inline scripts. 70 // This is used to determine if the default shebang must be used 71 // or should be taken from inline commands 72 inlineShebangDefined bool 73 74 ctx interpolate.Context 75 } 76 77 type Provisioner struct { 78 config Config 79 generatedData map[string]interface{} 80 } 81 82 func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec { return p.config.FlatMapstructure().HCL2Spec() } 83 84 func (p *Provisioner) Prepare(raws ...interface{}) error { 85 err := config.Decode(&p.config, &config.DecodeOpts{ 86 PluginType: "shell", 87 Interpolate: true, 88 InterpolateContext: &p.config.ctx, 89 InterpolateFilter: &interpolate.RenderFilter{ 90 Exclude: []string{ 91 "execute_command", 92 }, 93 }, 94 }, raws...) 95 96 if err != nil { 97 return err 98 } 99 100 if p.config.EnvVarFormat == "" { 101 p.config.EnvVarFormat = "%s='%s' " 102 103 if p.config.UseEnvVarFile == true { 104 p.config.EnvVarFormat = "export %s='%s'\n" 105 } 106 } 107 108 if p.config.ExecuteCommand == "" { 109 p.config.ExecuteCommand = "chmod +x {{.Path}}; {{.Vars}} {{.Path}}" 110 if p.config.UseEnvVarFile == true { 111 p.config.ExecuteCommand = "chmod +x {{.Path}}; . {{.EnvVarFile}} && {{.Path}}" 112 } 113 } 114 115 if p.config.Inline != nil && len(p.config.Inline) == 0 { 116 p.config.Inline = nil 117 } 118 119 if p.config.InlineShebang == "" { 120 p.config.InlineShebang = "/bin/sh -e" 121 } else { 122 p.config.inlineShebangDefined = true 123 } 124 125 if p.config.StartRetryTimeout == 0 { 126 p.config.StartRetryTimeout = 5 * time.Minute 127 } 128 129 if p.config.RemoteFolder == "" { 130 p.config.RemoteFolder = "/tmp" 131 } 132 133 if p.config.RemoteFile == "" { 134 p.config.RemoteFile = fmt.Sprintf("script_%d.sh", rand.Intn(9999)) 135 } 136 137 if p.config.RemotePath == "" { 138 p.config.RemotePath = fmt.Sprintf("%s/%s", p.config.RemoteFolder, p.config.RemoteFile) 139 } 140 141 if p.config.Scripts == nil { 142 p.config.Scripts = make([]string, 0) 143 } 144 145 if p.config.Vars == nil { 146 p.config.Vars = make([]string, 0) 147 } 148 149 var errs *packersdk.MultiError 150 if p.config.Script != "" && len(p.config.Scripts) > 0 { 151 errs = packersdk.MultiErrorAppend(errs, 152 errors.New("Only one of script or scripts can be specified.")) 153 } 154 155 if p.config.Script != "" { 156 p.config.Scripts = []string{p.config.Script} 157 } 158 159 if len(p.config.Scripts) == 0 && p.config.Inline == nil { 160 errs = packersdk.MultiErrorAppend(errs, 161 errors.New("Either a script file or inline script must be specified.")) 162 } else if len(p.config.Scripts) > 0 && p.config.Inline != nil { 163 errs = packersdk.MultiErrorAppend(errs, 164 errors.New("Only a script file or an inline script can be specified, not both.")) 165 } 166 167 for _, path := range p.config.Scripts { 168 if _, err := os.Stat(path); err != nil { 169 errs = packersdk.MultiErrorAppend(errs, 170 fmt.Errorf("Bad script '%s': %s", path, err)) 171 } 172 } 173 174 // Do a check for bad environment variables, such as '=foo', 'foobar' 175 for _, kv := range p.config.Vars { 176 vs := strings.SplitN(kv, "=", 2) 177 if len(vs) != 2 || vs[0] == "" { 178 errs = packersdk.MultiErrorAppend(errs, 179 fmt.Errorf("Environment variable not in format 'key=value': %s", kv)) 180 } 181 } 182 183 if errs != nil && len(errs.Errors) > 0 { 184 return errs 185 } 186 187 return nil 188 } 189 190 func (p *Provisioner) Provision(ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, generatedData map[string]interface{}) error { 191 if generatedData == nil { 192 generatedData = make(map[string]interface{}) 193 } 194 p.generatedData = generatedData 195 196 scripts := make([]string, len(p.config.Scripts)) 197 copy(scripts, p.config.Scripts) 198 199 // If we have an inline script, then turn that into a temporary 200 // shell script and use that. 201 if p.config.Inline != nil { 202 tf, err := tmp.File("packer-shell") 203 if err != nil { 204 return fmt.Errorf("Error preparing shell script: %s", err) 205 } 206 defer os.Remove(tf.Name()) 207 208 // Set the path to the temporary file 209 scripts = append(scripts, tf.Name()) 210 211 // Write all inline commands to this buffer 212 commandBuffer := strings.Builder{} 213 for _, command := range p.config.Inline { 214 p.config.ctx.Data = generatedData 215 command, err := interpolate.Render(command, &p.config.ctx) 216 if err != nil { 217 return fmt.Errorf("Error interpolating Inline: %s", err) 218 } 219 if _, err := fmt.Fprintf(&commandBuffer, "%s\n", command); err != nil { 220 return fmt.Errorf("Error preparing shell script: %s", err) 221 } 222 } 223 224 // If the user has defined an inline shebang, use that. 225 // Or If command does not start with a shebang, use the default shebang. 226 // else command already has a shebang, so do not write it. 227 if p.config.inlineShebangDefined || !strings.HasPrefix(commandBuffer.String(), "#!") { 228 if _, err := fmt.Fprintf(tf, "#!%s\n", p.config.InlineShebang); err != nil { 229 return fmt.Errorf("Error preparing shell script: %s", err) 230 } 231 } 232 233 // Write the collected commands to the file 234 if _, err := tf.WriteString(commandBuffer.String()); err != nil { 235 return fmt.Errorf("Error preparing shell script: %s", err) 236 } 237 238 tf.Close() 239 } 240 241 if p.config.UseEnvVarFile == true { 242 tf, err := tmp.File("packer-shell-vars") 243 if err != nil { 244 return fmt.Errorf("Error preparing shell script: %s", err) 245 } 246 defer os.Remove(tf.Name()) 247 248 // Write our contents to it 249 writer := bufio.NewWriter(tf) 250 if _, err := writer.WriteString(p.createEnvVarFileContent()); err != nil { 251 return fmt.Errorf("Error preparing shell script: %s", err) 252 } 253 254 if err := writer.Flush(); err != nil { 255 return fmt.Errorf("Error preparing shell script: %s", err) 256 } 257 258 p.config.envVarFile = tf.Name() 259 260 // upload the var file 261 var cmd *packersdk.RemoteCmd 262 err = retry.Config{StartTimeout: p.config.StartRetryTimeout}.Run(ctx, func(ctx context.Context) error { 263 if _, err := tf.Seek(0, 0); err != nil { 264 return err 265 } 266 267 var r io.Reader = tf 268 if !p.config.Binary { 269 r = &UnixReader{Reader: r} 270 } 271 remoteVFName := fmt.Sprintf("%s/%s", p.config.RemoteFolder, 272 fmt.Sprintf("varfile_%d.sh", rand.Intn(9999))) 273 if err := comm.Upload(remoteVFName, r, nil); err != nil { 274 return fmt.Errorf("Error uploading envVarFile: %s", err) 275 } 276 tf.Close() 277 278 cmd = &packersdk.RemoteCmd{ 279 Command: fmt.Sprintf("chmod 0600 %s", remoteVFName), 280 } 281 if err := comm.Start(ctx, cmd); err != nil { 282 return fmt.Errorf("Error chmodding script file to 0600 in remote machine: %s", err) 283 } 284 cmd.Wait() 285 p.config.envVarFile = remoteVFName 286 return nil 287 }) 288 if err != nil { 289 return err 290 } 291 } 292 293 // Create environment variables to set before executing the command 294 flattenedEnvVars := p.createFlattenedEnvVars() 295 296 for _, path := range scripts { 297 ui.Say(fmt.Sprintf("Provisioning with shell script: %s", path)) 298 299 log.Printf("Opening %s for reading", path) 300 f, err := os.Open(path) 301 if err != nil { 302 return fmt.Errorf("Error opening shell script: %s", err) 303 } 304 defer f.Close() 305 306 // Compile the command 307 // These are extra variables that will be made available for interpolation. 308 generatedData["Vars"] = flattenedEnvVars 309 generatedData["EnvVarFile"] = p.config.envVarFile 310 generatedData["Path"] = p.config.RemotePath 311 p.config.ctx.Data = generatedData 312 313 command, err := interpolate.Render(p.config.ExecuteCommand, &p.config.ctx) 314 if err != nil { 315 return fmt.Errorf("Error processing command: %s", err) 316 } 317 318 // Upload the file and run the command. Do this in the context of 319 // a single retryable function so that we don't end up with 320 // the case that the upload succeeded, a restart is initiated, 321 // and then the command is executed but the file doesn't exist 322 // any longer. 323 var cmd *packersdk.RemoteCmd 324 err = retry.Config{StartTimeout: p.config.StartRetryTimeout}.Run(ctx, func(ctx context.Context) error { 325 if _, err := f.Seek(0, 0); err != nil { 326 return err 327 } 328 329 var r io.Reader = f 330 if !p.config.Binary { 331 r = &UnixReader{Reader: r} 332 } 333 334 if err := comm.Upload(p.config.RemotePath, r, nil); err != nil { 335 return fmt.Errorf("Error uploading script: %s", err) 336 } 337 338 cmd = &packersdk.RemoteCmd{ 339 Command: fmt.Sprintf("chmod 0755 %s", p.config.RemotePath), 340 } 341 if err := comm.Start(ctx, cmd); err != nil { 342 return fmt.Errorf( 343 "Error chmodding script file to 0755 in remote "+ 344 "machine: %s", err) 345 } 346 cmd.Wait() 347 348 cmd = &packersdk.RemoteCmd{Command: command} 349 return cmd.RunWithUi(ctx, comm, ui) 350 }) 351 352 if err != nil { 353 return err 354 } 355 356 // If the exit code indicates a remote disconnect, fail unless 357 // we were expecting it. 358 if cmd.ExitStatus() == packersdk.CmdDisconnect { 359 if !p.config.ExpectDisconnect { 360 return fmt.Errorf("Script disconnected unexpectedly. " + 361 "If you expected your script to disconnect, i.e. from a " + 362 "restart, you can try adding `\"expect_disconnect\": true` " + 363 "or `\"valid_exit_codes\": [0, 2300218]` to the shell " + 364 "provisioner parameters.") 365 } 366 } else if err := p.config.ValidExitCode(cmd.ExitStatus()); err != nil { 367 return err 368 } 369 370 if p.config.SkipClean { 371 continue 372 } 373 374 // Delete the temporary file we created. We retry this a few times 375 // since if the above rebooted we have to wait until the reboot 376 // completes. 377 if err := p.cleanupRemoteFile(p.config.RemotePath, comm); err != nil { 378 return err 379 } 380 381 } 382 383 if !p.config.SkipClean { 384 if err := p.cleanupRemoteFile(p.config.envVarFile, comm); err != nil { 385 return err 386 } 387 } 388 389 if p.config.PauseAfter != 0 { 390 ui.Say(fmt.Sprintf("Pausing %s after this provisioner...", p.config.PauseAfter)) 391 time.Sleep(p.config.PauseAfter) 392 } 393 394 return nil 395 } 396 397 func (p *Provisioner) cleanupRemoteFile(path string, comm packersdk.Communicator) error { 398 ctx := context.TODO() 399 err := retry.Config{StartTimeout: p.config.StartRetryTimeout}.Run(ctx, func(ctx context.Context) error { 400 cmd := &packersdk.RemoteCmd{ 401 Command: fmt.Sprintf("rm -f %s", path), 402 } 403 if err := comm.Start(ctx, cmd); err != nil { 404 return fmt.Errorf( 405 "Error removing temporary script at %s: %s", 406 path, err) 407 } 408 cmd.Wait() 409 // treat disconnects as retryable by returning an error 410 if cmd.ExitStatus() == packersdk.CmdDisconnect { 411 return fmt.Errorf("Disconnect while removing temporary script.") 412 } 413 if cmd.ExitStatus() != 0 { 414 return fmt.Errorf( 415 "Error removing temporary script at %s!", 416 path) 417 } 418 return nil 419 }) 420 421 if err != nil { 422 return err 423 } 424 425 return nil 426 } 427 428 func (p *Provisioner) escapeEnvVars() ([]string, map[string]string) { 429 envVars := make(map[string]string) 430 431 // Always available Packer provided env vars 432 envVars["PACKER_BUILD_NAME"] = p.config.PackerBuildName 433 envVars["PACKER_BUILDER_TYPE"] = p.config.PackerBuilderType 434 435 // expose ip address variables 436 httpAddr := p.generatedData["PackerHTTPAddr"] 437 if httpAddr != nil && httpAddr != commonsteps.HttpAddrNotImplemented { 438 envVars["PACKER_HTTP_ADDR"] = httpAddr.(string) 439 } 440 httpIP := p.generatedData["PackerHTTPIP"] 441 if httpIP != nil && httpIP != commonsteps.HttpIPNotImplemented { 442 envVars["PACKER_HTTP_IP"] = httpIP.(string) 443 } 444 httpPort := p.generatedData["PackerHTTPPort"] 445 if httpPort != nil && httpPort != commonsteps.HttpPortNotImplemented { 446 envVars["PACKER_HTTP_PORT"] = httpPort.(string) 447 } 448 449 // Split vars into key/value components 450 for _, envVar := range p.config.Vars { 451 keyValue := strings.SplitN(envVar, "=", 2) 452 // Store pair, replacing any single quotes in value so they parse 453 // correctly with required environment variable format 454 envVars[keyValue[0]] = strings.Replace(keyValue[1], "'", `'"'"'`, -1) 455 } 456 457 // Add the environment variables defined in the HCL specs 458 for k, v := range p.config.Env { 459 // As with p.config.Vars, we escape single-quotes so they're not 460 // misinterpreted by the remote shell. 461 envVars[k] = strings.Replace(v, "'", `'"'"'`, -1) 462 } 463 464 // Create a list of env var keys in sorted order 465 var keys []string 466 for k := range envVars { 467 keys = append(keys, k) 468 } 469 sort.Strings(keys) 470 471 return keys, envVars 472 } 473 474 func (p *Provisioner) createEnvVarFileContent() string { 475 keys, envVars := p.escapeEnvVars() 476 477 var flattened string 478 for _, key := range keys { 479 flattened += fmt.Sprintf(p.config.EnvVarFormat, key, envVars[key]) 480 } 481 482 return flattened 483 } 484 485 func (p *Provisioner) createFlattenedEnvVars() string { 486 keys, envVars := p.escapeEnvVars() 487 488 // Re-assemble vars into specified format and flatten 489 var flattened string 490 for _, key := range keys { 491 flattened += fmt.Sprintf(p.config.EnvVarFormat, key, envVars[key]) 492 } 493 494 return flattened 495 }