Add support for `query` subcommand (#525)
diff --git a/tfexec/internal/e2etest/query_test.go b/tfexec/internal/e2etest/query_test.go
new file mode 100644
index 0000000..4d7f16b
--- /dev/null
+++ b/tfexec/internal/e2etest/query_test.go
@@ -0,0 +1,57 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package e2etest
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/hashicorp/go-version"
+ "github.com/hashicorp/terraform-exec/tfexec"
+ "github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
+)
+
+func TestQueryJSON_TF112(t *testing.T) {
+ versions := []string{testutil.Latest_v1_12}
+
+ runTestWithVersions(t, versions, "query", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+ err := tf.Init(context.Background())
+ if err != nil {
+ t.Fatalf("error running Init in test directory: %s", err)
+ }
+
+ re := regexp.MustCompile("terraform query -json was added in 1.14.0")
+
+ err = tf.QueryJSON(context.Background(), io.Discard)
+ if err != nil && !re.MatchString(err.Error()) {
+ t.Fatalf("error running Query: %s", err)
+ }
+ })
+}
+
+func TestQueryJSON_TF114(t *testing.T) {
+ versions := []string{testutil.Latest_Alpha_v1_14}
+
+ runTestWithVersions(t, versions, "query", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+ err := tf.Init(context.Background())
+ if err != nil {
+ t.Fatalf("error running Init in test directory: %s", err)
+ }
+
+ var output bytes.Buffer
+ err = tf.QueryJSON(context.Background(), &output)
+ if err != nil {
+ t.Fatalf("error running Query: %s", err)
+ }
+
+ results := strings.Count(output.String(), "list.concept_pet.pets: Result found")
+ if results != 5 {
+ t.Fatalf("expected 5 query results, but got %d", results)
+ }
+ })
+}
diff --git a/tfexec/internal/e2etest/testdata/query/main.tf b/tfexec/internal/e2etest/testdata/query/main.tf
new file mode 100644
index 0000000..a6cb2c2
--- /dev/null
+++ b/tfexec/internal/e2etest/testdata/query/main.tf
@@ -0,0 +1,8 @@
+terraform {
+ required_providers {
+ concept = {
+ source = "dbanck/concept"
+ version = "0.1.0"
+ }
+ }
+}
diff --git a/tfexec/internal/e2etest/testdata/query/main.tfquery.hcl b/tfexec/internal/e2etest/testdata/query/main.tfquery.hcl
new file mode 100644
index 0000000..99a5b2a
--- /dev/null
+++ b/tfexec/internal/e2etest/testdata/query/main.tfquery.hcl
@@ -0,0 +1,3 @@
+list "concept_pet" "pets" {
+ provider = concept
+}
diff --git a/tfexec/internal/testutil/tfcache.go b/tfexec/internal/testutil/tfcache.go
index 6c4d09d..fa7d260 100644
--- a/tfexec/internal/testutil/tfcache.go
+++ b/tfexec/internal/testutil/tfcache.go
@@ -36,6 +36,7 @@
Latest_Alpha_v1_10 = "1.10.0-alpha20240926"
Latest_v1_11 = "1.11.4"
Latest_v1_12 = "1.12.2"
+ Latest_Alpha_v1_14 = "1.14.0-alpha20250903"
)
const appendUserAgent = "tfexec-testutil"
diff --git a/tfexec/options.go b/tfexec/options.go
index 339bf39..0129bd5 100644
--- a/tfexec/options.go
+++ b/tfexec/options.go
@@ -184,6 +184,14 @@
return &FromModuleOption{source}
}
+type GenerateConfigOutOption struct {
+ path string
+}
+
+func GenerateConfigOut(path string) *GenerateConfigOutOption {
+ return &GenerateConfigOutOption{path}
+}
+
type GetOption struct {
get bool
}
diff --git a/tfexec/query.go b/tfexec/query.go
new file mode 100644
index 0000000..04021d1
--- /dev/null
+++ b/tfexec/query.go
@@ -0,0 +1,134 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package tfexec
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os/exec"
+)
+
+type queryConfig struct {
+ dir string
+ generateConfig string
+ reattachInfo ReattachInfo
+ vars []string
+ varFiles []string
+}
+
+var defaultQueryOptions = queryConfig{}
+
+// QueryOption represents options used in the Query method.
+type QueryOption interface {
+ configureQuery(*queryConfig)
+}
+
+func (opt *DirOption) configureQuery(conf *queryConfig) {
+ conf.dir = opt.path
+}
+
+func (opt *GenerateConfigOutOption) configureQuery(conf *queryConfig) {
+ conf.generateConfig = opt.path
+}
+
+func (opt *ReattachOption) configureQuery(conf *queryConfig) {
+ conf.reattachInfo = opt.info
+}
+
+func (opt *VarFileOption) configureQuery(conf *queryConfig) {
+ conf.varFiles = append(conf.varFiles, opt.path)
+}
+
+func (opt *VarOption) configureQuery(conf *queryConfig) {
+ conf.vars = append(conf.vars, opt.assignment)
+}
+
+// QueryJSON executes `terraform query` with the specified options as well as the
+// `-json` flag and waits for it to complete.
+//
+// Using the `-json` flag will result in
+// [machine-readable](https://developer.hashicorp.com/terraform/internals/machine-readable-ui)
+// JSON being written to the supplied `io.Writer`.
+//
+// The returned error is nil if `terraform query` has been executed and exits
+// with 0.
+//
+// QueryJSON is likely to be removed in a future major version in favour of
+// query returning JSON by default.
+func (tf *Terraform) QueryJSON(ctx context.Context, w io.Writer, opts ...QueryOption) error {
+ err := tf.compatible(ctx, tf1_14_0, nil)
+ if err != nil {
+ return fmt.Errorf("terraform query -json was added in 1.14.0: %w", err)
+ }
+
+ tf.SetStdout(w)
+
+ cmd, err := tf.queryJSONCmd(ctx, opts...)
+ if err != nil {
+ return err
+ }
+
+ err = tf.runTerraformCmd(ctx, cmd)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (tf *Terraform) queryJSONCmd(ctx context.Context, opts ...QueryOption) (*exec.Cmd, error) {
+ c := defaultQueryOptions
+
+ for _, o := range opts {
+ o.configureQuery(&c)
+ }
+
+ args, err := tf.buildQueryArgs(ctx, c)
+ if err != nil {
+ return nil, err
+ }
+
+ args = append(args, "-json")
+
+ return tf.buildQueryCmd(ctx, c, args)
+}
+
+func (tf *Terraform) buildQueryArgs(ctx context.Context, c queryConfig) ([]string, error) {
+ args := []string{"query", "-no-color"}
+
+ if c.generateConfig != "" {
+ args = append(args, "-generate-config-out="+c.generateConfig)
+ }
+
+ for _, vf := range c.varFiles {
+ args = append(args, "-var-file="+vf)
+ }
+
+ if c.vars != nil {
+ for _, v := range c.vars {
+ args = append(args, "-var", v)
+ }
+ }
+
+ return args, nil
+}
+
+func (tf *Terraform) buildQueryCmd(ctx context.Context, c queryConfig, args []string) (*exec.Cmd, error) {
+ // optional positional argument
+ if c.dir != "" {
+ args = append(args, c.dir)
+ }
+
+ mergeEnv := map[string]string{}
+ if c.reattachInfo != nil {
+ reattachStr, err := c.reattachInfo.marshalString()
+ if err != nil {
+ return nil, err
+ }
+ mergeEnv[reattachEnvVar] = reattachStr
+ }
+
+ return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil
+}
diff --git a/tfexec/query_test.go b/tfexec/query_test.go
new file mode 100644
index 0000000..9291667
--- /dev/null
+++ b/tfexec/query_test.go
@@ -0,0 +1,59 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package tfexec
+
+import (
+ "context"
+ "testing"
+
+ "github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
+)
+
+func TestQueryJSONCmd(t *testing.T) {
+ td := t.TempDir()
+
+ tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_Alpha_v1_14))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // empty env, to avoid environ mismatch in testing
+ tf.SetEnv(map[string]string{})
+
+ t.Run("defaults", func(t *testing.T) {
+ queryCmd, err := tf.queryJSONCmd(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assertCmd(t, []string{
+ "query",
+ "-no-color",
+ "-json",
+ }, nil, queryCmd)
+ })
+
+ t.Run("override all", func(t *testing.T) {
+ queryCmd, err := tf.queryJSONCmd(context.Background(),
+ GenerateConfigOut("generated.tf"),
+ Var("android=paranoid"),
+ Var("brain_size=planet"),
+ VarFile("trillian"),
+ Dir("earth"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assertCmd(t, []string{
+ "query",
+ "-no-color",
+ "-generate-config-out=generated.tf",
+ "-var-file=trillian",
+ "-var", "android=paranoid",
+ "-var", "brain_size=planet",
+ "-json",
+ "earth",
+ }, nil, queryCmd)
+ })
+}
diff --git a/tfexec/version.go b/tfexec/version.go
index 87addd1..40b9301 100644
--- a/tfexec/version.go
+++ b/tfexec/version.go
@@ -34,6 +34,8 @@
tf1_4_0 = version.Must(version.NewVersion("1.4.0"))
tf1_6_0 = version.Must(version.NewVersion("1.6.0"))
tf1_9_0 = version.Must(version.NewVersion("1.9.0"))
+ tf1_13_0 = version.Must(version.NewVersion("1.13.0"))
+ tf1_14_0 = version.Must(version.NewVersion("1.14.0"))
)
// Version returns structured output from the terraform version command including both the Terraform CLI version