diff --git a/internal/command/e2etest/provider_functions_test.go b/internal/command/e2etest/provider_functions_test.go index 1005fb9efe..533edcf572 100644 --- a/internal/command/e2etest/provider_functions_test.go +++ b/internal/command/e2etest/provider_functions_test.go @@ -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 diff --git a/internal/command/e2etest/testdata/functions_aws/main.tf b/internal/command/e2etest/testdata/functions_aws/main.tf new file mode 100644 index 0000000000..0303d09947 --- /dev/null +++ b/internal/command/e2etest/testdata/functions_aws/main.tf @@ -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) +} diff --git a/internal/tofu/graph_builder_apply.go b/internal/tofu/graph_builder_apply.go index 68026737c8..7145e343e5 100644 --- a/internal/tofu/graph_builder_apply.go +++ b/internal/tofu/graph_builder_apply.go @@ -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}, diff --git a/internal/tofu/graph_builder_eval.go b/internal/tofu/graph_builder_eval.go index 2692de92f9..df81b4c003 100644 --- a/internal/tofu/graph_builder_eval.go +++ b/internal/tofu/graph_builder_eval.go @@ -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}, diff --git a/internal/tofu/graph_builder_plan.go b/internal/tofu/graph_builder_plan.go index d4626a5df3..6628cc561e 100644 --- a/internal/tofu/graph_builder_plan.go +++ b/internal/tofu/graph_builder_plan.go @@ -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}, diff --git a/internal/tofu/node_provider.go b/internal/tofu/node_provider.go index 47dde89bb0..f8ada62ac9 100644 --- a/internal/tofu/node_provider.go +++ b/internal/tofu/node_provider.go @@ -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 } diff --git a/internal/tofu/transform_provider.go b/internal/tofu/transform_provider.go index dfdf41962c..b82a4a4bce 100644 --- a/internal/tofu/transform_provider.go +++ b/internal/tofu/transform_provider.go @@ -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, diff --git a/internal/tofu/transform_provider_test.go b/internal/tofu/transform_provider_test.go index 68cd7f0029..e729227b74 100644 --- a/internal/tofu/transform_provider_test.go +++ b/internal/tofu/transform_provider_test.go @@ -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"]