diff --git a/tfexec/apply.go b/tfexec/apply.go index b055f418..82d09d5f 100644 --- a/tfexec/apply.go +++ b/tfexec/apply.go @@ -91,7 +91,7 @@ func (tf *Terraform) Apply(ctx context.Context, opts ...ApplyOption) error { if err != nil { return err } - return tf.runTerraformCmd(cmd) + return tf.runTerraformCmd(ctx, cmd) } func (tf *Terraform) applyCmd(ctx context.Context, opts ...ApplyOption) (*exec.Cmd, error) { diff --git a/tfexec/cmd.go b/tfexec/cmd.go index 52a0d012..8875e2cf 100644 --- a/tfexec/cmd.go +++ b/tfexec/cmd.go @@ -135,11 +135,11 @@ func (tf *Terraform) buildTerraformCmd(ctx context.Context, mergeEnv map[string] return cmd } -func (tf *Terraform) runTerraformCmdJSON(cmd *exec.Cmd, v interface{}) error { +func (tf *Terraform) runTerraformCmdJSON(ctx context.Context, cmd *exec.Cmd, v interface{}) error { var outbuf = bytes.Buffer{} cmd.Stdout = mergeWriters(cmd.Stdout, &outbuf) - err := tf.runTerraformCmd(cmd) + err := tf.runTerraformCmd(ctx, cmd) if err != nil { return err } @@ -147,7 +147,7 @@ func (tf *Terraform) runTerraformCmdJSON(cmd *exec.Cmd, v interface{}) error { return json.Unmarshal(outbuf.Bytes(), v) } -func (tf *Terraform) runTerraformCmd(cmd *exec.Cmd) error { +func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { var errBuf strings.Builder cmd.Stdout = mergeWriters(cmd.Stdout, tf.stdout) @@ -155,7 +155,7 @@ func (tf *Terraform) runTerraformCmd(cmd *exec.Cmd) error { err := cmd.Run() if err != nil { - return tf.parseError(err, errBuf.String()) + return tf.wrapExitError(ctx, err, errBuf.String()) } return nil } diff --git a/tfexec/destroy.go b/tfexec/destroy.go index 61305580..8011c0ba 100644 --- a/tfexec/destroy.go +++ b/tfexec/destroy.go @@ -92,7 +92,7 @@ func (tf *Terraform) Destroy(ctx context.Context, opts ...DestroyOption) error { if err != nil { return err } - return tf.runTerraformCmd(cmd) + return tf.runTerraformCmd(ctx, cmd) } func (tf *Terraform) destroyCmd(ctx context.Context, opts ...DestroyOption) (*exec.Cmd, error) { diff --git a/tfexec/errors.go b/tfexec/errors.go index 9a0201d4..7a32ef2f 100644 --- a/tfexec/errors.go +++ b/tfexec/errors.go @@ -12,6 +12,10 @@ func (e *ErrNoSuitableBinary) Error() string { return fmt.Sprintf("no suitable terraform binary could be found: %s", e.err.Error()) } +func (e *ErrNoSuitableBinary) Unwrap() error { + return e.err +} + // ErrVersionMismatch is returned when the detected Terraform version is not compatible with the // command or flags being used in this invocation. type ErrVersionMismatch struct { @@ -27,9 +31,9 @@ func (e *ErrVersionMismatch) Error() string { // ErrManualEnvVar is returned when an env var that should be set programatically via an option or method // is set via the manual environment passing functions. type ErrManualEnvVar struct { - name string + Name string } func (err *ErrManualEnvVar) Error() string { - return fmt.Sprintf("manual setting of env var %q detected", err.name) + return fmt.Sprintf("manual setting of env var %q detected", err.Name) } diff --git a/tfexec/exit_errors.go b/tfexec/exit_errors.go index 88eabe49..66354fd3 100644 --- a/tfexec/exit_errors.go +++ b/tfexec/exit_errors.go @@ -1,7 +1,7 @@ package tfexec import ( - "errors" + "context" "fmt" "os/exec" "regexp" @@ -30,12 +30,21 @@ var ( tfVersionMismatchConstraintRegexp = regexp.MustCompile(`required_version = "(.+)"|Required version: (.+)\b`) ) -func (tf *Terraform) parseError(err error, stderr string) error { - ee, ok := err.(*exec.ExitError) +func (tf *Terraform) wrapExitError(ctx context.Context, err error, stderr string) error { + exitErr, ok := err.(*exec.ExitError) if !ok { + // not an exit error, short circuit, nothing to wrap return err } + ctxErr := ctx.Err() + + // nothing to parse, return early + errString := strings.TrimSpace(stderr) + if errString == "" { + return &unwrapper{exitErr, ctxErr} + } + switch { case tfVersionMismatchErrRegexp.MatchString(stderr): constraint := "" @@ -59,6 +68,8 @@ func (tf *Terraform) parseError(err error, stderr string) error { } return &ErrTFVersionMismatch{ + unwrapper: unwrapper{exitErr, ctxErr}, + Constraint: constraint, TFVersion: ver, } @@ -72,33 +83,77 @@ func (tf *Terraform) parseError(err error, stderr string) error { } } - return &ErrMissingVar{name} + return &ErrMissingVar{ + unwrapper: unwrapper{exitErr, ctxErr}, + + VariableName: name, + } case usageRegexp.MatchString(stderr): - return &ErrCLIUsage{stderr: stderr} + return &ErrCLIUsage{ + unwrapper: unwrapper{exitErr, ctxErr}, + + stderr: stderr, + } case noInitErrRegexp.MatchString(stderr): - return &ErrNoInit{stderr: stderr} + return &ErrNoInit{ + unwrapper: unwrapper{exitErr, ctxErr}, + + stderr: stderr, + } case noConfigErrRegexp.MatchString(stderr): - return &ErrNoConfig{stderr: stderr} + return &ErrNoConfig{ + unwrapper: unwrapper{exitErr, ctxErr}, + + stderr: stderr, + } case workspaceDoesNotExistRegexp.MatchString(stderr): submatches := workspaceDoesNotExistRegexp.FindStringSubmatch(stderr) if len(submatches) == 2 { - return &ErrNoWorkspace{submatches[1]} + return &ErrNoWorkspace{ + unwrapper: unwrapper{exitErr, ctxErr}, + + Name: submatches[1], + } } case workspaceAlreadyExistsRegexp.MatchString(stderr): submatches := workspaceAlreadyExistsRegexp.FindStringSubmatch(stderr) if len(submatches) == 2 { - return &ErrWorkspaceExists{submatches[1]} + return &ErrWorkspaceExists{ + unwrapper: unwrapper{exitErr, ctxErr}, + + Name: submatches[1], + } } } - errString := strings.TrimSpace(stderr) - if errString == "" { - // if stderr is empty, return the ExitError directly, as it will have a better message - return ee + + return fmt.Errorf("%w\n%s", &unwrapper{exitErr, ctxErr}, stderr) +} + +type unwrapper struct { + err error + ctxErr error +} + +func (u *unwrapper) Unwrap() error { + return u.err +} + +func (u *unwrapper) Is(target error) bool { + switch target { + case context.DeadlineExceeded, context.Canceled: + return u.ctxErr == context.DeadlineExceeded || + u.ctxErr == context.Canceled } - return errors.New(stderr) + return false +} + +func (u *unwrapper) Error() string { + return u.err.Error() } type ErrMissingVar struct { + unwrapper + VariableName string } @@ -107,6 +162,8 @@ func (err *ErrMissingVar) Error() string { } type ErrNoWorkspace struct { + unwrapper + Name string } @@ -116,6 +173,8 @@ func (err *ErrNoWorkspace) Error() string { // ErrWorkspaceExists is returned when creating a workspace that already exists type ErrWorkspaceExists struct { + unwrapper + Name string } @@ -124,6 +183,8 @@ func (err *ErrWorkspaceExists) Error() string { } type ErrNoInit struct { + unwrapper + stderr string } @@ -132,6 +193,8 @@ func (e *ErrNoInit) Error() string { } type ErrNoConfig struct { + unwrapper + stderr string } @@ -148,6 +211,8 @@ func (e *ErrNoConfig) Error() string { // Currently cases 1 and 2 are handled. // TODO KEM: Handle exit 127 case. How does this work on non-Unix platforms? type ErrCLIUsage struct { + unwrapper + stderr string } @@ -158,6 +223,8 @@ func (e *ErrCLIUsage) Error() string { // ErrTFVersionMismatch is returned when the running Terraform version is not compatible with the // value specified for required_version in the terraform block. type ErrTFVersionMismatch struct { + unwrapper + TFVersion string // Constraint is not returned in the error messaging on 0.12 diff --git a/tfexec/fmt.go b/tfexec/fmt.go index d80a39b7..00de8026 100644 --- a/tfexec/fmt.go +++ b/tfexec/fmt.go @@ -52,7 +52,7 @@ func (tf *Terraform) FormatString(ctx context.Context, content string) (string, var outBuf bytes.Buffer cmd.Stdout = mergeWriters(cmd.Stdout, &outBuf) - err = tf.runTerraformCmd(cmd) + err = tf.runTerraformCmd(ctx, cmd) if err != nil { return "", err } @@ -76,7 +76,7 @@ func (tf *Terraform) FormatWrite(ctx context.Context, opts ...FormatOption) erro return err } - return tf.runTerraformCmd(cmd) + return tf.runTerraformCmd(ctx, cmd) } // FormatCheck returns true if the config files in the working or selected (via DirOption) directory are already formatted. @@ -98,7 +98,7 @@ func (tf *Terraform) FormatCheck(ctx context.Context, opts ...FormatOption) (boo var outBuf bytes.Buffer cmd.Stdout = mergeWriters(cmd.Stdout, &outBuf) - err = tf.runTerraformCmd(cmd) + err = tf.runTerraformCmd(ctx, cmd) if err == nil { return true, nil, nil } diff --git a/tfexec/import.go b/tfexec/import.go index cffb4e92..e243d728 100644 --- a/tfexec/import.go +++ b/tfexec/import.go @@ -78,7 +78,7 @@ func (tf *Terraform) Import(ctx context.Context, address, id string, opts ...Imp if err != nil { return err } - return tf.runTerraformCmd(cmd) + return tf.runTerraformCmd(ctx, cmd) } func (tf *Terraform) importCmd(ctx context.Context, address, id string, opts ...ImportOption) (*exec.Cmd, error) { diff --git a/tfexec/init.go b/tfexec/init.go index 7d2a6bce..69010f54 100644 --- a/tfexec/init.go +++ b/tfexec/init.go @@ -98,7 +98,7 @@ func (tf *Terraform) Init(ctx context.Context, opts ...InitOption) error { if err != nil { return err } - return tf.runTerraformCmd(cmd) + return tf.runTerraformCmd(ctx, cmd) } func (tf *Terraform) initCmd(ctx context.Context, opts ...InitOption) (*exec.Cmd, error) { diff --git a/tfexec/internal/e2etest/errors_test.go b/tfexec/internal/e2etest/errors_test.go index cd7b97df..6d56ee95 100644 --- a/tfexec/internal/e2etest/errors_test.go +++ b/tfexec/internal/e2etest/errors_test.go @@ -7,13 +7,19 @@ import ( "context" "errors" "os" + "os/exec" "testing" + "time" "github.com/hashicorp/go-version" "github.com/hashicorp/terraform-exec/tfexec" ) +var ( + protocol5MinVersion = version.Must(version.NewVersion("0.12.0")) +) + func TestUnparsedError(t *testing.T) { // This simulates an unparsed error from the Cmd.Run method (in this case file not found). This // 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) { t.Fatalf("expected missing no_default, got %q", e.VariableName) } + var ee *exec.ExitError + if !errors.As(err, &ee) { + t.Fatalf("expected exec.ExitError, got %T, %s", err, err) + } + _, err = tf.Plan(context.Background(), tfexec.Var("no_default=foo")) if err != nil { t.Fatalf("expected no error, got %s", err) @@ -89,5 +100,68 @@ func TestTFVersionMismatch(t *testing.T) { if e.TFVersion != tfv.String() { t.Fatalf("expected %q, got %q", tfv.String(), e.TFVersion) } + + var ee *exec.ExitError + if !errors.As(err, &ee) { + t.Fatalf("expected exec.ExitError, got %T, %s", err, err) + } + }) +} + +func TestContext_alreadyPastDeadline(t *testing.T) { + runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-1*time.Second)) + defer cancel() + + _, _, err := tf.Version(ctx, true) + if err == nil { + t.Fatal("expected error from version command") + } + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected context.DeadlineExceeded, got %T %s", err, err) + } + }) +} + +func TestContext_timeoutExpired(t *testing.T) { + runTest(t, "sleep", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + // only testing versions that can cancel mid apply + if tfv.GreaterThanOrEqual(protocol5MinVersion) { + t.Skip("the ability to interrupt an apply was added in protocol 5.0 in Terraform 0.12, so test is not valid") + } + + err := tf.Init(context.Background()) + if err != nil { + t.Fatalf("err during init: %s", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + err = tf.Apply(ctx) + if err == nil { + t.Fatal("expected error, but didn't find one") + } + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected context.DeadlineExceeded, got %T %s", err, err) + } + }) +} + +func TestContext_alreadyCancelled(t *testing.T) { + runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, err := tf.Version(ctx, true) + if err == nil { + t.Fatal("expected error from version command") + } + + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %T %s", err, err) + } }) } diff --git a/tfexec/internal/e2etest/testdata/sleep/main.tf b/tfexec/internal/e2etest/testdata/sleep/main.tf new file mode 100644 index 00000000..01489610 --- /dev/null +++ b/tfexec/internal/e2etest/testdata/sleep/main.tf @@ -0,0 +1,3 @@ +resource "time_sleep" "wait_60_seconds" { + create_duration = "60s" +} diff --git a/tfexec/output.go b/tfexec/output.go index 4c204025..b16b8b72 100644 --- a/tfexec/output.go +++ b/tfexec/output.go @@ -37,7 +37,7 @@ func (tf *Terraform) Output(ctx context.Context, opts ...OutputOption) (map[stri outputCmd := tf.outputCmd(ctx, opts...) outputs := map[string]OutputMeta{} - err := tf.runTerraformCmdJSON(outputCmd, &outputs) + err := tf.runTerraformCmdJSON(ctx, outputCmd, &outputs) if err != nil { return nil, err } diff --git a/tfexec/plan.go b/tfexec/plan.go index 07541acd..bfe77db7 100644 --- a/tfexec/plan.go +++ b/tfexec/plan.go @@ -96,7 +96,7 @@ func (tf *Terraform) Plan(ctx context.Context, opts ...PlanOption) (bool, error) if err != nil { return false, err } - err = tf.runTerraformCmd(cmd) + err = tf.runTerraformCmd(ctx, cmd) if err != nil && cmd.ProcessState.ExitCode() == 2 { return true, nil } diff --git a/tfexec/providers_schema.go b/tfexec/providers_schema.go index 75e593a9..52efc5db 100644 --- a/tfexec/providers_schema.go +++ b/tfexec/providers_schema.go @@ -12,7 +12,7 @@ func (tf *Terraform) ProvidersSchema(ctx context.Context) (*tfjson.ProviderSchem schemaCmd := tf.providersSchemaCmd(ctx) var ret tfjson.ProviderSchemas - err := tf.runTerraformCmdJSON(schemaCmd, &ret) + err := tf.runTerraformCmdJSON(ctx, schemaCmd, &ret) if err != nil { return nil, err } diff --git a/tfexec/refresh.go b/tfexec/refresh.go index 5839d2df..78f6b4b5 100644 --- a/tfexec/refresh.go +++ b/tfexec/refresh.go @@ -75,7 +75,7 @@ func (tf *Terraform) Refresh(ctx context.Context, opts ...RefreshCmdOption) erro if err != nil { return err } - return tf.runTerraformCmd(cmd) + return tf.runTerraformCmd(ctx, cmd) } func (tf *Terraform) refreshCmd(ctx context.Context, opts ...RefreshCmdOption) (*exec.Cmd, error) { diff --git a/tfexec/show.go b/tfexec/show.go index e2f52870..59320699 100644 --- a/tfexec/show.go +++ b/tfexec/show.go @@ -49,7 +49,7 @@ func (tf *Terraform) Show(ctx context.Context, opts ...ShowOption) (*tfjson.Stat showCmd := tf.showCmd(ctx, true, mergeEnv) var ret tfjson.State - err = tf.runTerraformCmdJSON(showCmd, &ret) + err = tf.runTerraformCmdJSON(ctx, showCmd, &ret) if err != nil { return nil, err } @@ -91,7 +91,7 @@ func (tf *Terraform) ShowStateFile(ctx context.Context, statePath string, opts . showCmd := tf.showCmd(ctx, true, mergeEnv, statePath) var ret tfjson.State - err = tf.runTerraformCmdJSON(showCmd, &ret) + err = tf.runTerraformCmdJSON(ctx, showCmd, &ret) if err != nil { return nil, err } @@ -133,7 +133,7 @@ func (tf *Terraform) ShowPlanFile(ctx context.Context, planPath string, opts ... showCmd := tf.showCmd(ctx, true, mergeEnv, planPath) var ret tfjson.Plan - err = tf.runTerraformCmdJSON(showCmd, &ret) + err = tf.runTerraformCmdJSON(ctx, showCmd, &ret) if err != nil { return nil, err } @@ -173,7 +173,7 @@ func (tf *Terraform) ShowPlanFileRaw(ctx context.Context, planPath string, opts var ret bytes.Buffer showCmd.Stdout = &ret - err := tf.runTerraformCmd(showCmd) + err := tf.runTerraformCmd(ctx, showCmd) if err != nil { return "", err } diff --git a/tfexec/terraform.go b/tfexec/terraform.go index beeb8263..727e2eeb 100644 --- a/tfexec/terraform.go +++ b/tfexec/terraform.go @@ -23,6 +23,12 @@ type printfer interface { // but you can override paths used in some commands depending on the available // options. // +// All functions that execute CLI commands take a context.Context. It should be noted that +// exec.Cmd.Run will not return context.DeadlineExceeded or context.Canceled by default, we +// have augmented our wrapped errors to respond true to errors.Is for context.DeadlineExceeded +// and context.Canceled if those are present on the context when the error is parsed. See +// https://github.com/golang/go/issues/21880 for more about the Go limitations. +// // By default, the instance inherits the environment from the calling code (using os.Environ) // but it ignores certain environment variables that are managed within the code and prohibits // setting them through SetEnv: @@ -67,8 +73,9 @@ func NewTerraform(workingDir string, execPath string) (*Terraform, error) { if execPath == "" { err := fmt.Errorf("NewTerraform: please supply the path to a Terraform executable using execPath, e.g. using the tfinstall package.") - return nil, &ErrNoSuitableBinary{err: err} - + return nil, &ErrNoSuitableBinary{ + err: err, + } } tf := Terraform{ execPath: execPath, diff --git a/tfexec/version.go b/tfexec/version.go index 6f3f1395..2e45dda1 100644 --- a/tfexec/version.go +++ b/tfexec/version.go @@ -44,7 +44,7 @@ func (tf *Terraform) version(ctx context.Context) (*version.Version, map[string] var outBuf bytes.Buffer versionCmd.Stdout = &outBuf - err := tf.runTerraformCmd(versionCmd) + err := tf.runTerraformCmd(ctx, versionCmd) if err != nil { return nil, nil, err } diff --git a/tfexec/workspace_list.go b/tfexec/workspace_list.go index edf9adab..b8d03094 100644 --- a/tfexec/workspace_list.go +++ b/tfexec/workspace_list.go @@ -14,7 +14,7 @@ func (tf *Terraform) WorkspaceList(ctx context.Context) ([]string, string, error var outBuf bytes.Buffer wlCmd.Stdout = &outBuf - err := tf.runTerraformCmd(wlCmd) + err := tf.runTerraformCmd(ctx, wlCmd) if err != nil { return nil, "", err } diff --git a/tfexec/workspace_new.go b/tfexec/workspace_new.go index 1925c286..2e05ffdb 100644 --- a/tfexec/workspace_new.go +++ b/tfexec/workspace_new.go @@ -41,7 +41,7 @@ func (tf *Terraform) WorkspaceNew(ctx context.Context, workspace string, opts .. if err != nil { return err } - return tf.runTerraformCmd(cmd) + return tf.runTerraformCmd(ctx, cmd) } func (tf *Terraform) workspaceNewCmd(ctx context.Context, workspace string, opts ...WorkspaceNewCmdOption) (*exec.Cmd, error) { diff --git a/tfexec/workspace_select.go b/tfexec/workspace_select.go index 87f5301e..5a51330f 100644 --- a/tfexec/workspace_select.go +++ b/tfexec/workspace_select.go @@ -6,5 +6,5 @@ import "context" func (tf *Terraform) WorkspaceSelect(ctx context.Context, workspace string) error { // TODO: [DIR] param option - return tf.runTerraformCmd(tf.buildTerraformCmd(ctx, nil, "workspace", "select", "-no-color", workspace)) + return tf.runTerraformCmd(ctx, tf.buildTerraformCmd(ctx, nil, "workspace", "select", "-no-color", workspace)) }