diff --git a/tfexec/exit_errors.go b/tfexec/exit_errors.go index 3fa64349..ebbfc3a9 100644 --- a/tfexec/exit_errors.go +++ b/tfexec/exit_errors.go @@ -6,6 +6,7 @@ import ( "os/exec" "regexp" "strings" + "text/template" ) // this file contains errors parsed from stderr @@ -30,6 +31,9 @@ var ( tfVersionMismatchErrRegexp = regexp.MustCompile(`Error: The currently running version of Terraform doesn't meet the|Error: Unsupported Terraform Core version`) tfVersionMismatchConstraintRegexp = regexp.MustCompile(`required_version = "(.+)"|Required version: (.+)\b`) configInvalidErrRegexp = regexp.MustCompile(`There are some problems with the configuration, described below.`) + + 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`) ) func (tf *Terraform) wrapExitError(ctx context.Context, err error, stderr string) error { @@ -128,6 +132,20 @@ func (tf *Terraform) wrapExitError(ctx context.Context, err error, stderr string } case configInvalidErrRegexp.MatchString(stderr): return &ErrConfigInvalid{stderr: stderr} + 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], + } + } } return fmt.Errorf("%w\n%s", &unwrapper{exitErr, ctxErr}, stderr) @@ -257,3 +275,33 @@ func (e *ErrTFVersionMismatch) Error() string { return fmt.Sprintf("terraform %s not supported by configuration%s", version, requirement) } + +// 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 39d06cce..e281a790 100644 --- a/tfexec/internal/e2etest/errors_test.go +++ b/tfexec/internal/e2etest/errors_test.go @@ -127,6 +127,25 @@ func TestTFVersionMismatch(t *testing.T) { }) } +func TestLockedState(t *testing.T) { + runTest(t, "inmem-backend-locked", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + err := tf.Init(context.Background()) + if err != nil { + t.Fatalf("err during init: %s", err) + } + + err = tf.Apply(context.Background()) + if err == nil { + t.Fatal("expected error, but didn't find one") + } + + var stateLockedErr *tfexec.ErrStateLocked + if !errors.As(err, &stateLockedErr) { + t.Fatalf("expected ErrTFVersionMismatch, got %T, %s", err, err) + } + }) +} + func TestContext_alreadyPastDeadline(t *testing.T) { runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-1*time.Second)) diff --git a/tfexec/internal/e2etest/testdata/inmem-backend-locked/main.tf b/tfexec/internal/e2etest/testdata/inmem-backend-locked/main.tf new file mode 100644 index 00000000..9fb065d7 --- /dev/null +++ b/tfexec/internal/e2etest/testdata/inmem-backend-locked/main.tf @@ -0,0 +1,5 @@ +terraform { + backend "inmem" { + lock_id = "2b6a6738-5dd5-50d6-c0ae-f6352977666b" + } +}