all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Lukas Wagner <l.wagner@proxmox.com>
To: pbs-devel@lists.proxmox.com, pve-devel@lists.proxmox.com
Subject: [PATCH proxmox 15/29] notify: move legacy matcher keys behind feature flag
Date: Thu,  9 Jul 2026 13:57:02 +0200	[thread overview]
Message-ID: <20260709115716.299836-16-l.wagner@proxmox.com> (raw)
In-Reply-To: <20260709115716.299836-1-l.wagner@proxmox.com>

This allows us to only support 'expression' matchers if we want to, e.g.
when introducing the notification stack to PDM.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/Cargo.toml              |   1 +
 proxmox-notify/src/api/matcher.rs      | 110 ++++++---
 proxmox-notify/src/lib.rs              |  23 +-
 proxmox-notify/src/matcher/calendar.rs |  85 +++----
 proxmox-notify/src/matcher/field.rs    | 309 +++++++++++++------------
 proxmox-notify/src/matcher/mod.rs      | 106 +++++++--
 proxmox-notify/src/matcher/severity.rs | 132 ++++++-----
 7 files changed, 469 insertions(+), 297 deletions(-)

diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index ae82f649..cf0e8fc4 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -48,3 +48,4 @@ pve-context = ["dep:proxmox-sys"]
 pbs-context = ["dep:proxmox-sys"]
 smtp = ["dep:lettre"]
 webhook = ["dep:http", "dep:percent-encoding", "dep:proxmox-base64", "dep:proxmox-http"]
+legacy-matchers = []
diff --git a/proxmox-notify/src/api/matcher.rs b/proxmox-notify/src/api/matcher.rs
index be2d9c0e..77b2e1ce 100644
--- a/proxmox-notify/src/api/matcher.rs
+++ b/proxmox-notify/src/api/matcher.rs
@@ -34,6 +34,7 @@ pub fn get_matcher(config: &Config, name: &str) -> Result<MatcherConfig, HttpErr
 ///
 /// The caller is responsible for any needed permission checks.
 /// Returns the endpoint or a `HttpError` if the matcher was not found (`404 Not found`).
+#[cfg(feature = "legacy-matchers")]
 pub fn get_matcher_as_expression(config: &Config, name: &str) -> Result<MatcherConfig, HttpError> {
     let mut matcher: MatcherConfig = config
         .config
@@ -110,37 +111,49 @@ pub fn update_matcher(
     if let Some(delete) = delete {
         for deletable_property in delete {
             match deletable_property {
+                #[cfg(feature = "legacy-matchers")]
                 DeleteableMatcherProperty::MatchSeverity => matcher.match_severity.clear(),
+                #[cfg(feature = "legacy-matchers")]
                 DeleteableMatcherProperty::MatchField => matcher.match_field.clear(),
+                #[cfg(feature = "legacy-matchers")]
                 DeleteableMatcherProperty::MatchCalendar => matcher.match_calendar.clear(),
-                DeleteableMatcherProperty::Target => matcher.target.clear(),
-                DeleteableMatcherProperty::Mode => matcher.mode = None,
+                #[cfg(feature = "legacy-matchers")]
                 DeleteableMatcherProperty::InvertMatch => matcher.invert_match = None,
-                DeleteableMatcherProperty::Comment => matcher.comment = None,
-                DeleteableMatcherProperty::Disable => matcher.disable = None,
+                #[cfg(feature = "legacy-matchers")]
+                DeleteableMatcherProperty::Mode => matcher.mode = None,
                 DeleteableMatcherProperty::Expression => matcher.expression = None,
+                DeleteableMatcherProperty::Comment => matcher.comment = None,
+                DeleteableMatcherProperty::Target => matcher.target.clear(),
+                DeleteableMatcherProperty::Disable => matcher.disable = None,
             }
         }
     }
 
-    if let Some(match_severity) = matcher_updater.match_severity {
-        matcher.match_severity = match_severity;
+    #[cfg(feature = "legacy-matchers")]
+    {
+        if let Some(match_severity) = matcher_updater.match_severity {
+            matcher.match_severity = match_severity;
+        }
+
+        if let Some(match_field) = matcher_updater.match_field {
+            matcher.match_field = match_field;
+        }
+
+        if let Some(match_calendar) = matcher_updater.match_calendar {
+            matcher.match_calendar = match_calendar;
+        }
+
+        if let Some(mode) = matcher_updater.mode {
+            matcher.mode = Some(mode);
+        }
+
+        if let Some(invert_match) = matcher_updater.invert_match {
+            matcher.invert_match = Some(invert_match);
+        }
     }
 
-    if let Some(match_field) = matcher_updater.match_field {
-        matcher.match_field = match_field;
-    }
-
-    if let Some(match_calendar) = matcher_updater.match_calendar {
-        matcher.match_calendar = match_calendar;
-    }
-
-    if let Some(mode) = matcher_updater.mode {
-        matcher.mode = Some(mode);
-    }
-
-    if let Some(invert_match) = matcher_updater.invert_match {
-        matcher.invert_match = Some(invert_match);
+    if let Some(expression) = matcher_updater.expression {
+        matcher.expression = Some(expression);
     }
 
     if let Some(comment) = matcher_updater.comment {
@@ -150,11 +163,6 @@ pub fn update_matcher(
     if let Some(disable) = matcher_updater.disable {
         matcher.disable = Some(disable);
     }
-
-    if let Some(expression) = matcher_updater.expression {
-        matcher.expression = Some(expression);
-    }
-
     if let Some(target) = matcher_updater.target {
         super::ensure_endpoints_exist(config, target.as_slice())?;
         matcher.target = target;
@@ -198,7 +206,6 @@ mod tests {
 
     use super::*;
 
-    use crate::matcher::MatchModeOperator;
     use crate::matcher::expression::NotificationMatcher;
 
     fn empty_config() -> Config {
@@ -250,7 +257,10 @@ matcher: matcher2
     }
 
     #[test]
+    #[cfg(feature = "legacy-matchers")]
     fn test_matcher_update() -> Result<(), HttpError> {
+        use crate::matcher::MatchModeOperator;
+
         let mut config = config_with_two_matchers();
 
         let digest = config.digest;
@@ -305,6 +315,52 @@ matcher: matcher2
         Ok(())
     }
 
+    #[test]
+    #[cfg(not(feature = "legacy-matchers"))]
+    fn test_matcher_update() -> Result<(), HttpError> {
+        let mut config = config_with_two_matchers();
+
+        let digest = config.digest;
+
+        update_matcher(
+            &mut config,
+            "matcher1",
+            MatcherConfigUpdater {
+                expression: Some(valid_expression_string()),
+                target: Some(vec!["foo".into()]),
+                comment: Some("new comment".into()),
+                ..Default::default()
+            },
+            None,
+            Some(&digest),
+        )?;
+
+        let matcher = get_matcher(&config, "matcher1")?;
+
+        assert_eq!(matcher.comment, Some("new comment".into()));
+
+        // Test property deletion
+        update_matcher(
+            &mut config,
+            "matcher1",
+            Default::default(),
+            Some(&[
+                DeleteableMatcherProperty::Target,
+                DeleteableMatcherProperty::Comment,
+                DeleteableMatcherProperty::Expression,
+            ]),
+            Some(&digest),
+        )?;
+
+        let matcher = get_matcher(&config, "matcher1")?;
+
+        assert!(matcher.target.is_empty());
+        assert!(matcher.expression.is_none());
+        assert_eq!(matcher.comment, None);
+
+        Ok(())
+    }
+
     #[test]
     fn test_matcher_delete() -> Result<(), HttpError> {
         let mut config = config_with_two_matchers();
@@ -316,6 +372,7 @@ matcher: matcher2
     }
 
     #[test]
+    #[cfg(feature = "legacy-matchers")]
     fn test_update_matcher_mutually_exclusive_with_expression() -> Result<(), HttpError> {
         let mut config = config_with_two_matchers();
         let digest = config.digest;
@@ -366,6 +423,7 @@ matcher: matcher2
     }
 
     #[test]
+    #[cfg(feature = "legacy-matchers")]
     fn test_add_mutually_exclusive_with_expression() -> Result<(), HttpError> {
         let mut config = empty_config();
 
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index 1d1c03cc..8895796b 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -595,6 +595,10 @@ impl Bus {
 mod tests {
     use std::{cell::RefCell, rc::Rc};
 
+    use proxmox_match_expression::Expression;
+
+    use crate::matcher::{expression::NotificationMatcher, severity::SeverityMatcher};
+
     use super::*;
 
     #[derive(Default, Clone)]
@@ -634,6 +638,10 @@ mod tests {
         }
     }
 
+    fn expression_to_string(expression: Expression<NotificationMatcher>) -> String {
+        serde_json::to_string(&expression).unwrap()
+    }
+
     #[test]
     fn test_add_mock_endpoint() -> Result<(), Error> {
         let mock = MockEndpoint::new("endpoint");
@@ -643,6 +651,7 @@ mod tests {
 
         let matcher = MatcherConfig {
             target: vec!["endpoint".into()],
+            expression: Some(expression_to_string(Expression::Constant(true))),
             ..Default::default()
         };
 
@@ -673,14 +682,24 @@ mod tests {
 
         bus.add_matcher(MatcherConfig {
             name: "matcher1".into(),
-            match_severity: vec!["warning,error".parse()?],
+            expression: Some(expression_to_string(
+                SeverityMatcher {
+                    severities: vec![Severity::Error, Severity::Warning],
+                }
+                .into(),
+            )),
             target: vec!["mock1".into()],
             ..Default::default()
         });
 
         bus.add_matcher(MatcherConfig {
             name: "matcher2".into(),
-            match_severity: vec!["error".parse()?],
+            expression: Some(expression_to_string(
+                SeverityMatcher {
+                    severities: vec![Severity::Error],
+                }
+                .into(),
+            )),
             target: vec!["mock2".into()],
             ..Default::default()
         });
diff --git a/proxmox-notify/src/matcher/calendar.rs b/proxmox-notify/src/matcher/calendar.rs
index 43805840..ceb4db76 100644
--- a/proxmox-notify/src/matcher/calendar.rs
+++ b/proxmox-notify/src/matcher/calendar.rs
@@ -69,43 +69,51 @@ impl MatchDirective for CalendarMatcher {
     }
 }
 
-/// Match the timestamp of a notification.
-///
-/// This is a wrapper that serializes into an inline format that can be used
-/// in a section config key-value pair.
-#[derive(Clone, Debug)]
-pub struct InlineCalendarMatcher(CalendarMatcher);
+#[cfg(feature = "legacy-matchers")]
+pub mod inline {
+    use super::*;
 
-impl MatchDirective for InlineCalendarMatcher {
-    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
-        self.0.matches(notification)
+    /// Match the timestamp of a notification.
+    ///
+    /// This is a wrapper that serializes into an inline format that can be used
+    /// in a section config key-value pair.
+    #[derive(Clone, Debug)]
+    pub struct InlineCalendarMatcher(CalendarMatcher);
+
+    impl MatchDirective for InlineCalendarMatcher {
+        fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+            self.0.matches(notification)
+        }
     }
+
+    impl fmt::Display for InlineCalendarMatcher {
+        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+            f.write_str(self.0.schedule.as_str())
+        }
+    }
+
+    impl FromStr for InlineCalendarMatcher {
+        type Err = Error;
+
+        fn from_str(s: &str) -> Result<Self, Error> {
+            Ok(Self(CalendarMatcher {
+                schedule: s.parse()?,
+            }))
+        }
+    }
+
+    impl InlineCalendarMatcher {
+        pub fn into_inner(self) -> CalendarMatcher {
+            self.0
+        }
+    }
+
+    proxmox_serde::forward_deserialize_to_from_str!(InlineCalendarMatcher);
+    proxmox_serde::forward_serialize_to_display!(InlineCalendarMatcher);
 }
 
-impl fmt::Display for InlineCalendarMatcher {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        f.write_str(self.0.schedule.as_str())
-    }
-}
-
-impl FromStr for InlineCalendarMatcher {
-    type Err = Error;
-
-    fn from_str(s: &str) -> Result<Self, Error> {
-        Ok(Self(CalendarMatcher {
-            schedule: s.parse()?,
-        }))
-    }
-}
-
-impl InlineCalendarMatcher {
-    pub fn into_inner(self) -> CalendarMatcher {
-        self.0
-    }
-}
-
-proxmox_serde::forward_deserialize_to_from_str!(InlineCalendarMatcher);
-proxmox_serde::forward_serialize_to_display!(InlineCalendarMatcher);
+#[cfg(feature = "legacy-matchers")]
+pub use inline::*;
 
 #[cfg(test)]
 mod test {
@@ -125,7 +133,9 @@ mod test {
 
         // Match on a wide rage to avoid issues when running this test case
         // in a different time zone.
-        let matcher: InlineCalendarMatcher = "thu..sat 0-23".parse().unwrap();
+        let matcher = CalendarMatcher {
+            schedule: "thu..sat 0-23".parse().unwrap(),
+        };
         assert!(matcher.matches(&notification).unwrap());
     }
 
@@ -135,11 +145,6 @@ mod test {
 
         let calendar_matcher: CalendarMatcher = serde_json::from_str(calendar_matcher).unwrap();
 
-        let s = serde_json::to_string(&calendar_matcher).unwrap();
-        let m: CalendarMatcher = serde_json::from_str(&s).unwrap();
-
-        let a = InlineCalendarMatcher(m);
-
-        assert_eq!(a.to_string(), "thu..sat 0-23");
+        let _s = serde_json::to_string(&calendar_matcher).unwrap();
     }
 }
diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs
index a21b00d3..4a4b2588 100644
--- a/proxmox-notify/src/matcher/field.rs
+++ b/proxmox-notify/src/matcher/field.rs
@@ -1,36 +1,12 @@
-use std::{fmt, str::FromStr};
-
-use const_format::concatcp;
 use regex::Regex;
 use serde::{Deserialize, Serialize};
 
 use proxmox_match_expression::Expression;
-use proxmox_schema::{
-    ApiStringFormat, Schema, StringSchema, api_types::SAFE_ID_REGEX_STR, const_regex,
-};
 
 use crate::{Error, Notification, matcher::expression::NotificationMatcher};
 
 use super::MatchDirective;
 
-const_regex! {
-    pub MATCH_FIELD_ENTRY_REGEX = concatcp!(r"^(?:(exact|regex):)?(", SAFE_ID_REGEX_STR, r")=(.*)$");
-}
-
-pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat =
-    ApiStringFormat::VerifyFn(verify_inline_field_matcher);
-
-fn verify_inline_field_matcher(s: &str) -> Result<(), anyhow::Error> {
-    let _: InlineFieldMatcher = s.parse()?;
-    Ok(())
-}
-
-pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata field.")
-    .format(&MATCH_FIELD_ENTRY_FORMAT)
-    .min_length(1)
-    .max_length(1024)
-    .schema();
-
 /// Check if the notification metadata fields match
 #[derive(Clone, Debug, Serialize, Deserialize)]
 #[serde(rename_all = "kebab-case", untagged)]
@@ -79,146 +55,179 @@ impl MatchDirective for FieldMatcher {
     }
 }
 
-/// Check if the notification metadata fields match
-///
-/// This is a wrapper that serializes into an inline format that can be used
-/// in a section config key-value pair.
-#[derive(Clone, Debug)]
-pub struct InlineFieldMatcher(FieldMatcher);
+#[cfg(feature = "legacy-matchers")]
+pub mod inline {
+    use std::{fmt, str::FromStr};
 
-impl MatchDirective for InlineFieldMatcher {
-    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
-        self.0.matches(notification)
-    }
-}
+    use const_format::concatcp;
 
-impl fmt::Display for InlineFieldMatcher {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        // Attention, Display is used to implement Serialize, do not
-        // change the format.
-
-        match &self.0 {
-            FieldMatcher::Exact {
-                field,
-                values: matched_values,
-            } => {
-                let values = matched_values.join(",");
-                write!(f, "exact:{field}={values}")
-            }
-            FieldMatcher::Regex {
-                field,
-                regex: matched_regex,
-            } => {
-                let re = matched_regex.as_str();
-                write!(f, "regex:{field}={re}")
-            }
-        }
-    }
-}
-
-impl FromStr for InlineFieldMatcher {
-    type Err = Error;
-    fn from_str(s: &str) -> Result<Self, Error> {
-        if !MATCH_FIELD_ENTRY_REGEX.is_match(s) {
-            return Err(Error::FilterFailed(format!(
-                "invalid match-field statement: {s}"
-            )));
-        }
-
-        if let Some(remaining) = s.strip_prefix("regex:") {
-            match remaining.split_once('=') {
-                None => Err(Error::FilterFailed(format!(
-                    "invalid match-field statement: {s}"
-                ))),
-                Some((field, expected_value_regex)) => {
-                    let regex = Regex::new(expected_value_regex)
-                        .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?;
-
-                    Ok(Self(FieldMatcher::Regex {
-                        field: field.into(),
-                        regex,
-                    }))
-                }
-            }
-        } else if let Some(remaining) = s.strip_prefix("exact:") {
-            match remaining.split_once('=') {
-                None => Err(Error::FilterFailed(format!(
-                    "invalid match-field statement: {s}"
-                ))),
-                Some((field, expected_values)) => {
-                    let values: Vec<String> = expected_values
-                        .split(',')
-                        .map(str::trim)
-                        .map(String::from)
-                        .collect();
-                    Ok(Self(FieldMatcher::Exact {
-                        field: field.into(),
-                        values,
-                    }))
-                }
-            }
-        } else {
-            Err(Error::FilterFailed(format!(
-                "invalid match-field statement: {s}"
-            )))
-        }
-    }
-}
-
-impl InlineFieldMatcher {
-    pub fn into_inner(self) -> FieldMatcher {
-        self.0
-    }
-}
-
-proxmox_serde::forward_deserialize_to_from_str!(InlineFieldMatcher);
-proxmox_serde::forward_serialize_to_display!(InlineFieldMatcher);
-
-#[cfg(test)]
-mod tests {
-    use std::collections::HashMap;
-
-    use serde_json::Value;
-
-    use crate::Severity;
+    use proxmox_schema::{
+        ApiStringFormat, Schema, StringSchema, api_types::SAFE_ID_REGEX_STR, const_regex,
+    };
 
     use super::*;
 
-    #[test]
-    fn test_matching() {
-        let mut fields = HashMap::new();
-        fields.insert("foo".into(), "bar".into());
+    const_regex! {
+        pub MATCH_FIELD_ENTRY_REGEX = concatcp!(r"^(?:(exact|regex):)?(", SAFE_ID_REGEX_STR, r")=(.*)$");
+    }
 
-        let notification =
-            Notification::from_template(Severity::Notice, "test", Value::Null, fields);
+    pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat =
+        ApiStringFormat::VerifyFn(verify_inline_field_matcher);
 
-        let matcher: InlineFieldMatcher = "exact:foo=bar".parse().unwrap();
-        assert!(matcher.matches(&notification).unwrap());
+    fn verify_inline_field_matcher(s: &str) -> Result<(), anyhow::Error> {
+        let _: InlineFieldMatcher = s.parse()?;
+        Ok(())
+    }
 
-        let matcher: InlineFieldMatcher = "regex:foo=b.*".parse().unwrap();
-        assert!(matcher.matches(&notification).unwrap());
+    pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata field.")
+        .format(&MATCH_FIELD_ENTRY_FORMAT)
+        .min_length(1)
+        .max_length(1024)
+        .schema();
 
-        let matcher: InlineFieldMatcher = "regex:notthere=b.*".parse().unwrap();
-        assert!(!matcher.matches(&notification).unwrap());
+    /// Check if the notification metadata fields match
+    /// This is a wrapper that serializes into an inline format that can be used
+    /// in a section config key-value pair.
+    #[derive(Clone, Debug)]
+    pub struct InlineFieldMatcher(FieldMatcher);
 
-        let matcher: InlineFieldMatcher = "exact:foo=bar,test".parse().unwrap();
-        assert!(matcher.matches(&notification).unwrap());
+    impl MatchDirective for InlineFieldMatcher {
+        fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+            self.0.matches(notification)
+        }
+    }
 
-        let mut fields = HashMap::new();
-        fields.insert("foo".into(), "test".into());
+    impl fmt::Display for InlineFieldMatcher {
+        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+            // Attention, Display is used to implement Serialize, do not
+            // change the format.
 
-        let notification =
-            Notification::from_template(Severity::Notice, "test", Value::Null, fields);
-        assert!(matcher.matches(&notification).unwrap());
+            match &self.0 {
+                FieldMatcher::Exact {
+                    field,
+                    values: matched_values,
+                } => {
+                    let values = matched_values.join(",");
+                    write!(f, "exact:{field}={values}")
+                }
+                FieldMatcher::Regex {
+                    field,
+                    regex: matched_regex,
+                } => {
+                    let re = matched_regex.as_str();
+                    write!(f, "regex:{field}={re}")
+                }
+            }
+        }
+    }
 
-        let mut fields = HashMap::new();
-        fields.insert("foo".into(), "notthere".into());
+    impl FromStr for InlineFieldMatcher {
+        type Err = Error;
+        fn from_str(s: &str) -> Result<Self, Error> {
+            if !MATCH_FIELD_ENTRY_REGEX.is_match(s) {
+                return Err(Error::FilterFailed(format!(
+                    "invalid match-field statement: {s}"
+                )));
+            }
 
-        let notification =
-            Notification::from_template(Severity::Notice, "test", Value::Null, fields);
-        assert!(!matcher.matches(&notification).unwrap());
+            if let Some(remaining) = s.strip_prefix("regex:") {
+                match remaining.split_once('=') {
+                    None => Err(Error::FilterFailed(format!(
+                        "invalid match-field statement: {s}"
+                    ))),
+                    Some((field, expected_value_regex)) => {
+                        let regex = Regex::new(expected_value_regex)
+                            .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?;
 
-        assert!("regex:'3=b.*".parse::<InlineFieldMatcher>().is_err());
-        assert!("invalid:'bar=b.*".parse::<InlineFieldMatcher>().is_err());
+                        Ok(Self(FieldMatcher::Regex {
+                            field: field.into(),
+                            regex,
+                        }))
+                    }
+                }
+            } else if let Some(remaining) = s.strip_prefix("exact:") {
+                match remaining.split_once('=') {
+                    None => Err(Error::FilterFailed(format!(
+                        "invalid match-field statement: {s}"
+                    ))),
+                    Some((field, expected_values)) => {
+                        let values: Vec<String> = expected_values
+                            .split(',')
+                            .map(str::trim)
+                            .map(String::from)
+                            .collect();
+                        Ok(Self(FieldMatcher::Exact {
+                            field: field.into(),
+                            values,
+                        }))
+                    }
+                }
+            } else {
+                Err(Error::FilterFailed(format!(
+                    "invalid match-field statement: {s}"
+                )))
+            }
+        }
+    }
+
+    impl InlineFieldMatcher {
+        pub fn into_inner(self) -> FieldMatcher {
+            self.0
+        }
+    }
+
+    proxmox_serde::forward_deserialize_to_from_str!(InlineFieldMatcher);
+    proxmox_serde::forward_serialize_to_display!(InlineFieldMatcher);
+
+    #[cfg(test)]
+    mod tests {
+        use std::collections::HashMap;
+
+        use serde_json::Value;
+
+        use crate::Severity;
+
+        use super::*;
+
+        #[test]
+        fn test_matching() {
+            let mut fields = HashMap::new();
+            fields.insert("foo".into(), "bar".into());
+
+            let notification =
+                Notification::from_template(Severity::Notice, "test", Value::Null, fields);
+
+            let matcher: InlineFieldMatcher = "exact:foo=bar".parse().unwrap();
+            assert!(matcher.matches(&notification).unwrap());
+
+            let matcher: InlineFieldMatcher = "regex:foo=b.*".parse().unwrap();
+            assert!(matcher.matches(&notification).unwrap());
+
+            let matcher: InlineFieldMatcher = "regex:notthere=b.*".parse().unwrap();
+            assert!(!matcher.matches(&notification).unwrap());
+
+            let matcher: InlineFieldMatcher = "exact:foo=bar,test".parse().unwrap();
+            assert!(matcher.matches(&notification).unwrap());
+
+            let mut fields = HashMap::new();
+            fields.insert("foo".into(), "test".into());
+
+            let notification =
+                Notification::from_template(Severity::Notice, "test", Value::Null, fields);
+            assert!(matcher.matches(&notification).unwrap());
+
+            let mut fields = HashMap::new();
+            fields.insert("foo".into(), "notthere".into());
+
+            let notification =
+                Notification::from_template(Severity::Notice, "test", Value::Null, fields);
+            assert!(!matcher.matches(&notification).unwrap());
+
+            assert!("regex:'3=b.*".parse::<InlineFieldMatcher>().is_err());
+            assert!("invalid:'bar=b.*".parse::<InlineFieldMatcher>().is_err());
+        }
     }
 }
+
+#[cfg(feature = "legacy-matchers")]
+pub use inline::*;
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 60725e00..08235f05 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -17,10 +17,6 @@ pub mod expression;
 pub mod field;
 pub mod severity;
 
-use calendar::InlineCalendarMatcher;
-use field::InlineFieldMatcher;
-use severity::InlineSeverityMatcher;
-
 pub const MATCHER_TYPENAME: &str = "matcher";
 
 #[api]
@@ -35,6 +31,7 @@ pub enum MatchModeOperator {
     Any,
 }
 
+#[cfg(feature = "legacy-matchers")]
 #[api(
     properties: {
         name: {
@@ -87,17 +84,17 @@ pub struct MatcherConfig {
     /// List of matched metadata fields.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     #[updater(serde(skip_serializing_if = "Option::is_none"))]
-    pub match_field: Vec<InlineFieldMatcher>,
+    pub match_field: Vec<field::InlineFieldMatcher>,
 
     /// List of matched severity levels.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     #[updater(serde(skip_serializing_if = "Option::is_none"))]
-    pub match_severity: Vec<InlineSeverityMatcher>,
+    pub match_severity: Vec<severity::InlineSeverityMatcher>,
 
     /// List of matched severity levels.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     #[updater(serde(skip_serializing_if = "Option::is_none"))]
-    pub match_calendar: Vec<InlineCalendarMatcher>,
+    pub match_calendar: Vec<calendar::InlineCalendarMatcher>,
     /// Decide if 'all' or 'any' match statements must match.
     #[serde(skip_serializing_if = "Option::is_none")]
     pub mode: Option<MatchModeOperator>,
@@ -139,20 +136,79 @@ pub struct MatcherConfig {
     pub origin: Option<Origin>,
 }
 
+#[cfg(not(feature = "legacy-matchers"))]
+#[api(
+    properties: {
+        name: {
+            schema: ENTITY_NAME_SCHEMA,
+        },
+        comment: {
+            optional: true,
+            schema: COMMENT_SCHEMA,
+        },
+        "target": {
+            type: Array,
+            items: {
+                schema: ENTITY_NAME_SCHEMA,
+            },
+            optional: true,
+        },
+    })]
+#[derive(Clone, Debug, Serialize, Deserialize, Updater, Default)]
+#[serde(rename_all = "kebab-case")]
+/// Config for notification matchers.
+pub struct MatcherConfig {
+    /// Name of the matcher.
+    #[updater(skip)]
+    pub name: String,
+
+    /// Match expression as inline JSON.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub expression: Option<String>,
+
+    /// Targets to notify.
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    #[updater(serde(skip_serializing_if = "Option::is_none"))]
+    pub target: Vec<String>,
+
+    /// Comment.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub comment: Option<String>,
+
+    /// Disable this matcher.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub disable: Option<bool>,
+
+    /// Origin of this config entry.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    #[updater(skip)]
+    pub origin: Option<Origin>,
+}
+
 trait MatchDirective {
     fn matches(&self, notification: &Notification) -> Result<bool, Error>;
 }
 
 impl MatcherConfig {
     pub fn matches(&self, notification: &Notification) -> Result<Option<&[String]>, Error> {
-        let expression = if let Some(expression_str) = &self.expression {
-            self.warn_about_ignored_properties();
+        let expression: Expression<NotificationMatcher> = {
+            if let Some(expression_str) = &self.expression {
+                #[cfg(feature = "legacy-matchers")]
+                self.warn_about_ignored_properties();
 
-            serde_json::from_str(expression_str).map_err(|err| {
-                Error::FilterFailed(format!("could not deserialize filter expression: {err:#}"))
-            })?
-        } else {
-            self.generate_expression_from_legacy_config()
+                serde_json::from_str(expression_str).map_err(|err| {
+                    Error::FilterFailed(format!("could not deserialize filter expression: {err:#}"))
+                })?
+            } else {
+                #[cfg(feature = "legacy-matchers")]
+                {
+                    self.generate_expression_from_legacy_config()
+                }
+                #[cfg(not(feature = "legacy-matchers"))]
+                {
+                    Expression::Constant(true)
+                }
+            }
         };
 
         // Later, once we have a notification history, we can also save the evaluated expression.
@@ -164,6 +220,7 @@ impl MatcherConfig {
             .then_some(self.target.as_slice()))
     }
 
+    #[cfg(feature = "legacy-matchers")]
     pub(crate) fn generate_expression_from_legacy_config(&self) -> Expression<NotificationMatcher> {
         if self.match_severity.is_empty()
             && self.match_field.is_empty()
@@ -203,6 +260,7 @@ impl MatcherConfig {
         }
     }
 
+    #[cfg(feature = "legacy-matchers")]
     fn warn_about_ignored_properties(&self) {
         if !self.match_severity.is_empty()
             || !self.match_calendar.is_empty()
@@ -220,26 +278,31 @@ impl MatcherConfig {
     /// Ensure the validity of this matcher.
     pub(crate) fn ensure_valid(&self) -> Result<(), Error> {
         if let Some(expression) = &self.expression {
+            #[cfg(feature = "legacy-matchers")]
             if self.invert_match.is_some() {
                 return Err(Error::Generic(
                     "'expression' and 'invert-match' are mutually exclusive properties".into(),
                 ));
             }
+            #[cfg(feature = "legacy-matchers")]
             if self.mode.is_some() {
                 return Err(Error::Generic(
                     "'expression' and 'mode' are mutually exclusive properties".into(),
                 ));
             }
+            #[cfg(feature = "legacy-matchers")]
             if !self.match_field.is_empty() {
                 return Err(Error::Generic(
                     "'expression' and 'match-field' are mutually exclusive properties".into(),
                 ));
             }
+            #[cfg(feature = "legacy-matchers")]
             if !self.match_severity.is_empty() {
                 return Err(Error::Generic(
                     "'expression' and 'match-severity' are mutually exclusive properties".into(),
                 ));
             }
+            #[cfg(feature = "legacy-matchers")]
             if !self.match_calendar.is_empty() {
                 return Err(Error::Generic(
                     "'expression' and 'match-calendar' are mutually exclusive properties".into(),
@@ -267,14 +330,19 @@ pub enum DeleteableMatcherProperty {
     /// Delete `disable`
     Disable,
     /// Delete `invert-match`
+    #[cfg(feature = "legacy-matchers")]
     InvertMatch,
     /// Delete `match-calendar`
+    #[cfg(feature = "legacy-matchers")]
     MatchCalendar,
     /// Delete `match-field`
+    #[cfg(feature = "legacy-matchers")]
     MatchField,
     /// Delete `match-severity`
+    #[cfg(feature = "legacy-matchers")]
     MatchSeverity,
     /// Delete `mode`
+    #[cfg(feature = "legacy-matchers")]
     Mode,
     /// Delete `expression`
     Expression,
@@ -309,14 +377,14 @@ pub fn check_matches<'a>(
 
 #[cfg(test)]
 mod tests {
-    use serde_json::Value;
-
-    use crate::Severity;
-
-    use super::*;
 
     #[test]
+    #[cfg(feature = "legacy-matchers")]
     fn test_empty_matcher_matches_always() {
+        use super::*;
+        use crate::Severity;
+        use serde_json::Value;
+
         let notification =
             Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
 
diff --git a/proxmox-notify/src/matcher/severity.rs b/proxmox-notify/src/matcher/severity.rs
index a2538bcc..2088ca74 100644
--- a/proxmox-notify/src/matcher/severity.rs
+++ b/proxmox-notify/src/matcher/severity.rs
@@ -1,6 +1,3 @@
-use std::fmt;
-use std::str::FromStr;
-
 use serde::{Deserialize, Serialize};
 
 use proxmox_match_expression::Expression;
@@ -28,66 +25,81 @@ impl MatchDirective for SeverityMatcher {
     }
 }
 
-/// Match severity of the notification.
-///
-/// This is a wrapper that serializes into an inline format that can be used
-/// in a section config key-value pair.
-#[derive(Clone, Debug)]
-pub struct InlineSeverityMatcher(SeverityMatcher);
-
-/// Common trait implemented by all matching directives
-impl MatchDirective for InlineSeverityMatcher {
-    /// Check if this directive matches a given notification
-    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
-        self.0.matches(notification)
-    }
-}
-
-impl fmt::Display for InlineSeverityMatcher {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        let severities: Vec<String> = self.0.severities.iter().map(|s| format!("{s}")).collect();
-        f.write_str(&severities.join(","))
-    }
-}
-
-impl FromStr for InlineSeverityMatcher {
-    type Err = Error;
-
-    fn from_str(s: &str) -> Result<Self, Error> {
-        let mut severities = Vec::new();
-
-        for element in s.split(',') {
-            let element = element.trim();
-            let severity: Severity = element.parse()?;
-
-            severities.push(severity)
-        }
-
-        Ok(Self(SeverityMatcher { severities }))
-    }
-}
-
-impl InlineSeverityMatcher {
-    pub fn into_inner(self) -> SeverityMatcher {
-        self.0
-    }
-}
-
-proxmox_serde::forward_deserialize_to_from_str!(InlineSeverityMatcher);
-proxmox_serde::forward_serialize_to_display!(InlineSeverityMatcher);
-
-#[cfg(test)]
-mod test {
-    use serde_json::Value;
+#[cfg(feature = "legacy-matchers")]
+pub mod inline {
+    use std::{fmt, str::FromStr};
 
     use super::*;
 
-    #[test]
-    fn test_severities() {
-        let notification =
-            Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
+    /// Match severity of the notification.
+    ///
+    /// This is a wrapper that serializes into an inline format that can be used
+    /// in a section config key-value pair.
+    #[derive(Clone, Debug)]
+    pub struct InlineSeverityMatcher(SeverityMatcher);
 
-        let matcher: InlineSeverityMatcher = "info,notice,warning,error".parse().unwrap();
-        assert!(matcher.matches(&notification).unwrap());
+    /// Common trait implemented by all matching directives
+    impl MatchDirective for InlineSeverityMatcher {
+        /// Check if this directive matches a given notification
+        fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+            self.0.matches(notification)
+        }
+    }
+
+    impl fmt::Display for InlineSeverityMatcher {
+        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+            let severities: Vec<String> =
+                self.0.severities.iter().map(|s| format!("{s}")).collect();
+            f.write_str(&severities.join(","))
+        }
+    }
+
+    impl FromStr for InlineSeverityMatcher {
+        type Err = Error;
+
+        fn from_str(s: &str) -> Result<Self, Error> {
+            let mut severities = Vec::new();
+
+            for element in s.split(',') {
+                let element = element.trim();
+                let severity: Severity = element.parse()?;
+
+                severities.push(severity)
+            }
+
+            Ok(Self(SeverityMatcher { severities }))
+        }
+    }
+
+    impl InlineSeverityMatcher {
+        pub fn into_inner(self) -> SeverityMatcher {
+            self.0
+        }
+    }
+
+    proxmox_serde::forward_deserialize_to_from_str!(InlineSeverityMatcher);
+    proxmox_serde::forward_serialize_to_display!(InlineSeverityMatcher);
+
+    #[cfg(test)]
+    mod test {
+        use serde_json::Value;
+
+        use super::*;
+
+        #[test]
+        fn test_severities() {
+            let notification = Notification::from_template(
+                Severity::Notice,
+                "test",
+                Value::Null,
+                Default::default(),
+            );
+
+            let matcher: InlineSeverityMatcher = "info,notice,warning,error".parse().unwrap();
+            assert!(matcher.matches(&notification).unwrap());
+        }
     }
 }
+
+#[cfg(feature = "legacy-matchers")]
+pub use inline::*;
-- 
2.47.3





  parent reply	other threads:[~2026-07-09 12:02 UTC|newest]

Thread overview: 30+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 01/29] add new proxmox-match-expression crate Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 02/29] notify: promote matcher to dir-style module Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 03/29] notify: fix doc comment Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 04/29] notify: matcher: break out severity matcher into submodule Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 05/29] notify: matcher: break out field " Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 06/29] notify: matcher: break out calendar " Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 07/29] notify: matcher: calendar: add basic unit test Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 08/29] notify: matcher: add InlineSeverityMatcher Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 09/29] notify: matcher: add InlineFieldMatcher Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 10/29] notify: matcher: add InlineCalendarMatcher Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 11/29] notify: matcher: add expression support Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 12/29] notify: api: support new expression parameter Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox 13/29] notify: api: add `get_matcher_as_expression` Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox 14/29] notify: migrate PBS's and PVE's default matcher to expression syntax Lukas Wagner
2026-07-09 11:57 ` Lukas Wagner [this message]
2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 16/29] notification: increase matcher window width Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 17/29] notifications: matcher: add support for match expressions Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 18/29] notification: matcher: add better calendar editor Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 19/29] notifications: matcher: consistently use title case for UI elements Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-backup 20/29] notification: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-backup 21/29] api: notification: add 'migrate-to-expression' parameter to get_matcher Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-backup 22/29] ui: notification: enable new matcher UI Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-perl-rs 23/29] notify: matcher: pass matcher config / updater directly Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-perl-rs 24/29] notify: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-perl-rs 25/29] notify: add 'migrate_to_expression' parameter for get_matcher Lukas Wagner
2026-07-09 11:57 ` [PATCH manager 26/29] api: notification: pass config/updater directly to rust bindings Lukas Wagner
2026-07-09 11:57 ` [PATCH manager 27/29] api: notification: get_matcher: add 'migrate-to-expression' parameter Lukas Wagner
2026-07-09 11:57 ` [PATCH manager 28/29] api: notification: add 'expression' to matcher parameter schema Lukas Wagner
2026-07-09 11:57 ` [PATCH manager 29/29] ui: notification: enable new matcher UI Lukas Wagner

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260709115716.299836-16-l.wagner@proxmox.com \
    --to=l.wagner@proxmox.com \
    --cc=pbs-devel@lists.proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal