From dcc1fa3b88c449e258d3f822f07ebbf00de79cb2 Mon Sep 17 00:00:00 2001 From: Ronny Orot Date: Wed, 17 Apr 2024 17:12:10 +0300 Subject: [PATCH] Support `for_each` syntax in `import` block (#1492) Signed-off-by: RLRabinowitz Signed-off-by: Ronny Orot Co-authored-by: RLRabinowitz --- CHANGELOG.md | 1 + internal/configs/import.go | 9 ++ internal/tofu/context_import.go | 64 ++++++-- internal/tofu/context_plan2_test.go | 185 ++++++++++++++++++++++++ internal/tofu/eval_context.go | 2 +- internal/tofu/eval_context_builtin.go | 18 ++- internal/tofu/eval_context_mock.go | 2 +- internal/tofu/eval_for_each.go | 24 ++- internal/tofu/eval_for_each_test.go | 56 ++++++- internal/tofu/eval_import.go | 15 +- internal/tofu/eval_import_test.go | 4 +- internal/tofu/node_module_expand.go | 2 +- internal/tofu/node_resource_abstract.go | 3 +- internal/tofu/node_resource_plan.go | 2 +- internal/tofu/node_resource_validate.go | 2 +- 15 files changed, 355 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceccc6e6c5..d2ebdfc59e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ STATE ENCRYPTION NEW FEATURES: * Add support for a `removed` block that allows users to remove resources or modules from the state without destroying them. ([#1158](https://github.com/opentofu/opentofu/pull/1158)) * Provider-defined functions are now available. They may be referenced via `provider::::(args)`. ([#1439](https://github.com/opentofu/opentofu/pull/1439)) +* Support `for_each` in `import` blocks ([#1492](https://github.com/opentofu/opentofu/pull/1492) ENHANCEMENTS: * Added support to use `.tfvars` files from tests folder. ([#1386](https://github.com/opentofu/opentofu/pull/1386)) diff --git a/internal/configs/import.go b/internal/configs/import.go index 191e58da13..594d3c3c8b 100644 --- a/internal/configs/import.go +++ b/internal/configs/import.go @@ -43,6 +43,8 @@ type Import struct { // import blocks targeting the same resource ResolvedTo *addrs.AbsResourceInstance + ForEach hcl.Expression + ProviderConfigRef *ProviderConfigRef Provider addrs.Provider @@ -93,6 +95,10 @@ func decodeImportBlock(block *hcl.Block) (*Import, hcl.Diagnostics) { diags = append(diags, providerDiags...) } + if attr, exists := content.Attributes["for_each"]; exists { + imp.ForEach = attr.Expr + } + return imp, diags } @@ -109,6 +115,9 @@ var importBlockSchema = &hcl.BodySchema{ Name: "to", Required: true, }, + { + Name: "for_each", + }, }, } diff --git a/internal/tofu/context_import.go b/internal/tofu/context_import.go index 26f968844d..7b5c1602a4 100644 --- a/internal/tofu/context_import.go +++ b/internal/tofu/context_import.go @@ -11,9 +11,9 @@ import ( "sync" "github.com/hashicorp/hcl/v2" - "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/configs" + "github.com/opentofu/opentofu/internal/instances" "github.com/opentofu/opentofu/internal/states" "github.com/opentofu/opentofu/internal/tfdiags" ) @@ -107,12 +107,13 @@ func NewImportResolver() *ImportResolver { return &ImportResolver{imports: make(map[string]EvaluatedConfigImportTarget)} } -// ResolveImport resolves the ID and address (soon, when it will be necessary) of an ImportTarget originating -// from an import block, when we have the context necessary to resolve them. The resolved import target would be an -// EvaluatedConfigImportTarget. -// This function mutates the EvalContext's ImportResolver, adding the resolved import target -// The function errors if we failed to evaluate the ID or the address (soon) -func (ri *ImportResolver) ResolveImport(importTarget *ImportTarget, ctx EvalContext) tfdiags.Diagnostics { +// ExpandAndResolveImport is responsible for two operations: +// 1. Expands the ImportTarget (originating from an import block) if it contains a 'for_each' attribute. +// 2. Goes over the expanded imports and resolves the ID and address, when we have the context necessary to resolve +// them. The resolved import target would be an EvaluatedConfigImportTarget. +// This function mutates the EvalContext's ImportResolver, adding the resolved import target. +// The function errors if we failed to evaluate the ID or the address. +func (ri *ImportResolver) ExpandAndResolveImport(importTarget *ImportTarget, ctx EvalContext) tfdiags.Diagnostics { var diags tfdiags.Diagnostics // The import block expressions are declared within the root module. @@ -120,13 +121,52 @@ func (ri *ImportResolver) ResolveImport(importTarget *ImportTarget, ctx EvalCont // relative to the root module rootCtx := ctx.WithPath(addrs.RootModuleInstance) - importId, evalDiags := evaluateImportIdExpression(importTarget.Config.ID, rootCtx) + if importTarget.Config.ForEach != nil { + // The import target has a for_each attribute, so we need to expand it + forEachVal, evalDiags := evaluateForEachExpressionValue(importTarget.Config.ForEach, rootCtx, false, true) + diags = diags.Append(evalDiags) + if diags.HasErrors() { + return diags + } + + // We are building an instances.RepetitionData based on each for_each key and val combination + var repetitions []instances.RepetitionData + + it := forEachVal.ElementIterator() + for it.Next() { + k, v := it.Element() + repetitions = append(repetitions, instances.RepetitionData{ + EachKey: k, + EachValue: v, + }) + } + + for _, keyData := range repetitions { + diags = diags.Append(ri.resolveImport(importTarget, rootCtx, keyData)) + } + } else { + // The import target is singular, no need to expand + diags = diags.Append(ri.resolveImport(importTarget, rootCtx, EvalDataForNoInstanceKey)) + } + + return diags +} + +// resolveImport resolves the ID and address of an ImportTarget originating from an import block, +// when we have the context necessary to resolve them. The resolved import target would be an +// EvaluatedConfigImportTarget. +// This function mutates the EvalContext's ImportResolver, adding the resolved import target. +// The function errors if we failed to evaluate the ID or the address. +func (ri *ImportResolver) resolveImport(importTarget *ImportTarget, ctx EvalContext, keyData instances.RepetitionData) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + importId, evalDiags := evaluateImportIdExpression(importTarget.Config.ID, ctx, keyData) diags = diags.Append(evalDiags) if diags.HasErrors() { return diags } - importAddress, addressDiags := rootCtx.EvaluateImportAddress(importTarget.Config.To) + importAddress, addressDiags := ctx.EvaluateImportAddress(importTarget.Config.To, keyData) diags = diags.Append(addressDiags) if diags.HasErrors() { return diags @@ -152,6 +192,12 @@ func (ri *ImportResolver) ResolveImport(importTarget *ImportTarget, ctx EvalCont ID: importId, } + if keyData == EvalDataForNoInstanceKey { + log.Printf("[TRACE] importResolver: resolved a singular import target %s", importAddress) + } else { + log.Printf("[TRACE] importResolver: resolved an expanded import target %s", importAddress) + } + return diags } diff --git a/internal/tofu/context_plan2_test.go b/internal/tofu/context_plan2_test.go index e32740348c..0e0b6b46c5 100644 --- a/internal/tofu/context_plan2_test.go +++ b/internal/tofu/context_plan2_test.go @@ -4533,6 +4533,191 @@ import { } } +func TestContext2Plan_importForEach(t *testing.T) { + type ImportResult struct { + ResolvedAddress string + ResolvedId string + } + type TestConfiguration struct { + Description string + ImportResults []ImportResult + inlineConfiguration map[string]string + } + configurations := []TestConfiguration{ + { + Description: "valid map", + ImportResults: []ImportResult{{ResolvedAddress: `test_object.a["key1"]`, ResolvedId: "val1"}, {ResolvedAddress: `test_object.a["key2"]`, ResolvedId: "val2"}, {ResolvedAddress: `test_object.a["key3"]`, ResolvedId: "val3"}}, + inlineConfiguration: map[string]string{ + "main.tf": ` +locals { + map = { + "key1" = "val1" + "key2" = "val2" + "key3" = "val3" + } +} + +resource "test_object" "a" { + for_each = local.map +} + +import { + for_each = local.map + to = test_object.a[each.key] + id = each.value +} +`, + }, + }, + { + Description: "valid set", + ImportResults: []ImportResult{{ResolvedAddress: `test_object.a["val0"]`, ResolvedId: "val0"}, {ResolvedAddress: `test_object.a["val1"]`, ResolvedId: "val1"}, {ResolvedAddress: `test_object.a["val2"]`, ResolvedId: "val2"}}, + inlineConfiguration: map[string]string{ + "main.tf": ` +variable "set" { + type = set(string) + default = ["val0", "val1", "val2"] +} + +resource "test_object" "a" { + for_each = var.set +} + +import { + for_each = var.set + to = test_object.a[each.key] + id = each.value +} +`, + }, + }, + { + Description: "valid tuple", + ImportResults: []ImportResult{{ResolvedAddress: `module.mod[0].test_object.a["resKey1"]`, ResolvedId: "val1"}, {ResolvedAddress: `module.mod[0].test_object.a["resKey2"]`, ResolvedId: "val2"}, {ResolvedAddress: `module.mod[1].test_object.a["resKey1"]`, ResolvedId: "val3"}, {ResolvedAddress: `module.mod[1].test_object.a["resKey2"]`, ResolvedId: "val4"}}, + inlineConfiguration: map[string]string{ + "mod/main.tf": ` +variable "set" { + type = set(string) + default = ["resKey1", "resKey2"] +} + +resource "test_object" "a" { + for_each = var.set +} +`, + "main.tf": ` +locals { + tuple = [ + { + moduleKey = 0 + resourceKey = "resKey1" + id = "val1" + }, + { + moduleKey = 0 + resourceKey = "resKey2" + id = "val2" + }, + { + moduleKey = 1 + resourceKey = "resKey1" + id = "val3" + }, + { + moduleKey = 1 + resourceKey = "resKey2" + id = "val4" + }, + ] +} + +module "mod" { + count = 2 + source = "./mod" +} + +import { + for_each = local.tuple + id = each.value.id + to = module.mod[each.value.moduleKey].test_object.a[each.value.resourceKey] +} +`, + }, + }, + } + + for _, configuration := range configurations { + t.Run(configuration.Description, func(t *testing.T) { + m := testModuleInline(t, configuration.inlineConfiguration) + p := simpleMockProvider() + + hook := new(MockHook) + ctx := testContext2(t, &ContextOpts{ + Hooks: []Hook{hook}, + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + p.ReadResourceResponse = &providers.ReadResourceResponse{ + NewState: cty.ObjectVal(map[string]cty.Value{}), + } + + p.ImportResourceStateResponse = &providers.ImportResourceStateResponse{ + ImportedResources: []providers.ImportedResource{ + { + TypeName: "test_object", + State: cty.ObjectVal(map[string]cty.Value{}), + }, + }, + } + + plan, diags := ctx.Plan(m, states.NewState(), SimplePlanOpts(plans.NormalMode, testInputValuesUnset(m.Module.Variables))) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Err().Error()) + } + + if len(plan.Changes.Resources) != len(configuration.ImportResults) { + t.Fatalf("excpected %d resource chnages in the plan, got %d instead", len(configuration.ImportResults), len(plan.Changes.Resources)) + } + + for _, importResult := range configuration.ImportResults { + addr := mustResourceInstanceAddr(importResult.ResolvedAddress) + + t.Run(addr.String(), func(t *testing.T) { + instPlan := plan.Changes.ResourceInstance(addr) + if instPlan == nil { + t.Fatalf("no plan for %s at all", addr) + } + + if got, want := instPlan.Addr, addr; !got.Equal(want) { + t.Errorf("wrong current address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.PrevRunAddr, addr; !got.Equal(want) { + t.Errorf("wrong previous run address\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.Action, plans.NoOp; got != want { + t.Errorf("wrong planned action\ngot: %s\nwant: %s", got, want) + } + if got, want := instPlan.ActionReason, plans.ResourceInstanceChangeNoReason; got != want { + t.Errorf("wrong action reason\ngot: %s\nwant: %s", got, want) + } + if instPlan.Importing.ID != importResult.ResolvedId { + t.Errorf("expected import change from \"%s\", got non-import change", importResult.ResolvedId) + } + + if !hook.PrePlanImportCalled { + t.Fatalf("PostPlanImport hook not called") + } + + if !hook.PostPlanImportCalled { + t.Fatalf("PostPlanImport hook not called") + } + }) + } + }) + } +} + func TestContext2Plan_importToInvalidDynamicAddress(t *testing.T) { type TestConfiguration struct { Description string diff --git a/internal/tofu/eval_context.go b/internal/tofu/eval_context.go index ca4a0bfe4e..e954eca034 100644 --- a/internal/tofu/eval_context.go +++ b/internal/tofu/eval_context.go @@ -131,7 +131,7 @@ type EvalContext interface { // EvaluateImportAddress takes the raw reference expression of the import address // from the config, and returns the evaluated address addrs.AbsResourceInstance - EvaluateImportAddress(expr hcl.Expression) (addrs.AbsResourceInstance, tfdiags.Diagnostics) + EvaluateImportAddress(expr hcl.Expression, keyData instances.RepetitionData) (addrs.AbsResourceInstance, tfdiags.Diagnostics) // EvaluationScope returns a scope that can be used to evaluate reference // addresses in this context. diff --git a/internal/tofu/eval_context_builtin.go b/internal/tofu/eval_context_builtin.go index 39d74558fa..8c976334f1 100644 --- a/internal/tofu/eval_context_builtin.go +++ b/internal/tofu/eval_context_builtin.go @@ -404,8 +404,9 @@ func (ctx *BuiltinEvalContext) EvaluateReplaceTriggeredBy(expr hcl.Expression, r // The implementation is inspired by config.AbsTraversalForImportToExpr, but this time we can evaluate the expression // in the indexes of expressions. If we encounter a hclsyntax.IndexExpr, we can evaluate the Key expression and create // an Index Traversal, adding it to the Traverser -func (ctx *BuiltinEvalContext) EvaluateImportAddress(expr hcl.Expression) (addrs.AbsResourceInstance, tfdiags.Diagnostics) { - traversal, diags := ctx.traversalForImportExpr(expr) +// TODO move this function into eval_import.go +func (ctx *BuiltinEvalContext) EvaluateImportAddress(expr hcl.Expression, keyData instances.RepetitionData) (addrs.AbsResourceInstance, tfdiags.Diagnostics) { + traversal, diags := ctx.traversalForImportExpr(expr, keyData) if diags.HasErrors() { return addrs.AbsResourceInstance{}, diags } @@ -413,18 +414,18 @@ func (ctx *BuiltinEvalContext) EvaluateImportAddress(expr hcl.Expression) (addrs return addrs.ParseAbsResourceInstance(traversal) } -func (ctx *BuiltinEvalContext) traversalForImportExpr(expr hcl.Expression) (traversal hcl.Traversal, diags tfdiags.Diagnostics) { +func (ctx *BuiltinEvalContext) traversalForImportExpr(expr hcl.Expression, keyData instances.RepetitionData) (traversal hcl.Traversal, diags tfdiags.Diagnostics) { switch e := expr.(type) { case *hclsyntax.IndexExpr: - t, d := ctx.traversalForImportExpr(e.Collection) + t, d := ctx.traversalForImportExpr(e.Collection, keyData) diags = diags.Append(d) traversal = append(traversal, t...) - tIndex, dIndex := ctx.parseImportIndexKeyExpr(e.Key) + tIndex, dIndex := ctx.parseImportIndexKeyExpr(e.Key, keyData) diags = diags.Append(dIndex) traversal = append(traversal, tIndex) case *hclsyntax.RelativeTraversalExpr: - t, d := ctx.traversalForImportExpr(e.Source) + t, d := ctx.traversalForImportExpr(e.Source, keyData) diags = diags.Append(d) traversal = append(traversal, t...) traversal = append(traversal, e.Traversal...) @@ -446,12 +447,13 @@ func (ctx *BuiltinEvalContext) traversalForImportExpr(expr hcl.Expression) (trav // import target address, into a traversal of type hcl.TraverseIndex. // After evaluation, the expression must be known, not null, not sensitive, and must be a string (for_each) or a number // (count) -func (ctx *BuiltinEvalContext) parseImportIndexKeyExpr(expr hcl.Expression) (hcl.TraverseIndex, tfdiags.Diagnostics) { +func (ctx *BuiltinEvalContext) parseImportIndexKeyExpr(expr hcl.Expression, keyData instances.RepetitionData) (hcl.TraverseIndex, tfdiags.Diagnostics) { idx := hcl.TraverseIndex{ SrcRange: expr.Range(), } - val, diags := ctx.EvaluateExpr(expr, cty.DynamicPseudoType, nil) + // evaluate and take into consideration the for_each key (if exists) + val, diags := evaluateExprWithRepetitionData(ctx, expr, cty.DynamicPseudoType, keyData) if diags.HasErrors() { return idx, diags } diff --git a/internal/tofu/eval_context_mock.go b/internal/tofu/eval_context_mock.go index 254c277c68..59a38f7cd8 100644 --- a/internal/tofu/eval_context_mock.go +++ b/internal/tofu/eval_context_mock.go @@ -275,7 +275,7 @@ func (c *MockEvalContext) EvaluateReplaceTriggeredBy(hcl.Expression, instances.R return nil, false, nil } -func (c *MockEvalContext) EvaluateImportAddress(expression hcl.Expression) (addrs.AbsResourceInstance, tfdiags.Diagnostics) { +func (c *MockEvalContext) EvaluateImportAddress(expression hcl.Expression, keyData instances.RepetitionData) (addrs.AbsResourceInstance, tfdiags.Diagnostics) { return addrs.AbsResourceInstance{}, nil } diff --git a/internal/tofu/eval_for_each.go b/internal/tofu/eval_for_each.go index acc6bb8450..7ed10ed9d4 100644 --- a/internal/tofu/eval_for_each.go +++ b/internal/tofu/eval_for_each.go @@ -26,7 +26,7 @@ import ( // returning an error if the count value is not known, and converting the // cty.Value to a map[string]cty.Value for compatibility with other calls. func evaluateForEachExpression(expr hcl.Expression, ctx EvalContext) (forEach map[string]cty.Value, diags tfdiags.Diagnostics) { - forEachVal, diags := evaluateForEachExpressionValue(expr, ctx, false) + forEachVal, diags := evaluateForEachExpressionValue(expr, ctx, false, false) // forEachVal might be unknown, but if it is then there should already // be an error about it in diags, which we'll return below. @@ -40,7 +40,9 @@ func evaluateForEachExpression(expr hcl.Expression, ctx EvalContext) (forEach ma // evaluateForEachExpressionValue is like evaluateForEachExpression // except that it returns a cty.Value map or set which can be unknown. -func evaluateForEachExpressionValue(expr hcl.Expression, ctx EvalContext, allowUnknown bool) (cty.Value, tfdiags.Diagnostics) { +// The 'allowTuple' argument is used to support evaluating for_each from tuple +// values, and is currently supported when using for_each in import blocks. +func evaluateForEachExpressionValue(expr hcl.Expression, ctx EvalContext, allowUnknown bool, allowTuple bool) (cty.Value, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics nullMap := cty.NullVal(cty.Map(cty.DynamicPseudoType)) @@ -86,6 +88,16 @@ func evaluateForEachExpressionValue(expr hcl.Expression, ctx EvalContext, allowU } ty := forEachVal.Type() + var isAllowedType bool + var allowedTypesMessage string + if allowTuple { + isAllowedType = ty.IsMapType() || ty.IsSetType() || ty.IsObjectType() || ty.IsTupleType() + allowedTypesMessage = "map, set of strings, or a tuple" + } else { + isAllowedType = ty.IsMapType() || ty.IsSetType() || ty.IsObjectType() + allowedTypesMessage = "map, or set of strings" + } + const errInvalidUnknownDetailMap = "The \"for_each\" map includes keys derived from resource attributes that cannot be determined until apply, and so OpenTofu cannot determine the full set of keys that will identify the instances of this resource.\n\nWhen working with unknown values in for_each, it's better to define the map keys statically in your configuration and place apply-time results only in the map values.\n\nAlternatively, you could use the -target planning option to first apply only the resources that the for_each value depends on, and then apply a second time to fully converge." const errInvalidUnknownDetailSet = "The \"for_each\" set includes values derived from resource attributes that cannot be determined until apply, and so OpenTofu cannot determine the full set of keys that will identify the instances of this resource.\n\nWhen working with unknown values in for_each, it's better to use a map value where the keys are defined statically in your configuration and where only the values contain apply-time results.\n\nAlternatively, you could use the -target planning option to first apply only the resources that the for_each value depends on, and then apply a second time to fully converge." @@ -94,7 +106,7 @@ func evaluateForEachExpressionValue(expr hcl.Expression, ctx EvalContext, allowU diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid for_each argument", - Detail: `The given "for_each" argument value is unsuitable: the given "for_each" argument value is null. A map, or set of strings is allowed.`, + Detail: fmt.Sprintf(`The given "for_each" argument value is unsuitable: the given "for_each" argument value is null. A %s is allowed.`, allowedTypesMessage), Subject: expr.Range().Ptr(), Expression: expr, EvalContext: hclCtx, @@ -123,11 +135,11 @@ func evaluateForEachExpressionValue(expr hcl.Expression, ctx EvalContext, allowU // ensure that we have a map, and not a DynamicValue return cty.UnknownVal(cty.Map(cty.DynamicPseudoType)), diags - case !(ty.IsMapType() || ty.IsSetType() || ty.IsObjectType()): + case !(isAllowedType): diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid for_each argument", - Detail: fmt.Sprintf(`The given "for_each" argument value is unsuitable: the "for_each" argument must be a map, or set of strings, and you have provided a value of type %s.`, ty.FriendlyName()), + Detail: fmt.Sprintf(`The given "for_each" argument value is unsuitable: the "for_each" argument must be a %s, and you have provided a value of type %s.`, allowedTypesMessage, ty.FriendlyName()), Subject: expr.Range().Ptr(), Expression: expr, EvalContext: hclCtx, @@ -163,7 +175,7 @@ func evaluateForEachExpressionValue(expr hcl.Expression, ctx EvalContext, allowU diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid for_each set argument", - Detail: fmt.Sprintf(`The given "for_each" argument value is unsuitable: "for_each" supports maps and sets of strings, but you have provided a set containing type %s.`, forEachVal.Type().ElementType().FriendlyName()), + Detail: fmt.Sprintf(`The given "for_each" argument value is unsuitable: "for_each" supports sets of strings, but you have provided a set containing type %s.`, forEachVal.Type().ElementType().FriendlyName()), Subject: expr.Range().Ptr(), Expression: expr, EvalContext: hclCtx, diff --git a/internal/tofu/eval_for_each_test.go b/internal/tofu/eval_for_each_test.go index 3a4df5d653..549042bdf2 100644 --- a/internal/tofu/eval_for_each_test.go +++ b/internal/tofu/eval_for_each_test.go @@ -145,7 +145,7 @@ func TestEvaluateForEachExpression_errors(t *testing.T) { "set containing booleans": { hcltest.MockExprLiteral(cty.SetVal([]cty.Value{cty.BoolVal(true)})), "Invalid for_each set argument", - "supports maps and sets of strings, but you have provided a set containing type bool", + "supports sets of strings, but you have provided a set containing type bool", false, false, }, "set containing null": { @@ -217,13 +217,14 @@ func TestEvaluateForEachExpressionKnown(t *testing.T) { tests := map[string]hcl.Expression{ "unknown string set": hcltest.MockExprLiteral(cty.UnknownVal(cty.Set(cty.String))), "unknown map": hcltest.MockExprLiteral(cty.UnknownVal(cty.Map(cty.Bool))), + "unknown tuple": hcltest.MockExprLiteral(cty.UnknownVal(cty.Tuple([]cty.Type{cty.String, cty.Number, cty.Bool}))), } for name, expr := range tests { t.Run(name, func(t *testing.T) { ctx := &MockEvalContext{} ctx.installSimpleEval() - forEachVal, diags := evaluateForEachExpressionValue(expr, ctx, true) + forEachVal, diags := evaluateForEachExpressionValue(expr, ctx, true, true) if len(diags) != 0 { t.Errorf("unexpected diagnostics %s", spew.Sdump(diags)) @@ -235,3 +236,54 @@ func TestEvaluateForEachExpressionKnown(t *testing.T) { }) } } + +func TestEvaluateForEachExpressionValueTuple(t *testing.T) { + tests := map[string]struct { + Expr hcl.Expression + AllowTuple bool + ExpectedError string + }{ + "valid tuple": { + Expr: hcltest.MockExprLiteral(cty.TupleVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")})), + AllowTuple: true, + }, + "empty tuple": { + Expr: hcltest.MockExprLiteral(cty.EmptyTupleVal), + AllowTuple: true, + }, + "null tuple": { + Expr: hcltest.MockExprLiteral(cty.NullVal(cty.Tuple([]cty.Type{}))), + AllowTuple: true, + ExpectedError: "the given \"for_each\" argument value is null", + }, + "sensitive tuple": { + Expr: hcltest.MockExprLiteral(cty.TupleVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")}).Mark(marks.Sensitive)), + AllowTuple: true, + ExpectedError: "Sensitive values, or values derived from sensitive values, cannot be used as for_each arguments", + }, + "allow tuple is off": { + Expr: hcltest.MockExprLiteral(cty.TupleVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")})), + AllowTuple: false, + ExpectedError: "the \"for_each\" argument must be a map, or set of strings, and you have provided a value of type tuple.", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctx := &MockEvalContext{} + ctx.installSimpleEval() + _, diags := evaluateForEachExpressionValue(test.Expr, ctx, true, test.AllowTuple) + + if test.ExpectedError == "" { + if len(diags) != 0 { + t.Errorf("unexpected diagnostics %s", spew.Sdump(diags)) + } + } else { + if got, want := diags[0].Description().Detail, test.ExpectedError; test.ExpectedError != "" && !strings.Contains(got, want) { + t.Errorf("wrong diagnostic detail\ngot: %s\nwant substring: %s", got, want) + } + } + + }) + } +} diff --git a/internal/tofu/eval_import.go b/internal/tofu/eval_import.go index 1706a3b91e..eabd8ac826 100644 --- a/internal/tofu/eval_import.go +++ b/internal/tofu/eval_import.go @@ -9,13 +9,14 @@ import ( "fmt" "github.com/hashicorp/hcl/v2" + "github.com/opentofu/opentofu/internal/instances" "github.com/opentofu/opentofu/internal/lang/marks" "github.com/opentofu/opentofu/internal/tfdiags" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" ) -func evaluateImportIdExpression(expr hcl.Expression, ctx EvalContext) (string, tfdiags.Diagnostics) { +func evaluateImportIdExpression(expr hcl.Expression, ctx EvalContext, keyData instances.RepetitionData) (string, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics if expr == nil { @@ -27,7 +28,8 @@ func evaluateImportIdExpression(expr hcl.Expression, ctx EvalContext) (string, t }) } - importIdVal, evalDiags := ctx.EvaluateExpr(expr, cty.String, nil) + // evaluate the import ID and take into consideration the for_each key (if exists) + importIdVal, evalDiags := evaluateExprWithRepetitionData(ctx, expr, cty.String, keyData) diags = diags.Append(evalDiags) if importIdVal.IsNull() { @@ -73,3 +75,12 @@ func evaluateImportIdExpression(expr hcl.Expression, ctx EvalContext) (string, t return importId, diags } + +// evaluateExprWithRepetitionData takes the given HCL expression and evaluates +// it to produce a value, while taking into consideration any repetition key +// (a single combination of each.key and each.value of a for_each argument) +// that should be a part of the scope. +func evaluateExprWithRepetitionData(ctx EvalContext, expr hcl.Expression, wantType cty.Type, keyData instances.RepetitionData) (cty.Value, tfdiags.Diagnostics) { + scope := ctx.EvaluationScope(nil, nil, keyData) + return scope.EvalExpr(expr, wantType) +} diff --git a/internal/tofu/eval_import_test.go b/internal/tofu/eval_import_test.go index 702178c8c0..970e00d36f 100644 --- a/internal/tofu/eval_import_test.go +++ b/internal/tofu/eval_import_test.go @@ -11,6 +11,7 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hcltest" + "github.com/opentofu/opentofu/internal/lang" "github.com/opentofu/opentofu/internal/lang/marks" "github.com/zclconf/go-cty/cty" ) @@ -18,6 +19,7 @@ import ( func TestEvaluateImportIdExpression_SensitiveValue(t *testing.T) { ctx := &MockEvalContext{} ctx.installSimpleEval() + ctx.EvaluationScopeScope = &lang.Scope{} testCases := []struct { name string @@ -53,7 +55,7 @@ func TestEvaluateImportIdExpression_SensitiveValue(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - _, diags := evaluateImportIdExpression(tc.expr, ctx) + _, diags := evaluateImportIdExpression(tc.expr, ctx, EvalDataForNoInstanceKey) if tc.wantErr != "" { if len(diags) != 1 { diff --git a/internal/tofu/node_module_expand.go b/internal/tofu/node_module_expand.go index 254bad8be7..3b48f086ff 100644 --- a/internal/tofu/node_module_expand.go +++ b/internal/tofu/node_module_expand.go @@ -243,7 +243,7 @@ func (n *nodeValidateModule) Execute(ctx EvalContext, op walkOperation) (diags t diags = diags.Append(countDiags) case n.ModuleCall.ForEach != nil: - _, forEachDiags := evaluateForEachExpressionValue(n.ModuleCall.ForEach, ctx, true) + _, forEachDiags := evaluateForEachExpressionValue(n.ModuleCall.ForEach, ctx, true, false) diags = diags.Append(forEachDiags) } diff --git a/internal/tofu/node_resource_abstract.go b/internal/tofu/node_resource_abstract.go index 9b049bc682..a2422c309f 100644 --- a/internal/tofu/node_resource_abstract.go +++ b/internal/tofu/node_resource_abstract.go @@ -258,8 +258,9 @@ func (n *NodeAbstractResource) RootReferences() []*addrs.Reference { } refs, _ := referencesInImportAddress(importTarget.Config.To) + root = append(root, refs...) - // TODO - Add RootReferences of ForEach here later one, once for_each is added + refs, _ = lang.ReferencesInExpr(addrs.ParseRef, importTarget.Config.ForEach) root = append(root, refs...) refs, _ = lang.ReferencesInExpr(addrs.ParseRef, importTarget.Config.ID) diff --git a/internal/tofu/node_resource_plan.go b/internal/tofu/node_resource_plan.go index 3d6d1bf9ae..676dbd91db 100644 --- a/internal/tofu/node_resource_plan.go +++ b/internal/tofu/node_resource_plan.go @@ -156,7 +156,7 @@ func (n *nodeExpandPlannableResource) DynamicExpand(ctx EvalContext) (*Graph, er var diags tfdiags.Diagnostics for _, importTarget := range n.importTargets { if importTarget.IsFromImportBlock() { - err := importResolver.ResolveImport(importTarget, ctx) + err := importResolver.ExpandAndResolveImport(importTarget, ctx) diags = diags.Append(err) } } diff --git a/internal/tofu/node_resource_validate.go b/internal/tofu/node_resource_validate.go index ca69390eb2..2003ba1934 100644 --- a/internal/tofu/node_resource_validate.go +++ b/internal/tofu/node_resource_validate.go @@ -565,7 +565,7 @@ func validateCount(ctx EvalContext, expr hcl.Expression) (diags tfdiags.Diagnost } func validateForEach(ctx EvalContext, expr hcl.Expression) (diags tfdiags.Diagnostics) { - val, forEachDiags := evaluateForEachExpressionValue(expr, ctx, true) + val, forEachDiags := evaluateForEachExpressionValue(expr, ctx, true, false) // If the value isn't known then that's the best we can do for now, but // we'll check more thoroughly during the plan walk if !val.IsKnown() {