Make import block's to possibly more dynamic (#1270)

Signed-off-by: RLRabinowitz <rlrabinowitz2@gmail.com>
Signed-off-by: Ronny Orot <ronny.orot@gmail.com>
Co-authored-by: Ronny Orot <ronny.orot@gmail.com>
This commit is contained in:
Arel Rabinowitz 2024-04-15 13:06:35 +03:00 committed by GitHub
parent d7e96665f6
commit 63c88507a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1417 additions and 232 deletions

View File

@ -39,6 +39,7 @@ ENHANCEMENTS:
* Added aliases for `state list` (`state ls`), `state mv` (`state move`), and `state rm` (`state remove`) ([#1220](https://github.com/opentofu/opentofu/pull/1220))
* Added mechanism to introduce automatic retries for provider installations, specifically targeting transient errors ([#1233](https://github.com/opentofu/opentofu/issues/1233))
* Added `-json` flag to `tofu init` and `tofu get` to support output in json format. ([#1453](https://github.com/opentofu/opentofu/pull/1453))
* `import` blocks `to` address can now support dynamic values (like variables, locals, conditions, and references to resources or data blocks) in index keys. ([#1270](https://github.com/opentofu/opentofu/pull/1270))
BUG FIXES:
* Fix view hooks unit test flakiness by deterministically waiting for heartbeats to execute ([$1153](https://github.com/opentofu/opentofu/issues/1153))

View File

@ -432,7 +432,7 @@ func (c *Config) addProviderRequirements(reqs getproviders.Requirements, recurse
reqs[fqn] = nil
}
for _, i := range c.Module.Import {
implied, err := addrs.ParseProviderPart(i.To.Resource.Resource.ImpliedProvider())
implied, err := addrs.ParseProviderPart(i.StaticTo.Resource.ImpliedProvider())
if err == nil {
provider := c.Module.ImpliedProviderForUnqualifiedType(implied)
if _, exists := reqs[provider]; exists {
@ -454,14 +454,14 @@ func (c *Config) addProviderRequirements(reqs getproviders.Requirements, recurse
// this will be because the user has written explicit provider arguments
// that don't agree and we'll get them to fix it.
for _, i := range c.Module.Import {
if len(i.To.Module) > 0 {
if len(i.StaticTo.Module) > 0 {
// All provider information for imports into modules should come
// from the module block, so we don't need to load anything for
// import targets within modules.
continue
}
if target, exists := c.Module.ManagedResources[i.To.String()]; exists {
if target, exists := c.Module.ManagedResources[i.StaticTo.String()]; exists {
// This means the information about the provider for this import
// should come from the resource block itself and not the import
// block.

View File

@ -7,12 +7,41 @@ package configs
import (
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/tfdiags"
)
type Import struct {
ID hcl.Expression
To addrs.AbsResourceInstance
// To is the address HCL expression given in the `import` block configuration.
// It supports the following address formats:
// - aws_s3_bucket.my_bucket
// - module.my_module.aws_s3_bucket.my_bucket
// - aws_s3_bucket.my_bucket["static_key"]
// - module.my_module[0].aws_s3_bucket.my_buckets["static_key"]
// - aws_s3_bucket.my_bucket[expression]
// - module.my_module[expression].aws_s3_bucket.my_buckets[expression]
// A dynamic instance key supports a dynamic expression like - a variable, a local, a condition (for example,
// ternary), a resource block attribute, a data block attribute, etc.
To hcl.Expression
// StaticTo is the corresponding resource and module that the address is referring to. When decoding, as long
// as the `to` field is in the accepted format, we could determine the actual modules and resource that the
// address represents. However, we do not yet know for certain what module instance and resource instance this
// address refers to. So, Static import is mainly used to figure out the Module and Resource, and Provider of the
// import target resource
// If we could not determine the StaticTo when decoding the block, then the address is in an unacceptable format
StaticTo addrs.ConfigResource
// ResolvedTo will be a reference to the resource instance of the import target, if it can be resolved when decoding
// the `import` block. If the `to` field does not represent a static address
// (for example: module.my_module[var.var1].aws_s3_bucket.bucket), then this will be nil.
// However, if the address is static and can be fully resolved at decode time
// (for example: module.my_module[2].aws_s3_bucket.bucket), then this will be a reference to the resource instance's
// address
// Mainly used for early validations on the import block address, for example making sure there are no duplicate
// import blocks targeting the same resource
ResolvedTo *addrs.AbsResourceInstance
ProviderConfigRef *ProviderConfigRef
Provider addrs.Provider
@ -35,17 +64,21 @@ func decodeImportBlock(block *hcl.Block) (*Import, hcl.Diagnostics) {
}
if attr, exists := content.Attributes["to"]; exists {
traversal, traversalDiags := hcl.AbsTraversalForExpr(attr.Expr)
diags = append(diags, traversalDiags...)
if !traversalDiags.HasErrors() {
to, toDiags := addrs.ParseAbsResourceInstance(traversal)
diags = append(diags, toDiags.ToHCL()...)
imp.To = to
imp.To = attr.Expr
staticAddress, addressDiags := staticImportAddress(attr.Expr)
diags = append(diags, addressDiags.ToHCL()...)
// Exit early if there are issues resolving the static address part. We wouldn't be able to validate the provider in such a case
if addressDiags.HasErrors() {
return imp, diags
}
imp.StaticTo = staticAddress
imp.ResolvedTo = resolvedImportAddress(imp.To)
}
if attr, exists := content.Attributes["provider"]; exists {
if len(imp.To.Module) > 0 {
if len(imp.StaticTo.Module) > 0 {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid import provider argument",
@ -78,3 +111,72 @@ var importBlockSchema = &hcl.BodySchema{
},
},
}
// absTraversalForImportToExpr returns a static traversal of an import block's "to" field.
// It is inspired by hcl.AbsTraversalForExpr and by tofu.triggersExprToTraversal
// The use-case here is different - we want to also allow for hclsyntax.IndexExpr to be allowed,
// but we don't really care about the key part of it. We just want a traversal that could be converted to an address
// of a resource, so we could determine the module + resource + provider
//
// Currently, there are 4 types of HCL epressions that support AsTraversal:
// - hclsyntax.ScopeTraversalExpr - Simply returns the Traversal. Same for our use-case here
// - hclsyntax.RelativeTraversalExpr - Calculates hcl.AbsTraversalForExpr for the Source, and adds the Traversal to it. Same here, with absTraversalForImportToExpr instead
// - hclsyntax.LiteralValueExpr - Mainly for null/false/true values. Not relevant in our use-case, as it's could not really be part of a reference (unless it is inside of an index, which is irrelevant here anyway)
// - hclsyntax.ObjectConsKeyExpr - Not relevant here
//
// In addition to these, we need to also support hclsyntax.IndexExpr. For it - we do not care about what's in the index.
// We need only know the traversal parts of it the "Collection", as the index doesn't affect which resource/module this is
func absTraversalForImportToExpr(expr hcl.Expression) (traversal hcl.Traversal, diags tfdiags.Diagnostics) {
switch e := expr.(type) {
case *hclsyntax.IndexExpr:
t, d := absTraversalForImportToExpr(e.Collection)
diags = diags.Append(d)
traversal = append(traversal, t...)
case *hclsyntax.RelativeTraversalExpr:
t, d := absTraversalForImportToExpr(e.Source)
diags = diags.Append(d)
traversal = append(traversal, t...)
traversal = append(traversal, e.Traversal...)
case *hclsyntax.ScopeTraversalExpr:
traversal = append(traversal, e.Traversal...)
default:
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid import address expression",
Detail: "Import address must be a reference to a resource's address, and only allows for indexing with dynamic keys. For example: module.my_module[expression1].aws_s3_bucket.my_buckets[expression2] for resources inside of modules, or simply aws_s3_bucket.my_bucket for a resource in the root module",
Subject: expr.Range().Ptr(),
})
}
return
}
// staticImportAddress returns an addrs.ConfigResource representing the module and resource of the import target.
// If the address is of an unacceptable format, the function will return error diags
func staticImportAddress(expr hcl.Expression) (addrs.ConfigResource, tfdiags.Diagnostics) {
traversal, diags := absTraversalForImportToExpr(expr)
if diags.HasErrors() {
return addrs.ConfigResource{}, diags
}
absResourceInstance, diags := addrs.ParseAbsResourceInstance(traversal)
return absResourceInstance.ConfigResource(), diags
}
// resolvedImportAddress attempts to find the resource instance of the import target, if possible.
// Here, we attempt to resolve the address as though it is a static absolute traversal, if that's possible.
// This would only be possible if the `import` block's "to" field does not rely on any data that is dynamic
func resolvedImportAddress(expr hcl.Expression) *addrs.AbsResourceInstance {
var diags hcl.Diagnostics
traversal, traversalDiags := hcl.AbsTraversalForExpr(expr)
diags = append(diags, traversalDiags...)
if diags.HasErrors() {
return nil
}
to, toDiags := addrs.ParseAbsResourceInstance(traversal)
diags = append(diags, toDiags.ToHCL()...)
if diags.HasErrors() {
return nil
}
return &to
}

View File

@ -6,9 +6,11 @@
package configs
import (
"fmt"
"reflect"
"testing"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hcltest"
@ -17,8 +19,9 @@ import (
)
var (
typeComparer = cmp.Comparer(cty.Type.Equals)
valueComparer = cmp.Comparer(cty.Value.RawEquals)
typeComparer = cmp.Comparer(cty.Type.Equals)
valueComparer = cmp.Comparer(cty.Value.RawEquals)
traversalComparer = cmp.Comparer(traversalsAreEquivalent)
)
func TestImportBlock_decode(t *testing.T) {
@ -27,13 +30,42 @@ func TestImportBlock_decode(t *testing.T) {
Start: hcl.Pos{Line: 3, Column: 12, Byte: 27},
End: hcl.Pos{Line: 3, Column: 19, Byte: 34},
}
pos := hcl.Pos{Line: 1, Column: 1}
foo_str_expr := hcltest.MockExprLiteral(cty.StringVal("foo"))
bar_expr := hcltest.MockExprTraversalSrc("test_instance.bar")
fooStrExpr, hclDiags := hclsyntax.ParseExpression([]byte("\"foo\""), "", pos)
if hclDiags.HasErrors() {
t.Fatal(hclDiags)
}
barExpr, hclDiags := hclsyntax.ParseExpression([]byte("test_instance.bar"), "", pos)
if hclDiags.HasErrors() {
t.Fatal(hclDiags)
}
bar_index_expr := hcltest.MockExprTraversalSrc("test_instance.bar[\"one\"]")
barIndexExpr, hclDiags := hclsyntax.ParseExpression([]byte("test_instance.bar[\"one\"]"), "", pos)
if hclDiags.HasErrors() {
t.Fatal(hclDiags)
}
mod_bar_expr := hcltest.MockExprTraversalSrc("module.bar.test_instance.bar")
modBarExpr, hclDiags := hclsyntax.ParseExpression([]byte("module.bar.test_instance.bar"), "", pos)
if hclDiags.HasErrors() {
t.Fatal(hclDiags)
}
dynamicBarExpr, hclDiags := hclsyntax.ParseExpression([]byte("test_instance.bar[var.var1]"), "", pos)
if hclDiags.HasErrors() {
t.Fatal(hclDiags)
}
invalidExpr, hclDiags := hclsyntax.ParseExpression([]byte("var.var1 ? test_instance.bar : test_instance.foo"), "", pos)
if hclDiags.HasErrors() {
t.Fatal(hclDiags)
}
barResource := addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "bar",
}
tests := map[string]struct {
input *hcl.Block
@ -47,19 +79,25 @@ func TestImportBlock_decode(t *testing.T) {
Attributes: hcl.Attributes{
"id": {
Name: "id",
Expr: foo_str_expr,
Expr: fooStrExpr,
},
"to": {
Name: "to",
Expr: bar_expr,
Expr: barExpr,
},
},
}),
DefRange: blockRange,
},
&Import{
To: mustAbsResourceInstanceAddr("test_instance.bar"),
ID: foo_str_expr,
To: barExpr,
ResolvedTo: &addrs.AbsResourceInstance{
Resource: addrs.ResourceInstance{Resource: barResource},
},
StaticTo: addrs.ConfigResource{
Resource: barResource,
},
ID: fooStrExpr,
DeclRange: blockRange,
},
``,
@ -71,19 +109,28 @@ func TestImportBlock_decode(t *testing.T) {
Attributes: hcl.Attributes{
"id": {
Name: "id",
Expr: foo_str_expr,
Expr: fooStrExpr,
},
"to": {
Name: "to",
Expr: bar_index_expr,
Expr: barIndexExpr,
},
},
}),
DefRange: blockRange,
},
&Import{
To: mustAbsResourceInstanceAddr("test_instance.bar[\"one\"]"),
ID: foo_str_expr,
To: barIndexExpr,
StaticTo: addrs.ConfigResource{
Resource: barResource,
},
ResolvedTo: &addrs.AbsResourceInstance{
Resource: addrs.ResourceInstance{
Resource: barResource,
Key: addrs.StringKey("one"),
},
},
ID: fooStrExpr,
DeclRange: blockRange,
},
``,
@ -95,19 +142,58 @@ func TestImportBlock_decode(t *testing.T) {
Attributes: hcl.Attributes{
"id": {
Name: "id",
Expr: foo_str_expr,
Expr: fooStrExpr,
},
"to": {
Name: "to",
Expr: mod_bar_expr,
Expr: modBarExpr,
},
},
}),
DefRange: blockRange,
},
&Import{
To: mustAbsResourceInstanceAddr("module.bar.test_instance.bar"),
ID: foo_str_expr,
To: modBarExpr,
StaticTo: addrs.ConfigResource{
Module: addrs.Module{"bar"},
Resource: barResource,
},
ResolvedTo: &addrs.AbsResourceInstance{
Module: addrs.ModuleInstance{addrs.ModuleInstanceStep{
Name: "bar",
}},
Resource: addrs.ResourceInstance{
Resource: barResource,
},
},
ID: fooStrExpr,
DeclRange: blockRange,
},
``,
},
"dynamic resource index": {
&hcl.Block{
Type: "import",
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"id": {
Name: "id",
Expr: fooStrExpr,
},
"to": {
Name: "to",
Expr: dynamicBarExpr,
},
},
}),
DefRange: blockRange,
},
&Import{
To: dynamicBarExpr,
StaticTo: addrs.ConfigResource{
Resource: barResource,
},
ID: fooStrExpr,
DeclRange: blockRange,
},
``,
@ -119,14 +205,20 @@ func TestImportBlock_decode(t *testing.T) {
Attributes: hcl.Attributes{
"to": {
Name: "to",
Expr: bar_expr,
Expr: barExpr,
},
},
}),
DefRange: blockRange,
},
&Import{
To: mustAbsResourceInstanceAddr("test_instance.bar"),
To: barExpr,
ResolvedTo: &addrs.AbsResourceInstance{
Resource: addrs.ResourceInstance{Resource: barResource},
},
StaticTo: addrs.ConfigResource{
Resource: barResource,
},
DeclRange: blockRange,
},
"Missing required argument",
@ -138,18 +230,42 @@ func TestImportBlock_decode(t *testing.T) {
Attributes: hcl.Attributes{
"id": {
Name: "id",
Expr: foo_str_expr,
Expr: fooStrExpr,
},
},
}),
DefRange: blockRange,
},
&Import{
ID: foo_str_expr,
ID: fooStrExpr,
DeclRange: blockRange,
},
"Missing required argument",
},
"error: invalid import address": {
&hcl.Block{
Type: "import",
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"id": {
Name: "id",
Expr: fooStrExpr,
},
"to": {
Name: "to",
Expr: invalidExpr,
},
},
}),
DefRange: blockRange,
},
&Import{
To: invalidExpr,
ID: fooStrExpr,
DeclRange: blockRange,
},
"Invalid import address expression",
},
}
for name, test := range tests {
@ -167,17 +283,47 @@ func TestImportBlock_decode(t *testing.T) {
t.Fatal("expected error")
}
if !cmp.Equal(got, test.want, typeComparer, valueComparer) {
t.Fatalf("wrong result: %s", cmp.Diff(got, test.want))
if !cmp.Equal(got, test.want, typeComparer, valueComparer, traversalComparer) {
t.Fatalf("wrong result: %s", cmp.Diff(got, test.want, typeComparer, valueComparer, traversalComparer))
}
})
}
}
func mustAbsResourceInstanceAddr(str string) addrs.AbsResourceInstance {
addr, diags := addrs.ParseAbsResourceInstanceStr(str)
if diags.HasErrors() {
panic(fmt.Sprintf("invalid absolute resource instance address: %s", diags.Err()))
// Taken from traversalsAreEquivalent of hcl/v2
func traversalsAreEquivalent(a, b hcl.Traversal) bool {
if len(a) != len(b) {
return false
}
return addr
for i := range a {
aStep := a[i]
bStep := b[i]
if reflect.TypeOf(aStep) != reflect.TypeOf(bStep) {
return false
}
// We can now assume that both are of the same type.
switch ts := aStep.(type) {
case hcl.TraverseRoot:
if bStep.(hcl.TraverseRoot).Name != ts.Name {
return false
}
case hcl.TraverseAttr:
if bStep.(hcl.TraverseAttr).Name != ts.Name {
return false
}
case hcl.TraverseIndex:
if !bStep.(hcl.TraverseIndex).Key.RawEquals(ts.Key) {
return false
}
default:
return false
}
}
return true
}

View File

@ -442,11 +442,11 @@ func (m *Module) appendFile(file *File) hcl.Diagnostics {
for _, i := range file.Import {
for _, mi := range m.Import {
if i.To.Equal(mi.To) {
if i.ResolvedTo != nil && mi.ResolvedTo != nil && (*i.ResolvedTo).Equal(*mi.ResolvedTo) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Duplicate import configuration for %q", i.To),
Detail: fmt.Sprintf("An import block for the resource %q was already declared at %s. A resource can have only one import block.", i.To, mi.DeclRange),
Summary: fmt.Sprintf("Duplicate import configuration for %q", *i.ResolvedTo),
Detail: fmt.Sprintf("An import block for the resource %q was already declared at %s. A resource can have only one import block.", *i.ResolvedTo, mi.DeclRange),
Subject: &i.DeclRange,
})
continue
@ -459,7 +459,7 @@ func (m *Module) appendFile(file *File) hcl.Diagnostics {
Alias: i.ProviderConfigRef.Alias,
})
} else {
implied, err := addrs.ParseProviderPart(i.To.Resource.Resource.ImpliedProvider())
implied, err := addrs.ParseProviderPart(i.StaticTo.Resource.ImpliedProvider())
if err == nil {
i.Provider = m.ImpliedProviderForUnqualifiedType(implied)
}
@ -467,22 +467,6 @@ func (m *Module) appendFile(file *File) hcl.Diagnostics {
// will already have been caught.
}
// It is invalid for any import block to have a "to" argument matching
// any moved block's "from" argument.
for _, mb := range m.Moved {
// Comparing string serialisations is good enough here, because we
// only care about equality in the case that both addresses are
// AbsResourceInstances.
if mb.From.String() == i.To.String() {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Cannot import to a move source",
Detail: fmt.Sprintf("An import block for ID %q targets resource address %s, but this address appears in the \"from\" argument of a moved block, which is invalid. Please change the import target to a different address, such as the move target.", i.ID, i.To),
Subject: &i.DeclRange,
})
}
}
m.Import = append(m.Import, i)
}

View File

@ -1,12 +0,0 @@
import {
id = "foo/bar"
to = local_file.foo_bar
}
moved {
from = local_file.foo_bar
to = local_file.bar_baz
}
resource "local_file" "bar_baz" {
}

View File

@ -6,6 +6,7 @@
package tofu
import (
"fmt"
"log"
"sync"
@ -71,50 +72,39 @@ func (i *ImportTarget) IsFromImportCommandLine() bool {
// and could only be evaluated down the line. Here, we create a static representation for the address.
// This is useful so that we could have information on the ImportTarget early on, such as the Module and Resource of it
func (i *ImportTarget) StaticAddr() addrs.ConfigResource {
if i.CommandLineImportTarget != nil {
if i.IsFromImportCommandLine() {
return i.CommandLineImportTarget.Addr.ConfigResource()
}
// TODO change this later, once we change Config.To to not be a static address
return i.Config.To.ConfigResource()
return i.Config.StaticTo
}
// ResolvedAddr returns the resolved address of an import target, if possible. If not possible, returns an HCL diag
// ResolvedAddr returns a reference to the resolved address of an import target, if possible. If not possible, it
// returns nil.
// For an ImportTarget originating from the command line, the address is already known
// However for an ImportTarget originating from an import block, the full address might not be known initially,
// and could only be evaluated down the line. Here, we attempt to resolve the address as though it is a static absolute
// traversal, if that's possible
func (i *ImportTarget) ResolvedAddr() (address addrs.AbsResourceInstance, evaluationDiags hcl.Diagnostics) {
if i.CommandLineImportTarget != nil {
address = i.CommandLineImportTarget.Addr
// and could only be evaluated down the line.
func (i *ImportTarget) ResolvedAddr() *addrs.AbsResourceInstance {
if i.IsFromImportCommandLine() {
return &i.CommandLineImportTarget.Addr
} else {
// TODO change this later, when Config.To is not a static address
address = i.Config.To
return i.Config.ResolvedTo
}
return
}
// ResolvedConfigImportsKey is a key for a map of ImportTargets originating from the configuration
// It is used as a one-to-one representation of an EvaluatedConfigImportTarget.
// Used in ImportResolver to maintain a map of all resolved imports when walking the graph
type ResolvedConfigImportsKey struct {
// An address string is one-to-one with addrs.AbsResourceInstance
AddrStr string
ID string
}
// ImportResolver is a struct that maintains a map of all imports as they are being resolved.
// This is specifically for imports originating from configuration.
// Import targets' addresses are not fully known from the get-go, and could only be resolved later when walking
// the graph. This struct helps keep track of the resolved imports, mostly for validation that all imports
// have been addressed and point to an actual configuration
// have been addressed and point to an actual configuration.
// The key of the map is a string representation of the address, and the value is an EvaluatedConfigImportTarget.
type ImportResolver struct {
mu sync.RWMutex
imports map[ResolvedConfigImportsKey]EvaluatedConfigImportTarget
imports map[string]EvaluatedConfigImportTarget
}
func NewImportResolver() *ImportResolver {
return &ImportResolver{imports: make(map[ResolvedConfigImportsKey]EvaluatedConfigImportTarget)}
return &ImportResolver{imports: make(map[string]EvaluatedConfigImportTarget)}
}
// ResolveImport resolves the ID and address (soon, when it will be necessary) of an ImportTarget originating
@ -122,29 +112,47 @@ func NewImportResolver() *ImportResolver {
// 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) error {
importId, evalDiags := evaluateImportIdExpression(importTarget.Config.ID, ctx)
if evalDiags.HasErrors() {
return evalDiags.Err()
func (ri *ImportResolver) ResolveImport(importTarget *ImportTarget, ctx EvalContext) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
// The import block expressions are declared within the root module.
// We need to explicitly use the context with the path of the root module, so that all references will be
// relative to the root module
rootCtx := ctx.WithPath(addrs.RootModuleInstance)
importId, evalDiags := evaluateImportIdExpression(importTarget.Config.ID, rootCtx)
diags = diags.Append(evalDiags)
if diags.HasErrors() {
return diags
}
// TODO - Change once an import target's address is more dynamic
importAddress := importTarget.Config.To
importAddress, addressDiags := rootCtx.EvaluateImportAddress(importTarget.Config.To)
diags = diags.Append(addressDiags)
if diags.HasErrors() {
return diags
}
ri.mu.Lock()
defer ri.mu.Unlock()
resolvedImportKey := ResolvedConfigImportsKey{
AddrStr: importAddress.String(),
ID: importId,
resolvedImportKey := importAddress.String()
if importTarget, exists := ri.imports[resolvedImportKey]; exists {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Duplicate import configuration for %q", importAddress),
Detail: fmt.Sprintf("An import block for the resource %q was already declared at %s. A resource can have only one import block.", importAddress, importTarget.Config.DeclRange),
Subject: importTarget.Config.DeclRange.Ptr(),
})
}
ri.imports[resolvedImportKey] = EvaluatedConfigImportTarget{
Config: importTarget.Config,
Addr: importAddress,
ID: importId,
}
return nil
return diags
}
// GetAllImports returns all resolved imports
@ -159,6 +167,18 @@ func (ri *ImportResolver) GetAllImports() []EvaluatedConfigImportTarget {
return allImports
}
func (ri *ImportResolver) GetImport(address addrs.AbsResourceInstance) *EvaluatedConfigImportTarget {
ri.mu.RLock()
defer ri.mu.RUnlock()
for _, importTarget := range ri.imports {
if importTarget.Addr.Equal(address) {
return &importTarget
}
}
return nil
}
// Import takes already-created external resources and brings them
// under OpenTofu management. Import requires the exact type, name, and ID
// of the resources to import.

View File

@ -311,9 +311,12 @@ func (c *Context) plan(config *configs.Config, prevRunState *states.State, opts
panic(fmt.Sprintf("called Context.plan with %s", opts.Mode))
}
opts.ImportTargets = c.findImportTargets(config, prevRunState)
importTargetDiags := c.validateImportTargets(config, opts.ImportTargets)
opts.ImportTargets = c.findImportTargets(config)
importTargetDiags := c.validateImportTargets(config, opts.ImportTargets, opts.GenerateConfigPath)
diags = diags.Append(importTargetDiags)
if diags.HasErrors() {
return nil, diags
}
var endpointsToRemoveDiags tfdiags.Diagnostics
opts.EndpointsToRemove, endpointsToRemoveDiags = refactoring.GetEndpointsToRemove(config)
@ -547,53 +550,113 @@ func (c *Context) postPlanValidateMoves(config *configs.Config, stmts []refactor
// relaxed.
func (c *Context) postPlanValidateImports(importResolver *ImportResolver, allInst instances.Set) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
for resolvedImport := range importResolver.imports {
// We only care about import target addresses that have a key.
// If the address does not have a key, we don't need it to be in config
// because are able to generate config.
address, addrParseDiags := addrs.ParseAbsResourceInstanceStr(resolvedImport.AddrStr)
if addrParseDiags.HasErrors() {
return addrParseDiags
}
if !allInst.HasResourceInstance(address) {
diags = diags.Append(importResourceWithoutConfigDiags(address, nil))
for _, importTarget := range importResolver.GetAllImports() {
if !allInst.HasResourceInstance(importTarget.Addr) {
diags = diags.Append(importResourceWithoutConfigDiags(importTarget.Addr.String(), nil))
}
}
return diags
}
// findImportTargets builds a list of import targets by taking the import blocks
// in the config and filtering out any that target a resource already in state.
func (c *Context) findImportTargets(config *configs.Config, priorState *states.State) []*ImportTarget {
// findImportTargets builds a list of import targets by going over the import
// blocks in the config.
func (c *Context) findImportTargets(config *configs.Config) []*ImportTarget {
var importTargets []*ImportTarget
for _, ic := range config.Module.Import {
if priorState.ResourceInstance(ic.To) == nil {
importTargets = append(importTargets, &ImportTarget{
Config: ic,
})
}
importTargets = append(importTargets, &ImportTarget{
Config: ic,
})
}
return importTargets
}
func (c *Context) validateImportTargets(config *configs.Config, importTargets []*ImportTarget) (diags tfdiags.Diagnostics) {
// validateImportTargets makes sure all import targets are not breaking the following rules:
// 1. Imports are attempted into resources that do not exist (if config generation is not enabled).
// 2. Config generation is not attempted for resources inside sub-modules
// 3. Config generation is not attempted for resources with indexes (for_each/count) - This will always include
// resources for which we could not yet resolve the address
func (c *Context) validateImportTargets(config *configs.Config, importTargets []*ImportTarget, generateConfigPath string) (diags tfdiags.Diagnostics) {
configGeneration := len(generateConfigPath) > 0
for _, imp := range importTargets {
staticAddress := imp.StaticAddr()
descendantConfig := config.Descendent(staticAddress.Module)
// If import target's module does not exist
if descendantConfig == nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Cannot import to non-existent resource address",
Detail: fmt.Sprintf("Importing to resource address '%s' is not possible, because that address does not exist in configuration. Please ensure that the resource key is correct, or remove this import block.", staticAddress),
Subject: imp.Config.DeclRange.Ptr(),
})
return
if configGeneration {
// Attempted config generation for resource in non-existing module. So error because resource generation
// is not allowed in a sub-module
diags = diags.Append(importConfigGenerationInModuleDiags(staticAddress.String(), imp.Config))
} else {
diags = diags.Append(importResourceWithoutConfigDiags(staticAddress.String(), imp.Config))
}
continue
}
if _, exists := descendantConfig.Module.ManagedResources[staticAddress.Resource.String()]; !exists {
if configGeneration {
if imp.ResolvedAddr() == nil {
// If we could not resolve the address of the import target, the address must have contained indexes
diags = diags.Append(importConfigGenerationWithIndexDiags(staticAddress.String(), imp.Config))
continue
} else if !imp.ResolvedAddr().Module.IsRoot() {
diags = diags.Append(importConfigGenerationInModuleDiags(imp.ResolvedAddr().String(), imp.Config))
continue
} else if imp.ResolvedAddr().Resource.Key != addrs.NoKey {
diags = diags.Append(importConfigGenerationWithIndexDiags(imp.ResolvedAddr().String(), imp.Config))
continue
}
} else {
diags = diags.Append(importResourceWithoutConfigDiags(staticAddress.String(), imp.Config))
continue
}
}
}
return
}
func importConfigGenerationInModuleDiags(addressStr string, config *configs.Import) *hcl.Diagnostic {
diag := hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Cannot generate configuration for resource inside sub-module",
Detail: fmt.Sprintf("The configuration for the given import %s does not exist. Configuration generation is only possible for resources in the root module, and not possible for resources in sub-modules.", addressStr),
}
if config != nil {
diag.Subject = config.DeclRange.Ptr()
}
return &diag
}
func importConfigGenerationWithIndexDiags(addressStr string, config *configs.Import) *hcl.Diagnostic {
diag := hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Configuration generation for count and for_each resources not supported",
Detail: fmt.Sprintf("The configuration for the given import %s does not exist. Configuration generation is only possible for resources that do not use count or for_each", addressStr),
}
if config != nil {
diag.Subject = config.DeclRange.Ptr()
}
return &diag
}
func importResourceWithoutConfigDiags(addressStr string, config *configs.Import) *hcl.Diagnostic {
diag := hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Configuration for import target does not exist",
Detail: fmt.Sprintf("The configuration for the given import %s does not exist. All target instances must have an associated configuration to be imported.", addressStr),
}
if config != nil {
diag.Subject = config.DeclRange.Ptr()
}
return &diag
}
func (c *Context) planWalk(config *configs.Config, prevRunState *states.State, opts *PlanOpts) (*plans.Plan, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
log.Printf("[DEBUG] Building and walking plan graph for %s", opts.Mode)

View File

@ -9,6 +9,7 @@ import (
"bytes"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"testing"
@ -4286,6 +4287,417 @@ import {
})
}
func TestContext2Plan_importToDynamicAddress(t *testing.T) {
type TestConfiguration struct {
Description string
ResolvedAddress string
inlineConfiguration map[string]string
}
configurations := []TestConfiguration{
{
Description: "To address includes a variable as index",
ResolvedAddress: "test_object.a[0]",
inlineConfiguration: map[string]string{
"main.tf": `
variable "index" {
default = 0
}
resource "test_object" "a" {
count = 1
test_string = "foo"
}
import {
to = test_object.a[var.index]
id = "%d"
}
`,
},
},
{
Description: "To address includes a local as index",
ResolvedAddress: "test_object.a[0]",
inlineConfiguration: map[string]string{
"main.tf": `
locals {
index = 0
}
resource "test_object" "a" {
count = 1
test_string = "foo"
}
import {
to = test_object.a[local.index]
id = "%d"
}
`,
},
},
{
Description: "To address includes a conditional expression as index",
ResolvedAddress: "test_object.a[\"zero\"]",
inlineConfiguration: map[string]string{
"main.tf": `
resource "test_object" "a" {
for_each = toset(["zero"])
test_string = "foo"
}
import {
to = test_object.a[ true ? "zero" : "one"]
id = "%d"
}
`,
},
},
{
Description: "To address includes a conditional expression with vars and locals as index",
ResolvedAddress: "test_object.a[\"one\"]",
inlineConfiguration: map[string]string{
"main.tf": `
variable "one" {
default = 1
}
locals {
zero = "zero"
one = "one"
}
resource "test_object" "a" {
for_each = toset(["one"])
test_string = "foo"
}
import {
to = test_object.a[var.one == 1 ? local.one : local.zero]
id = "%d"
}
`,
},
},
{
Description: "To address includes a resource reference as index",
ResolvedAddress: "test_object.a[\"boop\"]",
inlineConfiguration: map[string]string{
"main.tf": `
resource "test_object" "reference" {
test_string = "boop"
}
resource "test_object" "a" {
for_each = toset(["boop"])
test_string = "foo"
}
import {
to = test_object.a[test_object.reference.test_string]
id = "%d"
}
`,
},
},
{
Description: "To address includes a data reference as index",
ResolvedAddress: "test_object.a[\"bip\"]",
inlineConfiguration: map[string]string{
"main.tf": `
data "test_object" "reference" {
}
resource "test_object" "a" {
for_each = toset(["bip"])
test_string = "foo"
}
import {
to = test_object.a[data.test_object.reference.test_string]
id = "%d"
}
`,
},
},
}
const importId = 123
for _, configuration := range configurations {
t.Run(configuration.Description, func(t *testing.T) {
// Format the configuration with the import ID
formattedConfiguration := make(map[string]string)
for configFileName, configFileContent := range configuration.inlineConfiguration {
formattedConfigFileContent := fmt.Sprintf(configFileContent, importId)
formattedConfiguration[configFileName] = formattedConfigFileContent
}
addr := mustResourceInstanceAddr(configuration.ResolvedAddress)
m := testModuleInline(t, formattedConfiguration)
p := &MockProvider{
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
Provider: providers.Schema{Block: simpleTestSchema()},
ResourceTypes: map[string]providers.Schema{
"test_object": providers.Schema{Block: simpleTestSchema()},
},
DataSources: map[string]providers.Schema{
"test_object": providers.Schema{
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"test_string": {
Type: cty.String,
Optional: true,
},
},
},
},
},
},
}
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{
"test_string": cty.StringVal("foo"),
}),
}
p.ReadDataSourceResponse = &providers.ReadDataSourceResponse{
State: cty.ObjectVal(map[string]cty.Value{
"test_string": cty.StringVal("bip"),
}),
}
p.ImportResourceStateResponse = &providers.ImportResourceStateResponse{
ImportedResources: []providers.ImportedResource{
{
TypeName: "test_object",
State: cty.ObjectVal(map[string]cty.Value{
"test_string": cty.StringVal("foo"),
}),
},
},
}
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())
}
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 != strconv.Itoa(importId) {
t.Errorf("expected import change from \"%d\", got non-import change", importId)
}
if !hook.PrePlanImportCalled {
t.Fatalf("PostPlanImport hook not called")
}
if addr, wantAddr := hook.PrePlanImportAddr, instPlan.Addr; !addr.Equal(wantAddr) {
t.Errorf("expected addr to be %s, but was %s", wantAddr, addr)
}
if !hook.PostPlanImportCalled {
t.Fatalf("PostPlanImport hook not called")
}
if addr, wantAddr := hook.PostPlanImportAddr, instPlan.Addr; !addr.Equal(wantAddr) {
t.Errorf("expected addr to be %s, but was %s", wantAddr, addr)
}
})
})
}
}
func TestContext2Plan_importToInvalidDynamicAddress(t *testing.T) {
type TestConfiguration struct {
Description string
expectedError string
inlineConfiguration map[string]string
}
configurations := []TestConfiguration{
{
Description: "To address index value is null",
expectedError: "Import block 'to' address contains an invalid key: Import block contained a resource address using an index which is null. Please ensure the expression for the index is not null",
inlineConfiguration: map[string]string{
"main.tf": `
variable "index" {
default = null
}
resource "test_object" "a" {
count = 1
test_string = "foo"
}
import {
to = test_object.a[var.index]
id = "123"
}
`,
},
},
{
Description: "To address index is not a number or a string",
expectedError: "Import block 'to' address contains an invalid key: Import block contained a resource address using an index which is not valid for a resource instance (not a string or a number). Please ensure the expression for the index is correct, and returns either a string or a number",
inlineConfiguration: map[string]string{
"main.tf": `
locals {
index = toset(["foo"])
}
resource "test_object" "a" {
for_each = toset(["foo"])
test_string = "foo"
}
import {
to = test_object.a[local.index]
id = "123"
}
`,
},
},
{
Description: "To address index value is sensitive",
expectedError: "Import block 'to' address contains an invalid key: Import block contained a resource address using an index which is sensitive. Please ensure indexes used in the resource address of an import target are not sensitive",
inlineConfiguration: map[string]string{
"main.tf": `
locals {
index = sensitive("foo")
}
resource "test_object" "a" {
for_each = toset(["foo"])
test_string = "foo"
}
import {
to = test_object.a[local.index]
id = "123"
}
`,
},
},
{
Description: "To address index value will only be known after apply",
expectedError: "Import block contained a resource address using an index that will only be known after apply. Please ensure to use expressions that are known at plan time for the index of an import target address",
inlineConfiguration: map[string]string{
"main.tf": `
resource "test_object" "reference" {
}
resource "test_object" "a" {
count = 1
test_string = "foo"
}
import {
to = test_object.a[test_object.reference.id]
id = "123"
}
`,
},
},
}
for _, configuration := range configurations {
t.Run(configuration.Description, func(t *testing.T) {
m := testModuleInline(t, configuration.inlineConfiguration)
providerSchema := &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"test_string": {
Type: cty.String,
Optional: true,
},
"id": {
Type: cty.String,
Computed: true,
},
},
}
p := &MockProvider{
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
Provider: providers.Schema{Block: providerSchema},
ResourceTypes: map[string]providers.Schema{
"test_object": providers.Schema{Block: providerSchema},
},
},
}
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{
"test_string": cty.StringVal("foo"),
}),
}
p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse {
testStringVal := req.ProposedNewState.GetAttr("test_string")
return providers.PlanResourceChangeResponse{
PlannedState: cty.ObjectVal(map[string]cty.Value{
"test_string": testStringVal,
"id": cty.UnknownVal(cty.String),
}),
}
}
p.ImportResourceStateResponse = &providers.ImportResourceStateResponse{
ImportedResources: []providers.ImportedResource{
{
TypeName: "test_object",
State: cty.ObjectVal(map[string]cty.Value{
"test_string": cty.StringVal("foo"),
}),
},
},
}
_, diags := ctx.Plan(m, states.NewState(), SimplePlanOpts(plans.NormalMode, testInputValuesUnset(m.Module.Variables)))
if !diags.HasErrors() {
t.Fatal("succeeded; want errors")
}
if got, want := diags.Err().Error(), configuration.expectedError; !strings.Contains(got, want) {
t.Fatalf("wrong error:\ngot: %s\nwant: message containing %q", got, want)
}
})
}
}
func TestContext2Plan_importResourceAlreadyInState(t *testing.T) {
addr := mustResourceInstanceAddr("test_object.a")
m := testModuleInline(t, map[string]string{
@ -4905,33 +5317,6 @@ resource "test_object" "a" {
}
}
func TestContext2Plan_importIntoNonExistentModule(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
import {
to = module.mod.test_object.a
id = "456"
}
`,
})
p := simpleMockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
_, diags := ctx.Plan(m, states.NewState(), &PlanOpts{
Mode: plans.NormalMode,
})
if !diags.HasErrors() {
t.Fatalf("expected error")
}
if !strings.Contains(diags.Err().Error(), "Cannot import to non-existent resource address") {
t.Fatalf("expected error to be \"Cannot import to non-existent resource address\", but it was %s", diags.Err().Error())
}
}
func TestContext2Plan_importIntoNonExistentConfiguration(t *testing.T) {
type TestConfiguration struct {
Description string
@ -4946,6 +5331,17 @@ import {
to = test_object.a
id = "123"
}
`,
},
},
{
Description: "Non-existent module",
inlineConfiguration: map[string]string{
"main.tf": `
import {
to = module.mod.test_object.a
id = "456"
}
`,
},
},
@ -5092,6 +5488,120 @@ resource "test_object" "a" {
}
}
func TestContext2Plan_importDuplication(t *testing.T) {
type TestConfiguration struct {
Description string
inlineConfiguration map[string]string
expectedError string
}
configurations := []TestConfiguration{
{
Description: "Duplication with dynamic address with a variable",
inlineConfiguration: map[string]string{
"main.tf": `
resource "test_object" "a" {
count = 2
}
variable "address1" {
default = 1
}
variable "address2" {
default = 1
}
import {
to = test_object.a[var.address1]
id = "123"
}
import {
to = test_object.a[var.address2]
id = "123"
}
`,
},
expectedError: "Duplicate import configuration for \"test_object.a[1]\"",
},
{
Description: "Duplication with dynamic address with a resource reference",
inlineConfiguration: map[string]string{
"main.tf": `
resource "test_object" "example" {
test_string = "boop"
}
resource "test_object" "a" {
for_each = toset(["boop"])
}
import {
to = test_object.a[test_object.example.test_string]
id = "123"
}
import {
to = test_object.a[test_object.example.test_string]
id = "123"
}
`,
},
expectedError: "Duplicate import configuration for \"test_object.a[\\\"boop\\\"]\"",
},
}
for _, configuration := range configurations {
t.Run(configuration.Description, func(t *testing.T) {
m := testModuleInline(t, configuration.inlineConfiguration)
p := simpleMockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
p.ReadResourceResponse = &providers.ReadResourceResponse{
NewState: cty.ObjectVal(map[string]cty.Value{
"test_string": cty.StringVal("foo"),
}),
}
p.ImportResourceStateResponse = &providers.ImportResourceStateResponse{
ImportedResources: []providers.ImportedResource{
{
TypeName: "test_object",
State: cty.ObjectVal(map[string]cty.Value{
"test_string": cty.StringVal("foo"),
}),
},
},
}
_, diags := ctx.Plan(m, states.NewState(), SimplePlanOpts(plans.NormalMode, testInputValuesUnset(m.Module.Variables)))
if !diags.HasErrors() {
t.Fatalf("expected error")
}
var errNum int
for _, diag := range diags {
if diag.Severity() == tfdiags.Error {
errNum++
}
}
if errNum > 1 {
t.Fatalf("expected a single error, but got %d", errNum)
}
if !strings.Contains(diags.Err().Error(), configuration.expectedError) {
t.Fatalf("expected error to be %s, but it was %s", configuration.expectedError, diags.Err().Error())
}
})
}
}
func TestContext2Plan_importResourceConfigGen(t *testing.T) {
addr := mustResourceInstanceAddr("test_object.a")
m := testModuleInline(t, map[string]string{
@ -5252,6 +5762,249 @@ import {
})
}
func TestContext2Plan_importResourceConfigGenValidation(t *testing.T) {
type TestConfiguration struct {
Description string
inlineConfiguration map[string]string
expectedError string
}
configurations := []TestConfiguration{
{
Description: "Resource with index",
inlineConfiguration: map[string]string{
"main.tf": `
import {
to = test_object.a[0]
id = "123"
}
`,
},
expectedError: "Configuration generation for count and for_each resources not supported",
},
{
Description: "Resource with dynamic index",
inlineConfiguration: map[string]string{
"main.tf": `
locals {
loc = "something"
}
import {
to = test_object.a[local.loc]
id = "123"
}
`,
},
expectedError: "Configuration generation for count and for_each resources not supported",
},
{
Description: "Resource in module",
inlineConfiguration: map[string]string{
"main.tf": `
import {
to = module.mod.test_object.b
id = "456"
}
module "mod" {
source = "./mod"
}
`,
"./mod/main.tf": `
resource "test_object" "a" {
test_string = "bar"
}
`,
},
expectedError: "Cannot generate configuration for resource inside sub-module",
},
{
Description: "Resource in non-existent module",
inlineConfiguration: map[string]string{
"main.tf": `
import {
to = module.mod.test_object.a
id = "456"
}
`,
},
expectedError: "Cannot generate configuration for resource inside sub-module",
},
{
Description: "Wrong module key",
inlineConfiguration: map[string]string{
"main.tf": `
import {
to = module.mod["non-existent"].test_object.a
id = "123"
}
module "mod" {
for_each = {
existent = "1"
}
source = "./mod"
}
`,
"./mod/main.tf": `
resource "test_object" "a" {
test_string = "bar"
}
`,
},
expectedError: "Configuration for import target does not exist",
},
{
Description: "In module with module key",
inlineConfiguration: map[string]string{
"main.tf": `
import {
to = module.mod["existent"].test_object.b
id = "123"
}
module "mod" {
for_each = {
existent = "1"
}
source = "./mod"
}
`,
"./mod/main.tf": `
resource "test_object" "a" {
test_string = "bar"
}
`,
},
expectedError: "Cannot generate configuration for resource inside sub-module",
},
{
Description: "Module key without for_each",
inlineConfiguration: map[string]string{
"main.tf": `
import {
to = module.mod["non-existent"].test_object.a
id = "123"
}
module "mod" {
source = "./mod"
}
`,
"./mod/main.tf": `
resource "test_object" "a" {
test_string = "bar"
}
`,
},
expectedError: "Configuration for import target does not exist",
},
{
Description: "Non-existent resource key - in module",
inlineConfiguration: map[string]string{
"main.tf": `
import {
to = module.mod.test_object.a["non-existent"]
id = "123"
}
module "mod" {
source = "./mod"
}
`,
"./mod/main.tf": `
resource "test_object" "a" {
for_each = {
existent = "1"
}
test_string = "bar"
}
`,
},
expectedError: "Configuration for import target does not exist",
},
{
Description: "Non-existent resource key - in root",
inlineConfiguration: map[string]string{
"main.tf": `
import {
to = test_object.a[42]
id = "123"
}
resource "test_object" "a" {
test_string = "bar"
}
`,
},
expectedError: "Configuration for import target does not exist",
},
{
Description: "Existent module key, non-existent resource key",
inlineConfiguration: map[string]string{
"main.tf": `
import {
to = module.mod["existent"].test_object.b
id = "123"
}
module "mod" {
for_each = {
existent = "1"
existent_two = "2"
}
source = "./mod"
}
`,
"./mod/main.tf": `
resource "test_object" "a" {
test_string = "bar"
}
`,
},
expectedError: "Cannot generate configuration for resource inside sub-module",
},
}
for _, configuration := range configurations {
t.Run(configuration.Description, func(t *testing.T) {
m := testModuleInline(t, configuration.inlineConfiguration)
p := simpleMockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
_, diags := ctx.Plan(m, states.NewState(), &PlanOpts{
Mode: plans.NormalMode,
GenerateConfigPath: "generated.tf",
})
if !diags.HasErrors() {
t.Fatalf("expected error")
}
var errNum int
for _, diag := range diags {
if diag.Severity() == tfdiags.Error {
errNum++
}
}
if errNum > 1 {
t.Fatalf("expected a single error, but got %d", errNum)
}
if !strings.Contains(diags.Err().Error(), configuration.expectedError) {
t.Fatalf("expected error to be %s, but it was %s", configuration.expectedError, diags.Err().Error())
}
})
}
}
func TestContext2Plan_importResourceConfigGenExpandedResource(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
@ -5291,7 +6044,7 @@ import {
if !diags.HasErrors() {
t.Fatalf("expected plan to error, but it did not")
}
if !strings.Contains(diags.Err().Error(), "Config generation for count and for_each resources not supported") {
if !strings.Contains(diags.Err().Error(), "Configuration generation for count and for_each resources not supported") {
t.Fatalf("expected error to be \"Config generation for count and for_each resources not supported\", but it is %s", diags.Err().Error())
}
}

View File

@ -129,6 +129,10 @@ type EvalContext interface {
// indicating if that reference forces replacement.
EvaluateReplaceTriggeredBy(expr hcl.Expression, repData instances.RepetitionData) (*addrs.Reference, bool, tfdiags.Diagnostics)
// 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)
// EvaluationScope returns a scope that can be used to evaluate reference
// addresses in this context.
EvaluationScope(self addrs.Referenceable, source addrs.Referenceable, keyData InstanceKeyEvalData) *lang.Scope
@ -204,8 +208,8 @@ type EvalContext interface {
// objects accessible through it.
MoveResults() refactoring.MoveResults
// ImportResolver returns a map describing the resolved imports
// after evaluating the dynamic address of the import targets
// ImportResolver returns a helper object for tracking the resolution of
// imports, after evaluating the dynamic address and ID of the import targets
//
// This data is created during the graph walk, as import target addresses are being resolved
// Its primary use is for validation at the end of a plan - To make sure all imports have been satisfied

View File

@ -12,14 +12,14 @@ import (
"sync"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/checks"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/encryption"
"github.com/opentofu/opentofu/internal/instances"
"github.com/opentofu/opentofu/internal/lang"
"github.com/opentofu/opentofu/internal/lang/marks"
"github.com/opentofu/opentofu/internal/plans"
"github.com/opentofu/opentofu/internal/providers"
"github.com/opentofu/opentofu/internal/provisioners"
@ -27,6 +27,7 @@ import (
"github.com/opentofu/opentofu/internal/states"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/opentofu/opentofu/version"
"github.com/zclconf/go-cty/cty"
)
// BuiltinEvalContext is an EvalContext implementation that is used by
@ -397,6 +398,108 @@ func (ctx *BuiltinEvalContext) EvaluateReplaceTriggeredBy(expr hcl.Expression, r
return ref, replace, diags
}
// EvaluateImportAddress takes the raw reference expression of the import address
// from the config, and returns the evaluated address addrs.AbsResourceInstance
//
// 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)
if diags.HasErrors() {
return addrs.AbsResourceInstance{}, diags
}
return addrs.ParseAbsResourceInstance(traversal)
}
func (ctx *BuiltinEvalContext) traversalForImportExpr(expr hcl.Expression) (traversal hcl.Traversal, diags tfdiags.Diagnostics) {
switch e := expr.(type) {
case *hclsyntax.IndexExpr:
t, d := ctx.traversalForImportExpr(e.Collection)
diags = diags.Append(d)
traversal = append(traversal, t...)
tIndex, dIndex := ctx.parseImportIndexKeyExpr(e.Key)
diags = diags.Append(dIndex)
traversal = append(traversal, tIndex)
case *hclsyntax.RelativeTraversalExpr:
t, d := ctx.traversalForImportExpr(e.Source)
diags = diags.Append(d)
traversal = append(traversal, t...)
traversal = append(traversal, e.Traversal...)
case *hclsyntax.ScopeTraversalExpr:
traversal = append(traversal, e.Traversal...)
default:
// This should not happen, as it should have failed validation earlier, in config.AbsTraversalForImportToExpr
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid import address expression",
Detail: "Import address must be a reference to a resource's address, and only allows for indexing with dynamic keys. For example: module.my_module[expression1].aws_s3_bucket.my_buckets[expression2] for resources inside of modules, or simply aws_s3_bucket.my_bucket for a resource in the root module",
Subject: expr.Range().Ptr(),
})
}
return
}
// parseImportIndexKeyExpr parses an expression that is used as a key in an index, of an HCL expression representing an
// 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) {
idx := hcl.TraverseIndex{
SrcRange: expr.Range(),
}
val, diags := ctx.EvaluateExpr(expr, cty.DynamicPseudoType, nil)
if diags.HasErrors() {
return idx, diags
}
if !val.IsKnown() {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Import block 'to' address contains an invalid key",
Detail: "Import block contained a resource address using an index that will only be known after apply. Please ensure to use expressions that are known at plan time for the index of an import target address",
Subject: expr.Range().Ptr(),
})
return idx, diags
}
if val.IsNull() {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Import block 'to' address contains an invalid key",
Detail: "Import block contained a resource address using an index which is null. Please ensure the expression for the index is not null",
Subject: expr.Range().Ptr(),
})
return idx, diags
}
if val.Type() != cty.String && val.Type() != cty.Number {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Import block 'to' address contains an invalid key",
Detail: "Import block contained a resource address using an index which is not valid for a resource instance (not a string or a number). Please ensure the expression for the index is correct, and returns either a string or a number",
Subject: expr.Range().Ptr(),
})
return idx, diags
}
unmarkedVal, valMarks := val.Unmark()
if _, sensitive := valMarks[marks.Sensitive]; sensitive {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Import block 'to' address contains an invalid key",
Detail: "Import block contained a resource address using an index which is sensitive. Please ensure indexes used in the resource address of an import target are not sensitive",
Subject: expr.Range().Ptr(),
})
}
idx.Key = unmarkedVal
return idx, diags
}
func (ctx *BuiltinEvalContext) EvaluationScope(self addrs.Referenceable, source addrs.Referenceable, keyData InstanceKeyEvalData) *lang.Scope {
if !ctx.pathSet {
panic("context path not set")

View File

@ -275,6 +275,10 @@ func (c *MockEvalContext) EvaluateReplaceTriggeredBy(hcl.Expression, instances.R
return nil, false, nil
}
func (c *MockEvalContext) EvaluateImportAddress(expression hcl.Expression) (addrs.AbsResourceInstance, tfdiags.Diagnostics) {
return addrs.AbsResourceInstance{}, nil
}
// installSimpleEval is a helper to install a simple mock implementation of
// both EvaluateBlock and EvaluateExpr into the receiver.
//

View File

@ -9,7 +9,6 @@ import (
"fmt"
"github.com/hashicorp/hcl/v2"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/lang/marks"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
@ -28,10 +27,6 @@ func evaluateImportIdExpression(expr hcl.Expression, ctx EvalContext) (string, t
})
}
// The import expression is declared within the root module
// We need to explicitly use that context
ctx = ctx.WithPath(addrs.RootModuleInstance)
importIdVal, evalDiags := ctx.EvaluateExpr(expr, cty.String, nil)
diags = diags.Append(evalDiags)

View File

@ -9,6 +9,8 @@ import (
"fmt"
"log"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/configs"
"github.com/opentofu/opentofu/internal/configs/configschema"
@ -211,6 +213,41 @@ func (n *NodeAbstractResource) References() []*addrs.Reference {
return result
}
// referencesInImportAddress find all references relevant to the node in an import target address expression.
// The only references we care about here are the references that exist in the keys of hclsyntax.IndexExpr.
// For example, if the address is module.my_module1[expression1].aws_s3_bucket.bucket[expression2], then we would only
// consider references in expression1 and expression2, as the rest of the expression is the static part of the current
// resource's address
func referencesInImportAddress(expr hcl.Expression) (refs []*addrs.Reference, diags tfdiags.Diagnostics) {
switch e := expr.(type) {
case *hclsyntax.IndexExpr:
r, d := referencesInImportAddress(e.Collection)
diags = diags.Append(d)
refs = append(refs, r...)
r, _ = lang.ReferencesInExpr(addrs.ParseRef, e.Key)
refs = append(refs, r...)
case *hclsyntax.RelativeTraversalExpr:
r, d := referencesInImportAddress(e.Source)
refs = append(refs, r...)
diags = diags.Append(d)
// We don't care about the traversal part of the relative expression
// as it should not contain any references in the index keys
case *hclsyntax.ScopeTraversalExpr:
// Static traversals should not contain any references in the index keys
default:
// This should not happen, as it should have failed validation earlier, in config.absTraversalForImportToExpr
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid import address expression",
Detail: "Import address must be a reference to a resource's address, and only allows for indexing with dynamic keys. For example: module.my_module[expression1].aws_s3_bucket.my_buckets[expression2] for resources inside of modules, or simply aws_s3_bucket.my_bucket for a resource in the root module",
Subject: expr.Range().Ptr(),
})
}
return
}
func (n *NodeAbstractResource) RootReferences() []*addrs.Reference {
var root []*addrs.Reference
@ -220,7 +257,12 @@ func (n *NodeAbstractResource) RootReferences() []*addrs.Reference {
continue
}
refs, _ := lang.ReferencesInExpr(addrs.ParseRef, importTarget.Config.ID)
refs, _ := referencesInImportAddress(importTarget.Config.To)
// TODO - Add RootReferences of ForEach here later one, once for_each is added
root = append(root, refs...)
refs, _ = lang.ReferencesInExpr(addrs.ParseRef, importTarget.Config.ID)
root = append(root, refs...)
}

View File

@ -150,6 +150,17 @@ func (n *nodeExpandPlannableResource) DynamicExpand(ctx EvalContext) (*Graph, er
}
}
// Resolve addresses and IDs of all import targets that originate from import blocks
// We do it here before expanding the resources in the modules, to avoid running this resolution multiple times
importResolver := ctx.ImportResolver()
var diags tfdiags.Diagnostics
for _, importTarget := range n.importTargets {
if importTarget.IsFromImportBlock() {
err := importResolver.ResolveImport(importTarget, ctx)
diags = diags.Append(err)
}
}
// The above dealt with the expansion of the containing module, so now
// we need to deal with the expansion of the resource itself across all
// instances of the module.
@ -157,7 +168,6 @@ func (n *nodeExpandPlannableResource) DynamicExpand(ctx EvalContext) (*Graph, er
// We'll gather up all of the leaf instances we learn about along the way
// so that we can inform the checks subsystem of which instances it should
// be expecting check results for, below.
var diags tfdiags.Diagnostics
instAddrs := addrs.MakeSet[addrs.Checkable]()
for _, module := range moduleInstances {
resAddr := n.Addr.Resource.Absolute(module)
@ -303,17 +313,10 @@ func (n *nodeExpandPlannableResource) resourceInstanceSubgraph(ctx EvalContext,
var diags tfdiags.Diagnostics
var commandLineImportTargets []CommandLineImportTarget
importResolver := ctx.ImportResolver()
// FIXME - Deal with cases of duplicate addresses
for _, importTarget := range n.importTargets {
if importTarget.IsFromImportCommandLine() {
commandLineImportTargets = append(commandLineImportTargets, *importTarget.CommandLineImportTarget)
} else {
err := importResolver.ResolveImport(importTarget, ctx)
if err != nil {
return nil, err
}
}
}
@ -364,14 +367,9 @@ func (n *nodeExpandPlannableResource) resourceInstanceSubgraph(ctx EvalContext,
forceReplace: n.forceReplace,
}
for _, evaluatedConfigImportTarget := range ctx.ImportResolver().GetAllImports() {
// TODO - Change this code once Config.To is not a static address, to actually evaluate it
if evaluatedConfigImportTarget.Config.To.Equal(a.Addr) {
// If we get here, we're definitely not in legacy import mode,
// so go ahead and plan the resource changes including import.
m.importTarget = evaluatedConfigImportTarget
break
}
resolvedImportTarget := ctx.ImportResolver().GetImport(a.Addr)
if resolvedImportTarget != nil {
m.importTarget = *resolvedImportTarget
}
return m

View File

@ -63,6 +63,10 @@ type EvaluatedConfigImportTarget struct {
// if the import did not originate in config.
Config *configs.Import
// Addr is the actual address of the resource instance that we should import into. At this point, the address
// should be fully evaluated
Addr addrs.AbsResourceInstance
// ID is the string ID of the resource to import. This is resource-instance specific.
ID string
}
@ -173,7 +177,7 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext)
}
}
importing := n.importTarget.ID != ""
importing := n.shouldImport(ctx)
if importing && n.Config == nil && len(n.generateConfigPath) == 0 {
// Then the user wrote an import target to a target that didn't exist.
@ -188,7 +192,7 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext)
// You can't generate config for a resource that is inside a
// module, so we will present a different error message for
// this case.
diags = diags.Append(importResourceWithoutConfigDiags(n.Addr, n.importTarget.Config))
diags = diags.Append(importResourceWithoutConfigDiags(n.Addr.String(), n.importTarget.Config))
}
return diags
}
@ -617,6 +621,17 @@ func (n *NodePlannableResourceInstance) importState(ctx EvalContext, addr addrs.
return instanceRefreshState, diags
}
func (n *NodePlannableResourceInstance) shouldImport(ctx EvalContext) bool {
if n.importTarget.ID == "" {
return false
}
// If the import target already has a state - we should not attempt to import it, but instead run a normal plan
// for it
state := ctx.State()
return state.ResourceInstance(n.ResourceInstanceAddr()) == nil
}
// generateHCLStringAttributes produces a string in HCL format for the given
// resource state and schema without the surrounding block.
func (n *NodePlannableResourceInstance) generateHCLStringAttributes(addr addrs.AbsResourceInstance, state *states.ResourceInstanceObject, schema *configschema.Block) (string, tfdiags.Diagnostics) {

View File

@ -6,11 +6,8 @@
package tofu
import (
"fmt"
"log"
"github.com/hashicorp/hcl/v2"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/configs"
"github.com/opentofu/opentofu/internal/dag"
@ -164,35 +161,18 @@ func (t *ConfigTransformer) transformSingle(g *Graph, config *configs.Config, ge
// If any import targets were not claimed by resources, then let's add them
// into the graph now.
//
// We actually know that if any of the resources aren't claimed and
// generateConfig is false, then we have a problem. But, we can't raise a
// nice error message from this function.
// We should only reach this point if config generation is enabled, since we validate that all import targets have
// a resource in validateImportTargets when config generation is disabled
//
// We'll add the nodes that we know will fail, and catch them again later
// in the processing when we are in a position to raise a much more helpful
// error message.
//
// TODO: We could actually catch and process these kind of problems earlier,
// this is something that could be done during the Validate process.
for _, i := range importTargets {
// We should only allow config generation for static addresses
// If config generation has been attempted for a non static address - we will fail here
address, evaluationDiags := i.ResolvedAddr()
if evaluationDiags.HasErrors() {
return evaluationDiags
}
// In case of config generation - We can error early here in two cases:
// 1. When attempting to import a resource with a key (Config generation for count / for_each resources)
// 2. When attempting to import a resource inside a module.
if len(generateConfigPath) > 0 {
if address.Resource.Key != addrs.NoKey {
return fmt.Errorf("Config generation for count and for_each resources not supported.\n\nYour configuration contains an import block with a \"to\" address of %s. This resource instance does not exist in configuration.\n\nIf you intended to target a resource that exists in configuration, please double-check the address. Otherwise, please remove this import block or re-run the plan without the -generate-config-out flag to ignore the import block.", address)
}
// Create a node with the resource and import target. This node will take care of the config generation
abstract := &NodeAbstractResource{
Addr: address.ConfigResource(),
// We've already validated in validateImportTargets that the address is fully resolvable
Addr: i.ResolvedAddr().ConfigResource(),
importTargets: []*ImportTarget{i},
generateConfigPath: generateConfigPath,
}
@ -204,24 +184,11 @@ func (t *ConfigTransformer) transformSingle(g *Graph, config *configs.Config, ge
g.Add(node)
} else {
return importResourceWithoutConfigDiags(address, i.Config)
// Technically we shouldn't reach this point, as we've already validated that a resource exists
// in validateImportTargets
return importResourceWithoutConfigDiags(i.StaticAddr().String(), i.Config)
}
}
return nil
}
// importResourceWithoutConfigDiags creates the common HCL error of an attempted import for a non-existent configuration
func importResourceWithoutConfigDiags(address addrs.AbsResourceInstance, config *configs.Import) *hcl.Diagnostic {
diag := hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Configuration for import target does not exist",
Detail: fmt.Sprintf("The configuration for the given import %s does not exist. All target instances must have an associated configuration to be imported.", address),
}
if config != nil {
diag.Subject = config.DeclRange.Ptr()
}
return &diag
}