public inbox for pve-devel@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:03 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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal