Skip to content

Commit 07d6f35

Browse files
committed
Unwrap exec.ExitError on all our special errors
And respond to errors.Is for Context errors
1 parent b6aeb51 commit 07d6f35

File tree

7 files changed

+213
-20
lines changed

7 files changed

+213
-20
lines changed

tfexec/cmd_default.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error {
3838
err = ctx.Err()
3939
}
4040
if err != nil {
41-
return tf.parseError(err, errBuf.String())
41+
return tf.wrapExitError(ctx, err, errBuf.String())
4242
}
4343

4444
return nil

tfexec/cmd_linux.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error {
4747
err = ctx.Err()
4848
}
4949
if err != nil {
50-
return tf.parseError(err, errBuf.String())
50+
return tf.wrapExitError(ctx, err, errBuf.String())
5151
}
5252

5353
return nil

tfexec/errors.go

+6-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ func (e *ErrNoSuitableBinary) Error() string {
1212
return fmt.Sprintf("no suitable terraform binary could be found: %s", e.err.Error())
1313
}
1414

15+
func (e *ErrNoSuitableBinary) Unwrap() error {
16+
return e.err
17+
}
18+
1519
// ErrVersionMismatch is returned when the detected Terraform version is not compatible with the
1620
// command or flags being used in this invocation.
1721
type ErrVersionMismatch struct {
@@ -27,9 +31,9 @@ func (e *ErrVersionMismatch) Error() string {
2731
// ErrManualEnvVar is returned when an env var that should be set programatically via an option or method
2832
// is set via the manual environment passing functions.
2933
type ErrManualEnvVar struct {
30-
name string
34+
Name string
3135
}
3236

3337
func (err *ErrManualEnvVar) Error() string {
34-
return fmt.Sprintf("manual setting of env var %q detected", err.name)
38+
return fmt.Sprintf("manual setting of env var %q detected", err.Name)
3539
}

tfexec/exit_errors.go

+81-14
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package tfexec
22

33
import (
4-
"errors"
4+
"context"
55
"fmt"
66
"os/exec"
77
"regexp"
@@ -31,12 +31,21 @@ var (
3131
configInvalidErrRegexp = regexp.MustCompile(`There are some problems with the configuration, described below.`)
3232
)
3333

34-
func (tf *Terraform) parseError(err error, stderr string) error {
35-
ee, ok := err.(*exec.ExitError)
34+
func (tf *Terraform) wrapExitError(ctx context.Context, err error, stderr string) error {
35+
exitErr, ok := err.(*exec.ExitError)
3636
if !ok {
37+
// not an exit error, short circuit, nothing to wrap
3738
return err
3839
}
3940

41+
ctxErr := ctx.Err()
42+
43+
// nothing to parse, return early
44+
errString := strings.TrimSpace(stderr)
45+
if errString == "" {
46+
return &unwrapper{exitErr, ctxErr}
47+
}
48+
4049
switch {
4150
case tfVersionMismatchErrRegexp.MatchString(stderr):
4251
constraint := ""
@@ -60,6 +69,8 @@ func (tf *Terraform) parseError(err error, stderr string) error {
6069
}
6170

6271
return &ErrTFVersionMismatch{
72+
unwrapper: unwrapper{exitErr, ctxErr},
73+
6374
Constraint: constraint,
6475
TFVersion: ver,
6576
}
@@ -73,32 +84,74 @@ func (tf *Terraform) parseError(err error, stderr string) error {
7384
}
7485
}
7586

76-
return &ErrMissingVar{name}
87+
return &ErrMissingVar{
88+
unwrapper: unwrapper{exitErr, ctxErr},
89+
90+
VariableName: name,
91+
}
7792
case usageRegexp.MatchString(stderr):
78-
return &ErrCLIUsage{stderr: stderr}
93+
return &ErrCLIUsage{
94+
unwrapper: unwrapper{exitErr, ctxErr},
95+
96+
stderr: stderr,
97+
}
7998
case noInitErrRegexp.MatchString(stderr):
80-
return &ErrNoInit{stderr: stderr}
99+
return &ErrNoInit{
100+
unwrapper: unwrapper{exitErr, ctxErr},
101+
102+
stderr: stderr,
103+
}
81104
case noConfigErrRegexp.MatchString(stderr):
82-
return &ErrNoConfig{stderr: stderr}
105+
return &ErrNoConfig{
106+
unwrapper: unwrapper{exitErr, ctxErr},
107+
108+
stderr: stderr,
109+
}
83110
case workspaceDoesNotExistRegexp.MatchString(stderr):
84111
submatches := workspaceDoesNotExistRegexp.FindStringSubmatch(stderr)
85112
if len(submatches) == 2 {
86-
return &ErrNoWorkspace{submatches[1]}
113+
return &ErrNoWorkspace{
114+
unwrapper: unwrapper{exitErr, ctxErr},
115+
116+
Name: submatches[1],
117+
}
87118
}
88119
case workspaceAlreadyExistsRegexp.MatchString(stderr):
89120
submatches := workspaceAlreadyExistsRegexp.FindStringSubmatch(stderr)
90121
if len(submatches) == 2 {
91-
return &ErrWorkspaceExists{submatches[1]}
122+
return &ErrWorkspaceExists{
123+
unwrapper: unwrapper{exitErr, ctxErr},
124+
125+
Name: submatches[1],
126+
}
92127
}
93128
case configInvalidErrRegexp.MatchString(stderr):
94129
return &ErrConfigInvalid{stderr: stderr}
95130
}
96-
errString := strings.TrimSpace(stderr)
97-
if errString == "" {
98-
// if stderr is empty, return the ExitError directly, as it will have a better message
99-
return ee
131+
132+
return fmt.Errorf("%w\n%s", &unwrapper{exitErr, ctxErr}, stderr)
133+
}
134+
135+
type unwrapper struct {
136+
err error
137+
ctxErr error
138+
}
139+
140+
func (u *unwrapper) Unwrap() error {
141+
return u.err
142+
}
143+
144+
func (u *unwrapper) Is(target error) bool {
145+
switch target {
146+
case context.DeadlineExceeded, context.Canceled:
147+
return u.ctxErr == context.DeadlineExceeded ||
148+
u.ctxErr == context.Canceled
100149
}
101-
return errors.New(stderr)
150+
return false
151+
}
152+
153+
func (u *unwrapper) Error() string {
154+
return u.err.Error()
102155
}
103156

104157
type ErrConfigInvalid struct {
@@ -110,6 +163,8 @@ func (e *ErrConfigInvalid) Error() string {
110163
}
111164

112165
type ErrMissingVar struct {
166+
unwrapper
167+
113168
VariableName string
114169
}
115170

@@ -118,6 +173,8 @@ func (err *ErrMissingVar) Error() string {
118173
}
119174

120175
type ErrNoWorkspace struct {
176+
unwrapper
177+
121178
Name string
122179
}
123180

@@ -127,6 +184,8 @@ func (err *ErrNoWorkspace) Error() string {
127184

128185
// ErrWorkspaceExists is returned when creating a workspace that already exists
129186
type ErrWorkspaceExists struct {
187+
unwrapper
188+
130189
Name string
131190
}
132191

@@ -135,6 +194,8 @@ func (err *ErrWorkspaceExists) Error() string {
135194
}
136195

137196
type ErrNoInit struct {
197+
unwrapper
198+
138199
stderr string
139200
}
140201

@@ -143,6 +204,8 @@ func (e *ErrNoInit) Error() string {
143204
}
144205

145206
type ErrNoConfig struct {
207+
unwrapper
208+
146209
stderr string
147210
}
148211

@@ -159,6 +222,8 @@ func (e *ErrNoConfig) Error() string {
159222
// Currently cases 1 and 2 are handled.
160223
// TODO KEM: Handle exit 127 case. How does this work on non-Unix platforms?
161224
type ErrCLIUsage struct {
225+
unwrapper
226+
162227
stderr string
163228
}
164229

@@ -169,6 +234,8 @@ func (e *ErrCLIUsage) Error() string {
169234
// ErrTFVersionMismatch is returned when the running Terraform version is not compatible with the
170235
// value specified for required_version in the terraform block.
171236
type ErrTFVersionMismatch struct {
237+
unwrapper
238+
172239
TFVersion string
173240

174241
// Constraint is not returned in the error messaging on 0.12

tfexec/internal/e2etest/errors_test.go

+101
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@ import (
77
"context"
88
"errors"
99
"os"
10+
"os/exec"
1011
"testing"
12+
"time"
1113

1214
"github.com/hashicorp/go-version"
1315

1416
"github.com/hashicorp/terraform-exec/tfexec"
1517
)
1618

19+
var (
20+
protocol5MinVersion = version.Must(version.NewVersion("0.12.0"))
21+
)
22+
1723
func TestUnparsedError(t *testing.T) {
1824
// This simulates an unparsed error from the Cmd.Run method (in this case file not found). This
1925
// is to ensure we don't miss raising unexpected errors in addition to parsed / well known ones.
@@ -74,6 +80,11 @@ func TestMissingVar(t *testing.T) {
7480
t.Fatalf("expected missing %s, got %q", longVarName, e.VariableName)
7581
}
7682

83+
var ee *exec.ExitError
84+
if !errors.As(err, &ee) {
85+
t.Fatalf("expected exec.ExitError, got %T, %s", err, err)
86+
}
87+
7788
// Test for no error when all variables have a value
7889
_, err = tf.Plan(context.Background(), tfexec.Var(shortVarName+"=foo"), tfexec.Var(longVarName+"=foo"))
7990
if err != nil {
@@ -108,5 +119,95 @@ func TestTFVersionMismatch(t *testing.T) {
108119
if e.TFVersion != tfv.String() {
109120
t.Fatalf("expected %q, got %q", tfv.String(), e.TFVersion)
110121
}
122+
123+
var ee *exec.ExitError
124+
if !errors.As(err, &ee) {
125+
t.Fatalf("expected exec.ExitError, got %T, %s", err, err)
126+
}
127+
})
128+
}
129+
130+
func TestContext_alreadyPastDeadline(t *testing.T) {
131+
runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
132+
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-1*time.Second))
133+
defer cancel()
134+
135+
_, _, err := tf.Version(ctx, true)
136+
if err == nil {
137+
t.Fatal("expected error from version command")
138+
}
139+
140+
if !errors.Is(err, context.DeadlineExceeded) {
141+
t.Fatalf("expected context.DeadlineExceeded, got %T %s", err, err)
142+
}
143+
})
144+
}
145+
146+
func TestContext_sleepNoCancellation(t *testing.T) {
147+
// this test is just to ensure that time_sleep works properly without cancellation
148+
runTest(t, "sleep", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
149+
// only testing versions that can cancel mid apply
150+
if !tfv.GreaterThanOrEqual(protocol5MinVersion) {
151+
t.Skip("the ability to interrupt an apply was added in protocol 5.0 in Terraform 0.12, so test is not valid")
152+
}
153+
154+
err := tf.Init(context.Background())
155+
if err != nil {
156+
t.Fatalf("err during init: %s", err)
157+
}
158+
159+
ctx := context.Background()
160+
start := time.Now()
161+
err = tf.Apply(ctx, tfexec.Var(`create_duration=5s`))
162+
if err != nil {
163+
t.Fatalf("error during apply: %s", err)
164+
}
165+
elapsed := time.Now().Sub(start)
166+
if elapsed < 5*time.Second {
167+
t.Fatalf("expected runtime of at least 5s, got %s", elapsed)
168+
}
169+
})
170+
}
171+
172+
func TestContext_sleepTimeoutExpired(t *testing.T) {
173+
runTest(t, "sleep", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
174+
// only testing versions that can cancel mid apply
175+
if !tfv.GreaterThanOrEqual(protocol5MinVersion) {
176+
t.Skip("the ability to interrupt an apply was added in protocol 5.0 in Terraform 0.12, so test is not valid")
177+
}
178+
179+
err := tf.Init(context.Background())
180+
if err != nil {
181+
t.Fatalf("err during init: %s", err)
182+
}
183+
184+
ctx := context.Background()
185+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
186+
defer cancel()
187+
188+
err = tf.Apply(ctx)
189+
if err == nil {
190+
t.Fatal("expected error, but didn't find one")
191+
}
192+
193+
if !errors.Is(err, context.DeadlineExceeded) {
194+
t.Fatalf("expected context.DeadlineExceeded, got %T %s", err, err)
195+
}
196+
})
197+
}
198+
199+
func TestContext_alreadyCancelled(t *testing.T) {
200+
runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
201+
ctx, cancel := context.WithCancel(context.Background())
202+
cancel()
203+
204+
_, _, err := tf.Version(ctx, true)
205+
if err == nil {
206+
t.Fatal("expected error from version command")
207+
}
208+
209+
if !errors.Is(err, context.Canceled) {
210+
t.Fatalf("expected context.Canceled, got %T %s", err, err)
211+
}
111212
})
112213
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
variable "create_duration" {
2+
type = string
3+
default = "60s"
4+
}
5+
6+
variable "destroy_duration" {
7+
type = string
8+
default = null
9+
}
10+
11+
resource "time_sleep" "sleep" {
12+
create_duration = var.create_duration
13+
destroy_duration = var.destroy_duration
14+
}

tfexec/terraform.go

+9-2
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ type printfer interface {
2222
// but you can override paths used in some commands depending on the available
2323
// options.
2424
//
25+
// All functions that execute CLI commands take a context.Context. It should be noted that
26+
// exec.Cmd.Run will not return context.DeadlineExceeded or context.Canceled by default, we
27+
// have augmented our wrapped errors to respond true to errors.Is for context.DeadlineExceeded
28+
// and context.Canceled if those are present on the context when the error is parsed. See
29+
// https://github.com/golang/go/issues/21880 for more about the Go limitations.
30+
//
2531
// By default, the instance inherits the environment from the calling code (using os.Environ)
2632
// but it ignores certain environment variables that are managed within the code and prohibits
2733
// setting them through SetEnv:
@@ -66,8 +72,9 @@ func NewTerraform(workingDir string, execPath string) (*Terraform, error) {
6672

6773
if execPath == "" {
6874
err := fmt.Errorf("NewTerraform: please supply the path to a Terraform executable using execPath, e.g. using the tfinstall package.")
69-
return nil, &ErrNoSuitableBinary{err: err}
70-
75+
return nil, &ErrNoSuitableBinary{
76+
err: err,
77+
}
7178
}
7279
tf := Terraform{
7380
execPath: execPath,

0 commit comments

Comments
 (0)