github.com/lukahartwig/terraform@v0.11.4-0.20180302171601-664391c254ea/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  			},
    62  			"remote_pillar_roots": &schema.Schema{
    63  				Type:     schema.TypeString,
    64  				Optional: true,
    65  			},
    66  			"temp_config_dir": &schema.Schema{
    67  				Type:     schema.TypeString,
    68  				Optional: true,
    69  				Default:  "/tmp/salt",
    70  			},
    71  			"skip_bootstrap": &schema.Schema{
    72  				Type:     schema.TypeBool,
    73  				Optional: true,
    74  			},
    75  			"no_exit_on_failure": &schema.Schema{
    76  				Type:     schema.TypeBool,
    77  				Optional: true,
    78  			},
    79  			"bootstrap_args": &schema.Schema{
    80  				Type:     schema.TypeString,
    81  				Optional: true,
    82  			},
    83  			"disable_sudo": &schema.Schema{
    84  				Type:     schema.TypeBool,
    85  				Optional: true,
    86  			},
    87  			"custom_state": &schema.Schema{
    88  				Type:     schema.TypeString,
    89  				Optional: true,
    90  			},
    91  			"minion_config_file": &schema.Schema{
    92  				Type:     schema.TypeString,
    93  				Optional: true,
    94  			},
    95  			"cmd_args": &schema.Schema{
    96  				Type:     schema.TypeString,
    97  				Optional: true,
    98  			},
    99  			"salt_call_args": &schema.Schema{
   100  				Type:     schema.TypeString,
   101  				Optional: true,
   102  			},
   103  			"log_level": &schema.Schema{
   104  				Type:     schema.TypeString,
   105  				Optional: true,
   106  			},
   107  		},
   108  
   109  		ApplyFunc:    applyFn,
   110  		ValidateFunc: validateFn,
   111  	}
   112  }
   113  
   114  // Apply executes the file provisioner
   115  func applyFn(ctx context.Context) error {
   116  	// Decode the raw config for this provisioner
   117  	var err error
   118  
   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  	ctx, cancelFunc := context.WithTimeout(ctx, comm.Timeout())
   135  	defer cancelFunc()
   136  
   137  	// Wait for the context to end and then disconnect
   138  	go func() {
   139  		<-ctx.Done()
   140  		comm.Disconnect()
   141  	}()
   142  
   143  	// Wait and retry until we establish the connection
   144  	err = communicator.Retry(ctx, func() error {
   145  		return comm.Connect(o)
   146  	})
   147  
   148  	if err != nil {
   149  		return err
   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 == nil {
   166  			cmd.Wait()
   167  			if cmd.ExitStatus != 0 {
   168  				err = fmt.Errorf("Curl exited with non-zero exit status: %d", cmd.ExitStatus)
   169  			}
   170  		}
   171  
   172  		outR, outW := io.Pipe()
   173  		errR, errW := io.Pipe()
   174  		outDoneCh := make(chan struct{})
   175  		errDoneCh := make(chan struct{})
   176  		go copyOutput(o, outR, outDoneCh)
   177  		go copyOutput(o, errR, errDoneCh)
   178  		cmd = &remote.Cmd{
   179  			Command: fmt.Sprintf("%s /tmp/install_salt.sh %s", p.sudo("sh"), p.BootstrapArgs),
   180  			Stdout:  outW,
   181  			Stderr:  errW,
   182  		}
   183  
   184  		o.Output(fmt.Sprintf("Installing Salt with command %s", cmd.Command))
   185  		if err = comm.Start(cmd); err != nil {
   186  			err = fmt.Errorf("Unable to install Salt: %s", err)
   187  		}
   188  
   189  		if err == nil {
   190  			cmd.Wait()
   191  			if cmd.ExitStatus != 0 {
   192  				err = fmt.Errorf("install_salt.sh exited with non-zero exit status: %d", cmd.ExitStatus)
   193  			}
   194  		}
   195  		// Wait for output to clean up
   196  		outW.Close()
   197  		errW.Close()
   198  		<-outDoneCh
   199  		<-errDoneCh
   200  		if err != nil {
   201  			return err
   202  		}
   203  	}
   204  
   205  	o.Output(fmt.Sprintf("Creating remote temporary directory: %s", p.TempConfigDir))
   206  	if err := p.createDir(o, comm, p.TempConfigDir); err != nil {
   207  		return fmt.Errorf("Error creating remote temporary directory: %s", err)
   208  	}
   209  
   210  	if p.MinionConfig != "" {
   211  		o.Output(fmt.Sprintf("Uploading minion config: %s", p.MinionConfig))
   212  		src = p.MinionConfig
   213  		dst = filepath.ToSlash(filepath.Join(p.TempConfigDir, "minion"))
   214  		if err = p.uploadFile(o, comm, dst, src); err != nil {
   215  			return fmt.Errorf("Error uploading local minion config file to remote: %s", err)
   216  		}
   217  
   218  		// move minion config into /etc/salt
   219  		o.Output(fmt.Sprintf("Make sure directory %s exists", "/etc/salt"))
   220  		if err := p.createDir(o, comm, "/etc/salt"); err != nil {
   221  			return fmt.Errorf("Error creating remote salt configuration directory: %s", err)
   222  		}
   223  		src = filepath.ToSlash(filepath.Join(p.TempConfigDir, "minion"))
   224  		dst = "/etc/salt/minion"
   225  		if err = p.moveFile(o, comm, dst, src); err != nil {
   226  			return fmt.Errorf("Unable to move %s/minion to /etc/salt/minion: %s", p.TempConfigDir, err)
   227  		}
   228  	}
   229  
   230  	o.Output(fmt.Sprintf("Uploading local state tree: %s", p.LocalStateTree))
   231  	src = p.LocalStateTree
   232  	dst = filepath.ToSlash(filepath.Join(p.TempConfigDir, "states"))
   233  	if err = p.uploadDir(o, comm, dst, src, []string{".git"}); err != nil {
   234  		return fmt.Errorf("Error uploading local state tree to remote: %s", err)
   235  	}
   236  
   237  	// move state tree from temporary directory
   238  	src = filepath.ToSlash(filepath.Join(p.TempConfigDir, "states"))
   239  	dst = p.RemoteStateTree
   240  	if err = p.removeDir(o, comm, dst); err != nil {
   241  		return fmt.Errorf("Unable to clear salt tree: %s", err)
   242  	}
   243  	if err = p.moveFile(o, comm, dst, src); err != nil {
   244  		return fmt.Errorf("Unable to move %s/states to %s: %s", p.TempConfigDir, dst, err)
   245  	}
   246  
   247  	if p.LocalPillarRoots != "" {
   248  		o.Output(fmt.Sprintf("Uploading local pillar roots: %s", p.LocalPillarRoots))
   249  		src = p.LocalPillarRoots
   250  		dst = filepath.ToSlash(filepath.Join(p.TempConfigDir, "pillar"))
   251  		if err = p.uploadDir(o, comm, dst, src, []string{".git"}); err != nil {
   252  			return fmt.Errorf("Error uploading local pillar roots to remote: %s", err)
   253  		}
   254  
   255  		// move pillar root from temporary directory
   256  		src = filepath.ToSlash(filepath.Join(p.TempConfigDir, "pillar"))
   257  		dst = p.RemotePillarRoots
   258  
   259  		if err = p.removeDir(o, comm, dst); err != nil {
   260  			return fmt.Errorf("Unable to clear pillar root: %s", err)
   261  		}
   262  		if err = p.moveFile(o, comm, dst, src); err != nil {
   263  			return fmt.Errorf("Unable to move %s/pillar to %s: %s", p.TempConfigDir, dst, err)
   264  		}
   265  	}
   266  
   267  	outR, outW := io.Pipe()
   268  	errR, errW := io.Pipe()
   269  	outDoneCh := make(chan struct{})
   270  	errDoneCh := make(chan struct{})
   271  
   272  	go copyOutput(o, outR, outDoneCh)
   273  	go copyOutput(o, errR, errDoneCh)
   274  	o.Output(fmt.Sprintf("Running: salt-call --local %s", p.CmdArgs))
   275  	cmd := &remote.Cmd{
   276  		Command: p.sudo(fmt.Sprintf("salt-call --local %s", p.CmdArgs)),
   277  		Stdout:  outW,
   278  		Stderr:  errW,
   279  	}
   280  	if err = comm.Start(cmd); err != nil || cmd.ExitStatus != 0 {
   281  		if err == nil {
   282  			err = fmt.Errorf("Bad exit status: %d", cmd.ExitStatus)
   283  		}
   284  
   285  		err = fmt.Errorf("Error executing salt-call: %s", err)
   286  	}
   287  	if err == nil {
   288  		cmd.Wait()
   289  		if cmd.ExitStatus != 0 {
   290  			err = fmt.Errorf("Script exited with non-zero exit status: %d", cmd.ExitStatus)
   291  		}
   292  	}
   293  	// Wait for output to clean up
   294  	outW.Close()
   295  	errW.Close()
   296  	<-outDoneCh
   297  	<-errDoneCh
   298  
   299  	return err
   300  }
   301  
   302  // Prepends sudo to supplied command if config says to
   303  func (p *provisioner) sudo(cmd string) string {
   304  	if p.DisableSudo {
   305  		return cmd
   306  	}
   307  
   308  	return "sudo " + cmd
   309  }
   310  
   311  func validateDirConfig(path string, name string, required bool) error {
   312  	if required == true && path == "" {
   313  		return fmt.Errorf("%s cannot be empty", name)
   314  	} else if required == false && path == "" {
   315  		return nil
   316  	}
   317  	info, err := os.Stat(path)
   318  	if err != nil {
   319  		return fmt.Errorf("%s: path '%s' is invalid: %s", name, path, err)
   320  	} else if !info.IsDir() {
   321  		return fmt.Errorf("%s: path '%s' must point to a directory", name, path)
   322  	}
   323  	return nil
   324  }
   325  
   326  func validateFileConfig(path string, name string, required bool) error {
   327  	if required == true && path == "" {
   328  		return fmt.Errorf("%s cannot be empty", name)
   329  	} else if required == false && path == "" {
   330  		return nil
   331  	}
   332  	info, err := os.Stat(path)
   333  	if err != nil {
   334  		return fmt.Errorf("%s: path '%s' is invalid: %s", name, path, err)
   335  	} else if info.IsDir() {
   336  		return fmt.Errorf("%s: path '%s' must point to a file", name, path)
   337  	}
   338  	return nil
   339  }
   340  
   341  func (p *provisioner) uploadFile(o terraform.UIOutput, comm communicator.Communicator, dst, src string) error {
   342  	f, err := os.Open(src)
   343  	if err != nil {
   344  		return fmt.Errorf("Error opening: %s", err)
   345  	}
   346  	defer f.Close()
   347  
   348  	if err = comm.Upload(dst, f); err != nil {
   349  		return fmt.Errorf("Error uploading %s: %s", src, err)
   350  	}
   351  	return nil
   352  }
   353  
   354  func (p *provisioner) moveFile(o terraform.UIOutput, comm communicator.Communicator, dst, src string) error {
   355  	o.Output(fmt.Sprintf("Moving %s to %s", src, dst))
   356  	cmd := &remote.Cmd{Command: fmt.Sprintf(p.sudo("mv %s %s"), src, dst)}
   357  	if err := comm.Start(cmd); err != nil || cmd.ExitStatus != 0 {
   358  		if err == nil {
   359  			err = fmt.Errorf("Bad exit status: %d", cmd.ExitStatus)
   360  		}
   361  
   362  		return fmt.Errorf("Unable to move %s to %s: %s", src, dst, err)
   363  	}
   364  	return nil
   365  }
   366  
   367  func (p *provisioner) createDir(o terraform.UIOutput, comm communicator.Communicator, dir string) error {
   368  	o.Output(fmt.Sprintf("Creating directory: %s", dir))
   369  	cmd := &remote.Cmd{
   370  		Command: fmt.Sprintf("mkdir -p '%s'", dir),
   371  	}
   372  	if err := comm.Start(cmd); err != nil {
   373  		return err
   374  	}
   375  	if cmd.ExitStatus != 0 {
   376  		return fmt.Errorf("Non-zero exit status.")
   377  	}
   378  	return nil
   379  }
   380  
   381  func (p *provisioner) removeDir(o terraform.UIOutput, comm communicator.Communicator, dir string) error {
   382  	o.Output(fmt.Sprintf("Removing directory: %s", dir))
   383  	cmd := &remote.Cmd{
   384  		Command: fmt.Sprintf("rm -rf '%s'", dir),
   385  	}
   386  	if err := comm.Start(cmd); err != nil {
   387  		return err
   388  	}
   389  	if cmd.ExitStatus != 0 {
   390  		return fmt.Errorf("Non-zero exit status.")
   391  	}
   392  	return nil
   393  }
   394  
   395  func (p *provisioner) uploadDir(o terraform.UIOutput, comm communicator.Communicator, dst, src string, ignore []string) error {
   396  	if err := p.createDir(o, comm, dst); err != nil {
   397  		return err
   398  	}
   399  
   400  	// Make sure there is a trailing "/" so that the directory isn't
   401  	// created on the other side.
   402  	if src[len(src)-1] != '/' {
   403  		src = src + "/"
   404  	}
   405  	return comm.UploadDir(dst, src)
   406  }
   407  
   408  // Validate checks if the required arguments are configured
   409  func validateFn(c *terraform.ResourceConfig) (ws []string, es []error) {
   410  	// require a salt state tree
   411  	localStateTreeTmp, ok := c.Get("local_state_tree")
   412  	var localStateTree string
   413  	if !ok {
   414  		es = append(es,
   415  			errors.New("Required local_state_tree is not set"))
   416  	} else {
   417  		localStateTree = localStateTreeTmp.(string)
   418  	}
   419  	err := validateDirConfig(localStateTree, "local_state_tree", true)
   420  	if err != nil {
   421  		es = append(es, err)
   422  	}
   423  
   424  	var localPillarRoots string
   425  	localPillarRootsTmp, ok := c.Get("local_pillar_roots")
   426  	if !ok {
   427  		localPillarRoots = ""
   428  	} else {
   429  		localPillarRoots = localPillarRootsTmp.(string)
   430  	}
   431  
   432  	err = validateDirConfig(localPillarRoots, "local_pillar_roots", false)
   433  	if err != nil {
   434  		es = append(es, err)
   435  	}
   436  
   437  	var minionConfig string
   438  	minionConfigTmp, ok := c.Get("minion_config_file")
   439  	if !ok {
   440  		minionConfig = ""
   441  	} else {
   442  		minionConfig = minionConfigTmp.(string)
   443  	}
   444  	err = validateFileConfig(minionConfig, "minion_config_file", false)
   445  	if err != nil {
   446  		es = append(es, err)
   447  	}
   448  
   449  	var remoteStateTree string
   450  	remoteStateTreeTmp, ok := c.Get("remote_state_tree")
   451  	if !ok {
   452  		remoteStateTree = ""
   453  	} else {
   454  		remoteStateTree = remoteStateTreeTmp.(string)
   455  	}
   456  
   457  	var remotePillarRoots string
   458  	remotePillarRootsTmp, ok := c.Get("remote_pillar_roots")
   459  	if !ok {
   460  		remotePillarRoots = ""
   461  	} else {
   462  		remotePillarRoots = remotePillarRootsTmp.(string)
   463  	}
   464  
   465  	if minionConfig != "" && (remoteStateTree != "" || remotePillarRoots != "") {
   466  		es = append(es,
   467  			errors.New("remote_state_tree and remote_pillar_roots only apply when minion_config_file is not used"))
   468  	}
   469  
   470  	if len(es) > 0 {
   471  		return ws, es
   472  	}
   473  
   474  	return ws, es
   475  }
   476  
   477  func decodeConfig(d *schema.ResourceData) (*provisioner, error) {
   478  	p := &provisioner{
   479  		LocalStateTree:    d.Get("local_state_tree").(string),
   480  		LogLevel:          d.Get("log_level").(string),
   481  		SaltCallArgs:      d.Get("salt_call_args").(string),
   482  		CmdArgs:           d.Get("cmd_args").(string),
   483  		MinionConfig:      d.Get("minion_config_file").(string),
   484  		CustomState:       d.Get("custom_state").(string),
   485  		DisableSudo:       d.Get("disable_sudo").(bool),
   486  		BootstrapArgs:     d.Get("bootstrap_args").(string),
   487  		NoExitOnFailure:   d.Get("no_exit_on_failure").(bool),
   488  		SkipBootstrap:     d.Get("skip_bootstrap").(bool),
   489  		TempConfigDir:     d.Get("temp_config_dir").(string),
   490  		RemotePillarRoots: d.Get("remote_pillar_roots").(string),
   491  		RemoteStateTree:   d.Get("remote_state_tree").(string),
   492  		LocalPillarRoots:  d.Get("local_pillar_roots").(string),
   493  	}
   494  
   495  	// build the command line args to pass onto salt
   496  	var cmdArgs bytes.Buffer
   497  
   498  	if p.CustomState == "" {
   499  		cmdArgs.WriteString(" state.highstate")
   500  	} else {
   501  		cmdArgs.WriteString(" state.sls ")
   502  		cmdArgs.WriteString(p.CustomState)
   503  	}
   504  
   505  	if p.MinionConfig == "" {
   506  		// pass --file-root and --pillar-root if no minion_config_file is supplied
   507  		if p.RemoteStateTree != "" {
   508  			cmdArgs.WriteString(" --file-root=")
   509  			cmdArgs.WriteString(p.RemoteStateTree)
   510  		} else {
   511  			cmdArgs.WriteString(" --file-root=")
   512  			cmdArgs.WriteString(DefaultStateTreeDir)
   513  		}
   514  		if p.RemotePillarRoots != "" {
   515  			cmdArgs.WriteString(" --pillar-root=")
   516  			cmdArgs.WriteString(p.RemotePillarRoots)
   517  		} else {
   518  			cmdArgs.WriteString(" --pillar-root=")
   519  			cmdArgs.WriteString(DefaultPillarRootDir)
   520  		}
   521  	}
   522  
   523  	if !p.NoExitOnFailure {
   524  		cmdArgs.WriteString(" --retcode-passthrough")
   525  	}
   526  
   527  	if p.LogLevel == "" {
   528  		cmdArgs.WriteString(" -l info")
   529  	} else {
   530  		cmdArgs.WriteString(" -l ")
   531  		cmdArgs.WriteString(p.LogLevel)
   532  	}
   533  
   534  	if p.SaltCallArgs != "" {
   535  		cmdArgs.WriteString(" ")
   536  		cmdArgs.WriteString(p.SaltCallArgs)
   537  	}
   538  
   539  	p.CmdArgs = cmdArgs.String()
   540  
   541  	return p, nil
   542  }
   543  
   544  func copyOutput(
   545  	o terraform.UIOutput, r io.Reader, doneCh chan<- struct{}) {
   546  	defer close(doneCh)
   547  	lr := linereader.New(r)
   548  	for line := range lr.Ch {
   549  		o.Output(line)
   550  	}
   551  }