From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 2CBCA1FF0E0 for ; Thu, 09 Jul 2026 14:02:57 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 2B35F219CA; Thu, 09 Jul 2026 13:58:50 +0200 (CEST) From: Lukas Wagner 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 Message-ID: <20260709115716.299836-16-l.wagner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260709115716.299836-1-l.wagner@proxmox.com> References: <20260709115716.299836-1-l.wagner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1783598239377 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.000 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: O3VSRB75LCKYH6KBAXZLWWZOIRGWL5AQ X-Message-ID-Hash: O3VSRB75LCKYH6KBAXZLWWZOIRGWL5AQ X-MailFrom: l.wagner@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Backup Server development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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 Result { 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) -> 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 { - 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 { + 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 { + 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 { - 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 { - 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 { - 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 = 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 { + 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 { + 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::().is_err()); - assert!("invalid:'bar=b.*".parse::().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 = 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::().is_err()); + assert!("invalid:'bar=b.*".parse::().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, + pub match_field: Vec, /// 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, + pub match_severity: Vec, /// 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, + pub match_calendar: Vec, /// Decide if 'all' or 'any' match statements must match. #[serde(skip_serializing_if = "Option::is_none")] pub mode: Option, @@ -139,20 +136,79 @@ pub struct MatcherConfig { pub origin: Option, } +#[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, + + /// Targets to notify. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub target: Vec, + + /// Comment. + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, + + /// Disable this matcher. + #[serde(skip_serializing_if = "Option::is_none")] + pub disable: Option, + + /// Origin of this config entry. + #[serde(skip_serializing_if = "Option::is_none")] + #[updater(skip)] + pub origin: Option, +} + trait MatchDirective { fn matches(&self, notification: &Notification) -> Result; } impl MatcherConfig { pub fn matches(&self, notification: &Notification) -> Result, Error> { - let expression = if let Some(expression_str) = &self.expression { - self.warn_about_ignored_properties(); + let expression: Expression = { + 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 { 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 { - self.0.matches(notification) - } -} - -impl fmt::Display for InlineSeverityMatcher { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let severities: Vec = 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 { - 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 { + self.0.matches(notification) + } + } + + impl fmt::Display for InlineSeverityMatcher { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let severities: Vec = + 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 { + 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