diff --git a/.changes/unreleased/FEATURES-20251106-180154.yaml b/.changes/unreleased/FEATURES-20251106-180154.yaml new file mode 100644 index 00000000..28454155 --- /dev/null +++ b/.changes/unreleased/FEATURES-20251106-180154.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'data/local_command: New data source that runs an executable on the local machine and returns the exit code, standard output data, and standard error data.' +time: 2025-11-06T18:01:54.341138-05:00 +custom: + Issue: "452" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1a8b686c..c38cd47f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,6 +68,10 @@ jobs: terraform: ${{ fromJSON(vars.TF_VERSIONS_PROTOCOL_V5) }} steps: + # https://github.com/actions/runner-images/issues/7443 + - name: Install yq (windows only) + if: "matrix.os == 'windows-latest'" + run: choco install yq - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 diff --git a/docs/data-sources/command.md b/docs/data-sources/command.md new file mode 100644 index 00000000..e03ab101 --- /dev/null +++ b/docs/data-sources/command.md @@ -0,0 +1,63 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "local_command Data Source - terraform-provider-local" +subcategory: "" +description: |- + Runs an executable on the local machine and returns the exit code, standard output data (stdout), and standard error data (stderr). All environment variables visible to the Terraform process are passed through to the child process. Both stdout and stderr returned by this data source are UTF-8 strings, which can be decoded into Terraform values https://developer.hashicorp.com/terraform/language/expressions/types for use elsewhere in the Terraform configuration. There are built-in decoding functions such as jsondecode https://developer.hashicorp.com/terraform/language/functions/jsondecode or yamldecode https://developer.hashicorp.com/terraform/language/functions/yamldecode, and more specialized decoding functions https://developer.hashicorp.com/terraform/plugin/framework/functions/concepts can be built with a Terraform provider. + Any non-zero exit code returned by the command will be treated as an error and will return a diagnostic to Terraform containing the stderr message if available. If a non-zero exit code is expected by the command, set allow_non_zero_exit_code to true. + ~> 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 data source, and implementing a data source via a local executable 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. + ~> Warning HCP Terraform and Terraform Enterprise do not guarantee availability of any particular language runtimes or external programs beyond standard shell utilities, so it is not recommended to use this data source within configurations that are applied within either. +--- + +# local_command (Data Source) + +Runs an executable on the local machine and returns the exit code, standard output data (`stdout`), and standard error data (`stderr`). All environment variables visible to the Terraform process are passed through to the child process. Both `stdout` and `stderr` returned by this data source are UTF-8 strings, which can be decoded into [Terraform values](https://developer.hashicorp.com/terraform/language/expressions/types) for use elsewhere in the Terraform configuration. There are built-in decoding functions such as [`jsondecode`](https://developer.hashicorp.com/terraform/language/functions/jsondecode) or [`yamldecode`](https://developer.hashicorp.com/terraform/language/functions/yamldecode), and more specialized [decoding functions](https://developer.hashicorp.com/terraform/plugin/framework/functions/concepts) can be built with a Terraform provider. + +Any non-zero exit code returned by the command will be treated as an error and will return a diagnostic to Terraform containing the `stderr` message if available. If a non-zero exit code is expected by the command, set `allow_non_zero_exit_code` to `true`. + +~> **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 data source, and implementing a data source via a local executable 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. + +~> **Warning** HCP Terraform and Terraform Enterprise do not guarantee availability of any particular language runtimes or external programs beyond standard shell utilities, so it is not recommended to use this data source within configurations that are applied within either. + +## Example Usage + +```terraform +// A toy example using the JSON utility `jq` to process Terraform data +// https://jqlang.org/ +data "local_command" "filter_fruit" { + command = "jq" + stdin = jsonencode([{ name = "apple" }, { name = "lemon" }, { name = "apricot" }]) + arguments = [".[:2] | [.[].name]"] # Grab the first two fruit names from the list +} + +output "fruit_tf" { + value = jsondecode(data.local_command.filter_fruit.stdout) +} + +# Outputs: +# +# fruit_tf = [ +# "apple", +# "lemon", +# ] +``` + + +## Schema + +### Required + +- `command` (String) Executable name to be discovered on the PATH or absolute path to executable. + +### Optional + +- `allow_non_zero_exit_code` (Boolean) Indicates that the command returning a non-zero exit code should be treated as a successful execution. Further assertions can be made of the `exit_code` value with the [`check` block](https://developer.hashicorp.com/terraform/language/block/check). Defaults to false. +- `arguments` (List of String) Arguments to be passed to the given command. Any `null` arguments will be removed from the list. +- `stdin` (String) Data to be passed to the given command's standard input as a UTF-8 string. [Terraform values](https://developer.hashicorp.com/terraform/language/expressions/types) can be encoded by any Terraform encode function, for example, [`jsonencode`](https://developer.hashicorp.com/terraform/language/functions/jsonencode). +- `working_directory` (String) The directory path where the command should be executed, either an absolute path or relative to the Terraform working directory. If not provided, defaults to the Terraform working directory. + +### Read-Only + +- `exit_code` (Number) The exit code returned by the command. By default, if the exit code is non-zero, the data source will return a diagnostic to Terraform. If a non-zero exit code is expected by the command, set `allow_non_zero_exit_code` to `true`. +- `stderr` (String) Data returned from the command's standard error stream. The data is returned directly from the command as a UTF-8 string and will be populated regardless of the exit code returned. +- `stdout` (String) Data returned from the command's standard output stream. The data is returned directly from the command as a UTF-8 string, which can then be decoded by any Terraform decode function, for example, [`jsondecode`](https://developer.hashicorp.com/terraform/language/functions/jsondecode). diff --git a/examples/data-sources/local_command/data-source.tf b/examples/data-sources/local_command/data-source.tf new file mode 100644 index 00000000..ea1bff16 --- /dev/null +++ b/examples/data-sources/local_command/data-source.tf @@ -0,0 +1,18 @@ +// A toy example using the JSON utility `jq` to process Terraform data +// https://jqlang.org/ +data "local_command" "filter_fruit" { + command = "jq" + stdin = jsonencode([{ name = "apple" }, { name = "lemon" }, { name = "apricot" }]) + arguments = [".[:2] | [.[].name]"] # Grab the first two fruit names from the list +} + +output "fruit_tf" { + value = jsondecode(data.local_command.filter_fruit.stdout) +} + +# Outputs: +# +# fruit_tf = [ +# "apple", +# "lemon", +# ] diff --git a/internal/provider/data_source_local_command.go b/internal/provider/data_source_local_command.go new file mode 100644 index 00000000..5e768adc --- /dev/null +++ b/internal/provider/data_source_local_command.go @@ -0,0 +1,227 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "runtime" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ datasource.DataSource = (*localCommandDataSource)(nil) +) + +func NewLocalCommandDataSource() datasource.DataSource { + return &localCommandDataSource{} +} + +type localCommandDataSource struct{} + +func (a *localCommandDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_command" +} + +func (a *localCommandDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Runs an executable on the local machine and returns the exit code, standard output data (`stdout`), and standard error data (`stderr`). " + + "All environment variables visible to the Terraform process are passed through to the child process. Both `stdout` and `stderr` returned by this data source " + + "are UTF-8 strings, which can be decoded into [Terraform values](https://developer.hashicorp.com/terraform/language/expressions/types) for use elsewhere in the Terraform configuration. " + + "There are built-in decoding functions such as [`jsondecode`](https://developer.hashicorp.com/terraform/language/functions/jsondecode) or [`yamldecode`](https://developer.hashicorp.com/terraform/language/functions/yamldecode), " + + "and more specialized [decoding functions](https://developer.hashicorp.com/terraform/plugin/framework/functions/concepts) can be built with a Terraform provider." + + "\n\n" + + "Any non-zero exit code returned by the command will be treated as an error and will return a diagnostic to Terraform containing the `stderr` message if available. " + + "If a non-zero exit code is expected by the command, set `allow_non_zero_exit_code` to `true`." + + "\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 data source, and implementing a data source via a local executable 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** HCP Terraform and Terraform Enterprise do not guarantee availability of any particular language runtimes or external programs beyond standard shell utilities, " + + "so it is not recommended to use this data source within configurations that are applied within either.", + Attributes: map[string]schema.Attribute{ + "command": schema.StringAttribute{ + Description: "Executable name to be discovered on the PATH or absolute path to executable.", + Required: true, + }, + "arguments": schema.ListAttribute{ + MarkdownDescription: "Arguments to be passed to the given command. Any `null` arguments will be removed from the list.", + ElementType: types.StringType, + Optional: true, + }, + "stdin": schema.StringAttribute{ + MarkdownDescription: "Data to be passed to the given command's standard input as a UTF-8 string. [Terraform values](https://developer.hashicorp.com/terraform/language/expressions/types) can be encoded " + + "by any Terraform encode function, for example, [`jsonencode`](https://developer.hashicorp.com/terraform/language/functions/jsonencode).", + Optional: true, + }, + "working_directory": schema.StringAttribute{ + Description: "The directory path where the command should be executed, either an absolute path or relative to the Terraform working directory. If not provided, defaults to the Terraform working directory.", + Optional: true, + }, + "allow_non_zero_exit_code": schema.BoolAttribute{ + MarkdownDescription: "Indicates that the command returning a non-zero exit code should be treated as a successful execution. " + + "Further assertions can be made of the `exit_code` value with the [`check` block](https://developer.hashicorp.com/terraform/language/block/check). Defaults to false.", + Optional: true, + }, + "exit_code": schema.Int64Attribute{ + MarkdownDescription: "The exit code returned by the command. By default, if the exit code is non-zero, the data source will return a diagnostic to Terraform. " + + "If a non-zero exit code is expected by the command, set `allow_non_zero_exit_code` to `true`.", + Computed: true, + }, + "stdout": schema.StringAttribute{ + MarkdownDescription: "Data returned from the command's standard output stream. The data is returned directly from the command as a UTF-8 string, " + + "which can then be decoded by any Terraform decode function, for example, [`jsondecode`](https://developer.hashicorp.com/terraform/language/functions/jsondecode).", + Computed: true, + }, + "stderr": schema.StringAttribute{ + Description: "Data returned from the command's standard error stream. The data is returned directly from the command as a UTF-8 string and will be " + + "populated regardless of the exit code returned.", + Computed: true, + }, + }, + } +} + +type localCommandDataSourceModel struct { + Command types.String `tfsdk:"command"` + Arguments types.List `tfsdk:"arguments"` + Stdin types.String `tfsdk:"stdin"` + WorkingDirectory types.String `tfsdk:"working_directory"` + AllowNonZeroExitCode types.Bool `tfsdk:"allow_non_zero_exit_code"` + ExitCode types.Int64 `tfsdk:"exit_code"` + Stdout types.String `tfsdk:"stdout"` + Stderr types.String `tfsdk:"stderr"` +} + +func (a *localCommandDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var state localCommandDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Prep the command + command := state.Command.ValueString() + if _, err := exec.LookPath(command); err != nil { + resp.Diagnostics.AddAttributeError( + path.Root("command"), + "Command Lookup Failed", + "The data source received an unexpected error while attempting to find the command."+ + "\n\n"+ + "The command must be accessible according to the platform where Terraform is running."+ + "\n\n"+ + "If the expected command should be automatically found on the platform where Terraform is running, "+ + "ensure that the command 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."+ + "\n\n"+ + "If the expected command is relative to the Terraform configuration, it is recommended that the command name includes "+ + "the interpolated value of 'path.module' before the command name to ensure that it is compatible with varying module usage. For example: \"${path.module}/my-command\""+ + "\n\n"+ + "The command 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."+ + "\n\n"+ + fmt.Sprintf("Platform: %s\n", runtime.GOOS)+ + fmt.Sprintf("Command: %s\n", command)+ + fmt.Sprintf("Error: %s", err), + ) + return + } + + arguments := make([]string, 0) + for _, element := range state.Arguments.Elements() { + strElement, ok := element.(types.String) + // Mirroring the underlying os/exec Command support for args (no nil arguments, but does support empty strings) + if element.IsNull() || !ok { + continue + } + + arguments = append(arguments, strElement.ValueString()) + } + + cmd := exec.CommandContext(ctx, command, arguments...) + + cmd.Dir = state.WorkingDirectory.ValueString() + + if !state.Stdin.IsNull() { + cmd.Stdin = bytes.NewReader([]byte(state.Stdin.ValueString())) + } + + var stderr strings.Builder + cmd.Stderr = &stderr + var stdout strings.Builder + cmd.Stdout = &stdout + + tflog.Trace(ctx, "Executing local command", map[string]interface{}{"command": cmd.String()}) + + // Run the command + commandErr := cmd.Run() + stdoutStr := stdout.String() + stderrStr := stderr.String() + + if len(stderrStr) > 0 { + state.Stderr = types.StringValue(stderrStr) + } + + if len(stdoutStr) > 0 { + state.Stdout = types.StringValue(stdoutStr) + } + + // ProcessState will always be populated if the command has been was successfully started (regardless of exit code) + if cmd.ProcessState != nil { + exitCode := cmd.ProcessState.ExitCode() + state.ExitCode = types.Int64Value(int64(exitCode)) + } + + tflog.Trace(ctx, "Executed local command", map[string]interface{}{"command": cmd.String(), "stdout": stdoutStr, "stderr": stderrStr}) + + // Set all of the data to state + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) + if commandErr == nil { + return + } + + // If running the command returned an exit error, we need to check and see if we should explicitly raise a diagnostic + if exitError, ok := commandErr.(*exec.ExitError); ok { + // We won't return a diagnostic because the command was successfully started and then exited + // with a non-zero exit code (which the user has indicated they will handle in configuration). + // + // All data has already been saved to state, so we just return. + if state.AllowNonZeroExitCode.ValueBool() { + return + } + + resp.Diagnostics.AddAttributeError( + path.Root("command"), + "Command Execution Failed", + "The data source executed the command but received a non-zero exit code. If a non-zero exit code is expected "+ + "and can be handled in configuration, set \"allow_non_zero_exit_code\" to true."+ + "\n\n"+ + fmt.Sprintf("Command: %s\n", cmd.String())+ + fmt.Sprintf("Command Error: %s\n", stderrStr)+ + fmt.Sprintf("State: %s", exitError), + ) + return + } + + // We need to raise a diagnostic because the command wasn't successfully started and we have no exit code. + resp.Diagnostics.AddAttributeError( + path.Root("command"), + "Command Execution Failed", + "The data source received an unexpected error while attempting to execute the command."+ + "\n\n"+ + fmt.Sprintf("Command: %s\n", cmd.String())+ + fmt.Sprintf("State: %s", commandErr), + ) +} diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go new file mode 100644 index 00000000..0e00061c --- /dev/null +++ b/internal/provider/data_source_local_command_test.go @@ -0,0 +1,489 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "fmt" + "os/exec" + "path/filepath" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "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" +) + +// Test is dependent on: https://github.com/jqlang/jq +func TestLocalCommandDataSource_stdout_json(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + // Parses the incoming STDIN and return single JSON object + Config: `data "local_command" "test" { + command = "jq" + stdin = jsonencode([ + { + arr = [1, 2, 3] + bool = true, + num = 1.23 + str = "obj1" + }, + { + arr = [3, 4, 5] + bool = false, + num = 2.34 + str = "obj2" + }, + ]) + arguments = [".[] | select(.str == \"obj1\")"] + } + + output "parse_stdout" { + value = jsondecode(data.local_command.test.stdout) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), + statecheck.ExpectKnownOutputValue("parse_stdout", knownvalue.ObjectExact(map[string]knownvalue.Check{ + "arr": knownvalue.ListExact([]knownvalue.Check{ + knownvalue.Int64Exact(1), + knownvalue.Int64Exact(2), + knownvalue.Int64Exact(3), + }), + "bool": knownvalue.Bool(true), + "num": knownvalue.Float64Exact(1.23), + "str": knownvalue.StringExact("obj1"), + })), + }, + }, + { + // Parses the incoming STDIN and return the first and third elements in a JSON array + Config: `data "local_command" "test" { + command = "jq" + stdin = jsonencode([ + { + obj1_attr = "hello" + }, + { + obj2_attr = "world!" + }, + { + obj3_attr = 1.23 + }, + ]) + arguments = ["[.[0, 2]]"] + } + + output "parse_stdout" { + value = jsondecode(data.local_command.test.stdout) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), + statecheck.ExpectKnownOutputValue("parse_stdout", knownvalue.TupleExact([]knownvalue.Check{ + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "obj1_attr": knownvalue.StringExact("hello"), + }), + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "obj3_attr": knownvalue.Float64Exact(1.23), + }), + })), + }, + }, + }, + }) +} + +// Test is dependent on: https://github.com/jqlang/jq +func TestLocalCommandDataSource_stdout_csv(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + // Parses the incoming STDIN (3 JSON arrays) and return as rows in CSV format + Config: `data "local_command" "test" { + command = "jq" + stdin = "[\"str\",\"num\",\"bool\"][\"hello\", 1.23, true][\"world!\", 2.34, false]" + arguments = ["-r", "@csv"] + } + + output "parse_stdout" { + value = tolist(csvdecode(data.local_command.test.stdout)) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), + // MAINTAINER NOTE: csvdecode function converts all attributes as strings + // https://github.com/zclconf/go-cty/blob/da4c600729aefcf628d6b042ee439e6927d1104e/cty/function/stdlib/csv.go#L72-L77 + statecheck.ExpectKnownOutputValue("parse_stdout", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "str": knownvalue.StringExact("hello"), + "num": knownvalue.StringExact("1.23"), + "bool": knownvalue.StringExact("true"), + }), + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "str": knownvalue.StringExact("world!"), + "num": knownvalue.StringExact("2.34"), + "bool": knownvalue.StringExact("false"), + }), + })), + }, + }, + }, + }) +} + +// Test is dependent on: https://github.com/mikefarah/yq +func TestLocalCommandDataSource_stdout_yaml(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + // Parses the incoming STDIN and return single YAML object + Config: `data "local_command" "test" { + command = "yq" + stdin = yamlencode([ + { + arr = [1, 2, 3] + bool = true, + num = 1.23 + str = "obj1" + }, + { + arr = [3, 4, 5] + bool = false, + num = 2.34 + str = "obj2" + }, + ]) + arguments = [".[] | select(.str == \"obj1\")"] + } + + output "parse_stdout" { + value = yamldecode(data.local_command.test.stdout) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), + statecheck.ExpectKnownOutputValue("parse_stdout", knownvalue.ObjectExact(map[string]knownvalue.Check{ + "arr": knownvalue.ListExact([]knownvalue.Check{ + knownvalue.Int64Exact(1), + knownvalue.Int64Exact(2), + knownvalue.Int64Exact(3), + }), + "bool": knownvalue.Bool(true), + "num": knownvalue.Float64Exact(1.23), + "str": knownvalue.StringExact("obj1"), + })), + }, + }, + { + // Parses the incoming STDIN and return the first and third elements in a YAML array + Config: `data "local_command" "test" { + command = "yq" + stdin = yamlencode([ + { + obj1_attr = "hello" + }, + { + obj2_attr = "world!" + }, + { + obj3_attr = 1.23 + }, + ]) + arguments = ["[.[0, 2]]"] + } + + output "parse_stdout" { + value = yamldecode(data.local_command.test.stdout) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), + statecheck.ExpectKnownOutputValue("parse_stdout", knownvalue.TupleExact([]knownvalue.Check{ + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "obj1_attr": knownvalue.StringExact("hello"), + }), + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "obj3_attr": knownvalue.Float64Exact(1.23), + }), + })), + }, + }, + }, + }) +} + +func TestLocalCommandDataSource_stdout_no_format_null_args(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + // Terraform 0.13.x has a bug where data sources are read before dependent managed resources are created, resulting in a "test script not found" error. + // https://github.com/hashicorp/terraform/pull/26284 + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBetween(tfversion.Version0_13_0, tfversion.Version0_14_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: `resource "local_file" "test_script" { + filename = "${path.module}/test_script.sh" + content = <&2 +echo "args: $@" +EOT + } + + data "local_command" "test" { + command = "bash" + stdin = "stdin-string" + arguments = [local_file.test_script.filename, "first-arg", "second-arg"] + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.StringExact("stdin: stdin-string\n")), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stdout"), knownvalue.StringExact("args: first-arg second-arg\n")), + }, + }, + }, + }) +} + +func TestLocalCommandDataSource_stdout_invalid_string(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + // Terraform 0.13.x has a bug where data sources are read before dependent managed resources are created, resulting in a "test script not found" error. + // https://github.com/hashicorp/terraform/pull/26284 + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBetween(tfversion.Version0_13_0, tfversion.Version0_14_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: `resource "local_file" "test_script" { + filename = "${path.module}/test_script.sh" + content = <&2 +exit 1 +EOT + } + + data "local_command" "test" { + command = "bash" + arguments = [local_file.test_script.filename] + }`, + ExpectError: regexp.MustCompile(`The data source executed the command but received a non-zero exit code.`), + }, + }, + }) +} + +func TestLocalCommandDataSource_allow_non_zero_exit_code(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + // Terraform 0.13.x has a bug where data sources are read before dependent managed resources are created, resulting in a "test script not found" error. + // https://github.com/hashicorp/terraform/pull/26284 + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBetween(tfversion.Version0_13_0, tfversion.Version0_14_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: `resource "local_file" "test_script" { + filename = "${path.module}/test_script.sh" + content = <&2 +exit 1 +EOT + } + + data "local_command" "test" { + command = "bash" + allow_non_zero_exit_code = true + arguments = [local_file.test_script.filename] + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(1)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.StringExact("😒")), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stdout"), knownvalue.StringExact("😏")), + }, + }, + }, + }) +} + +func TestLocalCommandDataSource_absolute_path_with_working_directory(t *testing.T) { + // Create a temporary testing directory to point to / assert with + tempDir := t.TempDir() + testScriptPath := filepath.Join(tempDir, "test_script.sh") + + startOfTempDir := filepath.Base(filepath.Dir(tempDir)) + // MAINTAINER NOTE: Typically, you'd want to use filepath.Join here, but the Windows GHA runner will use bash in WSL, so the test assertion needs + // to always be Unix format (forward slashes). On top of that, since it uses WSL, we can't assert with the absolute path `tempDir` because WSL + // will give us a new (UNIX formatted) absolute path and a failing test :). Comparing with the last two directory names is enough to verify that + // the working_directory was correctly set. + tempWdRegex := regexp.MustCompile(fmt.Sprintf("%s/%s", startOfTempDir, filepath.Base(tempDir))) + + bashAbsPath, err := exec.LookPath("bash") + if err != nil { + t.Fatalf("Failed to find bash executable: %s", err) + } + + resource.UnitTest(t, resource.TestCase{ + // Terraform 0.13.x has a bug where data sources are read before dependent managed resources are created, resulting in a "test script not found" error. + // https://github.com/hashicorp/terraform/pull/26284 + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBetween(tfversion.Version0_13_0, tfversion.Version0_14_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(`resource "local_file" "test_script" { + filename = %[1]q + content = <