helper/resource: basic ImportState acceptance testing

Still some TODOs, and more test cases to write, but the basics are all
here.
This commit is contained in:
Mitchell Hashimoto 2016-05-09 20:32:31 -07:00
parent 9bd1c9e7ca
commit 2d99c451fb
No known key found for this signature in database
GPG Key ID: 744E147AA52F5B0A
4 changed files with 317 additions and 114 deletions

View File

@ -31,6 +31,9 @@ const UnitTestOverride = "UnitTestOverride"
// it was created. // it was created.
type TestCheckFunc func(*terraform.State) error type TestCheckFunc func(*terraform.State) error
// ImportStateCheckFunc is the check function for ImportState tests
type ImportStateCheckFunc func([]*terraform.InstanceState) error
// TestCase is a single acceptance test case used to test the apply/destroy // TestCase is a single acceptance test case used to test the apply/destroy
// lifecycle of a resource in a specific configuration. // lifecycle of a resource in a specific configuration.
// //
@ -81,6 +84,14 @@ type TestCase struct {
// potentially complex update logic. In general, simply create/destroy // potentially complex update logic. In general, simply create/destroy
// tests will only need one step. // tests will only need one step.
type TestStep struct { type TestStep struct {
// ResourceName should be set to the name of the resource
// that is being tested. Example: "aws_instance.foo". Various test
// modes use this to auto-detect state information.
//
// This is only required if the test mode settings below say it is
// for the mode you're using.
ResourceName string
// PreConfig is called before the Config is applied to perform any per-step // PreConfig is called before the Config is applied to perform any per-step
// setup that needs to happen. This is called regardless of "test mode" // setup that needs to happen. This is called regardless of "test mode"
// below. // below.
@ -119,6 +130,25 @@ type TestStep struct {
// ExpectNonEmptyPlan can be set to true for specific types of tests that are // ExpectNonEmptyPlan can be set to true for specific types of tests that are
// looking to verify that a diff occurs // looking to verify that a diff occurs
ExpectNonEmptyPlan bool ExpectNonEmptyPlan bool
//---------------------------------------------------------------
// ImportState testing
//---------------------------------------------------------------
// ImportState, if true, will test the functionality of ImportState
// by importing the resource with ResourceName (must be set) and the
// ID of that resource.
ImportState bool
// ImportStateId is the ID to perform an ImportState operation with.
// This is optional. If it isn't set, then the resource ID is automatically
// determined by inspecting the state for ResourceName's ID.
ImportStateId string
// ImportStateCheck checks the results of ImportState. It should be
// used to verify that the resulting value of ImportState has the
// proper resources, IDs, and attributes.
ImportStateCheck ImportStateCheckFunc
} }
// Test performs an acceptance test on a resource. // Test performs an acceptance test on a resource.
@ -180,7 +210,19 @@ func Test(t TestT, c TestCase) {
for i, step := range c.Steps { for i, step := range c.Steps {
var err error var err error
log.Printf("[WARN] Test: Executing step %d", i) log.Printf("[WARN] Test: Executing step %d", i)
state, err = testStep(opts, state, step)
// Determine the test mode to execute
if step.Config != "" {
state, err = testStepConfig(opts, state, step)
} else if step.ImportState {
state, err = testStepImportState(opts, state, step)
} else {
err = fmt.Errorf(
"unknown test mode for step. Please see TestStep docs\n\n%#v",
step)
}
// If there was an error, exit
if err != nil { if err != nil {
errored = true errored = true
t.Error(fmt.Sprintf( t.Error(fmt.Sprintf(
@ -215,7 +257,7 @@ func Test(t TestT, c TestCase) {
if err := testIDOnlyRefresh(c, opts, step, idRefreshCheck); err != nil { if err := testIDOnlyRefresh(c, opts, step, idRefreshCheck); err != nil {
log.Printf("[ERROR] Test: ID-only test failed: %s", err) log.Printf("[ERROR] Test: ID-only test failed: %s", err)
t.Error(fmt.Sprintf( t.Error(fmt.Sprintf(
"ID-Only refresh test failure: %s", err)) "[ERROR] Test: ID-only test failed: %s", err))
break break
} }
} }
@ -367,118 +409,6 @@ func testIDOnlyRefresh(c TestCase, opts terraform.ContextOpts, step TestStep, r
return nil return nil
} }
func testStep(
opts terraform.ContextOpts,
state *terraform.State,
step TestStep) (*terraform.State, error) {
mod, err := testModule(opts, step)
if err != nil {
return state, err
}
// Build the context
opts.Module = mod
opts.State = state
opts.Destroy = step.Destroy
ctx, err := terraform.NewContext(&opts)
if err != nil {
return state, fmt.Errorf("Error initializing context: %s", err)
}
if ws, es := ctx.Validate(); len(ws) > 0 || len(es) > 0 {
if len(es) > 0 {
estrs := make([]string, len(es))
for i, e := range es {
estrs[i] = e.Error()
}
return state, fmt.Errorf(
"Configuration is invalid.\n\nWarnings: %#v\n\nErrors: %#v",
ws, estrs)
}
log.Printf("[WARN] Config warnings: %#v", ws)
}
// Refresh!
state, err = ctx.Refresh()
if err != nil {
return state, fmt.Errorf(
"Error refreshing: %s", err)
}
// Plan!
if p, err := ctx.Plan(); err != nil {
return state, fmt.Errorf(
"Error planning: %s", err)
} else {
log.Printf("[WARN] Test: Step plan: %s", p)
}
// We need to keep a copy of the state prior to destroying
// such that destroy steps can verify their behaviour in the check
// function
stateBeforeApplication := state.DeepCopy()
// Apply!
state, err = ctx.Apply()
if err != nil {
return state, fmt.Errorf("Error applying: %s", err)
}
// Check! Excitement!
if step.Check != nil {
if step.Destroy {
if err := step.Check(stateBeforeApplication); err != nil {
return state, fmt.Errorf("Check failed: %s", err)
}
} else {
if err := step.Check(state); err != nil {
return state, fmt.Errorf("Check failed: %s", err)
}
}
}
// Now, verify that Plan is now empty and we don't have a perpetual diff issue
// We do this with TWO plans. One without a refresh.
var p *terraform.Plan
if p, err = ctx.Plan(); err != nil {
return state, fmt.Errorf("Error on follow-up plan: %s", err)
}
if p.Diff != nil && !p.Diff.Empty() {
if step.ExpectNonEmptyPlan {
log.Printf("[INFO] Got non-empty plan, as expected:\n\n%s", p)
} else {
return state, fmt.Errorf(
"After applying this step, the plan was not empty:\n\n%s", p)
}
}
// And another after a Refresh.
state, err = ctx.Refresh()
if err != nil {
return state, fmt.Errorf(
"Error on follow-up refresh: %s", err)
}
if p, err = ctx.Plan(); err != nil {
return state, fmt.Errorf("Error on second follow-up plan: %s", err)
}
if p.Diff != nil && !p.Diff.Empty() {
if step.ExpectNonEmptyPlan {
log.Printf("[INFO] Got non-empty plan, as expected:\n\n%s", p)
} else {
return state, fmt.Errorf(
"After applying this step and refreshing, "+
"the plan was not empty:\n\n%s", p)
}
}
// Made it here, but expected a non-empty plan, fail!
if step.ExpectNonEmptyPlan && (p.Diff == nil || p.Diff.Empty()) {
return state, fmt.Errorf("Expected a non-empty plan, but got an empty plan!")
}
// Made it here? Good job test step!
return state, nil
}
func testModule( func testModule(
opts terraform.ContextOpts, opts terraform.ContextOpts,
step TestStep) (*module.Tree, error) { step TestStep) (*module.Tree, error) {
@ -526,6 +456,23 @@ func testModule(
return mod, nil return mod, nil
} }
func testResource(c TestStep, state *terraform.State) (*terraform.ResourceState, error) {
if c.ResourceName == "" {
return nil, fmt.Errorf("ResourceName must be set in TestStep")
}
for _, m := range state.Modules {
if len(m.Resources) > 0 {
if v, ok := m.Resources[c.ResourceName]; ok {
return v, nil
}
}
}
return nil, fmt.Errorf(
"Resource specified by ResourceName couldn't be found: %s", c.ResourceName)
}
// ComposeTestCheckFunc lets you compose multiple TestCheckFuncs into // ComposeTestCheckFunc lets you compose multiple TestCheckFuncs into
// a single TestCheckFunc. // a single TestCheckFunc.
// //

View File

@ -0,0 +1,128 @@
package resource
import (
"fmt"
"log"
"github.com/hashicorp/terraform/terraform"
)
// testStepConfig runs a config-mode test step
func testStepConfig(
opts terraform.ContextOpts,
state *terraform.State,
step TestStep) (*terraform.State, error) {
return testStep(opts, state, step)
}
func testStep(
opts terraform.ContextOpts,
state *terraform.State,
step TestStep) (*terraform.State, error) {
mod, err := testModule(opts, step)
if err != nil {
return state, err
}
// Build the context
opts.Module = mod
opts.State = state
opts.Destroy = step.Destroy
ctx, err := terraform.NewContext(&opts)
if err != nil {
return state, fmt.Errorf("Error initializing context: %s", err)
}
if ws, es := ctx.Validate(); len(ws) > 0 || len(es) > 0 {
if len(es) > 0 {
estrs := make([]string, len(es))
for i, e := range es {
estrs[i] = e.Error()
}
return state, fmt.Errorf(
"Configuration is invalid.\n\nWarnings: %#v\n\nErrors: %#v",
ws, estrs)
}
log.Printf("[WARN] Config warnings: %#v", ws)
}
// Refresh!
state, err = ctx.Refresh()
if err != nil {
return state, fmt.Errorf(
"Error refreshing: %s", err)
}
// Plan!
if p, err := ctx.Plan(); err != nil {
return state, fmt.Errorf(
"Error planning: %s", err)
} else {
log.Printf("[WARN] Test: Step plan: %s", p)
}
// We need to keep a copy of the state prior to destroying
// such that destroy steps can verify their behaviour in the check
// function
stateBeforeApplication := state.DeepCopy()
// Apply!
state, err = ctx.Apply()
if err != nil {
return state, fmt.Errorf("Error applying: %s", err)
}
// Check! Excitement!
if step.Check != nil {
if step.Destroy {
if err := step.Check(stateBeforeApplication); err != nil {
return state, fmt.Errorf("Check failed: %s", err)
}
} else {
if err := step.Check(state); err != nil {
return state, fmt.Errorf("Check failed: %s", err)
}
}
}
// Now, verify that Plan is now empty and we don't have a perpetual diff issue
// We do this with TWO plans. One without a refresh.
var p *terraform.Plan
if p, err = ctx.Plan(); err != nil {
return state, fmt.Errorf("Error on follow-up plan: %s", err)
}
if p.Diff != nil && !p.Diff.Empty() {
if step.ExpectNonEmptyPlan {
log.Printf("[INFO] Got non-empty plan, as expected:\n\n%s", p)
} else {
return state, fmt.Errorf(
"After applying this step, the plan was not empty:\n\n%s", p)
}
}
// And another after a Refresh.
state, err = ctx.Refresh()
if err != nil {
return state, fmt.Errorf(
"Error on follow-up refresh: %s", err)
}
if p, err = ctx.Plan(); err != nil {
return state, fmt.Errorf("Error on second follow-up plan: %s", err)
}
if p.Diff != nil && !p.Diff.Empty() {
if step.ExpectNonEmptyPlan {
log.Printf("[INFO] Got non-empty plan, as expected:\n\n%s", p)
} else {
return state, fmt.Errorf(
"After applying this step and refreshing, "+
"the plan was not empty:\n\n%s", p)
}
}
// Made it here, but expected a non-empty plan, fail!
if step.ExpectNonEmptyPlan && (p.Diff == nil || p.Diff.Empty()) {
return state, fmt.Errorf("Expected a non-empty plan, but got an empty plan!")
}
// Made it here? Good job test step!
return state, nil
}

View File

@ -0,0 +1,71 @@
package resource
import (
"log"
"github.com/hashicorp/terraform/terraform"
)
// testStepImportState runs an imort state test step
func testStepImportState(
opts terraform.ContextOpts,
state *terraform.State,
step TestStep) (*terraform.State, error) {
// Determine the ID to import
importId := step.ImportStateId
if importId == "" {
resource, err := testResource(step, state)
if err != nil {
return state, err
}
importId = resource.Primary.ID
}
// Setup the context. We initialize with an empty state. We use the
// full config for provider configurations.
mod, err := testModule(opts, step)
if err != nil {
return state, err
}
opts.Module = mod
opts.State = terraform.NewState()
ctx, err := terraform.NewContext(&opts)
if err != nil {
return state, err
}
// TODO: ImportOpts needs a flag to read a config module so it
// can load our provider config without env vars.
// Do the import!
newState, err := ctx.Import(&terraform.ImportOpts{
Targets: []*terraform.ImportTarget{
&terraform.ImportTarget{
Addr: step.ResourceName,
ID: importId,
},
},
})
if err != nil {
log.Printf("[ERROR] Test: ImportState failure: %s", err)
return state, err
}
// Go through the new state and verify
if step.ImportStateCheck != nil {
var states []*terraform.InstanceState
for _, r := range newState.RootModule().Resources {
if r.Primary != nil {
states = append(states, r.Primary)
}
}
if err := step.ImportStateCheck(states); err != nil {
return state, err
}
}
// Return the old state (non-imported) so we don't change anything.
return state, nil
}

View File

@ -0,0 +1,57 @@
package resource
import (
"fmt"
"testing"
"github.com/hashicorp/terraform/terraform"
)
func TestTest_importState(t *testing.T) {
mp := testProvider()
mp.ImportStateReturn = []*terraform.InstanceState{
&terraform.InstanceState{
ID: "foo",
Ephemeral: terraform.EphemeralState{Type: "test_instance"},
},
}
mp.RefreshFn = func(
i *terraform.InstanceInfo,
s *terraform.InstanceState) (*terraform.InstanceState, error) {
return s, nil
}
checked := false
checkFn := func(s []*terraform.InstanceState) error {
checked = true
if s[0].ID != "foo" {
return fmt.Errorf("bad: %#v", s)
}
return nil
}
mt := new(mockT)
Test(mt, TestCase{
Providers: map[string]terraform.ResourceProvider{
"test": mp,
},
Steps: []TestStep{
TestStep{
ResourceName: "test_instance.foo",
ImportState: true,
ImportStateId: "foo",
ImportStateCheck: checkFn,
},
},
})
if mt.failed() {
t.Fatalf("test failed: %s", mt.failMessage())
}
if !checked {
t.Fatal("didn't call check")
}
}