Skip to content

Commit 06beb79

Browse files
committed
hfexec: add InterruptChannel option to allow a user triggered interrupt
This is an alternative to #332, adding an option that enables the user to implement graceful shutdowns for Apply and Destroy operations. Rather than adding an option that changes the behavior of the input context, we instead add an option that specifically sends an interrupt to the terraform process. The input context behavior remains unchanged. This requires the caller to do a bit more orchestration work for timeouts, but keeps context truer to the "abandon work" intent. This also allows users to force quit _even if_ they are in the middle of a graceful shutdown, rathern than having one behavior mutually exclusive with the other.
1 parent 24e3216 commit 06beb79

File tree

11 files changed

+209
-13
lines changed

11 files changed

+209
-13
lines changed

tfexec/apply.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
)
99

1010
type applyConfig struct {
11+
interruptCh <-chan struct{}
12+
1113
backup string
1214
dirOrPlan string
1315
lock bool
@@ -42,6 +44,10 @@ func (opt *ParallelismOption) configureApply(conf *applyConfig) {
4244
conf.parallelism = opt.parallelism
4345
}
4446

47+
func (opt *InterruptChannelOption) configureApply(conf *applyConfig) {
48+
conf.interruptCh = opt.interrupt
49+
}
50+
4551
func (opt *BackupOption) configureApply(conf *applyConfig) {
4652
conf.backup = opt.path
4753
}
@@ -92,14 +98,17 @@ func (opt *ReattachOption) configureApply(conf *applyConfig) {
9298

9399
// Apply represents the terraform apply subcommand.
94100
func (tf *Terraform) Apply(ctx context.Context, opts ...ApplyOption) error {
95-
cmd, err := tf.applyCmd(ctx, opts...)
101+
cmd, cfg, err := tf.applyCmd(ctx, opts...)
96102
if err != nil {
97103
return err
98104
}
105+
if cfg.interruptCh != nil {
106+
ctx = context.WithValue(ctx, interruptContext, cfg.interruptCh)
107+
}
99108
return tf.runTerraformCmd(ctx, cmd)
100109
}
101110

102-
func (tf *Terraform) applyCmd(ctx context.Context, opts ...ApplyOption) (*exec.Cmd, error) {
111+
func (tf *Terraform) applyCmd(ctx context.Context, opts ...ApplyOption) (*exec.Cmd, *applyConfig, error) {
103112
c := defaultApplyOptions
104113

105114
for _, o := range opts {
@@ -134,7 +143,7 @@ func (tf *Terraform) applyCmd(ctx context.Context, opts ...ApplyOption) (*exec.C
134143
if c.replaceAddrs != nil {
135144
err := tf.compatible(ctx, tf0_15_2, nil)
136145
if err != nil {
137-
return nil, fmt.Errorf("replace option was introduced in Terraform 0.15.2: %w", err)
146+
return nil, nil, fmt.Errorf("replace option was introduced in Terraform 0.15.2: %w", err)
138147
}
139148
for _, addr := range c.replaceAddrs {
140149
args = append(args, "-replace="+addr)
@@ -160,10 +169,10 @@ func (tf *Terraform) applyCmd(ctx context.Context, opts ...ApplyOption) (*exec.C
160169
if c.reattachInfo != nil {
161170
reattachStr, err := c.reattachInfo.marshalString()
162171
if err != nil {
163-
return nil, err
172+
return nil, nil, err
164173
}
165174
mergeEnv[reattachEnvVar] = reattachStr
166175
}
167176

168-
return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil
177+
return tf.buildTerraformCmd(ctx, mergeEnv, args...), &c, nil
169178
}

tfexec/apply_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestApplyCmd(t *testing.T) {
1919
tf.SetEnv(map[string]string{})
2020

2121
t.Run("basic", func(t *testing.T) {
22-
applyCmd, err := tf.applyCmd(context.Background(),
22+
applyCmd, cfg, err := tf.applyCmd(context.Background(),
2323
Backup("testbackup"),
2424
LockTimeout("200s"),
2525
State("teststate"),
@@ -36,6 +36,7 @@ func TestApplyCmd(t *testing.T) {
3636
Var("var1=foo"),
3737
Var("var2=bar"),
3838
DirOrPlan("testfile"),
39+
InterruptChannel(make(chan struct{})),
3940
)
4041
if err != nil {
4142
t.Fatal(err)
@@ -63,5 +64,9 @@ func TestApplyCmd(t *testing.T) {
6364
"-var", "var2=bar",
6465
"testfile",
6566
}, nil, applyCmd)
67+
68+
if cfg.interruptCh == nil {
69+
t.Fatal("interrupt signal is unexpectedly nil")
70+
}
6671
})
6772
}

tfexec/cmd.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import (
1616
"github.com/hashicorp/terraform-exec/internal/version"
1717
)
1818

19+
// If using the InterruptSignal option, we stuff the interrupt channel into the
20+
// context to keep our APIs simpler (and non-changing).
21+
//
22+
// context.WithValue(ctx, interruptContext, interruptCh)
23+
var interruptContext = new(struct{})
24+
1925
const (
2026
checkpointDisableEnvVar = "CHECKPOINT_DISABLE"
2127
cliArgsEnvVar = "TF_CLI_ARGS"
@@ -191,7 +197,7 @@ func (tf *Terraform) buildTerraformCmd(ctx context.Context, mergeEnv map[string]
191197
}
192198

193199
func (tf *Terraform) runTerraformCmdJSON(ctx context.Context, cmd *exec.Cmd, v interface{}) error {
194-
var outbuf = bytes.Buffer{}
200+
var outbuf bytes.Buffer
195201
cmd.Stdout = mergeWriters(cmd.Stdout, &outbuf)
196202

197203
err := tf.runTerraformCmd(ctx, cmd)

tfexec/cmd_default.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package tfexec
55

66
import (
77
"context"
8+
"os"
89
"os/exec"
910
"strings"
1011
"sync"
@@ -47,6 +48,19 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error {
4748
return tf.wrapExitError(ctx, err, "")
4849
}
4950

51+
if interruptCh := ctx.Value(interruptContext); interruptCh != nil {
52+
exited := make(chan struct{})
53+
defer close(exited)
54+
go func() {
55+
select {
56+
case <-interruptCh.(<-chan struct{}):
57+
cmd.Process.Signal(os.Interrupt)
58+
case <-exited:
59+
case <-ctx.Done():
60+
}
61+
}()
62+
}
63+
5064
var errStdout, errStderr error
5165
var wg sync.WaitGroup
5266
wg.Add(1)

tfexec/cmd_default_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package tfexec
66
import (
77
"bytes"
88
"context"
9+
"errors"
910
"log"
1011
"strings"
1112
"testing"
@@ -37,3 +38,26 @@ func Test_runTerraformCmd_default(t *testing.T) {
3738
t.Fatal("canceling context should not lead to logging an error")
3839
}
3940
}
41+
42+
func Test_runTerraformCmdCancel_default(t *testing.T) {
43+
var buf bytes.Buffer
44+
45+
tf := &Terraform{
46+
logger: log.New(&buf, "", 0),
47+
execPath: "sleep",
48+
}
49+
50+
ctx, cancel := context.WithCancel(context.Background())
51+
defer cancel()
52+
53+
cmd := tf.buildTerraformCmd(ctx, nil, "10")
54+
go func() {
55+
time.Sleep(time.Second)
56+
cancel()
57+
}()
58+
59+
err := tf.runTerraformCmd(ctx, cmd)
60+
if !errors.Is(err, context.Canceled) {
61+
t.Fatalf("expected context.Canceled, got %T %s", err, err)
62+
}
63+
}

tfexec/cmd_linux.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tfexec
22

33
import (
44
"context"
5+
"os"
56
"os/exec"
67
"strings"
78
"sync"
@@ -52,6 +53,19 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error {
5253
return tf.wrapExitError(ctx, err, "")
5354
}
5455

56+
if interruptCh := ctx.Value(interruptContext); interruptCh != nil {
57+
exited := make(chan struct{})
58+
defer close(exited)
59+
go func() {
60+
select {
61+
case <-interruptCh.(<-chan struct{}):
62+
cmd.Process.Signal(os.Interrupt)
63+
case <-exited:
64+
case <-ctx.Done():
65+
}
66+
}()
67+
}
68+
5569
var errStdout, errStderr error
5670
var wg sync.WaitGroup
5771
wg.Add(1)

tfexec/destroy.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
)
99

1010
type destroyConfig struct {
11+
interruptCh <-chan struct{}
12+
1113
backup string
1214
dir string
1315
lock bool
@@ -46,6 +48,10 @@ func (opt *ParallelismOption) configureDestroy(conf *destroyConfig) {
4648
conf.parallelism = opt.parallelism
4749
}
4850

51+
func (opt *InterruptChannelOption) configureDestroy(conf *destroyConfig) {
52+
conf.interruptCh = opt.interrupt
53+
}
54+
4955
func (opt *BackupOption) configureDestroy(conf *destroyConfig) {
5056
conf.backup = opt.path
5157
}
@@ -88,14 +94,17 @@ func (opt *ReattachOption) configureDestroy(conf *destroyConfig) {
8894

8995
// Destroy represents the terraform destroy subcommand.
9096
func (tf *Terraform) Destroy(ctx context.Context, opts ...DestroyOption) error {
91-
cmd, err := tf.destroyCmd(ctx, opts...)
97+
cmd, cfg, err := tf.destroyCmd(ctx, opts...)
9298
if err != nil {
9399
return err
94100
}
101+
if cfg.interruptCh != nil {
102+
ctx = context.WithValue(ctx, interruptContext, cfg.interruptCh)
103+
}
95104
return tf.runTerraformCmd(ctx, cmd)
96105
}
97106

98-
func (tf *Terraform) destroyCmd(ctx context.Context, opts ...DestroyOption) (*exec.Cmd, error) {
107+
func (tf *Terraform) destroyCmd(ctx context.Context, opts ...DestroyOption) (*exec.Cmd, *destroyConfig, error) {
99108
c := defaultDestroyOptions
100109

101110
for _, o := range opts {
@@ -147,10 +156,10 @@ func (tf *Terraform) destroyCmd(ctx context.Context, opts ...DestroyOption) (*ex
147156
if c.reattachInfo != nil {
148157
reattachStr, err := c.reattachInfo.marshalString()
149158
if err != nil {
150-
return nil, err
159+
return nil, nil, err
151160
}
152161
mergeEnv[reattachEnvVar] = reattachStr
153162
}
154163

155-
return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil
164+
return tf.buildTerraformCmd(ctx, mergeEnv, args...), &c, nil
156165
}

tfexec/destroy_test.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestDestroyCmd(t *testing.T) {
1919
tf.SetEnv(map[string]string{})
2020

2121
t.Run("defaults", func(t *testing.T) {
22-
destroyCmd, err := tf.destroyCmd(context.Background())
22+
destroyCmd, cfg, err := tf.destroyCmd(context.Background())
2323
if err != nil {
2424
t.Fatal(err)
2525
}
@@ -34,10 +34,14 @@ func TestDestroyCmd(t *testing.T) {
3434
"-parallelism=10",
3535
"-refresh=true",
3636
}, nil, destroyCmd)
37+
38+
if cfg.interruptCh != nil {
39+
t.Fatal("interrupt signal is unexpectedly non-nil")
40+
}
3741
})
3842

3943
t.Run("override all defaults", func(t *testing.T) {
40-
destroyCmd, err := tf.destroyCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), VarFile("testvarfile"), Lock(false), Parallelism(99), Refresh(false), Target("target1"), Target("target2"), Var("var1=foo"), Var("var2=bar"), Dir("destroydir"))
44+
destroyCmd, cfg, err := tf.destroyCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), VarFile("testvarfile"), Lock(false), Parallelism(99), Refresh(false), Target("target1"), Target("target2"), Var("var1=foo"), Var("var2=bar"), Dir("destroydir"), InterruptChannel(make(chan struct{})))
4145
if err != nil {
4246
t.Fatal(err)
4347
}
@@ -61,5 +65,9 @@ func TestDestroyCmd(t *testing.T) {
6165
"-var", "var2=bar",
6266
"destroydir",
6367
}, nil, destroyCmd)
68+
69+
if cfg.interruptCh == nil {
70+
t.Fatal("interrupt signal is unexpectedly nil")
71+
}
6472
})
6573
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package e2etest
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
"github.com/hashicorp/go-version"
11+
"github.com/hashicorp/terraform-exec/tfexec"
12+
"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
13+
)
14+
15+
func Test_gracefulTerminationRunTerraformCmd(t *testing.T) {
16+
runTestVersions(t, []string{testutil.Latest_v1_1}, "infinite_loop", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
17+
var bufStdout bytes.Buffer
18+
var bufStderr bytes.Buffer
19+
tf.SetStderr(&bufStdout)
20+
tf.SetStdout(&bufStderr)
21+
22+
ctx, cancel := context.WithCancel(context.Background())
23+
defer cancel()
24+
25+
err := tf.Init(ctx)
26+
if err != nil {
27+
t.Fatalf("error running Init in test directory: %s", err)
28+
}
29+
30+
doneCh := make(chan error)
31+
shutdown := make(chan struct{})
32+
go func() {
33+
doneCh <- tf.Apply(ctx, tfexec.InterruptChannel(shutdown))
34+
}()
35+
36+
time.Sleep(3 * time.Second)
37+
close(shutdown)
38+
err = <-doneCh
39+
close(doneCh)
40+
if err != nil {
41+
t.Log(err)
42+
}
43+
output := bufStderr.String() + bufStdout.String()
44+
t.Log(output)
45+
if !strings.Contains(output, "Gracefully shutting down...") {
46+
t.Fatal("canceling context should gracefully shut terraform down")
47+
}
48+
})
49+
}
50+
51+
func Test_gracefulTerminationRunTerraformCmdWithNoGracefulShutdownTimeout(t *testing.T) {
52+
runTestVersions(t, []string{testutil.Latest_v1_1}, "infinite_loop", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
53+
var bufStdout bytes.Buffer
54+
var bufStderr bytes.Buffer
55+
tf.SetStderr(&bufStdout)
56+
tf.SetStdout(&bufStderr)
57+
58+
ctx, cancel := context.WithCancel(context.Background())
59+
defer cancel()
60+
61+
err := tf.Init(ctx)
62+
if err != nil {
63+
t.Fatalf("error running Init in test directory: %s", err)
64+
}
65+
66+
doneCh := make(chan error)
67+
go func() {
68+
doneCh <- tf.Apply(ctx, tfexec.InterruptChannel(make(chan struct{})))
69+
}()
70+
71+
time.Sleep(3 * time.Second)
72+
cancel()
73+
err = <-doneCh
74+
close(doneCh)
75+
if err != nil {
76+
t.Log(err)
77+
}
78+
output := bufStderr.String() + bufStdout.String()
79+
t.Log(output)
80+
if strings.Contains(output, "Gracefully shutting down...") {
81+
t.Fatal("canceling context with no graceful shutdown timeout should immediately kill the process and not start a graceful cancellation")
82+
}
83+
})
84+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
resource "null_resource" "example1" {
2+
triggers = {
3+
always_run = "${timestamp()}"
4+
}
5+
provisioner "local-exec" {
6+
command = " while true; do echo 'Hit CTRL+C'; sleep 1; done"
7+
}
8+
}

0 commit comments

Comments
 (0)