Skip to content

Commit 4d3bf72

Browse files
committed
Offload evaluable configs from Incident to a common place
1 parent 2b7f6dc commit 4d3bf72

File tree

3 files changed

+314
-80
lines changed

3 files changed

+314
-80
lines changed

internal/config/rule.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
import (
44
"fmt"
55
"github.com/icinga/icinga-notifications/internal/rule"
6+
"go.uber.org/zap"
67
"slices"
78
"time"
89
)
@@ -205,3 +206,107 @@ func (r *RuntimeConfig) applyPendingRules() {
205206
return nil
206207
})
207208
}
209+
210+
// EvalOptions specifies optional callbacks that are executed upon certain filter evaluation events.
211+
//
212+
// The EvalOptions type is used to configure the behaviour of the evaluation process when evaluating
213+
// filter expressions against a set of [rule.Escalation] entries. It allows you to hook into specific
214+
// events during the evaluation process and perform custom actions based on your requirements.
215+
type EvalOptions struct {
216+
// OnPreEvaluate can be used to perform some actions before evaluating the filter for the current entry.
217+
//
218+
// This callback receives the current [rule.Escalation] entry as an argument, which is about to be
219+
// evaluated. If this callback returns "false", the filter evaluation for the current entry is skipped,
220+
// and the evaluation continues with the next one. If it returns "true" or is nil, the filter evaluation
221+
// proceeds as normal.
222+
//
223+
// Note that if you skip the evaluation of an entry using this callback, the OnFilterMatch callback
224+
// will not be triggered for that entry, even if its filter would have matched on the filterable object.
225+
OnPreEvaluate func(*rule.Escalation) bool
226+
227+
// OnError is called when an error occurs during the filter evaluation.
228+
//
229+
// This callback receives the current [rule.Escalation] entry and the error that occurred as arguments.
230+
// By default, the evaluation continues even if some entries fail, but you can override this behaviour
231+
// by returning "false" in your handler, which aborts the evaluation prematurely. If you return "true"
232+
// or if this callback is nil, the evaluation continues with the remaining entries.
233+
//
234+
// Note that if you choose to abort the evaluation by returning "false", the OnAllConfigEvaluated callback
235+
// will not be triggered, as the evaluation did not complete successfully.
236+
OnError func(*rule.Escalation, error) bool
237+
238+
// OnFilterMatch is called when the filter for an entry matches successfully.
239+
//
240+
// This callback receives the current [rule.Escalation] entry as an argument. If this callback returns
241+
// an error, the evaluation is aborted prematurely, and the error is returned. Otherwise, the evaluation
242+
// continues with the remaining entries.
243+
//
244+
// Note that if you return an error from this callback, the OnAllConfigEvaluated callback will not be triggered,
245+
// as the evaluation did not complete successfully.
246+
OnFilterMatch func(*rule.Escalation) error
247+
248+
// OnAllConfigEvaluated is called after all configured entries have been evaluated.
249+
//
250+
// This callback receives a value of type [time.Duration] derived from the evaluation process as an argument.
251+
// This callback is guaranteed to be called if none of the individual evaluation callbacks return prematurely
252+
// with an error. If any of the callbacks return prematurely, this callback will not be triggered.
253+
//
254+
// The [time.Duration] argument can be used to indicate a duration after which a re-evaluation might be necessary,
255+
// based on the evaluation results. This is optional and can be ignored if not needed.
256+
OnAllConfigEvaluated func(time.Duration)
257+
}
258+
259+
// RuleEntries is a map of rule.Escalation entries, keyed by their ID.
260+
//
261+
// This type is used to store the results of evaluating rule.Escalation entries against a filterable object.
262+
// It allows for efficient lookups and ensures that each entry is unique based on its ID.
263+
type RuleEntries map[int64]*rule.Escalation
264+
265+
// Evaluate evaluates the rule.Escalation entries against the provided filterable object.
266+
//
267+
// Depending on the provided EvalOptions, various callbacks may be triggered during the evaluation process.
268+
// The results of the evaluation are stored in the RuleEntries map, with entries that match the filter
269+
// being added to the map.
270+
//
271+
// If an error occurs during the evaluation of an entry, the OnError callback is triggered (if provided).
272+
// If this callback returns "false", the evaluation is aborted prematurely, and the error is returned.
273+
// Otherwise, the evaluation continues with the remaining entries.
274+
func (re RuleEntries) Evaluate(res Resources, filterable *rule.EscalationFilter, rules map[int64]struct{}, opts EvalOptions) error {
275+
retryAfter := rule.RetryNever
276+
277+
for ruleID := range rules {
278+
r := res.RuntimeConfig.Rules[ruleID]
279+
if r == nil {
280+
res.Logger.Debugw("Referenced rule does not exist", zap.Int64("rule_id", ruleID))
281+
continue
282+
}
283+
284+
for _, entry := range r.Escalations {
285+
if opts.OnPreEvaluate != nil && !opts.OnPreEvaluate(entry) {
286+
continue
287+
}
288+
289+
if matched, err := entry.Eval(filterable); err != nil {
290+
if opts.OnError != nil && !opts.OnError(entry, err) {
291+
return err
292+
}
293+
} else if !matched {
294+
incidentAgeFilter := filterable.ReevaluateAfter(entry.Condition)
295+
retryAfter = min(retryAfter, incidentAgeFilter)
296+
} else {
297+
if opts.OnFilterMatch != nil {
298+
if err := opts.OnFilterMatch(entry); err != nil {
299+
return err
300+
}
301+
}
302+
re[entry.ID] = entry
303+
}
304+
}
305+
}
306+
307+
if opts.OnAllConfigEvaluated != nil {
308+
opts.OnAllConfigEvaluated(retryAfter)
309+
}
310+
311+
return nil
312+
}

internal/config/rule_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"maps"
6+
"testing"
7+
"time"
8+
9+
"github.com/icinga/icinga-go-library/logging"
10+
"github.com/icinga/icinga-go-library/notifications/event"
11+
"github.com/icinga/icinga-notifications/internal/filter"
12+
"github.com/icinga/icinga-notifications/internal/rule"
13+
"github.com/icinga/icinga-notifications/internal/testutils"
14+
"github.com/stretchr/testify/require"
15+
"go.uber.org/zap"
16+
)
17+
18+
const defaultDivisor = 3 // Every third rule gets a valid escalation condition.
19+
20+
func TestRuleEntries(t *testing.T) {
21+
t.Parallel()
22+
23+
logs := logging.NewLoggingWithFactory("rule-entries-test", zap.DebugLevel, time.Hour, testutils.NewTestLoggerFactory(t))
24+
runtimeConfig := NewRuntimeConfig(logs, nil)
25+
runtimeConfig.Rules = make(map[int64]*rule.Rule)
26+
for i := 1; i <= 50; i++ {
27+
runtimeConfig.Rules[int64(i)] = makeRule(t, i)
28+
}
29+
30+
t.Run("Evaluate", func(t *testing.T) {
31+
t.Parallel()
32+
33+
res := MakeResources(runtimeConfig, "test-evaluate-rule-entries")
34+
ruleEntries := make(RuleEntries)
35+
36+
expectedLen := 0
37+
filterContext := &rule.EscalationFilter{IncidentSeverity: event.SeverityEmerg}
38+
assertEntries := func(rules map[int64]struct{}, expectedLen *int, expectError bool, opts EvalOptions) {
39+
if expectError {
40+
require.Error(t, ruleEntries.Evaluate(res, filterContext, rules, opts))
41+
} else {
42+
require.NoError(t, ruleEntries.Evaluate(res, filterContext, rules, opts))
43+
}
44+
require.Len(t, ruleEntries, *expectedLen)
45+
clear(ruleEntries) // Clear the entries for the next run.
46+
}
47+
expectedLen = len(runtimeConfig.Rules)/defaultDivisor - 5 // 15/3 => (5) valid entries are going to be deleted below.
48+
49+
// Drop some random rules from the runtime config to simulate a runtime config deletion!
50+
maps.DeleteFunc(runtimeConfig.Rules, func(ruleID int64, _ *rule.Rule) bool { return ruleID > 35 && ruleID%defaultDivisor == 0 })
51+
52+
opts := EvalOptions{
53+
OnPreEvaluate: func(re *rule.Escalation) bool {
54+
if re.RuleID > 35 && re.RuleID%defaultDivisor == 0 { // Those rules are deleted from our runtime config.
55+
require.Failf(t, "OnPreEvaluate() shouldn't have been called", "rule %d was deleted from runtime config", re.RuleID)
56+
}
57+
require.Nilf(t, ruleEntries[re.ID], "Evaluate() shouldn't evaluate entry %d twice", re.ID)
58+
return true
59+
},
60+
OnError: func(re *rule.Escalation, err error) bool {
61+
require.EqualError(t, err, `unknown severity "evaluable"`)
62+
return true
63+
},
64+
OnFilterMatch: func(re *rule.Escalation) error {
65+
require.Nilf(t, ruleEntries[re.ID], "OnPreEvaluate() shouldn't evaluate %d twice", re.ID)
66+
return nil
67+
},
68+
}
69+
70+
rules := make(map[int64]struct{}, len(runtimeConfig.Rules))
71+
for id := range runtimeConfig.Rules {
72+
rules[id] = struct{}{}
73+
}
74+
assertEntries(rules, &expectedLen, false, opts)
75+
76+
lenBeforeError := new(int)
77+
opts.OnError = func(re *rule.Escalation, err error) bool {
78+
if *lenBeforeError != 0 {
79+
require.Fail(t, "OnError() shouldn't have been called again")
80+
}
81+
require.EqualError(t, err, `unknown severity "evaluable"`)
82+
83+
*lenBeforeError = len(ruleEntries)
84+
return false // This should let the evaluation fail completely!
85+
}
86+
assertEntries(rules, lenBeforeError, true, opts)
87+
88+
*lenBeforeError = 0
89+
opts.OnError = nil
90+
opts.OnFilterMatch = func(re *rule.Escalation) error {
91+
if *lenBeforeError != 0 {
92+
require.Fail(t, "OnFilterMatch() shouldn't have been called again")
93+
}
94+
*lenBeforeError = len(ruleEntries)
95+
return fmt.Errorf("OnFilterMatch() failed badly") // This should let the evaluation fail completely!
96+
}
97+
assertEntries(rules, lenBeforeError, true, opts)
98+
99+
expectedLen = 0
100+
filterContext.IncidentSeverity = event.SeverityOK
101+
filterContext.IncidentAge = 5 * time.Minute
102+
103+
opts.OnFilterMatch = nil
104+
opts.OnPreEvaluate = func(re *rule.Escalation) bool { return re.RuleID < 5 }
105+
opts.OnAllConfigEvaluated = func(result time.Duration) {
106+
// The filter string of the escalation condition is incident_age>=10m and the actual incident age is 5m.
107+
require.Equal(t, 5*time.Minute, result)
108+
}
109+
assertEntries(rules, &expectedLen, false, opts)
110+
})
111+
}
112+
113+
// makeRule creates a rule with some escalation entries.
114+
//
115+
// Every rule gets one invalid escalation condition that always fails to evaluate.
116+
// Additionally, every third (defaultDivisor) rule gets a valid escalation condition that matches
117+
// on `incident_severity>warning||incident_age>=10m` to simulate some real-world conditions.
118+
func makeRule(t *testing.T, i int) *rule.Rule {
119+
r := new(rule.Rule)
120+
r.ID = int64(i)
121+
r.Name = fmt.Sprintf("rule-%d", i)
122+
r.Escalations = make(map[int64]*rule.Escalation)
123+
124+
invalidSeverity, err := filter.Parse("incident_severity=evaluable")
125+
require.NoError(t, err, "parsing incident_severity=evaluable shouldn't fail")
126+
127+
redundant := new(rule.Escalation)
128+
redundant.ID = r.ID * 150 // It must be large enough to avoid colliding with others!
129+
redundant.RuleID = r.ID
130+
redundant.Condition = invalidSeverity
131+
132+
r.Escalations[redundant.ID] = redundant
133+
if i%defaultDivisor == 0 {
134+
escalationCond, err := filter.Parse("incident_severity>warning||incident_age>=10m")
135+
require.NoError(t, err, "parsing incident_severity>warning||incident_age>=10m shouldn't fail")
136+
137+
entry := new(rule.Escalation)
138+
entry.ID = r.ID * 2
139+
entry.RuleID = r.ID
140+
entry.Condition = escalationCond
141+
142+
r.Escalations[entry.ID] = entry
143+
}
144+
145+
return r
146+
}

0 commit comments

Comments
 (0)