295 lines
6.8 KiB
Go
295 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|