mirror of
https://github.com/opentofu/opentofu.git
synced 2025-01-09 15:43:53 -06:00
7fe34d4547
Each acceptance test step plays a Refresh, Plan, Apply for a given config. This adds a follow up Plan and fails the test if it does not come back empty. This will catch issues with perpetual, unresolvable diffs that crop up here and there. This is going to cause a lot of our existing acceptance tests to fail - too many to roll into a single PR. I think the best plan is to land this in master and then fix the failures (each of which should be catching a legitimate provider bug) one by one until we get the provider suites back to green.
309 lines
8.4 KiB
Go
309 lines
8.4 KiB
Go
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
|