diff --git a/.changes/v1.16/NEW FEATURES-20260410-085729.yaml b/.changes/v1.16/NEW FEATURES-20260410-085729.yaml new file mode 100644 index 000000000000..50e57bfabaed --- /dev/null +++ b/.changes/v1.16/NEW FEATURES-20260410-085729.yaml @@ -0,0 +1,5 @@ +kind: NEW FEATURES +body: feat(import): add support for import blocks inside modules +time: 2026-04-10T08:57:29.804462-04:00 +custom: + Issue: "38352" diff --git a/internal/configs/config_build.go b/internal/configs/config_build.go index 84abf6df3b82..38bb739f9882 100644 --- a/internal/configs/config_build.go +++ b/internal/configs/config_build.go @@ -362,15 +362,6 @@ func loadModule(root *Config, req *ModuleRequest, walker ModuleWalker) (*Config, }) } - if len(mod.Import) > 0 { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid import configuration", - Detail: fmt.Sprintf("An import block was detected in %q. Import blocks are only allowed in the root module.", cfg.Path), - Subject: mod.Import[0].DeclRange.Ptr(), - }) - } - if len(mod.ListResources) > 0 { first := slices.Collect(maps.Values(mod.ListResources))[0] diags = diags.Append(&hcl.Diagnostic{ diff --git a/internal/configs/config_build_test.go b/internal/configs/config_build_test.go index b9d1977ab580..4ac578685874 100644 --- a/internal/configs/config_build_test.go +++ b/internal/configs/config_build_test.go @@ -5,7 +5,7 @@ package configs import ( "fmt" - "io/ioutil" + "os" "path" "path/filepath" "reflect" @@ -214,7 +214,7 @@ func TestBuildConfigChildModule_CloudBlock(t *testing.T) { func TestBuildConfigInvalidModules(t *testing.T) { testDir := "testdata/config-diagnostics" - dirs, err := ioutil.ReadDir(testDir) + dirs, err := os.ReadDir(testDir) if err != nil { t.Fatal(err) } @@ -261,8 +261,8 @@ func TestBuildConfigInvalidModules(t *testing.T) { // expected location in the source, but is not required. // The literal characters `\n` are replaced with newlines, but // otherwise the string is unchanged. - expectedErrs := readDiags(ioutil.ReadFile(filepath.Join(testDir, name, "errors"))) - expectedWarnings := readDiags(ioutil.ReadFile(filepath.Join(testDir, name, "warnings"))) + expectedErrs := readDiags(os.ReadFile(filepath.Join(testDir, name, "errors"))) + expectedWarnings := readDiags(os.ReadFile(filepath.Join(testDir, name, "warnings"))) _, buildDiags := BuildConfig(mod, ModuleWalkerFunc( func(req *ModuleRequest) (*Module, *version.Version, hcl.Diagnostics) { diff --git a/internal/configs/config_test.go b/internal/configs/config_test.go index 3eeacc9d73cf..6a60d00640e2 100644 --- a/internal/configs/config_test.go +++ b/internal/configs/config_test.go @@ -18,7 +18,6 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/getproviders/providerreqs" - _ "github.com/hashicorp/terraform/internal/logging" ) @@ -29,7 +28,7 @@ func TestConfigProviderTypes(t *testing.T) { t.Fatal("expected empty result from empty config") } - cfg, diags := testModuleFromFileWithExperiments("testdata/valid-files/providers-explicit-implied.tf") + cfg, diags := testModuleCfgFromFileWithExperiments("testdata/valid-files/providers-explicit-implied.tf") if diags.HasErrors() { t.Fatal(diags.Error()) } diff --git a/internal/configs/module.go b/internal/configs/module.go index 5437360713c9..c18e2014cddc 100644 --- a/internal/configs/module.go +++ b/internal/configs/module.go @@ -10,7 +10,6 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/experiments" - tfversion "github.com/hashicorp/terraform/version" ) diff --git a/internal/configs/module_test.go b/internal/configs/module_test.go index 89d42556fc2c..97653c533106 100644 --- a/internal/configs/module_test.go +++ b/internal/configs/module_test.go @@ -8,8 +8,9 @@ import ( "testing" "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/terraform/internal/addrs" "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/addrs" ) // TestNewModule_provider_fqns exercises module.gatherProviderLocalNames() @@ -681,3 +682,14 @@ func TestModule_state_store_multiple(t *testing.T) { } }) } + +func TestModule_nested_import_blocks(t *testing.T) { + m, diags := testNestedModuleConfigFromDir(t, "testdata/valid-modules/import-blocks-in-module") + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + if len(m.Children["child"].Module.Import) != 2 { + t.Fatal("child module is missing nested import blocks") + } +} diff --git a/internal/configs/parser_config_dir.go b/internal/configs/parser_config_dir.go index 8f4fc607d533..a0f7ae2349f7 100644 --- a/internal/configs/parser_config_dir.go +++ b/internal/configs/parser_config_dir.go @@ -71,7 +71,7 @@ func (p *Parser) LoadConfigDir(path string, opts ...Option) (*Module, hcl.Diagno } // Check if we need to load query files if len(fileSet.Queries) > 0 { - queryFiles, fDiags := p.loadQueryFiles(path, fileSet.Queries) + queryFiles, fDiags := p.loadQueryFiles(fileSet.Queries) diags = append(diags, fDiags...) if mod != nil { for _, qf := range queryFiles { @@ -151,7 +151,7 @@ func (p Parser) ConfigDirFiles(dir string, opts ...Option) (primary, override [] // IsConfigDir determines whether the given path refers to a directory that // exists and contains at least one Terraform config file (with a .tf or -// .tf.json extension.). Note, we explicitely exclude checking for tests here +// .tf.json extension.). Note, we explicitly exclude checking for tests here // as tests must live alongside actual .tf config files. Same goes for query files. func (p *Parser) IsConfigDir(path string, opts ...Option) bool { pathSet, _ := p.dirFileSet(path, opts...) @@ -205,7 +205,7 @@ func (p *Parser) loadTestFiles(basePath string, paths []string) (map[string]*Tes return tfs, diags } -func (p *Parser) loadQueryFiles(basePath string, paths []string) ([]*QueryFile, hcl.Diagnostics) { +func (p *Parser) loadQueryFiles(paths []string) ([]*QueryFile, hcl.Diagnostics) { files := make([]*QueryFile, 0, len(paths)) var diags hcl.Diagnostics diff --git a/internal/configs/parser_test.go b/internal/configs/parser_test.go index ec640e34c1d9..d76753aa6273 100644 --- a/internal/configs/parser_test.go +++ b/internal/configs/parser_test.go @@ -52,9 +52,9 @@ func testModuleConfigFromFile(filename string) (*Config, hcl.Diagnostics) { return cfg, append(diags, moreDiags...) } -// testModuleFromFileWithExperiments File reads a single file from the given path as a +// testModuleCfgFromFileWithExperiments File reads a single file from the given path as a // module and returns its configuration. This is a helper for use in unit tests. -func testModuleFromFileWithExperiments(filename string) (*Config, hcl.Diagnostics) { +func testModuleCfgFromFileWithExperiments(filename string) (*Config, hcl.Diagnostics) { parser := NewParser(nil) parser.AllowLanguageExperiments(true) f, diags := parser.LoadConfigFile(filename) diff --git a/internal/configs/testdata/config-diagnostics/import-in-child-module/child/main.tf b/internal/configs/testdata/config-diagnostics/import-in-child-module/child/main.tf deleted file mode 100644 index bb8cb139d1db..000000000000 --- a/internal/configs/testdata/config-diagnostics/import-in-child-module/child/main.tf +++ /dev/null @@ -1,6 +0,0 @@ -resource "aws_instance" "web" {} - -import { - to = aws_instance.web - id = "test" -} \ No newline at end of file diff --git a/internal/configs/testdata/config-diagnostics/import-in-child-module/errors b/internal/configs/testdata/config-diagnostics/import-in-child-module/errors deleted file mode 100644 index b0a5ac4fc1c7..000000000000 --- a/internal/configs/testdata/config-diagnostics/import-in-child-module/errors +++ /dev/null @@ -1 +0,0 @@ -import-in-child-module/child/main.tf:3,1-7: Invalid import configuration; An import block was detected in "module.child". Import blocks are only allowed in the root module. \ No newline at end of file diff --git a/internal/configs/testdata/config-diagnostics/import-in-child-module/root.tf b/internal/configs/testdata/config-diagnostics/import-in-child-module/root.tf deleted file mode 100644 index 3133e57b9303..000000000000 --- a/internal/configs/testdata/config-diagnostics/import-in-child-module/root.tf +++ /dev/null @@ -1,10 +0,0 @@ -resource "aws_instance" "web" {} - -import { - to = aws_instance.web - id = "test" -} - -module "child" { - source = "./child" -} \ No newline at end of file diff --git a/internal/configs/testdata/valid-modules/import-blocks-in-module/child/main.tf b/internal/configs/testdata/valid-modules/import-blocks-in-module/child/main.tf new file mode 100644 index 000000000000..f09339ca46be --- /dev/null +++ b/internal/configs/testdata/valid-modules/import-blocks-in-module/child/main.tf @@ -0,0 +1,15 @@ +provider "random" { + alias = "thisone" +} + +import { + to = random_string.test1 + provider = localname + id = "importlocalname" +} + +import { + to = random_string.test2 + provider = random.thisone + id = "importaliased" +} \ No newline at end of file diff --git a/internal/configs/testdata/valid-modules/import-blocks-in-module/main.tf b/internal/configs/testdata/valid-modules/import-blocks-in-module/main.tf new file mode 100644 index 000000000000..0f6991c536ca --- /dev/null +++ b/internal/configs/testdata/valid-modules/import-blocks-in-module/main.tf @@ -0,0 +1,3 @@ +module "child" { + source = "./child" +} diff --git a/internal/refactoring/import_statement.go b/internal/refactoring/import_statement.go new file mode 100644 index 000000000000..ac2cafd3163f --- /dev/null +++ b/internal/refactoring/import_statement.go @@ -0,0 +1,63 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package refactoring + +import ( + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" +) + +type ImportStatement struct { + // AbsToResource is the original ImportConfig ToResource+ContainingModule + AbsToResource addrs.ConfigResource + ContainingModule addrs.Module + Import *configs.Import +} + +// FindImportStatements recurses through the modules of the given configuration +// and returns a set of all "import" blocks defined within after deduplication +// on the To address. +// +// An "import" block in a parent module overrides an import block in a child +// module when both target the same configuration object. +func FindImportStatements(rootCfg *configs.Config) addrs.Map[addrs.ConfigResource, ImportStatement] { + imports := findImportStatements(rootCfg, addrs.MakeMap[addrs.ConfigResource, ImportStatement]()) + return imports +} + +func findImportStatements(cfg *configs.Config, into addrs.Map[addrs.ConfigResource, ImportStatement]) addrs.Map[addrs.ConfigResource, ImportStatement] { + for _, mi := range cfg.Module.Import { + // First, stitch together the module path and the RelSubject to form + // the absolute address of the config resource being removed. + res := mi.ToResource + toAddr := addrs.ConfigResource{ + Module: append(cfg.Path, res.Module...), + Resource: res.Resource, + } + + // If we already have an import statement for this ConfigResource, it + // must have come from a parent module, because duplicate import + // blocks in the same module result in an error. + // The import block in the parent module overrides the block in the + // child module. + existingResource, ok := into.GetOk(toAddr) + if ok { + if existingResource.AbsToResource.Equal(toAddr) { + continue + } + } + + into.Put(toAddr, ImportStatement{ + AbsToResource: toAddr, + ContainingModule: cfg.Path, + Import: mi, + }) + } + + for _, childCfg := range cfg.Children { + into = findImportStatements(childCfg, into) + } + + return into +} diff --git a/internal/terraform/context_apply_import_test.go b/internal/terraform/context_apply_import_test.go new file mode 100644 index 000000000000..cd031412469c --- /dev/null +++ b/internal/terraform/context_apply_import_test.go @@ -0,0 +1,202 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "encoding/json" + "testing" + + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// other import tests can be found in context_apply2_test.go +func TestContextApply_import_in_module(t *testing.T) { + m := testModule(t, "import-block-in-module") + + p := mockProviderWithResourceTypeSchema("test_object", &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + "test_string": {Type: cty.String, Optional: true}, + }, + }) + p.ImportResourceStateFn = func(req providers.ImportResourceStateRequest) providers.ImportResourceStateResponse { + return providers.ImportResourceStateResponse{ + ImportedResources: []providers.ImportedResource{ + { + TypeName: "test_object", + State: cty.ObjectVal(map[string]cty.Value{ + "test_string": cty.StringVal("importable"), + "id": cty.StringVal(req.ID), + }), + }, + }, + } + } + p.ReadResourceFn = func(r providers.ReadResourceRequest) providers.ReadResourceResponse { + id := r.PriorState.GetAttr("id") + return providers.ReadResourceResponse{ + NewState: cty.ObjectVal(map[string]cty.Value{ + "test_string": cty.StringVal("importable"), + "id": id, + }), + } + } + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + diags := ctx.Validate(m, nil) + tfdiags.AssertNoErrors(t, diags) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + }) + tfdiags.AssertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m, nil) + tfdiags.AssertNoErrors(t, diags) + + if !p.ImportResourceStateCalled { + t.Fatal("resource not imported") + } + + rs := state.ResourceInstance(mustResourceInstanceAddr("module.child.test_object.bar[\"first\"]")) + if rs == nil { + t.Fatal("imported resource not found in module") + } + var attrs map[string]interface{} + err := json.Unmarshal(rs.Current.AttrsJSON, &attrs) + if err != nil { + t.Fatal(err) + } + if got, want := attrs["id"], "testa"; got != want { + t.Fatalf("wrong id for \"first\" got: %#v\nwant: %#v", got, want) + } + + rs = state.ResourceInstance(mustResourceInstanceAddr("module.child.test_object.bar[\"second\"]")) + if rs == nil { + t.Fatal("imported resource not found in module") + } + err = json.Unmarshal(rs.Current.AttrsJSON, &attrs) + if err != nil { + t.Fatal(err) + } + if got, want := attrs["id"], "testb"; got != want { + t.Fatalf("wrong id for \"second\" got: %#v\nwant: %#v", got, want) + } +} + +func TestContextApply_import_in_nested_module(t *testing.T) { // more nested than the test above. nesteder. + m := testModule(t, "import-block-in-nested-module") + + p := simpleMockProvider() + p.ImportResourceStateResponse = &providers.ImportResourceStateResponse{ + ImportedResources: []providers.ImportedResource{ + { + TypeName: "test_object", + State: cty.ObjectVal(map[string]cty.Value{ + "test_string": cty.StringVal("importable"), + }), + }, + }, + } + p.ReadResourceResponse = &providers.ReadResourceResponse{ + NewState: cty.ObjectVal(map[string]cty.Value{ + "test_string": cty.StringVal("importable"), + }), + } + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + diags := ctx.Validate(m, nil) + tfdiags.AssertNoErrors(t, diags) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + }) + tfdiags.AssertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m, nil) + tfdiags.AssertNoErrors(t, diags) + + rs := state.ResourceInstance(mustResourceInstanceAddr("module.child.module.kinder.test_object.bar")) + if rs == nil { + t.Fatal("imported resource not found in module") + } + + if !p.ImportResourceStateCalled { + t.Fatal("resources not imported") + } +} + +func TestContextApply_import_in_expanded_module(t *testing.T) { // count AND for each! + m := testModule(t, "import-block-in-module-with-expansion") + + p := simpleMockProvider() + p.ImportResourceStateResponse = &providers.ImportResourceStateResponse{ + ImportedResources: []providers.ImportedResource{ + { + TypeName: "test_object", + State: cty.ObjectVal(map[string]cty.Value{ + "test_string": cty.StringVal("importable"), + }), + }, + }, + } + p.ReadResourceResponse = &providers.ReadResourceResponse{ + NewState: cty.ObjectVal(map[string]cty.Value{ + "test_string": cty.StringVal("importable"), + }), + } + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + diags := ctx.Validate(m, nil) + tfdiags.AssertNoErrors(t, diags) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + }) + tfdiags.AssertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m, nil) + tfdiags.AssertNoErrors(t, diags) + + rs := state.ResourceInstance(mustResourceInstanceAddr("module.count_child[0].test_object.foo")) + if rs == nil { + t.Fatal("imported resource not found in module") + } + + rs = state.ResourceInstance(mustResourceInstanceAddr("module.count_child[1].test_object.foo")) + if rs == nil { + t.Fatal("imported resource not found in module") + } + + rs = state.ResourceInstance(mustResourceInstanceAddr("module.for_each_child[\"a\"].test_object.foo")) + if rs == nil { + t.Fatal("imported resource not found in module") + } + + if !p.ImportResourceStateCalled { + t.Fatal("resources not imported") + } +} diff --git a/internal/terraform/context_import.go b/internal/terraform/context_import.go index 79fa7c465779..ea2dfa559b66 100644 --- a/internal/terraform/context_import.go +++ b/internal/terraform/context_import.go @@ -22,13 +22,18 @@ type ImportOpts struct { SetVariables InputValues } -// ImportTarget is a single resource to import, -// in legacy (CLI) import mode. +// ImportTarget is a single resource to import. type ImportTarget struct { // Config is the original import block for this import. This might be null // if the import did not originate in config. Config *configs.Import + // The RelModule contains the module that the original import block was + // configured in, while the Config.ToResource is relative to the module it was in. + // We re-evaluate the Config.To (hcl.Expression) during plan, so this needs to be stored. + RelModule addrs.Module + AbsToConfigResource addrs.ConfigResource + // LegacyAddr is the import address set from the command line arguments // when using the import command. LegacyAddr addrs.AbsResourceInstance diff --git a/internal/terraform/context_plan.go b/internal/terraform/context_plan.go index 2c99c9f600e2..3cdff8277619 100644 --- a/internal/terraform/context_plan.go +++ b/internal/terraform/context_plan.go @@ -713,9 +713,12 @@ func (c *Context) postPlanValidateMoves(config *configs.Config, stmts []refactor // config. func (c *Context) findImportTargets(config *configs.Config) []*ImportTarget { var importTargets []*ImportTarget - for _, ic := range config.Module.Import { + importStatements := refactoring.FindImportStatements(config) + for _, ic := range importStatements.Values() { importTargets = append(importTargets, &ImportTarget{ - Config: ic, + Config: ic.Import, + RelModule: ic.ContainingModule, + AbsToConfigResource: ic.AbsToResource, }) } return importTargets diff --git a/internal/terraform/context_plan_import_test.go b/internal/terraform/context_plan_import_test.go index 309cead760cf..40662f016665 100644 --- a/internal/terraform/context_plan_import_test.go +++ b/internal/terraform/context_plan_import_test.go @@ -2464,3 +2464,52 @@ import { } }) } + +func TestContext2Plan_importDeferredResource(t *testing.T) { + addr := mustResourceInstanceAddr("test_object.a") + m := testModuleInline(t, map[string]string{ + "main.tf": ` +import { + to = test_object.a + id = "123" +} + +resource "test_object" "a" {} +`, + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + p.ImportResourceStateResponse = &providers.ImportResourceStateResponse{ + Deferred: &providers.Deferred{ + Reason: providers.DeferredReasonProviderConfigUnknown, // a made up problem for the test + }, + } + + diags := ctx.Validate(m, &ValidateOpts{}) + tfdiags.AssertNoDiagnostics(t, diags) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + DeferralAllowed: true, + GenerateConfigPath: "generated.tf", // Actual value here doesn't matter, as long as it is not empty. + }) + tfdiags.AssertNoDiagnostics(t, diags) + + instPlan := plan.Changes.ResourceInstance(addr) + if instPlan != nil { + t.Fatal("unexpected changes for the resource that should have been deferred") + } + + if len(plan.DeferredResources) != 1 { + t.Fatalf("wrong number of deferred resources, wanted 1, got %d\n", len(plan.DeferredResources)) + } + + if plan.DeferredResources[0].ChangeSrc.Addr.String() != addr.String() { + t.Fatal("Wrong, but impressive - how did you even defer the wrong resource?") + } +} diff --git a/internal/terraform/context_validate_test.go b/internal/terraform/context_validate_test.go index 9a554252d720..d5da74157ca9 100644 --- a/internal/terraform/context_validate_test.go +++ b/internal/terraform/context_validate_test.go @@ -12,9 +12,8 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/zclconf/go-cty/cty" - "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" @@ -5408,3 +5407,8 @@ output "test_output" { }, })) } + +func TestContextValidate_importNestedModule_ValidateInputVar(t *testing.T) { + // nested module + input variable + +} diff --git a/internal/terraform/node_resource_abstract_instance.go b/internal/terraform/node_resource_abstract_instance.go index 2ff8e5b6c1dd..b6588ef0a610 100644 --- a/internal/terraform/node_resource_abstract_instance.go +++ b/internal/terraform/node_resource_abstract_instance.go @@ -819,7 +819,7 @@ func (n *NodeAbstractResourceInstance) plan( } // If we're importing and generating config, generate it now. - if n.Config == nil { + if n.Config == nil && n.generateConfigPath == "" { // This shouldn't happen. A node that isn't generating config should // have embedded config, and the rest of Terraform should enforce this. // If, however, we didn't do things correctly the next line will panic, diff --git a/internal/terraform/node_resource_plan.go b/internal/terraform/node_resource_plan.go index 9815cb0cb1ea..609ddb91c161 100644 --- a/internal/terraform/node_resource_plan.go +++ b/internal/terraform/node_resource_plan.go @@ -173,118 +173,131 @@ func (n *nodeExpandPlannableResource) expandResourceImports(ctx EvalContext, all continue } - if imp.Config.ForEach == nil { - traversal, hds := hcl.AbsTraversalForExpr(imp.Config.To) - diags = diags.Append(hds) - to, tds := addrs.ParseAbsResourceInstance(traversal) - diags = diags.Append(tds) - if diags.HasErrors() { - return knownImports, unknownImports, diags - } + // "to" here needs the containing resource + // but I don't know how to work that out at this point (with expansion) + // do I need to get all possible expansions for the imp.RelModule and then only use the one for this actual node? + allMods := ctx.InstanceExpander().AllInstances().InstancesForModule(imp.RelModule, false) + for _, mod := range allMods { + // get the context from the module the import was defined in + ctx = evalContextForModuleInstance(ctx, mod) + state = ctx.State() + if imp.Config.ForEach == nil { + traversal, hds := hcl.AbsTraversalForExpr(imp.Config.To) + diags = diags.Append(hds) + to, tds := addrs.ParseAbsResourceInstance(traversal) + diags = diags.Append(tds) + if diags.HasErrors() { + return knownImports, unknownImports, diags + } + // add the module that the import block was configured in to the resource addr + to.Module = append(mod, to.Module...) + + diags = diags.Append(validateImportTargetExpansion(n.Config, to, imp.Config.To)) + + var importID cty.Value + var evalDiags tfdiags.Diagnostics + if imp.Config.ID != nil { + importID, evalDiags = evaluateImportIdExpression(imp.Config.ID, ctx, EvalDataForNoInstanceKey, allowUnknown) + } else if imp.Config.Identity != nil { + providerSchema, err := ctx.ProviderSchema(n.ResolvedProvider) + if err != nil { + diags = diags.Append(err) + return knownImports, unknownImports, diags + } + schema := providerSchema.SchemaForResourceAddr(to.Resource.Resource) - diags = diags.Append(validateImportTargetExpansion(n.Config, to, imp.Config.To)) + importID, evalDiags = evaluateImportIdentityExpression(imp.Config.Identity, schema.Identity, ctx, EvalDataForNoInstanceKey, allowUnknown) + } else { + // Should never happen + return knownImports, unknownImports, diags + } - var importID cty.Value - var evalDiags tfdiags.Diagnostics - if imp.Config.ID != nil { - importID, evalDiags = evaluateImportIdExpression(imp.Config.ID, ctx, EvalDataForNoInstanceKey, allowUnknown) - } else if imp.Config.Identity != nil { - providerSchema, err := ctx.ProviderSchema(n.ResolvedProvider) - if err != nil { - diags = diags.Append(err) + diags = diags.Append(evalDiags) + if diags.HasErrors() { return knownImports, unknownImports, diags } - schema := providerSchema.SchemaForResourceAddr(to.Resource.Resource) - importID, evalDiags = evaluateImportIdentityExpression(imp.Config.Identity, schema.Identity, ctx, EvalDataForNoInstanceKey, allowUnknown) - } else { - // Should never happen - return knownImports, unknownImports, diags - } + knownImports.Put(to, importID) - diags = diags.Append(evalDiags) - if diags.HasErrors() { - return knownImports, unknownImports, diags + log.Printf("[TRACE] expandResourceImports: found single import target %s", to) + continue } - knownImports.Put(to, importID) - - log.Printf("[TRACE] expandResourceImports: found single import target %s", to) - continue - } - - forEachData, known, forEachDiags := newForEachEvaluator(imp.Config.ForEach, ctx, allowUnknown).ImportValues() - diags = diags.Append(forEachDiags) - if forEachDiags.HasErrors() { - return knownImports, unknownImports, diags - } - - if !known { - // Then we need to parse the target address as a PartialResource - // instead of a known resource. - addr, evalDiags := evalImportUnknownToExpression(imp.Config.To) - diags = diags.Append(evalDiags) - if diags.HasErrors() { + forEachData, known, forEachDiags := newForEachEvaluator(imp.Config.ForEach, ctx, allowUnknown).ImportValues() + diags = diags.Append(forEachDiags) + if forEachDiags.HasErrors() { return knownImports, unknownImports, diags } - // We're going to work out which instances this import block might - // target actually already exist. - knownInstances := addrs.MakeSet[addrs.AbsResourceInstance]() - - cfg := addr.ConfigResource() - modInsts := state.ModuleInstances(cfg.Module) - for _, modInst := range modInsts { - abs := cfg.Absolute(modInst) - resource := state.Resource(cfg.Absolute(modInst)) - if resource == nil { - // Then we are creating every instance of this resource. - continue + if !known { + // Then we need to parse the target address as a PartialResource + // instead of a known resource. + addr, evalDiags := evalImportUnknownToExpression(imp.Config.To) + diags = diags.Append(evalDiags) + if diags.HasErrors() { + return knownImports, unknownImports, diags } - for inst := range resource.Instances { - knownInstances.Add(abs.Instance(inst)) - } - } + // We're going to work out which instances this import block might + // target actually already exist. + knownInstances := addrs.MakeSet[addrs.AbsResourceInstance]() + + cfg := addr.ConfigResource() + modInsts := state.ModuleInstances(append(imp.RelModule, cfg.Module...)) + for _, modInst := range modInsts { + abs := cfg.Absolute(modInst) + resource := state.Resource(cfg.Absolute(modInst)) + if resource == nil { + // Then we are creating every instance of this resource. + continue + } - unknownImports.Put(addr, knownInstances) - continue - } + for inst := range resource.Instances { + knownInstances.Add(abs.Instance(inst)) + } + } - for _, keyData := range forEachData { - var evalDiags tfdiags.Diagnostics - res, evalDiags := evalImportToExpression(imp.Config.To, keyData) - diags = diags.Append(evalDiags) - if diags.HasErrors() { - return knownImports, unknownImports, diags + unknownImports.Put(addr, knownInstances) + continue } - diags = diags.Append(validateImportTargetExpansion(n.Config, res, imp.Config.To)) + for _, keyData := range forEachData { + var evalDiags tfdiags.Diagnostics + res, evalDiags := evalImportToExpression(imp.Config.To, keyData) + diags = diags.Append(evalDiags) + if diags.HasErrors() { + return knownImports, unknownImports, diags + } + // add the module that the import block was configured in to the resource addr + res.Module = append(mod, res.Module...) + + diags = diags.Append(validateImportTargetExpansion(n.Config, res, imp.Config.To)) + + var importID cty.Value + if imp.Config.ID != nil { + importID, evalDiags = evaluateImportIdExpression(imp.Config.ID, ctx, keyData, allowUnknown) + } else if imp.Config.Identity != nil { + providerSchema, err := ctx.ProviderSchema(n.ResolvedProvider) + if err != nil { + diags = diags.Append(err) + return knownImports, unknownImports, diags + } + schema := providerSchema.SchemaForResourceAddr(res.Resource.Resource) - var importID cty.Value - if imp.Config.ID != nil { - importID, evalDiags = evaluateImportIdExpression(imp.Config.ID, ctx, keyData, allowUnknown) - } else if imp.Config.Identity != nil { - providerSchema, err := ctx.ProviderSchema(n.ResolvedProvider) - if err != nil { - diags = diags.Append(err) + importID, evalDiags = evaluateImportIdentityExpression(imp.Config.Identity, schema.Identity, ctx, keyData, allowUnknown) + } else { + // Should never happen return knownImports, unknownImports, diags } - schema := providerSchema.SchemaForResourceAddr(res.Resource.Resource) - importID, evalDiags = evaluateImportIdentityExpression(imp.Config.Identity, schema.Identity, ctx, keyData, allowUnknown) - } else { - // Should never happen - return knownImports, unknownImports, diags - } + diags = diags.Append(evalDiags) + if diags.HasErrors() { + return knownImports, unknownImports, diags + } - diags = diags.Append(evalDiags) - if diags.HasErrors() { - return knownImports, unknownImports, diags + knownImports.Put(res, importID) + log.Printf("[TRACE] expandResourceImports: expanded import target %s", res) } - - knownImports.Put(res, importID) - log.Printf("[TRACE] expandResourceImports: expanded import target %s", res) } } diff --git a/internal/terraform/node_resource_validate.go b/internal/terraform/node_resource_validate.go index 25d08d127df5..9a67341a2a75 100644 --- a/internal/terraform/node_resource_validate.go +++ b/internal/terraform/node_resource_validate.go @@ -655,8 +655,6 @@ func (n *NodeValidatableResource) validateImportTargets(ctx EvalContext) tfdiags diags = diags.Append(n.validateConfigGen(ctx)) - // Import blocks are only valid within the root module, and must be - // evaluated within that context ctx = evalContextForModuleInstance(ctx, addrs.RootModuleInstance) for _, imp := range n.importTargets { @@ -666,11 +664,16 @@ func (n *NodeValidatableResource) validateImportTargets(ctx EvalContext) tfdiags continue } + if !imp.RelModule.Equal(addrs.RootModule) { + // if the import was in a nested module, we can't get the correct + // module instance context, so validation is skipped + continue + } + diags = diags.Append(validateImportSelfRef(n.Addr.Resource, imp.Config.ID)) if diags.HasErrors() { return diags } - if imp.Config.ForEach != nil { diags = diags.Append(validateImportForEachRef(n.Addr.Resource, imp.Config.ForEach)) if diags.HasErrors() { diff --git a/internal/terraform/testdata/import-block-in-module-with-expansion/child/main.tf b/internal/terraform/testdata/import-block-in-module-with-expansion/child/main.tf new file mode 100644 index 000000000000..359c4111475e --- /dev/null +++ b/internal/terraform/testdata/import-block-in-module-with-expansion/child/main.tf @@ -0,0 +1,6 @@ +resource "test_object" "foo" {} + +import { + to = test_object.foo + id = "import" +} \ No newline at end of file diff --git a/internal/terraform/testdata/import-block-in-module-with-expansion/main.tf b/internal/terraform/testdata/import-block-in-module-with-expansion/main.tf new file mode 100644 index 000000000000..9faa8ede4ff6 --- /dev/null +++ b/internal/terraform/testdata/import-block-in-module-with-expansion/main.tf @@ -0,0 +1,20 @@ +locals { + val = 2 + m = { + "a" = "b" + } +} + +module "count_child" { + count = local.val + source = "./child" +} + +module "for_each_child" { + for_each = test_object.foo + source = "./child" +} + +resource "test_object" "foo" { + for_each = local.m +} \ No newline at end of file diff --git a/internal/terraform/testdata/import-block-in-module/child/main.tf b/internal/terraform/testdata/import-block-in-module/child/main.tf new file mode 100644 index 000000000000..0d0d78e7ae0a --- /dev/null +++ b/internal/terraform/testdata/import-block-in-module/child/main.tf @@ -0,0 +1,16 @@ +locals { + ids = { + first = "testa" + second = "testb" + } +} + +resource test_object bar { + for_each = local.ids +} + +import { + for_each = local.ids + to = test_object.bar[each.key] + id = each.value +} \ No newline at end of file diff --git a/internal/terraform/testdata/import-block-in-module/main.tf b/internal/terraform/testdata/import-block-in-module/main.tf new file mode 100644 index 000000000000..75ea1f31e990 --- /dev/null +++ b/internal/terraform/testdata/import-block-in-module/main.tf @@ -0,0 +1,3 @@ +module "child" { + source = "./child" +} \ No newline at end of file diff --git a/internal/terraform/testdata/import-block-in-nested-module/child/kinder/main.tf b/internal/terraform/testdata/import-block-in-nested-module/child/kinder/main.tf new file mode 100644 index 000000000000..90d51789fde6 --- /dev/null +++ b/internal/terraform/testdata/import-block-in-nested-module/child/kinder/main.tf @@ -0,0 +1,6 @@ +import { + to = test_object.bar + id = "importable" +} + +resource "test_object" "bar" {} \ No newline at end of file diff --git a/internal/terraform/testdata/import-block-in-nested-module/child/main.tf b/internal/terraform/testdata/import-block-in-nested-module/child/main.tf new file mode 100644 index 000000000000..e1cc305c5699 --- /dev/null +++ b/internal/terraform/testdata/import-block-in-nested-module/child/main.tf @@ -0,0 +1,3 @@ +module "kinder" { + source = "./kinder" +} \ No newline at end of file diff --git a/internal/terraform/testdata/import-block-in-nested-module/main.tf b/internal/terraform/testdata/import-block-in-nested-module/main.tf new file mode 100644 index 000000000000..75ea1f31e990 --- /dev/null +++ b/internal/terraform/testdata/import-block-in-nested-module/main.tf @@ -0,0 +1,3 @@ +module "child" { + source = "./child" +} \ No newline at end of file diff --git a/internal/terraform/transform_config.go b/internal/terraform/transform_config.go index 454cbf60e8fb..4cf101c1ea12 100644 --- a/internal/terraform/transform_config.go +++ b/internal/terraform/transform_config.go @@ -123,7 +123,9 @@ func (t *ConfigTransformer) transformSingle(g *Graph, config *configs.Config) er importTargets = append(importTargets, target) } default: - if target.Config.ToResource.Module.Equal(config.Path) { + // target.AbsToAddr is the absolute config resource, target.Config.ToResource is + // relative to the module of the import block + if target.AbsToConfigResource.Module.Equal(config.Path) { importTargets = append(importTargets, target) } } @@ -216,7 +218,7 @@ func (t *ConfigTransformer) transformSingle(g *Graph, config *configs.Config) er imports = append(imports, i) } - if i.Config != nil && i.Config.ToResource.Equal(configAddr) { + if i.Config != nil && i.AbsToConfigResource.Equal(configAddr) { // This import target has been claimed by an actual resource, // let's make a note of this to remove it from the targets. matchedIndices = append(matchedIndices, ix)