]> git.proxmox.com Git - proxmox.git/blob - proxmox-notify/src/matcher.rs
b03d11d1e98e0486fa1e042aea09358e1a0aedbc
[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, 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
145 #[derive(Clone, Debug)]
146 pub enum FieldMatcher {
147 Exact {
148 field: String,
149 matched_value: String,
150 },
151 Regex {
152 field: String,
153 matched_regex: Regex,
154 },
155 }
156
157 proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher);
158 proxmox_serde::forward_serialize_to_display!(FieldMatcher);
159
160 impl FieldMatcher {
161 fn matches(&self, notification: &Notification) -> bool {
162 match self {
163 FieldMatcher::Exact {
164 field,
165 matched_value,
166 } => {
167 let value = notification.metadata.additional_fields.get(field);
168
169 if let Some(value) = value {
170 matched_value == value
171 } else {
172 // Metadata field does not exist, so we do not match
173 false
174 }
175 }
176 FieldMatcher::Regex {
177 field,
178 matched_regex,
179 } => {
180 let value = notification.metadata.additional_fields.get(field);
181
182 if let Some(value) = value {
183 matched_regex.is_match(value)
184 } else {
185 // Metadata field does not exist, so we do not match
186 false
187 }
188 }
189 }
190 }
191 }
192
193 impl fmt::Display for FieldMatcher {
194 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
195 // Attention, Display is used to implement Serialize, do not
196 // change the format.
197
198 match self {
199 FieldMatcher::Exact {
200 field,
201 matched_value,
202 } => {
203 write!(f, "exact:{field}={matched_value}")
204 }
205 FieldMatcher::Regex {
206 field,
207 matched_regex,
208 } => {
209 let re = matched_regex.as_str();
210 write!(f, "regex:{field}={re}")
211 }
212 }
213 }
214 }
215
216 impl FromStr for FieldMatcher {
217 type Err = Error;
218 fn from_str(s: &str) -> Result<Self, Error> {
219 if !MATCH_FIELD_ENTRY_REGEX.is_match(s) {
220 return Err(Error::FilterFailed(format!(
221 "invalid match-field statement: {s}"
222 )));
223 }
224
225 if let Some(remaining) = s.strip_prefix("regex:") {
226 match remaining.split_once('=') {
227 None => Err(Error::FilterFailed(format!(
228 "invalid match-field statement: {s}"
229 ))),
230 Some((field, expected_value_regex)) => {
231 let regex = Regex::new(expected_value_regex)
232 .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?;
233
234 Ok(Self::Regex {
235 field: field.into(),
236 matched_regex: regex,
237 })
238 }
239 }
240 } else if let Some(remaining) = s.strip_prefix("exact:") {
241 match remaining.split_once('=') {
242 None => Err(Error::FilterFailed(format!(
243 "invalid match-field statement: {s}"
244 ))),
245 Some((field, expected_value)) => Ok(Self::Exact {
246 field: field.into(),
247 matched_value: expected_value.into(),
248 }),
249 }
250 } else {
251 Err(Error::FilterFailed(format!(
252 "invalid match-field statement: {s}"
253 )))
254 }
255 }
256 }
257
258 impl MatcherConfig {
259 pub fn matches(&self, notification: &Notification) -> Result<Option<&[String]>, Error> {
260 let mode = self.mode.unwrap_or_default();
261
262 let mut is_match = mode.neutral_element();
263 is_match = mode.apply(is_match, self.check_severity_match(notification));
264 is_match = mode.apply(is_match, self.check_field_match(notification)?);
265 is_match = mode.apply(is_match, self.check_calendar_match(notification)?);
266
267 let invert_match = self.invert_match.unwrap_or_default();
268
269 Ok(if is_match != invert_match {
270 Some(self.target.as_deref().unwrap_or_default())
271 } else {
272 None
273 })
274 }
275
276 fn check_field_match(&self, notification: &Notification) -> Result<bool, Error> {
277 let mode = self.mode.unwrap_or_default();
278 let mut is_match = mode.neutral_element();
279
280 if let Some(match_field) = self.match_field.as_deref() {
281 for field_matcher in match_field {
282 // let field_matcher: FieldMatcher = match_stmt.parse()?;
283 is_match = mode.apply(is_match, field_matcher.matches(notification));
284 }
285 }
286
287 Ok(is_match)
288 }
289
290 fn check_severity_match(&self, notification: &Notification) -> bool {
291 let mode = self.mode.unwrap_or_default();
292 let mut is_match = mode.neutral_element();
293
294 if let Some(matchers) = self.match_severity.as_ref() {
295 for severity_matcher in matchers {
296 is_match = mode.apply(is_match, severity_matcher.matches(notification));
297 }
298 }
299
300 is_match
301 }
302
303 fn check_calendar_match(&self, notification: &Notification) -> Result<bool, Error> {
304 let mode = self.mode.unwrap_or_default();
305 let mut is_match = mode.neutral_element();
306
307 if let Some(matchers) = self.match_calendar.as_ref() {
308 for matcher in matchers {
309 is_match = mode.apply(is_match, matcher.matches(notification)?);
310 }
311 }
312
313 Ok(is_match)
314 }
315 }
316 #[derive(Clone, Debug)]
317 pub struct SeverityMatcher {
318 severities: Vec<Severity>,
319 }
320
321 proxmox_serde::forward_deserialize_to_from_str!(SeverityMatcher);
322 proxmox_serde::forward_serialize_to_display!(SeverityMatcher);
323
324 impl SeverityMatcher {
325 fn matches(&self, notification: &Notification) -> bool {
326 self.severities.contains(&notification.metadata.severity)
327 }
328 }
329
330 impl fmt::Display for SeverityMatcher {
331 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
332 let severities: Vec<String> = self.severities.iter().map(|s| format!("{s}")).collect();
333 f.write_str(&severities.join(","))
334 }
335 }
336
337 impl FromStr for SeverityMatcher {
338 type Err = Error;
339 fn from_str(s: &str) -> Result<Self, Error> {
340 let mut severities = Vec::new();
341
342 for element in s.split(',') {
343 let element = element.trim();
344 let severity: Severity = element.parse()?;
345
346 severities.push(severity)
347 }
348
349 Ok(Self { severities })
350 }
351 }
352
353 /// Match timestamp of the notification.
354 #[derive(Clone, Debug)]
355 pub struct CalendarMatcher {
356 schedule: DailyDuration,
357 original: String,
358 }
359
360 proxmox_serde::forward_deserialize_to_from_str!(CalendarMatcher);
361 proxmox_serde::forward_serialize_to_display!(CalendarMatcher);
362
363 impl CalendarMatcher {
364 fn matches(&self, notification: &Notification) -> Result<bool, Error> {
365 self.schedule
366 .time_match(notification.metadata.timestamp, false)
367 .map_err(|err| Error::Generic(format!("could not match timestamp: {err}")))
368 }
369 }
370
371 impl fmt::Display for CalendarMatcher {
372 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
373 f.write_str(&self.original)
374 }
375 }
376
377 impl FromStr for CalendarMatcher {
378 type Err = Error;
379 fn from_str(s: &str) -> Result<Self, Error> {
380 let schedule = parse_daily_duration(s)
381 .map_err(|e| Error::Generic(format!("could not parse schedule: {e}")))?;
382
383 Ok(Self {
384 schedule,
385 original: s.to_string(),
386 })
387 }
388 }
389
390 #[derive(Serialize, Deserialize)]
391 #[serde(rename_all = "kebab-case")]
392 pub enum DeleteableMatcherProperty {
393 MatchSeverity,
394 MatchField,
395 MatchCalendar,
396 Target,
397 Mode,
398 InvertMatch,
399 Comment,
400 }
401
402 pub fn check_matches<'a>(
403 matchers: &'a [MatcherConfig],
404 notification: &Notification,
405 ) -> HashSet<&'a str> {
406 let mut targets = HashSet::new();
407
408 for matcher in matchers {
409 match matcher.matches(notification) {
410 Ok(t) => {
411 let t = t.unwrap_or_default();
412 targets.extend(t.iter().map(|s| s.as_str()));
413 }
414 Err(err) => log::error!("matcher '{matcher}' failed: {err}", matcher = matcher.name),
415 }
416 }
417
418 targets
419 }
420
421 #[cfg(test)]
422 mod tests {
423 use super::*;
424 use serde_json::Value;
425 use std::collections::HashMap;
426
427 #[test]
428 fn test_matching() {
429 let mut fields = HashMap::new();
430 fields.insert("foo".into(), "bar".into());
431
432 let notification =
433 Notification::new_templated(Severity::Notice, "test", "test", Value::Null, fields);
434
435 let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap();
436 assert!(matcher.matches(&notification));
437
438 let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap();
439 assert!(matcher.matches(&notification));
440
441 let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap();
442 assert!(!matcher.matches(&notification));
443
444 assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
445 assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err());
446 }
447 #[test]
448 fn test_severities() {
449 let notification = Notification::new_templated(
450 Severity::Notice,
451 "test",
452 "test",
453 Value::Null,
454 Default::default(),
455 );
456
457 let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
458 assert!(matcher.matches(&notification));
459 }
460 }