Introduce a new init graph

The new init graph builder creates a small graph that can be used to
install or load module configuration. It reuses different walkers to
either install modules or validate the manifest during configuration
loading.

The new module install node dynamically expands the graph after module
installation with a subgraph for the installed module.
This commit is contained in:
Daniel Banck 2026-02-26 16:20:42 +01:00 committed by Daniel Schmidt
parent 7127c76b04
commit 28b76c1105
13 changed files with 1840 additions and 20 deletions

View file

@ -0,0 +1,42 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package terraform
import (
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/tfdiags"
)
// BuildConfigWithGraph builds a configuration tree using the init graph so
// that module sources and versions can be resolved with full expression
// evaluation before loading descendant modules.
func BuildConfigWithGraph(rootMod *configs.Module, walker configs.ModuleWalker, vars InputValues, loader configs.MockDataLoader) (*configs.Config, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
ctx, ctxDiags := NewContext(&ContextOpts{
Parallelism: 1,
})
diags = diags.Append(ctxDiags)
if ctxDiags.HasErrors() {
return nil, diags
}
cfg, initDiags := ctx.Init(rootMod, InitOpts{
Walker: walker,
SetVariables: vars,
})
diags = diags.Append(initDiags)
if diags.HasErrors() {
if cfg == nil && rootMod != nil {
cfg = &configs.Config{Module: rootMod}
cfg.Root = cfg
}
return cfg, diags
}
finalDiags := configs.FinalizeConfig(cfg, walker, loader)
diags = diags.Append(finalDiags)
return cfg, diags
}

View file

@ -0,0 +1,60 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package terraform
import (
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/tfdiags"
)
type InitOpts struct {
Walker configs.ModuleWalker
// SetVariables are the raw values for root module variables as provided
// by the user who is requesting the run, prior to any normalization or
// substitution of defaults. See the documentation for the InputValue
// type for more information on how to correctly populate this.
SetVariables InputValues
}
func (c *Context) Init(rootMod *configs.Module, initOpts InitOpts) (*configs.Config, tfdiags.Diagnostics) {
return c.init(rootMod, initOpts)
}
func (c *Context) init(rootMod *configs.Module, initOpts InitOpts) (*configs.Config, tfdiags.Diagnostics) {
defer c.acquireRun("init")()
var diags tfdiags.Diagnostics
config := &configs.Config{
Module: rootMod,
Path: addrs.RootModule,
Children: map[string]*configs.Config{},
}
config.Root = config
graph, moreDiags := c.initGraph(config, initOpts)
diags = diags.Append(moreDiags)
if diags.HasErrors() {
return nil, diags
}
walker, walkDiags := c.walk(graph, walkInit, &graphWalkOpts{
Config: config,
})
diags = diags.Append(walker.NonFatalDiagnostics)
diags = diags.Append(walkDiags)
return config, diags
}
func (c *Context) initGraph(config *configs.Config, initOpts InitOpts) (*Graph, tfdiags.Diagnostics) {
graph, diags := (&InitGraphBuilder{
Config: config,
RootVariableValues: initOpts.SetVariables,
Walker: initOpts.Walker,
}).Build(addrs.RootModuleInstance)
return graph, diags
}

View file

@ -0,0 +1,712 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package terraform
import (
"path/filepath"
"strings"
"testing"
version "github.com/hashicorp/go-version"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/getmodules/moduleaddrs"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)
var _ configs.ModuleWalker = (*MockModuleWalker)(nil)
type MockModuleWalker struct {
Calls []*configs.ModuleRequest
DefaultModule *configs.Module
// the string key refers to ModuleSource.String()
MockedCalls map[string]*configs.Module
}
func (m *MockModuleWalker) LoadModule(req *configs.ModuleRequest) (*configs.Module, *version.Version, hcl.Diagnostics) {
m.Calls = append(m.Calls, req)
if mod, ok := m.MockedCalls[req.SourceAddr.String()]; ok {
return mod, nil, nil
}
return m.DefaultModule, nil, nil
}
func (m *MockModuleWalker) MockModuleCalls(t *testing.T, calls map[string]*configs.Module) {
t.Helper()
if m.MockedCalls == nil {
m.MockedCalls = make(map[string]*configs.Module)
}
for k, v := range calls {
// Make sure we can parse the module source
ms := mustModuleSource(t, k)
m.MockedCalls[ms.String()] = v
}
}
func TestInit(t *testing.T) {
for name, tc := range map[string]struct {
module map[string]string
vars InputValues
mockedLoadModuleCalls map[string]map[string]string
// m -> root module
// mc -> module calls
expectDiags func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics
expectLoadModuleCalls []*configs.ModuleRequest
}{
"empty config": {
module: map[string]string{"main.tf": ``},
},
"local - no variables": {
module: map[string]string{
"main.tf": `
module "example" {
source = "./modules/example"
}
`,
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "./modules/example"),
}},
},
"remote - no variables": {
module: map[string]string{
"main.tf": `
module "example" {
source = "terraform-aws-modules/vpc/aws"
version = "6.6.0"
}
module "example2" {
source = "terraform-iaac/cert-manager/kubernetes"
}
`,
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"),
}, {
SourceAddr: mustModuleSource(t, "terraform-aws-modules/vpc/aws"),
VersionConstraint: mustVersionContraint(t, "= 6.6.0"),
}},
},
"local - with variables": {
module: map[string]string{
"main.tf": `
variable "name" {
type = string
const = true
}
module "example" {
source = "./modules/${var.name}"
}
`,
},
vars: InputValues{
"name": &InputValue{Value: cty.StringVal("example"), SourceType: ValueFromCLIArg},
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "./modules/example"),
}},
},
"local with non-static variables": {
module: map[string]string{
"main.tf": `
variable "name" {
type = string
}
module "example" {
source = "./modules/${var.name}"
}
`,
},
vars: InputValues{
"name": &InputValue{Value: cty.StringVal("example"), SourceType: ValueFromCLIArg},
},
expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics {
// TODO: We should try to somehow add an "extra" into the diagnostics to indicate
// that this may be caused by a non-static variable used during init.
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid module source`,
Detail: `The value of a reference in the module source is unknown.`,
Subject: &hcl.Range{
Filename: filepath.Join(m.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 6, Column: 27, Byte: 82},
End: hcl.Pos{Line: 6, Column: 35, Byte: 90},
},
})
},
},
"remote - with variable in source": {
module: map[string]string{
"main.tf": `
variable "name" {
type = string
const = true
}
module "example2" {
source = "terraform-iaac/${var.name}/kubernetes"
}
`,
},
vars: InputValues{
"name": &InputValue{Value: cty.StringVal("cert-manager"), SourceType: ValueFromCLIArg},
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"),
}},
},
"remote - with variable in constraint": {
module: map[string]string{
"main.tf": `
variable "name" {
type = string
const = true
}
module "example2" {
source = "terraform-iaac/cert-manager/kubernetes"
version = ">= ${var.name}"
}
`,
},
vars: InputValues{
"name": &InputValue{Value: cty.StringVal("1.2.3"), SourceType: ValueFromCLIArg},
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"),
VersionConstraint: mustVersionContraint(t, ">= 1.2.3"),
}},
},
"locals in module sources": {
module: map[string]string{
"main.tf": `
variable "name" {
type = string
const = true
}
locals {
org_and_repo = "terraform-iaac/${var.name}"
}
module "example2" {
source = "${local.org_and_repo}/kubernetes"
}
`,
},
vars: InputValues{
"name": &InputValue{Value: cty.StringVal("cert-manager"), SourceType: ValueFromCLIArg},
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"),
VersionConstraint: mustVersionContraint(t, ">= 1.2.3"),
}},
},
"each in module sources": {
module: map[string]string{
"main.tf": `
module "example" {
for_each = toset(["cert-manager", "helm"])
source = "terraform-iaac/${each.key}/kubernetes"
}
`,
},
vars: InputValues{
"name": &InputValue{Value: cty.StringVal("cert-manager"), SourceType: ValueFromCLIArg},
},
expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics {
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid module source`,
Detail: `The module source can only reference input variables and local values.`,
Subject: &hcl.Range{
Filename: filepath.Join(m.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 4, Column: 31, Byte: 95},
End: hcl.Pos{Line: 4, Column: 39, Byte: 103},
},
})
},
},
"module variables in source": {
module: map[string]string{
"main.tf": `
module "mod" {
source = "./mod"
name = "cert-manager"
}
`,
},
vars: InputValues{
"name": &InputValue{Value: cty.StringVal("cert-manager"), SourceType: ValueFromCLIArg},
},
mockedLoadModuleCalls: map[string]map[string]string{
"./mod": {
"main.tf": `
variable "name" {
type = string
const = true
}
module "example" {
source = "terraform-iaac/${var.name}/kubernetes"
}
`,
},
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "./mod"),
}, {
SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"),
}},
},
"undefined variable in module source": {
module: map[string]string{
"main.tf": `
variable "name" {
type = string
const = true
}
module "example2" {
source = "terraform-iaac/${var.name}/kubernetes"
}
`,
},
expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics {
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Required variable not set",
Detail: `The variable "name" is required, but is not set.`,
Subject: &hcl.Range{
Filename: filepath.Join(m.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 2, Column: 1, Byte: 1},
End: hcl.Pos{Line: 2, Column: 16, Byte: 16},
},
})
},
},
"resource reference in module source": {
module: map[string]string{
"main.tf": `
resource "null_resource" "example" {}
module "example" {
source = "terraform-iaac/${null_resource.example.id}/kubernetes"
}
`,
},
expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics {
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid module source",
Detail: "The module source can only reference input variables and local values.",
Subject: &hcl.Range{
Filename: filepath.Join(m.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 5, Column: 33, Byte: 91},
End: hcl.Pos{Line: 5, Column: 54, Byte: 112},
},
})
},
},
"resource reference in module call": {
module: map[string]string{
"main.tf": `
variable "name" {
type = string
default = "aws"
const = true
}
resource "null_resource" "example" {}
module "example" {
source = "./${var.name}"
name = var.name
this_should_be_unknown_and_not_cause_error = null_resource.example.id
}
`,
},
mockedLoadModuleCalls: map[string]map[string]string{
"./aws": {
"main.tf": `
variable "name" {
type = string
const = true
}
variable "this_should_be_unknown_and_not_cause_error" {
type = string
}
module "example" {
source = "terraform-iaac/${var.name}/kubernetes"
}
output "foo" {
value = var.this_should_be_unknown_and_not_cause_error
}
`,
},
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "terraform-iaac/aws/kubernetes"),
}, {
SourceAddr: mustModuleSource(t, "./aws"),
}},
},
"module output reference in module source": {
module: map[string]string{
"main.tf": `
module "example" {
source = "./module/example"
}
module "example2" {
source = "terraform-iaac/${module.example.id}/kubernetes"
}
`,
},
mockedLoadModuleCalls: map[string]map[string]string{
"./module/example": {
"main.tf": `
output "id" {
value = "example-id"
}
`,
}},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "./module/example"),
}},
expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics {
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid module source",
Detail: "The module source can only reference input variables and local values.",
Subject: &hcl.Range{
Filename: filepath.Join(m.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 7, Column: 33, Byte: 107},
End: hcl.Pos{Line: 7, Column: 50, Byte: 124},
},
})
},
},
"nested module loading - no variables": {
module: map[string]string{
"main.tf": `
module "parent" {
source = "hashicorp/parent/aws"
}
`,
},
mockedLoadModuleCalls: map[string]map[string]string{
"hashicorp/parent/aws": {
"main.tf": `
module "child" {
source = "hashicorp/child/aws"
}
`,
},
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "hashicorp/parent/aws"),
}, {
SourceAddr: mustModuleSource(t, "hashicorp/child/aws"),
}},
},
"nested module loading - with variables": {
module: map[string]string{
"main.tf": `
module "parent" {
source = "hashicorp/parent/aws"
name = "child"
}
`,
},
mockedLoadModuleCalls: map[string]map[string]string{
"hashicorp/parent/aws": {
"main.tf": `
variable "name" {
type = string
const = true
}
module "child" {
source = "hashicorp/${var.name}/aws"
name = "grand${var.name}"
}
`,
},
"hashicorp/child/aws": {
"main.tf": `
variable "name" {
type = string
const = true
}
module "grandchild" {
source = "hashicorp/${var.name}/aws"
}
`,
},
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "hashicorp/parent/aws"),
}, {
SourceAddr: mustModuleSource(t, "hashicorp/child/aws"),
}, {
SourceAddr: mustModuleSource(t, "hashicorp/grandchild/aws"),
}},
},
"module nested expansion": {
module: map[string]string{
"main.tf": `
module "fromdisk" {
source = "./mod"
namespace = "terraform-iaac"
}
`,
},
mockedLoadModuleCalls: map[string]map[string]string{
"./mod": {
"main.tf": `
locals {
source = var.namespace
}
variable "namespace" {
type = string
const = true
}
module "terraform" {
source = "${var.namespace}/helm/kubernetes"
}
output "name" {
value = "fooo"
}
`,
},
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "./mod"),
}, {
SourceAddr: mustModuleSource(t, "terraform-iaac/helm/kubernetes"),
}},
},
"static variable with no value and no default": {
module: map[string]string{"main.tf": `
variable "name" {
type = string
const = true
}
module "example" {
source = "./modules/${var.name}"
}
`,
},
expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics {
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Required variable not set`,
Detail: `The variable "name" is required, but is not set.`,
Subject: &hcl.Range{
Filename: filepath.Join(m.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 2, Column: 1, Byte: 1},
End: hcl.Pos{Line: 2, Column: 16, Byte: 16},
},
})
},
},
"static variable with default": {
module: map[string]string{"main.tf": `
variable "name" {
type = string
const = true
default = "example"
}
module "example" {
source = "./modules/${var.name}"
}
`,
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "./modules/example"),
}},
},
"non-static variable passed into static module variable": {
module: map[string]string{"main.tf": `
variable "name" {
type = string
default = "example"
}
module "example" {
source = "./modules/example"
name = "./modules/${var.name}2"
}
`,
},
mockedLoadModuleCalls: map[string]map[string]string{
"./modules/example": {
"main.tf": `
variable "name" {
type = string
const = true
}
`,
},
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "./modules/example"),
}},
expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics {
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Static variables must be known`,
Detail: `Only a static value can be passed into a static module variable.`,
Subject: &hcl.Range{
Filename: filepath.Join(m.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 8, Column: 10, Byte: 118},
End: hcl.Pos{Line: 8, Column: 34, Byte: 142},
},
})
},
},
"non-static module variable used as static": {
module: map[string]string{"main.tf": `
module "example" {
source = "./modules/example"
name = "foo"
}
`,
},
mockedLoadModuleCalls: map[string]map[string]string{
"./modules/example": {
"main.tf": `
variable "name" {
type = string
}
module "nested" {
source = "./modules/${var.name}"
}
`,
},
},
expectLoadModuleCalls: []*configs.ModuleRequest{{
SourceAddr: mustModuleSource(t, "./modules/example"),
}},
expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics {
// TODO: We should try to somehow add an "extra" into the diagnostics to indicate
// that this may be caused by a non-static variable used during init.
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid module source`,
Detail: `The value of a reference in the module source is unknown.`,
Subject: &hcl.Range{
Filename: filepath.Join(mc["./modules/example"].SourceDir, "main.tf"),
Start: hcl.Pos{Line: 7, Column: 27, Byte: 82},
End: hcl.Pos{Line: 7, Column: 35, Byte: 90},
},
})
},
},
} {
t.Run(name, func(t *testing.T) {
m := testRootModuleInline(t, tc.module)
ctx := testContext2(t, &ContextOpts{
Parallelism: 1,
})
moduleWalker := MockModuleWalker{
DefaultModule: testRootModuleInline(t, map[string]string{"main.tf": `// empty`}),
}
mockedModules := make(map[string]*configs.Module)
if tc.mockedLoadModuleCalls != nil {
for k, v := range tc.mockedLoadModuleCalls {
mockedModules[k] = testRootModuleInline(t, v)
}
moduleWalker.MockModuleCalls(t, mockedModules)
}
_, diags := ctx.Init(m, InitOpts{
SetVariables: tc.vars,
Walker: &moduleWalker,
})
if tc.expectDiags != nil {
tfdiags.AssertDiagnosticsMatch(t, diags, tc.expectDiags(m, mockedModules))
} else {
tfdiags.AssertNoDiagnostics(t, diags)
}
if len(moduleWalker.Calls) != len(tc.expectLoadModuleCalls) {
t.Fatalf("expected %d LoadModule calls, got %d", len(tc.expectLoadModuleCalls), len(moduleWalker.Calls))
}
// Create a map of expected sources for easier comparison
expectedSources := make(map[string]bool)
foundSources := []string{}
for _, expected := range tc.expectLoadModuleCalls {
expectedSources[expected.SourceAddr.String()] = false
}
// Mark sources as found
for _, call := range moduleWalker.Calls {
source := call.SourceAddr.String()
foundSources = append(foundSources, source)
if _, exists := expectedSources[source]; !exists {
t.Errorf("unexpected LoadModule call for source %q", source)
} else {
expectedSources[source] = true
}
}
// Check all expected sources were called
for source, found := range expectedSources {
if !found {
t.Errorf("expected LoadModule call for source %q but it was not called. Calls that were made: \n %s", source, strings.Join(foundSources, ", "))
}
}
})
}
}
func mustModuleSource(t *testing.T, rawStr string) addrs.ModuleSource {
src, err := moduleaddrs.ParseModuleSource(rawStr)
if err != nil {
t.Fatalf("failed to parse module source %q: %s", rawStr, err)
}
return src
}
func mustVersionContraint(t *testing.T, rawStr string) configs.VersionConstraint {
constraints, err := version.NewConstraint(rawStr)
if err != nil {
t.Fatalf("failed to parse version constraint %q: %s", rawStr, err)
}
return configs.VersionConstraint{
Required: constraints,
}
}

View file

@ -0,0 +1,83 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package terraform
import (
"log"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/dag"
"github.com/hashicorp/terraform/internal/tfdiags"
)
type InitGraphBuilder struct {
// A config derived from the root module
Config *configs.Config
RootVariableValues InputValues
Walker configs.ModuleWalker
}
// See GraphBuilder
func (b *InitGraphBuilder) Build(path addrs.ModuleInstance) (*Graph, tfdiags.Diagnostics) {
log.Printf("[TRACE] building graph for terraform dependencies")
return (&BasicGraphBuilder{
Steps: b.Steps(),
Name: "InitGraphBuilder",
}).Build(path)
}
// See GraphBuilder
func (b *InitGraphBuilder) Steps() []GraphTransformer {
steps := []GraphTransformer{}
if b.Config.Parent == nil {
steps = append(steps, &RootVariableTransformer{
Config: b.Config,
RawValues: b.RootVariableValues,
})
} else {
steps = append(steps, &ModuleVariableTransformer{
Config: b.Config,
ModuleOnly: true,
})
}
steps = append(steps, []GraphTransformer{
&ModuleTransformer{
Config: b.Config,
Walker: b.Walker,
},
&LocalTransformer{
Config: b.Config,
},
&ReferenceTransformer{},
// Filters out any vertices that aren't relevant to the init graph
&TransformFilter{
Keep: func(v dag.Vertex) bool {
switch n := v.(type) {
case *nodeInstallModule:
return true
case *NodeRootVariable:
return n.Config.Const
case *nodeExpandModuleVariable:
return n.Config.Const
default:
return false
}
},
},
&RootTransformer{},
&TransitiveReductionTransformer{},
}...)
return steps
}

View file

@ -17,4 +17,5 @@ const (
walkDestroy
walkImport
walkEval // used just to prepare EvalContext for expression evaluation, with no other actions
walkInit
)

View file

@ -0,0 +1,392 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package terraform
import (
"fmt"
"github.com/hashicorp/go-version"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/getmodules/moduleaddrs"
"github.com/hashicorp/terraform/internal/lang/langrefs"
"github.com/hashicorp/terraform/internal/tfdiags"
)
type nodeInstallModule struct {
// We're using a ModuleInstance here,
// because the downstream graph builder requires it.
// But it was constructed with addrs.NoKey
Addr addrs.ModuleInstance
ModuleCall *configs.ModuleCall
Parent *configs.Config
Walker configs.ModuleWalker
// Stores the configuration of the installed module
Config *configs.Config
// Stores the version of the installed module
Version *version.Version
}
var (
_ GraphNodeExecutable = (*nodeInstallModule)(nil)
_ GraphNodeReferencer = (*nodeInstallModule)(nil)
_ GraphNodeDynamicExpandable = (*nodeInstallModule)(nil)
_ GraphNodeModuleInstance = (*nodeInstallModule)(nil)
)
func (n *nodeInstallModule) Path() addrs.ModuleInstance {
return n.Addr.Parent()
}
func (n *nodeInstallModule) Name() string {
return n.Addr.String()
}
func (n *nodeInstallModule) ModulePath() addrs.Module {
return n.Addr.Module().Parent()
}
func (n *nodeInstallModule) References() []*addrs.Reference {
var refs []*addrs.Reference
sourceRefs, _ := langrefs.ReferencesInExpr(addrs.ParseRef, n.ModuleCall.SourceExpr)
refs = append(refs, sourceRefs...)
versionRefs, _ := langrefs.ReferencesInExpr(addrs.ParseRef, n.ModuleCall.VersionExpr)
refs = append(refs, versionRefs...)
// We need to resolve all module inputs as well, because some might be used
// in the module as a constant variable to build a nested module source
attrs, _ := n.ModuleCall.Config.JustAttributes()
for _, attr := range attrs {
inputRefs, _ := langrefs.ReferencesInExpr(addrs.ParseRef, attr.Expr)
refs = append(refs, inputRefs...)
}
return refs
}
func (n *nodeInstallModule) Execute(ctx EvalContext, walkOp walkOperation) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
var version configs.VersionConstraint
if n.ModuleCall.VersionExpr != nil {
var versionDiags tfdiags.Diagnostics
version, versionDiags = decodeVersionConstraint(n.ModuleCall.VersionExpr, ctx)
diags = diags.Append(versionDiags)
if diags.HasErrors() {
return diags
}
}
hasVersion := n.ModuleCall.VersionExpr != nil
source, sourceDiags := decodeSource(n.ModuleCall.SourceExpr, hasVersion, ctx)
diags = diags.Append(sourceDiags)
if diags.HasErrors() {
return diags
}
req := &configs.ModuleRequest{
Name: n.ModuleCall.Name,
Path: n.Addr.Module(),
SourceAddr: source,
SourceAddrRange: n.ModuleCall.SourceExpr.Range(),
VersionConstraint: version,
Parent: n.Parent,
CallRange: n.ModuleCall.DeclRange,
}
cfg, v, modDiags := n.Walker.LoadModule(req)
diags = diags.Append(modDiags)
if diags.HasErrors() {
return diags
}
config := &configs.Config{
Module: cfg,
Parent: n.Parent,
Path: n.Addr.Module(),
Root: n.Parent.Root,
Children: map[string]*configs.Config{},
CallRange: n.ModuleCall.DeclRange,
SourceAddr: source,
SourceAddrRange: n.ModuleCall.SourceExpr.Range(),
Version: v,
VersionConstraint: version,
}
// Insert the installed module into the children of the current module
currentModuleKey := n.Addr[len(n.Addr)-1].Name
n.Parent.Children[currentModuleKey] = config
n.Config = config
n.Version = v
return nil
}
func (n *nodeInstallModule) DynamicExpand(ctx EvalContext) (*Graph, tfdiags.Diagnostics) {
var g Graph
var diags tfdiags.Diagnostics
expander := ctx.InstanceExpander()
_, call := n.Addr.Call()
expander.SetModuleSingle(n.Path(), call)
graph, graphDiags := (&InitGraphBuilder{
Config: n.Config,
Walker: n.Walker,
}).Build(n.Addr)
diags = diags.Append(graphDiags)
if graphDiags.HasErrors() {
return nil, diags
}
g.Subsume(&graph.AcyclicGraph.Graph)
addRootNodeToGraph(&g)
return &g, nil
}
func decodeSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) (addrs.ModuleSource, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
var addr addrs.ModuleSource
var err error
refs, refsDiags := langrefs.ReferencesInExpr(addrs.ParseRef, sourceExpr)
diags = diags.Append(refsDiags)
if diags.HasErrors() {
return nil, diags
}
for _, ref := range refs {
switch ref.Subject.(type) {
case addrs.InputVariable, addrs.LocalValue:
// These are allowed
default:
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid module source",
Detail: "The module source can only reference input variables and local values.",
Subject: ref.SourceRange.ToHCL().Ptr(),
})
return nil, diags
}
}
value, valueDiags := ctx.EvaluateExpr(sourceExpr, cty.String, nil)
diags = diags.Append(valueDiags)
if diags.HasErrors() {
return nil, diags
}
if !value.IsWhollyKnown() {
tExpr, ok := sourceExpr.(*hclsyntax.TemplateExpr)
if !ok {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid module source",
Detail: "The module source contains a reference that is unknown during init.",
Subject: sourceExpr.Range().Ptr(),
})
return nil, diags
}
for _, part := range tExpr.Parts {
partVal, partDiags := ctx.EvaluateExpr(part, cty.DynamicPseudoType, nil)
diags = diags.Append(partDiags)
if diags.HasErrors() {
return nil, diags
}
scope := ctx.EvaluationScope(nil, nil, EvalDataForNoInstanceKey)
hclCtx, evalDiags := scope.EvalContext(refs)
diags = diags.Append(evalDiags)
if diags.HasErrors() {
return nil, diags
}
if !partVal.IsKnown() {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid module source",
Detail: "The value of a reference in the module source is unknown.",
Subject: part.Range().Ptr(),
Expression: part,
EvalContext: hclCtx,
Extra: diagnosticCausedByUnknown(true),
})
return nil, diags
}
}
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid module source",
Detail: "The module source contains a reference that is unknown.",
Subject: sourceExpr.Range().Ptr(),
})
return nil, diags
}
if hasVersion {
addr, err = moduleaddrs.ParseModuleSourceRegistry(value.AsString())
} else {
addr, err = moduleaddrs.ParseModuleSource(value.AsString())
}
if err != nil {
// NOTE: We leave add as nil for any situation where the
// source attribute is invalid, so any code which tries to carefully
// use the partial result of a failed config decode must be
// resilient to that.
addr = nil
// NOTE: In practice it's actually very unlikely to end up here,
// because our source address parser can turn just about any string
// into some sort of remote package address, and so for most errors
// we'll detect them only during module installation. There are
// still a _few_ purely-syntax errors we can catch at parsing time,
// though, mostly related to remote package sub-paths and local
// paths.
switch err := err.(type) {
case *moduleaddrs.MaybeRelativePathErr:
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid module source address",
Detail: fmt.Sprintf(
"Terraform failed to determine your intended installation method for remote module package %q.\n\nIf you intended this as a path relative to the current module, use \"./%s\" instead. The \"./\" prefix indicates that the address is a relative filesystem path.",
err.Addr, err.Addr,
),
Subject: sourceExpr.Range().Ptr(),
})
default:
if hasVersion {
// In this case we'll include some extra context that
// we assumed a registry source address due to the
// version argument.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid registry module source address",
Detail: fmt.Sprintf("Failed to parse module registry address: %s.\n\nTerraform assumed that you intended a module registry source address because you also set the argument \"version\", which applies only to registry modules.", err),
Subject: sourceExpr.Range().Ptr(),
})
} else {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid module source address",
Detail: fmt.Sprintf("Failed to parse module source address: %s.", err),
Subject: sourceExpr.Range().Ptr(),
})
}
}
}
return addr, diags
}
func decodeVersionConstraint(versionExpr hcl.Expression, ctx EvalContext) (configs.VersionConstraint, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
rng := versionExpr.Range()
ret := configs.VersionConstraint{
DeclRange: rng,
}
refs, refsDiags := langrefs.ReferencesInExpr(addrs.ParseRef, versionExpr)
diags = diags.Append(refsDiags)
if diags.HasErrors() {
return ret, diags
}
for _, ref := range refs {
switch ref.Subject.(type) {
case addrs.InputVariable, addrs.LocalValue:
// These are allowed
default:
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid module version",
Detail: "The module version can only reference input variables and local values.",
Subject: ref.SourceRange.ToHCL().Ptr(),
})
return ret, diags
}
}
value, valueDiags := ctx.EvaluateExpr(versionExpr, cty.String, nil)
diags = diags.Append(valueDiags)
if diags.HasErrors() {
return ret, diags
}
if value.IsNull() {
// A null version constraint is strange, but we'll just treat it
// like an empty constraint set.
return ret, diags
}
if !value.IsWhollyKnown() {
tExpr, ok := versionExpr.(*hclsyntax.TemplateExpr)
if !ok {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid module version",
Detail: "The module version contains a reference that is unknown during init.",
Subject: versionExpr.Range().Ptr(),
})
return ret, diags
}
for _, part := range tExpr.Parts {
partVal, partDiags := ctx.EvaluateExpr(part, cty.DynamicPseudoType, nil)
diags = diags.Append(partDiags)
if diags.HasErrors() {
return ret, diags
}
scope := ctx.EvaluationScope(nil, nil, EvalDataForNoInstanceKey)
hclCtx, evalDiags := scope.EvalContext(refs)
diags = diags.Append(evalDiags)
if diags.HasErrors() {
return ret, diags
}
if !partVal.IsKnown() {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid module version",
Detail: "The value of a reference in the module version is unknown.",
Subject: part.Range().Ptr(),
Expression: part,
EvalContext: hclCtx,
Extra: diagnosticCausedByUnknown(true),
})
return ret, diags
}
}
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid module version",
Detail: "The module version contains a reference that is unknown.",
Subject: versionExpr.Range().Ptr(),
})
return ret, diags
}
constraintStr := value.AsString()
constraints, err := version.NewConstraint(constraintStr)
if err != nil {
// NewConstraint doesn't return user-friendly errors, so we'll just
// ignore the provided error and produce our own generic one.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid version constraint",
Detail: "This string does not use correct version constraint syntax.", // Not very actionable :(
Subject: rng.Ptr(),
})
return ret, diags
}
ret.Required = constraints
return ret, diags
}

View file

@ -217,14 +217,26 @@ func (n *nodeModuleVariable) Execute(ctx EvalContext, op walkOperation) (diags t
log.Printf("[TRACE] nodeModuleVariable: evaluating %s", n.Addr)
var val cty.Value
var errSourceRange tfdiags.SourceRange
var err error
switch op {
case walkValidate:
val, err = n.evalModuleVariable(ctx, true)
val, errSourceRange, err = n.evalModuleVariable(ctx, true)
diags = diags.Append(err)
case walkInit:
// During init we only want to record the value if it's static;
// otherwise we record it as dynamic to prevent its use in
// static contexts.
// We still evaluate it fully here to catch any errors early.
if n.Config.Const {
val, errSourceRange, err = n.evalModuleVariable(ctx, false)
diags = diags.Append(err)
} else {
val = cty.DynamicVal
}
default:
val, err = n.evalModuleVariable(ctx, false)
val, errSourceRange, err = n.evalModuleVariable(ctx, false)
diags = diags.Append(err)
}
if diags.HasErrors() {
@ -236,6 +248,15 @@ func (n *nodeModuleVariable) Execute(ctx EvalContext, op walkOperation) (diags t
diags = diags.Append(deprecationDiags)
}
if op == walkInit && n.Config.Const && !val.IsWhollyKnown() {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Static variables must be known",
Detail: "Only a static value can be passed into a static module variable.",
Subject: errSourceRange.ToHCL().Ptr(),
})
}
// Set values for arguments of a child module call, for later retrieval
// during expression evaluation.
ctx.NamedValues().SetInputVariableValue(n.Addr, val)
@ -263,7 +284,7 @@ func (n *nodeModuleVariable) DotNode(name string, opts *dag.DotOpts) *dag.DotNod
// validateOnly indicates that this evaluation is only for config
// validation, and we will not have any expansion module instance
// repetition data.
func (n *nodeModuleVariable) evalModuleVariable(ctx EvalContext, validateOnly bool) (cty.Value, error) {
func (n *nodeModuleVariable) evalModuleVariable(ctx EvalContext, validateOnly bool) (cty.Value, tfdiags.SourceRange, error) {
var diags tfdiags.Diagnostics
var givenVal cty.Value
var errSourceRange tfdiags.SourceRange
@ -289,7 +310,7 @@ func (n *nodeModuleVariable) evalModuleVariable(ctx EvalContext, validateOnly bo
val, moreDiags := scope.EvalExpr(expr, cty.DynamicPseudoType)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
return cty.DynamicVal, diags.ErrWithWarnings()
return cty.DynamicVal, errSourceRange, diags.ErrWithWarnings()
}
givenVal = val
errSourceRange = tfdiags.SourceRangeFromHCL(expr.Range())
@ -320,7 +341,7 @@ func (n *nodeModuleVariable) evalModuleVariable(ctx EvalContext, validateOnly bo
})
}
return finalVal, diags.ErrWithWarnings()
return finalVal, errSourceRange, diags.ErrWithWarnings()
}
// nodeModuleVariableInPartialModule represents an infinite set of possible

View file

@ -110,19 +110,43 @@ func (n *NodeRootVariable) Execute(ctx EvalContext, op walkOperation) tfdiags.Di
}
}
finalVal, moreDiags := PrepareFinalInputVariableValue(
addr,
givenVal,
n.Config,
)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
// No point in proceeding to validations then, because they'll
// probably fail trying to work with a value of the wrong type.
return diags
}
// During init we only want to prepare the final value for static variables.
if op == walkInit {
var finalVal cty.Value
if n.Config.Const {
var moreDiags tfdiags.Diagnostics
finalVal, moreDiags = PrepareFinalInputVariableValue(
addr,
givenVal,
n.Config,
)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
// No point in proceeding to validations then, because they'll
// probably fail trying to work with a value of the wrong type.
return diags
}
} else {
// All non-static variables are unknown during init.
finalVal = cty.UnknownVal(n.Config.Type)
}
ctx.NamedValues().SetInputVariableValue(addr, finalVal)
ctx.NamedValues().SetInputVariableValue(addr, finalVal)
} else {
finalVal, moreDiags := PrepareFinalInputVariableValue(
addr,
givenVal,
n.Config,
)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
// No point in proceeding to validations then, because they'll
// probably fail trying to work with a value of the wrong type.
return diags
}
ctx.NamedValues().SetInputVariableValue(addr, finalVal)
}
// Custom validation rules are handled by a separate graph node of type
// nodeVariableValidation, added by variableValidationTransformer.

View file

@ -0,0 +1,42 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package terraform
import (
"github.com/hashicorp/terraform/internal/dag"
)
// TransformFilter is a GraphTransformer that filters out nodes from the graph based on a provided function. The Keep function should return true for nodes that should be kept in the graph, and false for nodes that should be removed. The transformer will mark all nodes that the node to keep depends on as well, ensuring that the resulting graph is still valid.
type TransformFilter struct {
Keep func(node dag.Vertex) bool
}
var _ GraphTransformer = (*TransformFilter)(nil)
func (t *TransformFilter) Transform(g *Graph) error {
// Partition vertices into kept and candidates for removal.
var kept []dag.Vertex
var removalCandidates []dag.Vertex
for _, v := range g.Vertices() {
if t.Keep(v) {
kept = append(kept, v)
} else {
removalCandidates = append(removalCandidates, v)
}
}
// Also keep all ancestors (transitive dependencies) of the kept
// nodes so the resulting graph stays valid.
ancestors := g.Ancestors(kept...)
// Remove every vertex that isn't explicitly kept and isn't an
// ancestor of a kept node.
for _, v := range removalCandidates {
if !ancestors.Include(v) {
g.Remove(v)
}
}
return nil
}

View file

@ -0,0 +1,386 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package terraform
import (
"strings"
"testing"
"github.com/hashicorp/terraform/internal/dag"
)
func TestTransformFilter(t *testing.T) {
t.Run("empty graph", func(t *testing.T) {
var g Graph
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
return true
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
if actual != "" {
t.Fatalf("expected empty graph, got:\n%s", actual)
}
})
t.Run("keep all", func(t *testing.T) {
var g Graph
g.Add("a")
g.Add("b")
g.Add("c")
g.Connect(dag.BasicEdge("a", "b"))
g.Connect(dag.BasicEdge("b", "c"))
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
return true
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
expected := strings.TrimSpace(`
a
b
b
c
c
`)
if actual != expected {
t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
}
})
t.Run("remove all", func(t *testing.T) {
var g Graph
g.Add("a")
g.Add("b")
g.Add("c")
g.Connect(dag.BasicEdge("a", "b"))
g.Connect(dag.BasicEdge("b", "c"))
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
return false
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
if actual != "" {
t.Fatalf("expected empty graph, got:\n%s", actual)
}
})
t.Run("keep node preserves its dependencies", func(t *testing.T) {
// a -> b -> c
// Keep only "a"; "b" and "c" should be preserved as ancestors.
var g Graph
g.Add("a")
g.Add("b")
g.Add("c")
g.Connect(dag.BasicEdge("a", "b"))
g.Connect(dag.BasicEdge("b", "c"))
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
return v.(string) == "a"
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
expected := strings.TrimSpace(`
a
b
b
c
c
`)
if actual != expected {
t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
}
})
t.Run("keep leaf removes dependents", func(t *testing.T) {
// a -> b -> c
// Keep only "c"; "a" and "b" are not ancestors of "c" so they
// should be removed.
var g Graph
g.Add("a")
g.Add("b")
g.Add("c")
g.Connect(dag.BasicEdge("a", "b"))
g.Connect(dag.BasicEdge("b", "c"))
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
return v.(string) == "c"
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
expected := "c"
if actual != expected {
t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
}
})
t.Run("keep middle preserves dependencies and removes dependents", func(t *testing.T) {
// a -> b -> c
// Keep "b"; "c" is an ancestor and stays, "a" depends on "b"
// but is not an ancestor so it is removed.
var g Graph
g.Add("a")
g.Add("b")
g.Add("c")
g.Connect(dag.BasicEdge("a", "b"))
g.Connect(dag.BasicEdge("b", "c"))
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
return v.(string) == "b"
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
expected := strings.TrimSpace(`
b
c
c
`)
if actual != expected {
t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
}
})
t.Run("diamond keep root preserves all", func(t *testing.T) {
// a -> b -> d
// a -> c -> d
// Keep "a"; everything is an ancestor of "a" so nothing is removed.
var g Graph
g.Add("a")
g.Add("b")
g.Add("c")
g.Add("d")
g.Connect(dag.BasicEdge("a", "b"))
g.Connect(dag.BasicEdge("a", "c"))
g.Connect(dag.BasicEdge("b", "d"))
g.Connect(dag.BasicEdge("c", "d"))
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
return v.(string) == "a"
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
expected := strings.TrimSpace(`
a
b
c
b
d
c
d
d
`)
if actual != expected {
t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
}
})
t.Run("diamond keep one branch", func(t *testing.T) {
// a -> b -> d
// a -> c -> d
// Keep "b"; "d" is an ancestor of "b" so it stays. "a" and "c"
// are not ancestors of "b" so they are removed.
var g Graph
g.Add("a")
g.Add("b")
g.Add("c")
g.Add("d")
g.Connect(dag.BasicEdge("a", "b"))
g.Connect(dag.BasicEdge("a", "c"))
g.Connect(dag.BasicEdge("b", "d"))
g.Connect(dag.BasicEdge("c", "d"))
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
return v.(string) == "b"
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
expected := strings.TrimSpace(`
b
d
d
`)
if actual != expected {
t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
}
})
t.Run("disconnected nodes are removed", func(t *testing.T) {
// a -> b, c (standalone)
// Keep "a"; "b" is preserved as ancestor, "c" has no connection
// and is removed.
var g Graph
g.Add("a")
g.Add("b")
g.Add("c")
g.Connect(dag.BasicEdge("a", "b"))
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
return v.(string) == "a"
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
expected := strings.TrimSpace(`
a
b
b
`)
if actual != expected {
t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
}
})
t.Run("multiple kept nodes merge their ancestors", func(t *testing.T) {
// a -> b -> d
// c -> d
// Keep "a" and "c"; their combined ancestors are "b" and "d",
// so the entire graph is preserved.
var g Graph
g.Add("a")
g.Add("b")
g.Add("c")
g.Add("d")
g.Connect(dag.BasicEdge("a", "b"))
g.Connect(dag.BasicEdge("b", "d"))
g.Connect(dag.BasicEdge("c", "d"))
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
s := v.(string)
return s == "a" || s == "c"
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
expected := strings.TrimSpace(`
a
b
b
d
c
d
d
`)
if actual != expected {
t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
}
})
t.Run("shared dependency kept through one branch", func(t *testing.T) {
// a -> c
// b -> c
// Keep "a"; "c" is an ancestor and stays, "b" is removed.
var g Graph
g.Add("a")
g.Add("b")
g.Add("c")
g.Connect(dag.BasicEdge("a", "c"))
g.Connect(dag.BasicEdge("b", "c"))
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
return v.(string) == "a"
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
expected := strings.TrimSpace(`
a
c
c
`)
if actual != expected {
t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
}
})
t.Run("single node kept", func(t *testing.T) {
var g Graph
g.Add("a")
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
return true
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
expected := "a"
if actual != expected {
t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
}
})
t.Run("single node removed", func(t *testing.T) {
var g Graph
g.Add("a")
tf := &TransformFilter{
Keep: func(v dag.Vertex) bool {
return false
},
}
if err := tf.Transform(&g); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(g.String())
if actual != "" {
t.Fatalf("expected empty graph, got:\n%s", actual)
}
})
}

View file

@ -0,0 +1,48 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package terraform
import (
"log"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/dag"
)
type ModuleTransformer struct {
Config *configs.Config
Walker configs.ModuleWalker
}
func (t *ModuleTransformer) Transform(graph *Graph) error {
if t.Config == nil {
return nil
}
for _, call := range t.Config.Module.ModuleCalls {
instancePath := graph.Path.Child(call.Name, addrs.NoKey)
err := t.transform(graph, t.Config, instancePath, call)
if err != nil {
return err
}
}
return nil
}
func (t *ModuleTransformer) transform(graph *Graph, cfg *configs.Config, path addrs.ModuleInstance, modCall *configs.ModuleCall) error {
n := &nodeInstallModule{
Addr: path,
ModuleCall: modCall,
Parent: cfg,
Walker: t.Walker,
}
var installNode dag.Vertex = n
graph.Add(installNode)
log.Printf("[TRACE] ModuleTransformer: Added %s as %T", path, installNode)
return nil
}

View file

@ -29,6 +29,10 @@ import (
type ModuleVariableTransformer struct {
Config *configs.Config
// ModuleOnly, if true, makes the transformer only process the
// variables in the current module, skipping any child modules.
ModuleOnly bool
// Planning must be set to true when building a planning graph, and must be
// false when building an apply graph.
Planning bool
@ -39,7 +43,11 @@ type ModuleVariableTransformer struct {
}
func (t *ModuleVariableTransformer) Transform(g *Graph) error {
return t.transform(g, nil, t.Config)
if t.ModuleOnly && t.Config.Parent != nil {
return t.transformSingle(g, t.Config.Parent, t.Config)
} else {
return t.transform(g, nil, t.Config)
}
}
func (t *ModuleVariableTransformer) transform(g *Graph, parent, c *configs.Config) error {

View file

@ -16,11 +16,12 @@ func _() {
_ = x[walkDestroy-5]
_ = x[walkImport-6]
_ = x[walkEval-7]
_ = x[walkInit-8]
}
const _walkOperation_name = "walkInvalidwalkApplywalkPlanwalkPlanDestroywalkValidatewalkDestroywalkImportwalkEval"
const _walkOperation_name = "walkInvalidwalkApplywalkPlanwalkPlanDestroywalkValidatewalkDestroywalkImportwalkEvalwalkInit"
var _walkOperation_index = [...]uint8{0, 11, 20, 28, 43, 55, 66, 76, 84}
var _walkOperation_index = [...]uint8{0, 11, 20, 28, 43, 55, 66, 76, 84, 92}
func (i walkOperation) String() string {
idx := int(i) - 0