From 905338b08ee994dc1459c1d886f225aef0232b19 Mon Sep 17 00:00:00 2001 From: Junfeng Wu Date: Mon, 13 May 2024 17:25:10 +0800 Subject: [PATCH] feat: add graceful shutdown option for apply&destroy (#5) --- go.mod | 2 +- tfexec/apply.go | 49 +++++++++----- tfexec/apply_test.go | 46 +++++++------ tfexec/cmd.go | 128 +++++++++++++++++++++++++++++++++++++ tfexec/cmd_default.go | 92 ++------------------------ tfexec/cmd_default_test.go | 42 ------------ tfexec/cmd_linux.go | 102 +++-------------------------- tfexec/cmd_linux_test.go | 39 ----------- tfexec/cmd_test.go | 30 +++++++++ tfexec/cmd_unix.go | 13 ++++ tfexec/destroy.go | 49 +++++++++----- tfexec/destroy_test.go | 8 +-- tfexec/options.go | 15 +++++ 13 files changed, 296 insertions(+), 319 deletions(-) delete mode 100644 tfexec/cmd_default_test.go delete mode 100644 tfexec/cmd_linux_test.go create mode 100644 tfexec/cmd_unix.go diff --git a/go.mod b/go.mod index d5a18a45..f5dc6657 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/hashicorp/terraform-exec -go 1.18 +go 1.20 require ( github.com/google/go-cmp v0.6.0 diff --git a/tfexec/apply.go b/tfexec/apply.go index 7a6ea923..11119742 100644 --- a/tfexec/apply.go +++ b/tfexec/apply.go @@ -32,6 +32,8 @@ type applyConfig struct { // Vars: each var must be supplied as a single string, e.g. 'foo=bar' vars []string varFiles []string + + gracefulShutdownConfig GracefulShutdownConfig } var defaultApplyOptions = applyConfig{ @@ -110,12 +112,31 @@ func (opt *AllowDeferralOption) configureApply(conf *applyConfig) { conf.allowDeferral = opt.allowDeferral } +func (opt *GracefulShutdownOption) configureApply(conf *applyConfig) { + conf.gracefulShutdownConfig = opt.config +} + +func newApplyConfig(opts ...ApplyOption) applyConfig { + c := defaultApplyOptions + + for _, o := range opts { + o.configureApply(&c) + } + return c +} + // Apply represents the terraform apply subcommand. func (tf *Terraform) Apply(ctx context.Context, opts ...ApplyOption) error { - cmd, err := tf.applyCmd(ctx, opts...) + c := newApplyConfig(opts...) + + cmd, err := tf.applyCmd(ctx, c) if err != nil { return err } + + if c.gracefulShutdownConfig.Enable { + return tf.runTerraformCmdWithGracefulShutdown(ctx, cmd) + } return tf.runTerraformCmd(ctx, cmd) } @@ -132,21 +153,20 @@ func (tf *Terraform) ApplyJSON(ctx context.Context, w io.Writer, opts ...ApplyOp tf.SetStdout(w) - cmd, err := tf.applyJSONCmd(ctx, opts...) + c := newApplyConfig(opts...) + + cmd, err := tf.applyJSONCmd(ctx, c) if err != nil { return err } + if c.gracefulShutdownConfig.Enable { + return tf.runTerraformCmdWithGracefulShutdown(ctx, cmd) + } return tf.runTerraformCmd(ctx, cmd) } -func (tf *Terraform) applyCmd(ctx context.Context, opts ...ApplyOption) (*exec.Cmd, error) { - c := defaultApplyOptions - - for _, o := range opts { - o.configureApply(&c) - } - +func (tf *Terraform) applyCmd(ctx context.Context, c applyConfig) (*exec.Cmd, error) { args, err := tf.buildApplyArgs(ctx, c) if err != nil { return nil, err @@ -155,13 +175,7 @@ func (tf *Terraform) applyCmd(ctx context.Context, opts ...ApplyOption) (*exec.C return tf.buildApplyCmd(ctx, c, args) } -func (tf *Terraform) applyJSONCmd(ctx context.Context, opts ...ApplyOption) (*exec.Cmd, error) { - c := defaultApplyOptions - - for _, o := range opts { - o.configureApply(&c) - } - +func (tf *Terraform) applyJSONCmd(ctx context.Context, c applyConfig) (*exec.Cmd, error) { args, err := tf.buildApplyArgs(ctx, c) if err != nil { return nil, err @@ -271,5 +285,8 @@ func (tf *Terraform) buildApplyCmd(ctx context.Context, c applyConfig, args []st mergeEnv[reattachEnvVar] = reattachStr } + if c.gracefulShutdownConfig.Enable { + return tf.buildTerraformCmdWithGracefulShutdown(ctx, c.gracefulShutdownConfig.Period, mergeEnv, args...), nil + } return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil } diff --git a/tfexec/apply_test.go b/tfexec/apply_test.go index fe0420ad..42d39659 100644 --- a/tfexec/apply_test.go +++ b/tfexec/apply_test.go @@ -22,7 +22,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, err := tf.applyCmd(context.Background(), newApplyConfig( Backup("testbackup"), LockTimeout("200s"), State("teststate"), @@ -40,7 +40,7 @@ func TestApplyCmd(t *testing.T) { Var("var2=bar"), Destroy(true), DirOrPlan("testfile"), - ) + )) if err != nil { t.Fatal(err) } @@ -72,7 +72,9 @@ func TestApplyCmd(t *testing.T) { t.Run("refresh-only operation", func(t *testing.T) { applyCmd, err := tf.applyCmd(context.Background(), - RefreshOnly(true), + newApplyConfig( + RefreshOnly(true), + ), ) if err != nil { t.Fatal(err) @@ -104,22 +106,24 @@ func TestApplyJSONCmd(t *testing.T) { t.Run("basic", func(t *testing.T) { applyCmd, err := tf.applyJSONCmd(context.Background(), - Backup("testbackup"), - LockTimeout("200s"), - State("teststate"), - StateOut("teststateout"), - VarFile("foo.tfvars"), - VarFile("bar.tfvars"), - Lock(false), - Parallelism(99), - Refresh(false), - Replace("aws_instance.test"), - Replace("google_pubsub_topic.test"), - Target("target1"), - Target("target2"), - Var("var1=foo"), - Var("var2=bar"), - DirOrPlan("testfile"), + newApplyConfig( + Backup("testbackup"), + LockTimeout("200s"), + State("teststate"), + StateOut("teststateout"), + VarFile("foo.tfvars"), + VarFile("bar.tfvars"), + Lock(false), + Parallelism(99), + Refresh(false), + Replace("aws_instance.test"), + Replace("google_pubsub_topic.test"), + Target("target1"), + Target("target2"), + Var("var1=foo"), + Var("var2=bar"), + DirOrPlan("testfile"), + ), ) if err != nil { t.Fatal(err) @@ -164,7 +168,9 @@ func TestApplyCmd_AllowDeferral(t *testing.T) { t.Run("allow deferrals during apply", func(t *testing.T) { applyCmd, err := tf.applyCmd(context.Background(), - AllowDeferral(true), + newApplyConfig( + AllowDeferral(true), + ), ) if err != nil { t.Fatal(err) diff --git a/tfexec/cmd.go b/tfexec/cmd.go index 5e160324..518e2f0c 100644 --- a/tfexec/cmd.go +++ b/tfexec/cmd.go @@ -15,6 +15,8 @@ import ( "os" "os/exec" "strings" + "sync" + "time" "github.com/hashicorp/terraform-exec/internal/version" ) @@ -193,6 +195,30 @@ func (tf *Terraform) buildTerraformCmd(ctx context.Context, mergeEnv map[string] return cmd } +func (tf *Terraform) buildTerraformCmdWithGracefulShutdown(ctx context.Context, gracefulShutdownPeriod time.Duration, mergeEnv map[string]string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, tf.execPath, args...) + + cmd.Env = tf.buildEnv(mergeEnv) + cmd.Dir = tf.workingDir + + tf.logger.Printf("[INFO] configuring gracefully cancellation for command: %s", cmd.String()) + cmd.WaitDelay = gracefulShutdownPeriod + cmd.Cancel = func() error { + tf.logger.Printf("[INFO] gracefully cancelling Terraform command: %s", cmd.String()) + // os.Interrupt doesn't work on Windows so it will result into an immediate kill. + err := cmd.Process.Signal(os.Interrupt) + if err != nil && !errors.Is(err, os.ErrProcessDone) { + tf.logger.Printf("[ERROR] gracefully cancelling failed due to %s, forcefully cancelling Terraform command: %s", err.Error(), cmd.String()) + err = cmd.Process.Kill() + } + return err + } + + tf.logger.Printf("[INFO] running Terraform command: %s", cmd.String()) + + return cmd +} + func (tf *Terraform) runTerraformCmdJSON(ctx context.Context, cmd *exec.Cmd, v interface{}) error { var outbuf = bytes.Buffer{} cmd.Stdout = mergeWriters(cmd.Stdout, &outbuf) @@ -275,3 +301,105 @@ func writeOutput(ctx context.Context, r io.ReadCloser, w io.Writer) error { } } } + +func (tf *Terraform) runTerraformCmdWithGracefulShutdown(ctx context.Context, cmd *exec.Cmd) error { + return tf.runTerraformCmdInner(ctx, cmd, true /* enableGracefulShutdown */) +} + +func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { + return tf.runTerraformCmdInner(ctx, cmd, false /* enableGracefulShutdown */) +} + +func (tf *Terraform) runTerraformCmdInner(ctx context.Context, cmd *exec.Cmd, enableGracefulShutdown bool) error { + var errBuf strings.Builder + + cmd.SysProcAttr = defaultSysProcAttr + + // check for early cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Read stdout / stderr logs from pipe instead of setting cmd.Stdout and + // cmd.Stderr because it can cause hanging when killing the command + // https://github.com/golang/go/issues/23019 + stdoutWriter := mergeWriters(cmd.Stdout, tf.stdout) + stderrWriter := mergeWriters(tf.stderr, &errBuf) + + cmd.Stderr = nil + cmd.Stdout = nil + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return err + } + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return err + } + + err = cmd.Start() + if ctx.Err() != nil { + return cmdErr{ + err: err, + ctxErr: ctx.Err(), + } + } + if err != nil { + return err + } + + var errStdout, errStderr error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + errStdoutCtx := ctx + // When custom cancellation is enabled, avoid piping cancellation to prevent + // cancellation from being interrupted due to broken pipe. + if enableGracefulShutdown { + errStdoutCtx = context.WithoutCancel(ctx) + } + errStdout = writeOutput(errStdoutCtx, stdoutPipe, stdoutWriter) + }() + + wg.Add(1) + go func() { + defer wg.Done() + errStderrCtx := ctx + // When custome cancellation is enabled, avoid piping cancellation to prevent + // cancellation from being interrupted due to broken pipe. + if enableGracefulShutdown { + errStderrCtx = context.WithoutCancel(ctx) + } + errStderr = writeOutput(errStderrCtx, stderrPipe, stderrWriter) + }() + + // Reads from pipes must be completed before calling cmd.Wait(). Otherwise + // can cause a race condition + wg.Wait() + + err = cmd.Wait() + if ctx.Err() != nil { + return cmdErr{ + err: err, + ctxErr: ctx.Err(), + } + } + if err != nil { + return fmt.Errorf("%w\n%s", err, errBuf.String()) + } + + // Return error if there was an issue reading the std out/err + if errStdout != nil && ctx.Err() != nil { + return fmt.Errorf("%w\n%s", errStdout, errBuf.String()) + } + if errStderr != nil && ctx.Err() != nil { + return fmt.Errorf("%w\n%s", errStderr, errBuf.String()) + } + + return nil +} diff --git a/tfexec/cmd_default.go b/tfexec/cmd_default.go index 3af11c81..50a8affa 100644 --- a/tfexec/cmd_default.go +++ b/tfexec/cmd_default.go @@ -1,95 +1,11 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 -//go:build !linux -// +build !linux +//go:build !unix +// +build !unix package tfexec -import ( - "context" - "fmt" - "os/exec" - "strings" - "sync" -) +import "syscall" -func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { - var errBuf strings.Builder - - // check for early cancellation - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - // Read stdout / stderr logs from pipe instead of setting cmd.Stdout and - // cmd.Stderr because it can cause hanging when killing the command - // https://github.com/golang/go/issues/23019 - stdoutWriter := mergeWriters(cmd.Stdout, tf.stdout) - stderrWriter := mergeWriters(tf.stderr, &errBuf) - - cmd.Stderr = nil - cmd.Stdout = nil - - stdoutPipe, err := cmd.StdoutPipe() - if err != nil { - return err - } - - stderrPipe, err := cmd.StderrPipe() - if err != nil { - return err - } - - err = cmd.Start() - if ctx.Err() != nil { - return cmdErr{ - err: err, - ctxErr: ctx.Err(), - } - } - if err != nil { - return err - } - - var errStdout, errStderr error - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - errStdout = writeOutput(ctx, stdoutPipe, stdoutWriter) - }() - - wg.Add(1) - go func() { - defer wg.Done() - errStderr = writeOutput(ctx, stderrPipe, stderrWriter) - }() - - // Reads from pipes must be completed before calling cmd.Wait(). Otherwise - // can cause a race condition - wg.Wait() - - err = cmd.Wait() - if ctx.Err() != nil { - return cmdErr{ - err: err, - ctxErr: ctx.Err(), - } - } - if err != nil { - return fmt.Errorf("%w\n%s", err, errBuf.String()) - } - - // Return error if there was an issue reading the std out/err - if errStdout != nil && ctx.Err() != nil { - return fmt.Errorf("%w\n%s", errStdout, errBuf.String()) - } - if errStderr != nil && ctx.Err() != nil { - return fmt.Errorf("%w\n%s", errStderr, errBuf.String()) - } - - return nil -} +var defaultSysProcAttr = &syscall.SysProcAttr{} diff --git a/tfexec/cmd_default_test.go b/tfexec/cmd_default_test.go deleted file mode 100644 index b8a79265..00000000 --- a/tfexec/cmd_default_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -//go:build !linux -// +build !linux - -package tfexec - -import ( - "bytes" - "context" - "log" - "strings" - "testing" - "time" -) - -func Test_runTerraformCmd_default(t *testing.T) { - // Checks runTerraformCmd for race condition when using - // go test -race -run Test_runTerraformCmd_default ./tfexec - var buf bytes.Buffer - - tf := &Terraform{ - logger: log.New(&buf, "", 0), - execPath: "echo", - } - - ctx, cancel := context.WithCancel(context.Background()) - - cmd := tf.buildTerraformCmd(ctx, nil, "hello tf-exec!") - err := tf.runTerraformCmd(ctx, cmd) - if err != nil { - t.Fatal(err) - } - - // Cancel stops the leaked go routine which logs an error - cancel() - time.Sleep(time.Second) - if strings.Contains(buf.String(), "error from kill") { - t.Fatal("canceling context should not lead to logging an error") - } -} diff --git a/tfexec/cmd_linux.go b/tfexec/cmd_linux.go index 0565372c..b17e0382 100644 --- a/tfexec/cmd_linux.go +++ b/tfexec/cmd_linux.go @@ -1,100 +1,16 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 -package tfexec - -import ( - "context" - "fmt" - "os/exec" - "strings" - "sync" - "syscall" -) - -func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { - var errBuf strings.Builder - - cmd.SysProcAttr = &syscall.SysProcAttr{ - // kill children if parent is dead - Pdeathsig: syscall.SIGKILL, - // set process group ID - Setpgid: true, - } - - // check for early cancellation - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - // Read stdout / stderr logs from pipe instead of setting cmd.Stdout and - // cmd.Stderr because it can cause hanging when killing the command - // https://github.com/golang/go/issues/23019 - stdoutWriter := mergeWriters(cmd.Stdout, tf.stdout) - stderrWriter := mergeWriters(tf.stderr, &errBuf) - - cmd.Stderr = nil - cmd.Stdout = nil +//go:build linux +// +build linux - stdoutPipe, err := cmd.StdoutPipe() - if err != nil { - return err - } - - stderrPipe, err := cmd.StderrPipe() - if err != nil { - return err - } - - err = cmd.Start() - if ctx.Err() != nil { - return cmdErr{ - err: err, - ctxErr: ctx.Err(), - } - } - if err != nil { - return err - } - - var errStdout, errStderr error - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - errStdout = writeOutput(ctx, stdoutPipe, stdoutWriter) - }() - - wg.Add(1) - go func() { - defer wg.Done() - errStderr = writeOutput(ctx, stderrPipe, stderrWriter) - }() - - // Reads from pipes must be completed before calling cmd.Wait(). Otherwise - // can cause a race condition - wg.Wait() - - err = cmd.Wait() - if ctx.Err() != nil { - return cmdErr{ - err: err, - ctxErr: ctx.Err(), - } - } - if err != nil { - return fmt.Errorf("%w\n%s", err, errBuf.String()) - } +package tfexec - // Return error if there was an issue reading the std out/err - if errStdout != nil && ctx.Err() != nil { - return fmt.Errorf("%w\n%s", errStdout, errBuf.String()) - } - if errStderr != nil && ctx.Err() != nil { - return fmt.Errorf("%w\n%s", errStderr, errBuf.String()) - } +import "syscall" - return nil +var defaultSysProcAttr = &syscall.SysProcAttr{ + // kill children if parent is dead + Pdeathsig: syscall.SIGKILL, + // set process group ID + Setpgid: true, } diff --git a/tfexec/cmd_linux_test.go b/tfexec/cmd_linux_test.go deleted file mode 100644 index 57295c53..00000000 --- a/tfexec/cmd_linux_test.go +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package tfexec - -import ( - "bytes" - "context" - "log" - "strings" - "testing" - "time" -) - -func Test_runTerraformCmd_linux(t *testing.T) { - // Checks runTerraformCmd for race condition when using - // go test -race -run Test_runTerraformCmd_linux ./tfexec -tags=linux - var buf bytes.Buffer - - tf := &Terraform{ - logger: log.New(&buf, "", 0), - execPath: "echo", - } - - ctx, cancel := context.WithCancel(context.Background()) - - cmd := tf.buildTerraformCmd(ctx, nil, "hello tf-exec!") - err := tf.runTerraformCmd(ctx, cmd) - if err != nil { - t.Fatal(err) - } - - // Cancel stops the leaked go routine which logs an error - cancel() - time.Sleep(time.Second) - if strings.Contains(buf.String(), "error from kill") { - t.Fatal("canceling context should not lead to logging an error") - } -} diff --git a/tfexec/cmd_test.go b/tfexec/cmd_test.go index c86dae30..4ad1e157 100644 --- a/tfexec/cmd_test.go +++ b/tfexec/cmd_test.go @@ -4,10 +4,14 @@ package tfexec import ( + "bytes" + "context" "fmt" + "log" "os/exec" "strings" "testing" + "time" "github.com/hashicorp/terraform-exec/internal/version" ) @@ -100,3 +104,29 @@ func assertCmd(t *testing.T, expectedArgs []string, expectedEnv map[string]strin } } } + +func TestRunTerraformCmd(t *testing.T) { + // Checks runTerraformCmd for race condition when using + // go test -race -run Test_runTerraformCmd_default ./tfexec + var buf bytes.Buffer + + tf := &Terraform{ + logger: log.New(&buf, "", 0), + execPath: "echo", + } + + ctx, cancel := context.WithCancel(context.Background()) + + cmd := tf.buildTerraformCmd(ctx, nil, "hello tf-exec!") + err := tf.runTerraformCmd(ctx, cmd) + if err != nil { + t.Fatal(err) + } + + // Cancel stops the leaked go routine which logs an error + cancel() + time.Sleep(time.Second) + if strings.Contains(buf.String(), "error from kill") { + t.Fatal("canceling context should not lead to logging an error") + } +} diff --git a/tfexec/cmd_unix.go b/tfexec/cmd_unix.go new file mode 100644 index 00000000..9885b31d --- /dev/null +++ b/tfexec/cmd_unix.go @@ -0,0 +1,13 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +//go:build unix && !linux + +package tfexec + +import "syscall" + +var defaultSysProcAttr = &syscall.SysProcAttr{ + // set process group ID + Setpgid: true, +} diff --git a/tfexec/destroy.go b/tfexec/destroy.go index dbef8b37..b21178aa 100644 --- a/tfexec/destroy.go +++ b/tfexec/destroy.go @@ -28,6 +28,8 @@ type destroyConfig struct { // Vars: each var must be supplied as a single string, e.g. 'foo=bar' vars []string varFiles []string + + gracefulShutdownConfig GracefulShutdownConfig } var defaultDestroyOptions = destroyConfig{ @@ -90,12 +92,31 @@ func (opt *ReattachOption) configureDestroy(conf *destroyConfig) { conf.reattachInfo = opt.info } +func (opt *GracefulShutdownOption) configureDestroy(conf *destroyConfig) { + conf.gracefulShutdownConfig = opt.config +} + +func newDestroyConfig(opts ...DestroyOption) destroyConfig { + c := defaultDestroyOptions + + for _, o := range opts { + o.configureDestroy(&c) + } + return c +} + // Destroy represents the terraform destroy subcommand. func (tf *Terraform) Destroy(ctx context.Context, opts ...DestroyOption) error { - cmd, err := tf.destroyCmd(ctx, opts...) + c := newDestroyConfig(opts...) + + cmd, err := tf.destroyCmd(ctx, c) if err != nil { return err } + + if c.gracefulShutdownConfig.Enable { + return tf.runTerraformCmdWithGracefulShutdown(ctx, cmd) + } return tf.runTerraformCmd(ctx, cmd) } @@ -112,33 +133,26 @@ func (tf *Terraform) DestroyJSON(ctx context.Context, w io.Writer, opts ...Destr tf.SetStdout(w) - cmd, err := tf.destroyJSONCmd(ctx, opts...) + c := newDestroyConfig(opts...) + + cmd, err := tf.destroyJSONCmd(ctx, c) if err != nil { return err } + if c.gracefulShutdownConfig.Enable { + return tf.runTerraformCmdWithGracefulShutdown(ctx, cmd) + } return tf.runTerraformCmd(ctx, cmd) } -func (tf *Terraform) destroyCmd(ctx context.Context, opts ...DestroyOption) (*exec.Cmd, error) { - c := defaultDestroyOptions - - for _, o := range opts { - o.configureDestroy(&c) - } - +func (tf *Terraform) destroyCmd(ctx context.Context, c destroyConfig) (*exec.Cmd, error) { args := tf.buildDestroyArgs(c) return tf.buildDestroyCmd(ctx, c, args) } -func (tf *Terraform) destroyJSONCmd(ctx context.Context, opts ...DestroyOption) (*exec.Cmd, error) { - c := defaultDestroyOptions - - for _, o := range opts { - o.configureDestroy(&c) - } - +func (tf *Terraform) destroyJSONCmd(ctx context.Context, c destroyConfig) (*exec.Cmd, error) { args := tf.buildDestroyArgs(c) args = append(args, "-json") @@ -200,5 +214,8 @@ func (tf *Terraform) buildDestroyCmd(ctx context.Context, c destroyConfig, args mergeEnv[reattachEnvVar] = reattachStr } + if c.gracefulShutdownConfig.Enable { + return tf.buildTerraformCmdWithGracefulShutdown(ctx, c.gracefulShutdownConfig.Period, mergeEnv, args...), nil + } return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil } diff --git a/tfexec/destroy_test.go b/tfexec/destroy_test.go index fd80a45f..9ff33ade 100644 --- a/tfexec/destroy_test.go +++ b/tfexec/destroy_test.go @@ -22,7 +22,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, err := tf.destroyCmd(context.Background(), newDestroyConfig()) if err != nil { t.Fatal(err) } @@ -40,7 +40,7 @@ func TestDestroyCmd(t *testing.T) { }) 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, err := tf.destroyCmd(context.Background(), newDestroyConfig(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"))) if err != nil { t.Fatal(err) } @@ -79,7 +79,7 @@ func TestDestroyJSONCmd(t *testing.T) { tf.SetEnv(map[string]string{}) t.Run("defaults", func(t *testing.T) { - destroyCmd, err := tf.destroyJSONCmd(context.Background()) + destroyCmd, err := tf.destroyJSONCmd(context.Background(), newDestroyConfig()) if err != nil { t.Fatal(err) } @@ -98,7 +98,7 @@ func TestDestroyJSONCmd(t *testing.T) { }) t.Run("override all defaults", func(t *testing.T) { - destroyCmd, err := tf.destroyJSONCmd(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, err := tf.destroyJSONCmd(context.Background(), newDestroyConfig(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"))) if err != nil { t.Fatal(err) } diff --git a/tfexec/options.go b/tfexec/options.go index 339bf39e..5c8448e4 100644 --- a/tfexec/options.go +++ b/tfexec/options.go @@ -5,6 +5,7 @@ package tfexec import ( "encoding/json" + "time" ) // AllowDeferralOption represents the -allow-deferral flag. This flag is only enabled in @@ -450,3 +451,17 @@ type VerifyPluginsOption struct { func VerifyPlugins(verifyPlugins bool) *VerifyPluginsOption { return &VerifyPluginsOption{verifyPlugins} } + +type GracefulShutdownConfig struct { + Enable bool + // If Period is set to zero, wait till the command subprocess closes their descriptors for the pipes + Period time.Duration +} + +type GracefulShutdownOption struct { + config GracefulShutdownConfig +} + +func GracefulShutdown(config GracefulShutdownConfig) *GracefulShutdownOption { + return &GracefulShutdownOption{config} +}