github.com/opentofu/opentofu@v1.7.1/internal/builtin/provisioners/local-exec/resource_provisioner.go (about)

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