diff --git a/internal/plans/plan.go b/internal/plans/plan.go index a95653e536..f051732a34 100644 --- a/internal/plans/plan.go +++ b/internal/plans/plan.go @@ -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. @@ -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 } diff --git a/internal/terraform/context_apply.go b/internal/terraform/context_apply.go index 4f5851fab3..0a47a57d32 100644 --- a/internal/terraform/context_apply.go +++ b/internal/terraform/context_apply.go @@ -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 @@ -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() { diff --git a/internal/terraform/context_apply2_test.go b/internal/terraform/context_apply2_test.go index 9442c22899..46c07f08e8 100644 --- a/internal/terraform/context_apply2_test.go +++ b/internal/terraform/context_apply2_test.go @@ -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()) + } +} diff --git a/internal/terraform/context_plan.go b/internal/terraform/context_plan.go index f921eabe89..b739c67ca1 100644 --- a/internal/terraform/context_plan.go +++ b/internal/terraform/context_plan.go @@ -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 @@ -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 } @@ -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) @@ -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: diff --git a/internal/terraform/context_plan2_test.go b/internal/terraform/context_plan2_test.go index 59b22069cb..d7afec1d43 100644 --- a/internal/terraform/context_plan2_test.go +++ b/internal/terraform/context_plan2_test.go @@ -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" @@ -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) { @@ -4888,3 +4889,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") + } +} diff --git a/internal/terraform/evaluate.go b/internal/terraform/evaluate.go index a92b8df4a3..57b368c9d0 100644 --- a/internal/terraform/evaluate.go +++ b/internal/terraform/evaluate.go @@ -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" @@ -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 { @@ -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) } @@ -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) } @@ -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 diff --git a/internal/terraform/graph_builder_apply.go b/internal/terraform/graph_builder_apply.go index d892fc90f5..ed381e8c42 100644 --- a/internal/terraform/graph_builder_apply.go +++ b/internal/terraform/graph_builder_apply.go @@ -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 @@ -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{}, diff --git a/internal/terraform/graph_builder_plan.go b/internal/terraform/graph_builder_plan.go index 9d37781c94..4cea55e1fe 100644 --- a/internal/terraform/graph_builder_plan.go +++ b/internal/terraform/graph_builder_plan.go @@ -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 @@ -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{}, diff --git a/internal/terraform/node_external_reference.go b/internal/terraform/node_external_reference.go new file mode 100644 index 0000000000..1e91344475 --- /dev/null +++ b/internal/terraform/node_external_reference.go @@ -0,0 +1,31 @@ +package terraform + +import "github.com/hashicorp/terraform/internal/addrs" + +// nodeExternalReference allows external callers (such as the testing framework) +// to provide the list of references they are making into the graph. This +// ensures that Terraform will not remove any nodes from the graph that might +// not be referenced from within a module but are referenced by the currently +// executing test file. +// +// This should only be added to the graph if we are executing the +// `terraform test` command. +type nodeExternalReference struct { + ExternalReferences []*addrs.Reference +} + +var ( + _ GraphNodeReferencer = (*nodeExternalReference)(nil) +) + +// GraphNodeModulePath +func (n *nodeExternalReference) ModulePath() addrs.Module { + // The external references are always made from test files, which currently + // execute as if they are in the root module. + return addrs.RootModule +} + +// GraphNodeReferencer +func (n *nodeExternalReference) References() []*addrs.Reference { + return n.ExternalReferences +} diff --git a/internal/terraform/terraform_test.go b/internal/terraform/terraform_test.go index 740d88abd4..31e567cde4 100644 --- a/internal/terraform/terraform_test.go +++ b/internal/terraform/terraform_test.go @@ -135,7 +135,7 @@ func testModuleInline(t *testing.T, sources map[string]string) *configs.Config { t.Fatalf("failed to refresh modules after installation: %s", err) } - config, diags := loader.LoadConfig(cfgPath) + config, diags := loader.LoadConfigWithTests(cfgPath, "tests") if diags.HasErrors() { t.Fatal(diags.Error()) } @@ -232,6 +232,14 @@ func mustProviderConfig(s string) addrs.AbsProviderConfig { return p } +func mustReference(s string) *addrs.Reference { + p, diags := addrs.ParseRefStr(s) + if diags.HasErrors() { + panic(diags.Err()) + } + return p +} + // HookRecordApplyOrder is a test hook that records the order of applies // by recording the PreApply event. type HookRecordApplyOrder struct { diff --git a/internal/terraform/test_context.go b/internal/terraform/test_context.go new file mode 100644 index 0000000000..c592234b46 --- /dev/null +++ b/internal/terraform/test_context.go @@ -0,0 +1,176 @@ +package terraform + +import ( + "fmt" + "sync" + + "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/lang" + "github.com/hashicorp/terraform/internal/moduletest" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// TestContext wraps a Context, and adds in direct values for the current state, +// most recent plan, and configuration. +// +// This combination allows functions called on the TestContext to create a +// complete scope to evaluate test assertions. +type TestContext struct { + *Context + + Config *configs.Config + State *states.State + Plan *plans.Plan + Variables InputValues +} + +// TestContext creates a TestContext structure that can evaluate test assertions +// against the provided state and plan. +func (c *Context) TestContext(config *configs.Config, state *states.State, plan *plans.Plan, variables InputValues) *TestContext { + return &TestContext{ + Context: c, + Config: config, + State: state, + Plan: plan, + Variables: variables, + } +} + +// EvaluateAgainstState processes the assertions inside the provided +// configs.TestRun against the embedded state. +// +// The provided plan is import as it is needed to evaluate the `plantimestamp` +// function, but no data or changes from the embedded plan is referenced in +// this function. +func (ctx *TestContext) EvaluateAgainstState(run *moduletest.Run) { + defer ctx.acquireRun("evaluate")() + ctx.evaluate(ctx.State.SyncWrapper(), plans.NewChanges().SyncWrapper(), run, walkApply) +} + +// EvaluateAgainstPlan processes the assertions inside the provided +// configs.TestRun against the embedded plan and state. +func (ctx *TestContext) EvaluateAgainstPlan(run *moduletest.Run) { + defer ctx.acquireRun("evaluate")() + ctx.evaluate(ctx.State.SyncWrapper(), ctx.Plan.Changes.SyncWrapper(), run, walkPlan) +} + +func (ctx *TestContext) evaluate(state *states.SyncState, changes *plans.ChangesSync, run *moduletest.Run, operation walkOperation) { + data := &evaluationStateData{ + Evaluator: &Evaluator{ + Operation: operation, + Meta: ctx.meta, + Config: ctx.Config, + Plugins: ctx.plugins, + State: state, + Changes: changes, + VariableValues: func() map[string]map[string]cty.Value { + variables := map[string]map[string]cty.Value{ + addrs.RootModule.String(): make(map[string]cty.Value), + } + for name, variable := range ctx.Variables { + variables[addrs.RootModule.String()][name] = variable.Value + } + return variables + }(), + VariableValuesLock: new(sync.Mutex), + PlanTimestamp: ctx.Plan.Timestamp, + }, + ModulePath: nil, // nil for the root module + InstanceKeyData: EvalDataForNoInstanceKey, + Operation: operation, + } + + scope := &lang.Scope{ + Data: data, + BaseDir: ".", + PureOnly: operation != walkApply, + PlanTimestamp: ctx.Plan.Timestamp, + } + + // We're going to assume the run has passed, and then if anything fails this + // value will be updated. + run.Status = run.Status.Merge(moduletest.Pass) + + // Now validate all the assertions within this run block. + for _, rule := range run.Config.CheckRules { + refs, moreDiags := lang.ReferencesInExpr(rule.Condition) + run.Diagnostics = run.Diagnostics.Append(moreDiags) + moreRefs, moreDiags := lang.ReferencesInExpr(rule.ErrorMessage) + run.Diagnostics = run.Diagnostics.Append(moreDiags) + refs = append(refs, moreRefs...) + + hclCtx, moreDiags := scope.EvalContext(refs) + run.Diagnostics = run.Diagnostics.Append(moreDiags) + + errorMessage, moreDiags := evalCheckErrorMessage(rule.ErrorMessage, hclCtx) + run.Diagnostics = run.Diagnostics.Append(moreDiags) + + runVal, hclDiags := rule.Condition.Value(hclCtx) + run.Diagnostics = run.Diagnostics.Append(hclDiags) + + if run.Diagnostics.HasErrors() { + run.Status = run.Status.Merge(moduletest.Error) + continue + } + + if runVal.IsNull() { + run.Status = run.Status.Merge(moduletest.Error) + run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid condition run", + Detail: "Condition expression must return either true or false, not null.", + Subject: rule.Condition.Range().Ptr(), + Expression: rule.Condition, + EvalContext: hclCtx, + }) + continue + } + + if !runVal.IsKnown() { + run.Status = run.Status.Merge(moduletest.Error) + run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unknown condition run", + Detail: "Condition expression could not be evaluated at this time.", + Subject: rule.Condition.Range().Ptr(), + Expression: rule.Condition, + EvalContext: hclCtx, + }) + continue + } + + var err error + if runVal, err = convert.Convert(runVal, cty.Bool); err != nil { + run.Status = run.Status.Merge(moduletest.Error) + run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid condition run", + Detail: fmt.Sprintf("Invalid condition run value: %s.", tfdiags.FormatError(err)), + Subject: rule.Condition.Range().Ptr(), + Expression: rule.Condition, + EvalContext: hclCtx, + }) + continue + } + + if runVal.False() { + run.Status = run.Status.Merge(moduletest.Fail) + run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Test assertion failed", + Detail: errorMessage, + Subject: rule.Condition.Range().Ptr(), + Expression: rule.Condition, + EvalContext: hclCtx, + }) + continue + } + } +} diff --git a/internal/terraform/test_context_test.go b/internal/terraform/test_context_test.go new file mode 100644 index 0000000000..cf6137026b --- /dev/null +++ b/internal/terraform/test_context_test.go @@ -0,0 +1,467 @@ +package terraform + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" + ctymsgpack "github.com/zclconf/go-cty/cty/msgpack" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/moduletest" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestTestContext_EvaluateAgainstState(t *testing.T) { + tcs := map[string]struct { + configs map[string]string + state *states.State + variables InputValues + provider *MockProvider + + expectedDiags []tfdiags.Description + expectedStatus moduletest.Status + }{ + "basic_passing": { + configs: map[string]string{ + "main.tf": ` +resource "test_resource" "a" { + value = "Hello, world!" +} +`, + "main.tftest": ` +run "test_case" { + assert { + condition = test_resource.a.value == "Hello, world!" + error_message = "invalid value" + } +} +`, + }, + state: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_resource", + Name: "a", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: encodeCtyValue(t, cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("Hello, world!"), + })), + }, + addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.NewDefaultProvider("test"), + }) + }), + provider: &MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_resource": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + }, + expectedStatus: moduletest.Pass, + }, + "with_variables": { + configs: map[string]string{ + "main.tf": ` +variable "value" { + type = string +} + +resource "test_resource" "a" { + value = var.value +} +`, + "main.tftest": ` +variables { + value = "Hello, world!" +} + +run "test_case" { + assert { + condition = test_resource.a.value == var.value + error_message = "invalid value" + } +} +`, + }, + state: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_resource", + Name: "a", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: encodeCtyValue(t, cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("Hello, world!"), + })), + }, + addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.NewDefaultProvider("test"), + }) + }), + variables: InputValues{ + "value": { + Value: cty.StringVal("Hello, world!"), + }, + }, + provider: &MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_resource": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + }, + expectedStatus: moduletest.Pass, + }, + "basic_failing": { + configs: map[string]string{ + "main.tf": ` +resource "test_resource" "a" { + value = "Hello, world!" +} +`, + "main.tftest": ` +run "test_case" { + assert { + condition = test_resource.a.value == "incorrect!" + error_message = "invalid value" + } +} +`, + }, + state: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_resource", + Name: "a", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: encodeCtyValue(t, cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("Hello, world!"), + })), + }, + addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.NewDefaultProvider("test"), + }) + }), + provider: &MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_resource": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + }, + expectedStatus: moduletest.Fail, + expectedDiags: []tfdiags.Description{ + { + Summary: "Test assertion failed", + Detail: "invalid value", + }, + }, + }, + } + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + config := testModuleInline(t, tc.configs) + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(tc.provider), + }, + }) + + run := moduletest.Run{ + Config: config.Module.Tests["main.tftest"].Runs[0], + Name: "test_case", + } + + tctx := ctx.TestContext(config, tc.state, &plans.Plan{}, tc.variables) + tctx.EvaluateAgainstState(&run) + + if expected, actual := tc.expectedStatus, run.Status; expected != actual { + t.Errorf("expected status \"%s\" but got \"%s\"", expected, actual) + } + + compareDiagnosticsFromTestResult(t, tc.expectedDiags, run.Diagnostics) + }) + } +} + +func TestTestContext_EvaluateAgainstPlan(t *testing.T) { + tcs := map[string]struct { + configs map[string]string + state *states.State + plan *plans.Plan + variables InputValues + provider *MockProvider + + expectedDiags []tfdiags.Description + expectedStatus moduletest.Status + }{ + "basic_passing": { + configs: map[string]string{ + "main.tf": ` +resource "test_resource" "a" { + value = "Hello, world!" +} +`, + "main.tftest": ` +run "test_case" { + assert { + condition = test_resource.a.value == "Hello, world!" + error_message = "invalid value" + } +} +`, + }, + state: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_resource", + Name: "a", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectPlanned, + AttrsJSON: encodeCtyValue(t, cty.NullVal(cty.Object(map[string]cty.Type{ + "value": cty.String, + }))), + }, + addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.NewDefaultProvider("test"), + }) + }), + plan: &plans.Plan{ + Changes: &plans.Changes{ + Resources: []*plans.ResourceInstanceChangeSrc{ + { + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_resource", + Name: "a", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ProviderAddr: addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.NewDefaultProvider("test"), + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + Before: nil, + After: encodeDynamicValue(t, cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("Hello, world!"), + })), + }, + }, + }, + }, + }, + provider: &MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_resource": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + }, + expectedStatus: moduletest.Pass, + }, + "basic_failing": { + configs: map[string]string{ + "main.tf": ` +resource "test_resource" "a" { + value = "Hello, world!" +} +`, + "main.tftest": ` +run "test_case" { + assert { + condition = test_resource.a.value == "incorrect!" + error_message = "invalid value" + } +} +`, + }, + state: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_resource", + Name: "a", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectPlanned, + AttrsJSON: encodeCtyValue(t, cty.NullVal(cty.Object(map[string]cty.Type{ + "value": cty.String, + }))), + }, + addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.NewDefaultProvider("test"), + }) + }), + plan: &plans.Plan{ + Changes: &plans.Changes{ + Resources: []*plans.ResourceInstanceChangeSrc{ + { + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_resource", + Name: "a", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ProviderAddr: addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.NewDefaultProvider("test"), + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + Before: nil, + After: encodeDynamicValue(t, cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("Hello, world!"), + })), + }, + }, + }, + }, + }, + provider: &MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_resource": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + }, + expectedStatus: moduletest.Fail, + expectedDiags: []tfdiags.Description{ + { + Summary: "Test assertion failed", + Detail: "invalid value", + }, + }, + }, + } + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + config := testModuleInline(t, tc.configs) + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(tc.provider), + }, + }) + + run := moduletest.Run{ + Config: config.Module.Tests["main.tftest"].Runs[0], + Name: "test_case", + } + + tctx := ctx.TestContext(config, tc.state, tc.plan, tc.variables) + tctx.EvaluateAgainstPlan(&run) + + if expected, actual := tc.expectedStatus, run.Status; expected != actual { + t.Errorf("expected status \"%s\" but got \"%s\"", expected, actual) + } + + compareDiagnosticsFromTestResult(t, tc.expectedDiags, run.Diagnostics) + }) + } +} + +func compareDiagnosticsFromTestResult(t *testing.T, expected []tfdiags.Description, actual tfdiags.Diagnostics) { + if len(expected) != len(actual) { + t.Errorf("found invalid number of diagnostics, expected %d but found %d", len(expected), len(actual)) + } + + length := len(expected) + if len(actual) > length { + length = len(actual) + } + + for ix := 0; ix < length; ix++ { + if ix >= len(expected) { + t.Errorf("found extra diagnostic at %d:\n%v", ix, actual[ix].Description()) + } else if ix >= len(actual) { + t.Errorf("missing diagnostic at %d:\n%v", ix, expected[ix]) + } else { + expected := expected[ix] + actual := actual[ix].Description() + if diff := cmp.Diff(expected, actual); len(diff) > 0 { + t.Errorf("found different diagnostics at %d:\nexpected:\n%s\nactual:\n%s\ndiff:%s", ix, expected, actual, diff) + } + } + } +} + +func encodeDynamicValue(t *testing.T, value cty.Value) []byte { + data, err := ctymsgpack.Marshal(value, value.Type()) + if err != nil { + t.Fatalf("failed to marshal JSON: %s", err) + } + return data +} + +func encodeCtyValue(t *testing.T, value cty.Value) []byte { + data, err := ctyjson.Marshal(value, value.Type()) + if err != nil { + t.Fatalf("failed to marshal JSON: %s", err) + } + return data +} diff --git a/internal/terraform/transform_external_reference.go b/internal/terraform/transform_external_reference.go new file mode 100644 index 0000000000..42a960820e --- /dev/null +++ b/internal/terraform/transform_external_reference.go @@ -0,0 +1,23 @@ +package terraform + +import "github.com/hashicorp/terraform/internal/addrs" + +// ExternalReferenceTransformer will add a GraphNodeReferencer into the graph +// that makes no changes to the graph itself but, by referencing the addresses +// within ExternalReferences, ensures that any temporary nodes that are required +// by an external caller, such as the terraform testing framework, are not +// skipped because they are not referenced from within the module. +type ExternalReferenceTransformer struct { + ExternalReferences []*addrs.Reference +} + +func (t *ExternalReferenceTransformer) Transform(g *Graph) error { + if len(t.ExternalReferences) == 0 { + return nil + } + + g.Add(&nodeExternalReference{ + ExternalReferences: t.ExternalReferences, + }) + return nil +}