blob: f8edfb78556cbdbe0188d3f107d47e4239168a04 [file] [log] [blame]
package remoteexec
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"strings"
"github.com/hashicorp/terraform/internal/communicator"
"github.com/hashicorp/terraform/internal/communicator/remote"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/provisioners"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/mitchellh/go-linereader"
"github.com/zclconf/go-cty/cty"
)
func New() provisioners.Interface {
ctx, cancel := context.WithCancel(context.Background())
return &provisioner{
ctx: ctx,
cancel: cancel,
}
}
type provisioner struct {
// We store a context here tied to the lifetime of the provisioner.
// This allows the Stop method to cancel any in-flight requests.
ctx context.Context
cancel context.CancelFunc
}
func (p *provisioner) GetSchema() (resp provisioners.GetSchemaResponse) {
schema := &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"inline": {
Type: cty.List(cty.String),
Optional: true,
},
"script": {
Type: cty.String,
Optional: true,
},
"scripts": {
Type: cty.List(cty.String),
Optional: true,
},
},
}
resp.Provisioner = schema
return resp
}
func (p *provisioner) ValidateProvisionerConfig(req provisioners.ValidateProvisionerConfigRequest) (resp provisioners.ValidateProvisionerConfigResponse) {
cfg, err := p.GetSchema().Provisioner.CoerceValue(req.Config)
if err != nil {
resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
tfdiags.Error,
"Invalid remote-exec provisioner configuration",
err.Error(),
))
return resp
}
inline := cfg.GetAttr("inline")
script := cfg.GetAttr("script")
scripts := cfg.GetAttr("scripts")
set := 0
if !inline.IsNull() {
set++
}
if !script.IsNull() {
set++
}
if !scripts.IsNull() {
set++
}
if set != 1 {
resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
tfdiags.Error,
"Invalid remote-exec provisioner configuration",
`Only one of "inline", "script", or "scripts" must be set`,
))
}
return resp
}
func (p *provisioner) ProvisionResource(req provisioners.ProvisionResourceRequest) (resp provisioners.ProvisionResourceResponse) {
if req.Connection.IsNull() {
resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
tfdiags.Error,
"remote-exec provisioner error",
"Missing connection configuration for provisioner.",
))
return resp
}
comm, err := communicator.New(req.Connection)
if err != nil {
resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
tfdiags.Error,
"remote-exec provisioner error",
err.Error(),
))
return resp
}
// Collect the scripts
scripts, err := collectScripts(req.Config)
if err != nil {
resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
tfdiags.Error,
"remote-exec provisioner error",
err.Error(),
))
return resp
}
for _, s := range scripts {
defer s.Close()
}
// Copy and execute each script
if err := runScripts(p.ctx, req.UIOutput, comm, scripts); err != nil {
resp.Diagnostics = resp.Diagnostics.Append(tfdiags.WholeContainingBody(
tfdiags.Error,
"remote-exec provisioner error",
err.Error(),
))
return resp
}
return resp
}
func (p *provisioner) Stop() error {
p.cancel()
return nil
}
func (p *provisioner) Close() error {
return nil
}
// generateScripts takes the configuration and creates a script from each inline config
func generateScripts(inline cty.Value) ([]string, error) {
var lines []string
for _, l := range inline.AsValueSlice() {
if l.IsNull() {
return nil, errors.New("invalid null string in 'scripts'")
}
s := l.AsString()
if s == "" {
return nil, errors.New("invalid empty string in 'scripts'")
}
lines = append(lines, s)
}
lines = append(lines, "")
return []string{strings.Join(lines, "\n")}, nil
}
// collectScripts is used to collect all the scripts we need
// to execute in preparation for copying them.
func collectScripts(v cty.Value) ([]io.ReadCloser, error) {
// Check if inline
if inline := v.GetAttr("inline"); !inline.IsNull() {
scripts, err := generateScripts(inline)
if err != nil {
return nil, err
}
var r []io.ReadCloser
for _, script := range scripts {
r = append(r, ioutil.NopCloser(bytes.NewReader([]byte(script))))
}
return r, nil
}
// Collect scripts
var scripts []string
if script := v.GetAttr("script"); !script.IsNull() {
s := script.AsString()
if s == "" {
return nil, errors.New("invalid empty string in 'script'")
}
scripts = append(scripts, s)
}
if scriptList := v.GetAttr("scripts"); !scriptList.IsNull() {
for _, script := range scriptList.AsValueSlice() {
if script.IsNull() {
return nil, errors.New("invalid null string in 'script'")
}
s := script.AsString()
if s == "" {
return nil, errors.New("invalid empty string in 'script'")
}
scripts = append(scripts, s)
}
}
// Open all the scripts
var fhs []io.ReadCloser
for _, s := range scripts {
fh, err := os.Open(s)
if err != nil {
for _, fh := range fhs {
fh.Close()
}
return nil, fmt.Errorf("Failed to open script '%s': %v", s, err)
}
fhs = append(fhs, fh)
}
// Done, return the file handles
return fhs, nil
}
// runScripts is used to copy and execute a set of scripts
func runScripts(ctx context.Context, o provisioners.UIOutput, comm communicator.Communicator, scripts []io.ReadCloser) error {
retryCtx, cancel := context.WithTimeout(ctx, comm.Timeout())
defer cancel()
// Wait and retry until we establish the connection
err := communicator.Retry(retryCtx, func() error {
return comm.Connect(o)
})
if err != nil {
return err
}
// Wait for the context to end and then disconnect
go func() {
<-ctx.Done()
comm.Disconnect()
}()
for _, script := range scripts {
var cmd *remote.Cmd
outR, outW := io.Pipe()
errR, errW := io.Pipe()
defer outW.Close()
defer errW.Close()
go copyUIOutput(o, outR)
go copyUIOutput(o, errR)
remotePath := comm.ScriptPath()
if err := comm.UploadScript(remotePath, script); err != nil {
return fmt.Errorf("Failed to upload script: %v", err)
}
cmd = &remote.Cmd{
Command: remotePath,
Stdout: outW,
Stderr: errW,
}
if err := comm.Start(cmd); err != nil {
return fmt.Errorf("Error starting script: %v", err)
}
if err := cmd.Wait(); err != nil {
return err
}
// Upload a blank follow up file in the same path to prevent residual
// script contents from remaining on remote machine
empty := bytes.NewReader([]byte(""))
if err := comm.Upload(remotePath, empty); err != nil {
// This feature is best-effort.
log.Printf("[WARN] Failed to upload empty follow up script: %v", err)
}
}
return nil
}
func copyUIOutput(o provisioners.UIOutput, r io.Reader) {
lr := linereader.New(r)
for line := range lr.Ch {
o.Output(line)
}
}