tfdiags: new package for normalizing error and warning messages
Currently we lean heavily on the Go error type as our primary means of
describing errors, and along with that use several more specialized
implementations of it in different spots for additional capabilities such
as multiple errors in one object, source code range references, etc.
We also have a rather ad-hoc approach of returning an array of warnings
from certain functions along with one or multiple errors.
This rather-disorganized approach makes it hard for us to present
user-facing error messages consistently. As a step towards mitigating
this, package tfdiags provides a model for user-facing error and warning
messages and helper functions for creating them from various other
error and warning types used elsewhere in Terraform.
This mechanism is intended to be used to report errors and warnings where
the audience is the Terraform user, and so it may go a few layers deep
down the call stack into codepaths like config parsing, interpolation, etc
but is primarily a UX concern. The deepest reaches of Terraform core will
continue using "error" as normal, with higher layers preparing error
messages for presentation to the user.
To avoid needing to change the interface of every function that might
generate error diagnostics, the Diagnostics type can be "smuggled" via
an error value through other APIs and then unwrapped at the other end,
though it will lose any naked warnings (without at least one error) along
the way, and so codepaths that are expected to generate warnings
(validation, primarily) should use the concrete Diagnostics type
throughout the call chain.
2017-10-05 02:13:29 +02:00
|
|
|
package tfdiags
|
|
|
|
|
|
|
|
import (
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"reflect"
|
|
|
|
"strings"
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
"github.com/hashicorp/go-multierror"
|
|
|
|
|
|
|
|
"github.com/davecgh/go-spew/spew"
|
2019-09-10 00:58:44 +02:00
|
|
|
"github.com/hashicorp/hcl/v2"
|
tfdiags: new package for normalizing error and warning messages
Currently we lean heavily on the Go error type as our primary means of
describing errors, and along with that use several more specialized
implementations of it in different spots for additional capabilities such
as multiple errors in one object, source code range references, etc.
We also have a rather ad-hoc approach of returning an array of warnings
from certain functions along with one or multiple errors.
This rather-disorganized approach makes it hard for us to present
user-facing error messages consistently. As a step towards mitigating
this, package tfdiags provides a model for user-facing error and warning
messages and helper functions for creating them from various other
error and warning types used elsewhere in Terraform.
This mechanism is intended to be used to report errors and warnings where
the audience is the Terraform user, and so it may go a few layers deep
down the call stack into codepaths like config parsing, interpolation, etc
but is primarily a UX concern. The deepest reaches of Terraform core will
continue using "error" as normal, with higher layers preparing error
messages for presentation to the user.
To avoid needing to change the interface of every function that might
generate error diagnostics, the Diagnostics type can be "smuggled" via
an error value through other APIs and then unwrapped at the other end,
though it will lose any naked warnings (without at least one error) along
the way, and so codepaths that are expected to generate warnings
(validation, primarily) should use the concrete Diagnostics type
throughout the call chain.
2017-10-05 02:13:29 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
func TestBuild(t *testing.T) {
|
|
|
|
type diagFlat struct {
|
|
|
|
Severity Severity
|
|
|
|
Summary string
|
|
|
|
Detail string
|
|
|
|
Subject *SourceRange
|
|
|
|
Context *SourceRange
|
|
|
|
}
|
|
|
|
|
|
|
|
tests := map[string]struct {
|
|
|
|
Cons func(Diagnostics) Diagnostics
|
|
|
|
Want []diagFlat
|
|
|
|
}{
|
|
|
|
"nil": {
|
|
|
|
func(diags Diagnostics) Diagnostics {
|
|
|
|
return diags
|
|
|
|
},
|
|
|
|
nil,
|
|
|
|
},
|
|
|
|
"fmt.Errorf": {
|
|
|
|
func(diags Diagnostics) Diagnostics {
|
|
|
|
diags = diags.Append(fmt.Errorf("oh no bad"))
|
|
|
|
return diags
|
|
|
|
},
|
|
|
|
[]diagFlat{
|
|
|
|
{
|
|
|
|
Severity: Error,
|
|
|
|
Summary: "oh no bad",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
"errors.New": {
|
|
|
|
func(diags Diagnostics) Diagnostics {
|
|
|
|
diags = diags.Append(errors.New("oh no bad"))
|
|
|
|
return diags
|
|
|
|
},
|
|
|
|
[]diagFlat{
|
|
|
|
{
|
|
|
|
Severity: Error,
|
|
|
|
Summary: "oh no bad",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
"hcl.Diagnostic": {
|
|
|
|
func(diags Diagnostics) Diagnostics {
|
|
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
|
|
Severity: hcl.DiagError,
|
|
|
|
Summary: "Something bad happened",
|
|
|
|
Detail: "It was really, really bad.",
|
|
|
|
Subject: &hcl.Range{
|
|
|
|
Filename: "foo.tf",
|
|
|
|
Start: hcl.Pos{Line: 1, Column: 10, Byte: 9},
|
|
|
|
End: hcl.Pos{Line: 2, Column: 3, Byte: 25},
|
|
|
|
},
|
|
|
|
Context: &hcl.Range{
|
|
|
|
Filename: "foo.tf",
|
|
|
|
Start: hcl.Pos{Line: 1, Column: 1, Byte: 0},
|
|
|
|
End: hcl.Pos{Line: 3, Column: 1, Byte: 30},
|
|
|
|
},
|
|
|
|
})
|
|
|
|
return diags
|
|
|
|
},
|
|
|
|
[]diagFlat{
|
|
|
|
{
|
|
|
|
Severity: Error,
|
|
|
|
Summary: "Something bad happened",
|
|
|
|
Detail: "It was really, really bad.",
|
|
|
|
Subject: &SourceRange{
|
|
|
|
Filename: "foo.tf",
|
|
|
|
Start: SourcePos{Line: 1, Column: 10, Byte: 9},
|
|
|
|
End: SourcePos{Line: 2, Column: 3, Byte: 25},
|
|
|
|
},
|
|
|
|
Context: &SourceRange{
|
|
|
|
Filename: "foo.tf",
|
|
|
|
Start: SourcePos{Line: 1, Column: 1, Byte: 0},
|
|
|
|
End: SourcePos{Line: 3, Column: 1, Byte: 30},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
"hcl.Diagnostics": {
|
|
|
|
func(diags Diagnostics) Diagnostics {
|
|
|
|
diags = diags.Append(hcl.Diagnostics{
|
|
|
|
{
|
|
|
|
Severity: hcl.DiagError,
|
|
|
|
Summary: "Something bad happened",
|
|
|
|
Detail: "It was really, really bad.",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
Severity: hcl.DiagWarning,
|
|
|
|
Summary: "Also, somebody sneezed",
|
|
|
|
Detail: "How rude!",
|
|
|
|
},
|
|
|
|
})
|
|
|
|
return diags
|
|
|
|
},
|
|
|
|
[]diagFlat{
|
|
|
|
{
|
|
|
|
Severity: Error,
|
|
|
|
Summary: "Something bad happened",
|
|
|
|
Detail: "It was really, really bad.",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
Severity: Warning,
|
|
|
|
Summary: "Also, somebody sneezed",
|
|
|
|
Detail: "How rude!",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
"multierror.Error": {
|
|
|
|
func(diags Diagnostics) Diagnostics {
|
|
|
|
err := multierror.Append(nil, errors.New("bad thing A"))
|
|
|
|
err = multierror.Append(err, errors.New("bad thing B"))
|
|
|
|
diags = diags.Append(err)
|
|
|
|
return diags
|
|
|
|
},
|
|
|
|
[]diagFlat{
|
|
|
|
{
|
|
|
|
Severity: Error,
|
|
|
|
Summary: "bad thing A",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
Severity: Error,
|
|
|
|
Summary: "bad thing B",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
"concat Diagnostics": {
|
|
|
|
func(diags Diagnostics) Diagnostics {
|
|
|
|
var moreDiags Diagnostics
|
|
|
|
moreDiags = moreDiags.Append(errors.New("bad thing A"))
|
|
|
|
moreDiags = moreDiags.Append(errors.New("bad thing B"))
|
|
|
|
return diags.Append(moreDiags)
|
|
|
|
},
|
|
|
|
[]diagFlat{
|
|
|
|
{
|
|
|
|
Severity: Error,
|
|
|
|
Summary: "bad thing A",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
Severity: Error,
|
|
|
|
Summary: "bad thing B",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
"single Diagnostic": {
|
|
|
|
func(diags Diagnostics) Diagnostics {
|
|
|
|
return diags.Append(SimpleWarning("Don't forget your toothbrush!"))
|
|
|
|
},
|
|
|
|
[]diagFlat{
|
|
|
|
{
|
|
|
|
Severity: Warning,
|
|
|
|
Summary: "Don't forget your toothbrush!",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
"multiple appends": {
|
|
|
|
func(diags Diagnostics) Diagnostics {
|
|
|
|
diags = diags.Append(SimpleWarning("Don't forget your toothbrush!"))
|
|
|
|
diags = diags.Append(fmt.Errorf("exploded"))
|
|
|
|
return diags
|
|
|
|
},
|
|
|
|
[]diagFlat{
|
|
|
|
{
|
|
|
|
Severity: Warning,
|
|
|
|
Summary: "Don't forget your toothbrush!",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
Severity: Error,
|
|
|
|
Summary: "exploded",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
for name, test := range tests {
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
|
|
gotDiags := test.Cons(nil)
|
|
|
|
var got []diagFlat
|
|
|
|
for _, item := range gotDiags {
|
|
|
|
desc := item.Description()
|
|
|
|
source := item.Source()
|
|
|
|
got = append(got, diagFlat{
|
|
|
|
Severity: item.Severity(),
|
|
|
|
Summary: desc.Summary,
|
|
|
|
Detail: desc.Detail,
|
|
|
|
Subject: source.Subject,
|
|
|
|
Context: source.Context,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
if !reflect.DeepEqual(got, test.Want) {
|
|
|
|
t.Errorf("wrong result\ngot: %swant: %s", spew.Sdump(got), spew.Sdump(test.Want))
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestDiagnosticsErr(t *testing.T) {
|
|
|
|
t.Run("empty", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
err := diags.Err()
|
|
|
|
if err != nil {
|
|
|
|
t.Errorf("got non-nil error %#v; want nil", err)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("warning only", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
diags = diags.Append(SimpleWarning("bad"))
|
|
|
|
err := diags.Err()
|
|
|
|
if err != nil {
|
|
|
|
t.Errorf("got non-nil error %#v; want nil", err)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("one error", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
diags = diags.Append(errors.New("didn't work"))
|
|
|
|
err := diags.Err()
|
|
|
|
if err == nil {
|
|
|
|
t.Fatalf("got nil error %#v; want non-nil", err)
|
|
|
|
}
|
|
|
|
if got, want := err.Error(), "didn't work"; got != want {
|
|
|
|
t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("two errors", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
diags = diags.Append(errors.New("didn't work"))
|
|
|
|
diags = diags.Append(errors.New("didn't work either"))
|
|
|
|
err := diags.Err()
|
|
|
|
if err == nil {
|
|
|
|
t.Fatalf("got nil error %#v; want non-nil", err)
|
|
|
|
}
|
|
|
|
want := strings.TrimSpace(`
|
|
|
|
2 problems:
|
|
|
|
|
|
|
|
- didn't work
|
|
|
|
- didn't work either
|
|
|
|
`)
|
|
|
|
if got := err.Error(); got != want {
|
|
|
|
t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("error and warning", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
diags = diags.Append(errors.New("didn't work"))
|
|
|
|
diags = diags.Append(SimpleWarning("didn't work either"))
|
|
|
|
err := diags.Err()
|
|
|
|
if err == nil {
|
|
|
|
t.Fatalf("got nil error %#v; want non-nil", err)
|
|
|
|
}
|
|
|
|
// Since this "as error" mode is just a fallback for
|
|
|
|
// non-diagnostics-aware situations like tests, we don't actually
|
|
|
|
// distinguish warnings and errors here since the point is to just
|
|
|
|
// get the messages rendered. User-facing code should be printing
|
|
|
|
// each diagnostic separately, so won't enter this codepath,
|
|
|
|
want := strings.TrimSpace(`
|
|
|
|
2 problems:
|
|
|
|
|
|
|
|
- didn't work
|
|
|
|
- didn't work either
|
|
|
|
`)
|
|
|
|
if got := err.Error(); got != want {
|
|
|
|
t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
2018-04-18 19:50:06 +02:00
|
|
|
|
|
|
|
func TestDiagnosticsErrWithWarnings(t *testing.T) {
|
|
|
|
t.Run("empty", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
err := diags.ErrWithWarnings()
|
|
|
|
if err != nil {
|
|
|
|
t.Errorf("got non-nil error %#v; want nil", err)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("warning only", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
diags = diags.Append(SimpleWarning("bad"))
|
|
|
|
err := diags.ErrWithWarnings()
|
|
|
|
if err == nil {
|
|
|
|
t.Errorf("got nil error; want NonFatalError")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if _, ok := err.(NonFatalError); !ok {
|
|
|
|
t.Errorf("got %T; want NonFatalError", err)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("one error", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
diags = diags.Append(errors.New("didn't work"))
|
|
|
|
err := diags.ErrWithWarnings()
|
|
|
|
if err == nil {
|
|
|
|
t.Fatalf("got nil error %#v; want non-nil", err)
|
|
|
|
}
|
|
|
|
if got, want := err.Error(), "didn't work"; got != want {
|
|
|
|
t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("two errors", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
diags = diags.Append(errors.New("didn't work"))
|
|
|
|
diags = diags.Append(errors.New("didn't work either"))
|
|
|
|
err := diags.ErrWithWarnings()
|
|
|
|
if err == nil {
|
|
|
|
t.Fatalf("got nil error %#v; want non-nil", err)
|
|
|
|
}
|
|
|
|
want := strings.TrimSpace(`
|
|
|
|
2 problems:
|
|
|
|
|
|
|
|
- didn't work
|
|
|
|
- didn't work either
|
|
|
|
`)
|
|
|
|
if got := err.Error(); got != want {
|
|
|
|
t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("error and warning", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
diags = diags.Append(errors.New("didn't work"))
|
|
|
|
diags = diags.Append(SimpleWarning("didn't work either"))
|
|
|
|
err := diags.ErrWithWarnings()
|
|
|
|
if err == nil {
|
|
|
|
t.Fatalf("got nil error %#v; want non-nil", err)
|
|
|
|
}
|
|
|
|
// Since this "as error" mode is just a fallback for
|
|
|
|
// non-diagnostics-aware situations like tests, we don't actually
|
|
|
|
// distinguish warnings and errors here since the point is to just
|
|
|
|
// get the messages rendered. User-facing code should be printing
|
|
|
|
// each diagnostic separately, so won't enter this codepath,
|
|
|
|
want := strings.TrimSpace(`
|
|
|
|
2 problems:
|
|
|
|
|
|
|
|
- didn't work
|
|
|
|
- didn't work either
|
|
|
|
`)
|
|
|
|
if got := err.Error(); got != want {
|
|
|
|
t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestDiagnosticsNonFatalErr(t *testing.T) {
|
|
|
|
t.Run("empty", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
err := diags.NonFatalErr()
|
|
|
|
if err != nil {
|
|
|
|
t.Errorf("got non-nil error %#v; want nil", err)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("warning only", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
diags = diags.Append(SimpleWarning("bad"))
|
|
|
|
err := diags.NonFatalErr()
|
|
|
|
if err == nil {
|
|
|
|
t.Errorf("got nil error; want NonFatalError")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if _, ok := err.(NonFatalError); !ok {
|
|
|
|
t.Errorf("got %T; want NonFatalError", err)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("one error", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
diags = diags.Append(errors.New("didn't work"))
|
|
|
|
err := diags.NonFatalErr()
|
|
|
|
if err == nil {
|
|
|
|
t.Fatalf("got nil error %#v; want non-nil", err)
|
|
|
|
}
|
|
|
|
if got, want := err.Error(), "didn't work"; got != want {
|
|
|
|
t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want)
|
|
|
|
}
|
|
|
|
if _, ok := err.(NonFatalError); !ok {
|
|
|
|
t.Errorf("got %T; want NonFatalError", err)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("two errors", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
diags = diags.Append(errors.New("didn't work"))
|
|
|
|
diags = diags.Append(errors.New("didn't work either"))
|
|
|
|
err := diags.NonFatalErr()
|
|
|
|
if err == nil {
|
|
|
|
t.Fatalf("got nil error %#v; want non-nil", err)
|
|
|
|
}
|
|
|
|
want := strings.TrimSpace(`
|
|
|
|
2 problems:
|
|
|
|
|
|
|
|
- didn't work
|
|
|
|
- didn't work either
|
|
|
|
`)
|
|
|
|
if got := err.Error(); got != want {
|
|
|
|
t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want)
|
|
|
|
}
|
|
|
|
if _, ok := err.(NonFatalError); !ok {
|
|
|
|
t.Errorf("got %T; want NonFatalError", err)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
t.Run("error and warning", func(t *testing.T) {
|
|
|
|
var diags Diagnostics
|
|
|
|
diags = diags.Append(errors.New("didn't work"))
|
|
|
|
diags = diags.Append(SimpleWarning("didn't work either"))
|
|
|
|
err := diags.NonFatalErr()
|
|
|
|
if err == nil {
|
|
|
|
t.Fatalf("got nil error %#v; want non-nil", err)
|
|
|
|
}
|
|
|
|
// Since this "as error" mode is just a fallback for
|
|
|
|
// non-diagnostics-aware situations like tests, we don't actually
|
|
|
|
// distinguish warnings and errors here since the point is to just
|
|
|
|
// get the messages rendered. User-facing code should be printing
|
|
|
|
// each diagnostic separately, so won't enter this codepath,
|
|
|
|
want := strings.TrimSpace(`
|
|
|
|
2 problems:
|
|
|
|
|
|
|
|
- didn't work
|
|
|
|
- didn't work either
|
|
|
|
`)
|
|
|
|
if got := err.Error(); got != want {
|
|
|
|
t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want)
|
|
|
|
}
|
|
|
|
if _, ok := err.(NonFatalError); !ok {
|
|
|
|
t.Errorf("got %T; want NonFatalError", err)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|