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  }