Translate the AppConfig and Team validation messages to be more user-friendly (#8171)

This commit is contained in:
Martin Angers
2022-10-12 17:10:50 -04:00
committed by GitHub
parent 6939af045d
commit fae8e4ca2c
10 changed files with 175 additions and 37 deletions
@@ -0,0 +1 @@
* Translated technical error messages returned by Organization's and Team's validations to be more user-friendly.
+13 -13
View File
@@ -305,7 +305,7 @@ spec:
`)
runAppCheckErr(t, []string{"apply", "-f", name},
"applying fleet config: PATCH /api/latest/fleet/config received status 400 Bad request: json: unknown field \"enabled_software_inventory\"",
"applying fleet config: PATCH /api/latest/fleet/config received status 400 Bad Request: unsupported key provided: \"enabled_software_inventory\"",
)
require.Nil(t, savedAppConfig)
}
@@ -743,7 +743,7 @@ spec:
config:
blah: nope
`,
wantErr: `400 Bad request: common config: json: unknown field "blah"`,
wantErr: `400 Bad Request: unsupported key provided: "blah"`,
},
{
desc: "invalid top-level key for team",
@@ -769,7 +769,7 @@ spec:
config:
blah: nope
`,
wantErr: `400 Bad request: common config: json: unknown field "blah"`,
wantErr: `400 Bad Request: unsupported key provided: "blah"`,
},
{
desc: "invalid agent options dry-run",
@@ -784,7 +784,7 @@ spec:
blah: nope
`,
flags: []string{"--dry-run"},
wantErr: `400 Bad request: common config: json: unknown field "blah"`,
wantErr: `400 Bad Request: unsupported key provided: "blah"`,
},
{
desc: "invalid agent options force",
@@ -815,7 +815,7 @@ spec:
aws_debug: 123
`,
flags: []string{"--dry-run"},
wantErr: `400 Bad request: common config: json: cannot unmarshal number into Go struct field osqueryOptions.options.aws_debug of type bool`,
wantErr: `400 Bad Request: invalid value type at 'options.aws_debug': expected bool but got number`,
},
{
desc: "invalid team agent options command-line flag",
@@ -829,7 +829,7 @@ spec:
command_line_flags:
no_such_flag: 123
`,
wantErr: `400 Bad request: command-line flags: json: unknown field "no_such_flag"`,
wantErr: `400 Bad Request: unsupported key provided: "no_such_flag"`,
},
{
desc: "valid team agent options command-line flag",
@@ -863,7 +863,7 @@ spec:
options:
aws_debug: 123
`,
wantErr: `400 Bad request: darwin platform config: json: cannot unmarshal number into Go struct field osqueryOptions.options.aws_debug of type bool`,
wantErr: `400 Bad Request: invalid value type at 'options.aws_debug': expected bool but got number`,
},
{
desc: "empty config",
@@ -905,7 +905,7 @@ spec:
server_settings:
foo: bar
`,
wantErr: `400 Bad request: json: unknown field "foo"`,
wantErr: `400 Bad Request: unsupported key provided: "foo"`,
},
{
desc: "config with invalid key type",
@@ -928,7 +928,7 @@ spec:
foo: bar
`,
flags: []string{"--dry-run"},
wantErr: `400 Bad request: json: unknown field "foo"`,
wantErr: `400 Bad Request: unsupported key provided: "foo"`,
},
{
desc: "config with invalid agent options data type in dry-run",
@@ -942,7 +942,7 @@ spec:
aws_debug: 123
`,
flags: []string{"--dry-run"},
wantErr: `400 Bad request: common config: json: cannot unmarshal number into Go struct field osqueryOptions.options.aws_debug of type bool`,
wantErr: `400 Bad Request: invalid value type at 'options.aws_debug': expected bool but got number`,
},
{
desc: "config with invalid agent options data type with force",
@@ -969,7 +969,7 @@ spec:
enable_tables: "foo"
no_such_flag: false
`,
wantErr: `command-line flags: json: unknown field "no_such_flag"`,
wantErr: `400 Bad Request: unsupported key provided: "no_such_flag"`,
},
{
desc: "config with invalid value for agent options command-line flags",
@@ -981,7 +981,7 @@ spec:
command_line_flags:
enable_tables: 123
`,
wantErr: `command-line flags: json: cannot unmarshal number into Go struct field osqueryCommandLineFlags.enable_tables of type string`,
wantErr: `400 Bad Request: invalid value type at 'enable_tables': expected string but got number`,
},
{
desc: "config with valid agent options command-line flags",
@@ -1029,7 +1029,7 @@ spec:
enable_software_inventory: true
`,
flags: []string{"--dry-run"},
wantErr: `400 Bad request: warning: deprecated settings were used in the configuration`,
wantErr: `400 Bad request: warning: deprecated settings were used in the configuration: [host_settings]`,
wantOutput: `[!] ignoring labels, dry run mode only supported for 'config' and 'team' spec`,
},
{
+5 -2
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/authz"
@@ -148,11 +149,12 @@ func (svc *Service) ModifyTeamAgentOptions(ctx context.Context, teamID uint, tea
if teamOptions != nil {
if err := fleet.ValidateJSONAgentOptions(teamOptions); err != nil {
err = fleet.NewUserMessageError(err, http.StatusBadRequest)
if applyOptions.Force && !applyOptions.DryRun {
level.Info(svc.logger).Log("err", err, "msg", "force-apply team agent options with validation errors")
}
if !applyOptions.Force {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()}, "validate agent options")
return nil, ctxerr.Wrap(ctx, err, "validate agent options")
}
}
}
@@ -435,11 +437,12 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec,
if spec.AgentOptions != nil {
if err := fleet.ValidateJSONAgentOptions(*spec.AgentOptions); err != nil {
err = fleet.NewUserMessageError(err, http.StatusBadRequest)
if applyOpts.Force && !applyOpts.DryRun {
level.Info(svc.logger).Log("err", err, "msg", "force-apply team agent options with validation errors")
}
if !applyOpts.Force {
return ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()}, "validate agent options")
return ctxerr.Wrap(ctx, err, "validate agent options")
}
}
}
+2 -7
View File
@@ -21,6 +21,7 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
)
type key int
@@ -165,13 +166,7 @@ func Wrapf(ctx context.Context, cause error, format string, args ...interface{})
// Cause returns the root error in err's chain.
func Cause(err error) error {
for {
uerr := Unwrap(err)
if uerr == nil {
return err
}
err = uerr
}
return fleet.Cause(err)
}
// FleetCause is similar to Cause, but returns the root-most
+10 -7
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/url"
"sort"
"time"
"github.com/fleetdm/fleet/v4/server/config"
@@ -127,9 +128,9 @@ type AppConfig struct {
// when true, strictDecoding causes the UnmarshalJSON method to return an
// error if there are unknown fields in the raw JSON.
strictDecoding bool
// this field is set to true during UnmarshalJSON if any legacy settings
// were set in the raw JSON.
didUnmarshalLegacySettings bool
// this field is set to the list of legacy settings keys during UnmarshalJSON
// if any legacy settings were set in the raw JSON.
didUnmarshalLegacySettings []string
}
// legacyConfig holds settings that have been replaced, superceded or
@@ -274,9 +275,9 @@ func (c *AppConfig) ApplyDefaults() {
// EnableStrictDecoding enables strict decoding of the AppConfig struct.
func (c *AppConfig) EnableStrictDecoding() { c.strictDecoding = true }
// DidUnmarshalLegacySettings returns true if any legacy setting was set
// in the JSON used to unmarshal this AppConfig.
func (c *AppConfig) DidUnmarshalLegacySettings() bool { return c.didUnmarshalLegacySettings }
// DidUnmarshalLegacySettings returns the list of legacy settings keys that
// were set in the JSON used to unmarshal this AppConfig.
func (c *AppConfig) DidUnmarshalLegacySettings() []string { return c.didUnmarshalLegacySettings }
// UnmarshalJSON implements the json.Unmarshaler interface.
func (c *AppConfig) UnmarshalJSON(b []byte) error {
@@ -291,6 +292,7 @@ func (c *AppConfig) UnmarshalJSON(b []byte) error {
(*cfgStructUnmarshal)(c),
}
c.didUnmarshalLegacySettings = nil
decoder := json.NewDecoder(bytes.NewReader(b))
if c.strictDecoding {
decoder.DisallowUnknownFields()
@@ -306,9 +308,10 @@ func (c *AppConfig) UnmarshalJSON(b []byte) error {
// This has the drawback of legacy fields taking precedence over new fields
// if both are defined.
if compatConfig.legacyConfig.HostSettings != nil {
c.didUnmarshalLegacySettings = true
c.didUnmarshalLegacySettings = append(c.didUnmarshalLegacySettings, "host_settings")
c.Features = *compatConfig.legacyConfig.HostSettings
}
sort.Strings(c.didUnmarshalLegacySettings)
return nil
}
+75
View File
@@ -1,9 +1,13 @@
package fleet
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"regexp"
"strings"
)
var (
@@ -267,3 +271,74 @@ func NewErrorf(code int, format string, args ...interface{}) error {
func (ge *Error) Error() string {
return ge.Message
}
// UserMessageError is an error that adds the UserMessage interface
// implementation.
type UserMessageError struct {
error
statusCode int
}
// NewUserMessageError creates a UserMessageError that will translate the
// error message of err to a user-friendly form. If statusCode is > 0, it
// will be used as the HTTP status code for the error, otherwise it defaults
// to http.StatusUnprocessableEntity (422).
func NewUserMessageError(err error, statusCode int) *UserMessageError {
if err == nil {
return nil
}
return &UserMessageError{err, statusCode}
}
var rxJSONUnknownField = regexp.MustCompile(`^json: unknown field "(.+)"$`)
// UserMessage implements the user-friendly translation of the error if its
// root cause is one of the supported types, otherwise it returns the error
// message.
func (e UserMessageError) UserMessage() string {
cause := Cause(e.error)
switch cause := cause.(type) {
case *json.UnmarshalTypeError:
var sb strings.Builder
curType := cause.Type
for curType.Kind() == reflect.Slice || curType.Kind() == reflect.Array {
sb.WriteString("array of ")
curType = curType.Elem()
}
sb.WriteString(curType.Name())
if curType != cause.Type {
// it was an array
sb.WriteString("s")
}
return fmt.Sprintf("invalid value type at '%s': expected %s but got %s", cause.Field, sb.String(), cause.Value)
default:
// there's no specific error type for the strict json mode
// (DisallowUnknownFields), so resort to message-matching.
if matches := rxJSONUnknownField.FindStringSubmatch(cause.Error()); matches != nil {
return fmt.Sprintf("unsupported key provided: %q", matches[1])
}
return e.Error()
}
}
// StatusCode implements the kithttp.StatusCoder interface to return the status
// code to use in HTTP API responses.
func (e UserMessageError) StatusCode() int {
if e.statusCode > 0 {
return e.statusCode
}
return http.StatusUnprocessableEntity
}
// Cause returns the root error in err's chain.
func Cause(err error) error {
for {
uerr := errors.Unwrap(err)
if uerr == nil {
return err
}
err = uerr
}
}
+47
View File
@@ -0,0 +1,47 @@
package fleet
import (
"fmt"
"io"
"testing"
"github.com/stretchr/testify/require"
)
func TestUserMessageErrors(t *testing.T) {
var barString struct {
Bar string `json:"bar"`
}
type inner struct {
Foo int `json:"foo"`
Strings []string `json:"strings"`
IntsInts [][]int `json:"ints_ints"`
StringPtr *string `json:"string_ptr"`
}
type outer struct {
Inner inner `json:"inner"`
}
var nestedFoo outer
cases := []struct {
in error
out string
}{
{io.EOF, "EOF"},
{jsonStrictDecode([]byte(`{"foo":1}`), &barString), `unsupported key provided: "foo"`},
{jsonStrictDecode([]byte(`{"bar":1}`), &barString), `invalid value type at 'bar': expected string but got number`},
{jsonStrictDecode([]byte(`{"inner":{"foo":"bar"}}`), &nestedFoo), `invalid value type at 'inner.foo': expected int but got string`},
{jsonStrictDecode([]byte(`{"inner":{"strings":true}}`), &nestedFoo), `invalid value type at 'inner.strings': expected array of strings but got bool`},
{jsonStrictDecode([]byte(`{"inner":{"ints_ints":true}}`), &nestedFoo), `invalid value type at 'inner.ints_ints': expected array of array of ints but got bool`},
{jsonStrictDecode([]byte(`{"inner":{"string_ptr":true}}`), &nestedFoo), `invalid value type at 'inner.string_ptr': expected string but got bool`},
}
for _, c := range cases {
t.Run(fmt.Sprintf("%T: %[1]q", c.in), func(t *testing.T) {
ume := NewUserMessageError(c.in, 0)
got := ume.UserMessage()
require.Contains(t, got, c.out)
})
}
}
+8 -4
View File
@@ -8,8 +8,10 @@ import (
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"github.com/fleetdm/fleet/v4/server/authz"
@@ -276,14 +278,15 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
// We apply the config that is incoming to the old one
appConfig.EnableStrictDecoding()
if err := json.Unmarshal(p, &appConfig); err != nil {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()})
err = fleet.NewUserMessageError(err, http.StatusBadRequest)
return nil, ctxerr.Wrap(ctx, err)
}
var legacyUsedWarning error
if appConfig.DidUnmarshalLegacySettings() {
if legacyKeys := appConfig.DidUnmarshalLegacySettings(); len(legacyKeys) > 0 {
// this "warning" is returned only in dry-run mode, and if no other errors
// were encountered.
legacyUsedWarning = &fleet.BadRequestError{
Message: "warning: deprecated settings were used in the configuration; consider updating to the new settings: https://fleetdm.com/docs/using-fleet/configuration-files#settings",
Message: fmt.Sprintf("warning: deprecated settings were used in the configuration: %v; consider updating to the new settings: https://fleetdm.com/docs/using-fleet/configuration-files#settings", legacyKeys),
}
}
@@ -300,11 +303,12 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
// if there were Agent Options in the new app config, then it replaced the
// agent options in the resulting app config, so validate those.
if err := fleet.ValidateJSONAgentOptions(*appConfig.AgentOptions); err != nil {
err = fleet.NewUserMessageError(err, http.StatusBadRequest)
if applyOpts.Force && !applyOpts.DryRun {
level.Info(svc.logger).Log("err", err, "msg", "force-apply appConfig agent options with validation errors")
}
if !applyOpts.Force {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()}, "validate agent options")
return nil, ctxerr.Wrap(ctx, err, "validate agent options")
}
}
}
@@ -588,7 +588,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamEndpoints() {
}`), http.StatusBadRequest, "dry_run", "true")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), "cannot unmarshal string into Go struct field osqueryOptions.options.aws_debug of type bool")
require.Contains(t, string(body), "invalid value type at 'options.aws_debug': expected bool but got string")
// modify team agent using valid options with dry-run
tmResp.Team = nil
+13 -3
View File
@@ -185,7 +185,7 @@ func encodeError(ctx context.Context, err error, w http.ResponseWriter) {
enc.Encode(je)
return
}
if fleet.IsForeignKey(ctxerr.Cause(err)) {
if fleet.IsForeignKey(err) {
ve := jsonError{
Message: "Validation Failed",
Errors: baseError(err.Error()),
@@ -210,10 +210,20 @@ func encodeError(ctx context.Context, err error, w http.ResponseWriter) {
w.Header().Add("Retry-After", strconv.Itoa(ewra.RetryAfter()))
}
msg := err.Error()
reason := err.Error()
var ume *fleet.UserMessageError
if errors.As(err, &ume) {
if text := http.StatusText(status); text != "" {
msg = text
}
reason = ume.UserMessage()
}
w.WriteHeader(status)
je := jsonError{
Message: err.Error(),
Errors: baseError(err.Error()),
Message: msg,
Errors: baseError(reason),
}
enc.Encode(je)
}