github.com/rstandt/terraform@v0.12.32-0.20230710220336-b1063613405c/builtin/provisioners/salt-masterless/resource_provisioner.go (about)

     1  // This package implements a provisioner for Terraform that executes a
     2  // saltstack state within the remote machine
     3  //
     4  // Adapted from gitub.com/hashicorp/packer/provisioner/salt-masterless
     5  
     6  package saltmasterless
     7  
     8  import (
     9  	"bytes"
    10  	"context"
    11  	"errors"
    12  	"fmt"
    13  	"io"
    14  	"os"
    15  	"path/filepath"
    16  
    17  	"github.com/hashicorp/terraform/communicator"
    18  	"github.com/hashicorp/terraform/communicator/remote"
    19  	"github.com/hashicorp/terraform/helper/schema"
    20  	"github.com/hashicorp/terraform/terraform"
    21  	linereader "github.com/mitchellh/go-linereader"
    22  )
    23  
    24  type provisionFn func(terraform.UIOutput, communicator.Communicator) error
    25  
    26  type provisioner struct {
    27  	SkipBootstrap     bool
    28  	BootstrapArgs     string
    29  	LocalStateTree    string
    30  	DisableSudo       bool
    31  	CustomState       string
    32  	MinionConfig      string
    33  	LocalPillarRoots  string
    34  	RemoteStateTree   string
    35  	RemotePillarRoots string
    36  	TempConfigDir     string
    37  	NoExitOnFailure   bool
    38  	LogLevel          string
    39  	SaltCallArgs      string
    40  	CmdArgs           string
    41  }
    42  
    43  const DefaultStateTreeDir = "/srv/salt"
    44  const DefaultPillarRootDir = "/srv/pillar"
    45  
    46  // Provisioner returns a salt-masterless provisioner
    47  func Provisioner() terraform.ResourceProvisioner {
    48  	return &schema.Provisioner{
    49  		Schema: map[string]*schema.Schema{
    50  			"local_state_tree": &schema.Schema{
    51  				Type:     schema.TypeString,
    52  				Required: true,
    53  			},
    54  			"local_pillar_roots": &schema.Schema{
    55  				Type:     schema.TypeString,
    56  				Optional: true,
    57  			},
    58  			"remote_state_tree": &schema.Schema{
    59  				Type:     schema.TypeString,
    60  				Optional: true,
    61  				Default:  DefaultStateTreeDir,
    62  			},
    63  			"remote_pillar_roots": &schema.Schema{
    64  				Type:     schema.TypeString,
    65  				Optional: true,
    66  				Default:  DefaultPillarRootDir,
    67  			},
    68  			"temp_config_dir": &schema.Schema{
    69  				Type:     schema.TypeString,
    70  				Optional: true,
    71  				Default:  "/tmp/salt",
    72  			},
    73  			"skip_bootstrap": &schema.Schema{
    74  				Type:     schema.TypeBool,
    75  				Optional: true,
    76  			},
    77  			"no_exit_on_failure": &schema.Schema{
    78  				Type:     schema.TypeBool,
    79  				Optional: true,
    80  			},
    81  			"bootstrap_args": &schema.Schema{
    82  				Type:     schema.TypeString,
    83  				Optional: true,
    84  			},
    85  			"disable_sudo": &schema.Schema{
    86  				Type:     schema.TypeBool,
    87  				Optional: true,
    88  			},
    89  			"custom_state": &schema.Schema{
    90  				Type:     schema.TypeString,
    91  				Optional: true,
    92  			},
    93  			"minion_config_file": &schema.Schema{
    94  				Type:     schema.TypeString,
    95  				Optional: true,
    96  			},
    97  			"cmd_args": &schema.Schema{
    98  				Type:     schema.TypeString,
    99  				Optional: true,
   100  			},
   101  			"salt_call_args": &schema.Schema{
   102  				Type:     schema.TypeString,
   103  				Optional: true,
   104  			},
   105  			"log_level": &schema.Schema{
   106  				Type:     schema.TypeString,
   107  				Optional: true,
   108  			},
   109  		},
   110  
   111  		ApplyFunc:    applyFn,
   112  		ValidateFunc: validateFn,
   113  	}
   114  }
   115  
   116  // Apply executes the file provisioner
   117  func applyFn(ctx context.Context) error {
   118  	// Decode the raw config for this provisioner
   119  	o := ctx.Value(schema.ProvOutputKey).(terraform.UIOutput)
   120  	d := ctx.Value(schema.ProvConfigDataKey).(*schema.ResourceData)
   121  	connState := ctx.Value(schema.ProvRawStateKey).(*terraform.InstanceState)
   122  
   123  	p, err := decodeConfig(d)
   124  	if err != nil {
   125  		return err
   126  	}
   127  
   128  	// Get a new communicator
   129  	comm, err := communicator.New(connState)
   130  	if err != nil {
   131  		return err
   132  	}
   133  
   134  	retryCtx, cancel := context.WithTimeout(ctx, comm.Timeout())
   135  	defer cancel()
   136  
   137  	// Wait and retry until we establish the connection
   138  	err = communicator.Retry(retryCtx, func() error {
   139  		return comm.Connect(o)
   140  	})
   141  
   142  	if err != nil {
   143  		return err
   144  	}
   145  
   146  	// Wait for the context to end and then disconnect
   147  	go func() {
   148  		<-ctx.Done()
   149  		comm.Disconnect()
   150  	}()
   151  
   152  	var src, dst string
   153  
   154  	o.Output("Provisioning with Salt...")
   155  	if !p.SkipBootstrap {
   156  		cmd := &remote.Cmd{
   157  			// Fallback on wget if curl failed for any reason (such as not being installed)
   158  			Command: fmt.Sprintf("curl -L https://bootstrap.saltstack.com -o /tmp/install_salt.sh || wget -O /tmp/install_salt.sh https://bootstrap.saltstack.com"),
   159  		}
   160  		o.Output(fmt.Sprintf("Downloading saltstack bootstrap to /tmp/install_salt.sh"))
   161  		if err = comm.Start(cmd); err != nil {
   162  			err = fmt.Errorf("Unable to download Salt: %s", err)
   163  		}
   164  
   165  		if err := cmd.Wait(); err != nil {
   166  			return err
   167  		}
   168  
   169  		outR, outW := io.Pipe()
   170  		errR, errW := io.Pipe()
   171  		go copyOutput(o, outR)
   172  		go copyOutput(o, errR)
   173  		defer outW.Close()
   174  		defer errW.Close()
   175  
   176  		cmd = &remote.Cmd{
   177  			Command: fmt.Sprintf("%s /tmp/install_salt.sh %s", p.sudo("sh"), p.BootstrapArgs),
   178  			Stdout:  outW,
   179  			Stderr:  errW,
   180  		}
   181  
   182  		o.Output(fmt.Sprintf("Installing Salt with command %s", cmd.Command))
   183  		if err := comm.Start(cmd); err != nil {
   184  			return fmt.Errorf("Unable to install Salt: %s", err)
   185  		}
   186  
   187  		if err := cmd.Wait(); err != nil {
   188  			return err
   189  		}
   190  	}
   191  
   192  	o.Output(fmt.Sprintf("Creating remote temporary directory: %s", p.TempConfigDir))
   193  	if err := p.createDir(o, comm, p.TempConfigDir); err != nil {
   194  		return fmt.Errorf("Error creating remote temporary directory: %s", err)
   195  	}
   196  
   197  	if p.MinionConfig != "" {
   198  		o.Output(fmt.Sprintf("Uploading minion config: %s", p.MinionConfig))
   199  		src = p.MinionConfig
   200  		dst = filepath.ToSlash(filepath.Join(p.TempConfigDir, "minion"))
   201  		if err = p.uploadFile(o, comm, dst, src); err != nil {
   202  			return fmt.Errorf("Error uploading local minion config file to remote: %s", err)
   203  		}
   204  
   205  		// move minion config into /etc/salt
   206  		o.Output(fmt.Sprintf("Make sure directory %s exists", "/etc/salt"))
   207  		if err := p.createDir(o, comm, "/etc/salt"); err != nil {
   208  			return fmt.Errorf("Error creating remote salt configuration directory: %s", err)
   209  		}
   210  		src = filepath.ToSlash(filepath.Join(p.TempConfigDir, "minion"))
   211  		dst = "/etc/salt/minion"
   212  		if err = p.moveFile(o, comm, dst, src); err != nil {
   213  			return fmt.Errorf("Unable to move %s/minion to /etc/salt/minion: %s", p.TempConfigDir, err)
   214  		}
   215  	}
   216  
   217  	o.Output(fmt.Sprintf("Uploading local state tree: %s", p.LocalStateTree))
   218  	src = p.LocalStateTree
   219  	dst = filepath.ToSlash(filepath.Join(p.TempConfigDir, "states"))
   220  	if err = p.uploadDir(o, comm, dst, src, []string{".git"}); err != nil {
   221  		return fmt.Errorf("Error uploading local state tree to remote: %s", err)
   222  	}
   223  
   224  	// move state tree from temporary directory
   225  	src = filepath.ToSlash(filepath.Join(p.TempConfigDir, "states"))
   226  	dst = p.RemoteStateTree
   227  	if err = p.removeDir(o, comm, dst); err != nil {
   228  		return fmt.Errorf("Unable to clear salt tree: %s", err)
   229  	}
   230  	if err = p.moveFile(o, comm, dst, src); err != nil {
   231  		return fmt.Errorf("Unable to move %s/states to %s: %s", p.TempConfigDir, dst, err)
   232  	}
   233  
   234  	if p.LocalPillarRoots != "" {
   235  		o.Output(fmt.Sprintf("Uploading local pillar roots: %s", p.LocalPillarRoots))
   236  		src = p.LocalPillarRoots
   237  		dst = filepath.ToSlash(filepath.Join(p.TempConfigDir, "pillar"))
   238  		if err = p.uploadDir(o, comm, dst, src, []string{".git"}); err != nil {
   239  			return fmt.Errorf("Error uploading local pillar roots to remote: %s", err)
   240  		}
   241  
   242  		// move pillar root from temporary directory
   243  		src = filepath.ToSlash(filepath.Join(p.TempConfigDir, "pillar"))
   244  		dst = p.RemotePillarRoots
   245  
   246  		if err = p.removeDir(o, comm, dst); err != nil {
   247  			return fmt.Errorf("Unable to clear pillar root: %s", err)
   248  		}
   249  		if err = p.moveFile(o, comm, dst, src); err != nil {
   250  			return fmt.Errorf("Unable to move %s/pillar to %s: %s", p.TempConfigDir, dst, err)
   251  		}
   252  	}
   253  
   254  	outR, outW := io.Pipe()
   255  	errR, errW := io.Pipe()
   256  	go copyOutput(o, outR)
   257  	go copyOutput(o, errR)
   258  	defer outW.Close()
   259  	defer errW.Close()
   260  
   261  	o.Output(fmt.Sprintf("Running: salt-call --local %s", p.CmdArgs))
   262  	cmd := &remote.Cmd{
   263  		Command: p.sudo(fmt.Sprintf("salt-call --local %s", p.CmdArgs)),
   264  		Stdout:  outW,
   265  		Stderr:  errW,
   266  	}
   267  	if err = comm.Start(cmd); err != nil {
   268  		err = fmt.Errorf("Error executing salt-call: %s", err)
   269  	}
   270  
   271  	if err := cmd.Wait(); err != nil {
   272  		return err
   273  	}
   274  	return nil
   275  }
   276  
   277  // Prepends sudo to supplied command if config says to
   278  func (p *provisioner) sudo(cmd string) string {
   279  	if p.DisableSudo {
   280  		return cmd
   281  	}
   282  
   283  	return "sudo " + cmd
   284  }
   285  
   286  func validateDirConfig(path string, name string, required bool) error {
   287  	if required == true && path == "" {
   288  		return fmt.Errorf("%s cannot be empty", name)
   289  	} else if required == false && path == "" {
   290  		return nil
   291  	}
   292  	info, err := os.Stat(path)
   293  	if err != nil {
   294  		return fmt.Errorf("%s: path '%s' is invalid: %s", name, path, err)
   295  	} else if !info.IsDir() {
   296  		return fmt.Errorf("%s: path '%s' must point to a directory", name, path)
   297  	}
   298  	return nil
   299  }
   300  
   301  func validateFileConfig(path string, name string, required bool) error {
   302  	if required == true && path == "" {
   303  		return fmt.Errorf("%s cannot be empty", name)
   304  	} else if required == false && path == "" {
   305  		return nil
   306  	}
   307  	info, err := os.Stat(path)
   308  	if err != nil {
   309  		return fmt.Errorf("%s: path '%s' is invalid: %s", name, path, err)
   310  	} else if info.IsDir() {
   311  		return fmt.Errorf("%s: path '%s' must point to a file", name, path)
   312  	}
   313  	return nil
   314  }
   315  
   316  func (p *provisioner) uploadFile(o terraform.UIOutput, comm communicator.Communicator, dst, src string) error {
   317  	f, err := os.Open(src)
   318  	if err != nil {
   319  		return fmt.Errorf("Error opening: %s", err)
   320  	}
   321  	defer f.Close()
   322  
   323  	if err = comm.Upload(dst, f); err != nil {
   324  		return fmt.Errorf("Error uploading %s: %s", src, err)
   325  	}
   326  	return nil
   327  }
   328  
   329  func (p *provisioner) moveFile(o terraform.UIOutput, comm communicator.Communicator, dst, src string) error {
   330  	o.Output(fmt.Sprintf("Moving %s to %s", src, dst))
   331  	cmd := &remote.Cmd{Command: fmt.Sprintf(p.sudo("mv %s %s"), src, dst)}
   332  	if err := comm.Start(cmd); err != nil {
   333  		return fmt.Errorf("Unable to move %s to %s: %s", src, dst, err)
   334  	}
   335  	if err := cmd.Wait(); err != nil {
   336  		return err
   337  	}
   338  	return nil
   339  }
   340  
   341  func (p *provisioner) createDir(o terraform.UIOutput, comm communicator.Communicator, dir string) error {
   342  	o.Output(fmt.Sprintf("Creating directory: %s", dir))
   343  	cmd := &remote.Cmd{
   344  		Command: fmt.Sprintf("mkdir -p '%s'", dir),
   345  	}
   346  	if err := comm.Start(cmd); err != nil {
   347  		return err
   348  	}
   349  
   350  	if err := cmd.Wait(); err != nil {
   351  		return err
   352  	}
   353  	return nil
   354  }
   355  
   356  func (p *provisioner) removeDir(o terraform.UIOutput, comm communicator.Communicator, dir string) error {
   357  	o.Output(fmt.Sprintf("Removing directory: %s", dir))
   358  	cmd := &remote.Cmd{
   359  		Command: fmt.Sprintf("rm -rf '%s'", dir),
   360  	}
   361  	if err := comm.Start(cmd); err != nil {
   362  		return err
   363  	}
   364  	if err := cmd.Wait(); err != nil {
   365  		return err
   366  	}
   367  	return nil
   368  }
   369  
   370  func (p *provisioner) uploadDir(o terraform.UIOutput, comm communicator.Communicator, dst, src string, ignore []string) error {
   371  	if err := p.createDir(o, comm, dst); err != nil {
   372  		return err
   373  	}
   374  
   375  	// Make sure there is a trailing "/" so that the directory isn't
   376  	// created on the other side.
   377  	if src[len(src)-1] != '/' {
   378  		src = src + "/"
   379  	}
   380  	return comm.UploadDir(dst, src)
   381  }
   382  
   383  // Validate checks if the required arguments are configured
   384  func validateFn(c *terraform.ResourceConfig) (ws []string, es []error) {
   385  	// require a salt state tree
   386  	localStateTreeTmp, ok := c.Get("local_state_tree")
   387  	var localStateTree string
   388  	if !ok {
   389  		es = append(es,
   390  			errors.New("Required local_state_tree is not set"))
   391  	} else {
   392  		localStateTree = localStateTreeTmp.(string)
   393  	}
   394  	err := validateDirConfig(localStateTree, "local_state_tree", true)
   395  	if err != nil {
   396  		es = append(es, err)
   397  	}
   398  
   399  	var localPillarRoots string
   400  	localPillarRootsTmp, ok := c.Get("local_pillar_roots")
   401  	if !ok {
   402  		localPillarRoots = ""
   403  	} else {
   404  		localPillarRoots = localPillarRootsTmp.(string)
   405  	}
   406  
   407  	err = validateDirConfig(localPillarRoots, "local_pillar_roots", false)
   408  	if err != nil {
   409  		es = append(es, err)
   410  	}
   411  
   412  	var minionConfig string
   413  	minionConfigTmp, ok := c.Get("minion_config_file")
   414  	if !ok {
   415  		minionConfig = ""
   416  	} else {
   417  		minionConfig = minionConfigTmp.(string)
   418  	}
   419  	err = validateFileConfig(minionConfig, "minion_config_file", false)
   420  	if err != nil {
   421  		es = append(es, err)
   422  	}
   423  
   424  	var remoteStateTree string
   425  	remoteStateTreeTmp, ok := c.Get("remote_state_tree")
   426  	if !ok {
   427  		remoteStateTree = DefaultStateTreeDir
   428  	} else {
   429  		remoteStateTree = remoteStateTreeTmp.(string)
   430  	}
   431  
   432  	var remotePillarRoots string
   433  	remotePillarRootsTmp, ok := c.Get("remote_pillar_roots")
   434  	if !ok {
   435  		remotePillarRoots = DefaultPillarRootDir
   436  	} else {
   437  		remotePillarRoots = remotePillarRootsTmp.(string)
   438  	}
   439  
   440  	if minionConfig != "" && (remoteStateTree != DefaultStateTreeDir || remotePillarRoots != DefaultPillarRootDir) {
   441  		es = append(es,
   442  			errors.New("remote_state_tree and remote_pillar_roots only apply when minion_config_file is not used"))
   443  	}
   444  
   445  	if len(es) > 0 {
   446  		return ws, es
   447  	}
   448  
   449  	return ws, es
   450  }
   451  
   452  func decodeConfig(d *schema.ResourceData) (*provisioner, error) {
   453  	p := &provisioner{
   454  		LocalStateTree:    d.Get("local_state_tree").(string),
   455  		LogLevel:          d.Get("log_level").(string),
   456  		SaltCallArgs:      d.Get("salt_call_args").(string),
   457  		CmdArgs:           d.Get("cmd_args").(string),
   458  		MinionConfig:      d.Get("minion_config_file").(string),
   459  		CustomState:       d.Get("custom_state").(string),
   460  		DisableSudo:       d.Get("disable_sudo").(bool),
   461  		BootstrapArgs:     d.Get("bootstrap_args").(string),
   462  		NoExitOnFailure:   d.Get("no_exit_on_failure").(bool),
   463  		SkipBootstrap:     d.Get("skip_bootstrap").(bool),
   464  		TempConfigDir:     d.Get("temp_config_dir").(string),
   465  		RemotePillarRoots: d.Get("remote_pillar_roots").(string),
   466  		RemoteStateTree:   d.Get("remote_state_tree").(string),
   467  		LocalPillarRoots:  d.Get("local_pillar_roots").(string),
   468  	}
   469  
   470  	// build the command line args to pass onto salt
   471  	var cmdArgs bytes.Buffer
   472  
   473  	if p.CustomState == "" {
   474  		cmdArgs.WriteString(" state.highstate")
   475  	} else {
   476  		cmdArgs.WriteString(" state.sls ")
   477  		cmdArgs.WriteString(p.CustomState)
   478  	}
   479  
   480  	if p.MinionConfig == "" {
   481  		// pass --file-root and --pillar-root if no minion_config_file is supplied
   482  		if p.RemoteStateTree != "" {
   483  			cmdArgs.WriteString(" --file-root=")
   484  			cmdArgs.WriteString(p.RemoteStateTree)
   485  		} else {
   486  			cmdArgs.WriteString(" --file-root=")
   487  			cmdArgs.WriteString(DefaultStateTreeDir)
   488  		}
   489  		if p.RemotePillarRoots != "" {
   490  			cmdArgs.WriteString(" --pillar-root=")
   491  			cmdArgs.WriteString(p.RemotePillarRoots)
   492  		} else {
   493  			cmdArgs.WriteString(" --pillar-root=")
   494  			cmdArgs.WriteString(DefaultPillarRootDir)
   495  		}
   496  	}
   497  
   498  	if !p.NoExitOnFailure {
   499  		cmdArgs.WriteString(" --retcode-passthrough")
   500  	}
   501  
   502  	if p.LogLevel == "" {
   503  		cmdArgs.WriteString(" -l info")
   504  	} else {
   505  		cmdArgs.WriteString(" -l ")
   506  		cmdArgs.WriteString(p.LogLevel)
   507  	}
   508  
   509  	if p.SaltCallArgs != "" {
   510  		cmdArgs.WriteString(" ")
   511  		cmdArgs.WriteString(p.SaltCallArgs)
   512  	}
   513  
   514  	p.CmdArgs = cmdArgs.String()
   515  
   516  	return p, nil
   517  }
   518  
   519  func copyOutput(
   520  	o terraform.UIOutput, r io.Reader) {
   521  	lr := linereader.New(r)
   522  	for line := range lr.Ch {
   523  		o.Output(line)
   524  	}
   525  }