feat: allow calling functions from unconfigured providers when no resources are referenced (#3118)

Signed-off-by: Diogenes Fernandes <diofeher@gmail.com>
Signed-off-by: Diógenes Fernandes <diofeher@gmail.com>
Co-authored-by: Andrei Ciobanu <andrei.ciobanu@opentofu.org>
This commit is contained in:
Diógenes Fernandes
2025-08-22 14:50:17 -03:00
committed by GitHub
parent 8dc7aa2e24
commit dfc1a4e948
8 changed files with 189 additions and 2 deletions

View File

@@ -53,6 +53,47 @@ func TestFunction_Simple(t *testing.T) {
}
}
func TestFunction_ProviderDefinedFunctionWithoutConfigure(t *testing.T) {
// This test reaches out to registry.opentofu.org to download the
// test functions provider, so it can only run if network access is allowed
skipIfCannotAccessNetwork(t)
fixturePath := filepath.Join("testdata", "functions_aws")
tf := e2e.NewBinary(t, tofuBin, fixturePath)
// tofu init
_, stderr, err := tf.Run("init")
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if stderr != "" {
t.Errorf("unexpected stderr output:\n%s", stderr)
}
_, stderr, err = tf.Run("plan", "-out=fnplan")
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if stderr != "" {
t.Fatalf("unexpected stderr output:\n%s", stderr)
}
plan, err := tf.Plan("fnplan")
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if len(plan.Changes.Outputs) != 1 {
t.Fatalf("expected 1 outputs, got %d", len(plan.Changes.Outputs))
}
for _, out := range plan.Changes.Outputs {
if !strings.Contains(string(out.After), "arn:aws:s3:::bucket-prod") {
t.Fatalf("unexpected plan output: %s", string(out.After))
}
}
}
func TestFunction_Error(t *testing.T) {
// This test reaches out to registry.opentofu.org to download the
// test functions provider, so it can only run if network access is allowed

View File

@@ -0,0 +1,17 @@
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.9.0"
}
}
}
variable "bucket_name" {
type = string
default = "bucket-prod"
}
output "stuff" {
value = provider::aws::arn_build("aws", "s3", "", "", var.bucket_name)
}

View File

@@ -156,6 +156,11 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer {
// analyze the configuration to find references.
&AttachSchemaTransformer{Plugins: b.Plugins, Config: b.Config},
// Replace providers that have no config or dependencies to
// NodeEvalableProvider. This allows using provider-defined functions
// even when the provider isn't configured.
&ProviderUnconfiguredTransformer{},
// After schema transformer, we can add function references
&ProviderFunctionTransformer{Config: b.Config, ProviderFunctionTracker: b.ProviderFunctionTracker},

View File

@@ -93,6 +93,11 @@ func (b *EvalGraphBuilder) Steps() []GraphTransformer {
// analyze the configuration to find references.
&AttachSchemaTransformer{Plugins: b.Plugins, Config: b.Config},
// Replace providers that have no config or dependencies to
// NodeEvalableProvider. This allows using provider-defined functions
// even when the provider isn't configured.
&ProviderUnconfiguredTransformer{},
// After schema transformer, we can add function references
&ProviderFunctionTransformer{Config: b.Config, ProviderFunctionTracker: b.ProviderFunctionTracker},

View File

@@ -217,6 +217,11 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer {
// analyze the configuration to find references.
&AttachSchemaTransformer{Plugins: b.Plugins, Config: b.Config},
// Replace providers that have no config or dependencies to
// NodeEvalableProvider. This allows using provider-defined functions
// even when the provider isn't configured.
&ProviderUnconfiguredTransformer{},
// After schema transformer, we can add function references
&ProviderFunctionTransformer{Config: b.Config, ProviderFunctionTracker: b.ProviderFunctionTracker},

View File

@@ -47,7 +47,7 @@ const traceAttrProviderConfigAddr = "opentofu.provider_config.address"
// together uniquely identify the provider instance.
const traceAttrProviderInstanceAddr = "opentofu.provider_instance.address"
// NodeApplyableProvider represents a provider during an apply.
// NodeApplyableProvider represents a configured provider.
type NodeApplyableProvider struct {
*NodeAbstractProvider
}

View File

@@ -286,6 +286,39 @@ func (m ProviderFunctionMapping) Lookup(module addrs.Module, pf addrs.ProviderFu
return providedBy, ok
}
// ProviderUnconfiguredTransformer converts NodeApplyableProvider nodes to NodeEvalableProvider
// nodes so provider's functions can be used without configuration.
type ProviderUnconfiguredTransformer struct{}
func (t *ProviderUnconfiguredTransformer) Transform(_ context.Context, g *Graph) error {
// Locate all providerVerts in the graph
providerVerts := providerVertexMap(g)
// Iterate through the providers to identify their dependencies (edges). If a provider
// lacks both references and configuration, use a NodeEvalableProvider.
for _, p := range providerVerts {
applyableProvider, ok := p.(*NodeApplyableProvider)
// There are three conditions to skip the conversion
// from NodeApplyableProvider to NodeEvalableProvider:
// 1. The node is not an NodeApplyableProvider
// 2. The provider has existing configuration
// 3. The provider node is referenced by another node
edges := append(g.EdgesFrom(applyableProvider), g.EdgesTo(applyableProvider)...)
if !ok || applyableProvider.Config != nil || len(edges) > 0 {
continue
}
pAddr := applyableProvider.ProviderAddr()
log.Printf("[TRACE] ProviderFunctionTransformer: replacing NodeApplyableProvider with NodeEvalableProvider for %s since it's missing configuration and there are no consumers of it", pAddr)
unconfiguredProvider := &NodeEvalableProvider{
&NodeAbstractProvider{
Addr: pAddr,
},
}
g.Replace(applyableProvider, unconfiguredProvider)
}
return nil
}
// ProviderFunctionTransformer is a GraphTransformer that maps nodes which reference functions to providers
// within the graph. This will error if there are any provider functions that don't map to known providers.
type ProviderFunctionTransformer struct {
@@ -374,6 +407,7 @@ func (t *ProviderFunctionTransformer) Transform(_ context.Context, g *Graph) err
} else {
// If this provider doesn't exist, stub it out with an init-only provider node
// This works for unconfigured functions only, but that validation is elsewhere
log.Printf("[TRACE] ProviderFunctionTransformer: creating init-only node for %s", absPc)
stubAddr := addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: absPc.Provider,

View File

@@ -10,9 +10,12 @@ import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/configs"
"github.com/opentofu/opentofu/internal/dag"
"github.com/zclconf/go-cty/cty"
)
func testProviderTransformerGraph(t *testing.T, cfg *configs.Config) *Graph {
@@ -48,8 +51,13 @@ func testTransformProviders(concrete ConcreteProviderNodeFunc, config *configs.C
&ProviderTransformer{
Config: config,
},
// Replace providers that have no config or dependencies to
// NodeEvalableProvider. This allows using provider-defined functions
// even when the provider isn't configured.
&ProviderUnconfiguredTransformer{},
// After schema transformer, we can add function references
// &ProviderFunctionTransformer{Config: config},
&ProviderFunctionTransformer{Config: config, ProviderFunctionTracker: ProviderFunctionMapping{}},
// Remove unused providers and proxies
&PruneProviderTransformer{},
)
@@ -489,6 +497,78 @@ provider "test" {
}
}
// TestProviderFunctionTransformer_onlyFunctions tests that the
// ProviderFunctionTransformer is removing NodeApplyableProvider
// and adding a NodeEvalableProvider in its place instead.
// This is useful so we can call functions without needing to
// configure the provider.
func TestProviderFunctionTransformer_onlyFunctions(t *testing.T) {
mod := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
required_providers {
aws = {}
}
}
output "output_test" {
value = provider::aws::arn_build("aws", "s3", "", "", "test")
}
`})
concrete := func(a *NodeAbstractProvider) dag.Vertex {
return &NodeApplyableProvider{
a,
}
}
g := testProviderTransformerGraph(t, mod)
// Create a reference to the output
outputRef := &NodeApplyableOutput{
Addr: addrs.AbsOutputValue{
Module: addrs.RootModuleInstance,
OutputValue: addrs.OutputValue{
Name: "output_test",
},
},
Config: &configs.Output{
Name: "output_test",
Expr: &hclsyntax.FunctionCallExpr{
Name: "provider::aws::arn_build",
Args: []hclsyntax.Expression{
&hclsyntax.LiteralValueExpr{
Val: cty.StringVal("aws"),
},
},
},
},
}
g.Add(outputRef)
tf := testTransformProviders(concrete, mod)
if err := tf.Transform(t.Context(), g); err != nil {
t.Fatalf("err: %s", err)
}
expected := `output.output_test
provider["registry.opentofu.org/hashicorp/aws"]
provider["registry.opentofu.org/hashicorp/aws"]`
actual := strings.TrimSpace(g.String())
if diff := cmp.Diff(actual, expected); diff != "" {
t.Fatalf("expected: %s", diff)
}
edges := g.EdgesFrom(outputRef)
if len(edges) != 1 {
t.Fatalf("expecting 1 edge, got %d", len(edges))
}
edge := edges[0]
if _, ok := edge.Target().(*NodeEvalableProvider); !ok {
t.Fatalf("expecting NodeEvalableProvider provider, got %T", edge.Target())
}
}
const testTransformProviderBasicStr = `
aws_instance.web
provider["registry.opentofu.org/hashicorp/aws"]