diff --git a/.changes/v1.16/NEW FEATURES-20260423-120556.yaml b/.changes/v1.16/NEW FEATURES-20260423-120556.yaml new file mode 100644 index 000000000000..05ff03d768ff --- /dev/null +++ b/.changes/v1.16/NEW FEATURES-20260423-120556.yaml @@ -0,0 +1,5 @@ +kind: NEW FEATURES +body: Required provider' source and version can now be evaluated from variables +time: 2026-04-23T12:05:56.014251+02:00 +custom: + Issue: "38405" diff --git a/internal/configs/module.go b/internal/configs/module.go index 5437360713c9..d5e77430606e 100644 --- a/internal/configs/module.go +++ b/internal/configs/module.go @@ -33,13 +33,14 @@ type Module struct { ActiveExperiments experiments.Set - Backend *Backend - StateStore *StateStore - CloudConfig *CloudConfig - ProviderConfigs map[string]*Provider - ProviderRequirements *RequiredProviders - ProviderLocalNames map[addrs.Provider]string - ProviderMetas map[addrs.Provider]*ProviderMeta + Backend *Backend + StateStore *StateStore + CloudConfig *CloudConfig + ProviderConfigs map[string]*Provider + ProviderRequirements *RequiredProviders + ProviderRequirementExprs map[string]*ProviderRequirementExpr + ProviderLocalNames map[addrs.Provider]string + ProviderMetas map[addrs.Provider]*ProviderMeta Variables map[string]*Variable Locals map[string]*Local @@ -78,12 +79,13 @@ type File struct { ActiveExperiments experiments.Set - Backends []*Backend - StateStores []*StateStore - CloudConfigs []*CloudConfig - ProviderConfigs []*Provider - ProviderMetas []*ProviderMeta - RequiredProviders []*RequiredProviders + Backends []*Backend + StateStores []*StateStore + CloudConfigs []*CloudConfig + ProviderConfigs []*Provider + ProviderMetas []*ProviderMeta + RequiredProviders []*RequiredProviders + RequiredProviderExprs []*ProviderRequirementExpr Variables []*Variable Locals []*Local @@ -124,20 +126,21 @@ func NewModuleWithTests(primaryFiles, overrideFiles []*File, testFiles map[strin func NewModule(primaryFiles, overrideFiles []*File) (*Module, hcl.Diagnostics) { var diags hcl.Diagnostics mod := &Module{ - ProviderConfigs: map[string]*Provider{}, - ProviderLocalNames: map[addrs.Provider]string{}, - Variables: map[string]*Variable{}, - Locals: map[string]*Local{}, - Outputs: map[string]*Output{}, - ModuleCalls: map[string]*ModuleCall{}, - ManagedResources: map[string]*Resource{}, - EphemeralResources: map[string]*Resource{}, - DataResources: map[string]*Resource{}, - ListResources: map[string]*Resource{}, - Checks: map[string]*Check{}, - ProviderMetas: map[addrs.Provider]*ProviderMeta{}, - Tests: map[string]*TestFile{}, - Actions: map[string]*Action{}, + ProviderConfigs: map[string]*Provider{}, + ProviderLocalNames: map[addrs.Provider]string{}, + Variables: map[string]*Variable{}, + Locals: map[string]*Local{}, + Outputs: map[string]*Output{}, + ModuleCalls: map[string]*ModuleCall{}, + ManagedResources: map[string]*Resource{}, + EphemeralResources: map[string]*Resource{}, + DataResources: map[string]*Resource{}, + ListResources: map[string]*Resource{}, + Checks: map[string]*Check{}, + ProviderMetas: map[addrs.Provider]*ProviderMeta{}, + ProviderRequirementExprs: map[string]*ProviderRequirementExpr{}, + Tests: map[string]*TestFile{}, + Actions: map[string]*Action{}, } // Process the required_providers blocks first, to ensure that all @@ -175,6 +178,19 @@ func NewModule(primaryFiles, overrideFiles []*File) (*Module, hcl.Diagnostics) { } } + // Collect any deferred provider requirement expressions from all files. + for _, file := range primaryFiles { + for _, expr := range file.RequiredProviderExprs { + mod.ProviderRequirementExprs[expr.Name] = expr + } + } + + for _, file := range overrideFiles { + for _, expr := range file.RequiredProviderExprs { + mod.ProviderRequirementExprs[expr.Name] = expr + } + } + for _, file := range primaryFiles { fileDiags := mod.appendFile(file) diags = append(diags, fileDiags...) @@ -188,7 +204,7 @@ func NewModule(primaryFiles, overrideFiles []*File) (*Module, hcl.Diagnostics) { diags = append(diags, checkModuleExperiments(mod)...) // Generate the FQN -> LocalProviderName map - mod.gatherProviderLocalNames() + mod.GatherProviderLocalNames() if mod.StateStore != nil { diags = append(diags, mod.resolveStateStoreProviderType()...) @@ -883,11 +899,11 @@ func (m *Module) mergeFile(file *File) hcl.Diagnostics { return diags } -// gatherProviderLocalNames is a helper function that populates a map of +// GatherProviderLocalNames is a helper function that populates a map of // provider FQNs -> provider local names. This information is useful for // user-facing output, which should include both the FQN and LocalName. It must // only be populated after the module has been parsed. -func (m *Module) gatherProviderLocalNames() { +func (m *Module) GatherProviderLocalNames() { providers := make(map[addrs.Provider]string) for k, v := range m.ProviderRequirements.RequiredProviders { providers[v.Type] = k diff --git a/internal/configs/parser_config.go b/internal/configs/parser_config.go index 401092f2da43..f73d06957e2f 100644 --- a/internal/configs/parser_config.go +++ b/internal/configs/parser_config.go @@ -145,11 +145,14 @@ func parseConfigFile(body hcl.Body, diags hcl.Diagnostics, override, allowExperi } case "required_providers": - reqs, reqsDiags := decodeRequiredProvidersBlock(innerBlock) + reqs, reqExprs, reqsDiags := decodeRequiredProvidersBlock(innerBlock) diags = append(diags, reqsDiags...) if reqs != nil { file.RequiredProviders = append(file.RequiredProviders, reqs) } + for _, expr := range reqExprs { + file.RequiredProviderExprs = append(file.RequiredProviderExprs, expr) + } case "provider_meta": providerCfg, cfgDiags := decodeProviderMetaBlock(innerBlock) diff --git a/internal/configs/provider_requirement_expr.go b/internal/configs/provider_requirement_expr.go new file mode 100644 index 000000000000..a37636ef5eab --- /dev/null +++ b/internal/configs/provider_requirement_expr.go @@ -0,0 +1,25 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package configs + +import ( + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/addrs" +) + +// ProviderRequirementExpr is a container for deferred HCL expressions for a +// required provider's source and/or version. +type ProviderRequirementExpr struct { + Name string + SourceExpr hcl.Expression + VersionExpr hcl.Expression + + ConfigAliases []addrs.LocalProviderConfig + + DeclRange hcl.Range +} + +func (e *ProviderRequirementExpr) IsEmpty() bool { + return e.SourceExpr == nil && e.VersionExpr == nil +} diff --git a/internal/configs/provider_requirements.go b/internal/configs/provider_requirements.go index 7da2085e8514..a48215161c51 100644 --- a/internal/configs/provider_requirements.go +++ b/internal/configs/provider_requirements.go @@ -6,7 +6,7 @@ package configs import ( "fmt" - version "github.com/hashicorp/go-version" + "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/terraform/internal/addrs" "github.com/zclconf/go-cty/cty" @@ -30,16 +30,21 @@ type RequiredProviders struct { DeclRange hcl.Range } -func decodeRequiredProvidersBlock(block *hcl.Block) (*RequiredProviders, hcl.Diagnostics) { +func decodeRequiredProvidersBlock(block *hcl.Block) ( + *RequiredProviders, + map[string]*ProviderRequirementExpr, + hcl.Diagnostics, +) { attrs, diags := block.Body.JustAttributes() if diags.HasErrors() { - return nil, diags + return nil, nil, diags } ret := &RequiredProviders{ RequiredProviders: make(map[string]*RequiredProvider), DeclRange: block.DefRange, } + var deferredExprs map[string]*ProviderRequirementExpr for name, attr := range attrs { rp := &RequiredProvider{ @@ -89,6 +94,14 @@ func decodeRequiredProvidersBlock(block *hcl.Block) (*RequiredProviders, hcl.Dia continue } + providerExpr := &ProviderRequirementExpr{ + Name: name, + SourceExpr: nil, + VersionExpr: nil, + DeclRange: attr.Expr.Range(), + } + var sourceExpr, versionExpr hcl.Expression + LOOP: for _, kv := range kvs { key, keyDiags := kv.Key.Value(nil) @@ -109,6 +122,18 @@ func decodeRequiredProvidersBlock(block *hcl.Block) (*RequiredProviders, hcl.Dia switch key.AsString() { case "version": + versionExpr = kv.Value + + // Store the version expression if it contains variable that + // needs to be evaluated. + // + // Skip the "legacy" pure string resolution of the version + // attribute. + if vars := kv.Value.Variables(); len(vars) > 0 { + providerExpr.VersionExpr = kv.Value + continue + } + vc := VersionConstraint{ DeclRange: attr.Range, } @@ -142,6 +167,18 @@ func decodeRequiredProvidersBlock(block *hcl.Block) (*RequiredProviders, hcl.Dia rp.Requirement = vc case "source": + sourceExpr = kv.Value + + // Store the source expression if it contains variable that + // needs to be evaluated. + // + // Skip the "legacy" pure string resolution of the source + // attribute. + if vars := kv.Value.Variables(); len(vars) > 0 { + providerExpr.SourceExpr = kv.Value + continue + } + source, err := kv.Value.Value(nil) if err != nil || !source.Type().Equals(cty.String) { diags = append(diags, &hcl.Diagnostic{ @@ -226,6 +263,29 @@ func decodeRequiredProvidersBlock(block *hcl.Block) (*RequiredProviders, hcl.Dia continue } + // Provider Expression contains either source or version expression. + // Hydrate the rest, store it into the result map and skip adding it to + // required providers. + if !providerExpr.IsEmpty() { + providerExpr.ConfigAliases = rp.Aliases + + if providerExpr.SourceExpr == nil { + providerExpr.SourceExpr = sourceExpr + } + + if providerExpr.VersionExpr == nil { + providerExpr.VersionExpr = versionExpr + } + + if deferredExprs == nil { + deferredExprs = map[string]*ProviderRequirementExpr{} + } + deferredExprs[name] = providerExpr + + // Skip adding it to required providers. + continue + } + // We can add the required provider when there are no errors. // If a source was not given, create an implied type. if rp.Type.IsZero() { @@ -245,5 +305,5 @@ func decodeRequiredProvidersBlock(block *hcl.Block) (*RequiredProviders, hcl.Dia ret.RequiredProviders[rp.Name] = rp } - return ret, diags + return ret, deferredExprs, diags } diff --git a/internal/configs/provider_requirements_test.go b/internal/configs/provider_requirements_test.go index d23b3c8d2d70..a6039832cb94 100644 --- a/internal/configs/provider_requirements_test.go +++ b/internal/configs/provider_requirements_test.go @@ -8,7 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - version "github.com/hashicorp/go-version" + "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hcltest" "github.com/hashicorp/terraform/internal/addrs" @@ -324,7 +324,7 @@ func TestDecodeRequiredProvidersBlock(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - got, diags := decodeRequiredProvidersBlock(test.Block) + got, _, diags := decodeRequiredProvidersBlock(test.Block) if diags.HasErrors() { if test.Error == "" { t.Fatalf("unexpected error: %v", diags) diff --git a/internal/terraform/context_init_dyn_provider_test.go b/internal/terraform/context_init_dyn_provider_test.go new file mode 100644 index 000000000000..0060de5b3c22 --- /dev/null +++ b/internal/terraform/context_init_dyn_provider_test.go @@ -0,0 +1,427 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "testing" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +func TestInit_DynamicProviderSource(t *testing.T) { + for name, tc := range map[string]struct { + module map[string]string + vars InputValues + validationFunc func( + t *testing.T, + cfg *configs.Config, + diags tfdiags.Diagnostics, + ) + }{ + "resolve required provider source": { + module: map[string]string{ + "main.tf": ` +terraform { + required_providers { + test-provider = { + source = var.test-provider_src + } + } +} + +variable "test-provider_src" { + type = string + const = true +} +`, + }, + vars: InputValues{ + "test-provider_src": { + Value: cty.StringVal("test-provider"), + SourceType: ValueFromCLIArg, + }, + }, + validationFunc: func( + t *testing.T, + cfg *configs.Config, + diags tfdiags.Diagnostics, + ) { + if diags.HasErrors() { + t.Fatal(diags) + } + + rp := expectRequiredProviderInModule(t, "test-provider", cfg.Module) + expectRequiredProviderSource(t, "test-provider", rp.Source) + expectRequiredProviderVersion(t, "", rp.Requirement.Required) + }, + }, + "resolve required provider source and version from variables": { + module: map[string]string{ + "main.tf": ` +terraform { + required_providers { + test-provider = { + source = var.test-provider_src + version = var.test-provider_version + } + } +} + +variable "test-provider_src" { + type = string + const = true +} + +variable "test-provider_version" { + type = string + const = true +} +`, + }, + vars: InputValues{ + "test-provider_src": { + Value: cty.StringVal("test-provider"), + SourceType: ValueFromCLIArg, + }, + "test-provider_version": { + Value: cty.StringVal("0.0.1"), + SourceType: ValueFromCLIArg, + }, + }, + validationFunc: func( + t *testing.T, + cfg *configs.Config, + diags tfdiags.Diagnostics, + ) { + if diags.HasErrors() { + t.Fatal(diags) + } + + rp := expectRequiredProviderInModule(t, "test-provider", cfg.Module) + expectRequiredProviderSource(t, "test-provider", rp.Source) + expectRequiredProviderVersion(t, "0.0.1", rp.Requirement.Required) + }, + }, + "resolve required provider source including string interpolation": { + module: map[string]string{ + "main.tf": ` +terraform { + required_providers { + test-provider = { + source = "${var.test-provider_src}/interpolation" + } + } +} + +variable "test-provider_src" { + type = string + const = true +} +`, + }, + vars: InputValues{ + "test-provider_src": { + Value: cty.StringVal("test"), + SourceType: ValueFromCLIArg, + }, + }, + validationFunc: func( + t *testing.T, + cfg *configs.Config, + diags tfdiags.Diagnostics, + ) { + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + rp := expectRequiredProviderInModule(t, "test-provider", cfg.Module) + expectRequiredProviderSource(t, "test/interpolation", rp.Source) + }, + }, + "resolve required provider from local": { + module: map[string]string{ + "main.tf": ` +terraform { + required_providers { + test-provider = { + source = local.provider_src + } + } +} + +variable "test-provider_src" { + type = string + const = true +} + +locals { + provider_src = "${var.test-provider_src}/local" +} +`, + }, + vars: InputValues{ + "test-provider_src": { + Value: cty.StringVal("test"), + SourceType: ValueFromCLIArg, + }, + }, + validationFunc: func( + t *testing.T, + cfg *configs.Config, + diags tfdiags.Diagnostics, + ) { + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + rp := expectRequiredProviderInModule(t, "test-provider", cfg.Module) + expectRequiredProviderSource(t, "test/local", rp.Source) + }, + }, + "expect error when non const variable is being used": { + module: map[string]string{ + "main.tf": ` +terraform { + required_providers { + test-provider = { + source = var.test-provider_src + } + } +} + +variable "test-provider_src" { + type = string + default = "nonconst" +} +`, + }, + vars: InputValues{ + "test-provider_src": { + Value: cty.StringVal("test"), + SourceType: ValueFromCLIArg, + }, + }, + validationFunc: func( + t *testing.T, + cfg *configs.Config, + diags tfdiags.Diagnostics, + ) { + if !diags.HasErrors() { + t.Fatalf("expect error when non const variable is being used") + } + if diags.Err().Error() != "Invalid provider source: The provider source contains a reference that is unknown during init." { + t.Fatalf( + "expected error msg: %s, got %s", + "Invalid provider source: The provider source contains a reference that is unknown during init.", + diags.Err().Error(), + ) + } + }, + }, + "resolve required provider when static and dynamic providers are used": { + module: map[string]string{ + "main.tf": ` +terraform { + required_providers { + dyn-provider = { + source = var.test-provider_src + version = "~> 0.0.1-dynamic" + } + static-provider = { + source = "test/static" + version = "~> 0.0.1-static" + } + } +} + +variable "test-provider_src" { + type = string + const = true +} +`, + }, + vars: InputValues{ + "test-provider_src": { + Value: cty.StringVal("test/dynamic"), + SourceType: ValueFromCLIArg, + }, + }, + validationFunc: func( + t *testing.T, + cfg *configs.Config, + diags tfdiags.Diagnostics, + ) { + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + rp := expectRequiredProviderInModule(t, "dyn-provider", cfg.Module) + expectRequiredProviderSource(t, "test/dynamic", rp.Source) + expectRequiredProviderVersion(t, "~> 0.0.1-dynamic", rp.Requirement.Required) + + rp = expectRequiredProviderInModule(t, "static-provider", cfg.Module) + expectRequiredProviderSource(t, "test/static", rp.Source) + expectRequiredProviderVersion(t, "~> 0.0.1-static", rp.Requirement.Required) + }, + }, + "resolve required provider in the child module": { + module: map[string]string{ + "main.tf": ` +variable "test-provider_src" { + type = string + const = true +} + +variable "test-provider_ver" { + type = string + const = true +} + +module "child" { + source = "./child" + provider_src = var.test-provider_src + provider_ver = var.test-provider_ver +} +`, + "child/main.tf": ` +terraform { + required_providers { + child-provider = { + source = var.provider_src + version = var.provider_ver + } + } +} + +variable "provider_src" { + type = string + const = true +} + +variable "provider_ver" { + type = string + const = true +} +`, + }, + vars: InputValues{ + "test-provider_src": { + Value: cty.StringVal("test/child"), + SourceType: ValueFromCLIArg, + }, + "test-provider_ver": { + Value: cty.StringVal("0.4.2-child"), + SourceType: ValueFromCLIArg, + }, + }, + validationFunc: func( + t *testing.T, + cfg *configs.Config, + diags tfdiags.Diagnostics, + ) { + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + childCfg, ok := cfg.Children["child"] + if !ok { + t.Fatalf("expected child module 'child' in config, not found") + } + + rp, ok := childCfg.Module.ProviderRequirements.RequiredProviders["child-provider"] + if !ok { + t.Fatal("expected provider 'child-provider' in child module requirements, not found") + } + expectRequiredProviderSource(t, "test/child", rp.Source) + expectRequiredProviderVersion(t, "0.4.2-child", rp.Requirement.Required) + }, + }, + } { + t.Run(name, func(t *testing.T) { + m, d := testModuleInlineWithVarsReturnDiags(t, tc.module, tc.vars) + if d != nil { + tc.validationFunc(t, nil, d) + return + } + + ctx := testContext2(t, &ContextOpts{Parallelism: 1}) + walker := MockModuleWalker{ + DefaultModule: testRootModuleInline( + t, + map[string]string{"main.tf": `// empty`}, + ), + } + + // Mock root module calls to children if present + if len(m.Children) > 0 { + for cn, cc := range m.Root.Children { + if child, ok := m.Children[cn]; ok { + walker.MockModuleCalls(t, map[string]*configs.Module{ + child.SourceAddrRaw: cc.Module, + }) + } + } + } + + cfg, diags := ctx.Init(m.Root.Module, InitOpts{ + SetVariables: tc.vars, + Walker: &walker, + }) + + tc.validationFunc(t, cfg, diags) + }) + } +} + +func expectRequiredProviderInModule( + t *testing.T, + expect string, + module *configs.Module, +) *configs.RequiredProvider { + if module.ProviderRequirements == nil { + t.Fatal("no provider requirements were set") + } + + rp, ok := module.ProviderRequirements.RequiredProviders[expect] + if !ok { + t.Fatalf("required provider %q not found in config", expect) + } + + return rp +} + +func expectRequiredProviderSource(t *testing.T, expect, actual string) { + if expect != actual { + t.Fatalf( + "expected required provider source to be '%s', got '%s'", + expect, + actual, + ) + } +} + +func expectRequiredProviderVersion( + t *testing.T, + expect string, + actual version.Constraints, +) { + if expect == "" && actual == nil { + return + } + + if expect == "" { + t.Fatal("expected required provider version NOT to be set") + } + + if expect != actual.String() { + t.Fatalf( + "expected required provider version to be '%s', got '%s'", + expect, + actual, + ) + } +} diff --git a/internal/terraform/context_init_test.go b/internal/terraform/context_init_test.go index 1e9dc5136069..46ce2fdec590 100644 --- a/internal/terraform/context_init_test.go +++ b/internal/terraform/context_init_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - version "github.com/hashicorp/go-version" + "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" diff --git a/internal/terraform/graph_builder_init.go b/internal/terraform/graph_builder_init.go index bf22bec4c330..644654ea1c0c 100644 --- a/internal/terraform/graph_builder_init.go +++ b/internal/terraform/graph_builder_init.go @@ -54,6 +54,10 @@ func (b *InitGraphBuilder) Steps() []GraphTransformer { Walker: b.Walker, }, + &ProviderRequirementExprTransformer{ + Config: b.Config, + }, + &LocalTransformer{ Config: b.Config, }, @@ -66,6 +70,8 @@ func (b *InitGraphBuilder) Steps() []GraphTransformer { switch n := v.(type) { case *nodeInstallModule: return true + case *nodeResolveProviderRequirements: + return true case *NodeRootVariable: return n.Config.Const case *nodeExpandModuleVariable: diff --git a/internal/terraform/node_resolve_provider_requirement.go b/internal/terraform/node_resolve_provider_requirement.go new file mode 100644 index 000000000000..0ac237d68bee --- /dev/null +++ b/internal/terraform/node_resolve_provider_requirement.go @@ -0,0 +1,250 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "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/lang/langrefs" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +type nodeResolveProviderRequirements struct { + Addr addrs.ModuleInstance + Module *configs.Module + Exprs map[string]*configs.ProviderRequirementExpr +} + +var ( + _ GraphNodeExecutable = (*nodeResolveProviderRequirements)(nil) + _ GraphNodeReferencer = (*nodeResolveProviderRequirements)(nil) + _ GraphNodeModuleInstance = (*nodeResolveProviderRequirements)(nil) +) + +func (n *nodeResolveProviderRequirements) Execute( + ctx EvalContext, + _ walkOperation, +) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + for name, expr := range n.Exprs { + rp, rpDiags := n.resolveProvider(name, expr, ctx) + diags = append(diags, rpDiags...) + if rpDiags.HasErrors() { + continue + } + + n.Module.ProviderRequirements.RequiredProviders[name] = rp + } + + n.Module.GatherProviderLocalNames() + + return diags +} + +func (n *nodeResolveProviderRequirements) resolveProvider( + name string, + expr *configs.ProviderRequirementExpr, + ctx EvalContext, +) (*configs.RequiredProvider, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + rp := &configs.RequiredProvider{ + Name: name, + Aliases: expr.ConfigAliases, + DeclRange: expr.DeclRange, + } + + if expr.SourceExpr != nil { + sourceStr, sourceType, sourceDiags := + evalProviderSource(expr.SourceExpr, ctx) + diags = diags.Append(sourceDiags) + if sourceDiags.HasErrors() { + return nil, diags + } + rp.Source = sourceStr + rp.Type = sourceType + } else { // Regular string parsing (no vars) + pType, err := addrs.ParseProviderPart(name) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid provider name", + err.Error(), + )) + return nil, diags + } + rp.Type = addrs.ImpliedProviderForUnqualifiedType(pType) + } + + if expr.VersionExpr != nil { + vc, vcDiags := evalProviderVersion(expr.VersionExpr, ctx) + diags = diags.Append(vcDiags) + if vcDiags.HasErrors() { + return nil, diags + } + rp.Requirement = vc + } + + return rp, diags +} + +func evalProviderSource( + sourceExpr hcl.Expression, + ctx EvalContext, +) (string, addrs.Provider, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + refs, refsDiags := langrefs.ReferencesInExpr(addrs.ParseRef, sourceExpr) + diags = diags.Append(refsDiags) + if diags.HasErrors() { + return "", addrs.Provider{}, diags + } + + for _, ref := range refs { + // Limit references to vars, locals + switch ref.Subject.(type) { + case addrs.InputVariable, addrs.LocalValue: + // Allowed + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid provider source", + Detail: "The provider source can only reference constant " + + "input variables and local values." + constVariableDetail, + Subject: ref.SourceRange.ToHCL().Ptr(), + }) + return "", addrs.Provider{}, diags + } + } + + value, valueDiags := ctx.EvaluateExpr(sourceExpr, cty.String, nil) + diags = diags.Append(valueDiags) + if diags.HasErrors() { + return "", addrs.Provider{}, diags + } + + if !value.IsWhollyKnown() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid provider source", + Detail: "The provider source contains a reference that is " + + "unknown during init.", + Subject: sourceExpr.Range().Ptr(), + }) + return "", addrs.Provider{}, diags + } + + sourceStr := value.AsString() + fqn, sourceDiags := addrs.ParseProviderSourceString(sourceStr) + if sourceDiags.HasErrors() { + hclDiags := sourceDiags.ToHCL() + for _, d := range hclDiags { + if d.Subject == nil { + d.Subject = sourceExpr.Range().Ptr() + } + } + diags = diags.Append(hclDiags) + return "", addrs.Provider{}, diags + } + + return sourceStr, fqn, diags +} + +func evalProviderVersion( + versionExpr hcl.Expression, + ctx EvalContext, +) (configs.VersionConstraint, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + ret := configs.VersionConstraint{ + DeclRange: versionExpr.Range(), + } + + 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: + // Allowed + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid provider version", + Detail: "The provider version can only reference constant " + + "input variables and local values." + constVariableDetail, + 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.IsWhollyKnown() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid provider version", + Detail: "The provider version contains a reference that is " + + "unknown during init.", + Subject: versionExpr.Range().Ptr(), + }) + return ret, diags + } + + constraintStr := value.AsString() + constraints, err := version.NewConstraint(constraintStr) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid version constraint", + Detail: "This string is not a valid version constraint.", + Subject: versionExpr.Range().Ptr(), + }) + return ret, diags + } + + ret.Required = constraints + return ret, diags +} + +func (n *nodeResolveProviderRequirements) References() []*addrs.Reference { + var refs []*addrs.Reference + for _, expr := range n.Exprs { + if expr.SourceExpr != nil { + sourceRefs, _ := langrefs.ReferencesInExpr( + addrs.ParseRef, expr.SourceExpr, + ) + refs = append(refs, sourceRefs...) + } + + if expr.VersionExpr != nil { + versionRefs, _ := langrefs.ReferencesInExpr( + addrs.ParseRef, expr.VersionExpr, + ) + refs = append(refs, versionRefs...) + } + } + + return refs +} + +func (n *nodeResolveProviderRequirements) Path() addrs.ModuleInstance { + return n.Addr +} + +func (n *nodeResolveProviderRequirements) ModulePath() addrs.Module { + return n.Addr.Module() +} diff --git a/internal/terraform/node_resolve_provider_requirement_test.go b/internal/terraform/node_resolve_provider_requirement_test.go new file mode 100644 index 000000000000..e960e0d1be95 --- /dev/null +++ b/internal/terraform/node_resolve_provider_requirement_test.go @@ -0,0 +1,305 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +func TestNodeResolveProviderRequirements_References(t *testing.T) { + for name, tc := range map[string]struct { + nodeExprs map[string]*configs.ProviderRequirementExpr + validationFunc func(t *testing.T, r []*addrs.Reference) + }{ + "No references": { + nodeExprs: map[string]*configs.ProviderRequirementExpr{ + "testProvider": {}, + }, + validationFunc: func(t *testing.T, r []*addrs.Reference) { + if len(r) != 0 { + t.Fatalf("got %d references, want 0", len(r)) + } + }, + }, + "Resolve references for version": { + nodeExprs: map[string]*configs.ProviderRequirementExpr{ + "testProvider": { + VersionExpr: testMockExprWith("var.some_version"), + }, + }, + validationFunc: func(t *testing.T, r []*addrs.Reference) { + if len(r) != 1 { + t.Fatalf("got %d references, expected 1", len(r)) + } + if r[0].Subject.String() != "var.some_version" { + t.Errorf( + "got %s, expected var.some_version", + r[0].Subject, + ) + } + }, + }, + "Resolve references for source": { + nodeExprs: map[string]*configs.ProviderRequirementExpr{ + "testProvider": { + SourceExpr: testMockExprWith("var.some_source"), + }, + }, + validationFunc: func(t *testing.T, r []*addrs.Reference) { + if len(r) != 1 { + t.Fatalf("got %d references, expected 1", len(r)) + } + if r[0].Subject.String() != "var.some_source" { + t.Errorf( + "got %s, expected var.some_source", + r[0].Subject, + ) + } + }, + }, + "Resolve all references for multiple providers": { + nodeExprs: map[string]*configs.ProviderRequirementExpr{ + "testProvider_1": { + VersionExpr: testMockExprWith("var.version_1"), + SourceExpr: testMockExprWith("var.source_1"), + }, + "testProvider_2": { + VersionExpr: testMockExprWith("var.version_2"), + SourceExpr: testMockExprWith("var.source_2"), + }, + }, + validationFunc: func(t *testing.T, r []*addrs.Reference) { + if len(r) != 4 { + t.Fatalf("got %d references, expected 4", len(r)) + } + + expected := []*addrs.Reference{ + mustReference("var.source_1"), + mustReference("var.source_2"), + mustReference("var.version_1"), + mustReference("var.version_2"), + } + + if eq := cmp.Equal(r, expected, + cmpopts.SortSlices(func(a, b *addrs.Reference) bool { + return a.Subject.String() < b.Subject.String() + }), + cmp.Comparer(func(a, b *addrs.Reference) bool { + return a.Subject.String() == b.Subject.String() + }), + ); !eq { + t.Fatalf( + "references not equal\n got: %v\nwant: %v", + r, + expected, + ) + } + }, + }, + } { + t.Run(name, func(t *testing.T) { + mod := testModule(t, "empty") + n := nodeResolveProviderRequirements{ + Module: mod.Module, + Exprs: tc.nodeExprs, + } + + r := n.References() + + tc.validationFunc(t, r) + }) + } +} + +func testMockExprWith(variable string) mockHCLExpression { + varChunks := strings.Split(variable, ".") + if len(varChunks) != 2 { + panic("variable has to consist of two parts separated by '.'" + + "\nex: var.test_var") + } + return mockHCLExpression{ + variablesFunc: func() []hcl.Traversal { + return []hcl.Traversal{ + { + hcl.TraverseRoot{ + Name: varChunks[0], + }, + hcl.TraverseAttr{ + Name: varChunks[1], + }, + }, + } + }, + } +} + +func TestNodeResolveProviderRequirements_Execute(t *testing.T) { + for name, tc := range map[string]struct { + ctx EvalContext + module *configs.Module + nodeExprs map[string]*configs.ProviderRequirementExpr + validationFunc func( + t *testing.T, + n nodeResolveProviderRequirements, + diags tfdiags.Diagnostics, + ) + }{ + "Resolve root required providers successfully": { + ctx: &MockEvalContext{}, + module: testRequiredProvidersConfig(t).Module, + nodeExprs: map[string]*configs.ProviderRequirementExpr{}, + validationFunc: func( + t *testing.T, + n nodeResolveProviderRequirements, + diags tfdiags.Diagnostics, + ) { + if diags.HasErrors() { + t.Fatalf("got errors, expected none: %v", diags) + } + providers := n.Module.ProviderRequirements.RequiredProviders + if len(providers) != 1 { + t.Fatalf("got %d providers, expected 1", len(providers)) + } + + rp, ok := providers["testprovider"] + if !ok { + t.Fatalf("provider testprovider not found") + } + if rp.Requirement.Required.String() != "0.0.7-james" { + t.Errorf("got %s, expected 0.0.7-james", rp.Requirement.Required) + } + }, + }, + "Resolve children required providers successfully": { + ctx: &MockEvalContext{}, + module: testRequiredProvidersConfig(t).Children["child"].Module, + nodeExprs: map[string]*configs.ProviderRequirementExpr{}, + validationFunc: func( + t *testing.T, + n nodeResolveProviderRequirements, + diags tfdiags.Diagnostics, + ) { + if diags.HasErrors() { + t.Fatalf("got errors, expected none: %v", diags) + } + providers := n.Module.ProviderRequirements.RequiredProviders + if len(providers) != 1 { + t.Fatalf("got %d providers, expected 1", len(providers)) + } + + rp, ok := providers["testprovider"] + if !ok { + t.Fatalf("provider testprovider not found") + } + if rp.Requirement.Required.String() != "0.0.8-bill" { + t.Errorf("got %s, expected 0.0.8-bill", rp.Requirement.Required) + } + }, + }, + } { + t.Run(name, func(t *testing.T) { + n := nodeResolveProviderRequirements{ + Module: tc.module, + Exprs: tc.nodeExprs, + } + + diags := n.Execute(tc.ctx, walkInit) + + tc.validationFunc(t, n, diags) + }) + } +} + +func testRequiredProvidersConfig(t *testing.T) *configs.Config { + return testModuleInlineWithVars(t, + map[string]string{ + "main.tf": ` +terraform { + required_providers { + testprovider = { + source = "${var.testprovider_src}" + version = "${var.testprovider_ver}" + } + } +} + +variable "testprovider_src" { + type = string + const = true +} + +variable "testprovider_ver" { + type = string + const = true +} + +module "child" { + source = "./local_module" + testprovider_src = var.testprovider_src + testprovider_ver = "0.0.8-bill" +} +`, + "local_module/main.tf": ` +terraform { + required_providers { + testprovider = { + source = "${var.testprovider_src}" + version = "${var.testprovider_ver}" + } + } +} + +variable "testprovider_src" { + type = string + const = true +} + +variable "testprovider_ver" { + type = string + const = true +} +`}, + map[string]*InputValue{ + "testprovider_src": { + Value: cty.StringVal("hashicorp/testprovider"), + }, + "testprovider_ver": { + Value: cty.StringVal("0.0.7-james"), + }, + }) +} + +type mockHCLExpression struct { + rangeFunc func() hcl.Range + startRangeFunc func() hcl.Range + variablesFunc func() []hcl.Traversal + valuesFunc func(ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) +} + +func (e mockHCLExpression) Range() hcl.Range { + return e.rangeFunc() +} + +func (e mockHCLExpression) StartRange() hcl.Range { + return e.startRangeFunc() +} + +func (e mockHCLExpression) Variables() []hcl.Traversal { + return e.variablesFunc() +} + +func (e mockHCLExpression) Value( + ctx *hcl.EvalContext, +) (cty.Value, hcl.Diagnostics) { + return e.valuesFunc(ctx) +} diff --git a/internal/terraform/terraform_test.go b/internal/terraform/terraform_test.go index 3eab0c60d804..0d17888b810d 100644 --- a/internal/terraform/terraform_test.go +++ b/internal/terraform/terraform_test.go @@ -125,8 +125,25 @@ func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...con } // testModuleInlineWithVars is the same as testModuleInline but also allows passing in variable values to be used when loading the config. -func testModuleInlineWithVars(t testing.TB, sources map[string]string, vars InputValues, parserOpts ...configs.Option) *configs.Config { +func testModuleInlineWithVars( + t testing.TB, + sources map[string]string, + vars InputValues, + parserOpts ...configs.Option, +) *configs.Config { + config, diags := testModuleInlineWithVarsReturnDiags(t, sources, vars, parserOpts...) + if diags != nil && diags.HasErrors() { + t.Fatal(diags.Err()) + } + return config +} +func testModuleInlineWithVarsReturnDiags( + t testing.TB, + sources map[string]string, + vars InputValues, + parserOpts ...configs.Option, +) (*configs.Config, tfdiags.Diagnostics) { t.Helper() cfgPath, err := filepath.EvalSymlinks(t.TempDir()) @@ -167,7 +184,7 @@ func testModuleInlineWithVars(t testing.TB, sources map[string]string, vars Inpu inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { - t.Fatal(instDiags.Err()) + return nil, instDiags } // Since module installer has modified the module manifest on disk, we need @@ -178,7 +195,7 @@ func testModuleInlineWithVars(t testing.TB, sources map[string]string, vars Inpu rootMod, hclDiags := loader.LoadRootModuleWithTests(cfgPath, "tests") if hclDiags.HasErrors() { - t.Fatal(hclDiags.Error()) + return nil, tfdiags.Diagnostics{}.Append(hclDiags) } config, buildDiags := BuildConfigWithGraph( @@ -188,10 +205,10 @@ func testModuleInlineWithVars(t testing.TB, sources map[string]string, vars Inpu configs.MockDataLoaderFunc(loader.LoadExternalMockData), ) if buildDiags.HasErrors() { - t.Fatal(buildDiags.Err()) + return nil, buildDiags } - return config + return config, nil } func testRootModuleInline(t testing.TB, sources map[string]string) *configs.Module { diff --git a/internal/terraform/transform_provider_requirements.go b/internal/terraform/transform_provider_requirements.go new file mode 100644 index 000000000000..f357c91483ec --- /dev/null +++ b/internal/terraform/transform_provider_requirements.go @@ -0,0 +1,28 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import "github.com/hashicorp/terraform/internal/configs" + +type ProviderRequirementExprTransformer struct { + Config *configs.Config +} + +var _ GraphTransformer = (*ProviderRequirementExprTransformer)(nil) + +func (t *ProviderRequirementExprTransformer) Transform(g *Graph) error { + if len(t.Config.Module.ProviderRequirementExprs) == 0 { + return nil + } + + node := &nodeResolveProviderRequirements{ + Addr: g.Path, + Module: t.Config.Module, + Exprs: t.Config.Module.ProviderRequirementExprs, + } + + g.Add(node) + + return nil +}