]> git.proxmox.com Git - proxmox.git/blob - proxmox-notify/src/matcher.rs
notify: matcher: support lists of values for 'exact' match-field mode
[proxmox.git] / proxmox-notify / src / matcher.rs
1 use regex::Regex;
2 use std::collections::HashSet;
3 use std::fmt;
4 use std::fmt::Debug;
5 use std::str::FromStr;
6
7 use serde::{Deserialize, Serialize};
8
9 use proxmox_schema::api_types::COMMENT_SCHEMA;
10 use proxmox_schema::{
11 api, const_regex, ApiStringFormat, Schema, StringSchema, Updater, SAFE_ID_REGEX_STR,
12 };
13 use proxmox_time::{parse_daily_duration, DailyDuration};
14
15 use crate::schema::ENTITY_NAME_SCHEMA;
16 use crate::{Error, Notification, Origin, Severity};
17
18 pub const MATCHER_TYPENAME: &str = "matcher";
19
20 #[api]
21 #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)]
22 #[serde(rename_all = "kebab-case")]
23 pub enum MatchModeOperator {
24 /// All match statements have to match (AND)
25 #[default]
26 All,
27 /// At least one filter property has to match (OR)
28 Any,
29 }
30
31 impl MatchModeOperator {
32 /// Apply the mode operator to two bools, lhs and rhs
33 fn apply(&self, lhs: bool, rhs: bool) -> bool {
34 match self {
35 MatchModeOperator::All => lhs && rhs,
36 MatchModeOperator::Any => lhs || rhs,
37 }
38 }
39
40 // https://en.wikipedia.org/wiki/Identity_element
41 fn neutral_element(&self) -> bool {
42 match self {
43 MatchModeOperator::All => true,
44 MatchModeOperator::Any => false,
45 }
46 }
47 }
48
49 const_regex! {
50 pub MATCH_FIELD_ENTRY_REGEX = concat!(r"^(?:(exact|regex):)?(", SAFE_ID_REGEX_STR!(), r")=(.*)$");
51 }
52
53 pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat =
54 ApiStringFormat::VerifyFn(verify_field_matcher);
55
56 fn verify_field_matcher(s: &str) -> Result<(), anyhow::Error> {
57 let _: FieldMatcher = s.parse()?;
58 Ok(())
59 }
60
61 pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata field.")
62 .format(&MATCH_FIELD_ENTRY_FORMAT)
63 .min_length(1)
64 .max_length(1024)
65 .schema();
66
67 #[api(
68 properties: {
69 name: {
70 schema: ENTITY_NAME_SCHEMA,
71 },
72 comment: {
73 optional: true,
74 schema: COMMENT_SCHEMA,
75 },
76 "match-field": {
77 type: Array,
78 items: {
79 description: "Fields to match",
80 type: String
81 },
82 optional: true,
83 },
84 "match-severity": {
85 type: Array,
86 items: {
87 description: "Severity level to match.",
88 type: String
89 },
90 optional: true,
91 },
92 "match-calendar": {
93 type: Array,
94 items: {
95 description: "Time stamps to match",
96 type: String
97 },
98 optional: true,
99 },
100 "target": {
101 type: Array,
102 items: {
103 schema: ENTITY_NAME_SCHEMA,
104 },
105 optional: true,
106 },
107 })]
108 #[derive(Debug, Serialize, Deserialize, Updater, Default)]
109 #[serde(rename_all = "kebab-case")]
110 /// Config for Sendmail notification endpoints
111 pub struct MatcherConfig {
112 /// Name of the matcher
113 #[updater(skip)]
114 pub name: String,
115
116 /// List of matched metadata fields
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub match_field: Option<Vec<FieldMatcher>>,
119
120 /// List of matched severity levels
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub match_severity: Option<Vec<SeverityMatcher>>,
123
124 /// List of matched severity levels
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub match_calendar: Option<Vec<CalendarMatcher>>,
127
128 /// Decide if 'all' or 'any' match statements must match
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub mode: Option<MatchModeOperator>,
131
132 /// Invert match of the whole filter
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub invert_match: Option<bool>,
135
136 /// Targets to notify
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub target: Option<Vec<String>>,
139
140 /// Comment
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub comment: Option<String>,
143
144 /// Disable this matcher
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub disable: Option<bool>,
147
148 /// Origin of this config entry.
149 #[serde(skip_serializing_if = "Option::is_none")]
150 #[updater(skip)]
151 pub origin: Option<Origin>,
152 }
153
154 trait MatchDirective {
155 fn matches(&self, notification: &Notification) -> Result<bool, Error>;
156 }
157
158 /// Check if the notification metadata fields match
159 #[derive(Clone, Debug)]
160 pub enum FieldMatcher {
161 Exact {
162 field: String,
163 matched_values: Vec<String>,
164 },
165 Regex {
166 field: String,
167 matched_regex: Regex,
168 },
169 }
170
171 proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher);
172 proxmox_serde::forward_serialize_to_display!(FieldMatcher);
173
174 impl MatchDirective for FieldMatcher {
175 fn matches(&self, notification: &Notification) -> Result<bool, Error> {
176 Ok(match self {
177 FieldMatcher::Exact {
178 field,
179 matched_values,
180 } => {
181 let value = notification.metadata.additional_fields.get(field);
182
183 if let Some(value) = value {
184 matched_values.contains(value)
185 } else {
186 // Metadata field does not exist, so we do not match
187 false
188 }
189 }
190 FieldMatcher::Regex {
191 field,
192 matched_regex,
193 } => {
194 let value = notification.metadata.additional_fields.get(field);
195
196 if let Some(value) = value {
197 matched_regex.is_match(value)
198 } else {
199 // Metadata field does not exist, so we do not match
200 false
201 }
202 }
203 })
204 }
205 }
206
207 impl fmt::Display for FieldMatcher {
208 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
209 // Attention, Display is used to implement Serialize, do not
210 // change the format.
211
212 match self {
213 FieldMatcher::Exact {
214 field,
215 matched_values,
216 } => {
217 let values = matched_values.join(",");
218 write!(f, "exact:{field}={values}")
219 }
220 FieldMatcher::Regex {
221 field,
222 matched_regex,
223 } => {
224 let re = matched_regex.as_str();
225 write!(f, "regex:{field}={re}")
226 }
227 }
228 }
229 }
230
231 impl FromStr for FieldMatcher {
232 type Err = Error;
233 fn from_str(s: &str) -> Result<Self, Error> {
234 if !MATCH_FIELD_ENTRY_REGEX.is_match(s) {
235 return Err(Error::FilterFailed(format!(
236 "invalid match-field statement: {s}"
237 )));
238 }
239
240 if let Some(remaining) = s.strip_prefix("regex:") {
241 match remaining.split_once('=') {
242 None => Err(Error::FilterFailed(format!(
243 "invalid match-field statement: {s}"
244 ))),
245 Some((field, expected_value_regex)) => {
246 let regex = Regex::new(expected_value_regex)
247 .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?;
248
249 Ok(Self::Regex {
250 field: field.into(),
251 matched_regex: regex,
252 })
253 }
254 }
255 } else if let Some(remaining) = s.strip_prefix("exact:") {
256 match remaining.split_once('=') {
257 None => Err(Error::FilterFailed(format!(
258 "invalid match-field statement: {s}"
259 ))),
260 Some((field, expected_values)) => {
261 let values: Vec<String> = expected_values
262 .split(',')
263 .map(str::trim)
264 .map(String::from)
265 .collect();
266 Ok(Self::Exact {
267 field: field.into(),
268 matched_values: values,
269 })
270 }
271 }
272 } else {
273 Err(Error::FilterFailed(format!(
274 "invalid match-field statement: {s}"
275 )))
276 }
277 }
278 }
279
280 impl MatcherConfig {
281 pub fn matches(&self, notification: &Notification) -> Result<Option<&[String]>, Error> {
282 let mode = self.mode.unwrap_or_default();
283
284 let mut is_match = mode.neutral_element();
285 // If there are no matching directives, the matcher will always match
286 let mut no_matchers = true;
287
288 if let Some(severity_matchers) = self.match_severity.as_deref() {
289 no_matchers = false;
290 is_match = mode.apply(
291 is_match,
292 self.check_matches(notification, severity_matchers)?,
293 );
294 }
295 if let Some(field_matchers) = self.match_field.as_deref() {
296 no_matchers = false;
297 is_match = mode.apply(is_match, self.check_matches(notification, field_matchers)?);
298 }
299 if let Some(calendar_matchers) = self.match_calendar.as_deref() {
300 no_matchers = false;
301 is_match = mode.apply(
302 is_match,
303 self.check_matches(notification, calendar_matchers)?,
304 );
305 }
306
307 let invert_match = self.invert_match.unwrap_or_default();
308
309 Ok(if is_match != invert_match || no_matchers {
310 Some(self.target.as_deref().unwrap_or_default())
311 } else {
312 None
313 })
314 }
315
316 /// Check if given `MatchDirectives` match a notification.
317 fn check_matches(
318 &self,
319 notification: &Notification,
320 matchers: &[impl MatchDirective],
321 ) -> Result<bool, Error> {
322 let mode = self.mode.unwrap_or_default();
323 let mut is_match = mode.neutral_element();
324
325 for field_matcher in matchers {
326 is_match = mode.apply(is_match, field_matcher.matches(notification)?);
327 }
328
329 Ok(is_match)
330 }
331 }
332
333 /// Match severity of the notification.
334 #[derive(Clone, Debug)]
335 pub struct SeverityMatcher {
336 severities: Vec<Severity>,
337 }
338
339 proxmox_serde::forward_deserialize_to_from_str!(SeverityMatcher);
340 proxmox_serde::forward_serialize_to_display!(SeverityMatcher);
341
342 /// Common trait implemented by all matching directives
343 impl MatchDirective for SeverityMatcher {
344 /// Check if this directive matches a given notification
345 fn matches(&self, notification: &Notification) -> Result<bool, Error> {
346 Ok(self.severities.contains(&notification.metadata.severity))
347 }
348 }
349
350 impl fmt::Display for SeverityMatcher {
351 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
352 let severities: Vec<String> = self.severities.iter().map(|s| format!("{s}")).collect();
353 f.write_str(&severities.join(","))
354 }
355 }
356
357 impl FromStr for SeverityMatcher {
358 type Err = Error;
359 fn from_str(s: &str) -> Result<Self, Error> {
360 let mut severities = Vec::new();
361
362 for element in s.split(',') {
363 let element = element.trim();
364 let severity: Severity = element.parse()?;
365
366 severities.push(severity)
367 }
368
369 Ok(Self { severities })
370 }
371 }
372
373 /// Match timestamp of the notification.
374 #[derive(Clone, Debug)]
375 pub struct CalendarMatcher {
376 schedule: DailyDuration,
377 original: String,
378 }
379
380 proxmox_serde::forward_deserialize_to_from_str!(CalendarMatcher);
381 proxmox_serde::forward_serialize_to_display!(CalendarMatcher);
382
383 impl MatchDirective for CalendarMatcher {
384 fn matches(&self, notification: &Notification) -> Result<bool, Error> {
385 self.schedule
386 .time_match(notification.metadata.timestamp, false)
387 .map_err(|err| Error::Generic(format!("could not match timestamp: {err}")))
388 }
389 }
390
391 impl fmt::Display for CalendarMatcher {
392 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
393 f.write_str(&self.original)
394 }
395 }
396
397 impl FromStr for CalendarMatcher {
398 type Err = Error;
399 fn from_str(s: &str) -> Result<Self, Error> {
400 let schedule = parse_daily_duration(s)
401 .map_err(|e| Error::Generic(format!("could not parse schedule: {e}")))?;
402
403 Ok(Self {
404 schedule,
405 original: s.to_string(),
406 })
407 }
408 }
409
410 #[derive(Serialize, Deserialize)]
411 #[serde(rename_all = "kebab-case")]
412 pub enum DeleteableMatcherProperty {
413 Comment,
414 Disable,
415 InvertMatch,
416 MatchCalendar,
417 MatchField,
418 MatchSeverity,
419 Mode,
420 Target,
421 }
422
423 pub fn check_matches<'a>(
424 matchers: &'a [MatcherConfig],
425 notification: &Notification,
426 ) -> HashSet<&'a str> {
427 let mut targets = HashSet::new();
428
429 for matcher in matchers {
430 if matcher.disable.unwrap_or_default() {
431 // Skip this matcher if it is disabled
432 log::info!("skipping disabled matcher '{name}'", name = matcher.name);
433 continue;
434 }
435
436 match matcher.matches(notification) {
437 Ok(t) => {
438 let t = t.unwrap_or_default();
439 targets.extend(t.iter().map(|s| s.as_str()));
440 }
441 Err(err) => log::error!("matcher '{matcher}' failed: {err}", matcher = matcher.name),
442 }
443 }
444
445 targets
446 }
447
448 #[cfg(test)]
449 mod tests {
450 use super::*;
451 use serde_json::Value;
452 use std::collections::HashMap;
453
454 #[test]
455 fn test_matching() {
456 let mut fields = HashMap::new();
457 fields.insert("foo".into(), "bar".into());
458
459 let notification =
460 Notification::new_templated(Severity::Notice, "test", "test", Value::Null, fields);
461
462 let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap();
463 assert!(matcher.matches(&notification).unwrap());
464
465 let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap();
466 assert!(matcher.matches(&notification).unwrap());
467
468 let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap();
469 assert!(!matcher.matches(&notification).unwrap());
470
471 let matcher: FieldMatcher = "exact:foo=bar,test".parse().unwrap();
472 assert!(matcher.matches(&notification).unwrap());
473
474 let mut fields = HashMap::new();
475 fields.insert("foo".into(), "test".into());
476
477 let notification =
478 Notification::new_templated(Severity::Notice, "test", "test", Value::Null, fields);
479 assert!(matcher.matches(&notification).unwrap());
480
481 let mut fields = HashMap::new();
482 fields.insert("foo".into(), "notthere".into());
483
484 let notification =
485 Notification::new_templated(Severity::Notice, "test", "test", Value::Null, fields);
486 assert!(!matcher.matches(&notification).unwrap());
487
488 assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
489 assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err());
490 }
491 #[test]
492 fn test_severities() {
493 let notification = Notification::new_templated(
494 Severity::Notice,
495 "test",
496 "test",
497 Value::Null,
498 Default::default(),
499 );
500
501 let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
502 assert!(matcher.matches(&notification).unwrap());
503 }
504
505 #[test]
506 fn test_empty_matcher_matches_always() {
507 let notification = Notification::new_templated(
508 Severity::Notice,
509 "test",
510 "test",
511 Value::Null,
512 Default::default(),
513 );
514
515 for mode in [MatchModeOperator::All, MatchModeOperator::Any] {
516 let config = MatcherConfig {
517 name: "matcher".to_string(),
518 mode: Some(mode),
519 ..Default::default()
520 };
521
522 assert!(config.matches(&notification).unwrap().is_some())
523 }
524 }
525 }