github.com/opentofu/opentofu@v1.7.1/internal/builtin/provisioners/remote-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 remoteexec
     7  
     8  import (
     9  	"bytes"
    10  	"context"
    11  	"errors"
    12  	"fmt"
    13  	"io"
    14  	"log"
    15  	"os"
    16  	"strings"
    17  
    18  	"github.com/mitchellh/go-linereader"
    19  	"github.com/opentofu/opentofu/internal/communicator"
    20  	"github.com/opentofu/opentofu/internal/communicator/remote"
    21  	"github.com/opentofu/opentofu/internal/configs/configschema"
    22  	"github.com/opentofu/opentofu/internal/provisioners"
    23  	"github.com/opentofu/opentofu/internal/tfdiags"
    24  	"github.com/zclconf/go-cty/cty"
    25  )
    26  
    27  func New() provisioners.Interface {
    28  	ctx, cancel := context.WithCancel(context.Background())
    29  	return &provisioner{
    30  		ctx:    ctx,
    31  		cancel: cancel,
    32  	}
    33  }
    34  
    35  type provisioner struct {
    36  	// We store a context here tied to the lifetime of the provisioner.
    37  	// This allows the Stop method to cancel any in-flight requests.
    38  	ctx    context.Context
    39  	cancel context.CancelFunc
    40  }
    41  
    42  func (p *provisioner) GetSchema() (resp provisioners.GetSchemaResponse) {
    43  	schema := &configschema.Block{
    44  		Attributes: map[string]*configschema.Attribute{
    45  			"inline": {
    46  				Type:     cty.List(cty.String),
    47  				Optional: true,
    48  			},
    49  			"script": {
    50  				Type:     cty.String,
    51  				Optional: true,
    52  			},
    53  			"scripts": {
    54  				Type:     cty.List(cty.String),
    55  				Optional: true,
    56  			},
    57  		},
    58  	}
    59  
    60  	resp.Provisioner = schema
    61  	return resp
    62  }
    63  
    64  func (p *provisioner) ValidateProvisionerConfig(req provisioners.ValidateProvisionerConfigRequest) (resp provisioners.ValidateProvisionerConfigResponse) {
    65  	cfg, err := p.GetSchema().Provisioner.CoerceValue(req.Config)
    66  	if err != nil {
    67  		resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
    68  			tfdiags.Error,
    69  			"Invalid remote-exec provisioner configuration",
    70  			err.Error(),
    71  		))
    72  		return resp
    73  	}
    74  
    75  	inline := cfg.GetAttr("inline")
    76  	script := cfg.GetAttr("script")
    77  	scripts := cfg.GetAttr("scripts")
    78  
    79  	set := 0
    80  	if !inline.IsNull() {
    81  		set++
    82  	}
    83  	if !script.IsNull() {
    84  		set++
    85  	}
    86  	if !scripts.IsNull() {
    87  		set++
    88  	}
    89  	if set != 1 {
    90  		resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
    91  			tfdiags.Error,
    92  			"Invalid remote-exec provisioner configuration",
    93  			`Only one of "inline", "script", or "scripts" must be set`,
    94  		))
    95  	}
    96  	return resp
    97  }
    98  
    99  func (p *provisioner) ProvisionResource(req provisioners.ProvisionResourceRequest) (resp provisioners.ProvisionResourceResponse) {
   100  	if req.Connection.IsNull() {
   101  		resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
   102  			tfdiags.Error,
   103  			"remote-exec provisioner error",
   104  			"Missing connection configuration for provisioner.",
   105  		))
   106  		return resp
   107  	}
   108  
   109  	comm, err := communicator.New(req.Connection)
   110  	if err != nil {
   111  		resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
   112  			tfdiags.Error,
   113  			"remote-exec provisioner error",
   114  			err.Error(),
   115  		))
   116  		return resp
   117  	}
   118  
   119  	// Collect the scripts
   120  	scripts, err := collectScripts(req.Config)
   121  	if err != nil {
   122  		resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
   123  			tfdiags.Error,
   124  			"remote-exec provisioner error",
   125  			err.Error(),
   126  		))
   127  		return resp
   128  	}
   129  	for _, s := range scripts {
   130  		defer s.Close()
   131  	}
   132  
   133  	// Copy and execute each script
   134  	if err := runScripts(p.ctx, req.UIOutput, comm, scripts); err != nil {
   135  		resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
   136  			tfdiags.Error,
   137  			"remote-exec provisioner error",
   138  			err.Error(),
   139  		))
   140  		return resp
   141  	}
   142  
   143  	return resp
   144  }
   145  
   146  func (p *provisioner) Stop() error {
   147  	p.cancel()
   148  	return nil
   149  }
   150  
   151  func (p *provisioner) Close() error {
   152  	return nil
   153  }
   154  
   155  // generateScripts takes the configuration and creates a script from each inline config
   156  func generateScripts(inline cty.Value) ([]string, error) {
   157  	var lines []string
   158  	for _, l := range inline.AsValueSlice() {
   159  		if l.IsNull() {
   160  			return nil, errors.New("invalid null string in 'scripts'")
   161  		}
   162  
   163  		s := l.AsString()
   164  		if s == "" {
   165  			return nil, errors.New("invalid empty string in 'scripts'")
   166  		}
   167  		lines = append(lines, s)
   168  	}
   169  	lines = append(lines, "")
   170  
   171  	return []string{strings.Join(lines, "\n")}, nil
   172  }
   173  
   174  // collectScripts is used to collect all the scripts we need
   175  // to execute in preparation for copying them.
   176  func collectScripts(v cty.Value) ([]io.ReadCloser, error) {
   177  	// Check if inline
   178  	if inline := v.GetAttr("inline"); !inline.IsNull() {
   179  		scripts, err := generateScripts(inline)
   180  		if err != nil {
   181  			return nil, err
   182  		}
   183  
   184  		var r []io.ReadCloser
   185  		for _, script := range scripts {
   186  			r = append(r, io.NopCloser(bytes.NewReader([]byte(script))))
   187  		}
   188  
   189  		return r, nil
   190  	}
   191  
   192  	// Collect scripts
   193  	var scripts []string
   194  	if script := v.GetAttr("script"); !script.IsNull() {
   195  		s := script.AsString()
   196  		if s == "" {
   197  			return nil, errors.New("invalid empty string in 'script'")
   198  		}
   199  		scripts = append(scripts, s)
   200  	}
   201  
   202  	if scriptList := v.GetAttr("scripts"); !scriptList.IsNull() {
   203  		for _, script := range scriptList.AsValueSlice() {
   204  			if script.IsNull() {
   205  				return nil, errors.New("invalid null string in 'script'")
   206  			}
   207  			s := script.AsString()
   208  			if s == "" {
   209  				return nil, errors.New("invalid empty string in 'script'")
   210  			}
   211  			scripts = append(scripts, s)
   212  		}
   213  	}
   214  
   215  	// Open all the scripts
   216  	var fhs []io.ReadCloser
   217  	for _, s := range scripts {
   218  		fh, err := os.Open(s)
   219  		if err != nil {
   220  			for _, fh := range fhs {
   221  				fh.Close()
   222  			}
   223  			return nil, fmt.Errorf("Failed to open script '%s': %w", s, err)
   224  		}
   225  		fhs = append(fhs, fh)
   226  	}
   227  
   228  	// Done, return the file handles
   229  	return fhs, nil
   230  }
   231  
   232  // runScripts is used to copy and execute a set of scripts
   233  func runScripts(ctx context.Context, o provisioners.UIOutput, comm communicator.Communicator, scripts []io.ReadCloser) error {
   234  	retryCtx, cancel := context.WithTimeout(ctx, comm.Timeout())
   235  	defer cancel()
   236  
   237  	// Wait and retry until we establish the connection
   238  	err := communicator.Retry(retryCtx, func() error {
   239  		return comm.Connect(o)
   240  	})
   241  	if err != nil {
   242  		return err
   243  	}
   244  
   245  	// Wait for the context to end and then disconnect
   246  	go func() {
   247  		<-ctx.Done()
   248  		comm.Disconnect()
   249  	}()
   250  
   251  	for _, script := range scripts {
   252  		var cmd *remote.Cmd
   253  
   254  		outR, outW := io.Pipe()
   255  		errR, errW := io.Pipe()
   256  		defer outW.Close()
   257  		defer errW.Close()
   258  
   259  		go copyUIOutput(o, outR)
   260  		go copyUIOutput(o, errR)
   261  
   262  		remotePath := comm.ScriptPath()
   263  
   264  		if err := comm.UploadScript(remotePath, script); err != nil {
   265  			return fmt.Errorf("Failed to upload script: %w", err)
   266  		}
   267  
   268  		cmd = &remote.Cmd{
   269  			Command: remotePath,
   270  			Stdout:  outW,
   271  			Stderr:  errW,
   272  		}
   273  		if err := comm.Start(cmd); err != nil {
   274  			return fmt.Errorf("Error starting script: %w", err)
   275  		}
   276  
   277  		if err := cmd.Wait(); err != nil {
   278  			return err
   279  		}
   280  
   281  		// Upload a blank follow up file in the same path to prevent residual
   282  		// script contents from remaining on remote machine
   283  		empty := bytes.NewReader([]byte(""))
   284  		if err := comm.Upload(remotePath, empty); err != nil {
   285  			// This feature is best-effort.
   286  			log.Printf("[WARN] Failed to upload empty follow up script: %v", err)
   287  		}
   288  	}
   289  
   290  	return nil
   291  }
   292  
   293  func copyUIOutput(o provisioners.UIOutput, r io.Reader) {
   294  	lr := linereader.New(r)
   295  	for line := range lr.Ch {
   296  		o.Output(line)
   297  	}
   298  }