tfexec: Enable graceful (SIGINT-based) cancellation (#512)
diff --git a/tfexec/cmd.go b/tfexec/cmd.go
index 5e16032..c8b18ff 100644
--- a/tfexec/cmd.go
+++ b/tfexec/cmd.go
@@ -14,6 +14,7 @@
"io/ioutil"
"os"
"os/exec"
+ "runtime"
"strings"
"github.com/hashicorp/terraform-exec/internal/version"
@@ -187,6 +188,14 @@
cmd.Env = tf.buildEnv(mergeEnv)
cmd.Dir = tf.workingDir
+ if runtime.GOOS != "windows" {
+ // Windows does not support SIGINT so we cannot do graceful cancellation
+ // see https://pkg.go.dev/os#Signal (os.Interrupt)
+ cmd.Cancel = func() error {
+ return cmd.Process.Signal(os.Interrupt)
+ }
+ cmd.WaitDelay = tf.waitDelay
+ }
tf.logger.Printf("[INFO] running Terraform command: %s", cmd.String())
diff --git a/tfexec/errors.go b/tfexec/errors.go
index c6645e8..14a4772 100644
--- a/tfexec/errors.go
+++ b/tfexec/errors.go
@@ -62,6 +62,10 @@
return false
}
+func (e cmdErr) Unwrap() error {
+ return e.err
+}
+
func (e cmdErr) Error() string {
return e.err.Error()
}
diff --git a/tfexec/internal/e2etest/errors_test.go b/tfexec/internal/e2etest/errors_test.go
index 7bef86c..c99aad9 100644
--- a/tfexec/internal/e2etest/errors_test.go
+++ b/tfexec/internal/e2etest/errors_test.go
@@ -168,6 +168,10 @@
t.Skip("the ability to interrupt an apply was added in protocol 5.0 in Terraform 0.12, so test is not valid")
}
+ // sleep will not react to SIGINT
+ // This ensures that process is killed within the expected time limit.
+ tf.SetWaitDelay(500 * time.Millisecond)
+
err := tf.Init(context.Background())
if err != nil {
t.Fatalf("err during init: %s", err)
diff --git a/tfexec/sleepmock_test.go b/tfexec/sleepmock_test.go
new file mode 100644
index 0000000..57f4089
--- /dev/null
+++ b/tfexec/sleepmock_test.go
@@ -0,0 +1,25 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package tfexec
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "time"
+)
+
+func sleepMock(rawDuration string) {
+ signal.Ignore(os.Interrupt)
+
+ d, err := time.ParseDuration(rawDuration)
+ if err != nil {
+ log.Fatalf("invalid duration format: %s", err)
+ }
+
+ fmt.Printf("sleeping for %s\n", d)
+
+ time.Sleep(d)
+}
diff --git a/tfexec/terraform.go b/tfexec/terraform.go
index 628b733..9088616 100644
--- a/tfexec/terraform.go
+++ b/tfexec/terraform.go
@@ -5,12 +5,15 @@
import (
"context"
+ "errors"
"fmt"
"io"
"io/ioutil"
"log"
"os"
+ "runtime"
"sync"
+ "time"
"github.com/hashicorp/go-version"
)
@@ -67,6 +70,9 @@
// TF_LOG_PROVIDER environment variable
logProvider string
+ // waitDelay represents the WaitDelay field of the [exec.Cmd] of Terraform
+ waitDelay time.Duration
+
versionLock sync.Mutex
execVersion *version.Version
provVersions map[string]*version.Version
@@ -95,6 +101,7 @@
workingDir: workingDir,
env: nil, // explicit nil means copy os.Environ
logger: log.New(ioutil.Discard, "", 0),
+ waitDelay: 60 * time.Second,
}
return &tf, nil
@@ -216,6 +223,15 @@
return nil
}
+// SetWaitDelay sets the WaitDelay of running Terraform process as [exec.Cmd]
+func (tf *Terraform) SetWaitDelay(delay time.Duration) error {
+ if runtime.GOOS == "windows" {
+ return errors.New("cannot set WaitDelay, graceful cancellation not supported on windows")
+ }
+ tf.waitDelay = delay
+ return nil
+}
+
// WorkingDir returns the working directory for Terraform.
func (tf *Terraform) WorkingDir() string {
return tf.workingDir
diff --git a/tfexec/terraform_test.go b/tfexec/terraform_test.go
index 93a6de4..e20da21 100644
--- a/tfexec/terraform_test.go
+++ b/tfexec/terraform_test.go
@@ -8,9 +8,11 @@
"errors"
"io/ioutil"
"os"
+ "os/exec"
"path/filepath"
"runtime"
"testing"
+ "time"
"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
)
@@ -18,6 +20,11 @@
var tfCache *testutil.TFCache
func TestMain(m *testing.M) {
+ if rawDuration := os.Getenv("MOCK_SLEEP_DURATION"); rawDuration != "" {
+ sleepMock(rawDuration)
+ return
+ }
+
os.Exit(func() int {
var err error
installDir, err := ioutil.TempDir("", "tfinstall")
@@ -813,6 +820,78 @@
})
}
+func TestGracefulCancellation_interruption(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("graceful cancellation not supported on windows")
+ }
+ mockExecPath, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ td := t.TempDir()
+
+ tf, err := NewTerraform(td, mockExecPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx := context.Background()
+ ctx, cancelFunc := context.WithTimeout(ctx, 100*time.Millisecond)
+ t.Cleanup(cancelFunc)
+
+ _, _, err = tf.version(ctx)
+ if err != nil {
+ var exitErr *exec.ExitError
+ isExitErr := errors.As(err, &exitErr)
+ if isExitErr && exitErr.ProcessState.String() == "signal: interrupt" {
+ return
+ }
+ if isExitErr {
+ t.Fatalf("expected interrupt signal, received %q", exitErr)
+ }
+ t.Fatalf("unexpected command error: %s", err)
+ }
+}
+
+func TestGracefulCancellation_withDelay(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("graceful cancellation not supported on windows")
+ }
+ mockExecPath, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ td := t.TempDir()
+ tf, err := NewTerraform(td, mockExecPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tf.SetEnv(map[string]string{
+ "MOCK_SLEEP_DURATION": "5s",
+ })
+ tf.SetLogger(testutil.TestLogger())
+ tf.SetWaitDelay(100 * time.Millisecond)
+
+ ctx := context.Background()
+ ctx, cancelFunc := context.WithTimeout(ctx, 100*time.Millisecond)
+ t.Cleanup(cancelFunc)
+
+ _, _, err = tf.version(ctx)
+ if err != nil {
+ var exitErr *exec.ExitError
+ isExitErr := errors.As(err, &exitErr)
+ if isExitErr && exitErr.ProcessState.String() == "signal: killed" {
+ return
+ }
+ if isExitErr {
+ t.Fatalf("expected kill signal, received %q", exitErr)
+ }
+ t.Fatalf("unexpected command error: %s", err)
+ }
+}
+
// test that a suitable error is returned if NewTerraform is called without a valid
// executable path
func TestNoTerraformBinary(t *testing.T) {