Skip to content

Commit 699d77f

Browse files
dbanckradeksimkoSarahFrench
authored
Implement QueryJSON and introduce new way for consuming Terraform's structured logging (#539)
Co-authored-by: Radek Simko <[email protected]> Co-authored-by: Sarah French <[email protected]>
1 parent d46cfbd commit 699d77f

File tree

11 files changed

+361
-3
lines changed

11 files changed

+361
-3
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ require (
88
github.com/google/go-cmp v0.7.0
99
github.com/hashicorp/go-version v1.7.0
1010
github.com/hashicorp/hc-install v0.9.2
11-
github.com/hashicorp/terraform-json v0.26.0
11+
github.com/hashicorp/terraform-json v0.27.1
1212
github.com/zclconf/go-cty v1.16.4
1313
github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b
1414
)

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe
5151
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
5252
github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24=
5353
github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I=
54-
github.com/hashicorp/terraform-json v0.26.0 h1:+BnJavhRH+oyNWPnfzrfQwVWCZBFMvjdiH2Vi38Udz4=
55-
github.com/hashicorp/terraform-json v0.26.0/go.mod h1:eyWCeC3nrZamyrKLFnrvwpc3LQPIJsx8hWHQ/nu2/v4=
54+
github.com/hashicorp/terraform-json v0.27.1 h1:zWhEracxJW6lcjt/JvximOYyc12pS/gaKSy/wzzE7nY=
55+
github.com/hashicorp/terraform-json v0.27.1/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE=
5656
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
5757
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
5858
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=

tfexec/cmd.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import (
1212
"fmt"
1313
"io"
1414
"io/ioutil"
15+
"iter"
1516
"os"
1617
"os/exec"
1718
"runtime"
1819
"strings"
1920

2021
"github.com/hashicorp/terraform-exec/internal/version"
22+
tfjson "github.com/hashicorp/terraform-json"
2123
)
2224

2325
const (
@@ -216,6 +218,72 @@ func (tf *Terraform) runTerraformCmdJSON(ctx context.Context, cmd *exec.Cmd, v i
216218
return dec.Decode(v)
217219
}
218220

221+
func (tf *Terraform) runTerraformCmdJSONLog(ctx context.Context, cmd *exec.Cmd) iter.Seq[NextMessage] {
222+
pr, pw := io.Pipe()
223+
tf.SetStdout(pw)
224+
225+
emitter := newLogMsgEmitter(pr)
226+
227+
go func() {
228+
err := tf.runTerraformCmd(ctx, cmd)
229+
emitter.done <- errors.Join(err, pw.Close())
230+
}()
231+
232+
return func(yield func(msg NextMessage) bool) {
233+
for {
234+
nextMsg := emitter.NextMessage()
235+
ok := yield(nextMsg)
236+
if !ok || nextMsg.Msg == nil {
237+
return
238+
}
239+
}
240+
}
241+
}
242+
243+
func newLogMsgEmitter(stdoutReader io.ReadCloser) *logMsgEmitter {
244+
return &logMsgEmitter{
245+
scanner: bufio.NewScanner(stdoutReader),
246+
stdoutReader: stdoutReader,
247+
done: make(chan error, 1),
248+
}
249+
}
250+
251+
type logMsgEmitter struct {
252+
scanner *bufio.Scanner
253+
stdoutReader io.Closer
254+
done chan error
255+
}
256+
257+
type NextMessage struct {
258+
Msg tfjson.LogMsg
259+
Err error
260+
}
261+
262+
// NextMessage returns next decoded message, if any, along with any errors.
263+
// Stdout reader is closed when the last message is received.
264+
//
265+
// Error returned can be related to decoding of the message, the Terraform command
266+
// or closing of stdout reader.
267+
//
268+
// Any error coming from Terraform (such as wrong configuration syntax) is
269+
// represented as LogMsg of Level [tfjson.Error].
270+
func (e *logMsgEmitter) NextMessage() NextMessage {
271+
if e.scanner.Scan() {
272+
msg, err := tfjson.UnmarshalLogMessage(e.scanner.Bytes())
273+
return NextMessage{
274+
Msg: msg,
275+
Err: err,
276+
}
277+
}
278+
279+
err := <-e.done
280+
err = errors.Join(err, e.scanner.Err(), e.stdoutReader.Close())
281+
return NextMessage{
282+
Msg: nil,
283+
Err: err,
284+
}
285+
}
286+
219287
// mergeUserAgent does some minor deduplication to ensure we aren't
220288
// just using the same append string over and over.
221289
func mergeUserAgent(uas ...string) string {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package e2etest
5+
6+
import (
7+
"context"
8+
"regexp"
9+
"testing"
10+
11+
"github.com/google/go-cmp/cmp"
12+
"github.com/hashicorp/go-version"
13+
"github.com/hashicorp/terraform-exec/tfexec"
14+
"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
15+
tfjson "github.com/hashicorp/terraform-json"
16+
)
17+
18+
func TestQueryJSON_TF112(t *testing.T) {
19+
versions := []string{testutil.Latest_v1_12}
20+
21+
runTestWithVersions(t, versions, "query", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
22+
err := tf.Init(context.Background())
23+
if err != nil {
24+
t.Fatalf("error running Init in test directory: %s", err)
25+
}
26+
27+
re := regexp.MustCompile("terraform query -json was added in 1.14.0")
28+
29+
_, err = tf.QueryJSON(context.Background())
30+
if err != nil && !re.MatchString(err.Error()) {
31+
t.Fatalf("error running Query: %s", err)
32+
}
33+
})
34+
}
35+
36+
func TestQueryJSON_TF114(t *testing.T) {
37+
versions := []string{testutil.Latest_Alpha_v1_14}
38+
39+
runTestWithVersions(t, versions, "query", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
40+
err := tf.Init(context.Background())
41+
if err != nil {
42+
t.Fatalf("error running Init in test directory: %s", err)
43+
}
44+
45+
iter, err := tf.QueryJSON(context.Background())
46+
if err != nil {
47+
t.Fatalf("error running Query: %s", err)
48+
}
49+
50+
results := 0
51+
listingStarted := 0
52+
var completeData tfjson.ListCompleteData
53+
for nextMsg := range iter {
54+
if nextMsg.Err != nil {
55+
t.Fatalf("error getting next message: %s", err)
56+
}
57+
switch m := nextMsg.Msg.(type) {
58+
case tfjson.ListStartMessage:
59+
listingStarted++
60+
case tfjson.ListResourceFoundMessage:
61+
results++
62+
case tfjson.ListCompleteMessage:
63+
completeData = m.ListComplete
64+
}
65+
}
66+
67+
if listingStarted != 1 {
68+
t.Fatalf("expected exactly 1 list start message, got %d", listingStarted)
69+
}
70+
if results != 5 {
71+
t.Fatalf("expected 5 query results, got %d", results)
72+
}
73+
expectedData := tfjson.ListCompleteData{
74+
Address: "list.concept_pet.pets",
75+
ResourceType: "concept_pet",
76+
Total: 5,
77+
}
78+
if diff := cmp.Diff(expectedData, completeData); diff != "" {
79+
t.Fatalf("unexpected complete message data: %s", diff)
80+
}
81+
})
82+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
terraform {
2+
required_providers {
3+
concept = {
4+
source = "dbanck/concept"
5+
version = "0.1.0"
6+
}
7+
}
8+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
list "concept_pet" "pets" {
2+
provider = concept
3+
}

tfexec/internal/testutil/tfcache.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const (
3636
Latest_Alpha_v1_10 = "1.10.0-alpha20240926"
3737
Latest_v1_11 = "1.11.4"
3838
Latest_v1_12 = "1.12.2"
39+
Latest_Alpha_v1_14 = "1.14.0-alpha20250903"
3940
)
4041

4142
const appendUserAgent = "tfexec-testutil"

tfexec/options.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,14 @@ func FromModule(source string) *FromModuleOption {
184184
return &FromModuleOption{source}
185185
}
186186

187+
type GenerateConfigOutOption struct {
188+
path string
189+
}
190+
191+
func GenerateConfigOut(path string) *GenerateConfigOutOption {
192+
return &GenerateConfigOutOption{path}
193+
}
194+
187195
type GetOption struct {
188196
get bool
189197
}

tfexec/query.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package tfexec
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"iter"
10+
"os/exec"
11+
)
12+
13+
type queryConfig struct {
14+
dir string
15+
generateConfig string
16+
reattachInfo ReattachInfo
17+
vars []string
18+
varFiles []string
19+
}
20+
21+
var defaultQueryOptions = queryConfig{}
22+
23+
// QueryOption represents options used in the Query method.
24+
type QueryOption interface {
25+
configureQuery(*queryConfig)
26+
}
27+
28+
func (opt *DirOption) configureQuery(conf *queryConfig) {
29+
conf.dir = opt.path
30+
}
31+
32+
func (opt *GenerateConfigOutOption) configureQuery(conf *queryConfig) {
33+
conf.generateConfig = opt.path
34+
}
35+
36+
func (opt *ReattachOption) configureQuery(conf *queryConfig) {
37+
conf.reattachInfo = opt.info
38+
}
39+
40+
func (opt *VarFileOption) configureQuery(conf *queryConfig) {
41+
conf.varFiles = append(conf.varFiles, opt.path)
42+
}
43+
44+
func (opt *VarOption) configureQuery(conf *queryConfig) {
45+
conf.vars = append(conf.vars, opt.assignment)
46+
}
47+
48+
// QueryJSON executes `terraform query` with the specified options as well as the
49+
// `-json` flag and waits for it to complete.
50+
//
51+
// Using the `-json` flag will result in
52+
// [machine-readable](https://developer.hashicorp.com/terraform/internals/machine-readable-ui)
53+
// JSON being written to the supplied `io.Writer`.
54+
//
55+
// The returned error is nil if `terraform query` has been executed and exits
56+
// with 0.
57+
//
58+
// QueryJSON is likely to be removed in a future major version in favour of
59+
// query returning JSON by default.
60+
func (tf *Terraform) QueryJSON(ctx context.Context, opts ...QueryOption) (iter.Seq[NextMessage], error) {
61+
err := tf.compatible(ctx, tf1_14_0, nil)
62+
if err != nil {
63+
return nil, fmt.Errorf("terraform query -json was added in 1.14.0: %w", err)
64+
}
65+
66+
queryCmd, err := tf.queryJSONCmd(ctx, opts...)
67+
if err != nil {
68+
return nil, err
69+
}
70+
71+
return tf.runTerraformCmdJSONLog(ctx, queryCmd), nil
72+
}
73+
74+
func (tf *Terraform) queryJSONCmd(ctx context.Context, opts ...QueryOption) (*exec.Cmd, error) {
75+
c := defaultQueryOptions
76+
77+
for _, o := range opts {
78+
o.configureQuery(&c)
79+
}
80+
81+
args, err := tf.buildQueryArgs(ctx, c)
82+
if err != nil {
83+
return nil, err
84+
}
85+
86+
args = append(args, "-json")
87+
88+
return tf.buildQueryCmd(ctx, c, args)
89+
}
90+
91+
func (tf *Terraform) buildQueryArgs(ctx context.Context, c queryConfig) ([]string, error) {
92+
args := []string{"query", "-no-color"}
93+
94+
if c.generateConfig != "" {
95+
args = append(args, "-generate-config-out="+c.generateConfig)
96+
}
97+
98+
for _, vf := range c.varFiles {
99+
args = append(args, "-var-file="+vf)
100+
}
101+
102+
if c.vars != nil {
103+
for _, v := range c.vars {
104+
args = append(args, "-var", v)
105+
}
106+
}
107+
108+
return args, nil
109+
}
110+
111+
func (tf *Terraform) buildQueryCmd(ctx context.Context, c queryConfig, args []string) (*exec.Cmd, error) {
112+
// optional positional argument
113+
if c.dir != "" {
114+
args = append(args, c.dir)
115+
}
116+
117+
mergeEnv := map[string]string{}
118+
if c.reattachInfo != nil {
119+
reattachStr, err := c.reattachInfo.marshalString()
120+
if err != nil {
121+
return nil, err
122+
}
123+
mergeEnv[reattachEnvVar] = reattachStr
124+
}
125+
126+
return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil
127+
}

0 commit comments

Comments
 (0)