diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e6d35496..ebd3b259 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ func (tf *Terraform) Refresh(ctx context.Context, opts ...RefreshCmdOption) erro func (tf *Terraform) refreshCmd(ctx context.Context, opts ...RefreshCmdOption) (*exec.Cmd, error) { ... - return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil + return tf.buildTerraformCmd( mergeEnv, args...), nil } ``` diff --git a/tfexec/apply.go b/tfexec/apply.go index 40d9e69b..6a6d1e97 100644 --- a/tfexec/apply.go +++ b/tfexec/apply.go @@ -5,6 +5,7 @@ import ( "fmt" "os/exec" "strconv" + "time" ) type applyConfig struct { @@ -13,14 +14,15 @@ type applyConfig struct { lock bool // LockTimeout must be a string with time unit, e.g. '10s' - lockTimeout string - parallelism int - reattachInfo ReattachInfo - refresh bool - replaceAddrs []string - state string - stateOut string - targets []string + lockTimeout string + parallelism int + reattachInfo ReattachInfo + refresh bool + replaceAddrs []string + state string + stateOut string + targets []string + gracefulShutdownTimeout time.Duration // Vars: each var must be supplied as a single string, e.g. 'foo=bar' vars []string @@ -28,9 +30,10 @@ type applyConfig struct { } var defaultApplyOptions = applyConfig{ - lock: true, - parallelism: 10, - refresh: true, + lock: true, + parallelism: 10, + refresh: true, + gracefulShutdownTimeout: 0, } // ApplyOption represents options used in the Apply method. @@ -42,6 +45,10 @@ func (opt *ParallelismOption) configureApply(conf *applyConfig) { conf.parallelism = opt.parallelism } +func (opt *GracefulShutdownTimeoutOption) configureApply(conf *applyConfig) { + conf.gracefulShutdownTimeout = opt.timeout +} + func (opt *BackupOption) configureApply(conf *applyConfig) { conf.backup = opt.path } @@ -92,14 +99,15 @@ func (opt *ReattachOption) configureApply(conf *applyConfig) { // Apply represents the terraform apply subcommand. func (tf *Terraform) Apply(ctx context.Context, opts ...ApplyOption) error { - cmd, err := tf.applyCmd(ctx, opts...) + cmd, applyOpts, err := tf.applyCmd(ctx, opts...) if err != nil { return err } - return tf.runTerraformCmd(ctx, cmd) + + return tf.runTerraformCmdWithGracefulshutdownTimeout(ctx, cmd, applyOpts.gracefulShutdownTimeout) } -func (tf *Terraform) applyCmd(ctx context.Context, opts ...ApplyOption) (*exec.Cmd, error) { +func (tf *Terraform) applyCmd(ctx context.Context, opts ...ApplyOption) (*exec.Cmd, *applyConfig, error) { c := defaultApplyOptions for _, o := range opts { @@ -134,7 +142,7 @@ func (tf *Terraform) applyCmd(ctx context.Context, opts ...ApplyOption) (*exec.C if c.replaceAddrs != nil { err := tf.compatible(ctx, tf0_15_2, nil) if err != nil { - return nil, fmt.Errorf("replace option was introduced in Terraform 0.15.2: %w", err) + return nil, nil, fmt.Errorf("replace option was introduced in Terraform 0.15.2: %w", err) } for _, addr := range c.replaceAddrs { args = append(args, "-replace="+addr) @@ -160,10 +168,10 @@ func (tf *Terraform) applyCmd(ctx context.Context, opts ...ApplyOption) (*exec.C if c.reattachInfo != nil { reattachStr, err := c.reattachInfo.marshalString() if err != nil { - return nil, err + return nil, nil, err } mergeEnv[reattachEnvVar] = reattachStr } - return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil + return tf.buildTerraformCmd(mergeEnv, args...), &c, nil } diff --git a/tfexec/apply_test.go b/tfexec/apply_test.go index 1cf2f562..fa9c259a 100644 --- a/tfexec/apply_test.go +++ b/tfexec/apply_test.go @@ -3,6 +3,7 @@ package tfexec import ( "context" "testing" + "time" "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" ) @@ -19,7 +20,7 @@ func TestApplyCmd(t *testing.T) { tf.SetEnv(map[string]string{}) t.Run("basic", func(t *testing.T) { - applyCmd, err := tf.applyCmd(context.Background(), + applyCmd, applyOpts, err := tf.applyCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), @@ -36,6 +37,7 @@ func TestApplyCmd(t *testing.T) { Var("var1=foo"), Var("var2=bar"), DirOrPlan("testfile"), + GracefulShutdownTimeout(10*time.Second), ) if err != nil { t.Fatal(err) @@ -63,5 +65,10 @@ func TestApplyCmd(t *testing.T) { "-var", "var2=bar", "testfile", }, nil, applyCmd) + + if applyOpts.gracefulShutdownTimeout != 10*time.Second { + t.Fatalf("graceful shutdown timeout mismatch\n\nexpected:\n%v\n\ngot:\n%v", 10*time.Second, applyOpts.gracefulShutdownTimeout) + } }) + } diff --git a/tfexec/cmd.go b/tfexec/cmd.go index 56393a00..9f8c3849 100644 --- a/tfexec/cmd.go +++ b/tfexec/cmd.go @@ -179,8 +179,8 @@ func (tf *Terraform) buildEnv(mergeEnv map[string]string) []string { return envSlice(env) } -func (tf *Terraform) buildTerraformCmd(ctx context.Context, mergeEnv map[string]string, args ...string) *exec.Cmd { - cmd := exec.CommandContext(ctx, tf.execPath, args...) +func (tf *Terraform) buildTerraformCmd(mergeEnv map[string]string, args ...string) *exec.Cmd { + cmd := exec.Command(tf.execPath, args...) cmd.Env = tf.buildEnv(mergeEnv) cmd.Dir = tf.workingDir diff --git a/tfexec/cmd_default.go b/tfexec/cmd_default.go index 6d7b768e..ad4d8b43 100644 --- a/tfexec/cmd_default.go +++ b/tfexec/cmd_default.go @@ -5,12 +5,21 @@ package tfexec import ( "context" + "fmt" + "os" "os/exec" "strings" "sync" + "time" ) -func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { +const defaultGracefulShutdownTimeout = 0 + +func (tf *Terraform) runTerraformCmd(parentCtx context.Context, cmd *exec.Cmd) error { + return tf.runTerraformCmdWithGracefulshutdownTimeout(parentCtx, cmd, defaultGracefulShutdownTimeout) +} + +func (tf *Terraform) runTerraformCmdWithGracefulshutdownTimeout(ctx context.Context, cmd *exec.Cmd, _ time.Duration) error { var errBuf strings.Builder // check for early cancellation @@ -38,7 +47,19 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { if err != nil { return err } - + cmdDoneCh := make(chan error, 1) + returnCh := make(chan error, 1) + defer close(cmdDoneCh) + go func() { + defer close(returnCh) + select { + case <-ctx.Done(): // wait for context cancelled + cmd.Process.Signal(os.Kill) + returnCh <- fmt.Errorf("%w: terraform forcefully killed", ctx.Err()) + case err := <-cmdDoneCh: + returnCh <- err + } + }() err = cmd.Start() if err == nil && ctx.Err() != nil { err = ctx.Err() @@ -65,7 +86,9 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { // can cause a race condition wg.Wait() - err = cmd.Wait() + cmdDoneCh <- cmd.Wait() + err = <-returnCh + if err == nil && ctx.Err() != nil { err = ctx.Err() } diff --git a/tfexec/cmd_default_test.go b/tfexec/cmd_default_test.go index 0245ddaa..d17307bd 100644 --- a/tfexec/cmd_default_test.go +++ b/tfexec/cmd_default_test.go @@ -6,6 +6,7 @@ package tfexec import ( "bytes" "context" + "errors" "log" "strings" "testing" @@ -24,7 +25,7 @@ func Test_runTerraformCmd_default(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - cmd := tf.buildTerraformCmd(ctx, nil, "hello tf-exec!") + cmd := tf.buildTerraformCmd(nil, "hello tf-exec!") err := tf.runTerraformCmd(ctx, cmd) if err != nil { t.Fatal(err) @@ -37,3 +38,24 @@ func Test_runTerraformCmd_default(t *testing.T) { t.Fatal("canceling context should not lead to logging an error") } } + +func Test_runTerraformCmdCancelCtx_default(t *testing.T) { + var buf bytes.Buffer + + tf := &Terraform{ + logger: log.New(&buf, "", 0), + execPath: "sleep", + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cmd := tf.buildTerraformCmd(nil, "3") + go func() { + <-time.After(1 * time.Second) + cancel() + }() + err := tf.runTerraformCmd(ctx, cmd) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %T %s", err, err) + } +} diff --git a/tfexec/cmd_linux.go b/tfexec/cmd_linux.go index 6fa40e0a..0e69446c 100644 --- a/tfexec/cmd_linux.go +++ b/tfexec/cmd_linux.go @@ -2,13 +2,22 @@ package tfexec import ( "context" + "fmt" + "os" "os/exec" "strings" "sync" "syscall" + "time" ) -func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { +const defaultGracefulShutdownTimeout = 0 + +func (tf *Terraform) runTerraformCmd(parentCtx context.Context, cmd *exec.Cmd) error { + return tf.runTerraformCmdWithGracefulshutdownTimeout(parentCtx, cmd, defaultGracefulShutdownTimeout) +} + +func (tf *Terraform) runTerraformCmdWithGracefulshutdownTimeout(parentCtx context.Context, cmd *exec.Cmd, gracefulShutdownTimeout time.Duration) error { var errBuf strings.Builder cmd.SysProcAttr = &syscall.SysProcAttr{ @@ -20,10 +29,14 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { // check for early cancellation select { - case <-ctx.Done(): - return ctx.Err() + case <-parentCtx.Done(): + return parentCtx.Err() default: } + // Context for the stdout and stderr writers so that they are not closed on parentCtx cancellation + // and avoiding "broken pipe" + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() // Read stdout / stderr logs from pipe instead of setting cmd.Stdout and // cmd.Stderr because it can cause hanging when killing the command @@ -43,8 +56,51 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { if err != nil { return err } - - err = cmd.Start() + cmdMu := sync.Mutex{} + withCmdLock := func(fn func() error) error { + cmdMu.Lock() + defer cmdMu.Unlock() + return fn() + } + cmdDoneCh := make(chan error, 1) + returnCh := make(chan error, 1) + defer close(returnCh) + go func() { + select { + case <-parentCtx.Done(): // wait for context cancelled + if gracefulShutdownTimeout == 0 { + if err := withCmdLock(func() error { return cmd.Process.Signal(os.Kill) }); err != nil { + tf.logger.Printf("[ERROR] Error sending SIGKILL to terraform: %v", err) + } + cancel() // to cancel stdout/stderr writers + tf.logger.Printf("[ERROR] terraform forcefully killed") + returnCh <- fmt.Errorf("%w: terraform forcefully killed", parentCtx.Err()) + return + } + tf.logger.Printf("[WARN] The context was cancelled, we'll let Terraform finish by sending SIGINT signal") + if err := withCmdLock(func() error { return cmd.Process.Signal(os.Interrupt) }); err != nil { + tf.logger.Printf("[ERROR] Error sending SIGINT to terraform: %v", err) + } + // give some time to the process before forcefully killing it + select { + case <-time.After(gracefulShutdownTimeout): + // Forcefully kill the process + if err := withCmdLock(func() error { return cmd.Process.Signal(os.Kill) }); err != nil { + tf.logger.Printf("[ERROR] Error sending SIGKILL to terraform: %v", err) + } + cancel() // to cancel stdout/stderr writers + tf.logger.Printf("[ERROR] terraform forcefully killed after graceful shutdown timeout") + returnCh <- fmt.Errorf("%w: terraform forcefully killed after graceful shutdown timeout", parentCtx.Err()) + case err := <-cmdDoneCh: + returnCh <- fmt.Errorf("%w: %v", parentCtx.Err(), err) + tf.logger.Printf("[INFO] terraform successfully interrupted") + } + case err := <-cmdDoneCh: + tf.logger.Printf("[DEBUG] terraform run finished") + returnCh <- err + } + }() + err = withCmdLock(func() error { return cmd.Start() }) if err == nil && ctx.Err() != nil { err = ctx.Err() } @@ -70,12 +126,14 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { // can cause a race condition wg.Wait() - err = cmd.Wait() - if err == nil && ctx.Err() != nil { - err = ctx.Err() + cmdDoneCh <- cmd.Wait() + err = <-returnCh + + if err == nil && parentCtx.Err() != nil { + err = parentCtx.Err() } if err != nil { - return tf.wrapExitError(ctx, err, errBuf.String()) + return tf.wrapExitError(parentCtx, err, errBuf.String()) } // Return error if there was an issue reading the std out/err diff --git a/tfexec/cmd_linux_test.go b/tfexec/cmd_linux_test.go index 471ddf57..8a93e3d4 100644 --- a/tfexec/cmd_linux_test.go +++ b/tfexec/cmd_linux_test.go @@ -21,7 +21,7 @@ func Test_runTerraformCmd_linux(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - cmd := tf.buildTerraformCmd(ctx, nil, "hello tf-exec!") + cmd := tf.buildTerraformCmd(nil, "hello tf-exec!") err := tf.runTerraformCmd(ctx, cmd) if err != nil { t.Fatal(err) diff --git a/tfexec/destroy.go b/tfexec/destroy.go index 8011c0ba..34a89839 100644 --- a/tfexec/destroy.go +++ b/tfexec/destroy.go @@ -5,6 +5,7 @@ import ( "fmt" "os/exec" "strconv" + "time" ) type destroyConfig struct { @@ -13,13 +14,14 @@ type destroyConfig struct { lock bool // LockTimeout must be a string with time unit, e.g. '10s' - lockTimeout string - parallelism int - reattachInfo ReattachInfo - refresh bool - state string - stateOut string - targets []string + lockTimeout string + parallelism int + reattachInfo ReattachInfo + refresh bool + state string + stateOut string + targets []string + gracefulShutdownTimeout time.Duration // Vars: each var must be supplied as a single string, e.g. 'foo=bar' vars []string @@ -27,10 +29,11 @@ type destroyConfig struct { } var defaultDestroyOptions = destroyConfig{ - lock: true, - lockTimeout: "0s", - parallelism: 10, - refresh: true, + lock: true, + lockTimeout: "0s", + parallelism: 10, + refresh: true, + gracefulShutdownTimeout: 0, } // DestroyOption represents options used in the Destroy method. @@ -46,6 +49,10 @@ func (opt *ParallelismOption) configureDestroy(conf *destroyConfig) { conf.parallelism = opt.parallelism } +func (opt *GracefulShutdownTimeoutOption) configureDestroy(conf *destroyConfig) { + conf.gracefulShutdownTimeout = opt.timeout +} + func (opt *BackupOption) configureDestroy(conf *destroyConfig) { conf.backup = opt.path } @@ -88,14 +95,14 @@ func (opt *ReattachOption) configureDestroy(conf *destroyConfig) { // Destroy represents the terraform destroy subcommand. func (tf *Terraform) Destroy(ctx context.Context, opts ...DestroyOption) error { - cmd, err := tf.destroyCmd(ctx, opts...) + cmd, destroyOpts, err := tf.destroyCmd(ctx, opts...) if err != nil { return err } - return tf.runTerraformCmd(ctx, cmd) + return tf.runTerraformCmdWithGracefulshutdownTimeout(ctx, cmd, destroyOpts.gracefulShutdownTimeout) } -func (tf *Terraform) destroyCmd(ctx context.Context, opts ...DestroyOption) (*exec.Cmd, error) { +func (tf *Terraform) destroyCmd(ctx context.Context, opts ...DestroyOption) (*exec.Cmd, *destroyConfig, error) { c := defaultDestroyOptions for _, o := range opts { @@ -147,10 +154,10 @@ func (tf *Terraform) destroyCmd(ctx context.Context, opts ...DestroyOption) (*ex if c.reattachInfo != nil { reattachStr, err := c.reattachInfo.marshalString() if err != nil { - return nil, err + return nil, nil, err } mergeEnv[reattachEnvVar] = reattachStr } - return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil + return tf.buildTerraformCmd(mergeEnv, args...), &c, nil } diff --git a/tfexec/destroy_test.go b/tfexec/destroy_test.go index eb28f58a..b0ec39af 100644 --- a/tfexec/destroy_test.go +++ b/tfexec/destroy_test.go @@ -3,6 +3,7 @@ package tfexec import ( "context" "testing" + "time" "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" ) @@ -19,7 +20,7 @@ func TestDestroyCmd(t *testing.T) { tf.SetEnv(map[string]string{}) t.Run("defaults", func(t *testing.T) { - destroyCmd, err := tf.destroyCmd(context.Background()) + destroyCmd, destroyOpts, err := tf.destroyCmd(context.Background()) if err != nil { t.Fatal(err) } @@ -34,10 +35,14 @@ func TestDestroyCmd(t *testing.T) { "-parallelism=10", "-refresh=true", }, nil, destroyCmd) + + if destroyOpts.gracefulShutdownTimeout != 0 { + t.Fatalf("graceful shutdown timeout mismatch\n\nexpected:\n%v\n\ngot:\n%v", 0, destroyOpts.gracefulShutdownTimeout) + } }) t.Run("override all defaults", func(t *testing.T) { - 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")) + destroyCmd, destroyOpts, 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"), GracefulShutdownTimeout(5*time.Minute)) if err != nil { t.Fatal(err) } @@ -61,5 +66,9 @@ func TestDestroyCmd(t *testing.T) { "-var", "var2=bar", "destroydir", }, nil, destroyCmd) + + if destroyOpts.gracefulShutdownTimeout != 5*time.Minute { + t.Fatalf("graceful shutdown timeout mismatch\n\nexpected:\n%v\n\ngot:\n%v", 5*time.Minute, destroyOpts.gracefulShutdownTimeout) + } }) } diff --git a/tfexec/fmt.go b/tfexec/fmt.go index 2234c79f..37955c1e 100644 --- a/tfexec/fmt.go +++ b/tfexec/fmt.go @@ -155,5 +155,5 @@ func (tf *Terraform) formatCmd(ctx context.Context, args []string, opts ...Forma args = append(args, c.dir) } - return tf.buildTerraformCmd(ctx, nil, args...), nil + return tf.buildTerraformCmd(nil, args...), nil } diff --git a/tfexec/force_unlock.go b/tfexec/force_unlock.go index de95f547..093e3613 100644 --- a/tfexec/force_unlock.go +++ b/tfexec/force_unlock.go @@ -54,5 +54,5 @@ func (tf *Terraform) forceUnlockCmd(ctx context.Context, lockID string, opts ... args = append(args, c.dir) } - return tf.buildTerraformCmd(ctx, nil, args...), nil + return tf.buildTerraformCmd(nil, args...), nil } diff --git a/tfexec/get.go b/tfexec/get.go index 5bac9b19..0a2d39f7 100644 --- a/tfexec/get.go +++ b/tfexec/get.go @@ -48,5 +48,5 @@ func (tf *Terraform) getCmd(ctx context.Context, opts ...GetCmdOption) (*exec.Cm args = append(args, c.dir) } - return tf.buildTerraformCmd(ctx, nil, args...), nil + return tf.buildTerraformCmd(nil, args...), nil } diff --git a/tfexec/graph.go b/tfexec/graph.go index 73396280..f6e31a5b 100644 --- a/tfexec/graph.go +++ b/tfexec/graph.go @@ -81,5 +81,5 @@ func (tf *Terraform) graphCmd(ctx context.Context, opts ...GraphOption) (*exec.C args = append(args, "-type="+c.graphType) } - return tf.buildTerraformCmd(ctx, nil, args...), nil + return tf.buildTerraformCmd(nil, args...), nil } diff --git a/tfexec/import.go b/tfexec/import.go index e243d728..7647c6bb 100644 --- a/tfexec/import.go +++ b/tfexec/import.go @@ -137,5 +137,5 @@ func (tf *Terraform) importCmd(ctx context.Context, address, id string, opts ... mergeEnv[reattachEnvVar] = reattachStr } - return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil + return tf.buildTerraformCmd(mergeEnv, args...), nil } diff --git a/tfexec/init.go b/tfexec/init.go index 8fd36677..90aa37bc 100644 --- a/tfexec/init.go +++ b/tfexec/init.go @@ -183,5 +183,5 @@ func (tf *Terraform) initCmd(ctx context.Context, opts ...InitOption) (*exec.Cmd mergeEnv[reattachEnvVar] = reattachStr } - return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil + return tf.buildTerraformCmd(mergeEnv, args...), nil } diff --git a/tfexec/internal/e2etest/graceful_termination_linux_test.go b/tfexec/internal/e2etest/graceful_termination_linux_test.go new file mode 100644 index 00000000..419fc257 --- /dev/null +++ b/tfexec/internal/e2etest/graceful_termination_linux_test.go @@ -0,0 +1,77 @@ +package e2etest + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-exec/tfexec" + "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" +) + +func Test_gracefulTerminationRunTerraformCmd_linux(t *testing.T) { + runTestVersions(t, []string{testutil.Latest_v1_1}, "infinite_loop", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + var bufStdout bytes.Buffer + var bufStderr bytes.Buffer + tf.SetStderr(&bufStdout) + tf.SetStdout(&bufStderr) + + ctx, cancel := context.WithCancel(context.Background()) + err := tf.Init(context.Background()) + if err != nil { + t.Fatalf("error running Init in test directory: %s", err) + } + doneCh := make(chan error) + go func() { + doneCh <- tf.Apply(ctx, tfexec.GracefulShutdownTimeout(10*time.Second)) + }() + time.Sleep(3 * time.Second) + cancel() + err = <-doneCh + close(doneCh) + if err != nil { + t.Log(err) + } + output := bufStderr.String() + bufStdout.String() + t.Log(output) + if !strings.Contains(output, "Gracefully shutting down...") { + t.Fatal("canceling context should gracefully shut terraform down") + } + }) + +} + +func Test_gracefulTerminationRunTerraformCmdWithNoGracefulShutdownTimeout_linux(t *testing.T) { + runTestVersions(t, []string{testutil.Latest_v1_1}, "infinite_loop", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + var bufStdout bytes.Buffer + var bufStderr bytes.Buffer + tf.SetStderr(&bufStdout) + tf.SetStdout(&bufStderr) + + ctx, cancel := context.WithCancel(context.Background()) + err := tf.Init(context.Background()) + if err != nil { + t.Fatalf("error running Init in test directory: %s", err) + } + doneCh := make(chan error) + go func() { + doneCh <- tf.Apply(ctx) + }() + time.Sleep(3 * time.Second) + cancel() + err = <-doneCh + close(doneCh) + if err != nil { + t.Log(err) + } + output := bufStderr.String() + bufStdout.String() + t.Log(output) + if strings.Contains(output, "Gracefully shutting down...") { + t.Fatal("canceling context with no graceful shutdown timeout should immediately kill the process and not start a graceful cancellation") + } + }) + +} diff --git a/tfexec/internal/e2etest/testdata/infinite_loop/main.tf b/tfexec/internal/e2etest/testdata/infinite_loop/main.tf new file mode 100644 index 00000000..1fc938ee --- /dev/null +++ b/tfexec/internal/e2etest/testdata/infinite_loop/main.tf @@ -0,0 +1,8 @@ +resource "null_resource" "example1" { + triggers = { + always_run = "${timestamp()}" + } + provisioner "local-exec" { + command = " while true; do echo 'Hit CTRL+C'; sleep 1; done" + } +} diff --git a/tfexec/options.go b/tfexec/options.go index ad3cc65c..ff7447f4 100644 --- a/tfexec/options.go +++ b/tfexec/options.go @@ -2,6 +2,7 @@ package tfexec import ( "encoding/json" + "time" ) // AllowMissingConfigOption represents the -allow-missing-config flag. @@ -231,6 +232,14 @@ func Parallelism(n int) *ParallelismOption { return &ParallelismOption{n} } +type GracefulShutdownTimeoutOption struct { + timeout time.Duration +} + +func GracefulShutdownTimeout(timeout time.Duration) *GracefulShutdownTimeoutOption { + return &GracefulShutdownTimeoutOption{timeout} +} + type GraphPlanOption struct { file string } diff --git a/tfexec/output.go b/tfexec/output.go index b16b8b72..78e5fce3 100644 --- a/tfexec/output.go +++ b/tfexec/output.go @@ -59,5 +59,5 @@ func (tf *Terraform) outputCmd(ctx context.Context, opts ...OutputOption) *exec. args = append(args, "-state="+c.state) } - return tf.buildTerraformCmd(ctx, nil, args...) + return tf.buildTerraformCmd(nil, args...) } diff --git a/tfexec/plan.go b/tfexec/plan.go index bf41094b..8bdb4615 100644 --- a/tfexec/plan.go +++ b/tfexec/plan.go @@ -176,5 +176,5 @@ func (tf *Terraform) planCmd(ctx context.Context, opts ...PlanOption) (*exec.Cmd mergeEnv[reattachEnvVar] = reattachStr } - return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil + return tf.buildTerraformCmd(mergeEnv, args...), nil } diff --git a/tfexec/providers_lock.go b/tfexec/providers_lock.go index b3a20216..46419f5f 100644 --- a/tfexec/providers_lock.go +++ b/tfexec/providers_lock.go @@ -78,5 +78,5 @@ func (tf *Terraform) providersLockCmd(ctx context.Context, opts ...ProvidersLock args = append(args, p) } - return tf.buildTerraformCmd(ctx, nil, args...) + return tf.buildTerraformCmd(nil, args...) } diff --git a/tfexec/providers_schema.go b/tfexec/providers_schema.go index 52efc5db..df1722eb 100644 --- a/tfexec/providers_schema.go +++ b/tfexec/providers_schema.go @@ -29,5 +29,5 @@ func (tf *Terraform) providersSchemaCmd(ctx context.Context, args ...string) *ex allArgs := []string{"providers", "schema", "-json", "-no-color"} allArgs = append(allArgs, args...) - return tf.buildTerraformCmd(ctx, nil, allArgs...) + return tf.buildTerraformCmd(nil, allArgs...) } diff --git a/tfexec/refresh.go b/tfexec/refresh.go index 78f6b4b5..99b870bb 100644 --- a/tfexec/refresh.go +++ b/tfexec/refresh.go @@ -133,5 +133,5 @@ func (tf *Terraform) refreshCmd(ctx context.Context, opts ...RefreshCmdOption) ( mergeEnv[reattachEnvVar] = reattachStr } - return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil + return tf.buildTerraformCmd(mergeEnv, args...), nil } diff --git a/tfexec/show.go b/tfexec/show.go index 61e660ac..23cca6f3 100644 --- a/tfexec/show.go +++ b/tfexec/show.go @@ -192,5 +192,5 @@ func (tf *Terraform) showCmd(ctx context.Context, jsonOutput bool, mergeEnv map[ allArgs = append(allArgs, "-no-color") allArgs = append(allArgs, args...) - return tf.buildTerraformCmd(ctx, mergeEnv, allArgs...) + return tf.buildTerraformCmd(mergeEnv, allArgs...) } diff --git a/tfexec/state_mv.go b/tfexec/state_mv.go index fc7eecf8..3472f444 100644 --- a/tfexec/state_mv.go +++ b/tfexec/state_mv.go @@ -101,5 +101,5 @@ func (tf *Terraform) stateMvCmd(ctx context.Context, source string, destination args = append(args, source) args = append(args, destination) - return tf.buildTerraformCmd(ctx, nil, args...), nil + return tf.buildTerraformCmd(nil, args...), nil } diff --git a/tfexec/state_pull.go b/tfexec/state_pull.go index 11b6b9c7..708d67be 100644 --- a/tfexec/state_pull.go +++ b/tfexec/state_pull.go @@ -51,5 +51,5 @@ func (tf *Terraform) StatePull(ctx context.Context, opts ...StatePullOption) (st func (tf *Terraform) statePullCmd(ctx context.Context, mergeEnv map[string]string) *exec.Cmd { args := []string{"state", "pull"} - return tf.buildTerraformCmd(ctx, mergeEnv, args...) + return tf.buildTerraformCmd(mergeEnv, args...) } diff --git a/tfexec/state_push.go b/tfexec/state_push.go index 14e55a2e..1edd626b 100644 --- a/tfexec/state_push.go +++ b/tfexec/state_push.go @@ -63,5 +63,5 @@ func (tf *Terraform) statePushCmd(ctx context.Context, path string, opts ...Stat args = append(args, path) - return tf.buildTerraformCmd(ctx, nil, args...), nil + return tf.buildTerraformCmd(nil, args...), nil } diff --git a/tfexec/state_rm.go b/tfexec/state_rm.go index 0c5dd666..2423a35b 100644 --- a/tfexec/state_rm.go +++ b/tfexec/state_rm.go @@ -100,5 +100,5 @@ func (tf *Terraform) stateRmCmd(ctx context.Context, address string, opts ...Sta // positional arguments args = append(args, address) - return tf.buildTerraformCmd(ctx, nil, args...), nil + return tf.buildTerraformCmd(nil, args...), nil } diff --git a/tfexec/taint.go b/tfexec/taint.go index cd69df30..e789923d 100644 --- a/tfexec/taint.go +++ b/tfexec/taint.go @@ -74,5 +74,5 @@ func (tf *Terraform) taintCmd(ctx context.Context, address string, opts ...Taint } args = append(args, address) - return tf.buildTerraformCmd(ctx, nil, args...) + return tf.buildTerraformCmd(nil, args...) } diff --git a/tfexec/untaint.go b/tfexec/untaint.go index bda12727..f5c2881a 100644 --- a/tfexec/untaint.go +++ b/tfexec/untaint.go @@ -74,5 +74,5 @@ func (tf *Terraform) untaintCmd(ctx context.Context, address string, opts ...Unt } args = append(args, address) - return tf.buildTerraformCmd(ctx, nil, args...) + return tf.buildTerraformCmd(nil, args...) } diff --git a/tfexec/upgrade012.go b/tfexec/upgrade012.go index e55237a7..3c3c1ff0 100644 --- a/tfexec/upgrade012.go +++ b/tfexec/upgrade012.go @@ -76,5 +76,5 @@ func (tf *Terraform) upgrade012Cmd(ctx context.Context, opts ...Upgrade012Option mergeEnv[reattachEnvVar] = reattachStr } - return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil + return tf.buildTerraformCmd(mergeEnv, args...), nil } diff --git a/tfexec/upgrade013.go b/tfexec/upgrade013.go index f1f444e2..ba8b6114 100644 --- a/tfexec/upgrade013.go +++ b/tfexec/upgrade013.go @@ -64,5 +64,5 @@ func (tf *Terraform) upgrade013Cmd(ctx context.Context, opts ...Upgrade013Option mergeEnv[reattachEnvVar] = reattachStr } - return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil + return tf.buildTerraformCmd(mergeEnv, args...), nil } diff --git a/tfexec/validate.go b/tfexec/validate.go index 320011df..38dc1c14 100644 --- a/tfexec/validate.go +++ b/tfexec/validate.go @@ -17,7 +17,7 @@ func (tf *Terraform) Validate(ctx context.Context) (*tfjson.ValidateOutput, erro return nil, fmt.Errorf("terraform validate -json was added in 0.12.0: %w", err) } - cmd := tf.buildTerraformCmd(ctx, nil, "validate", "-no-color", "-json") + cmd := tf.buildTerraformCmd(nil, "validate", "-no-color", "-json") var outBuf = bytes.Buffer{} cmd.Stdout = &outBuf diff --git a/tfexec/version.go b/tfexec/version.go index 9978ae28..c86191d1 100644 --- a/tfexec/version.go +++ b/tfexec/version.go @@ -47,7 +47,7 @@ func (tf *Terraform) Version(ctx context.Context, skipCache bool) (tfVersion *ve // version does not use the locking on the Terraform instance and should probably not be used directly, prefer Version. func (tf *Terraform) version(ctx context.Context) (*version.Version, map[string]*version.Version, error) { - versionCmd := tf.buildTerraformCmd(ctx, nil, "version", "-json") + versionCmd := tf.buildTerraformCmd(nil, "version", "-json") var outBuf bytes.Buffer versionCmd.Stdout = &outBuf @@ -93,7 +93,7 @@ func parseJsonVersionOutput(stdout []byte) (*version.Version, map[string]*versio } func (tf *Terraform) versionFromPlaintext(ctx context.Context) (*version.Version, map[string]*version.Version, error) { - versionCmd := tf.buildTerraformCmd(ctx, nil, "version") + versionCmd := tf.buildTerraformCmd(nil, "version") var outBuf strings.Builder versionCmd.Stdout = &outBuf diff --git a/tfexec/workspace_delete.go b/tfexec/workspace_delete.go index 52677207..48adea4c 100644 --- a/tfexec/workspace_delete.go +++ b/tfexec/workspace_delete.go @@ -75,7 +75,7 @@ func (tf *Terraform) workspaceDeleteCmd(ctx context.Context, workspace string, o args = append(args, workspace) - cmd := tf.buildTerraformCmd(ctx, nil, args...) + cmd := tf.buildTerraformCmd(nil, args...) return cmd, nil } diff --git a/tfexec/workspace_list.go b/tfexec/workspace_list.go index 33c0d779..384c7c9c 100644 --- a/tfexec/workspace_list.go +++ b/tfexec/workspace_list.go @@ -8,7 +8,7 @@ import ( // WorkspaceList represents the workspace list subcommand to the Terraform CLI. func (tf *Terraform) WorkspaceList(ctx context.Context) ([]string, string, error) { // TODO: [DIR] param option - wlCmd := tf.buildTerraformCmd(ctx, nil, "workspace", "list", "-no-color") + wlCmd := tf.buildTerraformCmd(nil, "workspace", "list", "-no-color") var outBuf strings.Builder wlCmd.Stdout = &outBuf diff --git a/tfexec/workspace_new.go b/tfexec/workspace_new.go index 2e05ffdb..aa133bfd 100644 --- a/tfexec/workspace_new.go +++ b/tfexec/workspace_new.go @@ -77,7 +77,7 @@ func (tf *Terraform) workspaceNewCmd(ctx context.Context, workspace string, opts args = append(args, workspace) - cmd := tf.buildTerraformCmd(ctx, nil, args...) + cmd := tf.buildTerraformCmd(nil, args...) return cmd, nil } diff --git a/tfexec/workspace_select.go b/tfexec/workspace_select.go index 5a51330f..3f47aeb4 100644 --- a/tfexec/workspace_select.go +++ b/tfexec/workspace_select.go @@ -6,5 +6,5 @@ import "context" func (tf *Terraform) WorkspaceSelect(ctx context.Context, workspace string) error { // TODO: [DIR] param option - return tf.runTerraformCmd(ctx, tf.buildTerraformCmd(ctx, nil, "workspace", "select", "-no-color", workspace)) + return tf.runTerraformCmd(ctx, tf.buildTerraformCmd(nil, "workspace", "select", "-no-color", workspace)) } diff --git a/tfexec/workspace_show.go b/tfexec/workspace_show.go index 7d5a267f..083cb92a 100644 --- a/tfexec/workspace_show.go +++ b/tfexec/workspace_show.go @@ -31,5 +31,5 @@ func (tf *Terraform) workspaceShowCmd(ctx context.Context) (*exec.Cmd, error) { return nil, fmt.Errorf("workspace show was first introduced in Terraform 0.10.0: %w", err) } - return tf.buildTerraformCmd(ctx, nil, "workspace", "show", "-no-color"), nil + return tf.buildTerraformCmd(nil, "workspace", "show", "-no-color"), nil }