Skip to content

Commit

Permalink
feat: add graceful shutdown option for apply&destroy (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
wjf3121 authored May 13, 2024
1 parent 259b9e9 commit 2ac802c
Show file tree
Hide file tree
Showing 14 changed files with 298 additions and 318 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
49 changes: 33 additions & 16 deletions tfexec/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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{
Expand Down Expand Up @@ -105,12 +107,31 @@ func (opt *DestroyFlagOption) configureApply(conf *applyConfig) {
conf.destroy = opt.destroy
}

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

Expand All @@ -127,21 +148,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
Expand All @@ -150,13 +170,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
Expand Down Expand Up @@ -250,5 +264,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
}
42 changes: 23 additions & 19 deletions tfexec/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -40,7 +40,7 @@ func TestApplyCmd(t *testing.T) {
Var("var2=bar"),
Destroy(true),
DirOrPlan("testfile"),
)
))
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
128 changes: 128 additions & 0 deletions tfexec/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"os"
"os/exec"
"strings"
"sync"
"time"

"github.com/hashicorp/terraform-exec/internal/version"
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Loading

0 comments on commit 2ac802c

Please sign in to comment.