Skip to content

Commit da9aaae

Browse files
committed
Unwrap exec.ExitError on all our special errors
And respond to errors.Is for Context errors
1 parent 86379d5 commit da9aaae

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"
@@ -29,12 +29,21 @@ var (
2929
tfVersionMismatchConstraintRegexp = regexp.MustCompile(`required_version = "(.+)"|Required version: (.+)\b`)
3030
)
3131

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

39+
ctxErr := ctx.Err()
40+
41+
// nothing to parse, return early
42+
errString := strings.TrimSpace(stderr)
43+
if errString == "" {
44+
return &unwrapper{exitErr, ctxErr}
45+
}
46+
3847
switch {
3948
case tfVersionMismatchErrRegexp.MatchString(stderr):
4049
constraint := ""
@@ -58,6 +67,8 @@ func (tf *Terraform) parseError(err error, stderr string) error {
5867
}
5968

6069
return &ErrTFVersionMismatch{
70+
unwrapper: unwrapper{exitErr, ctxErr},
71+
6172
Constraint: constraint,
6273
TFVersion: ver,
6374
}
@@ -71,33 +82,77 @@ func (tf *Terraform) parseError(err error, stderr string) error {
7182
}
7283
}
7384

74-
return &ErrMissingVar{name}
85+
return &ErrMissingVar{
86+
unwrapper: unwrapper{exitErr, ctxErr},
87+
88+
VariableName: name,
89+
}
7590
case usageRegexp.MatchString(stderr):
76-
return &ErrCLIUsage{stderr: stderr}
91+
return &ErrCLIUsage{
92+
unwrapper: unwrapper{exitErr, ctxErr},
93+
94+
stderr: stderr,
95+
}
7796
case noInitErrRegexp.MatchString(stderr):
78-
return &ErrNoInit{stderr: stderr}
97+
return &ErrNoInit{
98+
unwrapper: unwrapper{exitErr, ctxErr},
99+
100+
stderr: stderr,
101+
}
79102
case noConfigErrRegexp.MatchString(stderr):
80-
return &ErrNoConfig{stderr: stderr}
103+
return &ErrNoConfig{
104+
unwrapper: unwrapper{exitErr, ctxErr},
105+
106+
stderr: stderr,
107+
}
81108
case workspaceDoesNotExistRegexp.MatchString(stderr):
82109
submatches := workspaceDoesNotExistRegexp.FindStringSubmatch(stderr)
83110
if len(submatches) == 2 {
84-
return &ErrNoWorkspace{submatches[1]}
111+
return &ErrNoWorkspace{
112+
unwrapper: unwrapper{exitErr, ctxErr},
113+
114+
Name: submatches[1],
115+
}
85116
}
86117
case workspaceAlreadyExistsRegexp.MatchString(stderr):
87118
submatches := workspaceAlreadyExistsRegexp.FindStringSubmatch(stderr)
88119
if len(submatches) == 2 {
89-
return &ErrWorkspaceExists{submatches[1]}
120+
return &ErrWorkspaceExists{
121+
unwrapper: unwrapper{exitErr, ctxErr},
122+
123+
Name: submatches[1],
124+
}
90125
}
91126
}
92-
errString := strings.TrimSpace(stderr)
93-
if errString == "" {
94-
// if stderr is empty, return the ExitError directly, as it will have a better message
95-
return ee
127+
128+
return fmt.Errorf("%w\n%s", &unwrapper{exitErr, ctxErr}, stderr)
129+
}
130+
131+
type unwrapper struct {
132+
err error
133+
ctxErr error
134+
}
135+
136+
func (u *unwrapper) Unwrap() error {
137+
return u.err
138+
}
139+
140+
func (u *unwrapper) Is(target error) bool {
141+
switch target {
142+
case context.DeadlineExceeded, context.Canceled:
143+
return u.ctxErr == context.DeadlineExceeded ||
144+
u.ctxErr == context.Canceled
96145
}
97-
return errors.New(stderr)
146+
return false
147+
}
148+
149+
func (u *unwrapper) Error() string {
150+
return u.err.Error()
98151
}
99152

100153
type ErrMissingVar struct {
154+
unwrapper
155+
101156
VariableName string
102157
}
103158

@@ -106,6 +161,8 @@ func (err *ErrMissingVar) Error() string {
106161
}
107162

108163
type ErrNoWorkspace struct {
164+
unwrapper
165+
109166
Name string
110167
}
111168

@@ -115,6 +172,8 @@ func (err *ErrNoWorkspace) Error() string {
115172

116173
// ErrWorkspaceExists is returned when creating a workspace that already exists
117174
type ErrWorkspaceExists struct {
175+
unwrapper
176+
118177
Name string
119178
}
120179

@@ -123,6 +182,8 @@ func (err *ErrWorkspaceExists) Error() string {
123182
}
124183

125184
type ErrNoInit struct {
185+
unwrapper
186+
126187
stderr string
127188
}
128189

@@ -131,6 +192,8 @@ func (e *ErrNoInit) Error() string {
131192
}
132193

133194
type ErrNoConfig struct {
195+
unwrapper
196+
134197
stderr string
135198
}
136199

@@ -147,6 +210,8 @@ func (e *ErrNoConfig) Error() string {
147210
// Currently cases 1 and 2 are handled.
148211
// TODO KEM: Handle exit 127 case. How does this work on non-Unix platforms?
149212
type ErrCLIUsage struct {
213+
unwrapper
214+
150215
stderr string
151216
}
152217

@@ -157,6 +222,8 @@ func (e *ErrCLIUsage) Error() string {
157222
// ErrTFVersionMismatch is returned when the running Terraform version is not compatible with the
158223
// value specified for required_version in the terraform block.
159224
type ErrTFVersionMismatch struct {
225+
unwrapper
226+
160227
TFVersion string
161228

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

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

0 commit comments

Comments
 (0)