package resource import ( "fmt" "io" "io/ioutil" "log" "os" "path/filepath" "strings" "testing" "github.com/hashicorp/terraform/config/module" "github.com/hashicorp/terraform/terraform" ) const TestEnvVar = "TF_ACC" // TestCheckFunc is the callback type used with acceptance tests to check // the state of a resource. The state passed in is the latest state known, // or in the case of being after a destroy, it is the last known state when // it was created. type TestCheckFunc func(*terraform.State) error // TestCase is a single acceptance test case used to test the apply/destroy // lifecycle of a resource in a specific configuration. // // When the destroy plan is executed, the config from the last TestStep // is used to plan it. type TestCase struct { // PreCheck, if non-nil, will be called before any test steps are // executed. It will only be executed in the case that the steps // would run, so it can be used for some validation before running // acceptance tests, such as verifying that keys are setup. PreCheck func() // Provider is the ResourceProvider that will be under test. Providers map[string]terraform.ResourceProvider // CheckDestroy is called after the resource is finally destroyed // to allow the tester to test that the resource is truly gone. CheckDestroy TestCheckFunc // Steps are the apply sequences done within the context of the // same state. Each step can have its own check to verify correctness. Steps []TestStep } // TestStep is a single apply sequence of a test, done within the // context of a state. // // Multiple TestSteps can be sequenced in a Test to allow testing // potentially complex update logic. In general, simply create/destroy // tests will only need one step. type TestStep struct { // Config a string of the configuration to give to Terraform. Config string // Check is called after the Config is applied. Use this step to // make your own API calls to check the status of things, and to // inspect the format of the ResourceState itself. // // If an error is returned, the test will fail. In this case, a // destroy plan will still be attempted. // // If this is nil, no check is done on this step. Check TestCheckFunc // Destroy will create a destroy plan if set to true. Destroy bool } // Test performs an acceptance test on a resource. // // Tests are not run unless an environmental variable "TF_ACC" is // set to some non-empty value. This is to avoid test cases surprising // a user by creating real resources. // // Tests will fail unless the verbose flag (`go test -v`, or explicitly // the "-test.v" flag) is set. Because some acceptance tests take quite // long, we require the verbose flag so users are able to see progress // output. func Test(t TestT, c TestCase) { // We only run acceptance tests if an env var is set because they're // slow and generally require some outside configuration. if os.Getenv(TestEnvVar) == "" { t.Skip(fmt.Sprintf( "Acceptance tests skipped unless env '%s' set", TestEnvVar)) return } // We require verbose mode so that the user knows what is going on. if !testTesting && !testing.Verbose() { t.Fatal("Acceptance tests must be run with the -v flag on tests") return } // Run the PreCheck if we have it if c.PreCheck != nil { c.PreCheck() } // Build our context options that we can ctxProviders := make(map[string]terraform.ResourceProviderFactory) for k, p := range c.Providers { ctxProviders[k] = terraform.ResourceProviderFactoryFixed(p) } opts := terraform.ContextOpts{Providers: ctxProviders} // A single state variable to track the lifecycle, starting with no state var state *terraform.State // Go through each step and run it for i, step := range c.Steps { var err error log.Printf("[WARN] Test: Executing step %d", i) state, err = testStep(opts, state, step) if err != nil { t.Error(fmt.Sprintf( "Step %d error: %s", i, err)) break } } // If we have a state, then run the destroy if state != nil { destroyStep := TestStep{ Config: c.Steps[len(c.Steps)-1].Config, Check: c.CheckDestroy, Destroy: true, } log.Printf("[WARN] Test: Executing destroy step") state, err := testStep(opts, state, destroyStep) if err != nil { t.Error(fmt.Sprintf( "Error destroying resource! WARNING: Dangling resources\n"+ "may exist. The full state and error is shown below.\n\n"+ "Error: %s\n\nState: %s", err, state)) } } else { log.Printf("[WARN] Skipping destroy test since there is no state.") } } func testStep( opts terraform.ContextOpts, state *terraform.State, step TestStep) (*terraform.State, error) { cfgPath, err := ioutil.TempDir("", "tf-test") if err != nil { return state, fmt.Errorf( "Error creating temporary directory for config: %s", err) } defer os.RemoveAll(cfgPath) // Write the configuration cfgF, err := os.Create(filepath.Join(cfgPath, "main.tf")) if err != nil { return state, fmt.Errorf( "Error creating temporary file for config: %s", err) } _, err = io.Copy(cfgF, strings.NewReader(step.Config)) cfgF.Close() if err != nil { return state, fmt.Errorf( "Error creating temporary file for config: %s", err) } // Parse the configuration mod, err := module.NewTreeModule("", cfgPath) if err != nil { return state, fmt.Errorf( "Error loading configuration: %s", err) } // Load the modules modStorage := &module.FolderStorage{ StorageDir: filepath.Join(cfgPath, ".tfmodules"), } err = mod.Load(modStorage, module.GetModeGet) if err != nil { return state, fmt.Errorf("Error downloading modules: %s", err) } // Build the context opts.Module = mod opts.State = state opts.Destroy = step.Destroy ctx := terraform.NewContext(&opts) if ws, es := ctx.Validate(); len(ws) > 0 || 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) } // 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) } // Apply! state, err = ctx.Apply() if err != nil { return state, fmt.Errorf("Error applying: %s", err) } // Check! Excitement! if step.Check != nil { if err = step.Check(state); err != nil { err = fmt.Errorf("Check failed: %s", err) } } // Verify that Plan is now empty and we don't have a perpetual diff issue if p, err := ctx.Plan(); err != nil { return state, fmt.Errorf("Error on follow-up plan: %s", err) } else { if p.Diff != nil && !p.Diff.Empty() { return state, fmt.Errorf( "After applying this step, the plan was not empty:\n\n%s", p) } } return state, err } // ComposeTestCheckFunc lets you compose multiple TestCheckFuncs into // a single TestCheckFunc. // // As a user testing their provider, this lets you decompose your checks // into smaller pieces more easily. func ComposeTestCheckFunc(fs ...TestCheckFunc) TestCheckFunc { return func(s *terraform.State) error { for _, f := range fs { if err := f(s); err != nil { return err } } return nil } } func TestCheckResourceAttr(name, key, value string) TestCheckFunc { return func(s *terraform.State) error { ms := s.RootModule() rs, ok := ms.Resources[name] if !ok { return fmt.Errorf("Not found: %s", name) } is := rs.Primary if is == nil { return fmt.Errorf("No primary instance: %s", name) } if is.Attributes[key] != value { return fmt.Errorf( "%s: Attribute '%s' expected %#v, got %#v", name, key, value, is.Attributes[key]) } return nil } } // TestCheckResourceAttrPtr is like TestCheckResourceAttr except the // value is a pointer so that it can be updated while the test is running. // It will only be dereferenced at the point this step is run. func TestCheckResourceAttrPtr(name string, key string, value *string) TestCheckFunc { return func(s *terraform.State) error { return TestCheckResourceAttr(name, key, *value)(s) } } // TestT is the interface used to handle the test lifecycle of a test. // // Users should just use a *testing.T object, which implements this. type TestT interface { Error(args ...interface{}) Fatal(args ...interface{}) Skip(args ...interface{}) } // This is set to true by unit tests to alter some behavior var testTesting = false