Skip to content

Commit 49c2a4c

Browse files
authored
tfexec: Enable graceful (SIGINT-based) cancellation (#512)
1 parent f8ddc4c commit 49c2a4c

File tree

6 files changed

+137
-0
lines changed

6 files changed

+137
-0
lines changed

tfexec/cmd.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"io/ioutil"
1515
"os"
1616
"os/exec"
17+
"runtime"
1718
"strings"
1819

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

188189
cmd.Env = tf.buildEnv(mergeEnv)
189190
cmd.Dir = tf.workingDir
191+
if runtime.GOOS != "windows" {
192+
// Windows does not support SIGINT so we cannot do graceful cancellation
193+
// see https://pkg.go.dev/os#Signal (os.Interrupt)
194+
cmd.Cancel = func() error {
195+
return cmd.Process.Signal(os.Interrupt)
196+
}
197+
cmd.WaitDelay = tf.waitDelay
198+
}
190199

191200
tf.logger.Printf("[INFO] running Terraform command: %s", cmd.String())
192201

tfexec/errors.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ func (e cmdErr) Is(target error) bool {
6262
return false
6363
}
6464

65+
func (e cmdErr) Unwrap() error {
66+
return e.err
67+
}
68+
6569
func (e cmdErr) Error() string {
6670
return e.err.Error()
6771
}

tfexec/internal/e2etest/errors_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ func TestContext_sleepTimeoutExpired(t *testing.T) {
168168
t.Skip("the ability to interrupt an apply was added in protocol 5.0 in Terraform 0.12, so test is not valid")
169169
}
170170

171+
// sleep will not react to SIGINT
172+
// This ensures that process is killed within the expected time limit.
173+
tf.SetWaitDelay(500 * time.Millisecond)
174+
171175
err := tf.Init(context.Background())
172176
if err != nil {
173177
t.Fatalf("err during init: %s", err)

tfexec/sleepmock_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package tfexec
5+
6+
import (
7+
"fmt"
8+
"log"
9+
"os"
10+
"os/signal"
11+
"time"
12+
)
13+
14+
func sleepMock(rawDuration string) {
15+
signal.Ignore(os.Interrupt)
16+
17+
d, err := time.ParseDuration(rawDuration)
18+
if err != nil {
19+
log.Fatalf("invalid duration format: %s", err)
20+
}
21+
22+
fmt.Printf("sleeping for %s\n", d)
23+
24+
time.Sleep(d)
25+
}

tfexec/terraform.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@ package tfexec
55

66
import (
77
"context"
8+
"errors"
89
"fmt"
910
"io"
1011
"io/ioutil"
1112
"log"
1213
"os"
14+
"runtime"
1315
"sync"
16+
"time"
1417

1518
"github.com/hashicorp/go-version"
1619
)
@@ -67,6 +70,9 @@ type Terraform struct {
6770
// TF_LOG_PROVIDER environment variable
6871
logProvider string
6972

73+
// waitDelay represents the WaitDelay field of the [exec.Cmd] of Terraform
74+
waitDelay time.Duration
75+
7076
versionLock sync.Mutex
7177
execVersion *version.Version
7278
provVersions map[string]*version.Version
@@ -95,6 +101,7 @@ func NewTerraform(workingDir string, execPath string) (*Terraform, error) {
95101
workingDir: workingDir,
96102
env: nil, // explicit nil means copy os.Environ
97103
logger: log.New(ioutil.Discard, "", 0),
104+
waitDelay: 60 * time.Second,
98105
}
99106

100107
return &tf, nil
@@ -216,6 +223,15 @@ func (tf *Terraform) SetSkipProviderVerify(skip bool) error {
216223
return nil
217224
}
218225

226+
// SetWaitDelay sets the WaitDelay of running Terraform process as [exec.Cmd]
227+
func (tf *Terraform) SetWaitDelay(delay time.Duration) error {
228+
if runtime.GOOS == "windows" {
229+
return errors.New("cannot set WaitDelay, graceful cancellation not supported on windows")
230+
}
231+
tf.waitDelay = delay
232+
return nil
233+
}
234+
219235
// WorkingDir returns the working directory for Terraform.
220236
func (tf *Terraform) WorkingDir() string {
221237
return tf.workingDir

tfexec/terraform_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,23 @@ import (
88
"errors"
99
"io/ioutil"
1010
"os"
11+
"os/exec"
1112
"path/filepath"
1213
"runtime"
1314
"testing"
15+
"time"
1416

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

1820
var tfCache *testutil.TFCache
1921

2022
func TestMain(m *testing.M) {
23+
if rawDuration := os.Getenv("MOCK_SLEEP_DURATION"); rawDuration != "" {
24+
sleepMock(rawDuration)
25+
return
26+
}
27+
2128
os.Exit(func() int {
2229
var err error
2330
installDir, err := ioutil.TempDir("", "tfinstall")
@@ -813,6 +820,78 @@ func TestCheckpointDisablePropagation_v1(t *testing.T) {
813820
})
814821
}
815822

823+
func TestGracefulCancellation_interruption(t *testing.T) {
824+
if runtime.GOOS == "windows" {
825+
t.Skip("graceful cancellation not supported on windows")
826+
}
827+
mockExecPath, err := os.Executable()
828+
if err != nil {
829+
t.Fatal(err)
830+
}
831+
832+
td := t.TempDir()
833+
834+
tf, err := NewTerraform(td, mockExecPath)
835+
if err != nil {
836+
t.Fatal(err)
837+
}
838+
839+
ctx := context.Background()
840+
ctx, cancelFunc := context.WithTimeout(ctx, 100*time.Millisecond)
841+
t.Cleanup(cancelFunc)
842+
843+
_, _, err = tf.version(ctx)
844+
if err != nil {
845+
var exitErr *exec.ExitError
846+
isExitErr := errors.As(err, &exitErr)
847+
if isExitErr && exitErr.ProcessState.String() == "signal: interrupt" {
848+
return
849+
}
850+
if isExitErr {
851+
t.Fatalf("expected interrupt signal, received %q", exitErr)
852+
}
853+
t.Fatalf("unexpected command error: %s", err)
854+
}
855+
}
856+
857+
func TestGracefulCancellation_withDelay(t *testing.T) {
858+
if runtime.GOOS == "windows" {
859+
t.Skip("graceful cancellation not supported on windows")
860+
}
861+
mockExecPath, err := os.Executable()
862+
if err != nil {
863+
t.Fatal(err)
864+
}
865+
866+
td := t.TempDir()
867+
tf, err := NewTerraform(td, mockExecPath)
868+
if err != nil {
869+
t.Fatal(err)
870+
}
871+
tf.SetEnv(map[string]string{
872+
"MOCK_SLEEP_DURATION": "5s",
873+
})
874+
tf.SetLogger(testutil.TestLogger())
875+
tf.SetWaitDelay(100 * time.Millisecond)
876+
877+
ctx := context.Background()
878+
ctx, cancelFunc := context.WithTimeout(ctx, 100*time.Millisecond)
879+
t.Cleanup(cancelFunc)
880+
881+
_, _, err = tf.version(ctx)
882+
if err != nil {
883+
var exitErr *exec.ExitError
884+
isExitErr := errors.As(err, &exitErr)
885+
if isExitErr && exitErr.ProcessState.String() == "signal: killed" {
886+
return
887+
}
888+
if isExitErr {
889+
t.Fatalf("expected kill signal, received %q", exitErr)
890+
}
891+
t.Fatalf("unexpected command error: %s", err)
892+
}
893+
}
894+
816895
// test that a suitable error is returned if NewTerraform is called without a valid
817896
// executable path
818897
func TestNoTerraformBinary(t *testing.T) {

0 commit comments

Comments
 (0)