From b03d94cd9287821be26b60d7aa9b41813e72ae93 Mon Sep 17 00:00:00 2001 From: Arthur Busser Date: Thu, 4 Sep 2025 18:51:08 +0200 Subject: [PATCH 1/2] Add ephemeral resource to provider This PR implements an ephemeral resource that works like the existing data source. This allows running external programs without storing the result in Terraform's state, addressing concerns with sensitive data handling (closes #437). Our use-case is running an external program that outputs credentials for use by a provider. Storing a Terraform user's credentials in a remote state backend would be a problem. Here's the code this PR allows us to write: ```hcl provider "google" { credentials = local.credentials } locals { credentials = jsonencode(ephemeral.external.credentials.result) } ephemeral "external" "credentials" { program = [ # Program that fetches credentials on the user's workstation ] } ``` The ephemeral resource's implementation is heavily copied from the data source's to ensure that the behavior is the same. Any difference would be confusing for users and a pain to document and maintain. The ephemeral resource's tests check the same scenarios as the data source's, but take a different approach to inspecting the program's output. The data source's tests run code with `output` blocks, but that doesn't work with ephemeral resources. I created a separate test program for the new tests. This program writes its output to a file specified by the test. After running Terraform, the tests can read this file to check that the content is as expected. I've run this provider on our Terraform codebase and it behaves as desired. If you have any changes you want me to make, I am happy to make them. --- internal/provider/ephemeral.go | 283 ++++++++++++ internal/provider/ephemeral_test.go | 406 ++++++++++++++++++ internal/provider/provider.go | 9 +- .../tf-acc-external-ephemeral/main.go | 107 +++++ 4 files changed, 804 insertions(+), 1 deletion(-) create mode 100644 internal/provider/ephemeral.go create mode 100644 internal/provider/ephemeral_test.go create mode 100644 internal/provider/test-programs/tf-acc-external-ephemeral/main.go diff --git a/internal/provider/ephemeral.go b/internal/provider/ephemeral.go new file mode 100644 index 00000000..8c90ade4 --- /dev/null +++ b/internal/provider/ephemeral.go @@ -0,0 +1,283 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "runtime" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ ephemeral.EphemeralResource = (*externalEphemeralResource)(nil) +) + +func NewExternalEphemeralResource() ephemeral.EphemeralResource { + return &externalEphemeralResource{} +} + +type externalEphemeralResource struct{} + +func (e *externalEphemeralResource) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = req.ProviderTypeName +} + +func (e *externalEphemeralResource) Schema(ctx context.Context, req ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "The `external` ephemeral resource allows an external program implementing a specific protocol " + + "(defined below) to act as an ephemeral resource, exposing arbitrary data for use elsewhere in the Terraform " + + "configuration without storing the data in state.\n" + + "\n" + + "**Warning** This mechanism is provided as an \"escape hatch\" for exceptional situations where a " + + "first-class Terraform provider is not more appropriate. Its capabilities are limited in comparison " + + "to a true ephemeral resource, and implementing an ephemeral resource via an external program is likely to hurt the " + + "portability of your Terraform configuration by creating dependencies on external programs and " + + "libraries that may not be available (or may need to be used differently) on different operating " + + "systems.\n" + + "\n" + + "**Warning** Terraform Enterprise does not guarantee availability of any particular language runtimes " + + "or external programs beyond standard shell utilities, so it is not recommended to use this ephemeral resource " + + "within configurations that are applied within Terraform Enterprise.", + + Attributes: map[string]schema.Attribute{ + "program": schema.ListAttribute{ + Description: "A list of strings, whose first element is the program to run and whose " + + "subsequent elements are optional command line arguments to the program. Terraform does " + + "not execute the program through a shell, so it is not necessary to escape shell " + + "metacharacters nor add quotes around arguments containing spaces.", + ElementType: types.StringType, + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + + "working_dir": schema.StringAttribute{ + Description: "Working directory of the program. If not supplied, the program will run " + + "in the current directory.", + Optional: true, + }, + + "query": schema.MapAttribute{ + Description: "A map of string values to pass to the external program as the query " + + "arguments. If not supplied, the program will receive an empty object as its input.", + ElementType: types.StringType, + Optional: true, + }, + + "result": schema.MapAttribute{ + Description: "A map of string values returned from the external program.", + ElementType: types.StringType, + Computed: true, + }, + }, + } +} + +func (e *externalEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + var config externalEphemeralResourceModelV0 + + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + var program []types.String + + diags = config.Program.ElementsAs(ctx, &program, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + filteredProgram := make([]string, 0, len(program)) + + for _, programArgRaw := range program { + if programArgRaw.IsNull() || programArgRaw.ValueString() == "" { + continue + } + + filteredProgram = append(filteredProgram, programArgRaw.ValueString()) + } + + if len(filteredProgram) == 0 { + resp.Diagnostics.AddAttributeError( + path.Root("program"), + "External Program Missing", + "The ephemeral resource was configured without a program to execute. Verify the configuration contains at least one non-empty value.", + ) + return + } + + var query map[string]types.String + + diags = config.Query.ElementsAs(ctx, &query, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + filteredQuery := make(map[string]string) + for key, value := range query { + // Preserve v2.2.3 and earlier behavior of filtering whole map elements + // with null values. + // Reference: https://github.com/hashicorp/terraform-provider-external/issues/208 + // + // The external program protocol could be updated to support null values + // as a breaking change by marshaling map[string]*string to JSON. + // Reference: https://github.com/hashicorp/terraform-provider-external/issues/209 + if value.IsNull() { + continue + } + + filteredQuery[key] = value.ValueString() + } + + queryJson, err := json.Marshal(filteredQuery) + if err != nil { + resp.Diagnostics.AddAttributeError( + path.Root("query"), + "Query Handling Failed", + "The ephemeral resource received an unexpected error while attempting to parse the query. "+ + "This is always a bug in the external provider code and should be reported to the provider developers."+ + fmt.Sprintf("\n\nError: %s", err), + ) + return + } + + // first element is assumed to be an executable command, possibly found + // using the PATH environment variable. + _, err = exec.LookPath(filteredProgram[0]) + + // This is a workaround to preserve pre-existing behaviour prior to the upgrade to Go 1.19. + // Reference: https://github.com/hashicorp/terraform-provider-external/pull/192 + // + // This workaround will be removed once a warning is being issued to notify practitioners + // of a change in behaviour. + // Reference: https://github.com/hashicorp/terraform-provider-external/issues/197 + if errors.Is(err, exec.ErrDot) { + err = nil + } + + if err != nil { + resp.Diagnostics.AddAttributeError( + path.Root("program"), + "External Program Lookup Failed", + "The ephemeral resource received an unexpected error while attempting to parse the query. "+ + `The ephemeral resource received an unexpected error while attempting to find the program. + +The program must be accessible according to the platform where Terraform is running. + +If the expected program should be automatically found on the platform where Terraform is running, ensure that the program is in an expected directory. On Unix-based platforms, these directories are typically searched based on the '$PATH' environment variable. On Windows-based platforms, these directories are typically searched based on the '%PATH%' environment variable. + +If the expected program is relative to the Terraform configuration, it is recommended that the program name includes the interpolated value of 'path.module' before the program name to ensure that it is compatible with varying module usage. For example: "${path.module}/my-program" + +The program must also be executable according to the platform where Terraform is running. On Unix-based platforms, the file on the filesystem must have the executable bit set. On Windows-based platforms, no action is typically necessary. +`+ + fmt.Sprintf("\nPlatform: %s", runtime.GOOS)+ + fmt.Sprintf("\nProgram: %s", program[0])+ + fmt.Sprintf("\nError: %s", err), + ) + return + } + + workingDir := config.WorkingDir.ValueString() + + cmd := exec.CommandContext(ctx, filteredProgram[0], filteredProgram[1:]...) + + // This is a workaround to preserve pre-existing behaviour prior to the upgrade to Go 1.19. + // Reference: https://github.com/hashicorp/terraform-provider-external/pull/192 + // + // This workaround will be removed once a warning is being issued to notify practitioners + // of a change in behaviour. + // Reference: https://github.com/hashicorp/terraform-provider-external/issues/197 + if errors.Is(cmd.Err, exec.ErrDot) { + cmd.Err = nil + } + + cmd.Dir = workingDir + cmd.Stdin = bytes.NewReader(queryJson) + + var stderr strings.Builder + cmd.Stderr = &stderr + + tflog.Trace(ctx, "Executing external program", map[string]interface{}{"program": cmd.String()}) + + resultJson, err := cmd.Output() + + stderrStr := stderr.String() + + tflog.Trace(ctx, "Executed external program", map[string]interface{}{"program": cmd.String(), "output": string(resultJson), "stderr": stderrStr}) + + if err != nil { + if len(stderrStr) > 0 { + resp.Diagnostics.AddAttributeError( + path.Root("program"), + "External Program Execution Failed", + "The ephemeral resource received an unexpected error while attempting to execute the program."+ + fmt.Sprintf("\n\nProgram: %s", cmd.Path)+ + fmt.Sprintf("\nError Message: %s", stderrStr)+ + fmt.Sprintf("\nState: %s", err), + ) + return + } + + resp.Diagnostics.AddAttributeError( + path.Root("program"), + "External Program Execution Failed", + "The ephemeral resource received an unexpected error while attempting to execute the program.\n\n"+ + "The program was executed, however it returned no additional error messaging."+ + fmt.Sprintf("\n\nProgram: %s", cmd.Path)+ + fmt.Sprintf("\nState: %s", err), + ) + return + } + + result := map[string]string{} + err = json.Unmarshal(resultJson, &result) + if err != nil { + resp.Diagnostics.AddAttributeError( + path.Root("program"), + "Unexpected External Program Results", + `The ephemeral resource received unexpected results after executing the program. + +Program output must be a JSON encoded map of string keys and string values. + +If the error is unclear, the output can be viewed by enabling Terraform's logging at TRACE level. Terraform documentation on logging: https://www.terraform.io/internals/debugging +`+ + fmt.Sprintf("\nProgram: %s", cmd.Path)+ + fmt.Sprintf("\nResult Error: %s", err), + ) + return + } + + config.Result, diags = types.MapValueFrom(ctx, types.StringType, result) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + diags = resp.Result.Set(ctx, config) + resp.Diagnostics.Append(diags...) +} + +type externalEphemeralResourceModelV0 struct { + Program types.List `tfsdk:"program"` + WorkingDir types.String `tfsdk:"working_dir"` + Query types.Map `tfsdk:"query"` + Result types.Map `tfsdk:"result"` +} diff --git a/internal/provider/ephemeral_test.go b/internal/provider/ephemeral_test.go new file mode 100644 index 00000000..d57c690c --- /dev/null +++ b/internal/provider/ephemeral_test.go @@ -0,0 +1,406 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +const testEphemeralExternalConfig_basic = ` +ephemeral "external" "test" { + program = ["%s", "cheese"] + + query = { + value = "pizza" + output_file = "%s" + } +} +` + +func TestEphemeralExternal_basic(t *testing.T) { + programPath, err := buildEphemeralTestProgram() + if err != nil { + t.Fatal(err) + return + } + + tempDir := t.TempDir() + outputFile := filepath.Join(tempDir, "result.json") + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(testEphemeralExternalConfig_basic, programPath, outputFile), + Check: func(_ *terraform.State) error { + return validateEphemeralOutput(outputFile, map[string]string{ + "result": "yes", + "query_value": "pizza", + "argument": "cheese", + "value": "pizza", + "output_file": outputFile, + }) + }, + }, + }, + }) +} + +const testEphemeralExternalConfig_error = ` +ephemeral "external" "test" { + program = ["%s"] + + query = { + fail = "true" + } +} +` + +func TestEphemeralExternal_error(t *testing.T) { + programPath, err := buildEphemeralTestProgram() + if err != nil { + t.Fatal(err) + return + } + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(testEphemeralExternalConfig_error, programPath), + ExpectError: regexp.MustCompile("I was asked to fail"), + }, + }, + }) +} + +const testEphemeralExternalConfig_workingDir = ` +ephemeral "external" "test" { + program = ["%s"] + working_dir = "%s" + + query = { + working_dir_file = "%s" + } +} +` + +func TestEphemeralExternal_workingDirectory(t *testing.T) { + programPath, err := buildEphemeralTestProgram() + if err != nil { + t.Fatal(err) + return + } + + tempDir := t.TempDir() + workingDirFile := filepath.Join(tempDir, "working_dir.txt") + workingDir := "/tmp" + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(testEphemeralExternalConfig_workingDir, programPath, workingDir, workingDirFile), + Check: func(_ *terraform.State) error { + return validateWorkingDirectory(workingDirFile, workingDir) + }, + }, + }, + }) +} + +const testEphemeralExternalConfig_emptyQuery = ` +ephemeral "external" "test" { + program = ["%s"] + + query = { + output_file = "%s" + } +} +` + +func TestEphemeralExternal_emptyQuery(t *testing.T) { + programPath, err := buildEphemeralTestProgram() + if err != nil { + t.Fatal(err) + return + } + + tempDir := t.TempDir() + outputFile := filepath.Join(tempDir, "result.json") + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(testEphemeralExternalConfig_emptyQuery, programPath, outputFile), + Check: func(_ *terraform.State) error { + return validateEphemeralOutput(outputFile, map[string]string{ + "result": "yes", + "output_file": outputFile, + }) + }, + }, + }, + }) +} + +const testEphemeralExternalConfig_missingProgram = ` +ephemeral "external" "test" { + program = [] +} +` + +func TestEphemeralExternal_missingProgram(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: testEphemeralExternalConfig_missingProgram, + ExpectError: regexp.MustCompile("Invalid Attribute Value"), + }, + }, + }) +} + +func TestEphemeralExternal_Program_OnlyEmptyString(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: ` + ephemeral "external" "test" { + program = [ + "", # e.g. a variable that became empty + ] + + query = { + value = "valuetest" + } + } + `, + ExpectError: regexp.MustCompile(`External Program Missing`), + }, + }, + }) +} + +func TestEphemeralExternal_Program_PathAndEmptyString(t *testing.T) { + programPath, err := buildEphemeralTestProgram() + if err != nil { + t.Fatal(err) + return + } + + tempDir := t.TempDir() + outputFile := filepath.Join(tempDir, "result.json") + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + ephemeral "external" "test" { + program = [ + %[1]q, + "", # e.g. a variable that became empty + ] + + query = { + value = "valuetest" + output_file = %[2]q + } + } + `, programPath, outputFile), + Check: func(_ *terraform.State) error { + return validateEphemeralOutput(outputFile, map[string]string{ + "result": "yes", + "query_value": "valuetest", + "value": "valuetest", + "output_file": outputFile, + }) + }, + }, + }, + }) +} + +func TestEphemeralExternal_Program_EmptyStringAndNullValues(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: ` + ephemeral "external" "test" { + program = [ + null, "", # e.g. a variable that became empty + ] + + query = { + value = "valuetest" + } + } + `, + ExpectError: regexp.MustCompile(`External Program Missing`), + }, + }, + }) +} + +func TestEphemeralExternal_Query_EmptyElementValue(t *testing.T) { + programPath, err := buildEphemeralTestProgram() + if err != nil { + t.Fatal(err) + return + } + + tempDir := t.TempDir() + outputFile := filepath.Join(tempDir, "result.json") + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + ephemeral "external" "test" { + program = [%[1]q] + + query = { + value = "" + output_file = %[2]q + } + } + `, programPath, outputFile), + Check: func(_ *terraform.State) error { + return validateEphemeralOutput(outputFile, map[string]string{ + "result": "yes", + "query_value": "", + "value": "", + "output_file": outputFile, + }) + }, + }, + }, + }) +} + +func TestEphemeralExternal_Query_NullElementValue(t *testing.T) { + programPath, err := buildEphemeralTestProgram() + if err != nil { + t.Fatal(err) + return + } + + tempDir := t.TempDir() + outputFile := filepath.Join(tempDir, "result.json") + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + ephemeral "external" "test" { + program = [%[1]q] + + query = { + # Program will return exit status 1 if the "fail" key is present. + fail = null + output_file = %[2]q + } + } + `, programPath, outputFile), + Check: func(_ *terraform.State) error { + // Validate that the program ran successfully and null values were filtered + data, err := os.ReadFile(outputFile) + if err != nil { + return fmt.Errorf("failed to read output file: %v", err) + } + + var result map[string]string + if err := json.Unmarshal(data, &result); err != nil { + return fmt.Errorf("failed to parse JSON: %v", err) + } + + // The "fail" key should not be present due to null filtering + if _, exists := result["fail"]; exists { + return fmt.Errorf("unexpected 'fail' key in result, null values should be filtered") + } + + return nil + }, + }, + }, + }) +} + +func buildEphemeralTestProgram() (string, error) { + // We have a simple Go program that we use as a stub for testing. + cmd := exec.Command( + "go", "install", + "github.com/terraform-providers/terraform-provider-external/internal/provider/test-programs/tf-acc-external-ephemeral", + ) + err := cmd.Run() + + if err != nil { + return "", fmt.Errorf("failed to build test stub program: %s", err) + } + + gopath := os.Getenv("GOPATH") + if gopath == "" { + gopath = filepath.Join(os.Getenv("HOME") + "/go") + } + + programPath := path.Join( + filepath.SplitList(gopath)[0], "bin", "tf-acc-external-ephemeral", + ) + return programPath, nil +} + +func validateEphemeralOutput(outputFile string, expectedResults map[string]string) error { + data, err := os.ReadFile(outputFile) + if err != nil { + return fmt.Errorf("failed to read output file: %v", err) + } + + var result map[string]string + if err := json.Unmarshal(data, &result); err != nil { + return fmt.Errorf("failed to parse JSON: %v", err) + } + + for key, expectedValue := range expectedResults { + actualValue, exists := result[key] + if !exists { + return fmt.Errorf("missing key '%s' in result", key) + } + if actualValue != expectedValue { + return fmt.Errorf("key '%s': expected '%s', got '%s'", key, expectedValue, actualValue) + } + } + + return nil +} + +func validateWorkingDirectory(workingDirFile, expectedWorkingDir string) error { + data, err := os.ReadFile(workingDirFile) + if err != nil { + return fmt.Errorf("failed to read working directory file: %v", err) + } + + actualWorkingDir := string(data) + if actualWorkingDir != expectedWorkingDir { + return fmt.Errorf("working directory: expected '%s', got '%s'", expectedWorkingDir, actualWorkingDir) + } + + return nil +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4278e9f9..5b438981 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,11 +7,12 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" ) -var _ provider.Provider = (*externalProvider)(nil) +var _ provider.ProviderWithEphemeralResources = (*externalProvider)(nil) func New() provider.Provider { return &externalProvider{} @@ -37,5 +38,11 @@ func (p *externalProvider) Resources(ctx context.Context) []func() resource.Reso return nil } +func (p *externalProvider) EphemeralResources(ctx context.Context) []func() ephemeral.EphemeralResource { + return []func() ephemeral.EphemeralResource{ + NewExternalEphemeralResource, + } +} + func (p *externalProvider) Schema(context.Context, provider.SchemaRequest, *provider.SchemaResponse) { } diff --git a/internal/provider/test-programs/tf-acc-external-ephemeral/main.go b/internal/provider/test-programs/tf-acc-external-ephemeral/main.go new file mode 100644 index 00000000..277e22e2 --- /dev/null +++ b/internal/provider/test-programs/tf-acc-external-ephemeral/main.go @@ -0,0 +1,107 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +// This is a minimal implementation of the external ephemeral resource protocol +// intended only for use in the provider acceptance tests. +// +// The main difference from the data source test program is that this writes +// output to files specified in the query, allowing tests to inspect the +// behavior of ephemeral resources that can't use output blocks. +func main() { + queryBytes, err := io.ReadAll(os.Stdin) + if err != nil { + panic(err) + } + + var query map[string]*string + err = json.Unmarshal(queryBytes, &query) + if err != nil { + panic(err) + } + + if _, ok := query["fail"]; ok { + fmt.Fprintf(os.Stderr, "I was asked to fail\n") + os.Exit(1) + } + + var result = map[string]string{ + "result": "yes", + } + + if queryValue, ok := query["value"]; ok && queryValue != nil { + result["query_value"] = *queryValue + } + + if len(os.Args) >= 2 { + result["argument"] = os.Args[1] + } + + // Add working directory to result if requested + if wd, err := os.Getwd(); err == nil { + result["working_dir"] = wd + } + + for queryKey, queryValue := range query { + if queryValue != nil { + result[queryKey] = *queryValue + } + } + + // Write results to stdout (standard protocol) + resultBytes, err := json.Marshal(result) + if err != nil { + panic(err) + } + os.Stdout.Write(resultBytes) + + // Additionally, write results to a file if output_file is specified + if outputFile, ok := query["output_file"]; ok && outputFile != nil { + // Create directory if it doesn't exist + dir := filepath.Dir(*outputFile) + if err := os.MkdirAll(dir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "Failed to create directory %s: %v\n", dir, err) + os.Exit(1) + } + + // Write JSON result to file + err := os.WriteFile(*outputFile, resultBytes, 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to write to file %s: %v\n", *outputFile, err) + os.Exit(1) + } + } + + // Write working directory to a separate file if working_dir_file is specified + if wdFile, ok := query["working_dir_file"]; ok && wdFile != nil { + wd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get working directory: %v\n", err) + os.Exit(1) + } + + // Create directory if it doesn't exist + dir := filepath.Dir(*wdFile) + if err := os.MkdirAll(dir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "Failed to create directory %s: %v\n", dir, err) + os.Exit(1) + } + + err = os.WriteFile(*wdFile, []byte(wd), 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to write working dir to file %s: %v\n", *wdFile, err) + os.Exit(1) + } + } + + os.Exit(0) +} From 67c3ec4167269c3f68cb5de85b6cb49bbf234ba6 Mon Sep 17 00:00:00 2001 From: Arthur Busser Date: Wed, 17 Sep 2025 15:07:33 +0200 Subject: [PATCH 2/2] Simplify tests by using the echo provider --- internal/provider/data_source_test.go | 69 ++++ internal/provider/ephemeral_test.go | 341 ++++++++++-------- .../tf-acc-external-data-source/main.go | 5 + .../tf-acc-external-ephemeral/main.go | 107 ------ 4 files changed, 260 insertions(+), 262 deletions(-) delete mode 100644 internal/provider/test-programs/tf-acc-external-ephemeral/main.go diff --git a/internal/provider/data_source_test.go b/internal/provider/data_source_test.go index 0454cfa3..ed838327 100644 --- a/internal/provider/data_source_test.go +++ b/internal/provider/data_source_test.go @@ -116,6 +116,75 @@ func TestDataSource_error(t *testing.T) { }) } +const testDataSourceConfig_workingDir = ` +data "external" "test" { + program = ["%s"] + working_dir = "%s" + + query = { + value = "test" + } +} + +output "working_dir" { + value = "${data.external.test.result["working_dir"]}" +} + +output "result" { + value = "${data.external.test.result["result"]}" +} +` + +func TestDataSource_workingDirectory(t *testing.T) { + programPath, err := buildDataSourceTestProgram() + if err != nil { + t.Fatal(err) + return + } + + workingDir := "/tmp" + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(testDataSourceConfig_workingDir, programPath, workingDir), + Check: func(s *terraform.State) error { + _, ok := s.RootModule().Resources["data.external.test"] + if !ok { + return fmt.Errorf("missing data resource") + } + + outputs := s.RootModule().Outputs + + if outputs["working_dir"] == nil { + return fmt.Errorf("missing 'working_dir' output") + } + if outputs["result"] == nil { + return fmt.Errorf("missing 'result' output") + } + + if outputs["working_dir"].Value != workingDir { + return fmt.Errorf( + "'working_dir' output is %q; want %q", + outputs["working_dir"].Value, + workingDir, + ) + } + if outputs["result"].Value != "yes" { + return fmt.Errorf( + "'result' output is %q; want 'yes'", + outputs["result"].Value, + ) + } + + return nil + }, + }, + }, + }) +} + // Reference: https://github.com/hashicorp/terraform-provider-external/issues/110 func TestDataSource_Program_OnlyEmptyString(t *testing.T) { resource.UnitTest(t, resource.TestCase{ diff --git a/internal/provider/ephemeral_test.go b/internal/provider/ephemeral_test.go index d57c690c..d6353547 100644 --- a/internal/provider/ephemeral_test.go +++ b/internal/provider/ephemeral_test.go @@ -4,17 +4,17 @@ package provider import ( - "encoding/json" "fmt" - "os" - "os/exec" - "path" - "path/filepath" "regexp" "testing" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/echoprovider" "github.com/hashicorp/terraform-plugin-testing/helper/resource" - "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" ) const testEphemeralExternalConfig_basic = ` @@ -23,34 +23,55 @@ ephemeral "external" "test" { query = { value = "pizza" - output_file = "%s" } } + +provider "echo" { + data = ephemeral.external.test.result +} + +resource "echo" "test" {} ` func TestEphemeralExternal_basic(t *testing.T) { - programPath, err := buildEphemeralTestProgram() + programPath, err := buildDataSourceTestProgram() if err != nil { t.Fatal(err) return } - tempDir := t.TempDir() - outputFile := filepath.Join(tempDir, "result.json") - resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { - Config: fmt.Sprintf(testEphemeralExternalConfig_basic, programPath, outputFile), - Check: func(_ *terraform.State) error { - return validateEphemeralOutput(outputFile, map[string]string{ - "result": "yes", - "query_value": "pizza", - "argument": "cheese", - "value": "pizza", - "output_file": outputFile, - }) + Config: fmt.Sprintf(testEphemeralExternalConfig_basic, programPath), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("result"), + knownvalue.StringExact("yes"), + ), + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("query_value"), + knownvalue.StringExact("pizza"), + ), + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("argument"), + knownvalue.StringExact("cheese"), + ), + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("value"), + knownvalue.StringExact("pizza"), + ), }, }, }, @@ -68,13 +89,16 @@ ephemeral "external" "test" { ` func TestEphemeralExternal_error(t *testing.T) { - programPath, err := buildEphemeralTestProgram() + programPath, err := buildDataSourceTestProgram() if err != nil { t.Fatal(err) return } resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { @@ -91,29 +115,48 @@ ephemeral "external" "test" { working_dir = "%s" query = { - working_dir_file = "%s" + value = "test" } } + +provider "echo" { + data = ephemeral.external.test.result +} + +resource "echo" "test" {} ` func TestEphemeralExternal_workingDirectory(t *testing.T) { - programPath, err := buildEphemeralTestProgram() + programPath, err := buildDataSourceTestProgram() if err != nil { t.Fatal(err) return } - tempDir := t.TempDir() - workingDirFile := filepath.Join(tempDir, "working_dir.txt") workingDir := "/tmp" resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { - Config: fmt.Sprintf(testEphemeralExternalConfig_workingDir, programPath, workingDir, workingDirFile), - Check: func(_ *terraform.State) error { - return validateWorkingDirectory(workingDirFile, workingDir) + Config: fmt.Sprintf(testEphemeralExternalConfig_workingDir, programPath, workingDir), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("working_dir"), + knownvalue.StringExact(workingDir), + ), + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("result"), + knownvalue.StringExact("yes"), + ), }, }, }, @@ -124,32 +167,40 @@ const testEphemeralExternalConfig_emptyQuery = ` ephemeral "external" "test" { program = ["%s"] - query = { - output_file = "%s" - } + query = {} } + +provider "echo" { + data = ephemeral.external.test.result +} + +resource "echo" "test" {} ` func TestEphemeralExternal_emptyQuery(t *testing.T) { - programPath, err := buildEphemeralTestProgram() + programPath, err := buildDataSourceTestProgram() if err != nil { t.Fatal(err) return } - tempDir := t.TempDir() - outputFile := filepath.Join(tempDir, "result.json") - resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { - Config: fmt.Sprintf(testEphemeralExternalConfig_emptyQuery, programPath, outputFile), - Check: func(_ *terraform.State) error { - return validateEphemeralOutput(outputFile, map[string]string{ - "result": "yes", - "output_file": outputFile, - }) + Config: fmt.Sprintf(testEphemeralExternalConfig_emptyQuery, programPath), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("result"), + knownvalue.StringExact("yes"), + ), }, }, }, @@ -164,6 +215,9 @@ ephemeral "external" "test" { func TestEphemeralExternal_missingProgram(t *testing.T) { resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { @@ -176,6 +230,9 @@ func TestEphemeralExternal_missingProgram(t *testing.T) { func TestEphemeralExternal_Program_OnlyEmptyString(t *testing.T) { resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { @@ -184,7 +241,7 @@ func TestEphemeralExternal_Program_OnlyEmptyString(t *testing.T) { program = [ "", # e.g. a variable that became empty ] - + query = { value = "valuetest" } @@ -197,16 +254,19 @@ func TestEphemeralExternal_Program_OnlyEmptyString(t *testing.T) { } func TestEphemeralExternal_Program_PathAndEmptyString(t *testing.T) { - programPath, err := buildEphemeralTestProgram() + programPath, err := buildDataSourceTestProgram() if err != nil { t.Fatal(err) return } - tempDir := t.TempDir() - outputFile := filepath.Join(tempDir, "result.json") - resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { @@ -216,20 +276,34 @@ func TestEphemeralExternal_Program_PathAndEmptyString(t *testing.T) { %[1]q, "", # e.g. a variable that became empty ] - + query = { value = "valuetest" - output_file = %[2]q } } - `, programPath, outputFile), - Check: func(_ *terraform.State) error { - return validateEphemeralOutput(outputFile, map[string]string{ - "result": "yes", - "query_value": "valuetest", - "value": "valuetest", - "output_file": outputFile, - }) + + provider "echo" { + data = ephemeral.external.test.result + } + + resource "echo" "test" {} + `, programPath), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("result"), + knownvalue.StringExact("yes"), + ), + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("query_value"), + knownvalue.StringExact("valuetest"), + ), + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("value"), + knownvalue.StringExact("valuetest"), + ), }, }, }, @@ -238,6 +312,9 @@ func TestEphemeralExternal_Program_PathAndEmptyString(t *testing.T) { func TestEphemeralExternal_Program_EmptyStringAndNullValues(t *testing.T) { resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { @@ -246,7 +323,7 @@ func TestEphemeralExternal_Program_EmptyStringAndNullValues(t *testing.T) { program = [ null, "", # e.g. a variable that became empty ] - + query = { value = "valuetest" } @@ -259,36 +336,53 @@ func TestEphemeralExternal_Program_EmptyStringAndNullValues(t *testing.T) { } func TestEphemeralExternal_Query_EmptyElementValue(t *testing.T) { - programPath, err := buildEphemeralTestProgram() + programPath, err := buildDataSourceTestProgram() if err != nil { t.Fatal(err) return } - tempDir := t.TempDir() - outputFile := filepath.Join(tempDir, "result.json") - resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` ephemeral "external" "test" { program = [%[1]q] - + query = { value = "" - output_file = %[2]q } } - `, programPath, outputFile), - Check: func(_ *terraform.State) error { - return validateEphemeralOutput(outputFile, map[string]string{ - "result": "yes", - "query_value": "", - "value": "", - "output_file": outputFile, - }) + + provider "echo" { + data = ephemeral.external.test.result + } + + resource "echo" "test" {} + `, programPath), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("result"), + knownvalue.StringExact("yes"), + ), + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("query_value"), + knownvalue.StringExact(""), + ), + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("value"), + knownvalue.StringExact(""), + ), }, }, }, @@ -296,111 +390,48 @@ func TestEphemeralExternal_Query_EmptyElementValue(t *testing.T) { } func TestEphemeralExternal_Query_NullElementValue(t *testing.T) { - programPath, err := buildEphemeralTestProgram() + programPath, err := buildDataSourceTestProgram() if err != nil { t.Fatal(err) return } - tempDir := t.TempDir() - outputFile := filepath.Join(tempDir, "result.json") - resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { Config: fmt.Sprintf(` ephemeral "external" "test" { program = [%[1]q] - + query = { # Program will return exit status 1 if the "fail" key is present. fail = null - output_file = %[2]q } } - `, programPath, outputFile), - Check: func(_ *terraform.State) error { - // Validate that the program ran successfully and null values were filtered - data, err := os.ReadFile(outputFile) - if err != nil { - return fmt.Errorf("failed to read output file: %v", err) - } - - var result map[string]string - if err := json.Unmarshal(data, &result); err != nil { - return fmt.Errorf("failed to parse JSON: %v", err) - } - // The "fail" key should not be present due to null filtering - if _, exists := result["fail"]; exists { - return fmt.Errorf("unexpected 'fail' key in result, null values should be filtered") - } + provider "echo" { + data = ephemeral.external.test.result + } - return nil + resource "echo" "test" {} + `, programPath), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + "echo.test", + tfjsonpath.New("data").AtMapKey("result"), + knownvalue.StringExact("yes"), + ), + // The test passes by successfully running without the "fail" key + // causing the external program to exit with status 1 }, }, }, }) } - -func buildEphemeralTestProgram() (string, error) { - // We have a simple Go program that we use as a stub for testing. - cmd := exec.Command( - "go", "install", - "github.com/terraform-providers/terraform-provider-external/internal/provider/test-programs/tf-acc-external-ephemeral", - ) - err := cmd.Run() - - if err != nil { - return "", fmt.Errorf("failed to build test stub program: %s", err) - } - - gopath := os.Getenv("GOPATH") - if gopath == "" { - gopath = filepath.Join(os.Getenv("HOME") + "/go") - } - - programPath := path.Join( - filepath.SplitList(gopath)[0], "bin", "tf-acc-external-ephemeral", - ) - return programPath, nil -} - -func validateEphemeralOutput(outputFile string, expectedResults map[string]string) error { - data, err := os.ReadFile(outputFile) - if err != nil { - return fmt.Errorf("failed to read output file: %v", err) - } - - var result map[string]string - if err := json.Unmarshal(data, &result); err != nil { - return fmt.Errorf("failed to parse JSON: %v", err) - } - - for key, expectedValue := range expectedResults { - actualValue, exists := result[key] - if !exists { - return fmt.Errorf("missing key '%s' in result", key) - } - if actualValue != expectedValue { - return fmt.Errorf("key '%s': expected '%s', got '%s'", key, expectedValue, actualValue) - } - } - - return nil -} - -func validateWorkingDirectory(workingDirFile, expectedWorkingDir string) error { - data, err := os.ReadFile(workingDirFile) - if err != nil { - return fmt.Errorf("failed to read working directory file: %v", err) - } - - actualWorkingDir := string(data) - if actualWorkingDir != expectedWorkingDir { - return fmt.Errorf("working directory: expected '%s', got '%s'", expectedWorkingDir, actualWorkingDir) - } - - return nil -} diff --git a/internal/provider/test-programs/tf-acc-external-data-source/main.go b/internal/provider/test-programs/tf-acc-external-data-source/main.go index e11df018..8063a982 100644 --- a/internal/provider/test-programs/tf-acc-external-data-source/main.go +++ b/internal/provider/test-programs/tf-acc-external-data-source/main.go @@ -46,6 +46,11 @@ func main() { result["argument"] = os.Args[1] } + // Add working directory to result + if wd, err := os.Getwd(); err == nil { + result["working_dir"] = wd + } + for queryKey, queryValue := range query { if queryValue != nil { result[queryKey] = *queryValue diff --git a/internal/provider/test-programs/tf-acc-external-ephemeral/main.go b/internal/provider/test-programs/tf-acc-external-ephemeral/main.go deleted file mode 100644 index 277e22e2..00000000 --- a/internal/provider/test-programs/tf-acc-external-ephemeral/main.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package main - -import ( - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" -) - -// This is a minimal implementation of the external ephemeral resource protocol -// intended only for use in the provider acceptance tests. -// -// The main difference from the data source test program is that this writes -// output to files specified in the query, allowing tests to inspect the -// behavior of ephemeral resources that can't use output blocks. -func main() { - queryBytes, err := io.ReadAll(os.Stdin) - if err != nil { - panic(err) - } - - var query map[string]*string - err = json.Unmarshal(queryBytes, &query) - if err != nil { - panic(err) - } - - if _, ok := query["fail"]; ok { - fmt.Fprintf(os.Stderr, "I was asked to fail\n") - os.Exit(1) - } - - var result = map[string]string{ - "result": "yes", - } - - if queryValue, ok := query["value"]; ok && queryValue != nil { - result["query_value"] = *queryValue - } - - if len(os.Args) >= 2 { - result["argument"] = os.Args[1] - } - - // Add working directory to result if requested - if wd, err := os.Getwd(); err == nil { - result["working_dir"] = wd - } - - for queryKey, queryValue := range query { - if queryValue != nil { - result[queryKey] = *queryValue - } - } - - // Write results to stdout (standard protocol) - resultBytes, err := json.Marshal(result) - if err != nil { - panic(err) - } - os.Stdout.Write(resultBytes) - - // Additionally, write results to a file if output_file is specified - if outputFile, ok := query["output_file"]; ok && outputFile != nil { - // Create directory if it doesn't exist - dir := filepath.Dir(*outputFile) - if err := os.MkdirAll(dir, 0755); err != nil { - fmt.Fprintf(os.Stderr, "Failed to create directory %s: %v\n", dir, err) - os.Exit(1) - } - - // Write JSON result to file - err := os.WriteFile(*outputFile, resultBytes, 0644) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to write to file %s: %v\n", *outputFile, err) - os.Exit(1) - } - } - - // Write working directory to a separate file if working_dir_file is specified - if wdFile, ok := query["working_dir_file"]; ok && wdFile != nil { - wd, err := os.Getwd() - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to get working directory: %v\n", err) - os.Exit(1) - } - - // Create directory if it doesn't exist - dir := filepath.Dir(*wdFile) - if err := os.MkdirAll(dir, 0755); err != nil { - fmt.Fprintf(os.Stderr, "Failed to create directory %s: %v\n", dir, err) - os.Exit(1) - } - - err = os.WriteFile(*wdFile, []byte(wd), 0644) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to write working dir to file %s: %v\n", *wdFile, err) - os.Exit(1) - } - } - - os.Exit(0) -}