packer/provisioner/shell/provisioner.go
hashicorp-copywrite[bot] 19055df3ec
[COMPLIANCE] License changes (#12568)
* Updating the license from MPL to Business Source License

Going forward, this project will be licensed under the Business Source License v1.1. Please see our blog post for more details at https://hashi.co/bsl-blog, FAQ at https://hashi.co/license-faq, and details of the license at www.hashicorp.com/bsl.

* Update copyright file headers to BUSL-1.1

---------

Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2023-08-10 15:53:29 -07:00

482 lines
13 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:generate packer-sdc mapstructure-to-hcl2 -type Config
// This package implements a provisioner for Packer that executes
// shell scripts within the remote machine.
package shell
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log"
"math/rand"
"os"
"sort"
"strings"
"time"
"github.com/hashicorp/hcl/v2/hcldec"
"github.com/hashicorp/packer-plugin-sdk/multistep/commonsteps"
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
"github.com/hashicorp/packer-plugin-sdk/retry"
"github.com/hashicorp/packer-plugin-sdk/shell"
"github.com/hashicorp/packer-plugin-sdk/template/config"
"github.com/hashicorp/packer-plugin-sdk/template/interpolate"
"github.com/hashicorp/packer-plugin-sdk/tmp"
)
type Config struct {
shell.Provisioner `mapstructure:",squash"`
shell.ProvisionerRemoteSpecific `mapstructure:",squash"`
// The shebang value used when running inline scripts.
InlineShebang string `mapstructure:"inline_shebang"`
// A duration of how long to pause after the provisioner
PauseAfter time.Duration `mapstructure:"pause_after"`
// Write the Vars to a file and source them from there rather than declaring
// inline
UseEnvVarFile bool `mapstructure:"use_env_var_file"`
// The remote folder where the local shell script will be uploaded to.
// This should be set to a pre-existing directory, it defaults to /tmp
RemoteFolder string `mapstructure:"remote_folder"`
// The remote file name of the local shell script.
// This defaults to script_nnn.sh
RemoteFile string `mapstructure:"remote_file"`
// 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"`
// Whether to clean scripts up
SkipClean bool `mapstructure:"skip_clean"`
ExpectDisconnect bool `mapstructure:"expect_disconnect"`
// name of the tmp environment variable file, if UseEnvVarFile is true
envVarFile string
ctx interpolate.Context
}
type Provisioner struct {
config Config
generatedData map[string]interface{}
}
func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec { return p.config.FlatMapstructure().HCL2Spec() }
func (p *Provisioner) Prepare(raws ...interface{}) error {
err := config.Decode(&p.config, &config.DecodeOpts{
PluginType: "shell",
Interpolate: true,
InterpolateContext: &p.config.ctx,
InterpolateFilter: &interpolate.RenderFilter{
Exclude: []string{
"execute_command",
},
},
}, raws...)
if err != nil {
return err
}
if p.config.EnvVarFormat == "" {
p.config.EnvVarFormat = "%s='%s' "
if p.config.UseEnvVarFile == true {
p.config.EnvVarFormat = "export %s='%s'\n"
}
}
if p.config.ExecuteCommand == "" {
p.config.ExecuteCommand = "chmod +x {{.Path}}; {{.Vars}} {{.Path}}"
if p.config.UseEnvVarFile == true {
p.config.ExecuteCommand = "chmod +x {{.Path}}; . {{.EnvVarFile}} && {{.Path}}"
}
}
if p.config.Inline != nil && len(p.config.Inline) == 0 {
p.config.Inline = nil
}
if p.config.InlineShebang == "" {
p.config.InlineShebang = "/bin/sh -e"
}
if p.config.StartRetryTimeout == 0 {
p.config.StartRetryTimeout = 5 * time.Minute
}
if p.config.RemoteFolder == "" {
p.config.RemoteFolder = "/tmp"
}
if p.config.RemoteFile == "" {
p.config.RemoteFile = fmt.Sprintf("script_%d.sh", rand.Intn(9999))
}
if p.config.RemotePath == "" {
p.config.RemotePath = fmt.Sprintf("%s/%s", p.config.RemoteFolder, p.config.RemoteFile)
}
if p.config.Scripts == nil {
p.config.Scripts = make([]string, 0)
}
if p.config.Vars == nil {
p.config.Vars = make([]string, 0)
}
var errs *packersdk.MultiError
if p.config.Script != "" && len(p.config.Scripts) > 0 {
errs = packersdk.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 = packersdk.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 = packersdk.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 = packersdk.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 = packersdk.MultiErrorAppend(errs,
fmt.Errorf("Environment variable not in format 'key=value': %s", kv))
}
}
if errs != nil && len(errs.Errors) > 0 {
return errs
}
return nil
}
func (p *Provisioner) Provision(ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, generatedData map[string]interface{}) error {
if generatedData == nil {
generatedData = make(map[string]interface{})
}
p.generatedData = generatedData
scripts := make([]string, len(p.config.Scripts))
copy(scripts, p.config.Scripts)
// If we have an inline script, then turn that into a temporary
// shell script and use that.
if p.config.Inline != nil {
tf, err := tmp.File("packer-shell")
if err != nil {
return fmt.Errorf("Error preparing shell script: %s", err)
}
defer os.Remove(tf.Name())
// Set the path to the temporary file
scripts = append(scripts, tf.Name())
// Write our contents to it
writer := bufio.NewWriter(tf)
writer.WriteString(fmt.Sprintf("#!%s\n", p.config.InlineShebang))
for _, command := range p.config.Inline {
p.config.ctx.Data = generatedData
command, err := interpolate.Render(command, &p.config.ctx)
if err != nil {
return fmt.Errorf("Error interpolating Inline: %s", err)
}
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)
}
tf.Close()
}
if p.config.UseEnvVarFile == true {
tf, err := tmp.File("packer-shell-vars")
if err != nil {
return fmt.Errorf("Error preparing shell script: %s", err)
}
defer os.Remove(tf.Name())
// Write our contents to it
writer := bufio.NewWriter(tf)
if _, err := writer.WriteString(p.createEnvVarFileContent()); 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)
}
p.config.envVarFile = tf.Name()
// upload the var file
var cmd *packersdk.RemoteCmd
err = retry.Config{StartTimeout: p.config.StartRetryTimeout}.Run(ctx, func(ctx context.Context) error {
if _, err := tf.Seek(0, 0); err != nil {
return err
}
var r io.Reader = tf
if !p.config.Binary {
r = &UnixReader{Reader: r}
}
remoteVFName := fmt.Sprintf("%s/%s", p.config.RemoteFolder,
fmt.Sprintf("varfile_%d.sh", rand.Intn(9999)))
if err := comm.Upload(remoteVFName, r, nil); err != nil {
return fmt.Errorf("Error uploading envVarFile: %s", err)
}
tf.Close()
cmd = &packersdk.RemoteCmd{
Command: fmt.Sprintf("chmod 0600 %s", remoteVFName),
}
if err := comm.Start(ctx, cmd); err != nil {
return fmt.Errorf("Error chmodding script file to 0600 in remote machine: %s", err)
}
cmd.Wait()
p.config.envVarFile = remoteVFName
return nil
})
if err != nil {
return err
}
}
// Create environment variables to set before executing the command
flattenedEnvVars := p.createFlattenedEnvVars()
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()
// Compile the command
// These are extra variables that will be made available for interpolation.
generatedData["Vars"] = flattenedEnvVars
generatedData["EnvVarFile"] = p.config.envVarFile
generatedData["Path"] = p.config.RemotePath
p.config.ctx.Data = generatedData
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 *packersdk.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
}
var r io.Reader = f
if !p.config.Binary {
r = &UnixReader{Reader: r}
}
if err := comm.Upload(p.config.RemotePath, r, nil); err != nil {
return fmt.Errorf("Error uploading script: %s", err)
}
cmd = &packersdk.RemoteCmd{
Command: fmt.Sprintf("chmod 0755 %s", p.config.RemotePath),
}
if err := comm.Start(ctx, cmd); err != nil {
return fmt.Errorf(
"Error chmodding script file to 0755 in remote "+
"machine: %s", err)
}
cmd.Wait()
cmd = &packersdk.RemoteCmd{Command: command}
return cmd.RunWithUi(ctx, comm, ui)
})
if err != nil {
return err
}
// If the exit code indicates a remote disconnect, fail unless
// we were expecting it.
if cmd.ExitStatus() == packersdk.CmdDisconnect {
if !p.config.ExpectDisconnect {
return fmt.Errorf("Script disconnected unexpectedly. " +
"If you expected your script to disconnect, i.e. from a " +
"restart, you can try adding `\"expect_disconnect\": true` " +
"or `\"valid_exit_codes\": [0, 2300218]` to the shell " +
"provisioner parameters.")
}
} else if err := p.config.ValidExitCode(cmd.ExitStatus()); err != nil {
return err
}
if p.config.SkipClean {
continue
}
// Delete the temporary file we created. We retry this a few times
// since if the above rebooted we have to wait until the reboot
// completes.
if err := p.cleanupRemoteFile(p.config.RemotePath, comm); err != nil {
return err
}
}
if !p.config.SkipClean {
if err := p.cleanupRemoteFile(p.config.envVarFile, comm); err != nil {
return err
}
}
if p.config.PauseAfter != 0 {
ui.Say(fmt.Sprintf("Pausing %s after this provisioner...", p.config.PauseAfter))
select {
case <-time.After(p.config.PauseAfter):
return nil
}
}
return nil
}
func (p *Provisioner) cleanupRemoteFile(path string, comm packersdk.Communicator) error {
ctx := context.TODO()
err := retry.Config{StartTimeout: p.config.StartRetryTimeout}.Run(ctx, func(ctx context.Context) error {
cmd := &packersdk.RemoteCmd{
Command: fmt.Sprintf("rm -f %s", path),
}
if err := comm.Start(ctx, cmd); err != nil {
return fmt.Errorf(
"Error removing temporary script at %s: %s",
path, err)
}
cmd.Wait()
// treat disconnects as retryable by returning an error
if cmd.ExitStatus() == packersdk.CmdDisconnect {
return fmt.Errorf("Disconnect while removing temporary script.")
}
if cmd.ExitStatus() != 0 {
return fmt.Errorf(
"Error removing temporary script at %s!",
path)
}
return nil
})
if err != nil {
return err
}
return nil
}
func (p *Provisioner) escapeEnvVars() ([]string, map[string]string) {
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
// expose ip address variables
httpAddr := p.generatedData["PackerHTTPAddr"]
if httpAddr != nil && httpAddr != commonsteps.HttpAddrNotImplemented {
envVars["PACKER_HTTP_ADDR"] = httpAddr.(string)
}
httpIP := p.generatedData["PackerHTTPIP"]
if httpIP != nil && httpIP != commonsteps.HttpIPNotImplemented {
envVars["PACKER_HTTP_IP"] = httpIP.(string)
}
httpPort := p.generatedData["PackerHTTPPort"]
if httpPort != nil && httpPort != commonsteps.HttpPortNotImplemented {
envVars["PACKER_HTTP_PORT"] = httpPort.(string)
}
// Split vars into key/value components
for _, envVar := range p.config.Vars {
keyValue := strings.SplitN(envVar, "=", 2)
// Store pair, replacing any single quotes in value so they parse
// correctly with required environment variable format
envVars[keyValue[0]] = strings.Replace(keyValue[1], "'", `'"'"'`, -1)
}
// Add the environment variables defined in the HCL specs
for k, v := range p.config.Env {
// As with p.config.Vars, we escape single-quotes so they're not
// misinterpreted by the remote shell.
envVars[k] = strings.Replace(v, "'", `'"'"'`, -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)
return keys, envVars
}
func (p *Provisioner) createEnvVarFileContent() string {
keys, envVars := p.escapeEnvVars()
var flattened string
for _, key := range keys {
flattened += fmt.Sprintf(p.config.EnvVarFormat, key, envVars[key])
}
return flattened
}
func (p *Provisioner) createFlattenedEnvVars() string {
keys, envVars := p.escapeEnvVars()
// Re-assemble vars into specified format and flatten
var flattened string
for _, key := range keys {
flattened += fmt.Sprintf(p.config.EnvVarFormat, key, envVars[key])
}
return flattened
}