Skip to content

tfexec: Enable graceful (SIGINT-based) cancellation #512

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions tfexec/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"io/ioutil"
"os"
"os/exec"
"runtime"
"strings"

"github.com/hashicorp/terraform-exec/internal/version"
Expand Down Expand Up @@ -187,6 +188,14 @@ func (tf *Terraform) buildTerraformCmd(ctx context.Context, mergeEnv map[string]

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

Expand Down
4 changes: 4 additions & 0 deletions tfexec/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ func (e cmdErr) Is(target error) bool {
return false
}

func (e cmdErr) Unwrap() error {
return e.err
}

func (e cmdErr) Error() string {
return e.err.Error()
}
4 changes: 4 additions & 0 deletions tfexec/internal/e2etest/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ func TestContext_sleepTimeoutExpired(t *testing.T) {
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)
Expand Down
25 changes: 25 additions & 0 deletions tfexec/sleepmock_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
16 changes: 16 additions & 0 deletions tfexec/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ package tfexec

import (
"context"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"runtime"
"sync"
"time"

"github.com/hashicorp/go-version"
)
Expand Down Expand Up @@ -67,6 +70,9 @@ type Terraform struct {
// 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
Expand Down Expand Up @@ -95,6 +101,7 @@ func NewTerraform(workingDir string, execPath string) (*Terraform, error) {
workingDir: workingDir,
env: nil, // explicit nil means copy os.Environ
logger: log.New(ioutil.Discard, "", 0),
waitDelay: 60 * time.Second,
}

return &tf, nil
Expand Down Expand Up @@ -216,6 +223,15 @@ func (tf *Terraform) SetSkipProviderVerify(skip bool) error {
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
Expand Down
79 changes: 79 additions & 0 deletions tfexec/terraform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,23 @@ import (
"errors"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"

"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
)

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")
Expand Down Expand Up @@ -813,6 +820,78 @@ func TestCheckpointDisablePropagation_v1(t *testing.T) {
})
}

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) {
Expand Down
Loading