From 870bf99593138c0c53bec5c7144db2253c7de961 Mon Sep 17 00:00:00 2001 From: Samsondeen <40821565+dsa0x@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:19:26 +0200 Subject: [PATCH] List implementation for simple provider and e2e tests (#37297) --- .../command/e2etest/terraform_query_test.go | 164 ++++++++++++++++++ .../e2etest/testdata/query-provider/main.tf | 10 ++ .../testdata/query-provider/main.tfquery.hcl | 7 + internal/command/e2etest/unmanaged_test.go | 32 ++++ internal/e2e/e2e.go | 18 +- internal/grpcwrap/provider.go | 6 +- internal/grpcwrap/provider6.go | 6 +- internal/provider-simple-v6/provider.go | 53 +++++- internal/provider-simple/provider.go | 53 +++++- internal/terraform/context_plan_query_test.go | 1 - 10 files changed, 330 insertions(+), 20 deletions(-) create mode 100644 internal/command/e2etest/terraform_query_test.go create mode 100644 internal/command/e2etest/testdata/query-provider/main.tf create mode 100644 internal/command/e2etest/testdata/query-provider/main.tfquery.hcl diff --git a/internal/command/e2etest/terraform_query_test.go b/internal/command/e2etest/terraform_query_test.go new file mode 100644 index 0000000000..4004306131 --- /dev/null +++ b/internal/command/e2etest/terraform_query_test.go @@ -0,0 +1,164 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package e2etest + +import ( + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/go-plugin" + "github.com/hashicorp/terraform/internal/e2e" + "github.com/hashicorp/terraform/internal/grpcwrap" + tfplugin5 "github.com/hashicorp/terraform/internal/plugin" + tfplugin "github.com/hashicorp/terraform/internal/plugin6" + simple "github.com/hashicorp/terraform/internal/provider-simple-v6" + proto5 "github.com/hashicorp/terraform/internal/tfplugin5" + proto "github.com/hashicorp/terraform/internal/tfplugin6" +) + +func TestUnmanagedQuery(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + protocolVersion int + }{ + { + name: "proto6", + protocolVersion: 6, + }, + { + name: "proto5", + protocolVersion: 5, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + os.Setenv(e2e.TestExperimentFlag, "true") + terraformBin := e2e.GoBuild("github.com/hashicorp/terraform", "terraform") + + fixturePath := filepath.Join("testdata", "query-provider") + tf := e2e.NewBinary(t, terraformBin, fixturePath) + + reattachCh := make(chan *plugin.ReattachConfig) + closeCh := make(chan struct{}) + + var provider interface { + ListResourceCalled() bool + } + var versionedPlugins map[int]plugin.PluginSet + + // Configure provider and plugins based on protocol version + if tc.protocolVersion == 6 { + provider6 := &providerServer{ + ProviderServer: grpcwrap.Provider6(simple.Provider()), + } + provider = provider6 + versionedPlugins = map[int]plugin.PluginSet{ + 6: { + "provider": &tfplugin.GRPCProviderPlugin{ + GRPCProvider: func() proto.ProviderServer { + return provider6 + }, + }, + }, + } + } else { + provider5 := &providerServer5{ + ProviderServer: grpcwrap.Provider(simple.Provider()), + } + provider = provider5 + versionedPlugins = map[int]plugin.PluginSet{ + 5: { + "provider": &tfplugin5.GRPCProviderPlugin{ + GRPCProvider: func() proto5.ProviderServer { + return provider5 + }, + }, + }, + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go plugin.Serve(&plugin.ServeConfig{ + Logger: hclog.New(&hclog.LoggerOptions{ + Name: "plugintest", + Level: hclog.Trace, + Output: io.Discard, + }), + Test: &plugin.ServeTestConfig{ + Context: ctx, + ReattachConfigCh: reattachCh, + CloseCh: closeCh, + }, + GRPCServer: plugin.DefaultGRPCServer, + VersionedPlugins: versionedPlugins, + }) + config := <-reattachCh + if config == nil { + t.Fatalf("no reattach config received") + } + reattachStr, err := json.Marshal(map[string]reattachConfig{ + "hashicorp/test": { + Protocol: string(config.Protocol), + ProtocolVersion: tc.protocolVersion, + Pid: config.Pid, + Test: true, + Addr: reattachConfigAddr{ + Network: config.Addr.Network(), + String: config.Addr.String(), + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + tf.AddEnv("TF_REATTACH_PROVIDERS=" + string(reattachStr)) + + //// INIT + stdout, stderr, err := tf.Run("init") + if err != nil { + t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) + } + + // Make sure we didn't download the binary + if strings.Contains(stdout, "Installing hashicorp/test v") { + t.Errorf("test provider download message is present in init output:\n%s", stdout) + } + if tf.FileExists(filepath.Join(".terraform", "plugins", "registry.terraform.io", "hashicorp", "test")) { + t.Errorf("test provider binary found in .terraform dir") + } + + //// QUERY + stdout, stderr, err = tf.Run("query") + if err != nil { + t.Fatalf("unexpected query error: %s\nstderr:\n%s", err, stderr) + } + + if !provider.ListResourceCalled() { + t.Error("ListResource not called on un-managed provider") + } + + // The output should contain the expected resource data. (using regex so that the number of whitespace characters doesn't matter) + regex := regexp.MustCompile(`(?m)^list\.simple_resource\.test\s+id=static_id\s+static_display_name$`) + if !regex.MatchString(stdout) { + t.Errorf("expected resource data not found in output:\n%s", stdout) + } + + cancel() + <-closeCh + }) + } +} diff --git a/internal/command/e2etest/testdata/query-provider/main.tf b/internal/command/e2etest/testdata/query-provider/main.tf new file mode 100644 index 0000000000..a4de134c84 --- /dev/null +++ b/internal/command/e2etest/testdata/query-provider/main.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + simple = { + source = "hashicorp/test" + } + } +} + +resource "simple_resource" "test" { +} diff --git a/internal/command/e2etest/testdata/query-provider/main.tfquery.hcl b/internal/command/e2etest/testdata/query-provider/main.tfquery.hcl new file mode 100644 index 0000000000..ff82eec11d --- /dev/null +++ b/internal/command/e2etest/testdata/query-provider/main.tfquery.hcl @@ -0,0 +1,7 @@ +list "simple_resource" "test" { + provider = simple + include_resource = true + config { + value = "dynamic_value" + } +} \ No newline at end of file diff --git a/internal/command/e2etest/unmanaged_test.go b/internal/command/e2etest/unmanaged_test.go index e3e2335f71..4960eda4ec 100644 --- a/internal/command/e2etest/unmanaged_test.go +++ b/internal/command/e2etest/unmanaged_test.go @@ -52,6 +52,7 @@ type providerServer struct { proto.ProviderServer planResourceChangeCalled bool applyResourceChangeCalled bool + listResourceCalled bool } func (p *providerServer) PlanResourceChange(ctx context.Context, req *proto.PlanResourceChange_Request) (*proto.PlanResourceChange_Response, error) { @@ -70,6 +71,14 @@ func (p *providerServer) ApplyResourceChange(ctx context.Context, req *proto.App return p.ProviderServer.ApplyResourceChange(ctx, req) } +func (p *providerServer) ListResource(req *proto.ListResource_Request, res proto.Provider_ListResourceServer) error { + p.Lock() + defer p.Unlock() + + p.listResourceCalled = true + return p.ProviderServer.ListResource(req, res) +} + func (p *providerServer) PlanResourceChangeCalled() bool { p.Lock() defer p.Unlock() @@ -96,11 +105,19 @@ func (p *providerServer) ResetApplyResourceChangeCalled() { p.applyResourceChangeCalled = false } +func (p *providerServer) ListResourceCalled() bool { + p.Lock() + defer p.Unlock() + + return p.listResourceCalled +} + type providerServer5 struct { sync.Mutex proto5.ProviderServer planResourceChangeCalled bool applyResourceChangeCalled bool + listResourceCalled bool } func (p *providerServer5) PlanResourceChange(ctx context.Context, req *proto5.PlanResourceChange_Request) (*proto5.PlanResourceChange_Response, error) { @@ -120,6 +137,14 @@ func (p *providerServer5) ApplyResourceChange(ctx context.Context, req *proto5.A return p.ProviderServer.ApplyResourceChange(ctx, req) } +func (p *providerServer5) ListResource(req *proto5.ListResource_Request, res proto5.Provider_ListResourceServer) error { + p.Lock() + defer p.Unlock() + + p.listResourceCalled = true + return p.ProviderServer.ListResource(req, res) +} + func (p *providerServer5) PlanResourceChangeCalled() bool { p.Lock() defer p.Unlock() @@ -146,6 +171,13 @@ func (p *providerServer5) ResetApplyResourceChangeCalled() { p.applyResourceChangeCalled = false } +func (p *providerServer5) ListResourceCalled() bool { + p.Lock() + defer p.Unlock() + + return p.listResourceCalled +} + func TestUnmanagedSeparatePlan(t *testing.T) { t.Parallel() diff --git a/internal/e2e/e2e.go b/internal/e2e/e2e.go index 77cc86a129..da63229b7a 100644 --- a/internal/e2e/e2e.go +++ b/internal/e2e/e2e.go @@ -19,6 +19,13 @@ import ( "github.com/hashicorp/terraform/internal/states/statefile" ) +var ( + // TestExperimentFlag is the name of the environment variable that + // can be set to built a terraform binary with experimental features enabled. + // Any value besides "false" or an empty string will enable the feature. + TestExperimentFlag = "TF_TEST_EXPERIMENTS" +) + // Type binary represents the combination of a compiled binary // and a temporary working directory to run it in. type binary struct { @@ -249,11 +256,12 @@ func GoBuild(pkgPath, tmpPrefix string) string { panic(err) } - cmd := exec.Command( - "go", "build", - "-o", tmpFilename, - pkgPath, - ) + args := []string{"build", "-o", tmpFilename} + if exp := os.Getenv(TestExperimentFlag); exp != "" && exp != "false" { + args = append(args, "-ldflags", "-X 'main.experimentsAllowed=yes'") + } + args = append(args, pkgPath) + cmd := exec.Command("go", args...) cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout diff --git a/internal/grpcwrap/provider.go b/internal/grpcwrap/provider.go index cd7ddb98a4..b89bbeb9be 100644 --- a/internal/grpcwrap/provider.go +++ b/internal/grpcwrap/provider.go @@ -794,10 +794,10 @@ func (p *provider) ListResource(req *tfplugin5.ListResource_Request, res tfplugi for iter := data.ElementIterator(); iter.Next(); { _, item := iter.Element() - state := item.GetAttr("state") var stateVal *tfplugin5.DynamicValue - var err error - if !state.IsNull() { + if item.Type().HasAttribute("state") { + state := item.GetAttr("state") + var err error stateVal, err = encodeDynamicValue(state, resourceSchema.Body.ImpliedType()) if err != nil { return status.Errorf(codes.Internal, "failed to encode list resource item state: %v", err) diff --git a/internal/grpcwrap/provider6.go b/internal/grpcwrap/provider6.go index 179ab66d89..5ca602a9f0 100644 --- a/internal/grpcwrap/provider6.go +++ b/internal/grpcwrap/provider6.go @@ -849,10 +849,10 @@ func (p *provider6) ListResource(req *tfplugin6.ListResource_Request, res tfplug for iter := data.ElementIterator(); iter.Next(); { _, item := iter.Element() - state := item.GetAttr("state") var stateVal *tfplugin6.DynamicValue - var err error - if !state.IsNull() { + if item.Type().HasAttribute("state") { + state := item.GetAttr("state") + var err error stateVal, err = encodeDynamicValue6(state, resourceSchema.Body.ImpliedType()) if err != nil { return status.Errorf(codes.Internal, "failed to encode list resource item state: %v", err) diff --git a/internal/provider-simple-v6/provider.go b/internal/provider-simple-v6/provider.go index d51bad90ac..22390cc26b 100644 --- a/internal/provider-simple-v6/provider.go +++ b/internal/provider-simple-v6/provider.go @@ -35,6 +35,15 @@ func Provider() providers.Interface { }, }, }, + Identity: &configschema.Object{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Required: true, + }, + }, + Nesting: configschema.NestingSingle, + }, } return simple{ @@ -51,6 +60,18 @@ func Provider() providers.Interface { EphemeralResourceTypes: map[string]providers.Schema{ "simple_resource": simpleResource, }, + ListResourceTypes: map[string]providers.Schema{ + "simple_resource": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Optional: true, + Type: cty.String, + }, + }, + }, + }, + }, ServerCapabilities: providers.ServerCapabilities{ PlanDestroy: true, GetProviderSchemaOptional: true, @@ -251,10 +272,34 @@ func (s simple) CallFunction(req providers.CallFunctionRequest) (resp providers. return resp } -func (s simple) ListResource(req providers.ListResourceRequest) providers.ListResourceResponse { - // Our schema doesn't include any list resource types, so it should be - // impossible to get in here. - panic("ListResource on provider that didn't declare any list resource types") +func (s simple) ListResource(req providers.ListResourceRequest) (resp providers.ListResourceResponse) { + vals := make([]cty.Value, 0) + + staticVal := cty.StringVal("static_value") + m := req.Config.AsValueMap() + if val, ok := m["value"]; ok && val != cty.NilVal { + staticVal = val + } + + obj := map[string]cty.Value{ + "display_name": cty.StringVal("static_display_name"), + "identity": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("static_id"), + }), + } + if req.IncludeResourceObject { + obj["state"] = cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("static_id"), + "value": staticVal, + }) + } + vals = append(vals, cty.ObjectVal(obj)) + + resp.Result = cty.ObjectVal(map[string]cty.Value{ + "data": cty.TupleVal(vals), + "config": req.Config, + }) + return } func (s simple) ValidateStateStoreConfig(req providers.ValidateStateStoreConfigRequest) providers.ValidateStateStoreConfigResponse { diff --git a/internal/provider-simple/provider.go b/internal/provider-simple/provider.go index 5d1ac3b28c..2541223f73 100644 --- a/internal/provider-simple/provider.go +++ b/internal/provider-simple/provider.go @@ -33,6 +33,15 @@ func Provider() providers.Interface { }, }, }, + Identity: &configschema.Object{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Required: true, + }, + }, + Nesting: configschema.NestingSingle, + }, } return simple{ @@ -49,6 +58,18 @@ func Provider() providers.Interface { EphemeralResourceTypes: map[string]providers.Schema{ "simple_resource": simpleResource, }, + ListResourceTypes: map[string]providers.Schema{ + "simple_resource": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Optional: true, + Type: cty.String, + }, + }, + }, + }, + }, ServerCapabilities: providers.ServerCapabilities{ PlanDestroy: true, }, @@ -211,10 +232,34 @@ func (s simple) CallFunction(req providers.CallFunctionRequest) (resp providers. panic("CallFunction on provider that didn't declare any functions") } -func (s simple) ListResource(req providers.ListResourceRequest) providers.ListResourceResponse { - // Our schema doesn't include any list resource types, so it should be - // impossible to get in here. - panic("ListResource on provider that didn't declare any list resource types") +func (s simple) ListResource(req providers.ListResourceRequest) (resp providers.ListResourceResponse) { + vals := make([]cty.Value, 0) + + staticVal := cty.StringVal("static_value") + m := req.Config.AsValueMap() + if val, ok := m["value"]; ok && val != cty.NilVal { + staticVal = val + } + + obj := map[string]cty.Value{ + "display_name": cty.StringVal("static_display_name"), + "identity": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("static_id"), + }), + } + if req.IncludeResourceObject { + obj["state"] = cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("static_id"), + "value": staticVal, + }) + } + vals = append(vals, cty.ObjectVal(obj)) + + resp.Result = cty.ObjectVal(map[string]cty.Value{ + "data": cty.TupleVal(vals), + "config": req.Config, + }) + return } func (s simple) ValidateStateStoreConfig(req providers.ValidateStateStoreConfigRequest) providers.ValidateStateStoreConfigResponse { diff --git a/internal/terraform/context_plan_query_test.go b/internal/terraform/context_plan_query_test.go index ecbcc745ac..0928395fc4 100644 --- a/internal/terraform/context_plan_query_test.go +++ b/internal/terraform/context_plan_query_test.go @@ -23,7 +23,6 @@ import ( ) func TestContext2Plan_queryList(t *testing.T) { - cases := []struct { name string mainConfig string