Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Catch signals when running terraform CLI commands #231

Merged
merged 2 commits into from
Jan 24, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 61 additions & 24 deletions internal/terraform/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"sort"
"strconv"
"strings"
"syscall"

"sync"

Expand All @@ -43,6 +44,9 @@ const (
errParse = "cannot parse Terraform output"
errWriteVarFile = "cannot write tfvars file"
errFmtInvalidConfig = "invalid Terraform configuration: found %d errors"
errRunCommand = "shutdown while running terraform command"
errSigTerm = "error sending SIGTERM to child process"
errWaitTerm = "error waiting for child process to terminate"

tfDefault = "default"
)
Expand Down Expand Up @@ -167,7 +171,7 @@ var rwmutex = &sync.RWMutex{}
// Init initializes a Terraform configuration.
func (h Harness) Init(ctx context.Context, cache bool, o ...InitOption) error {
args := append([]string{"init", "-input=false", "-no-color"}, InitArgsToString(o)...)
cmd := exec.CommandContext(ctx, h.Path, args...) //nolint:gosec
cmd := exec.Command(h.Path, args...) //nolint:gosec
cmd.Dir = h.Dir
for _, e := range os.Environ() {
if strings.Contains(e, "TF_PLUGIN_CACHE_DIR") {
Expand All @@ -180,7 +184,7 @@ func (h Harness) Init(ctx context.Context, cache bool, o ...InitOption) error {
cmd.Env = append(cmd.Env, "TF_CLI_CONFIG_FILE=./.terraformrc")
rwmutex.Lock()
defer rwmutex.Unlock()
_, err := cmd.Output()
_, err := runCommand(ctx, cmd)
return Classify(err)
}

Expand All @@ -189,7 +193,7 @@ func (h Harness) Init(ctx context.Context, cache bool, o ...InitOption) error {
// but isn't is deemed invalid. Attempts to initialise an invalid configuration
// will result in errors, which are not available in a machine readable format.
func (h Harness) Validate(ctx context.Context) error {
cmd := exec.CommandContext(ctx, h.Path, "validate", "-json") //nolint:gosec
cmd := exec.Command(h.Path, "validate", "-json") //nolint:gosec
cmd.Dir = h.Dir

type result struct {
Expand All @@ -199,7 +203,7 @@ func (h Harness) Validate(ctx context.Context) error {

// The validate command returns zero for a valid module and non-zero for an
// invalid module, but it returns its JSON to stdout either way.
out, err := cmd.Output()
out, err := runCommand(ctx, cmd)

r := &result{}
if jerr := json.Unmarshal(out, r); jerr != nil {
Expand All @@ -221,31 +225,31 @@ func (h Harness) Validate(ctx context.Context) error {
// Workspace selects the named Terraform workspace. The workspace will be
// created if it does not exist.
func (h Harness) Workspace(ctx context.Context, name string) error {
cmd := exec.CommandContext(ctx, h.Path, "workspace", "select", "-no-color", name) //nolint:gosec
cmd := exec.Command(h.Path, "workspace", "select", "-no-color", name) //nolint:gosec
cmd.Dir = h.Dir

if _, err := cmd.Output(); err == nil {
if _, err := runCommand(ctx, cmd); err == nil {
// We successfully selected the workspace; we're done.
return nil
}

// We weren't able to select a workspace. We assume this was because the
// workspace doesn't exist, which causes Terraform to return non-zero. This
// is somewhat optimistic, but it shouldn't hurt to try.
cmd = exec.CommandContext(ctx, h.Path, "workspace", "new", "-no-color", name) //nolint:gosec
cmd = exec.Command(h.Path, "workspace", "new", "-no-color", name) //nolint:gosec
cmd.Dir = h.Dir
rwmutex.RLock()
defer rwmutex.RUnlock()
_, err := cmd.Output()
_, err := runCommand(ctx, cmd)
return Classify(err)
}

// DeleteCurrentWorkspace deletes the current Terraform workspace if it is not the default.
func (h Harness) DeleteCurrentWorkspace(ctx context.Context) error {
cmd := exec.CommandContext(ctx, h.Path, "workspace", "show", "-no-color") //nolint:gosec
cmd := exec.Command(h.Path, "workspace", "show", "-no-color") //nolint:gosec
cmd.Dir = h.Dir

n, err := cmd.Output()
n, err := runCommand(ctx, cmd)
if err != nil {
return Classify(err)
}
Expand All @@ -259,12 +263,12 @@ func (h Harness) DeleteCurrentWorkspace(ctx context.Context) error {
if err != nil {
return Classify(err)
}
cmd = exec.CommandContext(ctx, h.Path, "workspace", "delete", "-no-color", name) //nolint:gosec
cmd = exec.Command(h.Path, "workspace", "delete", "-no-color", name) //nolint:gosec
cmd.Dir = h.Dir

rwmutex.RLock()
defer rwmutex.RUnlock()
_, err = cmd.Output()
_, err = runCommand(ctx, cmd)
if err == nil {
// We successfully deleted the workspace; we're done.
return nil
Expand All @@ -276,10 +280,10 @@ func (h Harness) DeleteCurrentWorkspace(ctx context.Context) error {
// GenerateChecksum calculates the md5sum of the workspace (excluding installed providers) to see if terraform init needs to run
func (h Harness) GenerateChecksum(ctx context.Context) (string, error) {
command := "/usr/bin/find . -path ./.git -prune -o -path ./.terraform/providers -prune -o -type f -exec /usr/bin/md5sum {} + | LC_ALL=C /usr/bin/sort | /usr/bin/md5sum | /usr/bin/awk '{print $1}'"
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) //nolint:gosec
cmd := exec.Command("/bin/sh", "-c", command) //nolint:gosec
cmd.Dir = h.Dir

checksum, err := cmd.Output()
checksum, err := runCommand(ctx, cmd)
result := strings.ReplaceAll(string(checksum), "\n", "")
return result, Classify(err)
}
Expand Down Expand Up @@ -363,7 +367,7 @@ func (o Output) JSONValue() ([]byte, error) {

// Outputs extracts outputs from Terraform state.
func (h Harness) Outputs(ctx context.Context) ([]Output, error) {
cmd := exec.CommandContext(ctx, h.Path, "output", "-json") //nolint:gosec
cmd := exec.Command(h.Path, "output", "-json") //nolint:gosec
cmd.Dir = h.Dir

type output struct {
Expand All @@ -376,7 +380,7 @@ func (h Harness) Outputs(ctx context.Context) ([]Output, error) {

rwmutex.RLock()
defer rwmutex.RUnlock()
out, err := cmd.Output()
out, err := runCommand(ctx, cmd)
if jerr := json.Unmarshal(out, &outputs); jerr != nil {
// If stdout doesn't appear to be the JSON we expected we try to extract
// an error from stderr.
Expand Down Expand Up @@ -417,12 +421,12 @@ func (h Harness) Outputs(ctx context.Context) ([]Output, error) {

// Resources returns a list of resources in the Terraform state.
func (h Harness) Resources(ctx context.Context) ([]string, error) {
cmd := exec.CommandContext(ctx, h.Path, "state", "list") //nolint:gosec
cmd := exec.Command(h.Path, "state", "list") //nolint:gosec
cmd.Dir = h.Dir

rwmutex.RLock()
defer rwmutex.RUnlock()
out, err := cmd.Output()
out, err := runCommand(ctx, cmd)
if err != nil {
return nil, Classify(err)
}
Expand Down Expand Up @@ -497,7 +501,7 @@ func (h Harness) Diff(ctx context.Context, o ...Option) (bool, error) {
}

args := append([]string{"plan", "-no-color", "-input=false", "-detailed-exitcode", "-lock=false"}, ao.args...)
cmd := exec.CommandContext(ctx, h.Path, args...) //nolint:gosec
cmd := exec.Command(h.Path, args...) //nolint:gosec
cmd.Dir = h.Dir

rwmutex.RLock()
Expand All @@ -507,7 +511,7 @@ func (h Harness) Diff(ctx context.Context, o ...Option) (bool, error) {
// 0 - Succeeded, diff is empty (no changes)
// 1 - Errored
// 2 - Succeeded, there is a diff
_, err := cmd.Output()
_, err := runCommand(ctx, cmd)
if cmd.ProcessState.ExitCode() == 2 {
return true, nil
}
Expand All @@ -528,12 +532,12 @@ func (h Harness) Apply(ctx context.Context, o ...Option) error {
}

args := append([]string{"apply", "-no-color", "-auto-approve", "-input=false"}, ao.args...)
cmd := exec.CommandContext(ctx, h.Path, args...) //nolint:gosec
cmd := exec.Command(h.Path, args...) //nolint:gosec
cmd.Dir = h.Dir

rwmutex.RLock()
defer rwmutex.RUnlock()
_, err := cmd.Output()
_, err := runCommand(ctx, cmd)
return Classify(err)
}

Expand All @@ -551,11 +555,44 @@ func (h Harness) Destroy(ctx context.Context, o ...Option) error {
}

args := append([]string{"destroy", "-no-color", "-auto-approve", "-input=false"}, do.args...)
cmd := exec.CommandContext(ctx, h.Path, args...) //nolint:gosec
cmd := exec.Command(h.Path, args...) //nolint:gosec
cmd.Dir = h.Dir

rwmutex.RLock()
defer rwmutex.RUnlock()
_, err := cmd.Output()
_, err := runCommand(ctx, cmd)
return Classify(err)
}

// cmdResult represents the result of the command execution
type cmdResult struct {
out []byte
err error
}

// runCommand executes the requested command and sends the process SIGTERM if the context finishes before the command
func runCommand(ctx context.Context, c *exec.Cmd) ([]byte, error) {
ch := make(chan cmdResult, 1)
go func() {
defer close(ch)
r, e := c.Output()
ch <- cmdResult{out: r, err: e}
}()
select {
case <-ctx.Done():
err := ctx.Err()
// This could be container termination or the reconciliation deadline was exceeded. Either way send a
// SIGTERM to the running process and wait for either the command to finish or the process to get killed.
e := c.Process.Signal(syscall.SIGTERM)
if e != nil {
return nil, errors.Wrap(errors.Wrap(err, errRunCommand), errors.Wrap(e, errSigTerm).Error())
}
e = c.Wait()
if e != nil {
return nil, errors.Wrap(errors.Wrap(err, errRunCommand), errors.Wrap(e, errWaitTerm).Error())
}
return nil, errors.Wrap(err, errRunCommand)
case res := <-ch:
return res.out, res.err
}
}
Loading