]> git.proxmox.com Git - proxmox.git/blame - proxmox-notify/src/matcher.rs
notify: add 'disable' parameter for matchers and targets.
[proxmox.git] / proxmox-notify / src / matcher.rs
CommitLineData
b421a7ca
LW
1use regex::Regex;
2use std::collections::HashSet;
3use std::fmt;
4use std::fmt::Debug;
5use std::str::FromStr;
6
7use serde::{Deserialize, Serialize};
8
9use proxmox_schema::api_types::COMMENT_SCHEMA;
10use proxmox_schema::{
11 api, const_regex, ApiStringFormat, Schema, StringSchema, Updater, SAFE_ID_REGEX_STR,
12};
bdbd55cc 13use proxmox_time::{parse_daily_duration, DailyDuration};
b421a7ca
LW
14
15use crate::schema::ENTITY_NAME_SCHEMA;
16use crate::{Error, Notification, Severity};
17
18pub const MATCHER_TYPENAME: &str = "matcher";
19
20#[api]
21#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)]
22#[serde(rename_all = "kebab-case")]
23pub 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
31impl 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
49const_regex! {
50 pub MATCH_FIELD_ENTRY_REGEX = concat!(r"^(?:(exact|regex):)?(", SAFE_ID_REGEX_STR!(), r")=(.*)$");
51}
52
53pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat =
54 ApiStringFormat::VerifyFn(verify_field_matcher);
55
56fn verify_field_matcher(s: &str) -> Result<(), anyhow::Error> {
57 let _: FieldMatcher = s.parse()?;
58 Ok(())
59}
60
61pub 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 },
bdbd55cc
LW
92 "match-calendar": {
93 type: Array,
94 items: {
95 description: "Time stamps to match",
96 type: String
97 },
98 optional: true,
99 },
b421a7ca
LW
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
111pub 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
bdbd55cc
LW
124 /// List of matched severity levels
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub match_calendar: Option<Vec<CalendarMatcher>>,
127
b421a7ca
LW
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>,
306f4005
LW
143
144 /// Disable this matcher
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub disable: Option<bool>,
b421a7ca
LW
147}
148
190d483b
LW
149trait MatchDirective {
150 fn matches(&self, notification: &Notification) -> Result<bool, Error>;
151}
152
153/// Check if the notification metadata fields match
b421a7ca
LW
154#[derive(Clone, Debug)]
155pub enum FieldMatcher {
156 Exact {
157 field: String,
158 matched_value: String,
159 },
160 Regex {
161 field: String,
162 matched_regex: Regex,
163 },
164}
165
166proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher);
167proxmox_serde::forward_serialize_to_display!(FieldMatcher);
168
190d483b
LW
169impl MatchDirective for FieldMatcher {
170 fn matches(&self, notification: &Notification) -> Result<bool, Error> {
171 Ok(match self {
b421a7ca
LW
172 FieldMatcher::Exact {
173 field,
174 matched_value,
175 } => {
176 let value = notification.metadata.additional_fields.get(field);
177
178 if let Some(value) = value {
179 matched_value == value
180 } else {
181 // Metadata field does not exist, so we do not match
182 false
183 }
184 }
185 FieldMatcher::Regex {
186 field,
187 matched_regex,
188 } => {
189 let value = notification.metadata.additional_fields.get(field);
190
191 if let Some(value) = value {
192 matched_regex.is_match(value)
193 } else {
194 // Metadata field does not exist, so we do not match
195 false
196 }
197 }
190d483b 198 })
b421a7ca
LW
199 }
200}
201
202impl fmt::Display for FieldMatcher {
203 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
204 // Attention, Display is used to implement Serialize, do not
205 // change the format.
206
207 match self {
208 FieldMatcher::Exact {
209 field,
210 matched_value,
211 } => {
212 write!(f, "exact:{field}={matched_value}")
213 }
214 FieldMatcher::Regex {
215 field,
216 matched_regex,
217 } => {
218 let re = matched_regex.as_str();
219 write!(f, "regex:{field}={re}")
220 }
221 }
222 }
223}
224
225impl FromStr for FieldMatcher {
226 type Err = Error;
227 fn from_str(s: &str) -> Result<Self, Error> {
228 if !MATCH_FIELD_ENTRY_REGEX.is_match(s) {
229 return Err(Error::FilterFailed(format!(
230 "invalid match-field statement: {s}"
231 )));
232 }
233
234 if let Some(remaining) = s.strip_prefix("regex:") {
235 match remaining.split_once('=') {
236 None => Err(Error::FilterFailed(format!(
237 "invalid match-field statement: {s}"
238 ))),
239 Some((field, expected_value_regex)) => {
240 let regex = Regex::new(expected_value_regex)
241 .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?;
242
243 Ok(Self::Regex {
244 field: field.into(),
245 matched_regex: regex,
246 })
247 }
248 }
249 } else if let Some(remaining) = s.strip_prefix("exact:") {
250 match remaining.split_once('=') {
251 None => Err(Error::FilterFailed(format!(
252 "invalid match-field statement: {s}"
253 ))),
254 Some((field, expected_value)) => Ok(Self::Exact {
255 field: field.into(),
256 matched_value: expected_value.into(),
257 }),
258 }
259 } else {
260 Err(Error::FilterFailed(format!(
261 "invalid match-field statement: {s}"
262 )))
263 }
264 }
265}
266
267impl MatcherConfig {
268 pub fn matches(&self, notification: &Notification) -> Result<Option<&[String]>, Error> {
269 let mode = self.mode.unwrap_or_default();
270
271 let mut is_match = mode.neutral_element();
80c90693
LW
272 // If there are no matching directives, the matcher will always match
273 let mut no_matchers = true;
190d483b
LW
274
275 if let Some(severity_matchers) = self.match_severity.as_deref() {
80c90693 276 no_matchers = false;
190d483b
LW
277 is_match = mode.apply(
278 is_match,
279 self.check_matches(notification, severity_matchers)?,
280 );
281 }
282 if let Some(field_matchers) = self.match_field.as_deref() {
80c90693 283 no_matchers = false;
190d483b
LW
284 is_match = mode.apply(is_match, self.check_matches(notification, field_matchers)?);
285 }
286 if let Some(calendar_matchers) = self.match_calendar.as_deref() {
80c90693 287 no_matchers = false;
190d483b
LW
288 is_match = mode.apply(
289 is_match,
290 self.check_matches(notification, calendar_matchers)?,
291 );
292 }
b421a7ca
LW
293
294 let invert_match = self.invert_match.unwrap_or_default();
295
80c90693 296 Ok(if is_match != invert_match || no_matchers {
b421a7ca
LW
297 Some(self.target.as_deref().unwrap_or_default())
298 } else {
299 None
300 })
301 }
302
190d483b
LW
303 /// Check if given `MatchDirectives` match a notification.
304 fn check_matches(
305 &self,
306 notification: &Notification,
307 matchers: &[impl MatchDirective],
308 ) -> Result<bool, Error> {
b421a7ca
LW
309 let mode = self.mode.unwrap_or_default();
310 let mut is_match = mode.neutral_element();
311
190d483b
LW
312 for field_matcher in matchers {
313 is_match = mode.apply(is_match, field_matcher.matches(notification)?);
bdbd55cc
LW
314 }
315
316 Ok(is_match)
317 }
b421a7ca 318}
190d483b
LW
319
320/// Match severity of the notification.
b421a7ca
LW
321#[derive(Clone, Debug)]
322pub struct SeverityMatcher {
323 severities: Vec<Severity>,
324}
325
326proxmox_serde::forward_deserialize_to_from_str!(SeverityMatcher);
327proxmox_serde::forward_serialize_to_display!(SeverityMatcher);
328
190d483b
LW
329/// Common trait implemented by all matching directives
330impl MatchDirective for SeverityMatcher {
331 /// Check if this directive matches a given notification
332 fn matches(&self, notification: &Notification) -> Result<bool, Error> {
333 Ok(self.severities.contains(&notification.metadata.severity))
b421a7ca
LW
334 }
335}
336
337impl fmt::Display for SeverityMatcher {
338 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
339 let severities: Vec<String> = self.severities.iter().map(|s| format!("{s}")).collect();
340 f.write_str(&severities.join(","))
341 }
342}
343
344impl FromStr for SeverityMatcher {
345 type Err = Error;
346 fn from_str(s: &str) -> Result<Self, Error> {
347 let mut severities = Vec::new();
348
349 for element in s.split(',') {
350 let element = element.trim();
351 let severity: Severity = element.parse()?;
352
353 severities.push(severity)
354 }
355
356 Ok(Self { severities })
357 }
358}
359
bdbd55cc
LW
360/// Match timestamp of the notification.
361#[derive(Clone, Debug)]
362pub struct CalendarMatcher {
363 schedule: DailyDuration,
364 original: String,
365}
366
367proxmox_serde::forward_deserialize_to_from_str!(CalendarMatcher);
368proxmox_serde::forward_serialize_to_display!(CalendarMatcher);
369
190d483b 370impl MatchDirective for CalendarMatcher {
bdbd55cc
LW
371 fn matches(&self, notification: &Notification) -> Result<bool, Error> {
372 self.schedule
373 .time_match(notification.metadata.timestamp, false)
374 .map_err(|err| Error::Generic(format!("could not match timestamp: {err}")))
375 }
376}
377
378impl fmt::Display for CalendarMatcher {
379 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
380 f.write_str(&self.original)
381 }
382}
383
384impl FromStr for CalendarMatcher {
385 type Err = Error;
386 fn from_str(s: &str) -> Result<Self, Error> {
387 let schedule = parse_daily_duration(s)
388 .map_err(|e| Error::Generic(format!("could not parse schedule: {e}")))?;
389
390 Ok(Self {
391 schedule,
392 original: s.to_string(),
393 })
394 }
395}
396
b421a7ca
LW
397#[derive(Serialize, Deserialize)]
398#[serde(rename_all = "kebab-case")]
399pub enum DeleteableMatcherProperty {
306f4005
LW
400 Comment,
401 Disable,
402 InvertMatch,
bdbd55cc 403 MatchCalendar,
306f4005
LW
404 MatchField,
405 MatchSeverity,
b421a7ca 406 Mode,
306f4005 407 Target,
b421a7ca
LW
408}
409
410pub fn check_matches<'a>(
411 matchers: &'a [MatcherConfig],
412 notification: &Notification,
413) -> HashSet<&'a str> {
414 let mut targets = HashSet::new();
415
416 for matcher in matchers {
306f4005
LW
417 if matcher.disable.unwrap_or_default() {
418 // Skip this matcher if it is disabled
419 log::info!("skipping disabled matcher '{name}'", name = matcher.name);
420 continue;
421 }
422
b421a7ca
LW
423 match matcher.matches(notification) {
424 Ok(t) => {
425 let t = t.unwrap_or_default();
426 targets.extend(t.iter().map(|s| s.as_str()));
427 }
428 Err(err) => log::error!("matcher '{matcher}' failed: {err}", matcher = matcher.name),
429 }
430 }
431
432 targets
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use serde_json::Value;
439 use std::collections::HashMap;
440
441 #[test]
442 fn test_matching() {
443 let mut fields = HashMap::new();
444 fields.insert("foo".into(), "bar".into());
445
446 let notification =
447 Notification::new_templated(Severity::Notice, "test", "test", Value::Null, fields);
448
449 let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap();
190d483b 450 assert!(matcher.matches(&notification).unwrap());
b421a7ca
LW
451
452 let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap();
190d483b 453 assert!(matcher.matches(&notification).unwrap());
b421a7ca
LW
454
455 let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap();
190d483b 456 assert!(!matcher.matches(&notification).unwrap());
b421a7ca
LW
457
458 assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
459 assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err());
460 }
461 #[test]
462 fn test_severities() {
463 let notification = Notification::new_templated(
464 Severity::Notice,
465 "test",
466 "test",
467 Value::Null,
468 Default::default(),
469 );
470
471 let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
190d483b 472 assert!(matcher.matches(&notification).unwrap());
b421a7ca 473 }
80c90693
LW
474
475 #[test]
476 fn test_empty_matcher_matches_always() {
477 let notification = Notification::new_templated(
478 Severity::Notice,
479 "test",
480 "test",
481 Value::Null,
482 Default::default(),
483 );
484
485 for mode in [MatchModeOperator::All, MatchModeOperator::Any] {
486 let config = MatcherConfig {
487 name: "matcher".to_string(),
488 mode: Some(mode),
489 ..Default::default()
490 };
491
492 assert!(config.matches(&notification).unwrap().is_some())
493 }
494 }
b421a7ca 495}