Implement `QueryJSON` and introduce new way for consuming Terraform's structured logging (#539)

Co-authored-by: Radek Simko <radeksimko@users.noreply.github.com>
Co-authored-by: Sarah French <15078782+SarahFrench@users.noreply.github.com>
diff --git a/go.mod b/go.mod
index 19545b4..cd72c26 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@
 	github.com/google/go-cmp v0.7.0
 	github.com/hashicorp/go-version v1.7.0
 	github.com/hashicorp/hc-install v0.9.2
-	github.com/hashicorp/terraform-json v0.26.0
+	github.com/hashicorp/terraform-json v0.27.1
 	github.com/zclconf/go-cty v1.16.4
 	github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b
 )
diff --git a/go.sum b/go.sum
index 9b15158..64bde7f 100644
--- a/go.sum
+++ b/go.sum
@@ -51,8 +51,8 @@
 github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
 github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24=
 github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I=
-github.com/hashicorp/terraform-json v0.26.0 h1:+BnJavhRH+oyNWPnfzrfQwVWCZBFMvjdiH2Vi38Udz4=
-github.com/hashicorp/terraform-json v0.26.0/go.mod h1:eyWCeC3nrZamyrKLFnrvwpc3LQPIJsx8hWHQ/nu2/v4=
+github.com/hashicorp/terraform-json v0.27.1 h1:zWhEracxJW6lcjt/JvximOYyc12pS/gaKSy/wzzE7nY=
+github.com/hashicorp/terraform-json v0.27.1/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
diff --git a/tfexec/cmd.go b/tfexec/cmd.go
index cd5a7e2..47e0e68 100644
--- a/tfexec/cmd.go
+++ b/tfexec/cmd.go
@@ -12,12 +12,14 @@
 	"fmt"
 	"io"
 	"io/ioutil"
+	"iter"
 	"os"
 	"os/exec"
 	"runtime"
 	"strings"
 
 	"github.com/hashicorp/terraform-exec/internal/version"
+	tfjson "github.com/hashicorp/terraform-json"
 )
 
 const (
@@ -216,6 +218,72 @@
 	return dec.Decode(v)
 }
 
+func (tf *Terraform) runTerraformCmdJSONLog(ctx context.Context, cmd *exec.Cmd) iter.Seq[NextMessage] {
+	pr, pw := io.Pipe()
+	tf.SetStdout(pw)
+
+	emitter := newLogMsgEmitter(pr)
+
+	go func() {
+		err := tf.runTerraformCmd(ctx, cmd)
+		emitter.done <- errors.Join(err, pw.Close())
+	}()
+
+	return func(yield func(msg NextMessage) bool) {
+		for {
+			nextMsg := emitter.NextMessage()
+			ok := yield(nextMsg)
+			if !ok || nextMsg.Msg == nil {
+				return
+			}
+		}
+	}
+}
+
+func newLogMsgEmitter(stdoutReader io.ReadCloser) *logMsgEmitter {
+	return &logMsgEmitter{
+		scanner:      bufio.NewScanner(stdoutReader),
+		stdoutReader: stdoutReader,
+		done:         make(chan error, 1),
+	}
+}
+
+type logMsgEmitter struct {
+	scanner      *bufio.Scanner
+	stdoutReader io.Closer
+	done         chan error
+}
+
+type NextMessage struct {
+	Msg tfjson.LogMsg
+	Err error
+}
+
+// NextMessage returns next decoded message, if any, along with any errors.
+// Stdout reader is closed when the last message is received.
+//
+// Error returned can be related to decoding of the message, the Terraform command
+// or closing of stdout reader.
+//
+// Any error coming from Terraform (such as wrong configuration syntax) is
+// represented as LogMsg of Level [tfjson.Error].
+func (e *logMsgEmitter) NextMessage() NextMessage {
+	if e.scanner.Scan() {
+		msg, err := tfjson.UnmarshalLogMessage(e.scanner.Bytes())
+		return NextMessage{
+			Msg: msg,
+			Err: err,
+		}
+	}
+
+	err := <-e.done
+	err = errors.Join(err, e.scanner.Err(), e.stdoutReader.Close())
+	return NextMessage{
+		Msg: nil,
+		Err: err,
+	}
+}
+
 // mergeUserAgent does some minor deduplication to ensure we aren't
 // just using the same append string over and over.
 func mergeUserAgent(uas ...string) string {
diff --git a/tfexec/internal/e2etest/query_test.go b/tfexec/internal/e2etest/query_test.go
new file mode 100644
index 0000000..0ca5cfd
--- /dev/null
+++ b/tfexec/internal/e2etest/query_test.go
@@ -0,0 +1,82 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package e2etest
+
+import (
+	"context"
+	"regexp"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/hashicorp/go-version"
+	"github.com/hashicorp/terraform-exec/tfexec"
+	"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
+	tfjson "github.com/hashicorp/terraform-json"
+)
+
+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())
+		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)
+		}
+
+		iter, err := tf.QueryJSON(context.Background())
+		if err != nil {
+			t.Fatalf("error running Query: %s", err)
+		}
+
+		results := 0
+		listingStarted := 0
+		var completeData tfjson.ListCompleteData
+		for nextMsg := range iter {
+			if nextMsg.Err != nil {
+				t.Fatalf("error getting next message: %s", err)
+			}
+			switch m := nextMsg.Msg.(type) {
+			case tfjson.ListStartMessage:
+				listingStarted++
+			case tfjson.ListResourceFoundMessage:
+				results++
+			case tfjson.ListCompleteMessage:
+				completeData = m.ListComplete
+			}
+		}
+
+		if listingStarted != 1 {
+			t.Fatalf("expected exactly 1 list start message, got %d", listingStarted)
+		}
+		if results != 5 {
+			t.Fatalf("expected 5 query results, got %d", results)
+		}
+		expectedData := tfjson.ListCompleteData{
+			Address:      "list.concept_pet.pets",
+			ResourceType: "concept_pet",
+			Total:        5,
+		}
+		if diff := cmp.Diff(expectedData, completeData); diff != "" {
+			t.Fatalf("unexpected complete message data: %s", diff)
+		}
+	})
+}
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..04b515f
--- /dev/null
+++ b/tfexec/query.go
@@ -0,0 +1,127 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package tfexec
+
+import (
+	"context"
+	"fmt"
+	"iter"
+	"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, opts ...QueryOption) (iter.Seq[NextMessage], error) {
+	err := tf.compatible(ctx, tf1_14_0, nil)
+	if err != nil {
+		return nil, fmt.Errorf("terraform query -json was added in 1.14.0: %w", err)
+	}
+
+	queryCmd, err := tf.queryJSONCmd(ctx, opts...)
+	if err != nil {
+		return nil, err
+	}
+
+	return tf.runTerraformCmdJSONLog(ctx, queryCmd), 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