Skip to content

Commit 563d4f0

Browse files
committed
Offload evaluable configs from Incident to a common place
1 parent 7c9c468 commit 563d4f0

File tree

4 files changed

+472
-110
lines changed

4 files changed

+472
-110
lines changed

internal/config/evaluable_config.go

+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package config
2+
3+
import (
4+
"github.com/icinga/icinga-notifications/internal/filter"
5+
"github.com/icinga/icinga-notifications/internal/rule"
6+
)
7+
8+
// EvalOptions specifies optional callbacks that are executed upon certain filter evaluation events.
9+
type EvalOptions[T, U any] struct {
10+
// OnPreEvaluate can be used to perform arbitrary actions before evaluating the current entry of type "T".
11+
// An entry of type "T" for which this hook returns "false" will be excluded from evaluation.
12+
OnPreEvaluate func(T) bool
13+
14+
// OnError can be used to perform arbitrary actions on filter evaluation errors.
15+
// The original filter evaluation error is passed to this function as well as the current
16+
// entry of type "T", whose filter evaluation triggered the error.
17+
//
18+
// By default, the filter evaluation doesn't get interrupted if any of them fail, instead it will continue
19+
// evaluating all the remaining entries. However, you can override this behaviour by returning "false" in
20+
// your handler, in which case the filter evaluation is aborted prematurely.
21+
OnError func(T, error) bool
22+
23+
// OnFilterMatch can be used to perform arbitrary actions after a successful filter evaluation of type "T".
24+
// This callback obtains the current entry of type "T" as an argument, whose filter matched on the filterableTest.
25+
//
26+
// Note, any error returned by the OnFilterMatch hook causes the filter evaluation to be aborted
27+
// immediately before even reaching the remaining ones.
28+
OnFilterMatch func(T) error
29+
30+
// OnAllConfigEvaluated can be used to perform some post filter evaluation actions.
31+
// This handler receives an arbitrary value, be it a result of any filter evaluation or a made-up one of type "U".
32+
//
33+
// OnAllConfigEvaluated will only be called once all the entries of type "T" are evaluated, though it doesn't
34+
// necessarily depend on the result of the individual entry filter evaluation. If the individual Eval* receivers
35+
// don't return prematurely with an error, this hook is guaranteed to be called in any other cases. However, you
36+
// should be aware, that this hook may not be supported by all Eval* methods.
37+
OnAllConfigEvaluated func(U)
38+
}
39+
40+
// Evaluable manages an evaluable config types in a centralised and structured way.
41+
// An evaluable config is a config type that allows to evaluate filter expressions in some way.
42+
type Evaluable struct {
43+
Rules map[int64]bool `db:"-"`
44+
RuleEntries map[int64]*rule.Entry `db:"-" json:"-"`
45+
}
46+
47+
// NewEvaluable returns a fully initialised and ready to use Evaluable type.
48+
func NewEvaluable() *Evaluable {
49+
return &Evaluable{
50+
Rules: make(map[int64]bool),
51+
RuleEntries: make(map[int64]*rule.Entry),
52+
}
53+
}
54+
55+
// EvaluateRules evaluates all the configured event rule.Rule(s) for the given filter.Filterable object.
56+
//
57+
// Please note that this function may not always evaluate *all* configured rules from the specified RuntimeConfig,
58+
// as it internally caches all previously matched rules based on their ID.
59+
//
60+
// EvaluateRules allows you to specify EvalOptions and hook up certain filter evaluation steps.
61+
// This function does not support the EvalOptions.OnAllConfigEvaluated callback and will never trigger
62+
// it (if provided). Please refer to the description of the individual EvalOptions to find out more about
63+
// when the hooks get triggered and possible special cases.
64+
//
65+
// Returns an error if any of the provided callbacks return an error, otherwise always nil.
66+
func (e *Evaluable) EvaluateRules(r *RuntimeConfig, filterable filter.Filterable, options EvalOptions[*rule.Rule, any]) error {
67+
for _, ru := range r.Rules {
68+
if !e.Rules[ru.ID] && (options.OnPreEvaluate == nil || options.OnPreEvaluate(ru)) {
69+
matched, err := ru.Eval(filterable)
70+
if err != nil && options.OnError != nil && !options.OnError(ru, err) {
71+
return err
72+
}
73+
if err != nil || !matched {
74+
continue
75+
}
76+
77+
if options.OnFilterMatch != nil {
78+
if err := options.OnFilterMatch(ru); err != nil {
79+
return err
80+
}
81+
}
82+
83+
e.Rules[ru.ID] = true
84+
}
85+
}
86+
87+
return nil
88+
}
89+
90+
// EvaluateRuleEntries evaluates all the configured rule.Entry for the provided filter.Filterable object.
91+
//
92+
// This function allows you to specify EvalOptions and hook up certain filter evaluation steps.
93+
// Currently, EvaluateRuleEntries fully support all the available EvalOptions. Please refer to the
94+
// description of the individual EvalOptions to find out more about when the hooks get triggered and
95+
// possible special cases.
96+
//
97+
// Returns an error if any of the provided callbacks return an error, otherwise always nil.
98+
func (e *Evaluable) EvaluateRuleEntries(r *RuntimeConfig, filterable filter.Filterable, options EvalOptions[*rule.Entry, any]) error {
99+
retryAfter := rule.RetryNever
100+
101+
for ruleID := range e.Rules {
102+
ru := r.Rules[ruleID]
103+
if ru == nil {
104+
// It would be appropriate to have a debug log here, but unfortunately we don't have access to a logger.
105+
continue
106+
}
107+
108+
for _, entry := range ru.Entries {
109+
if options.OnPreEvaluate != nil && !options.OnPreEvaluate(entry) {
110+
continue
111+
}
112+
113+
if matched, err := entry.Eval(filterable); err != nil {
114+
if options.OnError != nil && !options.OnError(entry, err) {
115+
return err
116+
}
117+
} else if cond, ok := filterable.(*rule.EscalationFilter); !matched && ok {
118+
incidentAgeFilter := cond.ReevaluateAfter(entry.Condition)
119+
retryAfter = min(retryAfter, incidentAgeFilter)
120+
} else if matched {
121+
if options.OnFilterMatch != nil {
122+
if err := options.OnFilterMatch(entry); err != nil {
123+
return err
124+
}
125+
}
126+
127+
e.RuleEntries[entry.ID] = entry
128+
}
129+
}
130+
}
131+
132+
if options.OnAllConfigEvaluated != nil {
133+
options.OnAllConfigEvaluated(retryAfter)
134+
}
135+
136+
return nil
137+
}
+244
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"github.com/icinga/icinga-notifications/internal/filter"
6+
"github.com/icinga/icinga-notifications/internal/rule"
7+
"github.com/stretchr/testify/require"
8+
"maps"
9+
"math/rand/v2"
10+
"testing"
11+
"time"
12+
)
13+
14+
const defaultDivisor = 3
15+
16+
func TestEvaluableConfig(t *testing.T) {
17+
t.Parallel()
18+
19+
runtimeConfigTest := new(RuntimeConfig)
20+
runtimeConfigTest.Rules = make(map[int64]*rule.Rule)
21+
for i := 1; i <= 50; i++ {
22+
runtimeConfigTest.Rules[int64(i)] = makeRule(t, i)
23+
}
24+
25+
t.Run("NewEvaluable", func(t *testing.T) {
26+
t.Parallel()
27+
28+
e := NewEvaluable()
29+
require.NotNil(t, e, "it should create a fully initialised evaluable config")
30+
require.NotNil(t, e.Rules)
31+
require.NotNil(t, e.RuleEntries)
32+
})
33+
34+
t.Run("EvaluateRules", func(t *testing.T) {
35+
t.Parallel()
36+
37+
runtime := new(RuntimeConfig)
38+
runtime.Rules = maps.Clone(runtimeConfigTest.Rules)
39+
40+
expectedLen := len(runtime.Rules) / defaultDivisor
41+
options := EvalOptions[*rule.Rule, any]{}
42+
e := NewEvaluable()
43+
assertRules := func(expectedLen *int, expectError bool) {
44+
if expectError {
45+
require.Error(t, e.EvaluateRules(runtime, new(filterableTest), options))
46+
} else {
47+
require.NoError(t, e.EvaluateRules(runtime, new(filterableTest), options))
48+
}
49+
require.Len(t, e.Rules, *expectedLen)
50+
}
51+
52+
assertRules(&expectedLen, false)
53+
maps.DeleteFunc(e.Rules, func(ruleID int64, _ bool) bool { return int(ruleID) > expectedLen/2 })
54+
55+
options.OnPreEvaluate = func(r *rule.Rule) bool {
56+
require.Falsef(t, e.Rules[r.ID], "EvaluateRules() shouldn't evaluate %q twice", r.Name)
57+
return true
58+
}
59+
options.OnError = func(r *rule.Rule, err error) bool {
60+
require.EqualError(t, err, `"nonexistent" is not a valid filter key`)
61+
require.Truef(t, r.ID%defaultDivisor != 0, "evaluating rule %q should not fail", r.Name)
62+
return true
63+
}
64+
options.OnFilterMatch = func(r *rule.Rule) error {
65+
require.Falsef(t, e.Rules[r.ID], "EvaluateRules() shouldn't evaluate %q twice", r.Name)
66+
return nil
67+
}
68+
69+
assertRules(&expectedLen, false)
70+
maps.DeleteFunc(e.Rules, func(ruleID int64, _ bool) bool { return int(ruleID) > expectedLen/2 })
71+
72+
lenBeforeError := new(int)
73+
options.OnError = func(r *rule.Rule, err error) bool {
74+
if *lenBeforeError != 0 {
75+
require.Fail(t, "OnError() shouldn't have been called again")
76+
}
77+
78+
require.EqualError(t, err, `"nonexistent" is not a valid filter key`)
79+
require.Truef(t, r.ID%defaultDivisor != 0, "evaluating rule %q should not fail", r.Name)
80+
81+
*lenBeforeError = len(e.Rules)
82+
return false // This should let the evaluation fail completely!
83+
}
84+
assertRules(lenBeforeError, true)
85+
maps.DeleteFunc(e.Rules, func(ruleID int64, _ bool) bool { return int(ruleID) > expectedLen/2 })
86+
87+
*lenBeforeError = 0
88+
options.OnError = nil
89+
options.OnFilterMatch = func(r *rule.Rule) error {
90+
if *lenBeforeError != 0 {
91+
require.Fail(t, "OnFilterMatch() shouldn't have been called again")
92+
}
93+
94+
*lenBeforeError = len(e.Rules)
95+
return fmt.Errorf("OnFilterMatch() failed badly") // This should let the evaluation fail completely!
96+
}
97+
assertRules(lenBeforeError, true)
98+
})
99+
100+
t.Run("EvaluateRuleEntries", func(t *testing.T) {
101+
t.Parallel()
102+
103+
runtime := new(RuntimeConfig)
104+
runtime.Rules = maps.Clone(runtimeConfigTest.Rules)
105+
106+
e := NewEvaluable()
107+
options := EvalOptions[*rule.Entry, any]{}
108+
109+
expectedLen := 0
110+
filterContext := &rule.EscalationFilter{IncidentSeverity: 9} // Event severity "emergency"
111+
assertEntries := func(expectedLen *int, expectError bool) {
112+
if expectError {
113+
require.Error(t, e.EvaluateRuleEntries(runtime, filterContext, options))
114+
} else {
115+
require.NoError(t, e.EvaluateRuleEntries(runtime, filterContext, options))
116+
}
117+
require.Len(t, e.RuleEntries, *expectedLen)
118+
e.RuleEntries = make(map[int64]*rule.Entry)
119+
}
120+
121+
assertEntries(&expectedLen, false)
122+
require.NoError(t, e.EvaluateRules(runtime, new(filterableTest), EvalOptions[*rule.Rule, any]{}))
123+
require.Len(t, e.Rules, len(runtime.Rules)/defaultDivisor)
124+
expectedLen = len(runtime.Rules)/defaultDivisor - 5 // 15/3 => (5) valid entries are going to be deleted below.
125+
126+
// Drop some random rules from the runtime config to simulate a runtime config deletion!
127+
maps.DeleteFunc(runtime.Rules, func(ruleID int64, _ *rule.Rule) bool { return ruleID > 35 && ruleID%defaultDivisor == 0 })
128+
129+
options.OnPreEvaluate = func(re *rule.Entry) bool {
130+
if re.RuleID > 35 && re.RuleID%defaultDivisor == 0 { // Those rules are deleted from our runtime config.
131+
require.Failf(t, "OnPreEvaluate() shouldn't have been called", "rule %d was deleted from runtime config", re.RuleID)
132+
}
133+
134+
require.Nilf(t, e.RuleEntries[re.ID], "EvaluateRuleEntries() shouldn't evaluate entry %d twice", re.ID)
135+
return true
136+
}
137+
options.OnError = func(re *rule.Entry, err error) bool {
138+
require.EqualError(t, err, `unknown severity "evaluable"`)
139+
require.Truef(t, re.RuleID%defaultDivisor == 0, "evaluating rule entry %d should not fail", re.ID)
140+
return true
141+
}
142+
options.OnFilterMatch = func(re *rule.Entry) error {
143+
require.Nilf(t, e.RuleEntries[re.ID], "OnPreEvaluate() shouldn't evaluate %d twice", re.ID)
144+
return nil
145+
}
146+
assertEntries(&expectedLen, false)
147+
148+
lenBeforeError := new(int)
149+
options.OnError = func(re *rule.Entry, err error) bool {
150+
if *lenBeforeError != 0 {
151+
require.Fail(t, "OnError() shouldn't have been called again")
152+
}
153+
154+
require.EqualError(t, err, `unknown severity "evaluable"`)
155+
require.Truef(t, re.RuleID%defaultDivisor == 0, "evaluating rule entry %d should not fail", re.ID)
156+
157+
*lenBeforeError = len(e.RuleEntries)
158+
return false // This should let the evaluation fail completely!
159+
}
160+
assertEntries(lenBeforeError, true)
161+
162+
*lenBeforeError = 0
163+
options.OnError = nil
164+
options.OnFilterMatch = func(re *rule.Entry) error {
165+
if *lenBeforeError != 0 {
166+
require.Fail(t, "OnFilterMatch() shouldn't have been called again")
167+
}
168+
169+
*lenBeforeError = len(e.RuleEntries)
170+
return fmt.Errorf("OnFilterMatch() failed badly") // This should let the evaluation fail completely!
171+
}
172+
assertEntries(lenBeforeError, true)
173+
174+
expectedLen = 0
175+
filterContext.IncidentSeverity = 1 // OK
176+
filterContext.IncidentAge = 5 * time.Minute
177+
178+
options.OnFilterMatch = nil
179+
options.OnPreEvaluate = func(re *rule.Entry) bool { return re.RuleID < 5 }
180+
options.OnAllConfigEvaluated = func(result any) {
181+
retryAfter := result.(time.Duration)
182+
// The filter string of the escalation condition is incident_age>=10m and the actual incident age is 5m.
183+
require.Equal(t, 5*time.Minute, retryAfter)
184+
}
185+
assertEntries(&expectedLen, false)
186+
})
187+
}
188+
189+
func makeRule(t *testing.T, i int) *rule.Rule {
190+
r := new(rule.Rule)
191+
r.ID = int64(i)
192+
r.Name = fmt.Sprintf("rule-%d", i)
193+
r.Entries = make(map[int64]*rule.Entry)
194+
195+
invalidSeverity, err := filter.Parse("incident_severity=evaluable")
196+
require.NoError(t, err, "parsing incident_severity=evaluable shouldn't fail")
197+
198+
redundant := new(rule.Entry)
199+
redundant.ID = rand.Int64()
200+
redundant.RuleID = r.ID
201+
redundant.Condition = invalidSeverity
202+
203+
nonexistent, err := filter.Parse("nonexistent=evaluable")
204+
require.NoError(t, err, "parsing nonexistent=evaluable shouldn't fail")
205+
206+
r.Entries[redundant.ID] = redundant
207+
r.ObjectFilter = nonexistent
208+
if i%defaultDivisor == 0 {
209+
objCond, err := filter.Parse("host=evaluable")
210+
require.NoError(t, err, "parsing host=evaluable shouldn't fail")
211+
212+
escalationCond, err := filter.Parse("incident_severity>warning||incident_age>=10m")
213+
require.NoError(t, err, "parsing incident_severity>=ok shouldn't fail")
214+
215+
entry := new(rule.Entry)
216+
entry.ID = r.ID * 2
217+
entry.RuleID = r.ID
218+
entry.Condition = escalationCond
219+
220+
r.ObjectFilter = objCond
221+
r.Entries[entry.ID] = entry
222+
}
223+
224+
return r
225+
}
226+
227+
// filterableTest is a test type that simulates a filter evaluation and eliminates
228+
// the need of having to import e.g. the object package.
229+
type filterableTest struct{}
230+
231+
func (f *filterableTest) EvalEqual(k string, v string) (bool, error) {
232+
if k != "host" {
233+
return false, fmt.Errorf("%q is not a valid filter key", k)
234+
}
235+
236+
return v == "evaluable", nil
237+
}
238+
239+
func (f *filterableTest) EvalExists(_ string) bool { return true }
240+
func (f *filterableTest) EvalLess(_ string, _ string) (bool, error) {
241+
panic("Oh dear - you shouldn't have called me")
242+
}
243+
func (f *filterableTest) EvalLike(_, _ string) (bool, error) { return f.EvalLess("", "") }
244+
func (f *filterableTest) EvalLessOrEqual(_, _ string) (bool, error) { return f.EvalLess("", "") }

0 commit comments

Comments
 (0)