Skip to content

Commit aa8df1d

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

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
@@ -36,7 +36,7 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error {
3636
err = ctx.Err()
3737
}
3838
if err != nil {
39-
return tf.parseError(err, errBuf.String())
39+
return tf.wrapExitError(ctx, err, errBuf.String())
4040
}
4141

4242
return nil

tfexec/cmd_linux.go

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

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

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

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

6170
return &ErrTFVersionMismatch{
71+
unwrapper: unwrapper{exitErr, ctxErr},
72+
6273
Constraint: constraint,
6374
TFVersion: ver,
6475
}
@@ -72,33 +83,77 @@ func (tf *Terraform) parseError(err error, stderr string) error {
7283
}
7384
}
7485

75-
return &ErrMissingVar{name}
86+
return &ErrMissingVar{
87+
unwrapper: unwrapper{exitErr, ctxErr},
88+
89+
VariableName: name,
90+
}
7691
case usageRegexp.MatchString(stderr):
77-
return &ErrCLIUsage{stderr: stderr}
92+
return &ErrCLIUsage{
93+
unwrapper: unwrapper{exitErr, ctxErr},
94+
95+
stderr: stderr,
96+
}
7897
case noInitErrRegexp.MatchString(stderr):
79-
return &ErrNoInit{stderr: stderr}
98+
return &ErrNoInit{
99+
unwrapper: unwrapper{exitErr, ctxErr},
100+
101+
stderr: stderr,
102+
}
80103
case noConfigErrRegexp.MatchString(stderr):
81-
return &ErrNoConfig{stderr: stderr}
104+
return &ErrNoConfig{
105+
unwrapper: unwrapper{exitErr, ctxErr},
106+
107+
stderr: stderr,
108+
}
82109
case workspaceDoesNotExistRegexp.MatchString(stderr):
83110
submatches := workspaceDoesNotExistRegexp.FindStringSubmatch(stderr)
84111
if len(submatches) == 2 {
85-
return &ErrNoWorkspace{submatches[1]}
112+
return &ErrNoWorkspace{
113+
unwrapper: unwrapper{exitErr, ctxErr},
114+
115+
Name: submatches[1],
116+
}
86117
}
87118
case workspaceAlreadyExistsRegexp.MatchString(stderr):
88119
submatches := workspaceAlreadyExistsRegexp.FindStringSubmatch(stderr)
89120
if len(submatches) == 2 {
90-
return &ErrWorkspaceExists{submatches[1]}
121+
return &ErrWorkspaceExists{
122+
unwrapper: unwrapper{exitErr, ctxErr},
123+
124+
Name: submatches[1],
125+
}
91126
}
92127
}
93-
errString := strings.TrimSpace(stderr)
94-
if errString == "" {
95-
// if stderr is empty, return the ExitError directly, as it will have a better message
96-
return ee
128+
129+
return fmt.Errorf("%w\n%s", &unwrapper{exitErr, ctxErr}, stderr)
130+
}
131+
132+
type unwrapper struct {
133+
err error
134+
ctxErr error
135+
}
136+
137+
func (u *unwrapper) Unwrap() error {
138+
return u.err
139+
}
140+
141+
func (u *unwrapper) Is(target error) bool {
142+
switch target {
143+
case context.DeadlineExceeded, context.Canceled:
144+
return u.ctxErr == context.DeadlineExceeded ||
145+
u.ctxErr == context.Canceled
97146
}
98-
return errors.New(stderr)
147+
return false
148+
}
149+
150+
func (u *unwrapper) Error() string {
151+
return u.err.Error()
99152
}
100153

101154
type ErrMissingVar struct {
155+
unwrapper
156+
102157
VariableName string
103158
}
104159

@@ -107,6 +162,8 @@ func (err *ErrMissingVar) Error() string {
107162
}
108163

109164
type ErrNoWorkspace struct {
165+
unwrapper
166+
110167
Name string
111168
}
112169

@@ -116,6 +173,8 @@ func (err *ErrNoWorkspace) Error() string {
116173

117174
// ErrWorkspaceExists is returned when creating a workspace that already exists
118175
type ErrWorkspaceExists struct {
176+
unwrapper
177+
119178
Name string
120179
}
121180

@@ -124,6 +183,8 @@ func (err *ErrWorkspaceExists) Error() string {
124183
}
125184

126185
type ErrNoInit struct {
186+
unwrapper
187+
127188
stderr string
128189
}
129190

@@ -132,6 +193,8 @@ func (e *ErrNoInit) Error() string {
132193
}
133194

134195
type ErrNoConfig struct {
196+
unwrapper
197+
135198
stderr string
136199
}
137200

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

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

163230
// 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.
@@ -56,6 +62,11 @@ func TestMissingVar(t *testing.T) {
5662
t.Fatalf("expected missing no_default, got %q", e.VariableName)
5763
}
5864

65+
var ee *exec.ExitError
66+
if !errors.As(err, &ee) {
67+
t.Fatalf("expected exec.ExitError, got %T, %s", err, err)
68+
}
69+
5970
_, err = tf.Plan(context.Background(), tfexec.Var("no_default=foo"))
6071
if err != nil {
6172
t.Fatalf("expected no error, got %s", err)
@@ -89,5 +100,95 @@ func TestTFVersionMismatch(t *testing.T) {
89100
if e.TFVersion != tfv.String() {
90101
t.Fatalf("expected %q, got %q", tfv.String(), e.TFVersion)
91102
}
103+
104+
var ee *exec.ExitError
105+
if !errors.As(err, &ee) {
106+
t.Fatalf("expected exec.ExitError, got %T, %s", err, err)
107+
}
108+
})
109+
}
110+
111+
func TestContext_alreadyPastDeadline(t *testing.T) {
112+
runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
113+
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-1*time.Second))
114+
defer cancel()
115+
116+
_, _, err := tf.Version(ctx, true)
117+
if err == nil {
118+
t.Fatal("expected error from version command")
119+
}
120+
121+
if !errors.Is(err, context.DeadlineExceeded) {
122+
t.Fatalf("expected context.DeadlineExceeded, got %T %s", err, err)
123+
}
124+
})
125+
}
126+
127+
func TestContext_sleepNoCancellation(t *testing.T) {
128+
// this test is just to ensure that time_sleep works properly without cancellation
129+
runTest(t, "sleep", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
130+
// only testing versions that can cancel mid apply
131+
if !tfv.GreaterThanOrEqual(protocol5MinVersion) {
132+
t.Skip("the ability to interrupt an apply was added in protocol 5.0 in Terraform 0.12, so test is not valid")
133+
}
134+
135+
err := tf.Init(context.Background())
136+
if err != nil {
137+
t.Fatalf("err during init: %s", err)
138+
}
139+
140+
ctx := context.Background()
141+
start := time.Now()
142+
err = tf.Apply(ctx, tfexec.Var(`create_duration=5s`))
143+
if err != nil {
144+
t.Fatalf("error during apply: %s", err)
145+
}
146+
elapsed := time.Now().Sub(start)
147+
if elapsed < 5*time.Second {
148+
t.Fatalf("expected runtime of at least 5s, got %s", elapsed)
149+
}
150+
})
151+
}
152+
153+
func TestContext_sleepTimeoutExpired(t *testing.T) {
154+
runTest(t, "sleep", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
155+
// only testing versions that can cancel mid apply
156+
if !tfv.GreaterThanOrEqual(protocol5MinVersion) {
157+
t.Skip("the ability to interrupt an apply was added in protocol 5.0 in Terraform 0.12, so test is not valid")
158+
}
159+
160+
err := tf.Init(context.Background())
161+
if err != nil {
162+
t.Fatalf("err during init: %s", err)
163+
}
164+
165+
ctx := context.Background()
166+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
167+
defer cancel()
168+
169+
err = tf.Apply(ctx)
170+
if err == nil {
171+
t.Fatal("expected error, but didn't find one")
172+
}
173+
174+
if !errors.Is(err, context.DeadlineExceeded) {
175+
t.Fatalf("expected context.DeadlineExceeded, got %T %s", err, err)
176+
}
177+
})
178+
}
179+
180+
func TestContext_alreadyCancelled(t *testing.T) {
181+
runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
182+
ctx, cancel := context.WithCancel(context.Background())
183+
cancel()
184+
185+
_, _, err := tf.Version(ctx, true)
186+
if err == nil {
187+
t.Fatal("expected error from version command")
188+
}
189+
190+
if !errors.Is(err, context.Canceled) {
191+
t.Fatalf("expected context.Canceled, got %T %s", err, err)
192+
}
92193
})
93194
}
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)