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) {