mirror of
https://github.com/opentofu/opentofu.git
synced 2025-01-26 16:36:26 -06:00
97acccd3ed
Add `-target=resource` flag to core operations, allowing users to target specific resources in their infrastructure. When `-target` is used, the operation will only apply to that resource and its dependencies. The calculated dependencies are different depending on whether we're running a normal operation or a `terraform destroy`. Generally, "dependencies" refers to ancestors: resources falling _before_ the target in the graph, because their changes are required to accurately act on the target. For destroys, "dependencies" are descendents: those resources which fall _after_ the target. These resources depend on our target, which is going to be destroyed, so they should also be destroyed.
299 lines
8.0 KiB
Go
299 lines
8.0 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)
|
|
}
|
|
}
|
|
|
|
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
|