From 9adfaa9b5d39daa0c19a5ce33f3f8839f69df858 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Wed, 25 Nov 2020 09:24:10 -0500 Subject: [PATCH] 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. --- .../local-exec/resource_provisioner.go | 117 +++++++---- .../local-exec/resource_provisioner_test.go | 181 +++++++++--------- 2 files changed, 171 insertions(+), 127 deletions(-) diff --git a/builtin/provisioners/local-exec/resource_provisioner.go b/builtin/provisioners/local-exec/resource_provisioner.go index 12c69161fd..6b03725dc4 100644 --- a/builtin/provisioners/local-exec/resource_provisioner.go +++ b/builtin/provisioners/local-exec/resource_provisioner.go @@ -7,11 +7,13 @@ import ( "os" "os/exec" "runtime" + "sync" "github.com/armon/circbuf" - "github.com/hashicorp/terraform/internal/legacy/helper/schema" - "github.com/hashicorp/terraform/internal/legacy/terraform" + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/provisioners" "github.com/mitchellh/go-linereader" + "github.com/zclconf/go-cty/cty" ) const ( @@ -21,59 +23,79 @@ const ( maxBufSize = 8 * 1024 ) -func Provisioner() terraform.ResourceProvisioner { - return &schema.Provisioner{ - Schema: map[string]*schema.Schema{ - "command": &schema.Schema{ - Type: schema.TypeString, +func New() provisioners.Interface { + return &provisioner{} +} + +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, }, - "interpreter": &schema.Schema{ - Type: schema.TypeList, - Elem: &schema.Schema{Type: schema.TypeString}, + "interpreter": { + Type: cty.List(cty.String), Optional: true, }, - "working_dir": &schema.Schema{ - Type: schema.TypeString, + "working_dir": { + Type: cty.String, Optional: true, }, - "environment": &schema.Schema{ - Type: schema.TypeMap, + "environment": { + Type: cty.Map(cty.String), Optional: true, }, }, + } - ApplyFunc: applyFn, + resp.Provisioner = schema + return resp +} + +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 applyFn(ctx context.Context) error { - data := ctx.Value(schema.ProvConfigDataKey).(*schema.ResourceData) - o := ctx.Value(schema.ProvOutputKey).(terraform.UIOutput) +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 := data.Get("command").(string) + command := req.Config.GetAttr("command").AsString() 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 - environment := data.Get("environment").(map[string]interface{}) - + envVal := req.Config.GetAttr("environment") var env []string - for k := range environment { - entry := fmt.Sprintf("%s=%s", k, environment[k].(string)) - env = append(env, entry) + + if !envVal.IsNull() { + for k, v := range envVal.AsValueMap() { + entry := fmt.Sprintf("%s=%s", k, v.AsString()) + env = append(env, entry) + } } // Execute the command using a shell - interpreter := data.Get("interpreter").([]interface{}) + intrVal := req.Config.GetAttr("interpreter") var cmdargs []string - if len(interpreter) > 0 { - for _, i := range interpreter { - if arg, ok := i.(string); ok { - cmdargs = append(cmdargs, arg) - } + if !intrVal.IsNull() && intrVal.LengthInt() > 0 { + for _, v := range intrVal.AsValueSlice() { + cmdargs = append(cmdargs, v.AsString()) } } else { if runtime.GOOS == "windows" { @@ -82,9 +104,13 @@ func applyFn(ctx context.Context) error { cmdargs = []string{"/bin/sh", "-c"} } } + 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. // 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 pr, pw, err := os.Pipe() 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 @@ -118,10 +145,10 @@ func applyFn(ctx context.Context) error { // copy the teed output to the UI output copyDoneCh := make(chan struct{}) - go copyOutput(o, tee, copyDoneCh) + go copyUIOutput(req.UIOutput, tee, copyDoneCh) // 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 err = cmd.Start() @@ -142,14 +169,26 @@ func applyFn(ctx context.Context) error { } if err != nil { - return fmt.Errorf("Error running command '%s': %v. Output: %s", - command, err, output.Bytes()) + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("Error running command '%s': %v. Output: %s", + command, err, output.Bytes())) + return resp } + return resp +} + +func (p *provisioner) Stop() error { + p.mu.Lock() + defer p.mu.Unlock() + p.cancel() + return nil +} + +func (p *provisioner) Close() error { return nil } -func copyOutput(o terraform.UIOutput, r io.Reader, doneCh chan<- struct{}) { +func copyUIOutput(o provisioners.UIOutput, r io.Reader, doneCh chan<- struct{}) { defer close(doneCh) lr := linereader.New(r) for line := range lr.Ch { diff --git a/builtin/provisioners/local-exec/resource_provisioner_test.go b/builtin/provisioners/local-exec/resource_provisioner_test.go index 32ccfa7e62..f828cc8fd9 100644 --- a/builtin/provisioners/local-exec/resource_provisioner_test.go +++ b/builtin/provisioners/local-exec/resource_provisioner_test.go @@ -7,31 +7,30 @@ import ( "testing" "time" - "github.com/hashicorp/terraform/internal/legacy/helper/schema" - "github.com/hashicorp/terraform/internal/legacy/terraform" + "github.com/hashicorp/terraform/provisioners" + "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) { defer os.Remove("test_out") - c := testConfig(t, map[string]interface{}{ - "command": "echo foo > test_out", - }) + output := cli.NewMockUi() + 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) + } - output := new(terraform.MockUIOutput) - p := Provisioner() + resp := p.ProvisionResource(provisioners.ProvisionResourceRequest{ + Config: c, + UIOutput: output, + }) - if err := p.Apply(output, nil, c); err != nil { - t.Fatalf("err: %v", err) + if resp.Diagnostics.HasErrors() { + t.Fatalf("err: %v", resp.Diagnostics.Err()) } // Check the file @@ -48,14 +47,18 @@ func TestResourceProvider_Apply(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 // makes certain there's a subprocess in the shell. - "command": "sleep 30; sleep 30", - }) - - output := new(terraform.MockUIOutput) - p := Provisioner() + "command": cty.StringVal("sleep 30; sleep 30"), + })) + if err != nil { + t.Fatal(err) + } doneCh := make(chan struct{}) 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 // result would be ignored or would cause a panic if the parent goroutine // has already completed. - _ = p.Apply(output, nil, c) + _ = p.ProvisionResource(provisioners.ProvisionResourceRequest{ + Config: c, + UIOutput: output, + }) }() 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_ApplyCustomInterpreter(t *testing.T) { + output := cli.NewMockUi() + p := New() -func TestResourceProvider_Validate_missing(t *testing.T) { - c := testConfig(t, map[string]interface{}{}) + schema := p.GetSchema().Provisioner - warn, errs := Provisioner().Validate(c) - if len(warn) > 0 { - t.Fatalf("Warnings: %v", warn) - } - if len(errs) == 0 { - t.Fatalf("Should have errors") + c, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{ + "interpreter": cty.ListVal([]cty.Value{cty.StringVal("echo"), cty.StringVal("is")}), + "command": cty.StringVal("not really an interpreter"), + })) + if err != nil { + t.Fatal(err) } -} - -func testConfig(t *testing.T, c map[string]interface{}) *terraform.ResourceConfig { - return terraform.NewResourceConfigRaw(c) -} -func TestResourceProvider_ApplyCustomInterpreter(t *testing.T) { - c := testConfig(t, map[string]interface{}{ - "interpreter": []interface{}{"echo", "is"}, - "command": "not really an interpreter", + resp := p.ProvisionResource(provisioners.ProvisionResourceRequest{ + Config: c, + UIOutput: output, }) - output := new(terraform.MockUIOutput) - p := Provisioner() - - if err := p.Apply(output, nil, c); err != nil { - t.Fatalf("err: %v", err) + if resp.Diagnostics.HasErrors() { + t.Fatal(resp.Diagnostics.Err()) } - got := strings.TrimSpace(output.OutputMessage) - want := "is not really an interpreter" + got := strings.TrimSpace(output.OutputWriter.String()) + want := `Executing: ["echo" "is" "not really an interpreter"] +is not really an interpreter` if 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) defer os.Remove(testdir) - c := testConfig(t, map[string]interface{}{ - "working_dir": testdir, - "command": "echo `pwd`", - }) + output := cli.NewMockUi() + p := New() + 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) + } - output := new(terraform.MockUIOutput) - p := Provisioner() + resp := p.ProvisionResource(provisioners.ProvisionResourceRequest{ + Config: c, + UIOutput: output, + }) - if err := p.Apply(output, nil, c); err != nil { - t.Fatalf("err: %v", err) + if resp.Diagnostics.HasErrors() { + t.Fatal(resp.Diagnostics.Err()) } dir, err := os.Getwd() @@ -162,32 +158,41 @@ func TestResourceProvider_ApplyCustomWorkingDirectory(t *testing.T) { t.Fatalf("err: %v", err) } - got := strings.TrimSpace(output.OutputMessage) - want := dir + "/" + testdir + got := strings.TrimSpace(output.OutputWriter.String()) + want := "Executing: [\"/bin/sh\" \"-c\" \"echo `pwd`\"]\n" + dir + "/" + testdir if got != want { t.Errorf("wrong output\ngot: %s\nwant: %s", got, want) } } func TestResourceProvider_ApplyCustomEnv(t *testing.T) { - c := testConfig(t, map[string]interface{}{ - "command": "echo $FOO $BAR $BAZ", - "environment": map[string]interface{}{ - "FOO": "BAR", - "BAR": 1, - "BAZ": "true", - }, - }) - - output := new(terraform.MockUIOutput) - p := Provisioner() + output := cli.NewMockUi() + p := New() + schema := p.GetSchema().Provisioner + + c, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{ + "command": cty.StringVal("echo $FOO $BAR $BAZ"), + "environment": cty.MapVal(map[string]cty.Value{ + "FOO": cty.StringVal("BAR"), + "BAR": cty.StringVal("1"), + "BAZ": cty.StringVal("true"), + }), + })) + if err != nil { + t.Fatal(err) + } - if err := p.Apply(output, nil, c); err != nil { - t.Fatalf("err: %v", err) + resp := p.ProvisionResource(provisioners.ProvisionResourceRequest{ + Config: c, + UIOutput: output, + }) + if resp.Diagnostics.HasErrors() { + t.Fatal(resp.Diagnostics.Err()) } - got := strings.TrimSpace(output.OutputMessage) - want := "BAR 1 true" + got := strings.TrimSpace(output.OutputWriter.String()) + want := `Executing: ["/bin/sh" "-c" "echo $FOO $BAR $BAZ"] +BAR 1 true` if got != want { t.Errorf("wrong output\ngot: %s\nwant: %s", got, want) }