From ea9d8cc873b6cdd19f6ee9e4ae67cde7d323b02f Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Wed, 11 Feb 2026 14:49:16 +0100 Subject: [PATCH] terraform: add new init graph workflow and builder --- internal/terraform/context_init.go | 60 ++ internal/terraform/context_init_test.go | 710 ++++++++++++++++++ internal/terraform/graph_builder_init.go | 60 ++ internal/terraform/graph_walk_operation.go | 1 + internal/terraform/node_module_install.go | 343 +++++++++ internal/terraform/terraform_test.go | 43 ++ .../terraform/transform_module_install.go | 46 ++ .../terraform/transform_module_variable.go | 10 +- internal/terraform/walkoperation_string.go | 5 +- 9 files changed, 1275 insertions(+), 3 deletions(-) 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_module_install.go diff --git a/internal/terraform/context_init.go b/internal/terraform/context_init.go new file mode 100644 index 0000000000..83c6801301 --- /dev/null +++ b/internal/terraform/context_init.go @@ -0,0 +1,60 @@ +// Copyright (c) HashiCorp, Inc. +// 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..226c88ac86 --- /dev/null +++ b/internal/terraform/context_init_test.go @@ -0,0 +1,710 @@ +// Copyright (c) HashiCorp, Inc. +// 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-const 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-const 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"), + }}, + }, + + "const 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}, + }, + }) + }, + }, + + "const 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-const variable passed into const 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: `Constant variables must be known`, + Detail: `Only a constant value can be passed into a constant 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-const module variable used as const": { + 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-constant 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{}) + 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..69d1f69435 --- /dev/null +++ b/internal/terraform/graph_builder_init.go @@ -0,0 +1,60 @@ +package terraform + +import ( + "log" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +type InitGraphBuilder struct { + Config *configs.Config + + RootVariableValues InputValues + + Walker configs.ModuleWalker +} + +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) +} + +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, + Installer: b.Walker, + }, + + &LocalTransformer{ + Config: b.Config, + }, + + &ReferenceTransformer{}, + + &RootTransformer{}, + + &TransitiveReductionTransformer{}, + }...) + + return steps +} diff --git a/internal/terraform/graph_walk_operation.go b/internal/terraform/graph_walk_operation.go index 9100c8b881..af467631fd 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..2b2bc03917 --- /dev/null +++ b/internal/terraform/node_module_install.go @@ -0,0 +1,343 @@ +package terraform + +import ( + "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 + Installer 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.Installer.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, + } + + // 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.Installer, + }).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 { + diags = diags.Append(diags, err) + } + + return addr, nil +} + +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/terraform_test.go b/internal/terraform/terraform_test.go index 44412d34a9..2ffb908409 100644 --- a/internal/terraform/terraform_test.go +++ b/internal/terraform/terraform_test.go @@ -147,6 +147,49 @@ func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...con return config } +func testRootModuleInline(t testing.TB, sources map[string]string) *configs.Module { + t.Helper() + + cfgPath, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + + for path, configStr := range sources { + dir := filepath.Dir(path) + if dir != "." { + err := os.MkdirAll(filepath.Join(cfgPath, dir), os.FileMode(0777)) + if err != nil { + t.Fatalf("Error creating subdir: %s", err) + } + } + // Write the configuration + cfgF, err := os.Create(filepath.Join(cfgPath, path)) + if err != nil { + t.Fatalf("Error creating temporary file for config: %s", err) + } + + _, err = io.Copy(cfgF, strings.NewReader(configStr)) + cfgF.Close() + if err != nil { + t.Fatalf("Error creating temporary file for config: %s", err) + } + } + + loader, cleanup := configload.NewLoaderForTests(t) + defer cleanup() + + // We need to be able to exercise experimental features in our integration tests. + loader.AllowLanguageExperiments(true) + + mod, diags := loader.Parser().LoadConfigDir(cfgPath) + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + return mod +} + // testSetResourceInstanceCurrent is a helper function for tests that sets a Current, // Ready resource instance for the given module. func testSetResourceInstanceCurrent(module *states.Module, resource, attrsJson, provider string) { diff --git a/internal/terraform/transform_module_install.go b/internal/terraform/transform_module_install.go new file mode 100644 index 0000000000..a819ddc4a6 --- /dev/null +++ b/internal/terraform/transform_module_install.go @@ -0,0 +1,46 @@ +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 + Installer 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) + // instancePath := graph.Path.Module().Child(call.Name) + + 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, + Installer: t.Installer, + } + 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 6da5c4bc6e..2f40a6e772 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