Merge pull request #10934 from hashicorp/f-provisioner-stop

core: stoppable provisioners, helper/schema for provisioners
This commit is contained in:
Mitchell Hashimoto 2017-01-30 12:53:15 -08:00 committed by GitHub
commit 61881d2795
37 changed files with 1386 additions and 319 deletions

View File

@ -3,13 +3,10 @@ package main
import (
"github.com/hashicorp/terraform/builtin/provisioners/file"
"github.com/hashicorp/terraform/plugin"
"github.com/hashicorp/terraform/terraform"
)
func main() {
plugin.Serve(&plugin.ServeOpts{
ProvisionerFunc: func() terraform.ResourceProvisioner {
return new(file.ResourceProvisioner)
},
ProvisionerFunc: file.Provisioner,
})
}

View File

@ -3,13 +3,10 @@ package main
import (
"github.com/hashicorp/terraform/builtin/provisioners/local-exec"
"github.com/hashicorp/terraform/plugin"
"github.com/hashicorp/terraform/terraform"
)
func main() {
plugin.Serve(&plugin.ServeOpts{
ProvisionerFunc: func() terraform.ResourceProvisioner {
return new(localexec.ResourceProvisioner)
},
ProvisionerFunc: localexec.Provisioner,
})
}

View File

@ -3,13 +3,10 @@ package main
import (
"github.com/hashicorp/terraform/builtin/provisioners/remote-exec"
"github.com/hashicorp/terraform/plugin"
"github.com/hashicorp/terraform/terraform"
)
func main() {
plugin.Serve(&plugin.ServeOpts{
ProvisionerFunc: func() terraform.ResourceProvisioner {
return new(remoteexec.ResourceProvisioner)
},
ProvisionerFunc: remoteexec.Provisioner,
})
}

View File

@ -132,6 +132,11 @@ type Provisioner struct {
// ResourceProvisioner represents a generic chef provisioner
type ResourceProvisioner struct{}
func (r *ResourceProvisioner) Stop() error {
// Noop for now. TODO in the future.
return nil
}
// Apply executes the file provisioner
func (r *ResourceProvisioner) Apply(
o terraform.UIOutput,

View File

@ -1,6 +1,7 @@
package file
import (
"context"
"fmt"
"io/ioutil"
"log"
@ -8,26 +9,48 @@ import (
"time"
"github.com/hashicorp/terraform/communicator"
"github.com/hashicorp/terraform/helper/schema"
"github.com/hashicorp/terraform/terraform"
"github.com/mitchellh/go-homedir"
)
// ResourceProvisioner represents a file provisioner
type ResourceProvisioner struct{}
func Provisioner() terraform.ResourceProvisioner {
return &schema.Provisioner{
Schema: map[string]*schema.Schema{
"source": &schema.Schema{
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"content"},
},
"content": &schema.Schema{
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"source"},
},
"destination": &schema.Schema{
Type: schema.TypeString,
Required: true,
},
},
ApplyFunc: applyFn,
}
}
func applyFn(ctx context.Context) error {
connState := ctx.Value(schema.ProvRawStateKey).(*terraform.InstanceState)
data := ctx.Value(schema.ProvConfigDataKey).(*schema.ResourceData)
// Apply executes the file provisioner
func (p *ResourceProvisioner) Apply(
o terraform.UIOutput,
s *terraform.InstanceState,
c *terraform.ResourceConfig) error {
// Get a new communicator
comm, err := communicator.New(s)
comm, err := communicator.New(connState)
if err != nil {
return err
}
// Get the source
src, deleteSource, err := p.getSrc(c)
src, deleteSource, err := getSrc(data)
if err != nil {
return err
}
@ -35,58 +58,35 @@ func (p *ResourceProvisioner) Apply(
defer os.Remove(src)
}
// Get destination
dRaw := c.Config["destination"]
dst, ok := dRaw.(string)
if !ok {
return fmt.Errorf("Unsupported 'destination' type! Must be string.")
}
return p.copyFiles(comm, src, dst)
}
// Begin the file copy
dst := data.Get("destination").(string)
resultCh := make(chan error, 1)
go func() {
resultCh <- copyFiles(comm, src, dst)
}()
// Validate checks if the required arguments are configured
func (p *ResourceProvisioner) Validate(c *terraform.ResourceConfig) (ws []string, es []error) {
numDst := 0
numSrc := 0
for name := range c.Raw {
switch name {
case "destination":
numDst++
case "source", "content":
numSrc++
default:
es = append(es, fmt.Errorf("Unknown configuration '%s'", name))
}
// Allow the file copy to complete unless there is an interrupt.
// If there is an interrupt we make no attempt to cleanly close
// the connection currently. We just abruptly exit. Because Terraform
// taints the resource, this is fine.
select {
case err := <-resultCh:
return err
case <-ctx.Done():
return fmt.Errorf("file transfer interrupted")
}
if numSrc != 1 || numDst != 1 {
es = append(es, fmt.Errorf("Must provide one of 'content' or 'source' and 'destination' to file"))
}
return
}
// getSrc returns the file to use as source
func (p *ResourceProvisioner) getSrc(c *terraform.ResourceConfig) (string, bool, error) {
var src string
sRaw, ok := c.Config["source"]
if ok {
if src, ok = sRaw.(string); !ok {
return "", false, fmt.Errorf("Unsupported 'source' type! Must be string.")
}
}
content, ok := c.Config["content"]
if ok {
func getSrc(data *schema.ResourceData) (string, bool, error) {
src := data.Get("source").(string)
if content, ok := data.GetOk("content"); ok {
file, err := ioutil.TempFile("", "tf-file-content")
if err != nil {
return "", true, err
}
contentStr, ok := content.(string)
if !ok {
return "", true, fmt.Errorf("Unsupported 'content' type! Must be string.")
}
if _, err = file.WriteString(contentStr); err != nil {
if _, err = file.WriteString(content.(string)); err != nil {
return "", true, err
}
@ -98,7 +98,7 @@ func (p *ResourceProvisioner) getSrc(c *terraform.ResourceConfig) (string, bool,
}
// copyFiles is used to copy the files from a source to a destination
func (p *ResourceProvisioner) copyFiles(comm communicator.Communicator, src, dst string) error {
func copyFiles(comm communicator.Communicator, src, dst string) error {
// Wait and retry until we establish the connection
err := retryFunc(comm.Timeout(), func() error {
err := comm.Connect(nil)

View File

@ -7,16 +7,12 @@ import (
"github.com/hashicorp/terraform/terraform"
)
func TestResourceProvisioner_impl(t *testing.T) {
var _ terraform.ResourceProvisioner = new(ResourceProvisioner)
}
func TestResourceProvider_Validate_good_source(t *testing.T) {
c := testConfig(t, map[string]interface{}{
"source": "/tmp/foo",
"destination": "/tmp/bar",
})
p := new(ResourceProvisioner)
p := Provisioner()
warn, errs := p.Validate(c)
if len(warn) > 0 {
t.Fatalf("Warnings: %v", warn)
@ -31,7 +27,7 @@ func TestResourceProvider_Validate_good_content(t *testing.T) {
"content": "value to copy",
"destination": "/tmp/bar",
})
p := new(ResourceProvisioner)
p := Provisioner()
warn, errs := p.Validate(c)
if len(warn) > 0 {
t.Fatalf("Warnings: %v", warn)
@ -45,7 +41,7 @@ func TestResourceProvider_Validate_bad_not_destination(t *testing.T) {
c := testConfig(t, map[string]interface{}{
"source": "nope",
})
p := new(ResourceProvisioner)
p := Provisioner()
warn, errs := p.Validate(c)
if len(warn) > 0 {
t.Fatalf("Warnings: %v", warn)
@ -61,7 +57,7 @@ func TestResourceProvider_Validate_bad_to_many_src(t *testing.T) {
"content": "value to copy",
"destination": "/tmp/bar",
})
p := new(ResourceProvisioner)
p := Provisioner()
warn, errs := p.Validate(c)
if len(warn) > 0 {
t.Fatalf("Warnings: %v", warn)

View File

@ -1,13 +1,14 @@
package localexec
import (
"context"
"fmt"
"io"
"os/exec"
"runtime"
"github.com/armon/circbuf"
"github.com/hashicorp/terraform/helper/config"
"github.com/hashicorp/terraform/helper/schema"
"github.com/hashicorp/terraform/terraform"
"github.com/mitchellh/go-linereader"
)
@ -19,21 +20,26 @@ const (
maxBufSize = 8 * 1024
)
type ResourceProvisioner struct{}
func Provisioner() terraform.ResourceProvisioner {
return &schema.Provisioner{
Schema: map[string]*schema.Schema{
"command": &schema.Schema{
Type: schema.TypeString,
Required: true,
},
},
func (p *ResourceProvisioner) Apply(
o terraform.UIOutput,
s *terraform.InstanceState,
c *terraform.ResourceConfig) error {
// Get the command
commandRaw, ok := c.Config["command"]
if !ok {
return fmt.Errorf("local-exec provisioner missing 'command'")
ApplyFunc: applyFn,
}
command, ok := commandRaw.(string)
if !ok {
return fmt.Errorf("local-exec provisioner command must be a string")
}
func applyFn(ctx context.Context) error {
data := ctx.Value(schema.ProvConfigDataKey).(*schema.ResourceData)
o := ctx.Value(schema.ProvOutputKey).(terraform.UIOutput)
command := data.Get("command").(string)
if command == "" {
return fmt.Errorf("local-exec provisioner command must be a non-empty string")
}
// Execute the command using a shell
@ -49,7 +55,7 @@ func (p *ResourceProvisioner) Apply(
// Setup the reader that will read the lines from the command
pr, pw := io.Pipe()
copyDoneCh := make(chan struct{})
go p.copyOutput(o, pr, copyDoneCh)
go copyOutput(o, pr, copyDoneCh)
// Setup the command
cmd := exec.Command(shell, flag, command)
@ -62,8 +68,23 @@ func (p *ResourceProvisioner) Apply(
"Executing: %s %s \"%s\"",
shell, flag, command))
// Run the command to completion
err := cmd.Run()
// Start the command
err := cmd.Start()
if err == nil {
// Wait for the command to complete in a goroutine
doneCh := make(chan error, 1)
go func() {
doneCh <- cmd.Wait()
}()
// Wait for the command to finish or for us to be interrupted
select {
case err = <-doneCh:
case <-ctx.Done():
cmd.Process.Kill()
err = cmd.Wait()
}
}
// Close the write-end of the pipe so that the goroutine mirroring output
// ends properly.
@ -78,15 +99,7 @@ func (p *ResourceProvisioner) Apply(
return nil
}
func (p *ResourceProvisioner) Validate(c *terraform.ResourceConfig) ([]string, []error) {
validator := config.Validator{
Required: []string{"command"},
}
return validator.Validate(c)
}
func (p *ResourceProvisioner) copyOutput(
o terraform.UIOutput, r io.Reader, doneCh chan<- struct{}) {
func copyOutput(o terraform.UIOutput, r io.Reader, doneCh chan<- struct{}) {
defer close(doneCh)
lr := linereader.New(r)
for line := range lr.Ch {

View File

@ -5,15 +5,12 @@ import (
"os"
"strings"
"testing"
"time"
"github.com/hashicorp/terraform/config"
"github.com/hashicorp/terraform/terraform"
)
func TestResourceProvisioner_impl(t *testing.T) {
var _ terraform.ResourceProvisioner = new(ResourceProvisioner)
}
func TestResourceProvider_Apply(t *testing.T) {
defer os.Remove("test_out")
c := testConfig(t, map[string]interface{}{
@ -21,7 +18,7 @@ func TestResourceProvider_Apply(t *testing.T) {
})
output := new(terraform.MockUIOutput)
p := new(ResourceProvisioner)
p := Provisioner()
if err := p.Apply(output, nil, c); err != nil {
t.Fatalf("err: %v", err)
}
@ -39,11 +36,42 @@ func TestResourceProvider_Apply(t *testing.T) {
}
}
func TestResourceProvider_stop(t *testing.T) {
c := testConfig(t, map[string]interface{}{
"command": "sleep 60",
})
output := new(terraform.MockUIOutput)
p := Provisioner()
var err error
doneCh := make(chan struct{})
go func() {
defer close(doneCh)
err = p.Apply(output, nil, c)
}()
select {
case <-doneCh:
t.Fatal("should not finish quickly")
case <-time.After(10 * time.Millisecond):
}
// Stop it
p.Stop()
select {
case <-doneCh:
case <-time.After(100 * time.Millisecond):
t.Fatal("should finish")
}
}
func TestResourceProvider_Validate_good(t *testing.T) {
c := testConfig(t, map[string]interface{}{
"command": "echo foo",
})
p := new(ResourceProvisioner)
p := Provisioner()
warn, errs := p.Validate(c)
if len(warn) > 0 {
t.Fatalf("Warnings: %v", warn)
@ -55,7 +83,7 @@ func TestResourceProvider_Validate_good(t *testing.T) {
func TestResourceProvider_Validate_missing(t *testing.T) {
c := testConfig(t, map[string]interface{}{})
p := new(ResourceProvisioner)
p := Provisioner()
warn, errs := p.Validate(c)
if len(warn) > 0 {
t.Fatalf("Warnings: %v", warn)

View File

@ -2,35 +2,65 @@ package remoteexec
import (
"bytes"
"context"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"sync/atomic"
"time"
"github.com/hashicorp/terraform/communicator"
"github.com/hashicorp/terraform/communicator/remote"
"github.com/hashicorp/terraform/helper/schema"
"github.com/hashicorp/terraform/terraform"
"github.com/mitchellh/go-linereader"
)
// ResourceProvisioner represents a remote exec provisioner
type ResourceProvisioner struct{}
func Provisioner() terraform.ResourceProvisioner {
return &schema.Provisioner{
Schema: map[string]*schema.Schema{
"inline": &schema.Schema{
Type: schema.TypeList,
Elem: &schema.Schema{Type: schema.TypeString},
PromoteSingle: true,
Optional: true,
ConflictsWith: []string{"script", "scripts"},
},
"script": &schema.Schema{
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"inline", "scripts"},
},
"scripts": &schema.Schema{
Type: schema.TypeList,
Elem: &schema.Schema{Type: schema.TypeString},
Optional: true,
ConflictsWith: []string{"script", "inline"},
},
},
ApplyFunc: applyFn,
}
}
// Apply executes the remote exec provisioner
func (p *ResourceProvisioner) Apply(
o terraform.UIOutput,
s *terraform.InstanceState,
c *terraform.ResourceConfig) error {
func applyFn(ctx context.Context) error {
connState := ctx.Value(schema.ProvRawStateKey).(*terraform.InstanceState)
data := ctx.Value(schema.ProvConfigDataKey).(*schema.ResourceData)
o := ctx.Value(schema.ProvOutputKey).(terraform.UIOutput)
// Get a new communicator
comm, err := communicator.New(s)
comm, err := communicator.New(connState)
if err != nil {
return err
}
// Collect the scripts
scripts, err := p.collectScripts(c)
scripts, err := collectScripts(data)
if err != nil {
return err
}
@ -39,67 +69,33 @@ func (p *ResourceProvisioner) Apply(
}
// Copy and execute each script
if err := p.runScripts(o, comm, scripts); err != nil {
if err := runScripts(ctx, o, comm, scripts); err != nil {
return err
}
return nil
}
// Validate checks if the required arguments are configured
func (p *ResourceProvisioner) Validate(c *terraform.ResourceConfig) (ws []string, es []error) {
num := 0
for name := range c.Raw {
switch name {
case "scripts", "script", "inline":
num++
default:
es = append(es, fmt.Errorf("Unknown configuration '%s'", name))
}
}
if num != 1 {
es = append(es, fmt.Errorf("Must provide one of 'scripts', 'script' or 'inline' to remote-exec"))
}
return
}
// generateScripts takes the configuration and creates a script from each inline config
func (p *ResourceProvisioner) generateScripts(c *terraform.ResourceConfig) ([]string, error) {
func generateScripts(d *schema.ResourceData) ([]string, error) {
var scripts []string
command, ok := c.Config["inline"]
if ok {
switch cmd := command.(type) {
case string:
scripts = append(scripts, cmd)
case []string:
scripts = append(scripts, cmd...)
case []interface{}:
for _, l := range cmd {
lStr, ok := l.(string)
if ok {
scripts = append(scripts, lStr)
} else {
return nil, fmt.Errorf("Unsupported 'inline' type! Must be string, or list of strings.")
}
}
default:
return nil, fmt.Errorf("Unsupported 'inline' type! Must be string, or list of strings.")
}
for _, l := range d.Get("inline").([]interface{}) {
scripts = append(scripts, l.(string))
}
return scripts, nil
}
// collectScripts is used to collect all the scripts we need
// to execute in preparation for copying them.
func (p *ResourceProvisioner) collectScripts(c *terraform.ResourceConfig) ([]io.ReadCloser, error) {
func collectScripts(d *schema.ResourceData) ([]io.ReadCloser, error) {
// Check if inline
_, ok := c.Config["inline"]
if ok {
scripts, err := p.generateScripts(c)
if _, ok := d.GetOk("inline"); ok {
scripts, err := generateScripts(d)
if err != nil {
return nil, err
}
r := []io.ReadCloser{}
var r []io.ReadCloser
for _, script := range scripts {
r = append(r, ioutil.NopCloser(bytes.NewReader([]byte(script))))
}
@ -109,31 +105,13 @@ func (p *ResourceProvisioner) collectScripts(c *terraform.ResourceConfig) ([]io.
// Collect scripts
var scripts []string
s, ok := c.Config["script"]
if ok {
sStr, ok := s.(string)
if !ok {
return nil, fmt.Errorf("Unsupported 'script' type! Must be a string.")
}
scripts = append(scripts, sStr)
if script, ok := d.GetOk("script"); ok {
scripts = append(scripts, script.(string))
}
sl, ok := c.Config["scripts"]
if ok {
switch slt := sl.(type) {
case []string:
scripts = append(scripts, slt...)
case []interface{}:
for _, l := range slt {
lStr, ok := l.(string)
if ok {
scripts = append(scripts, lStr)
} else {
return nil, fmt.Errorf("Unsupported 'scripts' type! Must be list of strings.")
}
}
default:
return nil, fmt.Errorf("Unsupported 'scripts' type! Must be list of strings.")
if scriptList, ok := d.GetOk("scripts"); ok {
for _, script := range scriptList.([]interface{}) {
scripts = append(scripts, script.(string))
}
}
@ -155,19 +133,30 @@ func (p *ResourceProvisioner) collectScripts(c *terraform.ResourceConfig) ([]io.
}
// runScripts is used to copy and execute a set of scripts
func (p *ResourceProvisioner) runScripts(
func runScripts(
ctx context.Context,
o terraform.UIOutput,
comm communicator.Communicator,
scripts []io.ReadCloser) error {
// Wrap out context in a cancelation function that we use to
// kill the connection.
ctx, cancelFunc := context.WithCancel(ctx)
defer cancelFunc()
// Wait for the context to end and then disconnect
go func() {
<-ctx.Done()
comm.Disconnect()
}()
// Wait and retry until we establish the connection
err := retryFunc(comm.Timeout(), func() error {
err := retryFunc(ctx, comm.Timeout(), func() error {
err := comm.Connect(o)
return err
})
if err != nil {
return err
}
defer comm.Disconnect()
for _, script := range scripts {
var cmd *remote.Cmd
@ -175,11 +164,11 @@ func (p *ResourceProvisioner) runScripts(
errR, errW := io.Pipe()
outDoneCh := make(chan struct{})
errDoneCh := make(chan struct{})
go p.copyOutput(o, outR, outDoneCh)
go p.copyOutput(o, errR, errDoneCh)
go copyOutput(o, outR, outDoneCh)
go copyOutput(o, errR, errDoneCh)
remotePath := comm.ScriptPath()
err = retryFunc(comm.Timeout(), func() error {
err = retryFunc(ctx, comm.Timeout(), func() error {
if err := comm.UploadScript(remotePath, script); err != nil {
return fmt.Errorf("Failed to upload script: %v", err)
}
@ -202,6 +191,13 @@ func (p *ResourceProvisioner) runScripts(
}
}
// If we have an error, end our context so the disconnect happens.
// This has to happen before the output cleanup below since during
// an interrupt this will cause the outputs to end.
if err != nil {
cancelFunc()
}
// Wait for output to clean up
outW.Close()
errW.Close()
@ -225,7 +221,7 @@ func (p *ResourceProvisioner) runScripts(
return nil
}
func (p *ResourceProvisioner) copyOutput(
func copyOutput(
o terraform.UIOutput, r io.Reader, doneCh chan<- struct{}) {
defer close(doneCh)
lr := linereader.New(r)
@ -235,19 +231,54 @@ func (p *ResourceProvisioner) copyOutput(
}
// retryFunc is used to retry a function for a given duration
func retryFunc(timeout time.Duration, f func() error) error {
finish := time.After(timeout)
for {
err := f()
if err == nil {
return nil
}
log.Printf("Retryable error: %v", err)
func retryFunc(ctx context.Context, timeout time.Duration, f func() error) error {
// Build a new context with the timeout
ctx, done := context.WithTimeout(ctx, timeout)
defer done()
select {
case <-finish:
return err
case <-time.After(3 * time.Second):
// Try the function in a goroutine
var errVal atomic.Value
doneCh := make(chan struct{})
go func() {
defer close(doneCh)
for {
// If our context ended, we want to exit right away.
select {
case <-ctx.Done():
return
default:
}
// Try the function call
err := f()
if err == nil {
return
}
log.Printf("Retryable error: %v", err)
errVal.Store(err)
}
}()
// Wait for completion
select {
case <-doneCh:
case <-ctx.Done():
}
// Check if we have a context error to check if we're interrupted or timeout
switch ctx.Err() {
case context.Canceled:
return fmt.Errorf("interrupted")
case context.DeadlineExceeded:
return fmt.Errorf("timeout")
}
// Check if we got an error executing
if err, ok := errVal.Load().(error); ok {
return err
}
return nil
}

View File

@ -9,18 +9,15 @@ import (
"reflect"
"github.com/hashicorp/terraform/config"
"github.com/hashicorp/terraform/helper/schema"
"github.com/hashicorp/terraform/terraform"
)
func TestResourceProvisioner_impl(t *testing.T) {
var _ terraform.ResourceProvisioner = new(ResourceProvisioner)
}
func TestResourceProvider_Validate_good(t *testing.T) {
c := testConfig(t, map[string]interface{}{
"inline": "echo foo",
})
p := new(ResourceProvisioner)
p := Provisioner()
warn, errs := p.Validate(c)
if len(warn) > 0 {
t.Fatalf("Warnings: %v", warn)
@ -34,7 +31,7 @@ func TestResourceProvider_Validate_bad(t *testing.T) {
c := testConfig(t, map[string]interface{}{
"invalid": "nope",
})
p := new(ResourceProvisioner)
p := Provisioner()
warn, errs := p.Validate(c)
if len(warn) > 0 {
t.Fatalf("Warnings: %v", warn)
@ -51,16 +48,17 @@ exit 0
var expectedInlineScriptsOut = strings.Split(expectedScriptOut, "\n")
func TestResourceProvider_generateScripts(t *testing.T) {
p := new(ResourceProvisioner)
conf := testConfig(t, map[string]interface{}{
func TestResourceProvider_generateScript(t *testing.T) {
p := Provisioner().(*schema.Provisioner)
conf := map[string]interface{}{
"inline": []interface{}{
"cd /tmp",
"wget http://foobar",
"exit 0",
},
})
out, err := p.generateScripts(conf)
}
out, err := generateScripts(schema.TestResourceDataRaw(
t, p.Schema, conf))
if err != nil {
t.Fatalf("err: %v", err)
}
@ -71,16 +69,17 @@ func TestResourceProvider_generateScripts(t *testing.T) {
}
func TestResourceProvider_CollectScripts_inline(t *testing.T) {
p := new(ResourceProvisioner)
conf := testConfig(t, map[string]interface{}{
p := Provisioner().(*schema.Provisioner)
conf := map[string]interface{}{
"inline": []interface{}{
"cd /tmp",
"wget http://foobar",
"exit 0",
},
})
}
scripts, err := p.collectScripts(conf)
scripts, err := collectScripts(schema.TestResourceDataRaw(
t, p.Schema, conf))
if err != nil {
t.Fatalf("err: %v", err)
}
@ -103,12 +102,13 @@ func TestResourceProvider_CollectScripts_inline(t *testing.T) {
}
func TestResourceProvider_CollectScripts_script(t *testing.T) {
p := new(ResourceProvisioner)
conf := testConfig(t, map[string]interface{}{
p := Provisioner().(*schema.Provisioner)
conf := map[string]interface{}{
"script": "test-fixtures/script1.sh",
})
}
scripts, err := p.collectScripts(conf)
scripts, err := collectScripts(schema.TestResourceDataRaw(
t, p.Schema, conf))
if err != nil {
t.Fatalf("err: %v", err)
}
@ -129,16 +129,17 @@ func TestResourceProvider_CollectScripts_script(t *testing.T) {
}
func TestResourceProvider_CollectScripts_scripts(t *testing.T) {
p := new(ResourceProvisioner)
conf := testConfig(t, map[string]interface{}{
p := Provisioner().(*schema.Provisioner)
conf := map[string]interface{}{
"scripts": []interface{}{
"test-fixtures/script1.sh",
"test-fixtures/script1.sh",
"test-fixtures/script1.sh",
},
})
}
scripts, err := p.collectScripts(conf)
scripts, err := collectScripts(schema.TestResourceDataRaw(
t, p.Schema, conf))
if err != nil {
t.Fatalf("err: %v", err)
}

View File

@ -65,13 +65,15 @@ import (
vaultprovider "github.com/hashicorp/terraform/builtin/providers/vault"
vcdprovider "github.com/hashicorp/terraform/builtin/providers/vcd"
vsphereprovider "github.com/hashicorp/terraform/builtin/providers/vsphere"
chefresourceprovisioner "github.com/hashicorp/terraform/builtin/provisioners/chef"
fileresourceprovisioner "github.com/hashicorp/terraform/builtin/provisioners/file"
localexecresourceprovisioner "github.com/hashicorp/terraform/builtin/provisioners/local-exec"
remoteexecresourceprovisioner "github.com/hashicorp/terraform/builtin/provisioners/remote-exec"
fileprovisioner "github.com/hashicorp/terraform/builtin/provisioners/file"
localexecprovisioner "github.com/hashicorp/terraform/builtin/provisioners/local-exec"
remoteexecprovisioner "github.com/hashicorp/terraform/builtin/provisioners/remote-exec"
"github.com/hashicorp/terraform/plugin"
"github.com/hashicorp/terraform/terraform"
// Legacy, will remove once it conforms with new structure
chefprovisioner "github.com/hashicorp/terraform/builtin/provisioners/chef"
)
var InternalProviders = map[string]plugin.ProviderFunc{
@ -137,8 +139,13 @@ var InternalProviders = map[string]plugin.ProviderFunc{
}
var InternalProvisioners = map[string]plugin.ProvisionerFunc{
"chef": func() terraform.ResourceProvisioner { return new(chefresourceprovisioner.ResourceProvisioner) },
"file": func() terraform.ResourceProvisioner { return new(fileresourceprovisioner.ResourceProvisioner) },
"local-exec": func() terraform.ResourceProvisioner { return new(localexecresourceprovisioner.ResourceProvisioner) },
"remote-exec": func() terraform.ResourceProvisioner { return new(remoteexecresourceprovisioner.ResourceProvisioner) },
"file": fileprovisioner.Provisioner,
"local-exec": localexecprovisioner.Provisioner,
"remote-exec": remoteexecprovisioner.Provisioner,
}
func init() {
// Legacy provisioners that don't match our heuristics for auto-finding
// built-in provisioners.
InternalProvisioners["chef"] = func() terraform.ResourceProvisioner { return new(chefprovisioner.ResourceProvisioner) }
}

View File

@ -42,6 +42,8 @@ type Communicator struct {
config *sshConfig
conn net.Conn
address string
lock sync.Mutex
}
type sshConfig struct {
@ -96,6 +98,10 @@ func New(s *terraform.InstanceState) (*Communicator, error) {
// Connect implementation of communicator.Communicator interface
func (c *Communicator) Connect(o terraform.UIOutput) (err error) {
// Grab a lock so we can modify our internal attributes
c.lock.Lock()
defer c.lock.Unlock()
if c.conn != nil {
c.conn.Close()
}
@ -190,8 +196,19 @@ func (c *Communicator) Connect(o terraform.UIOutput) (err error) {
// Disconnect implementation of communicator.Communicator interface
func (c *Communicator) Disconnect() error {
c.lock.Lock()
defer c.lock.Unlock()
if c.config.sshAgent != nil {
return c.config.sshAgent.Close()
if err := c.config.sshAgent.Close(); err != nil {
return err
}
}
if c.conn != nil {
conn := c.conn
c.conn = nil
return conn.Close()
}
return nil

View File

@ -79,10 +79,35 @@ func (r *ConfigFieldReader) readField(
k := strings.Join(address, ".")
schema := schemaList[len(schemaList)-1]
// If we're getting the single element of a promoted list, then
// check to see if we have a single element we need to promote.
if address[len(address)-1] == "0" && len(schemaList) > 1 {
lastSchema := schemaList[len(schemaList)-2]
if lastSchema.Type == TypeList && lastSchema.PromoteSingle {
k := strings.Join(address[:len(address)-1], ".")
result, err := r.readPrimitive(k, schema)
if err == nil {
return result, nil
}
}
}
switch schema.Type {
case TypeBool, TypeFloat, TypeInt, TypeString:
return r.readPrimitive(k, schema)
case TypeList:
// If we support promotion then we first check if we have a lone
// value that we must promote.
// a value that is alone.
if schema.PromoteSingle {
result, err := r.readPrimitive(k, schema.Elem.(*Schema))
if err == nil && result.Exists {
result.Value = []interface{}{result.Value}
return result, nil
}
}
return readListField(&nestedConfigFieldReader{r}, address, schema)
case TypeMap:
return r.readMap(k, schema)

View File

@ -0,0 +1,180 @@
package schema
import (
"context"
"errors"
"fmt"
"sync"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/terraform/config"
"github.com/hashicorp/terraform/terraform"
)
// Provisioner represents a resource provisioner in Terraform and properly
// implements all of the ResourceProvisioner API.
//
// This higher level structure makes it much easier to implement a new or
// custom provisioner for Terraform.
//
// The function callbacks for this structure are all passed a context object.
// This context object has a number of pre-defined values that can be accessed
// via the global functions defined in context.go.
type Provisioner struct {
// ConnSchema is the schema for the connection settings for this
// provisioner.
//
// The keys of this map are the configuration keys, and the value is
// the schema describing the value of the configuration.
//
// NOTE: The value of connection keys can only be strings for now.
ConnSchema map[string]*Schema
// Schema is the schema for the usage of this provisioner.
//
// The keys of this map are the configuration keys, and the value is
// the schema describing the value of the configuration.
Schema map[string]*Schema
// ApplyFunc is the function for executing the provisioner. This is required.
// It is given a context. See the Provisioner struct docs for more
// information.
ApplyFunc func(ctx context.Context) error
stopCtx context.Context
stopCtxCancel context.CancelFunc
stopOnce sync.Once
}
// These constants are the keys that can be used to access data in
// the context parameters for Provisioners.
const (
connDataInvalid int = iota
// This returns a *ResourceData for the connection information.
// Guaranteed to never be nil.
ProvConnDataKey
// This returns a *ResourceData for the config information.
// Guaranteed to never be nil.
ProvConfigDataKey
// This returns a terraform.UIOutput. Guaranteed to never be nil.
ProvOutputKey
// This returns the raw InstanceState passed to Apply. Guaranteed to
// be set, but may be nil.
ProvRawStateKey
)
// InternalValidate should be called to validate the structure
// of the provisioner.
//
// This should be called in a unit test to verify before release that this
// structure is properly configured for use.
func (p *Provisioner) InternalValidate() error {
if p == nil {
return errors.New("provisioner is nil")
}
var validationErrors error
{
sm := schemaMap(p.ConnSchema)
if err := sm.InternalValidate(sm); err != nil {
validationErrors = multierror.Append(validationErrors, err)
}
}
{
sm := schemaMap(p.Schema)
if err := sm.InternalValidate(sm); err != nil {
validationErrors = multierror.Append(validationErrors, err)
}
}
if p.ApplyFunc == nil {
validationErrors = multierror.Append(validationErrors, fmt.Errorf(
"ApplyFunc must not be nil"))
}
return validationErrors
}
// StopContext returns a context that checks whether a provisioner is stopped.
func (p *Provisioner) StopContext() context.Context {
p.stopOnce.Do(p.stopInit)
return p.stopCtx
}
func (p *Provisioner) stopInit() {
p.stopCtx, p.stopCtxCancel = context.WithCancel(context.Background())
}
// Stop implementation of terraform.ResourceProvisioner interface.
func (p *Provisioner) Stop() error {
p.stopOnce.Do(p.stopInit)
p.stopCtxCancel()
return nil
}
func (p *Provisioner) Validate(c *terraform.ResourceConfig) ([]string, []error) {
return schemaMap(p.Schema).Validate(c)
}
// Apply implementation of terraform.ResourceProvisioner interface.
func (p *Provisioner) Apply(
o terraform.UIOutput,
s *terraform.InstanceState,
c *terraform.ResourceConfig) error {
var connData, configData *ResourceData
{
// We first need to turn the connection information into a
// terraform.ResourceConfig so that we can use that type to more
// easily build a ResourceData structure. We do this by simply treating
// the conn info as configuration input.
raw := make(map[string]interface{})
if s != nil {
for k, v := range s.Ephemeral.ConnInfo {
raw[k] = v
}
}
c, err := config.NewRawConfig(raw)
if err != nil {
return err
}
sm := schemaMap(p.ConnSchema)
diff, err := sm.Diff(nil, terraform.NewResourceConfig(c))
if err != nil {
return err
}
connData, err = sm.Data(nil, diff)
if err != nil {
return err
}
}
{
// Build the configuration data. Doing this requires making a "diff"
// even though that's never used. We use that just to get the correct types.
configMap := schemaMap(p.Schema)
diff, err := configMap.Diff(nil, c)
if err != nil {
return err
}
configData, err = configMap.Data(nil, diff)
if err != nil {
return err
}
}
// Build the context and call the function
ctx := p.StopContext()
ctx = context.WithValue(ctx, ProvConnDataKey, connData)
ctx = context.WithValue(ctx, ProvConfigDataKey, configData)
ctx = context.WithValue(ctx, ProvOutputKey, o)
ctx = context.WithValue(ctx, ProvRawStateKey, s)
return p.ApplyFunc(ctx)
}

View File

@ -0,0 +1,277 @@
package schema
import (
"context"
"fmt"
"testing"
"time"
"github.com/hashicorp/terraform/config"
"github.com/hashicorp/terraform/terraform"
)
func TestProvisioner_impl(t *testing.T) {
var _ terraform.ResourceProvisioner = new(Provisioner)
}
func TestProvisionerValidate(t *testing.T) {
cases := []struct {
Name string
P *Provisioner
Config map[string]interface{}
Err bool
}{
{
"Basic required field",
&Provisioner{
Schema: map[string]*Schema{
"foo": &Schema{
Required: true,
Type: TypeString,
},
},
},
nil,
true,
},
{
"Basic required field set",
&Provisioner{
Schema: map[string]*Schema{
"foo": &Schema{
Required: true,
Type: TypeString,
},
},
},
map[string]interface{}{
"foo": "bar",
},
false,
},
}
for i, tc := range cases {
t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) {
c, err := config.NewRawConfig(tc.Config)
if err != nil {
t.Fatalf("err: %s", err)
}
_, es := tc.P.Validate(terraform.NewResourceConfig(c))
if len(es) > 0 != tc.Err {
t.Fatalf("%d: %#v", i, es)
}
})
}
}
func TestProvisionerApply(t *testing.T) {
cases := []struct {
Name string
P *Provisioner
Conn map[string]string
Config map[string]interface{}
Err bool
}{
{
"Basic config",
&Provisioner{
ConnSchema: map[string]*Schema{
"foo": &Schema{
Type: TypeString,
Optional: true,
},
},
Schema: map[string]*Schema{
"foo": &Schema{
Type: TypeInt,
Optional: true,
},
},
ApplyFunc: func(ctx context.Context) error {
cd := ctx.Value(ProvConnDataKey).(*ResourceData)
d := ctx.Value(ProvConfigDataKey).(*ResourceData)
if d.Get("foo").(int) != 42 {
return fmt.Errorf("bad config data")
}
if cd.Get("foo").(string) != "bar" {
return fmt.Errorf("bad conn data")
}
return nil
},
},
map[string]string{
"foo": "bar",
},
map[string]interface{}{
"foo": 42,
},
false,
},
}
for i, tc := range cases {
t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) {
c, err := config.NewRawConfig(tc.Config)
if err != nil {
t.Fatalf("err: %s", err)
}
state := &terraform.InstanceState{
Ephemeral: terraform.EphemeralState{
ConnInfo: tc.Conn,
},
}
err = tc.P.Apply(
nil, state, terraform.NewResourceConfig(c))
if err != nil != tc.Err {
t.Fatalf("%d: %s", i, err)
}
})
}
}
func TestProvisionerApply_nilState(t *testing.T) {
p := &Provisioner{
ConnSchema: map[string]*Schema{
"foo": &Schema{
Type: TypeString,
Optional: true,
},
},
Schema: map[string]*Schema{
"foo": &Schema{
Type: TypeInt,
Optional: true,
},
},
ApplyFunc: func(ctx context.Context) error {
return nil
},
}
conf := map[string]interface{}{
"foo": 42,
}
c, err := config.NewRawConfig(conf)
if err != nil {
t.Fatalf("err: %s", err)
}
err = p.Apply(nil, nil, terraform.NewResourceConfig(c))
if err != nil {
t.Fatalf("err: %s", err)
}
}
func TestProvisionerStop(t *testing.T) {
var p Provisioner
// Verify stopch blocks
ch := p.StopContext().Done()
select {
case <-ch:
t.Fatal("should not be stopped")
case <-time.After(10 * time.Millisecond):
}
// Stop it
if err := p.Stop(); err != nil {
t.Fatalf("err: %s", err)
}
select {
case <-ch:
case <-time.After(10 * time.Millisecond):
t.Fatal("should be stopped")
}
}
func TestProvisionerStop_apply(t *testing.T) {
p := &Provisioner{
ConnSchema: map[string]*Schema{
"foo": &Schema{
Type: TypeString,
Optional: true,
},
},
Schema: map[string]*Schema{
"foo": &Schema{
Type: TypeInt,
Optional: true,
},
},
ApplyFunc: func(ctx context.Context) error {
<-ctx.Done()
return nil
},
}
conn := map[string]string{
"foo": "bar",
}
conf := map[string]interface{}{
"foo": 42,
}
c, err := config.NewRawConfig(conf)
if err != nil {
t.Fatalf("err: %s", err)
}
state := &terraform.InstanceState{
Ephemeral: terraform.EphemeralState{
ConnInfo: conn,
},
}
// Run the apply in a goroutine
doneCh := make(chan struct{})
go func() {
p.Apply(nil, state, terraform.NewResourceConfig(c))
close(doneCh)
}()
// Should block
select {
case <-doneCh:
t.Fatal("should not be done")
case <-time.After(10 * time.Millisecond):
}
// Stop!
p.Stop()
select {
case <-doneCh:
case <-time.After(10 * time.Millisecond):
t.Fatal("should be done")
}
}
func TestProvisionerStop_stopFirst(t *testing.T) {
var p Provisioner
// Stop it
if err := p.Stop(); err != nil {
t.Fatalf("err: %s", err)
}
select {
case <-p.StopContext().Done():
case <-time.After(10 * time.Millisecond):
t.Fatal("should be stopped")
}
}

View File

@ -118,9 +118,16 @@ type Schema struct {
// TypeSet or TypeList. Specific use cases would be if a TypeSet is being
// used to wrap a complex structure, however less than one instance would
// cause instability.
Elem interface{}
MaxItems int
MinItems int
//
// PromoteSingle, if true, will allow single elements to be standalone
// and promote them to a list. For example "foo" would be promoted to
// ["foo"] automatically. This is primarily for legacy reasons and the
// ambiguity is not recommended for new usage. Promotion is only allowed
// for primitive element types.
Elem interface{}
MaxItems int
MinItems int
PromoteSingle bool
// The following fields are only valid for a TypeSet type.
//
@ -1163,6 +1170,14 @@ func (m schemaMap) validateList(
// We use reflection to verify the slice because you can't
// case to []interface{} unless the slice is exactly that type.
rawV := reflect.ValueOf(raw)
// If we support promotion and the raw value isn't a slice, wrap
// it in []interface{} and check again.
if schema.PromoteSingle && rawV.Kind() != reflect.Slice {
raw = []interface{}{raw}
rawV = reflect.ValueOf(raw)
}
if rawV.Kind() != reflect.Slice {
return nil, []error{fmt.Errorf(
"%s: should be a list", k)}

View File

@ -582,6 +582,72 @@ func TestSchemaMap_Diff(t *testing.T) {
Err: false,
},
{
Name: "List decode with promotion",
Schema: map[string]*Schema{
"ports": &Schema{
Type: TypeList,
Required: true,
Elem: &Schema{Type: TypeInt},
PromoteSingle: true,
},
},
State: nil,
Config: map[string]interface{}{
"ports": "5",
},
Diff: &terraform.InstanceDiff{
Attributes: map[string]*terraform.ResourceAttrDiff{
"ports.#": &terraform.ResourceAttrDiff{
Old: "0",
New: "1",
},
"ports.0": &terraform.ResourceAttrDiff{
Old: "",
New: "5",
},
},
},
Err: false,
},
{
Name: "List decode with promotion with list",
Schema: map[string]*Schema{
"ports": &Schema{
Type: TypeList,
Required: true,
Elem: &Schema{Type: TypeInt},
PromoteSingle: true,
},
},
State: nil,
Config: map[string]interface{}{
"ports": []interface{}{"5"},
},
Diff: &terraform.InstanceDiff{
Attributes: map[string]*terraform.ResourceAttrDiff{
"ports.#": &terraform.ResourceAttrDiff{
Old: "0",
New: "1",
},
"ports.0": &terraform.ResourceAttrDiff{
Old: "",
New: "5",
},
},
},
Err: false,
},
{
Schema: map[string]*Schema{
"ports": &Schema{
@ -3853,6 +3919,40 @@ func TestSchemaMap_Validate(t *testing.T) {
Err: true,
},
"List with promotion": {
Schema: map[string]*Schema{
"ingress": &Schema{
Type: TypeList,
Elem: &Schema{Type: TypeInt},
PromoteSingle: true,
Optional: true,
},
},
Config: map[string]interface{}{
"ingress": "5",
},
Err: false,
},
"List with promotion set as list": {
Schema: map[string]*Schema{
"ingress": &Schema{
Type: TypeList,
Elem: &Schema{Type: TypeInt},
PromoteSingle: true,
Optional: true,
},
},
Config: map[string]interface{}{
"ingress": []interface{}{"5"},
},
Err: false,
},
"Optional sub-resource": {
Schema: map[string]*Schema{
"ingress": &Schema{

30
helper/schema/testing.go Normal file
View File

@ -0,0 +1,30 @@
package schema
import (
"testing"
"github.com/hashicorp/terraform/config"
"github.com/hashicorp/terraform/terraform"
)
// TestResourceDataRaw creates a ResourceData from a raw configuration map.
func TestResourceDataRaw(
t *testing.T, schema map[string]*Schema, raw map[string]interface{}) *ResourceData {
c, err := config.NewRawConfig(raw)
if err != nil {
t.Fatalf("err: %s", err)
}
sm := schemaMap(schema)
diff, err := sm.Diff(nil, terraform.NewResourceConfig(c))
if err != nil {
t.Fatalf("err: %s", err)
}
result, err := sm.Data(nil, diff)
if err != nil {
t.Fatalf("err: %s", err)
}
return result
}

View File

@ -77,6 +77,19 @@ func (p *ResourceProvisioner) Apply(
return err
}
func (p *ResourceProvisioner) Stop() error {
var resp ResourceProvisionerStopResponse
err := p.Client.Call("Plugin.Stop", new(interface{}), &resp)
if err != nil {
return err
}
if resp.Error != nil {
err = resp.Error
}
return err
}
func (p *ResourceProvisioner) Close() error {
return p.Client.Close()
}
@ -100,6 +113,10 @@ type ResourceProvisionerApplyResponse struct {
Error *plugin.BasicError
}
type ResourceProvisionerStopResponse struct {
Error *plugin.BasicError
}
// ResourceProvisionerServer is a net/rpc compatible structure for serving
// a ResourceProvisioner. This should not be used directly.
type ResourceProvisionerServer struct {
@ -143,3 +160,14 @@ func (s *ResourceProvisionerServer) Validate(
}
return nil
}
func (s *ResourceProvisionerServer) Stop(
_ interface{},
reply *ResourceProvisionerStopResponse) error {
err := s.Provisioner.Stop()
*reply = ResourceProvisionerStopResponse{
Error: plugin.NewBasicError(err),
}
return nil
}

View File

@ -14,6 +14,61 @@ func TestResourceProvisioner_impl(t *testing.T) {
var _ terraform.ResourceProvisioner = new(ResourceProvisioner)
}
func TestResourceProvisioner_stop(t *testing.T) {
// Create a mock provider
p := new(terraform.MockResourceProvisioner)
client, _ := plugin.TestPluginRPCConn(t, pluginMap(&ServeOpts{
ProvisionerFunc: testProvisionerFixed(p),
}))
defer client.Close()
// Request the provider
raw, err := client.Dispense(ProvisionerPluginName)
if err != nil {
t.Fatalf("err: %s", err)
}
provider := raw.(terraform.ResourceProvisioner)
// Stop
e := provider.Stop()
if !p.StopCalled {
t.Fatal("stop should be called")
}
if e != nil {
t.Fatalf("bad: %#v", e)
}
}
func TestResourceProvisioner_stopErrors(t *testing.T) {
p := new(terraform.MockResourceProvisioner)
p.StopReturnError = errors.New("foo")
// Create a mock provider
client, _ := plugin.TestPluginRPCConn(t, pluginMap(&ServeOpts{
ProvisionerFunc: testProvisionerFixed(p),
}))
defer client.Close()
// Request the provider
raw, err := client.Dispense(ProvisionerPluginName)
if err != nil {
t.Fatalf("err: %s", err)
}
provider := raw.(terraform.ResourceProvisioner)
// Stop
e := provider.Stop()
if !p.StopCalled {
t.Fatal("stop should be called")
}
if e == nil {
t.Fatal("should have error")
}
if e.Error() != "foo" {
t.Fatalf("bad: %s", e)
}
}
func TestResourceProvisioner_apply(t *testing.T) {
// Create a mock provider
p := new(terraform.MockResourceProvisioner)

View File

@ -19,7 +19,7 @@ var Handshake = plugin.HandshakeConfig{
// one or the other that makes it so that they can't safely communicate.
// This could be adding a new interface value, it could be how
// helper/schema computes diffs, etc.
ProtocolVersion: 2,
ProtocolVersion: 3,
// The magic cookie values should NEVER be changed.
MagicCookieKey: "TF_PLUGIN_MAGIC_COOKIE",

View File

@ -91,7 +91,7 @@ func makeProviderMap(items []plugin) string {
func makeProvisionerMap(items []plugin) string {
output := ""
for _, item := range items {
output += fmt.Sprintf("\t\"%s\": func() terraform.ResourceProvisioner { return new(%s.%s) },\n", item.PluginName, item.ImportName, item.TypeName)
output += fmt.Sprintf("\t\"%s\": %s.%s,\n", item.PluginName, item.ImportName, item.TypeName)
}
return output
}
@ -254,8 +254,8 @@ func discoverProviders() ([]plugin, error) {
func discoverProvisioners() ([]plugin, error) {
path := "./builtin/provisioners"
typeID := "ResourceProvisioner"
typeName := ""
typeID := "terraform.ResourceProvisioner"
typeName := "Provisioner"
return discoverTypesInPath(path, typeID, typeName)
}
@ -270,6 +270,9 @@ import (
IMPORTS
"github.com/hashicorp/terraform/plugin"
"github.com/hashicorp/terraform/terraform"
// Legacy, will remove once it conforms with new structure
chefprovisioner "github.com/hashicorp/terraform/builtin/provisioners/chef"
)
var InternalProviders = map[string]plugin.ProviderFunc{
@ -280,4 +283,10 @@ var InternalProvisioners = map[string]plugin.ProvisionerFunc{
PROVISIONERS
}
func init() {
// Legacy provisioners that don't match our heuristics for auto-finding
// built-in provisioners.
InternalProvisioners["chef"] = func() terraform.ResourceProvisioner { return new(chefprovisioner.ResourceProvisioner) }
}
`

View File

@ -7,29 +7,29 @@ func TestMakeProvisionerMap(t *testing.T) {
{
Package: "file",
PluginName: "file",
TypeName: "ResourceProvisioner",
TypeName: "Provisioner",
Path: "builtin/provisioners/file",
ImportName: "fileresourceprovisioner",
ImportName: "fileprovisioner",
},
{
Package: "localexec",
PluginName: "local-exec",
TypeName: "ResourceProvisioner",
TypeName: "Provisioner",
Path: "builtin/provisioners/local-exec",
ImportName: "localexecresourceprovisioner",
ImportName: "localexecprovisioner",
},
{
Package: "remoteexec",
PluginName: "remote-exec",
TypeName: "ResourceProvisioner",
TypeName: "Provisioner",
Path: "builtin/provisioners/remote-exec",
ImportName: "remoteexecresourceprovisioner",
ImportName: "remoteexecprovisioner",
},
})
expected := ` "file": func() terraform.ResourceProvisioner { return new(fileresourceprovisioner.ResourceProvisioner) },
"local-exec": func() terraform.ResourceProvisioner { return new(localexecresourceprovisioner.ResourceProvisioner) },
"remote-exec": func() terraform.ResourceProvisioner { return new(remoteexecresourceprovisioner.ResourceProvisioner) },
expected := ` "file": fileprovisioner.Provisioner,
"local-exec": localexecprovisioner.Provisioner,
"remote-exec": remoteexecprovisioner.Provisioner,
`
if p != expected {
@ -86,13 +86,10 @@ func TestDiscoverTypesProviders(t *testing.T) {
}
func TestDiscoverTypesProvisioners(t *testing.T) {
plugins, err := discoverTypesInPath("../builtin/provisioners", "ResourceProvisioner", "")
plugins, err := discoverTypesInPath("../builtin/provisioners", "terraform.ResourceProvisioner", "Provisioner")
if err != nil {
t.Fatalf(err.Error())
}
if !contains(plugins, "chef") {
t.Errorf("Expected to find chef provisioner")
}
if !contains(plugins, "remote-exec") {
t.Errorf("Expected to find remote-exec provisioner")
}

View File

@ -1,6 +1,7 @@
package terraform
import (
"context"
"fmt"
"log"
"sort"
@ -91,8 +92,10 @@ type Context struct {
l sync.Mutex // Lock acquired during any task
parallelSem Semaphore
providerInputConfig map[string]map[string]interface{}
runCh <-chan struct{}
stopCh chan struct{}
runLock sync.Mutex
runCond *sync.Cond
runContext context.Context
runContextCancel context.CancelFunc
shadowErr error
}
@ -320,8 +323,7 @@ func (c *Context) Interpolater() *Interpolater {
// This modifies the configuration in-place, so asking for Input twice
// may result in different UI output showing different current values.
func (c *Context) Input(mode InputMode) error {
v := c.acquireRun("input")
defer c.releaseRun(v)
defer c.acquireRun("input")()
if mode&InputModeVar != 0 {
// Walk the variables first for the root module. We walk them in
@ -440,8 +442,7 @@ func (c *Context) Input(mode InputMode) error {
// In addition to returning the resulting state, this context is updated
// with the latest state.
func (c *Context) Apply() (*State, error) {
v := c.acquireRun("apply")
defer c.releaseRun(v)
defer c.acquireRun("apply")()
// Copy our own state
c.state = c.state.DeepCopy()
@ -478,8 +479,7 @@ func (c *Context) Apply() (*State, error) {
// Plan also updates the diff of this context to be the diff generated
// by the plan, so Apply can be called after.
func (c *Context) Plan() (*Plan, error) {
v := c.acquireRun("plan")
defer c.releaseRun(v)
defer c.acquireRun("plan")()
p := &Plan{
Module: c.module,
@ -569,8 +569,7 @@ func (c *Context) Plan() (*Plan, error) {
// Even in the case an error is returned, the state will be returned and
// will potentially be partially updated.
func (c *Context) Refresh() (*State, error) {
v := c.acquireRun("refresh")
defer c.releaseRun(v)
defer c.acquireRun("refresh")()
// Copy our own state
c.state = c.state.DeepCopy()
@ -596,30 +595,34 @@ func (c *Context) Refresh() (*State, error) {
//
// Stop will block until the task completes.
func (c *Context) Stop() {
c.l.Lock()
ch := c.runCh
log.Printf("[WARN] terraform: Stop called, initiating interrupt sequence")
// If we aren't running, then just return
if ch == nil {
c.l.Unlock()
return
c.l.Lock()
defer c.l.Unlock()
// If we're running, then stop
if c.runContextCancel != nil {
log.Printf("[WARN] terraform: run context exists, stopping")
// Tell the hook we want to stop
c.sh.Stop()
// Stop the context
c.runContextCancel()
c.runContextCancel = nil
}
// Tell the hook we want to stop
c.sh.Stop()
// Grab the condition var before we exit
if cond := c.runCond; cond != nil {
cond.Wait()
}
// Close the stop channel
close(c.stopCh)
// Wait for us to stop
c.l.Unlock()
<-ch
log.Printf("[WARN] terraform: stop complete")
}
// Validate validates the configuration and returns any warnings or errors.
func (c *Context) Validate() ([]string, []error) {
v := c.acquireRun("validate")
defer c.releaseRun(v)
defer c.acquireRun("validate")()
var errs error
@ -680,26 +683,25 @@ func (c *Context) SetVariable(k string, v interface{}) {
c.variables[k] = v
}
func (c *Context) acquireRun(phase string) chan<- struct{} {
func (c *Context) acquireRun(phase string) func() {
// With the run lock held, grab the context lock to make changes
// to the run context.
c.l.Lock()
defer c.l.Unlock()
dbug.SetPhase(phase)
// Wait for no channel to exist
for c.runCh != nil {
c.l.Unlock()
ch := c.runCh
<-ch
c.l.Lock()
// Wait until we're no longer running
for c.runCond != nil {
c.runCond.Wait()
}
// Create the new channel
ch := make(chan struct{})
c.runCh = ch
// Build our lock
c.runCond = sync.NewCond(&c.l)
// Reset the stop channel so we can watch that
c.stopCh = make(chan struct{})
// Setup debugging
dbug.SetPhase(phase)
// Create a new run context
c.runContext, c.runContextCancel = context.WithCancel(context.Background())
// Reset the stop hook so we're not stopped
c.sh.Reset()
@ -707,10 +709,11 @@ func (c *Context) acquireRun(phase string) chan<- struct{} {
// Reset the shadow errors
c.shadowErr = nil
return ch
return c.releaseRun
}
func (c *Context) releaseRun(ch chan<- struct{}) {
func (c *Context) releaseRun() {
// Grab the context lock so that we can make modifications to fields
c.l.Lock()
defer c.l.Unlock()
@ -719,9 +722,19 @@ func (c *Context) releaseRun(ch chan<- struct{}) {
// phase
dbug.SetPhase("INVALID")
close(ch)
c.runCh = nil
c.stopCh = nil
// End our run. We check if runContext is non-nil because it can be
// set to nil if it was cancelled via Stop()
if c.runContextCancel != nil {
c.runContextCancel()
}
// Unlock all waiting our condition
cond := c.runCond
c.runCond = nil
cond.Broadcast()
// Unset the context
c.runContext = nil
}
func (c *Context) walk(
@ -753,13 +766,15 @@ func (c *Context) walk(
log.Printf("[DEBUG] Starting graph walk: %s", operation.String())
walker := &ContextGraphWalker{
Context: realCtx,
Operation: operation,
Context: realCtx,
Operation: operation,
StopContext: c.runContext,
}
// Watch for a stop so we can call the provider Stop() API.
doneCh := make(chan struct{})
go c.watchStop(walker, c.stopCh, doneCh)
stopCh := c.runContext.Done()
go c.watchStop(walker, doneCh, stopCh)
// Walk the real graph, this will block until it completes
realErr := graph.Walk(walker)
@ -854,7 +869,7 @@ func (c *Context) walk(
return walker, realErr
}
func (c *Context) watchStop(walker *ContextGraphWalker, stopCh, doneCh <-chan struct{}) {
func (c *Context) watchStop(walker *ContextGraphWalker, doneCh, stopCh <-chan struct{}) {
// Wait for a stop or completion
select {
case <-stopCh:
@ -866,20 +881,39 @@ func (c *Context) watchStop(walker *ContextGraphWalker, stopCh, doneCh <-chan st
// If we're here, we're stopped, trigger the call.
// Copy the providers so that a misbehaved blocking Stop doesn't
// completely hang Terraform.
walker.providerLock.Lock()
ps := make([]ResourceProvider, 0, len(walker.providerCache))
for _, p := range walker.providerCache {
ps = append(ps, p)
}
defer walker.providerLock.Unlock()
{
// Copy the providers so that a misbehaved blocking Stop doesn't
// completely hang Terraform.
walker.providerLock.Lock()
ps := make([]ResourceProvider, 0, len(walker.providerCache))
for _, p := range walker.providerCache {
ps = append(ps, p)
}
defer walker.providerLock.Unlock()
for _, p := range ps {
// We ignore the error for now since there isn't any reasonable
// action to take if there is an error here, since the stop is still
// advisory: Terraform will exit once the graph node completes.
p.Stop()
for _, p := range ps {
// We ignore the error for now since there isn't any reasonable
// action to take if there is an error here, since the stop is still
// advisory: Terraform will exit once the graph node completes.
p.Stop()
}
}
{
// Call stop on all the provisioners
walker.provisionerLock.Lock()
ps := make([]ResourceProvisioner, 0, len(walker.provisionerCache))
for _, p := range walker.provisionerCache {
ps = append(ps, p)
}
defer walker.provisionerLock.Unlock()
for _, p := range ps {
// We ignore the error for now since there isn't any reasonable
// action to take if there is an error here, since the stop is still
// advisory: Terraform will exit once the graph node completes.
p.Stop()
}
}
}

View File

@ -1720,6 +1720,145 @@ func TestContext2Apply_cancel(t *testing.T) {
}
}
func TestContext2Apply_cancelBlock(t *testing.T) {
m := testModule(t, "apply-cancel-block")
p := testProvider("aws")
ctx := testContext2(t, &ContextOpts{
Module: m,
Providers: map[string]ResourceProviderFactory{
"aws": testProviderFuncFixed(p),
},
})
applyCh := make(chan struct{})
p.DiffFn = testDiffFn
p.ApplyFn = func(*InstanceInfo, *InstanceState, *InstanceDiff) (*InstanceState, error) {
close(applyCh)
for !ctx.sh.Stopped() {
// Wait for stop to be called
}
// Sleep
time.Sleep(100 * time.Millisecond)
return &InstanceState{
ID: "foo",
}, nil
}
if _, err := ctx.Plan(); err != nil {
t.Fatalf("err: %s", err)
}
// Start the Apply in a goroutine
var applyErr error
stateCh := make(chan *State)
go func() {
state, err := ctx.Apply()
if err != nil {
applyErr = err
}
stateCh <- state
}()
stopDone := make(chan struct{})
go func() {
defer close(stopDone)
<-applyCh
ctx.Stop()
}()
// Make sure that stop blocks
select {
case <-stopDone:
t.Fatal("stop should block")
case <-time.After(10 * time.Millisecond):
}
// Wait for stop
select {
case <-stopDone:
case <-time.After(500 * time.Millisecond):
t.Fatal("stop should be done")
}
// Wait for apply to complete
state := <-stateCh
if applyErr != nil {
t.Fatalf("err: %s", applyErr)
}
checkStateString(t, state, `
aws_instance.foo:
ID = foo
`)
}
func TestContext2Apply_cancelProvisioner(t *testing.T) {
m := testModule(t, "apply-cancel-provisioner")
p := testProvider("aws")
p.ApplyFn = testApplyFn
p.DiffFn = testDiffFn
pr := testProvisioner()
ctx := testContext2(t, &ContextOpts{
Module: m,
Providers: map[string]ResourceProviderFactory{
"aws": testProviderFuncFixed(p),
},
Provisioners: map[string]ResourceProvisionerFactory{
"shell": testProvisionerFuncFixed(pr),
},
})
prStopped := make(chan struct{})
pr.ApplyFn = func(rs *InstanceState, c *ResourceConfig) error {
// Start the stop process
go ctx.Stop()
<-prStopped
return nil
}
pr.StopFn = func() error {
close(prStopped)
return nil
}
if _, err := ctx.Plan(); err != nil {
t.Fatalf("err: %s", err)
}
// Start the Apply in a goroutine
var applyErr error
stateCh := make(chan *State)
go func() {
state, err := ctx.Apply()
if err != nil {
applyErr = err
}
stateCh <- state
}()
// Wait for completion
state := <-stateCh
if applyErr != nil {
t.Fatalf("err: %s", applyErr)
}
checkStateString(t, state, `
aws_instance.foo: (tainted)
ID = foo
num = 2
type = aws_instance
`)
if !pr.StopCalled {
t.Fatal("stop should be called")
}
}
func TestContext2Apply_compute(t *testing.T) {
m := testModule(t, "apply-compute")
p := testProvider("aws")

View File

@ -40,8 +40,7 @@ type ImportTarget struct {
// imported.
func (c *Context) Import(opts *ImportOpts) (*State, error) {
// Hold a lock since we can modify our own state here
v := c.acquireRun("import")
defer c.releaseRun(v)
defer c.acquireRun("import")()
// Copy our own state
c.state = c.state.DeepCopy()

View File

@ -928,6 +928,7 @@ func TestContext2Validate_PlanGraphBuilder(t *testing.T) {
Targets: c.targets,
}).Build(RootModulePath)
defer c.acquireRun("validate-test")()
walker, err := c.walk(graph, graph, walkValidate)
if err != nil {
t.Fatal(err)

View File

@ -8,6 +8,10 @@ import (
// EvalContext is the interface that is given to eval nodes to execute.
type EvalContext interface {
// Stopped returns a channel that is closed when evaluation is stopped
// via Terraform.Context.Stop()
Stopped() <-chan struct{}
// Path is the current module path.
Path() []string

View File

@ -1,6 +1,7 @@
package terraform
import (
"context"
"fmt"
"log"
"strings"
@ -12,6 +13,9 @@ import (
// BuiltinEvalContext is an EvalContext implementation that is used by
// Terraform by default.
type BuiltinEvalContext struct {
// StopContext is the context used to track whether we're complete
StopContext context.Context
// PathValue is the Path that this context is operating within.
PathValue []string
@ -43,6 +47,15 @@ type BuiltinEvalContext struct {
once sync.Once
}
func (ctx *BuiltinEvalContext) Stopped() <-chan struct{} {
// This can happen during tests. During tests, we just block forever.
if ctx.StopContext == nil {
return nil
}
return ctx.StopContext.Done()
}
func (ctx *BuiltinEvalContext) Hook(fn func(Hook) (HookAction, error)) error {
for _, h := range ctx.Hooks {
action, err := fn(h)

View File

@ -9,6 +9,9 @@ import (
// MockEvalContext is a mock version of EvalContext that can be used
// for tests.
type MockEvalContext struct {
StoppedCalled bool
StoppedValue <-chan struct{}
HookCalled bool
HookHook Hook
HookError error
@ -85,6 +88,11 @@ type MockEvalContext struct {
StateLock *sync.RWMutex
}
func (c *MockEvalContext) Stopped() <-chan struct{} {
c.StoppedCalled = true
return c.StoppedValue
}
func (c *MockEvalContext) Hook(fn func(Hook) (HookAction, error)) error {
c.HookCalled = true
if c.HookHook != nil {

View File

@ -1,6 +1,7 @@
package terraform
import (
"context"
"fmt"
"log"
"sync"
@ -15,8 +16,9 @@ type ContextGraphWalker struct {
NullGraphWalker
// Configurable values
Context *Context
Operation walkOperation
Context *Context
Operation walkOperation
StopContext context.Context
// Outputs, do not set these. Do not read these while the graph
// is being walked.
@ -65,6 +67,7 @@ func (w *ContextGraphWalker) EnterPath(path []string) EvalContext {
w.interpolaterVarLock.Unlock()
ctx := &BuiltinEvalContext{
StopContext: w.StopContext,
PathValue: path,
Hooks: w.Context.hooks,
InputValue: w.Context.uiInput,

View File

@ -21,6 +21,26 @@ type ResourceProvisioner interface {
// is provided since provisioners only run after a resource has been
// newly created.
Apply(UIOutput, *InstanceState, *ResourceConfig) error
// Stop is called when the provisioner should halt any in-flight actions.
//
// This can be used to make a nicer Ctrl-C experience for Terraform.
// Even if this isn't implemented to do anything (just returns nil),
// Terraform will still cleanly stop after the currently executing
// graph node is complete. However, this API can be used to make more
// efficient halts.
//
// Stop doesn't have to and shouldn't block waiting for in-flight actions
// to complete. It should take any action it wants and return immediately
// acknowledging it has received the stop request. Terraform core will
// automatically not make any further API calls to the provider soon
// after Stop is called (technically exactly once the currently executing
// graph nodes are complete).
//
// The error returned, if non-nil, is assumed to mean that signaling the
// stop somehow failed and that the user should expect potentially waiting
// a longer period of time.
Stop() error
}
// ResourceProvisionerCloser is an interface that provisioners that can close

View File

@ -21,6 +21,10 @@ type MockResourceProvisioner struct {
ValidateFn func(c *ResourceConfig) ([]string, []error)
ValidateReturnWarns []string
ValidateReturnErrors []error
StopCalled bool
StopFn func() error
StopReturnError error
}
func (p *MockResourceProvisioner) Validate(c *ResourceConfig) ([]string, []error) {
@ -40,14 +44,29 @@ func (p *MockResourceProvisioner) Apply(
state *InstanceState,
c *ResourceConfig) error {
p.Lock()
defer p.Unlock()
p.ApplyCalled = true
p.ApplyOutput = output
p.ApplyState = state
p.ApplyConfig = c
if p.ApplyFn != nil {
return p.ApplyFn(state, c)
fn := p.ApplyFn
p.Unlock()
return fn(state, c)
}
defer p.Unlock()
return p.ApplyReturnError
}
func (p *MockResourceProvisioner) Stop() error {
p.Lock()
defer p.Unlock()
p.StopCalled = true
if p.StopFn != nil {
return p.StopFn()
}
return p.StopReturnError
}

View File

@ -88,7 +88,8 @@ func newShadowContext(c *Context) (*Context, *Context, Shadow) {
// l - no copy
parallelSem: c.parallelSem,
providerInputConfig: c.providerInputConfig,
runCh: c.runCh,
runContext: c.runContext,
runContextCancel: c.runContextCancel,
shadowErr: c.shadowErr,
}

View File

@ -112,6 +112,10 @@ func (p *shadowResourceProvisionerReal) Apply(
return err
}
func (p *shadowResourceProvisionerReal) Stop() error {
return p.ResourceProvisioner.Stop()
}
// shadowResourceProvisionerShadow is the shadow resource provisioner. Function
// calls never affect real resources. This is paired with the "real" side
// which must be called properly to enable recording.
@ -228,6 +232,13 @@ func (p *shadowResourceProvisionerShadow) Apply(
return result.ResultErr
}
func (p *shadowResourceProvisionerShadow) Stop() error {
// For the shadow, we always just return nil since a Stop indicates
// that we were interrupted and shadows are disabled during interrupts
// anyways.
return nil
}
// The structs for the various function calls are put below. These structs
// are used to carry call information across the real/shadow boundaries.

View File

@ -0,0 +1,3 @@
resource "aws_instance" "foo" {
num = "2"
}

View File

@ -0,0 +1,7 @@
resource "aws_instance" "foo" {
num = "2"
provisioner "shell" {
foo = "bar"
}
}