From 2d99c451fbe28622eb95c5286afaebb9acc616fd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 9 May 2016 20:32:31 -0700 Subject: [PATCH] helper/resource: basic ImportState acceptance testing Still some TODOs, and more test cases to write, but the basics are all here. --- helper/resource/testing.go | 175 +++++++------------ helper/resource/testing_config.go | 128 ++++++++++++++ helper/resource/testing_import_state.go | 71 ++++++++ helper/resource/testing_import_state_test.go | 57 ++++++ 4 files changed, 317 insertions(+), 114 deletions(-) create mode 100644 helper/resource/testing_config.go create mode 100644 helper/resource/testing_import_state.go create mode 100644 helper/resource/testing_import_state_test.go diff --git a/helper/resource/testing.go b/helper/resource/testing.go index 31f572b49..19cbe4a1f 100644 --- a/helper/resource/testing.go +++ b/helper/resource/testing.go @@ -31,6 +31,9 @@ const UnitTestOverride = "UnitTestOverride" // it was created. 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 // lifecycle of a resource in a specific configuration. // @@ -81,6 +84,14 @@ type TestCase struct { // potentially complex update logic. In general, simply create/destroy // tests will only need one step. 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 // setup that needs to happen. This is called regardless of "test mode" // below. @@ -119,6 +130,25 @@ type TestStep struct { // ExpectNonEmptyPlan can be set to true for specific types of tests that are // looking to verify that a diff occurs 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. @@ -180,7 +210,19 @@ func Test(t TestT, c TestCase) { for i, step := range c.Steps { var err error 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 { errored = true t.Error(fmt.Sprintf( @@ -215,7 +257,7 @@ func Test(t TestT, c TestCase) { if err := testIDOnlyRefresh(c, opts, step, idRefreshCheck); err != nil { log.Printf("[ERROR] Test: ID-only test failed: %s", err) t.Error(fmt.Sprintf( - "ID-Only refresh test failure: %s", err)) + "[ERROR] Test: ID-only test failed: %s", err)) break } } @@ -367,118 +409,6 @@ func testIDOnlyRefresh(c TestCase, opts terraform.ContextOpts, step TestStep, r 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( opts terraform.ContextOpts, step TestStep) (*module.Tree, error) { @@ -526,6 +456,23 @@ func testModule( 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 // a single TestCheckFunc. // diff --git a/helper/resource/testing_config.go b/helper/resource/testing_config.go new file mode 100644 index 000000000..62c01954c --- /dev/null +++ b/helper/resource/testing_config.go @@ -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 +} diff --git a/helper/resource/testing_import_state.go b/helper/resource/testing_import_state.go new file mode 100644 index 000000000..d93bc76eb --- /dev/null +++ b/helper/resource/testing_import_state.go @@ -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 +} diff --git a/helper/resource/testing_import_state_test.go b/helper/resource/testing_import_state_test.go new file mode 100644 index 000000000..211258905 --- /dev/null +++ b/helper/resource/testing_import_state_test.go @@ -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") + } +}