rewrite local-exec to run internally

Use the new interface directly, so that there are no shims or plugins
involved with the execution of the provisioner.
This commit is contained in:
James Bardin 2020-11-25 09:24:10 -05:00
parent dc9ded8618
commit 9adfaa9b5d
2 changed files with 175 additions and 131 deletions

View File

@ -7,11 +7,13 @@ import (
"os" "os"
"os/exec" "os/exec"
"runtime" "runtime"
"sync"
"github.com/armon/circbuf" "github.com/armon/circbuf"
"github.com/hashicorp/terraform/internal/legacy/helper/schema" "github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/internal/legacy/terraform" "github.com/hashicorp/terraform/provisioners"
"github.com/mitchellh/go-linereader" "github.com/mitchellh/go-linereader"
"github.com/zclconf/go-cty/cty"
) )
const ( const (
@ -21,59 +23,79 @@ const (
maxBufSize = 8 * 1024 maxBufSize = 8 * 1024
) )
func Provisioner() terraform.ResourceProvisioner { func New() provisioners.Interface {
return &schema.Provisioner{ return &provisioner{}
Schema: map[string]*schema.Schema{ }
"command": &schema.Schema{
Type: schema.TypeString, type provisioner struct {
// this stored from the running context, so that Stop() can cancel the
// command
mu sync.Mutex
cancel context.CancelFunc
}
func (p *provisioner) GetSchema() (resp provisioners.GetSchemaResponse) {
schema := &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"command": {
Type: cty.String,
Required: true, Required: true,
}, },
"interpreter": &schema.Schema{ "interpreter": {
Type: schema.TypeList, Type: cty.List(cty.String),
Elem: &schema.Schema{Type: schema.TypeString},
Optional: true, Optional: true,
}, },
"working_dir": &schema.Schema{ "working_dir": {
Type: schema.TypeString, Type: cty.String,
Optional: true, Optional: true,
}, },
"environment": &schema.Schema{ "environment": {
Type: schema.TypeMap, Type: cty.Map(cty.String),
Optional: true, Optional: true,
}, },
}, },
ApplyFunc: applyFn,
}
} }
func applyFn(ctx context.Context) error { resp.Provisioner = schema
data := ctx.Value(schema.ProvConfigDataKey).(*schema.ResourceData) return resp
o := ctx.Value(schema.ProvOutputKey).(terraform.UIOutput) }
command := data.Get("command").(string) func (p *provisioner) ValidateProvisionerConfig(req provisioners.ValidateProvisionerConfigRequest) (resp provisioners.ValidateProvisionerConfigResponse) {
if _, err := p.GetSchema().Provisioner.CoerceValue(req.Config); err != nil {
resp.Diagnostics = resp.Diagnostics.Append(err)
}
return resp
}
func (p *provisioner) ProvisionResource(req provisioners.ProvisionResourceRequest) (resp provisioners.ProvisionResourceResponse) {
p.mu.Lock()
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
p.mu.Unlock()
command := req.Config.GetAttr("command").AsString()
if command == "" { if command == "" {
return fmt.Errorf("local-exec provisioner command must be a non-empty string") resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("local-exec provisioner command must be a non-empty string"))
return resp
} }
// Execute the command with env envVal := req.Config.GetAttr("environment")
environment := data.Get("environment").(map[string]interface{})
var env []string var env []string
for k := range environment {
entry := fmt.Sprintf("%s=%s", k, environment[k].(string)) if !envVal.IsNull() {
for k, v := range envVal.AsValueMap() {
entry := fmt.Sprintf("%s=%s", k, v.AsString())
env = append(env, entry) env = append(env, entry)
} }
}
// Execute the command using a shell // Execute the command using a shell
interpreter := data.Get("interpreter").([]interface{}) intrVal := req.Config.GetAttr("interpreter")
var cmdargs []string var cmdargs []string
if len(interpreter) > 0 { if !intrVal.IsNull() && intrVal.LengthInt() > 0 {
for _, i := range interpreter { for _, v := range intrVal.AsValueSlice() {
if arg, ok := i.(string); ok { cmdargs = append(cmdargs, v.AsString())
cmdargs = append(cmdargs, arg)
}
} }
} else { } else {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
@ -82,9 +104,13 @@ func applyFn(ctx context.Context) error {
cmdargs = []string{"/bin/sh", "-c"} cmdargs = []string{"/bin/sh", "-c"}
} }
} }
cmdargs = append(cmdargs, command) cmdargs = append(cmdargs, command)
workingdir := data.Get("working_dir").(string) workingdir := ""
if wdVal := req.Config.GetAttr("working_dir"); !wdVal.IsNull() {
workingdir = wdVal.AsString()
}
// Setup the reader that will read the output from the command. // Setup the reader that will read the output from the command.
// We use an os.Pipe so that the *os.File can be passed directly to the // We use an os.Pipe so that the *os.File can be passed directly to the
@ -92,7 +118,8 @@ func applyFn(ctx context.Context) error {
// See golang.org/issue/18874 // See golang.org/issue/18874
pr, pw, err := os.Pipe() pr, pw, err := os.Pipe()
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize pipe for output: %s", err) resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("failed to initialize pipe for output: %s", err))
return resp
} }
var cmdEnv []string var cmdEnv []string
@ -118,10 +145,10 @@ func applyFn(ctx context.Context) error {
// copy the teed output to the UI output // copy the teed output to the UI output
copyDoneCh := make(chan struct{}) copyDoneCh := make(chan struct{})
go copyOutput(o, tee, copyDoneCh) go copyUIOutput(req.UIOutput, tee, copyDoneCh)
// Output what we're about to run // Output what we're about to run
o.Output(fmt.Sprintf("Executing: %q", cmdargs)) req.UIOutput.Output(fmt.Sprintf("Executing: %q", cmdargs))
// Start the command // Start the command
err = cmd.Start() err = cmd.Start()
@ -142,14 +169,26 @@ func applyFn(ctx context.Context) error {
} }
if err != nil { if err != nil {
return fmt.Errorf("Error running command '%s': %v. Output: %s", resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("Error running command '%s': %v. Output: %s",
command, err, output.Bytes()) command, err, output.Bytes()))
return resp
} }
return resp
}
func (p *provisioner) Stop() error {
p.mu.Lock()
defer p.mu.Unlock()
p.cancel()
return nil return nil
} }
func copyOutput(o terraform.UIOutput, r io.Reader, doneCh chan<- struct{}) { func (p *provisioner) Close() error {
return nil
}
func copyUIOutput(o provisioners.UIOutput, r io.Reader, doneCh chan<- struct{}) {
defer close(doneCh) defer close(doneCh)
lr := linereader.New(r) lr := linereader.New(r)
for line := range lr.Ch { for line := range lr.Ch {

View File

@ -7,31 +7,30 @@ import (
"testing" "testing"
"time" "time"
"github.com/hashicorp/terraform/internal/legacy/helper/schema" "github.com/hashicorp/terraform/provisioners"
"github.com/hashicorp/terraform/internal/legacy/terraform" "github.com/mitchellh/cli"
"github.com/zclconf/go-cty/cty"
) )
func TestResourceProvisioner_impl(t *testing.T) {
var _ terraform.ResourceProvisioner = Provisioner()
}
func TestProvisioner(t *testing.T) {
if err := Provisioner().(*schema.Provisioner).InternalValidate(); err != nil {
t.Fatalf("err: %s", err)
}
}
func TestResourceProvider_Apply(t *testing.T) { func TestResourceProvider_Apply(t *testing.T) {
defer os.Remove("test_out") defer os.Remove("test_out")
c := testConfig(t, map[string]interface{}{ output := cli.NewMockUi()
"command": "echo foo > test_out", p := New()
schema := p.GetSchema().Provisioner
c, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{
"command": cty.StringVal("echo foo > test_out"),
}))
if err != nil {
t.Fatal(err)
}
resp := p.ProvisionResource(provisioners.ProvisionResourceRequest{
Config: c,
UIOutput: output,
}) })
output := new(terraform.MockUIOutput) if resp.Diagnostics.HasErrors() {
p := Provisioner() t.Fatalf("err: %v", resp.Diagnostics.Err())
if err := p.Apply(output, nil, c); err != nil {
t.Fatalf("err: %v", err)
} }
// Check the file // Check the file
@ -48,14 +47,18 @@ func TestResourceProvider_Apply(t *testing.T) {
} }
func TestResourceProvider_stop(t *testing.T) { func TestResourceProvider_stop(t *testing.T) {
c := testConfig(t, map[string]interface{}{ output := cli.NewMockUi()
p := New()
schema := p.GetSchema().Provisioner
c, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{
// bash/zsh/ksh will exec a single command in the same process. This // bash/zsh/ksh will exec a single command in the same process. This
// makes certain there's a subprocess in the shell. // makes certain there's a subprocess in the shell.
"command": "sleep 30; sleep 30", "command": cty.StringVal("sleep 30; sleep 30"),
}) }))
if err != nil {
output := new(terraform.MockUIOutput) t.Fatal(err)
p := Provisioner() }
doneCh := make(chan struct{}) doneCh := make(chan struct{})
startTime := time.Now() startTime := time.Now()
@ -65,7 +68,10 @@ func TestResourceProvider_stop(t *testing.T) {
// Because p.Apply is called in a goroutine, trying to t.Fatal() on its // Because p.Apply is called in a goroutine, trying to t.Fatal() on its
// result would be ignored or would cause a panic if the parent goroutine // result would be ignored or would cause a panic if the parent goroutine
// has already completed. // has already completed.
_ = p.Apply(output, nil, c) _ = p.ProvisionResource(provisioners.ProvisionResourceRequest{
Config: c,
UIOutput: output,
})
}() }()
mustExceed := (50 * time.Millisecond) mustExceed := (50 * time.Millisecond)
@ -90,51 +96,32 @@ func TestResourceProvider_stop(t *testing.T) {
} }
} }
func TestResourceProvider_Validate_good(t *testing.T) {
c := testConfig(t, map[string]interface{}{
"command": "echo foo",
})
warn, errs := Provisioner().Validate(c)
if len(warn) > 0 {
t.Fatalf("Warnings: %v", warn)
}
if len(errs) > 0 {
t.Fatalf("Errors: %v", errs)
}
}
func TestResourceProvider_Validate_missing(t *testing.T) {
c := testConfig(t, map[string]interface{}{})
warn, errs := Provisioner().Validate(c)
if len(warn) > 0 {
t.Fatalf("Warnings: %v", warn)
}
if len(errs) == 0 {
t.Fatalf("Should have errors")
}
}
func testConfig(t *testing.T, c map[string]interface{}) *terraform.ResourceConfig {
return terraform.NewResourceConfigRaw(c)
}
func TestResourceProvider_ApplyCustomInterpreter(t *testing.T) { func TestResourceProvider_ApplyCustomInterpreter(t *testing.T) {
c := testConfig(t, map[string]interface{}{ output := cli.NewMockUi()
"interpreter": []interface{}{"echo", "is"}, p := New()
"command": "not really an interpreter",
})
output := new(terraform.MockUIOutput) schema := p.GetSchema().Provisioner
p := Provisioner()
if err := p.Apply(output, nil, c); err != nil { c, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{
t.Fatalf("err: %v", err) "interpreter": cty.ListVal([]cty.Value{cty.StringVal("echo"), cty.StringVal("is")}),
"command": cty.StringVal("not really an interpreter"),
}))
if err != nil {
t.Fatal(err)
} }
got := strings.TrimSpace(output.OutputMessage) resp := p.ProvisionResource(provisioners.ProvisionResourceRequest{
want := "is not really an interpreter" Config: c,
UIOutput: output,
})
if resp.Diagnostics.HasErrors() {
t.Fatal(resp.Diagnostics.Err())
}
got := strings.TrimSpace(output.OutputWriter.String())
want := `Executing: ["echo" "is" "not really an interpreter"]
is not really an interpreter`
if got != want { if got != want {
t.Errorf("wrong output\ngot: %s\nwant: %s", got, want) t.Errorf("wrong output\ngot: %s\nwant: %s", got, want)
} }
@ -145,16 +132,25 @@ func TestResourceProvider_ApplyCustomWorkingDirectory(t *testing.T) {
os.Mkdir(testdir, 0755) os.Mkdir(testdir, 0755)
defer os.Remove(testdir) defer os.Remove(testdir)
c := testConfig(t, map[string]interface{}{ output := cli.NewMockUi()
"working_dir": testdir, p := New()
"command": "echo `pwd`", schema := p.GetSchema().Provisioner
c, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{
"working_dir": cty.StringVal(testdir),
"command": cty.StringVal("echo `pwd`"),
}))
if err != nil {
t.Fatal(err)
}
resp := p.ProvisionResource(provisioners.ProvisionResourceRequest{
Config: c,
UIOutput: output,
}) })
output := new(terraform.MockUIOutput) if resp.Diagnostics.HasErrors() {
p := Provisioner() t.Fatal(resp.Diagnostics.Err())
if err := p.Apply(output, nil, c); err != nil {
t.Fatalf("err: %v", err)
} }
dir, err := os.Getwd() dir, err := os.Getwd()
@ -162,32 +158,41 @@ func TestResourceProvider_ApplyCustomWorkingDirectory(t *testing.T) {
t.Fatalf("err: %v", err) t.Fatalf("err: %v", err)
} }
got := strings.TrimSpace(output.OutputMessage) got := strings.TrimSpace(output.OutputWriter.String())
want := dir + "/" + testdir want := "Executing: [\"/bin/sh\" \"-c\" \"echo `pwd`\"]\n" + dir + "/" + testdir
if got != want { if got != want {
t.Errorf("wrong output\ngot: %s\nwant: %s", got, want) t.Errorf("wrong output\ngot: %s\nwant: %s", got, want)
} }
} }
func TestResourceProvider_ApplyCustomEnv(t *testing.T) { func TestResourceProvider_ApplyCustomEnv(t *testing.T) {
c := testConfig(t, map[string]interface{}{ output := cli.NewMockUi()
"command": "echo $FOO $BAR $BAZ", p := New()
"environment": map[string]interface{}{ schema := p.GetSchema().Provisioner
"FOO": "BAR",
"BAR": 1,
"BAZ": "true",
},
})
output := new(terraform.MockUIOutput) c, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{
p := Provisioner() "command": cty.StringVal("echo $FOO $BAR $BAZ"),
"environment": cty.MapVal(map[string]cty.Value{
if err := p.Apply(output, nil, c); err != nil { "FOO": cty.StringVal("BAR"),
t.Fatalf("err: %v", err) "BAR": cty.StringVal("1"),
"BAZ": cty.StringVal("true"),
}),
}))
if err != nil {
t.Fatal(err)
} }
got := strings.TrimSpace(output.OutputMessage) resp := p.ProvisionResource(provisioners.ProvisionResourceRequest{
want := "BAR 1 true" Config: c,
UIOutput: output,
})
if resp.Diagnostics.HasErrors() {
t.Fatal(resp.Diagnostics.Err())
}
got := strings.TrimSpace(output.OutputWriter.String())
want := `Executing: ["/bin/sh" "-c" "echo $FOO $BAR $BAZ"]
BAR 1 true`
if got != want { if got != want {
t.Errorf("wrong output\ngot: %s\nwant: %s", got, want) t.Errorf("wrong output\ngot: %s\nwant: %s", got, want)
} }