Extract Route validation from serialization methods so it can be re-used (#47649)

* Extract validation and reject invalid policies

* Validation in dedicated file

* Tests for validation

* Extract root route validation

* Update call and drop TODO

* empty commit to kick actions

* Normalization should be idempotent

* Cleaner representation of validation errors, chain errors properly

* Make internal validate unexported

* Fix missed rename

* Genericize error message

* Improve method names

* Rebase, fix

* Update asserts
This commit is contained in:
Alexander Weaver
2022-04-27 15:15:41 -05:00
committed by GitHub
parent 900d9bf9a1
commit 60ec10566f
8 changed files with 380 additions and 50 deletions
@@ -10,7 +10,6 @@ import (
"time"
"github.com/go-openapi/strfmt"
"github.com/pkg/errors"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/pkg/labels"
@@ -724,35 +723,7 @@ func (r *Route) UnmarshalYAML(unmarshal func(interface{}) error) error {
return err
}
for _, l := range r.GroupByStr {
if l == "..." {
r.GroupByAll = true
} else {
r.GroupBy = append(r.GroupBy, model.LabelName(l))
}
}
if len(r.GroupBy) > 0 && r.GroupByAll {
return fmt.Errorf("cannot have wildcard group_by (`...`) and other other labels at the same time")
}
groupBy := map[model.LabelName]struct{}{}
for _, ln := range r.GroupBy {
if _, ok := groupBy[ln]; ok {
return fmt.Errorf("duplicated label %q in group_by", ln)
}
groupBy[ln] = struct{}{}
}
if r.GroupInterval != nil && time.Duration(*r.GroupInterval) == time.Duration(0) {
return fmt.Errorf("group_interval cannot be zero")
}
if r.RepeatInterval != nil && time.Duration(*r.RepeatInterval) == time.Duration(0) {
return fmt.Errorf("repeat_interval cannot be zero")
}
return nil
return r.validateChild()
}
// Return an alertmanager route from a Grafana route. The ObjectMatchers are converted to Matchers.
@@ -837,25 +808,9 @@ func (c *Config) UnmarshalJSON(b []byte) error {
return fmt.Errorf("no routes provided")
}
// Route is a recursive structure that includes validation in the yaml unmarshaler.
// Therefore, we'll redirect json -> yaml to utilize these.
b, err := yaml.Marshal(c.Route)
err := c.Route.Validate()
if err != nil {
return errors.Wrap(err, "marshaling route to yaml for validation")
}
err = yaml.Unmarshal(b, c.Route)
if err != nil {
return errors.Wrap(err, "unmarshaling route for validations")
}
if len(c.Route.Receiver) == 0 {
return fmt.Errorf("root route must specify a default receiver")
}
if len(c.Route.Match) > 0 || len(c.Route.MatchRE) > 0 {
return fmt.Errorf("root route must not have any matchers")
}
if len(c.Route.MuteTimeIntervals) > 0 {
return fmt.Errorf("root route must not have any mute time intervals")
return err
}
for _, r := range c.InhibitRules {
@@ -0,0 +1,67 @@
package definitions
import (
"fmt"
"time"
"github.com/prometheus/common/model"
)
// Validate normalizes a possibly nested Route r, and returns errors if r is invalid.
func (r *Route) validateChild() error {
r.GroupBy = nil
r.GroupByAll = false
for _, l := range r.GroupByStr {
if l == "..." {
r.GroupByAll = true
} else {
r.GroupBy = append(r.GroupBy, model.LabelName(l))
}
}
if len(r.GroupBy) > 0 && r.GroupByAll {
return fmt.Errorf("cannot have wildcard group_by (`...`) and other other labels at the same time")
}
groupBy := map[model.LabelName]struct{}{}
for _, ln := range r.GroupBy {
if _, ok := groupBy[ln]; ok {
return fmt.Errorf("duplicated label %q in group_by, %s %s", ln, r.Receiver, r.GroupBy)
}
groupBy[ln] = struct{}{}
}
if r.GroupInterval != nil && time.Duration(*r.GroupInterval) == time.Duration(0) {
return fmt.Errorf("group_interval cannot be zero")
}
if r.RepeatInterval != nil && time.Duration(*r.RepeatInterval) == time.Duration(0) {
return fmt.Errorf("repeat_interval cannot be zero")
}
// Routes are a self-referential structure.
if r.Routes != nil {
for _, child := range r.Routes {
err := child.validateChild()
if err != nil {
return err
}
}
}
return nil
}
// Validate normalizes a Route r, and returns errors if r is an invalid root route. Root routes must satisfy a few additional conditions.
func (r *Route) Validate() error {
if len(r.Receiver) == 0 {
return fmt.Errorf("root route must specify a default receiver")
}
if len(r.Match) > 0 || len(r.MatchRE) > 0 {
return fmt.Errorf("root route must not have any matchers")
}
if len(r.MuteTimeIntervals) > 0 {
return fmt.Errorf("root route must not have any mute time intervals")
}
return r.validateChild()
}
@@ -0,0 +1,257 @@
package definitions
import (
"testing"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
)
func TestValidateRoutes(t *testing.T) {
zero := model.Duration(0)
type testCase struct {
desc string
route Route
expMsg string
}
t.Run("valid route", func(t *testing.T) {
cases := []testCase{
{
desc: "empty",
route: Route{},
},
{
desc: "simple",
route: Route{
Receiver: "foo",
GroupByStr: []string{"..."},
},
},
{
desc: "nested",
route: Route{
Receiver: "foo",
GroupByStr: []string{"..."},
Routes: []*Route{
{
Receiver: "bar",
},
},
},
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
err := c.route.validateChild()
require.NoError(t, err)
})
}
})
t.Run("invalid route", func(t *testing.T) {
cases := []testCase{
{
desc: "zero group interval",
route: Route{
Receiver: "foo",
GroupByStr: []string{"..."},
GroupInterval: &zero,
},
expMsg: "group_interval cannot be zero",
},
{
desc: "zero repeat interval",
route: Route{
Receiver: "foo",
GroupByStr: []string{"..."},
RepeatInterval: &zero,
},
expMsg: "repeat_interval cannot be zero",
},
{
desc: "duplicated label",
route: Route{
Receiver: "foo",
GroupByStr: []string{
"abc",
"abc",
},
},
expMsg: "duplicated label",
},
{
desc: "wildcard and non-wildcard label simultaneously",
route: Route{
Receiver: "foo",
GroupByStr: []string{
"...",
"abc",
},
},
expMsg: "cannot have wildcard",
},
{
desc: "valid with nested invalid",
route: Route{
Receiver: "foo",
GroupByStr: []string{"..."},
Routes: []*Route{
{
GroupByStr: []string{
"abc",
"abc",
},
},
},
},
expMsg: "duplicated label",
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
err := c.route.validateChild()
require.Error(t, err)
require.Contains(t, err.Error(), c.expMsg)
})
}
})
t.Run("route validator normalizes group_by", func(t *testing.T) {
t.Run("when grouping normally", func(t *testing.T) {
route := Route{
Receiver: "foo",
GroupByStr: []string{"abc", "def"},
}
_ = route.validateChild()
require.False(t, route.GroupByAll)
require.Equal(t, []model.LabelName{"abc", "def"}, route.GroupBy)
})
t.Run("when grouping by wildcard, nil", func(t *testing.T) {
route := Route{
Receiver: "foo",
GroupByStr: []string{"..."},
}
_ = route.validateChild()
require.True(t, route.GroupByAll)
require.Nil(t, route.GroupBy)
})
t.Run("idempotently", func(t *testing.T) {
route := Route{
Receiver: "foo",
GroupByStr: []string{"abc", "def"},
}
err := route.validateChild()
require.NoError(t, err)
err = route.validateChild()
require.NoError(t, err)
require.False(t, route.GroupByAll)
require.Equal(t, []model.LabelName{"abc", "def"}, route.GroupBy)
})
})
t.Run("valid root route", func(t *testing.T) {
cases := []testCase{
{
desc: "simple",
route: Route{
Receiver: "foo",
GroupByStr: []string{"..."},
},
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
err := c.route.Validate()
require.NoError(t, err)
})
}
})
t.Run("invalid root route", func(t *testing.T) {
cases := []testCase{
{
desc: "no receiver",
route: Route{
GroupByStr: []string{"..."},
},
expMsg: "must specify a default receiver",
},
{
desc: "exact matchers present",
route: Route{
Receiver: "foo",
GroupByStr: []string{"..."},
Match: map[string]string{
"abc": "def",
},
},
expMsg: "must not have any matchers",
},
{
desc: "regex matchers present",
route: Route{
Receiver: "foo",
GroupByStr: []string{"..."},
Match: map[string]string{
"abc": "def",
},
},
expMsg: "must not have any matchers",
},
{
desc: "mute time intervals present",
route: Route{
Receiver: "foo",
GroupByStr: []string{"..."},
MuteTimeIntervals: []string{"10"},
},
expMsg: "must not have any mute time intervals",
},
{
desc: "validation error that is not specific to root",
route: Route{
Receiver: "foo",
GroupByStr: []string{"abc", "abc"},
},
expMsg: "duplicated label",
},
{
desc: "nested validation error that is not specific to root",
route: Route{
Receiver: "foo",
Routes: []*Route{
{
GroupByStr: []string{"abc", "abc"},
},
},
},
expMsg: "duplicated label",
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
err := c.route.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), c.expMsg)
})
}
})
}