Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/v1.16/NEW FEATURES-20260410-085729.yaml
Original file line number Diff line number Diff line change
@@ -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"
9 changes: 0 additions & 9 deletions internal/configs/config_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
}
Comment on lines -365 to -372
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we changing any user assumptions by allowing modules to now use import blocks by default? (and if so, would there be value in only allowing local modules initially?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, and I think it could be a security concern (see my above comment). On some level I didn't think of it because I wouldn't use terraform modules I didn't write or code review myself 🤔


if len(mod.ListResources) > 0 {
first := slices.Collect(maps.Values(mod.ListResources))[0]
diags = diags.Append(&hcl.Diagnostic{
Expand Down
8 changes: 4 additions & 4 deletions internal/configs/config_build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ package configs

import (
"fmt"
"io/ioutil"
"os"
"path"
"path/filepath"
"reflect"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 1 addition & 2 deletions internal/configs/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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())
}
Expand Down
1 change: 0 additions & 1 deletion internal/configs/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (

"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/experiments"

tfversion "github.com/hashicorp/terraform/version"
)

Expand Down
14 changes: 13 additions & 1 deletion internal/configs/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")
}
}
6 changes: 3 additions & 3 deletions internal/configs/parser_config_dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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...)
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions internal/configs/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module "child" {
source = "./child"
}
63 changes: 63 additions & 0 deletions internal/refactoring/import_statement.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1

package refactoring
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super sold on putting this file in refactoring, but this package was the inspiration for it so 🤷🏻 happy to move it or nah.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah 🤔, I don't think prior to this PR that I would have thought of import as a "refactoring" operation but it doesn't seem too far off in concept I guess. The only other location I can think that would make sense is just closer to the caller/tests for this logic back in internal/terraform 😆


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.
Comment on lines +39 to +43
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not immediately clear to me what the use-case for overriding an import block inside a module would be, maybe just a situation where a module author doesn't provide a proper way to influence the import command? 🤔 (with like a variable for example)

Although, I'm also not well versed in why the existing moved block allows that either 🙂

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed; this was weird so I copied the moved behavior but I could also return an error if we get this.

Though ... never mind overriding an import block, to be honest I am not very comfortable with this feature at all. the only use-cases I can actually come up with are solved by data sources, not importing resources into your state from nested modules. I can't think of many instances where I'd want to blanket import something in to every instance of a module that's being used multiple places, even back when I was managing relatively well contained modules and environments. If I owned everything, I could write the import statement in the root module, and if I don't own everything ... I don't want imports I didn't write. But I also came from a fairly specific way of working so I tried to let all that go.

.... though now that I'm thinking about it, wearing my infra hat, I'd prefer want the ability to control this behavior (ie, block nested-module imports/fail/etc) from the root module (as a security stance), but I guess a sentinel policy could cover that.

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