github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/builtin/provisioners/local-exec/resource_provisioner.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package localexec
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"io"
    10  	"os"
    11  	"os/exec"
    12  	"runtime"
    13  
    14  	"github.com/armon/circbuf"
    15  	"github.com/mitchellh/go-linereader"
    16  	"github.com/terramate-io/tf/configs/configschema"
    17  	"github.com/terramate-io/tf/provisioners"
    18  	"github.com/terramate-io/tf/tfdiags"
    19  	"github.com/zclconf/go-cty/cty"
    20  )
    21  
    22  const (
    23  	// maxBufSize limits how much output we collect from a local
    24  	// invocation. This is to prevent TF memory usage from growing
    25  	// to an enormous amount due to a faulty process.
    26  	maxBufSize = 8 * 1024
    27  )
    28  
    29  func New() provisioners.Interface {
    30  	ctx, cancel := context.WithCancel(context.Background())
    31  	return &provisioner{
    32  		ctx:    ctx,
    33  		cancel: cancel,
    34  	}
    35  }
    36  
    37  type provisioner struct {
    38  	// We store a context here tied to the lifetime of the provisioner.
    39  	// This allows the Stop method to cancel any in-flight requests.
    40  	ctx    context.Context
    41  	cancel context.CancelFunc
    42  }
    43  
    44  func (p *provisioner) GetSchema() (resp provisioners.GetSchemaResponse) {
    45  	schema := &configschema.Block{
    46  		Attributes: map[string]*configschema.Attribute{
    47  			"command": {
    48  				Type:     cty.String,
    49  				Required: true,
    50  			},
    51  			"interpreter": {
    52  				Type:     cty.List(cty.String),
    53  				Optional: true,
    54  			},
    55  			"working_dir": {
    56  				Type:     cty.String,
    57  				Optional: true,
    58  			},
    59  			"environment": {
    60  				Type:     cty.Map(cty.String),
    61  				Optional: true,
    62  			},
    63  			"quiet": {
    64  				Type:     cty.Bool,
    65  				Optional: true,
    66  			},
    67  		},
    68  	}
    69  
    70  	resp.Provisioner = schema
    71  	return resp
    72  }
    73  
    74  func (p *provisioner) ValidateProvisionerConfig(req provisioners.ValidateProvisionerConfigRequest) (resp provisioners.ValidateProvisionerConfigResponse) {
    75  	if _, err := p.GetSchema().Provisioner.CoerceValue(req.Config); err != nil {
    76  		resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
    77  			tfdiags.Error,
    78  			"Invalid local-exec provisioner configuration",
    79  			err.Error(),
    80  		))
    81  	}
    82  	return resp
    83  }
    84  
    85  func (p *provisioner) ProvisionResource(req provisioners.ProvisionResourceRequest) (resp provisioners.ProvisionResourceResponse) {
    86  	command := req.Config.GetAttr("command").AsString()
    87  	if command == "" {
    88  		resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
    89  			tfdiags.Error,
    90  			"Invalid local-exec provisioner command",
    91  			"The command must be a non-empty string.",
    92  		))
    93  		return resp
    94  	}
    95  
    96  	envVal := req.Config.GetAttr("environment")
    97  	var env []string
    98  
    99  	if !envVal.IsNull() {
   100  		for k, v := range envVal.AsValueMap() {
   101  			if !v.IsNull() {
   102  				entry := fmt.Sprintf("%s=%s", k, v.AsString())
   103  				env = append(env, entry)
   104  			}
   105  		}
   106  	}
   107  
   108  	// Execute the command using a shell
   109  	intrVal := req.Config.GetAttr("interpreter")
   110  
   111  	var cmdargs []string
   112  	if !intrVal.IsNull() && intrVal.LengthInt() > 0 {
   113  		for _, v := range intrVal.AsValueSlice() {
   114  			if !v.IsNull() {
   115  				cmdargs = append(cmdargs, v.AsString())
   116  			}
   117  		}
   118  	} else {
   119  		if runtime.GOOS == "windows" {
   120  			cmdargs = []string{"cmd", "/C"}
   121  		} else {
   122  			cmdargs = []string{"/bin/sh", "-c"}
   123  		}
   124  	}
   125  
   126  	cmdargs = append(cmdargs, command)
   127  
   128  	workingdir := ""
   129  	if wdVal := req.Config.GetAttr("working_dir"); !wdVal.IsNull() {
   130  		workingdir = wdVal.AsString()
   131  	}
   132  
   133  	// Set up the reader that will read the output from the command.
   134  	// We use an os.Pipe so that the *os.File can be passed directly to the
   135  	// process, and not rely on goroutines copying the data which may block.
   136  	// See golang.org/issue/18874
   137  	pr, pw, err := os.Pipe()
   138  	if err != nil {
   139  		resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
   140  			tfdiags.Error,
   141  			"local-exec provisioner error",
   142  			fmt.Sprintf("Failed to initialize pipe for output: %s", err),
   143  		))
   144  		return resp
   145  	}
   146  
   147  	var cmdEnv []string
   148  	cmdEnv = os.Environ()
   149  	cmdEnv = append(cmdEnv, env...)
   150  
   151  	// Set up the command
   152  	cmd := exec.CommandContext(p.ctx, cmdargs[0], cmdargs[1:]...)
   153  	cmd.Stderr = pw
   154  	cmd.Stdout = pw
   155  	// Dir specifies the working directory of the command.
   156  	// If Dir is the empty string (this is default), runs the command
   157  	// in the calling process's current directory.
   158  	cmd.Dir = workingdir
   159  	// Env specifies the environment of the command.
   160  	// By default will use the calling process's environment
   161  	cmd.Env = cmdEnv
   162  
   163  	output, _ := circbuf.NewBuffer(maxBufSize)
   164  
   165  	// Write everything we read from the pipe to the output buffer too
   166  	tee := io.TeeReader(pr, output)
   167  
   168  	// copy the teed output to the UI output
   169  	copyDoneCh := make(chan struct{})
   170  	go copyUIOutput(req.UIOutput, tee, copyDoneCh)
   171  
   172  	// Output what we're about to run
   173  	if quietVal := req.Config.GetAttr("quiet"); !quietVal.IsNull() && quietVal.True() {
   174  		req.UIOutput.Output("local-exec: Executing: Suppressed by quiet=true")
   175  	} else {
   176  		req.UIOutput.Output(fmt.Sprintf("Executing: %q", cmdargs))
   177  	}
   178  
   179  	// Start the command
   180  	err = cmd.Start()
   181  	if err == nil {
   182  		err = cmd.Wait()
   183  	}
   184  
   185  	// Close the write-end of the pipe so that the goroutine mirroring output
   186  	// ends properly.
   187  	pw.Close()
   188  
   189  	// Cancelling the command may block the pipe reader if the file descriptor
   190  	// was passed to a child process which hasn't closed it. In this case the
   191  	// copyOutput goroutine will just hang out until exit.
   192  	select {
   193  	case <-copyDoneCh:
   194  	case <-p.ctx.Done():
   195  	}
   196  
   197  	if err != nil {
   198  		resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
   199  			tfdiags.Error,
   200  			"local-exec provisioner error",
   201  			fmt.Sprintf("Error running command '%s': %v. Output: %s", command, err, output.Bytes()),
   202  		))
   203  		return resp
   204  	}
   205  
   206  	return resp
   207  }
   208  
   209  func (p *provisioner) Stop() error {
   210  	p.cancel()
   211  	return nil
   212  }
   213  
   214  func (p *provisioner) Close() error {
   215  	return nil
   216  }
   217  
   218  func copyUIOutput(o provisioners.UIOutput, r io.Reader, doneCh chan<- struct{}) {
   219  	defer close(doneCh)
   220  	lr := linereader.New(r)
   221  	for line := range lr.Ch {
   222  		o.Output(line)
   223  	}
   224  }