helper/schema: Provisioner support

This commit is contained in:
Mitchell Hashimoto 2016-12-22 13:57:42 -08:00
parent 96884ec42d
commit b2891bc9ef
No known key found for this signature in database
GPG Key ID: 744E147AA52F5B0A
2 changed files with 414 additions and 0 deletions

View File

@ -0,0 +1,173 @@
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
)
// 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{})
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)
return p.ApplyFunc(ctx)
}

View File

@ -0,0 +1,241 @@
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 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")
}
}