feat(import): support import blocks inside child modules

pull/38352/head
Kristin Laemmert 1 month ago
parent 2c125d7952
commit 044a9ad3a7

@ -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{

@ -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) {

@ -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())
}

@ -10,7 +10,6 @@ import (
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/experiments"
tfversion "github.com/hashicorp/terraform/version"
)

@ -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")
}
}

@ -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

@ -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)

@ -1,6 +0,0 @@
resource "aws_instance" "web" {}
import {
to = aws_instance.web
id = "test"
}

@ -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.

@ -1,10 +0,0 @@
resource "aws_instance" "web" {}
import {
to = aws_instance.web
id = "test"
}
module "child" {
source = "./child"
}

@ -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"
}

@ -0,0 +1,3 @@
module "child" {
source = "./child"
}

@ -0,0 +1,60 @@
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 From 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
}

@ -0,0 +1,158 @@
package terraform
import (
"testing"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/addrs"
"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 := simpleMockProvider()
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"),
}),
},
},
}
}
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),
},
})
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"))
if rs == nil {
t.Fatal("imported resource not found in module")
}
}
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),
},
})
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),
},
})
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")
}
}

@ -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

@ -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

@ -173,118 +173,128 @@ 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 {
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)
}
}

@ -0,0 +1,6 @@
resource "test_object" "foo" {}
import {
to = test_object.foo
id = "import"
}

@ -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
}

@ -0,0 +1,7 @@
import {
to = test_object.bar
id = "importable"
}
resource "test_object" "bar" {
}

@ -0,0 +1,3 @@
module "child" {
source = "./child"
}

@ -0,0 +1,6 @@
import {
to = test_object.bar
id = "importable"
}
resource "test_object" "bar" {}

@ -0,0 +1,3 @@
module "kinder" {
source = "./kinder"
}

@ -0,0 +1,3 @@
module "child" {
source = "./child"
}

@ -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)

Loading…
Cancel
Save