371 lines
11 KiB
Go
371 lines
11 KiB
Go
package resource
|
|
|
|
import (
|
|
"errors"
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/hashicorp/terraform/addrs"
|
|
"github.com/hashicorp/terraform/config"
|
|
"github.com/hashicorp/terraform/config/hcl2shim"
|
|
"github.com/hashicorp/terraform/states"
|
|
|
|
"github.com/hashicorp/errwrap"
|
|
"github.com/hashicorp/terraform/plans"
|
|
"github.com/hashicorp/terraform/terraform"
|
|
"github.com/hashicorp/terraform/tfdiags"
|
|
)
|
|
|
|
// testStepConfig runs a config-mode test step
|
|
func testStepConfig(
|
|
opts terraform.ContextOpts,
|
|
state *terraform.State,
|
|
step TestStep,
|
|
schemas *terraform.Schemas) (*terraform.State, error) {
|
|
return testStep(opts, state, step, schemas)
|
|
}
|
|
|
|
func testStep(opts terraform.ContextOpts, state *terraform.State, step TestStep, schemas *terraform.Schemas) (*terraform.State, error) {
|
|
if !step.Destroy {
|
|
if err := testStepTaint(state, step); err != nil {
|
|
return state, err
|
|
}
|
|
}
|
|
|
|
cfg, err := testConfig(opts, step)
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
|
|
var stepDiags tfdiags.Diagnostics
|
|
|
|
// Build the context
|
|
opts.Config = cfg
|
|
opts.State = terraform.MustShimLegacyState(state)
|
|
opts.Destroy = step.Destroy
|
|
ctx, stepDiags := terraform.NewContext(&opts)
|
|
if stepDiags.HasErrors() {
|
|
return state, fmt.Errorf("Error initializing context: %s", stepDiags.Err())
|
|
}
|
|
if stepDiags := ctx.Validate(); len(stepDiags) > 0 {
|
|
if stepDiags.HasErrors() {
|
|
return nil, errwrap.Wrapf("config is invalid: {{err}}", stepDiags.Err())
|
|
}
|
|
|
|
log.Printf("[WARN] Config warnings:\n%s", stepDiags)
|
|
}
|
|
|
|
// Refresh!
|
|
newState, stepDiags := ctx.Refresh()
|
|
state = mustShimNewState(newState, schemas)
|
|
if stepDiags.HasErrors() {
|
|
return state, fmt.Errorf("Error refreshing: %s", stepDiags.Err())
|
|
}
|
|
|
|
// If this step is a PlanOnly step, skip over this first Plan and subsequent
|
|
// Apply, and use the follow up Plan that checks for perpetual diffs
|
|
if !step.PlanOnly {
|
|
// Plan!
|
|
if p, stepDiags := ctx.Plan(); stepDiags.HasErrors() {
|
|
return state, fmt.Errorf("Error planning: %s", stepDiags.Err())
|
|
} else {
|
|
log.Printf("[WARN] Test: Step plan: %s", legacyPlanComparisonString(newState, p.Changes))
|
|
}
|
|
|
|
// 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 the diff, creating real resources.
|
|
newState, stepDiags = ctx.Apply()
|
|
state = mustShimNewState(newState, schemas)
|
|
if stepDiags.HasErrors() {
|
|
return state, fmt.Errorf("Error applying: %s", stepDiags.Err())
|
|
}
|
|
|
|
// Run any configured checks
|
|
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 *plans.Plan
|
|
if p, stepDiags = ctx.Plan(); stepDiags.HasErrors() {
|
|
return state, fmt.Errorf("Error on follow-up plan: %s", stepDiags.Err())
|
|
}
|
|
if !p.Changes.Empty() {
|
|
if step.ExpectNonEmptyPlan {
|
|
log.Printf("[INFO] Got non-empty plan, as expected:\n\n%s", legacyPlanComparisonString(newState, p.Changes))
|
|
} else {
|
|
return state, fmt.Errorf(
|
|
"After applying this step, the plan was not empty:\n\n%s", legacyPlanComparisonString(newState, p.Changes))
|
|
}
|
|
}
|
|
|
|
// And another after a Refresh.
|
|
if !step.Destroy || (step.Destroy && !step.PreventPostDestroyRefresh) {
|
|
newState, stepDiags = ctx.Refresh()
|
|
if stepDiags.HasErrors() {
|
|
return state, fmt.Errorf("Error on follow-up refresh: %s", stepDiags.Err())
|
|
}
|
|
state = mustShimNewState(newState, schemas)
|
|
}
|
|
if p, stepDiags = ctx.Plan(); stepDiags.HasErrors() {
|
|
return state, fmt.Errorf("Error on second follow-up plan: %s", stepDiags.Err())
|
|
}
|
|
empty := p.Changes.Empty()
|
|
|
|
// Data resources are tricky because they legitimately get instantiated
|
|
// during refresh so that they will be already populated during the
|
|
// plan walk. Because of this, if we have any data resources in the
|
|
// config we'll end up wanting to destroy them again here. This is
|
|
// acceptable and expected, and we'll treat it as "empty" for the
|
|
// sake of this testing.
|
|
if step.Destroy && !empty {
|
|
empty = true
|
|
for _, change := range p.Changes.Resources {
|
|
if change.Addr.Resource.Resource.Mode != addrs.DataResourceMode {
|
|
empty = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !empty {
|
|
if step.ExpectNonEmptyPlan {
|
|
log.Printf("[INFO] Got non-empty plan, as expected:\n\n%s", legacyPlanComparisonString(newState, p.Changes))
|
|
} else {
|
|
return state, fmt.Errorf(
|
|
"After applying this step and refreshing, "+
|
|
"the plan was not empty:\n\n%s", legacyPlanComparisonString(newState, p.Changes))
|
|
}
|
|
}
|
|
|
|
// Made it here, but expected a non-empty plan, fail!
|
|
if step.ExpectNonEmptyPlan && 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
|
|
}
|
|
|
|
// legacyPlanComparisonString produces a string representation of the changes
|
|
// from a plan and a given state togther, as was formerly produced by the
|
|
// String method of terraform.Plan.
|
|
//
|
|
// This is here only for compatibility with existing tests that predate our
|
|
// new plan and state types, and should not be used in new tests. Instead, use
|
|
// a library like "cmp" to do a deep equality check and diff on the two
|
|
// data structures.
|
|
func legacyPlanComparisonString(state *states.State, changes *plans.Changes) string {
|
|
return fmt.Sprintf(
|
|
"DIFF:\n\n%s\n\nSTATE:\n\n%s",
|
|
legacyDiffComparisonString(changes),
|
|
state.String(),
|
|
)
|
|
}
|
|
|
|
// legacyDiffComparisonString produces a string representation of the changes
|
|
// from a planned changes object, as was formerly produced by the String method
|
|
// of terraform.Diff.
|
|
//
|
|
// This is here only for compatibility with existing tests that predate our
|
|
// new plan types, and should not be used in new tests. Instead, use a library
|
|
// like "cmp" to do a deep equality check and diff on the two data structures.
|
|
func legacyDiffComparisonString(changes *plans.Changes) string {
|
|
// The old string representation of a plan was grouped by module, but
|
|
// our new plan structure is not grouped in that way and so we'll need
|
|
// to preprocess it in order to produce that grouping.
|
|
type ResourceChanges struct {
|
|
Current *plans.ResourceInstanceChangeSrc
|
|
Deposed map[states.DeposedKey]*plans.ResourceInstanceChangeSrc
|
|
}
|
|
byModule := map[string]map[string]*ResourceChanges{}
|
|
resourceKeys := map[string][]string{}
|
|
var moduleKeys []string
|
|
for _, rc := range changes.Resources {
|
|
if rc.Action == plans.NoOp {
|
|
// We won't mention no-op changes here at all, since the old plan
|
|
// model we are emulating here didn't have such a concept.
|
|
continue
|
|
}
|
|
moduleKey := rc.Addr.Module.String()
|
|
if _, exists := byModule[moduleKey]; !exists {
|
|
moduleKeys = append(moduleKeys, moduleKey)
|
|
byModule[moduleKey] = make(map[string]*ResourceChanges)
|
|
}
|
|
resourceKey := rc.Addr.Resource.String()
|
|
if _, exists := byModule[moduleKey][resourceKey]; !exists {
|
|
resourceKeys[moduleKey] = append(resourceKeys[moduleKey], resourceKey)
|
|
byModule[moduleKey][resourceKey] = &ResourceChanges{
|
|
Deposed: make(map[states.DeposedKey]*plans.ResourceInstanceChangeSrc),
|
|
}
|
|
}
|
|
|
|
if rc.DeposedKey == states.NotDeposed {
|
|
byModule[moduleKey][resourceKey].Current = rc
|
|
} else {
|
|
byModule[moduleKey][resourceKey].Deposed[rc.DeposedKey] = rc
|
|
}
|
|
}
|
|
sort.Strings(moduleKeys)
|
|
for _, ks := range resourceKeys {
|
|
sort.Strings(ks)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
|
|
for _, moduleKey := range moduleKeys {
|
|
rcs := byModule[moduleKey]
|
|
var mBuf bytes.Buffer
|
|
|
|
for _, resourceKey := range resourceKeys[moduleKey] {
|
|
rc := rcs[resourceKey]
|
|
|
|
crud := "UPDATE"
|
|
if rc.Current != nil {
|
|
switch rc.Current.Action {
|
|
case plans.DeleteThenCreate:
|
|
crud = "DESTROY/CREATE"
|
|
case plans.CreateThenDelete:
|
|
crud = "CREATE/DESTROY"
|
|
case plans.Delete:
|
|
crud = "DESTROY"
|
|
case plans.Create:
|
|
crud = "CREATE"
|
|
}
|
|
} else {
|
|
// We must be working on a deposed object then, in which
|
|
// case destroying is the only possible action.
|
|
crud = "DESTROY"
|
|
}
|
|
|
|
extra := ""
|
|
if rc.Current == nil && len(rc.Deposed) > 0 {
|
|
extra = " (deposed only)"
|
|
}
|
|
|
|
fmt.Fprintf(
|
|
&mBuf, "%s: %s%s\n",
|
|
crud, resourceKey, extra,
|
|
)
|
|
|
|
attrNames := map[string]bool{}
|
|
var oldAttrs map[string]string
|
|
var newAttrs map[string]string
|
|
if rc.Current != nil {
|
|
if before := rc.Current.Before; before != nil {
|
|
ty, err := before.ImpliedType()
|
|
if err == nil {
|
|
val, err := before.Decode(ty)
|
|
if err == nil {
|
|
oldAttrs = hcl2shim.FlatmapValueFromHCL2(val)
|
|
for k := range oldAttrs {
|
|
attrNames[k] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if after := rc.Current.After; after != nil {
|
|
ty, err := after.ImpliedType()
|
|
if err == nil {
|
|
val, err := after.Decode(ty)
|
|
if err == nil {
|
|
newAttrs = hcl2shim.FlatmapValueFromHCL2(val)
|
|
for k := range newAttrs {
|
|
attrNames[k] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if oldAttrs == nil {
|
|
oldAttrs = make(map[string]string)
|
|
}
|
|
if newAttrs == nil {
|
|
newAttrs = make(map[string]string)
|
|
}
|
|
|
|
attrNamesOrder := make([]string, 0, len(attrNames))
|
|
keyLen := 0
|
|
for n := range attrNames {
|
|
attrNamesOrder = append(attrNamesOrder, n)
|
|
if len(n) > keyLen {
|
|
keyLen = len(n)
|
|
}
|
|
}
|
|
sort.Strings(attrNamesOrder)
|
|
|
|
for _, attrK := range attrNamesOrder {
|
|
v := newAttrs[attrK]
|
|
u := oldAttrs[attrK]
|
|
|
|
if v == config.UnknownVariableValue {
|
|
v = "<computed>"
|
|
}
|
|
// NOTE: we don't support <sensitive> here because we would
|
|
// need schema to do that. Excluding sensitive values
|
|
// is now done at the UI layer, and so should not be tested
|
|
// at the core layer.
|
|
|
|
updateMsg := ""
|
|
// TODO: Mark " (forces new resource)" in updateMsg when appropriate.
|
|
|
|
fmt.Fprintf(
|
|
&mBuf, " %s:%s %#v => %#v%s\n",
|
|
attrK,
|
|
strings.Repeat(" ", keyLen-len(attrK)),
|
|
u, v,
|
|
updateMsg,
|
|
)
|
|
}
|
|
}
|
|
|
|
if moduleKey == "" { // root module
|
|
buf.Write(mBuf.Bytes())
|
|
buf.WriteByte('\n')
|
|
continue
|
|
}
|
|
|
|
fmt.Fprintf(&buf, "%s:\n", moduleKey)
|
|
s := bufio.NewScanner(&mBuf)
|
|
for s.Scan() {
|
|
buf.WriteString(fmt.Sprintf(" %s\n", s.Text()))
|
|
}
|
|
}
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
func testStepTaint(state *terraform.State, step TestStep) error {
|
|
for _, p := range step.Taint {
|
|
m := state.RootModule()
|
|
if m == nil {
|
|
return errors.New("no state")
|
|
}
|
|
rs, ok := m.Resources[p]
|
|
if !ok {
|
|
return fmt.Errorf("resource %q not found in state", p)
|
|
}
|
|
log.Printf("[WARN] Test: Explicitly tainting resource %q", p)
|
|
rs.Taint()
|
|
}
|
|
return nil
|
|
}
|