Skip to content

Commit

Permalink
Adds a TestContext structure for evaluating assertions against the st…
Browse files Browse the repository at this point in the history
…ate and plan
  • Loading branch information
liamcervante committed Jun 7, 2023
1 parent b6dfa16 commit cb26156
Show file tree
Hide file tree
Showing 13 changed files with 909 additions and 34 deletions.
26 changes: 25 additions & 1 deletion internal/plans/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import (
"sort"
"time"

"github.com/zclconf/go-cty/cty"

"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/lang/globalref"
"github.com/hashicorp/terraform/internal/states"
"github.com/zclconf/go-cty/cty"
)

// Plan is the top-level type representing a planned set of changes.
Expand Down Expand Up @@ -88,6 +89,29 @@ type Plan struct {
PrevRunState *states.State
PriorState *states.State

// PlannedState is the temporary planned state that was created during the
// graph walk that generated this plan.
//
// This is required by the testing framework when evaluating run blocks
// executing in plan mode. The graph updates the state with certain values
// that are difficult to retrieve later, such as local values that reference
// updated resources. It is easier to build the testing scope with access
// to same temporary state the plan used/built.
//
// This is never recorded outside of Terraform. It is not written into the
// binary plan file, and it is not written into the JSON structured outputs.
// The testing framework never writes the plans out but holds everything in
// memory as it executes, so there is no need to add any kind of
// serialization for this field. This does mean that you shouldn't rely on
// this field existing unless you have just generated the plan.
PlannedState *states.State

// ExternalReferences are references that are being made to resources within
// the plan from external sources. As with PlannedState this is used by the
// terraform testing framework, and so isn't written into any external
// representation of the plan.
ExternalReferences []*addrs.Reference

// Timestamp is the record of truth for when the plan happened.
Timestamp time.Time
}
Expand Down
4 changes: 3 additions & 1 deletion internal/terraform/context_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import (
"fmt"
"log"

"github.com/zclconf/go-cty/cty"

"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)

// Apply performs the actions described by the given Plan object and returns
Expand Down Expand Up @@ -176,6 +177,7 @@ func (c *Context) applyGraph(plan *plans.Plan, config *configs.Config, validate
Targets: plan.TargetAddrs,
ForceReplace: plan.ForceReplaceAddrs,
Operation: operation,
ExternalReferences: plan.ExternalReferences,
}).Build(addrs.RootModuleInstance)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
Expand Down
82 changes: 82 additions & 0 deletions internal/terraform/context_apply2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2162,3 +2162,85 @@ import {
t.Errorf("expected addr to be %s, but was %s", wantAddr, addr)
}
}

func TestContext2Apply_noExternalReferences(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
resource "test_object" "a" {
test_string = "foo"
}
locals {
local_value = test_object.a.test_string
}
`,
})

p := simpleMockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})

plan, diags := ctx.Plan(m, states.NewState(), nil)
if diags.HasErrors() {
t.Errorf("expected no errors, but got %s", diags)
}

state, diags := ctx.Apply(plan, m)
if diags.HasErrors() {
t.Errorf("expected no errors, but got %s", diags)
}

// We didn't specify any external references, so the unreferenced local
// value should have been tidied up and never made it into the state.
module := state.RootModule()
if len(module.LocalValues) > 0 {
t.Errorf("expected no local values in the state but found %d", len(module.LocalValues))
}
}

func TestContext2Apply_withExternalReferences(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
resource "test_object" "a" {
test_string = "foo"
}
locals {
local_value = test_object.a.test_string
}
`,
})

p := simpleMockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})

plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{
Mode: plans.NormalMode,
ExternalReferences: []*addrs.Reference{
mustReference("local.local_value"),
},
})
if diags.HasErrors() {
t.Errorf("expected no errors, but got %s", diags)
}

state, diags := ctx.Apply(plan, m)
if diags.HasErrors() {
t.Errorf("expected no errors, but got %s", diags)
}

// We did specify the local value in the external references, so it should
// have been preserved even though it is not referenced by anything directly
// in the config.
module := state.RootModule()
if module.LocalValues["local_value"].AsString() != "foo" {
t.Errorf("expected local value to be \"foo\" but was \"%s\"", module.LocalValues["local_value"].AsString())
}
}
23 changes: 16 additions & 7 deletions internal/terraform/context_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ type PlanOpts struct {
// fully-functional new object.
ForceReplace []addrs.AbsResourceInstance

// ExternalReferences allows the external caller to pass in references to
// nodes that should not be pruned even if they are not referenced within
// the actual graph.
ExternalReferences []*addrs.Reference

// ImportTargets is a list of target resources to import. These resources
// will be added to the plan graph.
ImportTargets []*ImportTarget
Expand Down Expand Up @@ -644,13 +649,15 @@ func (c *Context) planWalk(config *configs.Config, prevRunState *states.State, o
diags = diags.Append(driftDiags)

plan := &plans.Plan{
UIMode: opts.Mode,
Changes: changes,
DriftedResources: driftedResources,
PrevRunState: prevRunState,
PriorState: priorState,
Checks: states.NewCheckResults(walker.Checks),
Timestamp: timestamp,
UIMode: opts.Mode,
Changes: changes,
DriftedResources: driftedResources,
PrevRunState: prevRunState,
PriorState: priorState,
PlannedState: walker.State.Close(),
ExternalReferences: opts.ExternalReferences,
Checks: states.NewCheckResults(walker.Checks),
Timestamp: timestamp,

// Other fields get populated by Context.Plan after we return
}
Expand All @@ -670,6 +677,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State,
skipRefresh: opts.SkipRefresh,
preDestroyRefresh: opts.PreDestroyRefresh,
Operation: walkPlan,
ExternalReferences: opts.ExternalReferences,
ImportTargets: opts.ImportTargets,
GenerateConfigPath: opts.GenerateConfigPath,
}).Build(addrs.RootModuleInstance)
Expand All @@ -684,6 +692,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State,
skipRefresh: opts.SkipRefresh,
skipPlanChanges: true, // this activates "refresh only" mode.
Operation: walkPlan,
ExternalReferences: opts.ExternalReferences,
}).Build(addrs.RootModuleInstance)
return graph, walkPlan, diags
case plans.DestroyMode:
Expand Down
54 changes: 53 additions & 1 deletion internal/terraform/context_plan2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (

"github.com/davecgh/go-spew/spew"
"github.com/google/go-cmp/cmp"
"github.com/zclconf/go-cty/cty"

"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/checks"
"github.com/hashicorp/terraform/internal/configs/configschema"
Expand All @@ -21,7 +23,6 @@ import (
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)

func TestContext2Plan_removedDuringRefresh(t *testing.T) {
Expand Down Expand Up @@ -4847,3 +4848,54 @@ import {
t.Errorf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, want, diff)
}
}

func TestContext2Plan_plannedState(t *testing.T) {
addr := mustResourceInstanceAddr("test_object.a")
m := testModuleInline(t, map[string]string{
"main.tf": `
resource "test_object" "a" {
test_string = "foo"
}
locals {
local_value = test_object.a.test_string
}
`,
})

p := simpleMockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})

state := states.NewState()
plan, diags := ctx.Plan(m, state, nil)
if diags.HasErrors() {
t.Errorf("expected no errors, but got %s", diags)
}

module := state.RootModule()

// So, the original state shouldn't have been updated at all.
if len(module.LocalValues) > 0 {
t.Errorf("expected no local values in the state but found %d", len(module.LocalValues))
}

if len(module.Resources) > 0 {
t.Errorf("expected no resources in the state but found %d", len(module.LocalValues))
}

// But, this makes it hard for the testing framework to valid things about
// the returned plan. So, the plan contains the planned state:
module = plan.PlannedState.RootModule()

if module.LocalValues["local_value"].AsString() != "foo" {
t.Errorf("expected local value to be \"foo\" but was \"%s\"", module.LocalValues["local_value"].AsString())
}

if module.ResourceInstance(addr.Resource).Current.Status != states.ObjectPlanned {
t.Errorf("expected resource to be in planned state")
}
}
27 changes: 4 additions & 23 deletions internal/terraform/evaluate.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import (
"sync"
"time"

"github.com/agext/levenshtein"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"

"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/didyoumean"
"github.com/hashicorp/terraform/internal/instances"
"github.com/hashicorp/terraform/internal/lang"
"github.com/hashicorp/terraform/internal/lang/marks"
Expand Down Expand Up @@ -229,7 +229,7 @@ func (d *evaluationStateData) GetInputVariable(addr addrs.InputVariable, rng tfd
for k := range moduleConfig.Module.Variables {
suggestions = append(suggestions, k)
}
suggestion := nameSuggestion(addr.Name, suggestions)
suggestion := didyoumean.NameSuggestion(addr.Name, suggestions)
if suggestion != "" {
suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
} else {
Expand Down Expand Up @@ -325,7 +325,7 @@ func (d *evaluationStateData) GetLocalValue(addr addrs.LocalValue, rng tfdiags.S
for k := range moduleConfig.Module.Locals {
suggestions = append(suggestions, k)
}
suggestion := nameSuggestion(addr.Name, suggestions)
suggestion := didyoumean.NameSuggestion(addr.Name, suggestions)
if suggestion != "" {
suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
}
Expand Down Expand Up @@ -624,7 +624,7 @@ func (d *evaluationStateData) GetPathAttr(addr addrs.PathAttr, rng tfdiags.Sourc
return cty.StringVal(filepath.ToSlash(sourceDir)), diags

default:
suggestion := nameSuggestion(addr.Name, []string{"cwd", "module", "root"})
suggestion := didyoumean.NameSuggestion(addr.Name, []string{"cwd", "module", "root"})
if suggestion != "" {
suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
}
Expand Down Expand Up @@ -940,25 +940,6 @@ func (d *evaluationStateData) GetTerraformAttr(addr addrs.TerraformAttr, rng tfd
}
}

// nameSuggestion tries to find a name from the given slice of suggested names
// that is close to the given name and returns it if found. If no suggestion
// is close enough, returns the empty string.
//
// The suggestions are tried in order, so earlier suggestions take precedence
// if the given string is similar to two or more suggestions.
//
// This function is intended to be used with a relatively-small number of
// suggestions. It's not optimized for hundreds or thousands of them.
func nameSuggestion(given string, suggestions []string) string {
for _, suggestion := range suggestions {
dist := levenshtein.Distance(given, suggestion, nil)
if dist < 3 { // threshold determined experimentally
return suggestion
}
}
return ""
}

// moduleDisplayAddr returns a string describing the given module instance
// address that is appropriate for returning to users in situations where the
// root module is possible. Specifically, it returns "the root module" if the
Expand Down
10 changes: 10 additions & 0 deletions internal/terraform/graph_builder_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ type ApplyGraphBuilder struct {

// Plan Operation this graph will be used for.
Operation walkOperation

// ExternalReferences allows the external caller to pass in references to
// nodes that should not be pruned even if they are not referenced within
// the actual graph.
ExternalReferences []*addrs.Reference
}

// See GraphBuilder
Expand Down Expand Up @@ -144,6 +149,11 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer {
// objects that can belong to modules.
&ModuleExpansionTransformer{Config: b.Config},

// Plug in any external references.
&ExternalReferenceTransformer{
ExternalReferences: b.ExternalReferences,
},

// Connect references so ordering is correct
&ReferenceTransformer{},
&AttachDependenciesTransformer{},
Expand Down
10 changes: 10 additions & 0 deletions internal/terraform/graph_builder_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ type PlanGraphBuilder struct {
// Plan Operation this graph will be used for.
Operation walkOperation

// ExternalReferences allows the external caller to pass in references to
// nodes that should not be pruned even if they are not referenced within
// the actual graph.
ExternalReferences []*addrs.Reference

// ImportTargets are the list of resources to import.
ImportTargets []*ImportTarget

Expand Down Expand Up @@ -193,6 +198,11 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer {
// objects that can belong to modules.
&ModuleExpansionTransformer{Concrete: b.ConcreteModule, Config: b.Config},

// Plug in any external references.
&ExternalReferenceTransformer{
ExternalReferences: b.ExternalReferences,
},

&ReferenceTransformer{},

&AttachDependenciesTransformer{},
Expand Down
Loading

0 comments on commit cb26156

Please sign in to comment.