From 28b76c110569789cbff77a0e3207929bad7daeb3 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 26 Feb 2026 16:20:42 +0100 Subject: [PATCH] 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. --- internal/terraform/config_graph_build.go | 42 ++ internal/terraform/context_init.go | 60 ++ internal/terraform/context_init_test.go | 712 ++++++++++++++++++ internal/terraform/graph_builder_init.go | 83 ++ internal/terraform/graph_walk_operation.go | 1 + internal/terraform/node_module_install.go | 392 ++++++++++ internal/terraform/node_module_variable.go | 31 +- internal/terraform/node_root_variable.go | 48 +- internal/terraform/transform_filter.go | 42 ++ internal/terraform/transform_filter_test.go | 386 ++++++++++ .../terraform/transform_module_install.go | 48 ++ .../terraform/transform_module_variable.go | 10 +- internal/terraform/walkoperation_string.go | 5 +- 13 files changed, 1840 insertions(+), 20 deletions(-) create mode 100644 internal/terraform/config_graph_build.go create mode 100644 internal/terraform/context_init.go create mode 100644 internal/terraform/context_init_test.go create mode 100644 internal/terraform/graph_builder_init.go create mode 100644 internal/terraform/node_module_install.go create mode 100644 internal/terraform/transform_filter.go create mode 100644 internal/terraform/transform_filter_test.go create mode 100644 internal/terraform/transform_module_install.go diff --git a/internal/terraform/config_graph_build.go b/internal/terraform/config_graph_build.go new file mode 100644 index 0000000000..e8451f238f --- /dev/null +++ b/internal/terraform/config_graph_build.go @@ -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 +} diff --git a/internal/terraform/context_init.go b/internal/terraform/context_init.go new file mode 100644 index 0000000000..e84a4b2f5e --- /dev/null +++ b/internal/terraform/context_init.go @@ -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 +} diff --git a/internal/terraform/context_init_test.go b/internal/terraform/context_init_test.go new file mode 100644 index 0000000000..c9f7f59fd7 --- /dev/null +++ b/internal/terraform/context_init_test.go @@ -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, + } +} diff --git a/internal/terraform/graph_builder_init.go b/internal/terraform/graph_builder_init.go new file mode 100644 index 0000000000..da985bae72 --- /dev/null +++ b/internal/terraform/graph_builder_init.go @@ -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 +} diff --git a/internal/terraform/graph_walk_operation.go b/internal/terraform/graph_walk_operation.go index a408f78465..fcfc23bfae 100644 --- a/internal/terraform/graph_walk_operation.go +++ b/internal/terraform/graph_walk_operation.go @@ -17,4 +17,5 @@ const ( walkDestroy walkImport walkEval // used just to prepare EvalContext for expression evaluation, with no other actions + walkInit ) diff --git a/internal/terraform/node_module_install.go b/internal/terraform/node_module_install.go new file mode 100644 index 0000000000..9692c8cb56 --- /dev/null +++ b/internal/terraform/node_module_install.go @@ -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 +} diff --git a/internal/terraform/node_module_variable.go b/internal/terraform/node_module_variable.go index a458af0990..7672906851 100644 --- a/internal/terraform/node_module_variable.go +++ b/internal/terraform/node_module_variable.go @@ -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 diff --git a/internal/terraform/node_root_variable.go b/internal/terraform/node_root_variable.go index c05c0c9ba0..694a65a0a7 100644 --- a/internal/terraform/node_root_variable.go +++ b/internal/terraform/node_root_variable.go @@ -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. diff --git a/internal/terraform/transform_filter.go b/internal/terraform/transform_filter.go new file mode 100644 index 0000000000..2feb115ac2 --- /dev/null +++ b/internal/terraform/transform_filter.go @@ -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 +} diff --git a/internal/terraform/transform_filter_test.go b/internal/terraform/transform_filter_test.go new file mode 100644 index 0000000000..6fff7182aa --- /dev/null +++ b/internal/terraform/transform_filter_test.go @@ -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) + } + }) +} diff --git a/internal/terraform/transform_module_install.go b/internal/terraform/transform_module_install.go new file mode 100644 index 0000000000..175ebc8574 --- /dev/null +++ b/internal/terraform/transform_module_install.go @@ -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 +} diff --git a/internal/terraform/transform_module_variable.go b/internal/terraform/transform_module_variable.go index 0dc19eb122..e09b9d6baf 100644 --- a/internal/terraform/transform_module_variable.go +++ b/internal/terraform/transform_module_variable.go @@ -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 { diff --git a/internal/terraform/walkoperation_string.go b/internal/terraform/walkoperation_string.go index 20a8220844..5500ba0817 100644 --- a/internal/terraform/walkoperation_string.go +++ b/internal/terraform/walkoperation_string.go @@ -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