From 58042f1496b8f1ac55ffe8dc9c2012357e6691ce Mon Sep 17 00:00:00 2001 From: Daniel Ciaglia Date: Tue, 19 Aug 2025 09:08:02 +0200 Subject: [PATCH 1/2] - some helpers for local testing --- .gitignore | 3 ++- Makefile | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 Makefile diff --git a/.gitignore b/.gitignore index 1bfef83a..54ef08ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ dist -go.work* \ No newline at end of file +go.work* +.idea \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..8afb3400 --- /dev/null +++ b/Makefile @@ -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 From 22f97ce39bc1bf35bbc1686a2353f3ceee2a9dae Mon Sep 17 00:00:00 2001 From: Daniel Ciaglia Date: Tue, 19 Aug 2025 09:17:33 +0200 Subject: [PATCH 2/2] re-introduce `ErrStateLocked` as it is needed for tofu-controller see: https://github.com/hashicorp/terraform-exec/pull/352 --- tfexec/cmd_default.go | 9 +- tfexec/cmd_linux.go | 9 +- tfexec/exit_errors.go | 117 +++++++++++++++++++++++++ tfexec/internal/e2etest/errors_test.go | 4 + 4 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 tfexec/exit_errors.go diff --git a/tfexec/cmd_default.go b/tfexec/cmd_default.go index 3af11c81..ed5ea803 100644 --- a/tfexec/cmd_default.go +++ b/tfexec/cmd_default.go @@ -8,7 +8,6 @@ package tfexec import ( "context" - "fmt" "os/exec" "strings" "sync" @@ -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 @@ -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 diff --git a/tfexec/cmd_linux.go b/tfexec/cmd_linux.go index 0565372c..ac17490d 100644 --- a/tfexec/cmd_linux.go +++ b/tfexec/cmd_linux.go @@ -5,7 +5,6 @@ package tfexec import ( "context" - "fmt" "os/exec" "strings" "sync" @@ -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 @@ -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 diff --git a/tfexec/exit_errors.go b/tfexec/exit_errors.go new file mode 100644 index 00000000..e839e373 --- /dev/null +++ b/tfexec/exit_errors.go @@ -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()) +} diff --git a/tfexec/internal/e2etest/errors_test.go b/tfexec/internal/e2etest/errors_test.go index c99aad9b..21d38064 100644 --- a/tfexec/internal/e2etest/errors_test.go +++ b/tfexec/internal/e2etest/errors_test.go @@ -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) + } }) }