terraform/internal/command/test.go

713 lines
27 KiB
Go

package command
import (
"context"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"strings"
ctyjson "github.com/zclconf/go-cty/cty/json"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/format"
"github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/configs/configload"
"github.com/hashicorp/terraform/internal/depsfile"
"github.com/hashicorp/terraform/internal/initwd"
"github.com/hashicorp/terraform/internal/moduletest"
"github.com/hashicorp/terraform/internal/providercache"
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/hashicorp/terraform/plans"
"github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/terraform"
)
// TestCommand is the implementation of "terraform test".
type TestCommand struct {
Meta
}
func (c *TestCommand) Run(rawArgs []string) int {
// Parse and apply global view arguments
common, rawArgs := arguments.ParseView(rawArgs)
c.View.Configure(common)
args, diags := arguments.ParseTest(rawArgs)
view := views.NewTest(c.View, args.Output)
if diags.HasErrors() {
view.Diagnostics(diags)
return 1
}
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Warning,
`The "terraform test" command is experimental`,
"We'd like to invite adventurous module authors to write integration tests for their modules using this command, but all of the behaviors of this command are currently experimental and may change based on feedback.\n\nFor more information on the testing experiment, including ongoing research goals and avenues for feedback, see:\n https://www.terraform.io/docs/language/modules/testing-experiment.html",
))
ctx, cancel := c.InterruptibleContext()
defer cancel()
results, moreDiags := c.run(ctx, args)
diags = diags.Append(moreDiags)
initFailed := diags.HasErrors()
view.Diagnostics(diags)
diags = view.Results(results)
resultsFailed := diags.HasErrors()
view.Diagnostics(diags) // possible additional errors from saving the results
var testsFailed bool
for _, suite := range results {
for _, component := range suite.Components {
for _, assertion := range component.Assertions {
if !assertion.Outcome.SuiteCanPass() {
testsFailed = true
}
}
}
}
// Lots of things can possibly have failed
if initFailed || resultsFailed || testsFailed {
return 1
}
return 0
}
func (c *TestCommand) run(ctx context.Context, args arguments.Test) (results map[string]*moduletest.Suite, diags tfdiags.Diagnostics) {
suiteNames, err := c.collectSuiteNames()
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Error while searching for test configurations",
fmt.Sprintf("While attempting to scan the 'tests' subdirectory for potential test configurations, Terraform encountered an error: %s.", err),
))
return nil, diags
}
ret := make(map[string]*moduletest.Suite, len(suiteNames))
for _, suiteName := range suiteNames {
if ctx.Err() != nil {
// If the context has already failed in some way then we'll
// halt early and report whatever's already happened.
break
}
suite, moreDiags := c.runSuite(ctx, suiteName)
diags = diags.Append(moreDiags)
ret[suiteName] = suite
}
return ret, diags
}
func (c *TestCommand) runSuite(ctx context.Context, suiteName string) (*moduletest.Suite, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
ret := moduletest.Suite{
Name: suiteName,
Components: map[string]*moduletest.Component{},
}
// In order to make this initial round of "terraform test" pretty self
// contained while it's experimental, it's largely just mimicking what
// would happen when running the main Terraform workflow commands, which
// comes at the expense of a few irritants that we'll hopefully resolve
// in future iterations as the design solidifies:
// - We need to install remote modules separately for each of the
// test suites, because we don't have any sense of a shared cache
// of modules that multiple configurations can refer to at once.
// - We _do_ have a sense of a cache of remote providers, but it's fixed
// at being specifically a two-level cache (global vs. directory-specific)
// and so we can't easily capture a third level of "all of the test suites
// for this module" that sits between the two. Consequently, we need to
// dynamically choose between creating a directory-specific "global"
// cache or using the user's existing global cache, to avoid any
// situation were we'd be re-downloading the same providers for every
// one of the test suites.
// - We need to do something a bit horrid in order to have our test
// provider instance persist between the plan and apply steps, because
// normally that is the exact opposite of what we want.
// The above notes are here mainly as an aid to someone who might be
// planning a subsequent phase of this R&D effort, to help distinguish
// between things we're doing here because they are valuable vs. things
// we're doing just to make it work without doing any disruptive
// refactoring.
suiteDirs, moreDiags := c.prepareSuiteDir(ctx, suiteName)
diags = diags.Append(moreDiags)
if diags.HasErrors() {
// Generate a special failure representing the test initialization
// having failed, since we therefore won'tbe able to run the actual
// tests defined inside.
ret.Components["(init)"] = &moduletest.Component{
Assertions: map[string]*moduletest.Assertion{
"(init)": {
Outcome: moduletest.Error,
Description: "terraform init",
Message: "failed to install test suite dependencies",
Diagnostics: diags,
},
},
}
return &ret, nil
}
// When we run the suite itself, we collect up diagnostics associated
// with individual components, so ret.Components may or may not contain
// failed/errored components after runTestSuite returns.
var finalState *states.State
ret.Components, finalState = c.runTestSuite(ctx, suiteDirs)
// Regardless of the success or failure of the test suite, if there are
// any objects left in the state then we'll generate a top-level error
// about each one to minimize the chance of the user failing to notice
// that there are leftover objects that might continue to cost money
// unless manually deleted.
for _, ms := range finalState.Modules {
for _, rs := range ms.Resources {
for instanceKey, is := range rs.Instances {
var objs []*states.ResourceInstanceObjectSrc
if is.Current != nil {
objs = append(objs, is.Current)
}
for _, obj := range is.Deposed {
objs = append(objs, obj)
}
for _, obj := range objs {
// Unfortunately we don't have provider schemas out here
// and so we're limited in what we can achieve with these
// ResourceInstanceObjectSrc values, but we can try some
// heuristicy things to try to give some useful information
// in common cases.
var k, v string
if ty, err := ctyjson.ImpliedType(obj.AttrsJSON); err == nil {
if approxV, err := ctyjson.Unmarshal(obj.AttrsJSON, ty); err == nil {
k, v = format.ObjectValueIDOrName(approxV)
}
}
var detail string
if k != "" {
// We can be more specific if we were able to infer
// an identifying attribute for this object.
detail = fmt.Sprintf(
"Due to errors during destroy, test suite %q has left behind an object for %s, with the following identity:\n %s = %q\n\nYou will need to delete this object manually in the remote system, or else it may have an ongoing cost.",
suiteName,
rs.Addr.Instance(instanceKey),
k, v,
)
} else {
// If our heuristics for finding a suitable identifier
// failed then unfortunately we must be more vague.
// (We can't just print the entire object, because it
// might be overly large and it might contain sensitive
// values.)
detail = fmt.Sprintf(
"Due to errors during destroy, test suite %q has left behind an object for %s. You will need to delete this object manually in the remote system, or else it may have an ongoing cost.",
suiteName,
rs.Addr.Instance(instanceKey),
)
}
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to clean up after tests",
detail,
))
}
}
}
}
return &ret, diags
}
func (c *TestCommand) prepareSuiteDir(ctx context.Context, suiteName string) (testCommandSuiteDirs, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
configDir := filepath.Join("tests", suiteName)
log.Printf("[TRACE] terraform test: Prepare directory for suite %q in %s", suiteName, configDir)
suiteDirs := testCommandSuiteDirs{
SuiteName: suiteName,
ConfigDir: configDir,
}
// Before we can run a test suite we need to make sure that we have all of
// its dependencies available, so the following is essentially an
// abbreviated form of what happens during "terraform init", with some
// extra trickery in places.
// First, module installation. This will include linking in the module
// under test, but also includes grabbing the dependencies of that module
// if it has any.
suiteDirs.ModulesDir = filepath.Join(configDir, ".terraform", "modules")
os.MkdirAll(suiteDirs.ModulesDir, 0755) // if this fails then we'll ignore it and let InstallModules below fail instead
reg := c.registryClient()
moduleInst := initwd.NewModuleInstaller(suiteDirs.ModulesDir, reg)
_, moreDiags := moduleInst.InstallModules(configDir, true, nil)
diags = diags.Append(moreDiags)
if diags.HasErrors() {
return suiteDirs, diags
}
// The installer puts the files in a suitable place on disk, but we
// still need to actually load the configuration. We need to do this
// with a separate config loader because the Meta.configLoader instance
// is intended for interacting with the current working directory, not
// with the test suite subdirectories.
loader, err := configload.NewLoader(&configload.Config{
ModulesDir: suiteDirs.ModulesDir,
Services: c.Services,
})
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to create test configuration loader",
fmt.Sprintf("Failed to prepare loader for test configuration %s: %s.", configDir, err),
))
return suiteDirs, diags
}
cfg, hclDiags := loader.LoadConfig(configDir)
diags = diags.Append(hclDiags)
if diags.HasErrors() {
return suiteDirs, diags
}
suiteDirs.Config = cfg
// With the full configuration tree available, we can now install
// the necessary providers. We'll use a separate local cache directory
// here, because the test configuration might have additional requirements
// compared to the module itself.
suiteDirs.ProvidersDir = filepath.Join(configDir, ".terraform", "providers")
os.MkdirAll(suiteDirs.ProvidersDir, 0755) // if this fails then we'll ignore it and operations below fail instead
localCacheDir := providercache.NewDir(suiteDirs.ProvidersDir)
providerInst := c.providerInstaller().Clone(localCacheDir)
if !providerInst.HasGlobalCacheDir() {
// If the user already configured a global cache directory then we'll
// just use it for caching the test providers too, because then we
// can potentially reuse cache entries they already have. However,
// if they didn't configure one then we'll still establish one locally
// in the working directory, which we'll then share across all tests
// to avoid downloading the same providers repeatedly.
cachePath := filepath.Join(c.DataDir(), "testing-providers") // note this is _not_ under the suite dir
err := os.MkdirAll(cachePath, 0755)
// If we were unable to create the directory for any reason then we'll
// just proceed without a cache, at the expense of repeated downloads.
// (With that said, later installing might end up failing for the
// same reason anyway...)
if err == nil || os.IsExist(err) {
cacheDir := providercache.NewDir(cachePath)
providerInst.SetGlobalCacheDir(cacheDir)
}
}
reqs, hclDiags := cfg.ProviderRequirements()
diags = diags.Append(hclDiags)
if diags.HasErrors() {
return suiteDirs, diags
}
// For test suites we only retain the "locks" in memory for the duration
// for one run, just to make sure that we use the same providers when we
// eventually run the test suite.
locks := depsfile.NewLocks()
evts := &providercache.InstallerEvents{
QueryPackagesFailure: func(provider addrs.Provider, err error) {
if err != nil && provider.IsDefault() && provider.Type == "test" {
// This is some additional context for the failure error
// we'll generate afterwards. Not the most ideal UX but
// good enough for this prototype implementation, to help
// hint about the special builtin provider we use here.
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Warning,
"Probably-unintended reference to \"hashicorp/test\" provider",
"For the purposes of this experimental implementation of module test suites, you must use the built-in test provider terraform.io/builtin/test, which requires an explicit required_providers declaration.",
))
}
},
}
ctx = evts.OnContext(ctx)
locks, err = providerInst.EnsureProviderVersions(ctx, locks, reqs, providercache.InstallUpgrades)
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to install required providers",
fmt.Sprintf("Couldn't install necessary providers for test configuration %s: %s.", configDir, err),
))
return suiteDirs, diags
}
suiteDirs.ProviderLocks = locks
suiteDirs.ProviderCache = localCacheDir
return suiteDirs, diags
}
func (c *TestCommand) runTestSuite(ctx context.Context, suiteDirs testCommandSuiteDirs) (map[string]*moduletest.Component, *states.State) {
log.Printf("[TRACE] terraform test: Run test suite %q", suiteDirs.SuiteName)
ret := make(map[string]*moduletest.Component)
// To collect test results we'll use an instance of the special "test"
// provider, which records the intention to make a test assertion during
// planning and then hopefully updates that to an actual assertion result
// during apply, unless an apply error causes the graph walk to exit early.
// For this to work correctly, we must ensure we're using the same provider
// instance for both plan and apply.
testProvider := moduletest.NewProvider()
// synthError is a helper to return early with a synthetic failing
// component, for problems that prevent us from even discovering what an
// appropriate component and assertion name might be.
state := states.NewState()
synthError := func(name string, desc string, msg string, diags tfdiags.Diagnostics) (map[string]*moduletest.Component, *states.State) {
key := "(" + name + ")" // parens ensure this can't conflict with an actual component/assertion key
ret[key] = &moduletest.Component{
Assertions: map[string]*moduletest.Assertion{
key: {
Outcome: moduletest.Error,
Description: desc,
Message: msg,
Diagnostics: diags,
},
},
}
return ret, state
}
// NOTE: This function intentionally deviates from the usual pattern of
// gradually appending more diagnostics to the same diags, because
// here we're associating each set of diagnostics with the specific
// operation it belongs to.
providerFactories, diags := c.testSuiteProviders(suiteDirs, testProvider)
if diags.HasErrors() {
// It should be unusual to get in here, because testSuiteProviders
// should rely only on things guaranteed by prepareSuiteDir, but
// since we're doing external I/O here there is always the risk that
// the filesystem changes or fails between setting up and using the
// providers.
return synthError(
"init",
"terraform init",
"failed to resolve the required providers",
diags,
)
}
plan, diags := c.testSuitePlan(ctx, suiteDirs, providerFactories)
if diags.HasErrors() {
// It should be unusual to get in here, because testSuitePlan
// should rely only on things guaranteed by prepareSuiteDir, but
// since we're doing external I/O here there is always the risk that
// the filesystem changes or fails between setting up and using the
// providers.
return synthError(
"plan",
"terraform plan",
"failed to create a plan",
diags,
)
}
// Now we'll apply the plan. Once we try to apply, we might've created
// real remote objects, and so we must try to run destroy even if the
// apply returns errors, and we must return whatever state we end up
// with so the caller can generate additional loud errors if anything
// is left in it.
state, diags = c.testSuiteApply(ctx, plan, suiteDirs, providerFactories)
if diags.HasErrors() {
// We don't return here, unlike the others above, because we want to
// continue to the destroy below even if there are apply errors.
synthError(
"apply",
"terraform apply",
"failed to apply the created plan",
diags,
)
}
// By the time we get here, the test provider will have gathered up all
// of the planned assertions and the final results for any assertions that
// were not blocked by an error. This also resets the provider so that
// the destroy operation below won't get tripped up on stale results.
ret = testProvider.Reset()
state, diags = c.testSuiteDestroy(ctx, state, suiteDirs, providerFactories)
if diags.HasErrors() {
synthError(
"destroy",
"terraform destroy",
"failed to destroy objects created during test (NOTE: leftover remote objects may still exist)",
diags,
)
}
return ret, state
}
func (c *TestCommand) testSuiteProviders(suiteDirs testCommandSuiteDirs, testProvider *moduletest.Provider) (map[addrs.Provider]providers.Factory, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
ret := make(map[addrs.Provider]providers.Factory)
// We can safely use the internal providers returned by Meta here because
// the built-in provider versions can never vary based on the configuration
// and thus we don't need to worry about potential version differences
// between main module and test suite modules.
for name, factory := range c.internalProviders() {
ret[addrs.NewBuiltInProvider(name)] = factory
}
// For the remaining non-builtin providers, we'll just take whatever we
// recorded earlier in the in-memory-only "lock file". All of these should
// typically still be available because we would've only just installed
// them, but this could fail if e.g. the filesystem has been somehow
// damaged in the meantime.
for provider, lock := range suiteDirs.ProviderLocks.AllProviders() {
version := lock.Version()
cached := suiteDirs.ProviderCache.ProviderVersion(provider, version)
if cached == nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Required provider not found",
fmt.Sprintf("Although installation previously succeeded for %s v%s, it no longer seems to be present in the cache directory.", provider.ForDisplay(), version.String()),
))
continue // potentially collect up multiple errors
}
// NOTE: We don't consider the checksums for test suite dependencies,
// because we're creating a fresh "lock file" each time we run anyway
// and so they wouldn't actually guarantee anything useful.
ret[provider] = providerFactory(cached)
}
// We'll replace the test provider instance with the one our caller
// provided, so it'll be able to interrogate the test results directly.
ret[addrs.NewBuiltInProvider("test")] = func() (providers.Interface, error) {
return testProvider, nil
}
return ret, diags
}
func (c *TestCommand) testSuiteContext(suiteDirs testCommandSuiteDirs, providerFactories map[addrs.Provider]providers.Factory, state *states.State, plan *plans.Plan, destroy bool) (*terraform.Context, tfdiags.Diagnostics) {
var changes *plans.Changes
if plan != nil {
changes = plan.Changes
}
planMode := plans.NormalMode
if destroy {
planMode = plans.DestroyMode
}
return terraform.NewContext(&terraform.ContextOpts{
Config: suiteDirs.Config,
Providers: providerFactories,
// We just use the provisioners from the main Meta here, because
// unlike providers provisioner plugins are not automatically
// installable anyway, and so we'll need to hunt for them in the same
// legacy way that normal Terraform operations do.
Provisioners: c.provisionerFactories(),
Meta: &terraform.ContextMeta{
Env: "test_" + suiteDirs.SuiteName,
},
State: state,
Changes: changes,
PlanMode: planMode,
})
}
func (c *TestCommand) testSuitePlan(ctx context.Context, suiteDirs testCommandSuiteDirs, providerFactories map[addrs.Provider]providers.Factory) (*plans.Plan, tfdiags.Diagnostics) {
log.Printf("[TRACE] terraform test: create plan for suite %q", suiteDirs.SuiteName)
tfCtx, diags := c.testSuiteContext(suiteDirs, providerFactories, nil, nil, false)
if diags.HasErrors() {
return nil, diags
}
// We'll also validate as part of planning, since the "terraform plan"
// command would typically do that and so inconsistencies we detect only
// during planning typically produce error messages saying that they are
// a bug in Terraform.
// (It's safe to use the same context for both validate and plan, because
// validate doesn't generate any new sticky content inside the context
// as plan and apply both do.)
moreDiags := tfCtx.Validate()
diags = diags.Append(moreDiags)
if diags.HasErrors() {
return nil, diags
}
plan, moreDiags := tfCtx.Plan()
diags = diags.Append(moreDiags)
return plan, diags
}
func (c *TestCommand) testSuiteApply(ctx context.Context, plan *plans.Plan, suiteDirs testCommandSuiteDirs, providerFactories map[addrs.Provider]providers.Factory) (*states.State, tfdiags.Diagnostics) {
log.Printf("[TRACE] terraform test: apply plan for suite %q", suiteDirs.SuiteName)
tfCtx, diags := c.testSuiteContext(suiteDirs, providerFactories, nil, plan, false)
if diags.HasErrors() {
// To make things easier on the caller, we'll return a valid empty
// state even in this case.
return states.NewState(), diags
}
state, moreDiags := tfCtx.Apply()
diags = diags.Append(moreDiags)
return state, diags
}
func (c *TestCommand) testSuiteDestroy(ctx context.Context, state *states.State, suiteDirs testCommandSuiteDirs, providerFactories map[addrs.Provider]providers.Factory) (*states.State, tfdiags.Diagnostics) {
log.Printf("[TRACE] terraform test: plan to destroy any existing objects for suite %q", suiteDirs.SuiteName)
tfCtx, diags := c.testSuiteContext(suiteDirs, providerFactories, state, nil, true)
if diags.HasErrors() {
return state, diags
}
plan, moreDiags := tfCtx.Plan()
diags = diags.Append(moreDiags)
if diags.HasErrors() {
return state, diags
}
log.Printf("[TRACE] terraform test: apply the plan to destroy any existing objects for suite %q", suiteDirs.SuiteName)
tfCtx, moreDiags = c.testSuiteContext(suiteDirs, providerFactories, state, plan, true)
diags = diags.Append(moreDiags)
if diags.HasErrors() {
return state, diags
}
state, moreDiags = tfCtx.Apply()
diags = diags.Append(moreDiags)
return state, diags
}
func (c *TestCommand) collectSuiteNames() ([]string, error) {
items, err := ioutil.ReadDir("tests")
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
ret := make([]string, 0, len(items))
for _, item := range items {
if !item.IsDir() {
continue
}
name := item.Name()
suitePath := filepath.Join("tests", name)
tfFiles, err := filepath.Glob(filepath.Join(suitePath, "*.tf"))
if err != nil {
// We'll just ignore it and treat it like a dir with no .tf files
tfFiles = nil
}
tfJSONFiles, err := filepath.Glob(filepath.Join(suitePath, "*.tf.json"))
if err != nil {
// We'll just ignore it and treat it like a dir with no .tf.json files
tfJSONFiles = nil
}
if (len(tfFiles) + len(tfJSONFiles)) == 0 {
// Not a test suite, then.
continue
}
ret = append(ret, name)
}
return ret, nil
}
func (c *TestCommand) Help() string {
helpText := `
Usage: terraform test [options]
This is an experimental command to help with automated integration
testing of shared modules. The usage and behavior of this command is
likely to change in breaking ways in subsequent releases, as we
are currently using this command primarily for research purposes.
In its current experimental form, "test" will look under the current
working directory for a subdirectory called "tests", and then within
that directory search for one or more subdirectories that contain
".tf" or ".tf.json" files. For any that it finds, it will perform
Terraform operations similar to the following sequence of commands
in each of those directories:
terraform validate
terraform apply
terraform destroy
The test configurations should not declare any input variables and
should at least contain a call to the module being tested, which
will always be available at the path ../.. due to the expected
filesystem layout.
The tests are considered to be successful if all of the above steps
succeed.
Test configurations may optionally include uses of the special
built-in test provider terraform.io/builtin/test, which allows
writing explicit test assertions which must also all pass in order
for the test run to be considered successful.
This initial implementation is intended as a minimally-viable
product to use for further research and experimentation, and in
particular it currently lacks the following capabilities that we
expect to consider in later iterations, based on feedback:
- Testing of subsequent updates to existing infrastructure,
where currently it only supports initial creation and
then destruction.
- Testing top-level modules that are intended to be used for
"real" environments, which typically have hard-coded values
that don't permit creating a separate "copy" for testing.
- Some sort of support for unit test runs that don't interact
with remote systems at all, e.g. for use in checking pull
requests from untrusted contributors.
In the meantime, we'd like to hear feedback from module authors
who have tried writing some experimental tests for their modules
about what sorts of tests you were able to write, what sorts of
tests you weren't able to write, and any tests that you were
able to write but that were difficult to model in some way.
Options:
-compact-warnings Use a more compact representation for warnings, if
this command produces only warnings and no errors.
-junit-xml=FILE In addition to the usual output, also write test
results to the given file path in JUnit XML format.
This format is commonly supported by CI systems, and
they typically expect to be given a filename to search
for in the test workspace after the test run finishes.
-no-color Don't include virtual terminal formatting sequences in
the output.
`
return strings.TrimSpace(helpText)
}
func (c *TestCommand) Synopsis() string {
return "Experimental support for module integration testing"
}
type testCommandSuiteDirs struct {
SuiteName string
ConfigDir string
ModulesDir string
ProvidersDir string
Config *configs.Config
ProviderCache *providercache.Dir
ProviderLocks *depsfile.Locks
}