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(¬ification).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(¬ification).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(¬ification).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(¬ification).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(¬ification).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(¬ification).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(¬ification).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(¬ification).unwrap());
+
+ let matcher: InlineFieldMatcher = "regex:foo=b.*".parse().unwrap();
+ assert!(matcher.matches(¬ification).unwrap());
+
+ let matcher: InlineFieldMatcher = "regex:notthere=b.*".parse().unwrap();
+ assert!(!matcher.matches(¬ification).unwrap());
+
+ let matcher: InlineFieldMatcher = "exact:foo=bar,test".parse().unwrap();
+ assert!(matcher.matches(¬ification).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(¬ification).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(¬ification).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(¬ification).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(¬ification).unwrap());
+ }
}
}
+
+#[cfg(feature = "legacy-matchers")]
+pub use inline::*;
--
2.47.3
next prev 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