2 use std
::collections
::HashSet
;
7 use serde
::{Deserialize, Serialize}
;
9 use proxmox_schema
::api_types
::COMMENT_SCHEMA
;
11 api
, const_regex
, ApiStringFormat
, Schema
, StringSchema
, Updater
, SAFE_ID_REGEX_STR
,
13 use proxmox_time
::{parse_daily_duration, DailyDuration}
;
15 use crate::schema
::ENTITY_NAME_SCHEMA
;
16 use crate::{Error, Notification, Origin, Severity}
;
18 pub const MATCHER_TYPENAME
: &str = "matcher";
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)
27 /// At least one filter property has to match (OR)
31 impl MatchModeOperator
{
32 /// Apply the mode operator to two bools, lhs and rhs
33 fn apply(&self, lhs
: bool
, rhs
: bool
) -> bool
{
35 MatchModeOperator
::All
=> lhs
&& rhs
,
36 MatchModeOperator
::Any
=> lhs
|| rhs
,
40 // https://en.wikipedia.org/wiki/Identity_element
41 fn neutral_element(&self) -> bool
{
43 MatchModeOperator
::All
=> true,
44 MatchModeOperator
::Any
=> false,
50 pub MATCH_FIELD_ENTRY_REGEX
= concat
!(r
"^(?:(exact|regex):)?(", SAFE_ID_REGEX_STR
!(), r
")=(.*)$");
53 pub const MATCH_FIELD_ENTRY_FORMAT
: ApiStringFormat
=
54 ApiStringFormat
::VerifyFn(verify_field_matcher
);
56 fn verify_field_matcher(s
: &str) -> Result
<(), anyhow
::Error
> {
57 let _
: FieldMatcher
= s
.parse()?
;
61 pub const MATCH_FIELD_ENTRY_SCHEMA
: Schema
= StringSchema
::new("Match metadata field.")
62 .format(&MATCH_FIELD_ENTRY_FORMAT
)
70 schema
: ENTITY_NAME_SCHEMA
,
74 schema
: COMMENT_SCHEMA
,
79 description
: "Fields to match",
87 description
: "Severity level to match.",
95 description
: "Time stamps to match",
103 schema
: ENTITY_NAME_SCHEMA
,
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
116 /// List of matched metadata fields
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub match_field
: Option
<Vec
<FieldMatcher
>>,
120 /// List of matched severity levels
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub match_severity
: Option
<Vec
<SeverityMatcher
>>,
124 /// List of matched severity levels
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub match_calendar
: Option
<Vec
<CalendarMatcher
>>,
128 /// Decide if 'all' or 'any' match statements must match
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub mode
: Option
<MatchModeOperator
>,
132 /// Invert match of the whole filter
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub invert_match
: Option
<bool
>,
136 /// Targets to notify
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub target
: Option
<Vec
<String
>>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub comment
: Option
<String
>,
144 /// Disable this matcher
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub disable
: Option
<bool
>,
148 /// Origin of this config entry.
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub origin
: Option
<Origin
>,
154 trait MatchDirective
{
155 fn matches(&self, notification
: &Notification
) -> Result
<bool
, Error
>;
158 /// Check if the notification metadata fields match
159 #[derive(Clone, Debug)]
160 pub enum FieldMatcher
{
163 matched_values
: Vec
<String
>,
167 matched_regex
: Regex
,
171 proxmox_serde
::forward_deserialize_to_from_str
!(FieldMatcher
);
172 proxmox_serde
::forward_serialize_to_display
!(FieldMatcher
);
174 impl MatchDirective
for FieldMatcher
{
175 fn matches(&self, notification
: &Notification
) -> Result
<bool
, Error
> {
177 FieldMatcher
::Exact
{
181 let value
= notification
.metadata
.additional_fields
.get(field
);
183 if let Some(value
) = value
{
184 matched_values
.contains(value
)
186 // Metadata field does not exist, so we do not match
190 FieldMatcher
::Regex
{
194 let value
= notification
.metadata
.additional_fields
.get(field
);
196 if let Some(value
) = value
{
197 matched_regex
.is_match(value
)
199 // Metadata field does not exist, so we do not match
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.
213 FieldMatcher
::Exact
{
217 let values
= matched_values
.join(",");
218 write
!(f
, "exact:{field}={values}")
220 FieldMatcher
::Regex
{
224 let re
= matched_regex
.as_str();
225 write
!(f
, "regex:{field}={re}")
231 impl FromStr
for FieldMatcher
{
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}"
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}"
245 Some((field
, expected_value_regex
)) => {
246 let regex
= Regex
::new(expected_value_regex
)
247 .map_err(|err
| Error
::FilterFailed(format
!("invalid regex: {err}")))?
;
251 matched_regex
: regex
,
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}"
260 Some((field
, expected_values
)) => {
261 let values
: Vec
<String
> = expected_values
268 matched_values
: values
,
273 Err(Error
::FilterFailed(format
!(
274 "invalid match-field statement: {s}"
281 pub fn matches(&self, notification
: &Notification
) -> Result
<Option
<&[String
]>, Error
> {
282 let mode
= self.mode
.unwrap_or_default();
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;
288 if let Some(severity_matchers
) = self.match_severity
.as_deref() {
290 is_match
= mode
.apply(
292 self.check_matches(notification
, severity_matchers
)?
,
295 if let Some(field_matchers
) = self.match_field
.as_deref() {
297 is_match
= mode
.apply(is_match
, self.check_matches(notification
, field_matchers
)?
);
299 if let Some(calendar_matchers
) = self.match_calendar
.as_deref() {
301 is_match
= mode
.apply(
303 self.check_matches(notification
, calendar_matchers
)?
,
307 let invert_match
= self.invert_match
.unwrap_or_default();
309 Ok(if is_match
!= invert_match
|| no_matchers
{
310 Some(self.target
.as_deref().unwrap_or_default())
316 /// Check if given `MatchDirectives` match a notification.
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();
325 for field_matcher
in matchers
{
326 is_match
= mode
.apply(is_match
, field_matcher
.matches(notification
)?
);
333 /// Match severity of the notification.
334 #[derive(Clone, Debug)]
335 pub struct SeverityMatcher
{
336 severities
: Vec
<Severity
>,
339 proxmox_serde
::forward_deserialize_to_from_str
!(SeverityMatcher
);
340 proxmox_serde
::forward_serialize_to_display
!(SeverityMatcher
);
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(¬ification
.metadata
.severity
))
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(","))
357 impl FromStr
for SeverityMatcher
{
359 fn from_str(s
: &str) -> Result
<Self, Error
> {
360 let mut severities
= Vec
::new();
362 for element
in s
.split('
,'
) {
363 let element
= element
.trim();
364 let severity
: Severity
= element
.parse()?
;
366 severities
.push(severity
)
369 Ok(Self { severities }
)
373 /// Match timestamp of the notification.
374 #[derive(Clone, Debug)]
375 pub struct CalendarMatcher
{
376 schedule
: DailyDuration
,
380 proxmox_serde
::forward_deserialize_to_from_str
!(CalendarMatcher
);
381 proxmox_serde
::forward_serialize_to_display
!(CalendarMatcher
);
383 impl MatchDirective
for CalendarMatcher
{
384 fn matches(&self, notification
: &Notification
) -> Result
<bool
, Error
> {
386 .time_match(notification
.metadata
.timestamp
, false)
387 .map_err(|err
| Error
::Generic(format
!("could not match timestamp: {err}")))
391 impl fmt
::Display
for CalendarMatcher
{
392 fn fmt(&self, f
: &mut fmt
::Formatter
) -> fmt
::Result
{
393 f
.write_str(&self.original
)
397 impl FromStr
for CalendarMatcher
{
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}")))?
;
405 original
: s
.to_string(),
410 #[derive(Serialize, Deserialize)]
411 #[serde(rename_all = "kebab-case")]
412 pub enum DeleteableMatcherProperty
{
423 pub fn check_matches
<'a
>(
424 matchers
: &'a
[MatcherConfig
],
425 notification
: &Notification
,
426 ) -> HashSet
<&'a
str> {
427 let mut targets
= HashSet
::new();
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
);
436 match matcher
.matches(notification
) {
438 let t
= t
.unwrap_or_default();
439 targets
.extend(t
.iter().map(|s
| s
.as_str()));
441 Err(err
) => log
::error
!("matcher '{matcher}' failed: {err}", matcher
= matcher
.name
),
451 use serde_json
::Value
;
452 use std
::collections
::HashMap
;
456 let mut fields
= HashMap
::new();
457 fields
.insert("foo".into(), "bar".into());
460 Notification
::new_templated(Severity
::Notice
, "test", "test", Value
::Null
, fields
);
462 let matcher
: FieldMatcher
= "exact:foo=bar".parse().unwrap();
463 assert
!(matcher
.matches(¬ification
).unwrap());
465 let matcher
: FieldMatcher
= "regex:foo=b.*".parse().unwrap();
466 assert
!(matcher
.matches(¬ification
).unwrap());
468 let matcher
: FieldMatcher
= "regex:notthere=b.*".parse().unwrap();
469 assert
!(!matcher
.matches(¬ification
).unwrap());
471 let matcher
: FieldMatcher
= "exact:foo=bar,test".parse().unwrap();
472 assert
!(matcher
.matches(¬ification
).unwrap());
474 let mut fields
= HashMap
::new();
475 fields
.insert("foo".into(), "test".into());
478 Notification
::new_templated(Severity
::Notice
, "test", "test", Value
::Null
, fields
);
479 assert
!(matcher
.matches(¬ification
).unwrap());
481 let mut fields
= HashMap
::new();
482 fields
.insert("foo".into(), "notthere".into());
485 Notification
::new_templated(Severity
::Notice
, "test", "test", Value
::Null
, fields
);
486 assert
!(!matcher
.matches(¬ification
).unwrap());
488 assert
!("regex:'3=b.*".parse
::<FieldMatcher
>().is_err());
489 assert
!("invalid:'bar=b.*".parse
::<FieldMatcher
>().is_err());
492 fn test_severities() {
493 let notification
= Notification
::new_templated(
501 let matcher
: SeverityMatcher
= "info,notice,warning,error".parse().unwrap();
502 assert
!(matcher
.matches(¬ification
).unwrap());
506 fn test_empty_matcher_matches_always() {
507 let notification
= Notification
::new_templated(
515 for mode
in [MatchModeOperator
::All
, MatchModeOperator
::Any
] {
516 let config
= MatcherConfig
{
517 name
: "matcher".to_string(),
522 assert
!(config
.matches(¬ification
).unwrap().is_some())