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