Skip to content
Merged
Show file tree
Hide file tree
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dist
go.work*
go.work*
.idea
22 changes: 22 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Makefile for local testing (mimics GH Actions tests.yml)

.PHONY: all static-checks build unit-test e2e-test

all: static-checks build unit-test e2e-test

static-checks:
go mod tidy
go mod verify
go vet ./...

build:
go build ./...

# List all non-e2e test packages dynamically
UNIT_TEST_PKGS := $(shell go list ./... | grep -v ./tfexec/internal/e2etest)

unit-test:
go test -cover -race $(UNIT_TEST_PKGS)

e2e-test:
TFEXEC_E2ETEST_VERSIONS=1.5.7 go test -race -timeout=30m -v ./tfexec/internal/e2etest
9 changes: 4 additions & 5 deletions tfexec/cmd_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ package tfexec

import (
"context"
"fmt"
"os/exec"
"strings"
"sync"
Expand Down Expand Up @@ -51,7 +50,7 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error {
}
}
if err != nil {
return err
return tf.wrapExitError(ctx, err, "")
}

var errStdout, errStderr error
Expand Down Expand Up @@ -80,15 +79,15 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error {
}
}
if err != nil {
return fmt.Errorf("%w\n%s", err, errBuf.String())
return tf.wrapExitError(ctx, 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())
return tf.wrapExitError(ctx, err, errBuf.String())
}
if errStderr != nil && ctx.Err() != nil {
return fmt.Errorf("%w\n%s", errStderr, errBuf.String())
return tf.wrapExitError(ctx, err, errBuf.String())
}

return nil
Expand Down
9 changes: 4 additions & 5 deletions tfexec/cmd_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package tfexec

import (
"context"
"fmt"
"os/exec"
"strings"
"sync"
Expand Down Expand Up @@ -56,7 +55,7 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error {
}
}
if err != nil {
return err
return tf.wrapExitError(ctx, err, "")
}

var errStdout, errStderr error
Expand Down Expand Up @@ -85,15 +84,15 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error {
}
}
if err != nil {
return fmt.Errorf("%w\n%s", err, errBuf.String())
return tf.wrapExitError(ctx, 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())
return tf.wrapExitError(ctx, errStdout, errBuf.String())
}
if errStderr != nil && ctx.Err() != nil {
return fmt.Errorf("%w\n%s", errStderr, errBuf.String())
return tf.wrapExitError(ctx, errStdout, errBuf.String())
}

return nil
Expand Down
117 changes: 117 additions & 0 deletions tfexec/exit_errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package tfexec

import (
"context"
"fmt"
"os/exec"
"regexp"
"strings"
"text/template"
)

// this file contains errors parsed from stderr

var (
stateLockErrRegexp = regexp.MustCompile(`Error acquiring the state lock`)
stateLockInfoRegexp = regexp.MustCompile(`Lock Info:\n\s*ID:\s*([^\n]+)\n\s*Path:\s*([^\n]+)\n\s*Operation:\s*([^\n]+)\n\s*Who:\s*([^\n]+)\n\s*Version:\s*([^\n]+)\n\s*Created:\s*([^\n]+)\n`)
lockIdInvalidErrRegexp = regexp.MustCompile(`Failed to unlock state: `)
)

func (tf *Terraform) wrapExitError(ctx context.Context, err error, stderr string) error {
exitErr, ok := err.(*exec.ExitError)
if !ok {
// not an exit error, short circuit, nothing to wrap
return err
}

ctxErr := ctx.Err()

// nothing to parse, return early
errString := strings.TrimSpace(stderr)
if errString == "" {
return &unwrapper{exitErr, ctxErr}
}

switch {
case stateLockErrRegexp.MatchString(stderr):
submatches := stateLockInfoRegexp.FindStringSubmatch(stderr)
if len(submatches) == 7 {
return &ErrStateLocked{
unwrapper: unwrapper{exitErr, ctxErr},

ID: submatches[1],
Path: submatches[2],
Operation: submatches[3],
Who: submatches[4],
Version: submatches[5],
Created: submatches[6],
}
}
case lockIdInvalidErrRegexp.MatchString(stderr):
return &ErrLockIdInvalid{stderr: stderr}
}

return fmt.Errorf("%w\n%s", &unwrapper{exitErr, ctxErr}, stderr)
}

type unwrapper struct {
err error
ctxErr error
}

func (u *unwrapper) Unwrap() error {
return u.err
}

func (u *unwrapper) Is(target error) bool {
switch target {
case context.DeadlineExceeded, context.Canceled:
return u.ctxErr == context.DeadlineExceeded ||
u.ctxErr == context.Canceled
}
return false
}

func (u *unwrapper) Error() string {
return u.err.Error()
}

type ErrLockIdInvalid struct {
unwrapper

stderr string
}

func (e *ErrLockIdInvalid) Error() string {
return e.stderr
}

// ErrStateLocked is returned when the state lock is already held by another process.
type ErrStateLocked struct {
unwrapper

ID string
Path string
Operation string
Who string
Version string
Created string
}

func (e *ErrStateLocked) Error() string {
tmpl := `Lock Info:
ID: {{.ID}}
Path: {{.Path}}
Operation: {{.Operation}}
Who: {{.Who}}
Version: {{.Version}}
Created: {{.Created}}
`

t := template.Must(template.New("LockInfo").Parse(tmpl))
var out strings.Builder
if err := t.Execute(&out, e); err != nil {
return "error acquiring the state lock"
}
return fmt.Sprintf("error acquiring the state lock: %v", out.String())
}
4 changes: 4 additions & 0 deletions tfexec/internal/e2etest/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ func TestLockedState(t *testing.T) {
if !strings.Contains(err.Error(), "state lock") {
t.Fatal("expected err.Error() to contain 'state lock', but it did not")
}
var stateLockedErr *tfexec.ErrStateLocked
if !errors.As(err, &stateLockedErr) {
t.Fatalf("expected ErrStateLocked, got %T, %s", err, err)
}
})
}

Expand Down