-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Offload evaluable configs from
Incident
to a common place
- Loading branch information
Showing
4 changed files
with
471 additions
and
110 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
package config | ||
|
||
import ( | ||
"github.com/icinga/icinga-notifications/internal/filter" | ||
"github.com/icinga/icinga-notifications/internal/rule" | ||
) | ||
|
||
// EvalOptions specifies optional callbacks that are executed upon certain filter evaluation events. | ||
type EvalOptions[T, U any] struct { | ||
// OnPreEvaluate can be used to perform arbitrary actions before evaluating the current entry of type "T". | ||
// An entry of type "T" for which this hook returns "false" will be excluded from evaluation. | ||
OnPreEvaluate func(T) bool | ||
|
||
// OnError can be used to perform arbitrary actions on filter evaluation errors. | ||
// The original filter evaluation error is passed to this function as well as the current | ||
// entry of type "T", whose filter evaluation triggered the error. | ||
// | ||
// By default, the filter evaluation doesn't get interrupted if any of them fail, instead it will continue | ||
// evaluating all the remaining entries. However, you can override this behaviour by returning "false" in | ||
// your handler, in which case the filter evaluation is aborted prematurely. | ||
OnError func(T, error) bool | ||
|
||
// OnFilterMatch can be used to perform arbitrary actions after a successful filter evaluation of type "T". | ||
// This callback obtains the current entry of type "T" as an argument, whose filter matched on the filterableTest. | ||
// | ||
// Note, any error returned by the OnFilterMatch hook causes the filter evaluation to be aborted | ||
// immediately before even reaching the remaining ones. | ||
OnFilterMatch func(T) error | ||
|
||
// OnAllConfigEvaluated can be used to perform some post filter evaluation actions. | ||
// This handler receives an arbitrary value, be it a result of any filter evaluation or a made-up one of type "U". | ||
// | ||
// OnAllConfigEvaluated will only be called once all the entries of type "T" are evaluated, though it doesn't | ||
// necessarily depend on the result of the individual entry filter evaluation. If the individual Eval* receivers | ||
// don't return prematurely with an error, this hook is guaranteed to be called in any other cases. However, you | ||
// should be aware, that this hook may not be supported by all Eval* methods. | ||
OnAllConfigEvaluated func(U) | ||
} | ||
|
||
// Evaluable manages an evaluable config types in a centralised and structured way. | ||
// An evaluable config is a config type that allows to evaluate filter expressions in some way. | ||
type Evaluable struct { | ||
Rules map[int64]bool `db:"-"` | ||
RuleEntries map[int64]*rule.Entry `db:"-" json:"-"` | ||
} | ||
|
||
// NewEvaluable returns a fully initialised and ready to use Evaluable type. | ||
func NewEvaluable() *Evaluable { | ||
return &Evaluable{ | ||
Rules: make(map[int64]bool), | ||
RuleEntries: make(map[int64]*rule.Entry), | ||
} | ||
} | ||
|
||
// EvaluateRules evaluates all the configured event rule.Rule(s) for the given filter.Filterable object. | ||
// | ||
// Please note that this function may not always evaluate *all* configured rules from the specified RuntimeConfig, | ||
// as it internally caches all previously matched rules based on their ID. | ||
// | ||
// EvaluateRules allows you to specify EvalOptions and hook up certain filter evaluation steps. | ||
// This function does not support the EvalOptions.OnAllConfigEvaluated callback and will never trigger | ||
// it (if provided). Please refer to the description of the individual EvalOptions to find out more about | ||
// when the hooks get triggered and possible special cases. | ||
// | ||
// Returns an error if any of the provided callbacks return an error, otherwise always nil. | ||
func (e *Evaluable) EvaluateRules(r *RuntimeConfig, filterable filter.Filterable, options EvalOptions[*rule.Rule, any]) error { | ||
for _, ru := range r.Rules { | ||
if !e.Rules[ru.ID] && (options.OnPreEvaluate == nil || options.OnPreEvaluate(ru)) { | ||
matched, err := ru.Eval(filterable) | ||
if err != nil && options.OnError != nil && !options.OnError(ru, err) { | ||
return err | ||
} | ||
if err != nil || !matched { | ||
continue | ||
} | ||
|
||
if options.OnFilterMatch != nil { | ||
if err := options.OnFilterMatch(ru); err != nil { | ||
return err | ||
} | ||
} | ||
|
||
e.Rules[ru.ID] = true | ||
} | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// EvaluateRuleEntries evaluates all the configured rule.Entry for the provided filter.Filterable object. | ||
// | ||
// This function allows you to specify EvalOptions and hook up certain filter evaluation steps. | ||
// Currently, EvaluateRuleEntries fully support all the available EvalOptions. Please refer to the | ||
// description of the individual EvalOptions to find out more about when the hooks get triggered and | ||
// possible special cases. | ||
// | ||
// Returns an error if any of the provided callbacks return an error, otherwise always nil. | ||
func (e *Evaluable) EvaluateRuleEntries(r *RuntimeConfig, filterable filter.Filterable, options EvalOptions[*rule.Entry, any]) error { | ||
retryAfter := rule.RetryNever | ||
|
||
for ruleID := range e.Rules { | ||
ru := r.Rules[ruleID] | ||
if ru == nil { | ||
// It would be appropriate to have a debug log here, but unfortunately we don't have access to a logger. | ||
continue | ||
} | ||
|
||
for _, entry := range ru.Entries { | ||
if options.OnPreEvaluate != nil && !options.OnPreEvaluate(entry) { | ||
continue | ||
} | ||
|
||
if matched, err := entry.Eval(filterable); err != nil { | ||
if options.OnError != nil && !options.OnError(entry, err) { | ||
return err | ||
} | ||
} else if cond, ok := filterable.(*rule.EscalationFilter); !matched && ok { | ||
incidentAgeFilter := cond.ReevaluateAfter(entry.Condition) | ||
retryAfter = min(retryAfter, incidentAgeFilter) | ||
} else if matched { | ||
if options.OnFilterMatch != nil { | ||
if err := options.OnFilterMatch(entry); err != nil { | ||
return err | ||
} | ||
} | ||
|
||
e.RuleEntries[entry.ID] = entry | ||
} | ||
} | ||
} | ||
|
||
if options.OnAllConfigEvaluated != nil { | ||
options.OnAllConfigEvaluated(retryAfter) | ||
} | ||
|
||
return nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,243 @@ | ||
package config | ||
|
||
import ( | ||
"fmt" | ||
"github.com/icinga/icinga-notifications/internal/filter" | ||
"github.com/icinga/icinga-notifications/internal/rule" | ||
"github.com/stretchr/testify/require" | ||
"maps" | ||
"testing" | ||
"time" | ||
) | ||
|
||
const defaultDivisor = 3 | ||
|
||
func TestEvaluableConfig(t *testing.T) { | ||
t.Parallel() | ||
|
||
runtimeConfigTest := new(RuntimeConfig) | ||
runtimeConfigTest.Rules = make(map[int64]*rule.Rule) | ||
for i := 1; i <= 50; i++ { | ||
runtimeConfigTest.Rules[int64(i)] = makeRule(t, i) | ||
} | ||
|
||
t.Run("NewEvaluable", func(t *testing.T) { | ||
t.Parallel() | ||
|
||
e := NewEvaluable() | ||
require.NotNil(t, e, "it should create a fully initialised evaluable config") | ||
require.NotNil(t, e.Rules) | ||
require.NotNil(t, e.RuleEntries) | ||
}) | ||
|
||
t.Run("EvaluateRules", func(t *testing.T) { | ||
t.Parallel() | ||
|
||
runtime := new(RuntimeConfig) | ||
runtime.Rules = maps.Clone(runtimeConfigTest.Rules) | ||
|
||
expectedLen := len(runtime.Rules) / defaultDivisor | ||
options := EvalOptions[*rule.Rule, any]{} | ||
e := NewEvaluable() | ||
assertRules := func(expectedLen *int, expectError bool) { | ||
if expectError { | ||
require.Error(t, e.EvaluateRules(runtime, new(filterableTest), options)) | ||
} else { | ||
require.NoError(t, e.EvaluateRules(runtime, new(filterableTest), options)) | ||
} | ||
require.Len(t, e.Rules, *expectedLen) | ||
} | ||
|
||
assertRules(&expectedLen, false) | ||
maps.DeleteFunc(e.Rules, func(ruleID int64, _ bool) bool { return int(ruleID) > expectedLen/2 }) | ||
|
||
options.OnPreEvaluate = func(r *rule.Rule) bool { | ||
require.Falsef(t, e.Rules[r.ID], "EvaluateRules() shouldn't evaluate %q twice", r.Name) | ||
return true | ||
} | ||
options.OnError = func(r *rule.Rule, err error) bool { | ||
require.EqualError(t, err, `"nonexistent" is not a valid filter key`) | ||
require.Truef(t, r.ID%defaultDivisor != 0, "evaluating rule %q should not fail", r.Name) | ||
return true | ||
} | ||
options.OnFilterMatch = func(r *rule.Rule) error { | ||
require.Falsef(t, e.Rules[r.ID], "EvaluateRules() shouldn't evaluate %q twice", r.Name) | ||
return nil | ||
} | ||
|
||
assertRules(&expectedLen, false) | ||
maps.DeleteFunc(e.Rules, func(ruleID int64, _ bool) bool { return int(ruleID) > expectedLen/2 }) | ||
|
||
lenBeforeError := new(int) | ||
options.OnError = func(r *rule.Rule, err error) bool { | ||
if *lenBeforeError != 0 { | ||
require.Fail(t, "OnError() shouldn't have been called again") | ||
} | ||
|
||
require.EqualError(t, err, `"nonexistent" is not a valid filter key`) | ||
require.Truef(t, r.ID%defaultDivisor != 0, "evaluating rule %q should not fail", r.Name) | ||
|
||
*lenBeforeError = len(e.Rules) | ||
return false // This should let the evaluation fail completely! | ||
} | ||
assertRules(lenBeforeError, true) | ||
maps.DeleteFunc(e.Rules, func(ruleID int64, _ bool) bool { return int(ruleID) > expectedLen/2 }) | ||
|
||
*lenBeforeError = 0 | ||
options.OnError = nil | ||
options.OnFilterMatch = func(r *rule.Rule) error { | ||
if *lenBeforeError != 0 { | ||
require.Fail(t, "OnFilterMatch() shouldn't have been called again") | ||
} | ||
|
||
*lenBeforeError = len(e.Rules) | ||
return fmt.Errorf("OnFilterMatch() failed badly") // This should let the evaluation fail completely! | ||
} | ||
assertRules(lenBeforeError, true) | ||
}) | ||
|
||
t.Run("EvaluateRuleEntries", func(t *testing.T) { | ||
t.Parallel() | ||
|
||
runtime := new(RuntimeConfig) | ||
runtime.Rules = maps.Clone(runtimeConfigTest.Rules) | ||
|
||
e := NewEvaluable() | ||
options := EvalOptions[*rule.Entry, any]{} | ||
|
||
expectedLen := 0 | ||
filterContext := &rule.EscalationFilter{IncidentSeverity: 9} // Event severity "emergency" | ||
assertEntries := func(expectedLen *int, expectError bool) { | ||
if expectError { | ||
require.Error(t, e.EvaluateRuleEntries(runtime, filterContext, options)) | ||
} else { | ||
require.NoError(t, e.EvaluateRuleEntries(runtime, filterContext, options)) | ||
} | ||
require.Len(t, e.RuleEntries, *expectedLen) | ||
e.RuleEntries = make(map[int64]*rule.Entry) | ||
} | ||
|
||
assertEntries(&expectedLen, false) | ||
require.NoError(t, e.EvaluateRules(runtime, new(filterableTest), EvalOptions[*rule.Rule, any]{})) | ||
require.Len(t, e.Rules, len(runtime.Rules)/defaultDivisor) | ||
expectedLen = len(runtime.Rules)/defaultDivisor - 5 // 15/3 => (5) valid entries are going to be deleted below. | ||
|
||
// Drop some random rules from the runtime config to simulate a runtime config deletion! | ||
maps.DeleteFunc(runtime.Rules, func(ruleID int64, _ *rule.Rule) bool { return ruleID > 35 && ruleID%defaultDivisor == 0 }) | ||
|
||
options.OnPreEvaluate = func(re *rule.Entry) bool { | ||
if re.RuleID > 35 && re.RuleID%defaultDivisor == 0 { // Those rules are deleted from our runtime config. | ||
require.Failf(t, "OnPreEvaluate() shouldn't have been called", "rule %d was deleted from runtime config", re.RuleID) | ||
} | ||
|
||
require.Nilf(t, e.RuleEntries[re.ID], "EvaluateRuleEntries() shouldn't evaluate entry %d twice", re.ID) | ||
return true | ||
} | ||
options.OnError = func(re *rule.Entry, err error) bool { | ||
require.EqualError(t, err, `unknown severity "evaluable"`) | ||
require.Truef(t, re.RuleID%defaultDivisor == 0, "evaluating rule entry %d should not fail", re.ID) | ||
return true | ||
} | ||
options.OnFilterMatch = func(re *rule.Entry) error { | ||
require.Nilf(t, e.RuleEntries[re.ID], "OnPreEvaluate() shouldn't evaluate %d twice", re.ID) | ||
return nil | ||
} | ||
assertEntries(&expectedLen, false) | ||
|
||
lenBeforeError := new(int) | ||
options.OnError = func(re *rule.Entry, err error) bool { | ||
if *lenBeforeError != 0 { | ||
require.Fail(t, "OnError() shouldn't have been called again") | ||
} | ||
|
||
require.EqualError(t, err, `unknown severity "evaluable"`) | ||
require.Truef(t, re.RuleID%defaultDivisor == 0, "evaluating rule entry %d should not fail", re.ID) | ||
|
||
*lenBeforeError = len(e.RuleEntries) | ||
return false // This should let the evaluation fail completely! | ||
} | ||
assertEntries(lenBeforeError, true) | ||
|
||
*lenBeforeError = 0 | ||
options.OnError = nil | ||
options.OnFilterMatch = func(re *rule.Entry) error { | ||
if *lenBeforeError != 0 { | ||
require.Fail(t, "OnFilterMatch() shouldn't have been called again") | ||
} | ||
|
||
*lenBeforeError = len(e.RuleEntries) | ||
return fmt.Errorf("OnFilterMatch() failed badly") // This should let the evaluation fail completely! | ||
} | ||
assertEntries(lenBeforeError, true) | ||
|
||
expectedLen = 0 | ||
filterContext.IncidentSeverity = 1 // OK | ||
filterContext.IncidentAge = 5 * time.Minute | ||
|
||
options.OnFilterMatch = nil | ||
options.OnPreEvaluate = func(re *rule.Entry) bool { return re.RuleID < 5 } | ||
options.OnAllConfigEvaluated = func(result any) { | ||
retryAfter := result.(time.Duration) | ||
// The filter string of the escalation condition is incident_age>=10m and the actual incident age is 5m. | ||
require.Equal(t, 5*time.Minute, retryAfter) | ||
} | ||
assertEntries(&expectedLen, false) | ||
}) | ||
} | ||
|
||
func makeRule(t *testing.T, i int) *rule.Rule { | ||
r := new(rule.Rule) | ||
r.ID = int64(i) | ||
r.Name = fmt.Sprintf("rule-%d", i) | ||
r.Entries = make(map[int64]*rule.Entry) | ||
|
||
invalidSeverity, err := filter.Parse("incident_severity=evaluable") | ||
require.NoError(t, err, "parsing incident_severity=evaluable shouldn't fail") | ||
|
||
redundant := new(rule.Entry) | ||
redundant.ID = r.ID * 150 // It must be large enough to avoid colliding with others! | ||
redundant.RuleID = r.ID | ||
redundant.Condition = invalidSeverity | ||
|
||
nonexistent, err := filter.Parse("nonexistent=evaluable") | ||
require.NoError(t, err, "parsing nonexistent=evaluable shouldn't fail") | ||
|
||
r.Entries[redundant.ID] = redundant | ||
r.ObjectFilter = nonexistent | ||
if i%defaultDivisor == 0 { | ||
objCond, err := filter.Parse("host=evaluable") | ||
require.NoError(t, err, "parsing host=evaluable shouldn't fail") | ||
|
||
escalationCond, err := filter.Parse("incident_severity>warning||incident_age>=10m") | ||
require.NoError(t, err, "parsing incident_severity>=ok shouldn't fail") | ||
|
||
entry := new(rule.Entry) | ||
entry.ID = r.ID * 2 | ||
entry.RuleID = r.ID | ||
entry.Condition = escalationCond | ||
|
||
r.ObjectFilter = objCond | ||
r.Entries[entry.ID] = entry | ||
} | ||
|
||
return r | ||
} | ||
|
||
// filterableTest is a test type that simulates a filter evaluation and eliminates | ||
// the need of having to import e.g. the object package. | ||
type filterableTest struct{} | ||
|
||
func (f *filterableTest) EvalEqual(k string, v string) (bool, error) { | ||
if k != "host" { | ||
return false, fmt.Errorf("%q is not a valid filter key", k) | ||
} | ||
|
||
return v == "evaluable", nil | ||
} | ||
|
||
func (f *filterableTest) EvalExists(_ string) bool { return true } | ||
func (f *filterableTest) EvalLess(_ string, _ string) (bool, error) { | ||
panic("Oh dear - you shouldn't have called me") | ||
} | ||
func (f *filterableTest) EvalLike(_, _ string) (bool, error) { return f.EvalLess("", "") } | ||
func (f *filterableTest) EvalLessOrEqual(_, _ string) (bool, error) { return f.EvalLess("", "") } |
Oops, something went wrong.