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/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 applyFn(ctx context.Context) error {
data := ctx.Value(schema.ProvConfigDataKey).(*schema.ResourceData)
o := ctx.Value(schema.ProvOutputKey).(terraform.UIOutput)
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
}
command := data.Get("command").(string)
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 == "" {
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 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)
lr := linereader.New(r)
for line := range lr.Ch {

View File

@ -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)
}
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.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_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) {
c := testConfig(t, map[string]interface{}{
"interpreter": []interface{}{"echo", "is"},
"command": "not really an interpreter",
})
output := cli.NewMockUi()
p := New()
output := new(terraform.MockUIOutput)
p := Provisioner()
schema := p.GetSchema().Provisioner
if err := p.Apply(output, nil, c); err != nil {
t.Fatalf("err: %v", err)
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)
}
got := strings.TrimSpace(output.OutputMessage)
want := "is not really an interpreter"
resp := p.ProvisionResource(provisioners.ProvisionResourceRequest{
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 {
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)
}
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())
}
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 := cli.NewMockUi()
p := New()
schema := p.GetSchema().Provisioner
output := new(terraform.MockUIOutput)
p := Provisioner()
if err := p.Apply(output, nil, c); err != nil {
t.Fatalf("err: %v", err)
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)
}
got := strings.TrimSpace(output.OutputMessage)
want := "BAR 1 true"
resp := p.ProvisionResource(provisioners.ProvisionResourceRequest{
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 {
t.Errorf("wrong output\ngot: %s\nwant: %s", got, want)
}