diff --git a/tfexec/cmd_default.go b/tfexec/cmd_default.go index 6d7b768e..7323d555 100644 --- a/tfexec/cmd_default.go +++ b/tfexec/cmd_default.go @@ -40,11 +40,14 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { } err = cmd.Start() - if err == nil && ctx.Err() != nil { - err = ctx.Err() + if ctx.Err() != nil { + return cmdErr{ + err: err, + ctxErr: ctx.Err(), + } } if err != nil { - return tf.wrapExitError(ctx, err, "") + return err } var errStdout, errStderr error @@ -66,19 +69,22 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { wg.Wait() err = cmd.Wait() - if err == nil && ctx.Err() != nil { - err = ctx.Err() + if ctx.Err() != nil { + return cmdErr{ + err: err, + ctxErr: ctx.Err(), + } } if err != nil { - return tf.wrapExitError(ctx, err, errBuf.String()) + return err } // Return error if there was an issue reading the std out/err if errStdout != nil && ctx.Err() != nil { - return tf.wrapExitError(ctx, errStdout, errBuf.String()) + return errStdout } if errStderr != nil && ctx.Err() != nil { - return tf.wrapExitError(ctx, errStderr, errBuf.String()) + return errStderr } return nil diff --git a/tfexec/cmd_linux.go b/tfexec/cmd_linux.go index 6fa40e0a..9975791a 100644 --- a/tfexec/cmd_linux.go +++ b/tfexec/cmd_linux.go @@ -45,11 +45,14 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { } err = cmd.Start() - if err == nil && ctx.Err() != nil { - err = ctx.Err() + if ctx.Err() != nil { + return cmdErr{ + err: err, + ctxErr: ctx.Err(), + } } if err != nil { - return tf.wrapExitError(ctx, err, "") + return err } var errStdout, errStderr error @@ -71,19 +74,22 @@ func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { wg.Wait() err = cmd.Wait() - if err == nil && ctx.Err() != nil { - err = ctx.Err() + if ctx.Err() != nil { + return cmdErr{ + err: err, + ctxErr: ctx.Err(), + } } if err != nil { - return tf.wrapExitError(ctx, err, errBuf.String()) + return err } // Return error if there was an issue reading the std out/err if errStdout != nil && ctx.Err() != nil { - return tf.wrapExitError(ctx, errStdout, errBuf.String()) + return errStdout } if errStderr != nil && ctx.Err() != nil { - return tf.wrapExitError(ctx, errStderr, errBuf.String()) + return errStderr } return nil diff --git a/tfexec/errors.go b/tfexec/errors.go index 7a32ef2f..3bbb431c 100644 --- a/tfexec/errors.go +++ b/tfexec/errors.go @@ -1,6 +1,9 @@ package tfexec -import "fmt" +import ( + "context" + "fmt" +) // this file contains non-parsed exported errors @@ -37,3 +40,25 @@ type ErrManualEnvVar struct { func (err *ErrManualEnvVar) Error() string { return fmt.Sprintf("manual setting of env var %q detected", err.Name) } + +// cmdErr is a custom error type to be returned when a cmd exits with a context +// error such as context.Canceled or context.DeadlineExceeded. +// The type is specifically designed to respond true to errors.Is for these two +// errors. +// See https://github.com/golang/go/issues/21880 for why this is necessary. +type cmdErr struct { + err error + ctxErr error +} + +func (e cmdErr) Is(target error) bool { + switch target { + case context.DeadlineExceeded, context.Canceled: + return e.ctxErr == context.DeadlineExceeded || e.ctxErr == context.Canceled + } + return false +} + +func (e cmdErr) Error() string { + return e.err.Error() +} diff --git a/tfexec/exit_errors.go b/tfexec/exit_errors.go deleted file mode 100644 index cd84bf59..00000000 --- a/tfexec/exit_errors.go +++ /dev/null @@ -1,348 +0,0 @@ -package tfexec - -import ( - "context" - "fmt" - "os/exec" - "regexp" - "strings" - "text/template" -) - -// this file contains errors parsed from stderr - -var ( - // The "Required variable not set:" case is for 0.11 - missingVarErrRegexp = regexp.MustCompile(`Error: No value for required variable|Error: Required variable not set:`) - missingVarNameRegexp = regexp.MustCompile(`The root module input variable\s"(.+)"\sis\snot\sset,\sand\shas\sno\sdefault|Error: Required variable not set: (.+)`) - - usageRegexp = regexp.MustCompile(`Too many command line arguments|^Usage: .*Options:.*|Error: Invalid -\d+ option`) - - noInitErrRegexp = regexp.MustCompile( - // UNINITIALISED PROVIDERS/MODULES - `Error: Could not satisfy plugin requirements|` + - `Error: Could not load plugin|` + // v0.13 - `Please run \"terraform init\"|` + // v1.1.0 early alpha versions (ref 89b05050) - `run:\s+terraform init|` + // v1.1.0 (ref df578afd) - `Run\s+\"terraform init\"|` + // v1.2.0 - - // UNINITIALISED BACKENDS - `Error: Initialization required.|` + // v0.13 - `Error: Backend initialization required, please run \"terraform init\"`, // v0.15 - ) - - noConfigErrRegexp = regexp.MustCompile(`Error: No configuration files`) - - workspaceDoesNotExistRegexp = regexp.MustCompile(`Workspace "(.+)" doesn't exist.`) - - workspaceAlreadyExistsRegexp = regexp.MustCompile(`Workspace "(.+)" already exists`) - - 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.|` + - `Error: Unsupported block type|Error: Unsupported argument`, // v1.4+ - ) - - 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`) - statePlanReadErrRegexp = regexp.MustCompile( - `Terraform couldn't read the given file as a state or plan file.|` + - `Error: Failed to read the given file as a state or plan file`) - 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 tfVersionMismatchErrRegexp.MatchString(stderr): - constraint := "" - constraints := tfVersionMismatchConstraintRegexp.FindStringSubmatch(stderr) - for i := 1; i < len(constraints); i++ { - constraint = strings.TrimSpace(constraints[i]) - if constraint != "" { - break - } - } - - if constraint == "" { - // hardcode a value here for weird cases (incl. 0.12) - constraint = "unknown" - } - - // only set this if it happened to be cached already - ver := "" - if tf != nil && tf.execVersion != nil { - ver = tf.execVersion.String() - } - - return &ErrTFVersionMismatch{ - unwrapper: unwrapper{exitErr, ctxErr}, - - Constraint: constraint, - TFVersion: ver, - } - case missingVarErrRegexp.MatchString(stderr): - name := "" - names := missingVarNameRegexp.FindStringSubmatch(stderr) - for i := 1; i < len(names); i++ { - name = strings.TrimSpace(names[i]) - if name != "" { - break - } - } - - return &ErrMissingVar{ - unwrapper: unwrapper{exitErr, ctxErr}, - - VariableName: name, - } - case usageRegexp.MatchString(stderr): - return &ErrCLIUsage{ - unwrapper: unwrapper{exitErr, ctxErr}, - - stderr: stderr, - } - case noInitErrRegexp.MatchString(stderr): - return &ErrNoInit{ - unwrapper: unwrapper{exitErr, ctxErr}, - - stderr: stderr, - } - case noConfigErrRegexp.MatchString(stderr): - return &ErrNoConfig{ - unwrapper: unwrapper{exitErr, ctxErr}, - - stderr: stderr, - } - case workspaceDoesNotExistRegexp.MatchString(stderr): - submatches := workspaceDoesNotExistRegexp.FindStringSubmatch(stderr) - if len(submatches) == 2 { - return &ErrNoWorkspace{ - unwrapper: unwrapper{exitErr, ctxErr}, - - Name: submatches[1], - } - } - case workspaceAlreadyExistsRegexp.MatchString(stderr): - submatches := workspaceAlreadyExistsRegexp.FindStringSubmatch(stderr) - if len(submatches) == 2 { - return &ErrWorkspaceExists{ - unwrapper: unwrapper{exitErr, ctxErr}, - - Name: submatches[1], - } - } - 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], - } - } - case statePlanReadErrRegexp.MatchString(stderr): - return &ErrStatePlanRead{stderr: stderr} - 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 ErrConfigInvalid struct { - stderr string -} - -func (e *ErrConfigInvalid) Error() string { - return "configuration is invalid" -} - -type ErrMissingVar struct { - unwrapper - - VariableName string -} - -func (err *ErrMissingVar) Error() string { - return fmt.Sprintf("variable %q was required but not supplied", err.VariableName) -} - -type ErrNoWorkspace struct { - unwrapper - - Name string -} - -func (err *ErrNoWorkspace) Error() string { - return fmt.Sprintf("workspace %q does not exist", err.Name) -} - -// ErrWorkspaceExists is returned when creating a workspace that already exists -type ErrWorkspaceExists struct { - unwrapper - - Name string -} - -func (err *ErrWorkspaceExists) Error() string { - return fmt.Sprintf("workspace %q already exists", err.Name) -} - -type ErrNoInit struct { - unwrapper - - stderr string -} - -func (e *ErrNoInit) Error() string { - return e.stderr -} - -type ErrStatePlanRead struct { - unwrapper - - stderr string -} - -func (e *ErrStatePlanRead) Error() string { - return e.stderr -} - -type ErrNoConfig struct { - unwrapper - - stderr string -} - -func (e *ErrNoConfig) Error() string { - return e.stderr -} - -type ErrLockIdInvalid struct { - unwrapper - - stderr string -} - -func (e *ErrLockIdInvalid) Error() string { - return e.stderr -} - -// ErrCLIUsage is returned when the combination of flags or arguments is incorrect. -// -// CLI indicates usage errors in three different ways: either -// -// 1. Exit 1, with a custom error message on stderr. -// 2. Exit 1, with command usage logged to stderr. -// 3. Exit 127, with command usage logged to stdout. -// Currently cases 1 and 2 are handled. -// TODO KEM: Handle exit 127 case. How does this work on non-Unix platforms? -type ErrCLIUsage struct { - unwrapper - - stderr string -} - -func (e *ErrCLIUsage) Error() string { - return e.stderr -} - -// ErrTFVersionMismatch is returned when the running Terraform version is not compatible with the -// value specified for required_version in the terraform block. -type ErrTFVersionMismatch struct { - unwrapper - - TFVersion string - - // Constraint is not returned in the error messaging on 0.12 - Constraint string -} - -func (e *ErrTFVersionMismatch) Error() string { - version := "version" - if e.TFVersion != "" { - version = e.TFVersion - } - - requirement := "" - if e.Constraint != "" { - requirement = fmt.Sprintf(" (%s required)", e.Constraint) - } - - 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 95775557..d75986da 100644 --- a/tfexec/internal/e2etest/errors_test.go +++ b/tfexec/internal/e2etest/errors_test.go @@ -54,32 +54,15 @@ func TestMissingVar(t *testing.T) { shortVarName := "no_default" longVarName := "no_default_really_long_variable_name_that_will_line_wrap_tf_output" - // Test for ErrMissingVar and properly formatted error message on shorter variable names _, err = tf.Plan(context.Background(), tfexec.Var(longVarName+"=foo")) if err == nil { t.Fatalf("expected error running Plan, none returned") } - var e *tfexec.ErrMissingVar - if !errors.As(err, &e) { - t.Fatalf("expected ErrMissingVar, got %T, %s", err, err) - } - if e.VariableName != shortVarName { - t.Fatalf("expected missing %s, got %q", shortVarName, e.VariableName) - } - - // Test for ErrMissingVar and properly formatted error message on long variable names _, err = tf.Plan(context.Background(), tfexec.Var(shortVarName+"=foo")) if err == nil { t.Fatalf("expected error running Plan, none returned") } - if !errors.As(err, &e) { - t.Fatalf("expected ErrMissingVar, got %T, %s", err, err) - } - - if e.VariableName != longVarName { - t.Fatalf("expected missing %s, got %q", longVarName, e.VariableName) - } var ee *exec.ExitError if !errors.As(err, &ee) { @@ -107,20 +90,6 @@ func TestTFVersionMismatch(t *testing.T) { t.Fatal("expected error, but didn't find one") } - var e *tfexec.ErrTFVersionMismatch - if !errors.As(err, &e) { - t.Fatalf("expected ErrTFVersionMismatch, got %T, %s", err, err) - } - - // in 0.12, we just return "unknown" as the specifics are not included in the error messaging - if e.Constraint != "unknown" && e.Constraint != ">99.0.0" { - t.Fatalf("unexpected constraint %q", e.Constraint) - } - - if e.TFVersion != tfv.String() { - t.Fatalf("expected %q, got %q", tfv.String(), e.TFVersion) - } - var ee *exec.ExitError if !errors.As(err, &ee) { t.Fatalf("expected exec.ExitError, got %T, %s", err, err) @@ -139,11 +108,6 @@ func TestLockedState(t *testing.T) { 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) - } }) } diff --git a/tfexec/internal/e2etest/force_unlock_test.go b/tfexec/internal/e2etest/force_unlock_test.go index c5d626ac..1280aa3f 100644 --- a/tfexec/internal/e2etest/force_unlock_test.go +++ b/tfexec/internal/e2etest/force_unlock_test.go @@ -2,7 +2,6 @@ package e2etest import ( "context" - "errors" "testing" "github.com/hashicorp/go-version" @@ -51,9 +50,5 @@ func TestForceUnlock(t *testing.T) { if err == nil { t.Fatalf("expected error when running ForceUnlock with invalid lock id") } - var foErr *tfexec.ErrLockIdInvalid - if !errors.As(err, &foErr) { - t.Fatalf("expected ErrLockIdInvalid, %T returned: %s", err, err) - } }) } diff --git a/tfexec/internal/e2etest/show_test.go b/tfexec/internal/e2etest/show_test.go index e1cb92b6..72e0ae92 100644 --- a/tfexec/internal/e2etest/show_test.go +++ b/tfexec/internal/e2etest/show_test.go @@ -111,16 +111,15 @@ func TestShow_emptyDir(t *testing.T) { } func TestShow_noInitBasic(t *testing.T) { - // Prior to v1.2.0, running show before init always results in ErrNoInit. + // Prior to v1.2.0, running show before init always results in an error. // In the basic case, in which the local backend is implicit and there are // no providers to download, this is unintended behaviour, as // init is not actually necessary. This is considered a known issue in // pre-1.2.0 versions. runTestWithVersions(t, []string{testutil.Latest012, testutil.Latest013, testutil.Latest014, testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1}, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { - var noInit *tfexec.ErrNoInit _, err := tf.Show(context.Background()) - if !errors.As(err, &noInit) { - t.Fatalf("expected error ErrNoInit, got %T: %s", err, err) + if err == nil { + t.Fatalf("expected error, but did not get one") } }) @@ -149,16 +148,15 @@ func TestShow_noInitBasic(t *testing.T) { } func TestShow_noInitModule(t *testing.T) { - // Prior to v1.2.0, running show before init always results in ErrNoInit. + // Prior to v1.2.0, running show before init always results in an error. // In the basic case, in which the local backend is implicit and there are // no providers to download, this is unintended behaviour, as // init is not actually necessary. This is considered a known issue in // pre-1.2.0 versions. runTestWithVersions(t, []string{testutil.Latest012, testutil.Latest013, testutil.Latest014, testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1}, "registry_module", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { - var noInit *tfexec.ErrNoInit _, err := tf.Show(context.Background()) - if !errors.As(err, &noInit) { - t.Fatalf("expected error ErrNoInit, got %T: %s", err, err) + if err == nil { + t.Fatalf("expected error, but did not get one") } }) @@ -191,10 +189,9 @@ func TestShow_noInitInmemBackend(t *testing.T) { t.Skip("terraform show was added in Terraform 0.12, so test is not valid") } - var noInit *tfexec.ErrNoInit _, err := tf.Show(context.Background()) - if !errors.As(err, &noInit) { - t.Fatalf("expected error ErrNoInit, got %T: %s", err, err) + if err == nil { + t.Fatalf("expected error, but did not get one") } }) } @@ -205,10 +202,9 @@ func TestShow_noInitLocalBackendNonDefaultState(t *testing.T) { t.Skip("terraform show was added in Terraform 0.12, so test is not valid") } - var noInit *tfexec.ErrNoInit _, err := tf.Show(context.Background()) - if !errors.As(err, &noInit) { - t.Fatalf("expected error ErrNoInit, got %T: %s", err, err) + if err == nil { + t.Fatalf("expected error, but did not get one") } }) } @@ -219,10 +215,9 @@ func TestShow_noInitCloudBackend(t *testing.T) { t.Skip("cloud backend was added in Terraform 1.1, so test is not valid") } - var noInit *tfexec.ErrNoInit _, err := tf.Show(context.Background()) - if !errors.As(err, &noInit) { - t.Fatalf("expected error ErrNoInit, got %T: %s", err, err) + if err == nil { + t.Fatalf("expected error, but did not get one") } }) } @@ -237,10 +232,9 @@ func TestShow_noInitEtcdBackend(t *testing.T) { t.Skip("etcd backend was removed in Terraform 1.3, so test is not valid") } - var noInit *tfexec.ErrNoInit _, err := tf.Show(context.Background()) - if !errors.As(err, &noInit) { - t.Fatalf("expected error ErrNoInit, got %T: %s", err, err) + if err == nil { + t.Fatalf("expected error, but did not get one") } }) } @@ -251,10 +245,9 @@ func TestShow_noInitRemoteBackend(t *testing.T) { t.Skip("terraform show was added in Terraform 0.12, so test is not valid") } - var noInit *tfexec.ErrNoInit _, err := tf.Show(context.Background()) - if !errors.As(err, &noInit) { - t.Fatalf("expected error ErrNoInit, got %T: %s", err, err) + if err == nil { + t.Fatalf("expected error, but did not get one") } }) } @@ -270,10 +263,9 @@ func TestShow_statefileDoesNotExist(t *testing.T) { t.Fatalf("error running Init in test directory: %s", err) } - var statePlanReadErr *tfexec.ErrStatePlanRead _, err = tf.ShowStateFile(context.Background(), "statefilefoo") - if !errors.As(err, &statePlanReadErr) { - t.Fatalf("expected error ErrStatePlanRead, got %T: %s", err, err) + if err == nil { + t.Fatalf("expected error, but did not get one") } }) } diff --git a/tfexec/internal/e2etest/validate_test.go b/tfexec/internal/e2etest/validate_test.go index d57ebe28..e2f247ce 100644 --- a/tfexec/internal/e2etest/validate_test.go +++ b/tfexec/internal/e2etest/validate_test.go @@ -2,7 +2,6 @@ package e2etest import ( "context" - "errors" "testing" "github.com/google/go-cmp/cmp" @@ -46,12 +45,10 @@ func TestValidate(t *testing.T) { if err != nil { t.Logf("error initializing: %s", err) - // allow for invalid config errors only here - // 0.13 will return this, 0.12 will not + // 0.13 will error, 0.12 will not // unsure why 0.12 terraform init does not have a non-zero exit code for syntax problems - var confErr *tfexec.ErrConfigInvalid - if !errors.As(err, &confErr) { - t.Fatalf("expected err ErrConfigInvalid, got %T: %s", err, err) + if err == nil { + t.Fatalf("expected error, but did not get one") } } diff --git a/tfexec/internal/e2etest/workspace_test.go b/tfexec/internal/e2etest/workspace_test.go index 35964ecc..68489653 100644 --- a/tfexec/internal/e2etest/workspace_test.go +++ b/tfexec/internal/e2etest/workspace_test.go @@ -2,7 +2,6 @@ package e2etest import ( "context" - "errors" "reflect" "testing" @@ -45,12 +44,8 @@ func TestWorkspace_does_not_exist(t *testing.T) { runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { const doesNotExistWorkspace = "does-not-exist" err := tf.WorkspaceSelect(context.Background(), doesNotExistWorkspace) - var wsErr *tfexec.ErrNoWorkspace - if !errors.As(err, &wsErr) { - t.Fatalf("expected ErrNoWorkspace, %T returned: %s", err, err) - } - if wsErr.Name != doesNotExistWorkspace { - t.Fatalf("expected %q, got %q", doesNotExistWorkspace, wsErr.Name) + if err == nil { + t.Fatalf("expected error, but did not get one") } }) } @@ -71,12 +66,8 @@ func TestWorkspace_already_exists(t *testing.T) { t.Run("create existing workspace", func(t *testing.T) { err := tf.WorkspaceNew(context.Background(), newWorkspace) - var wsErr *tfexec.ErrWorkspaceExists - if !errors.As(err, &wsErr) { - t.Fatalf("expected ErrWorkspaceExists, %T returned: %s", err, err) - } - if wsErr.Name != newWorkspace { - t.Fatalf("expected %q, got %q", newWorkspace, wsErr.Name) + if err == nil { + t.Fatalf("expected error, but did not get one") } }) })