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 1748B1FF0E0 for ; Thu, 09 Jul 2026 14:01:54 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 78D80218E5; Thu, 09 Jul 2026 13:58:29 +0200 (CEST) From: Lukas Wagner To: pbs-devel@lists.proxmox.com, pve-devel@lists.proxmox.com Subject: [PATCH proxmox 12/29] notify: api: support new expression parameter Date: Thu, 9 Jul 2026 13:56:59 +0200 Message-ID: <20260709115716.299836-13-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: 1783598238992 X-SPAM-LEVEL: Spam detection results: 0 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: G6QVQVCUE6ZDTOFDE5JW4FEMMY5MSP63 X-Message-ID-Hash: G6QVQVCUE6ZDTOFDE5JW4FEMMY5MSP63 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: Add support for the new 'expression' parameter to the add_matcher and updater_matcher API functions. Also add some validation that won't allow to create a configuration with both old properties and new the 'expression'. Signed-off-by: Lukas Wagner --- proxmox-notify/src/api/matcher.rs | 119 ++++++++++++++++++++++++++++++ proxmox-notify/src/matcher/mod.rs | 43 ++++++++++- 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/proxmox-notify/src/api/matcher.rs b/proxmox-notify/src/api/matcher.rs index b2bf15ae..3713dfef 100644 --- a/proxmox-notify/src/api/matcher.rs +++ b/proxmox-notify/src/api/matcher.rs @@ -40,6 +40,10 @@ pub fn add_matcher(config: &mut Config, matcher_config: MatcherConfig) -> Result super::ensure_unique(config, &matcher_config.name)?; super::ensure_endpoints_exist(config, &matcher_config.target)?; + matcher_config + .ensure_valid() + .map_err(|err| http_err!(BAD_REQUEST, "invalid matcher config: {err}"))?; + config .config .set_data(&matcher_config.name, MATCHER_TYPENAME, &matcher_config) @@ -83,6 +87,7 @@ pub fn update_matcher( DeleteableMatcherProperty::InvertMatch => matcher.invert_match = None, DeleteableMatcherProperty::Comment => matcher.comment = None, DeleteableMatcherProperty::Disable => matcher.disable = None, + DeleteableMatcherProperty::Expression => matcher.expression = None, } } } @@ -115,11 +120,19 @@ pub fn update_matcher( 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; } + matcher + .ensure_valid() + .map_err(|err| http_err!(BAD_REQUEST, "invalid matcher config: {err}"))?; + config .config .set_data(name, MATCHER_TYPENAME, &matcher) @@ -150,8 +163,12 @@ pub fn delete_matcher(config: &mut Config, name: &str) -> Result<(), HttpError> #[cfg(all(test, feature = "sendmail"))] mod tests { + use proxmox_match_expression::Expression; + use super::*; + use crate::matcher::MatchModeOperator; + use crate::matcher::expression::NotificationMatcher; fn empty_config() -> Config { Config::new("", "").unwrap() @@ -172,6 +189,11 @@ matcher: matcher2 .unwrap() } + fn valid_expression_string() -> String { + let expr = Expression::::Constant(true); + serde_json::to_string(&expr).unwrap() + } + #[test] fn test_update_not_existing_returns_error() -> Result<(), HttpError> { let mut config = empty_config(); @@ -261,4 +283,101 @@ matcher: matcher2 Ok(()) } + + #[test] + fn test_update_matcher_mutually_exclusive_with_expression() -> Result<(), HttpError> { + let mut config = config_with_two_matchers(); + let digest = config.digest; + + let proto = MatcherConfigUpdater { + expression: Some(valid_expression_string()), + ..Default::default() + }; + + let mut updater = proto.clone(); + updater.match_field = Some(vec!["exact:foo=bar".parse().unwrap()]); + + assert!(update_matcher(&mut config, "matcher1", updater, None, Some(&digest),).is_err()); + + let mut updater = proto.clone(); + updater.match_calendar = Some(vec!["mon..sun 12-13".parse().unwrap()]); + + assert!(update_matcher(&mut config, "matcher1", updater, None, Some(&digest),).is_err()); + + let mut updater = proto.clone(); + updater.match_severity = Some(vec!["info,warning".parse().unwrap()]); + + assert!(update_matcher(&mut config, "matcher1", updater, None, Some(&digest),).is_err()); + + Ok(()) + } + + #[test] + fn test_update_invalid_expression() -> Result<(), HttpError> { + let mut config = config_with_two_matchers(); + let digest = config.digest; + + assert!( + update_matcher( + &mut config, + "matcher1", + MatcherConfigUpdater { + expression: Some("invalid".into()), + ..Default::default() + }, + None, + Some(&digest), + ) + .is_err() + ); + + Ok(()) + } + + #[test] + fn test_add_mutually_exclusive_with_expression() -> Result<(), HttpError> { + let mut config = empty_config(); + + let proto = MatcherConfig { + name: "matcher3".into(), + expression: Some(valid_expression_string()), + ..Default::default() + }; + + let mut entity = proto.clone(); + entity.match_field = vec!["exact:foo=bar".parse().unwrap()]; + + assert!(add_matcher(&mut config, entity).is_err()); + + let mut entity = proto.clone(); + entity.match_severity = vec!["info,warning".parse().unwrap()]; + + assert!(add_matcher(&mut config, entity).is_err()); + + let mut entity = proto.clone(); + entity.match_calendar = vec!["mon..sun 12-13".parse().unwrap()]; + + assert!(add_matcher(&mut config, entity).is_err()); + + Ok(()) + } + + #[test] + fn test_add_invalid_expression() -> Result<(), HttpError> { + let mut config = empty_config(); + + assert!( + add_matcher( + &mut config, + MatcherConfig { + name: "matcher2".into(), + expression: Some("invalid".into()), + ..Default::default() + } + ) + .is_err() + ); + + Ok(()) + } } diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs index 429692a4..60725e00 100644 --- a/proxmox-notify/src/matcher/mod.rs +++ b/proxmox-notify/src/matcher/mod.rs @@ -76,7 +76,7 @@ pub enum MatchModeOperator { optional: true, }, })] -#[derive(Debug, Serialize, Deserialize, Updater, Default)] +#[derive(Clone, Debug, Serialize, Deserialize, Updater, Default)] #[serde(rename_all = "kebab-case")] /// Config for notification matchers. pub struct MatcherConfig { @@ -216,6 +216,45 @@ impl MatcherConfig { ); } } + + /// Ensure the validity of this matcher. + pub(crate) fn ensure_valid(&self) -> Result<(), Error> { + if let Some(expression) = &self.expression { + if self.invert_match.is_some() { + return Err(Error::Generic( + "'expression' and 'invert-match' are mutually exclusive properties".into(), + )); + } + if self.mode.is_some() { + return Err(Error::Generic( + "'expression' and 'mode' are mutually exclusive properties".into(), + )); + } + if !self.match_field.is_empty() { + return Err(Error::Generic( + "'expression' and 'match-field' are mutually exclusive properties".into(), + )); + } + if !self.match_severity.is_empty() { + return Err(Error::Generic( + "'expression' and 'match-severity' are mutually exclusive properties".into(), + )); + } + if !self.match_calendar.is_empty() { + return Err(Error::Generic( + "'expression' and 'match-calendar' are mutually exclusive properties".into(), + )); + } + + if let Err(err) = serde_json::from_str::>(expression) { + return Err(Error::Generic(format!( + "'expression' is not valid: {err:#}" + ))); + } + } + + Ok(()) + } } #[api] @@ -237,6 +276,8 @@ pub enum DeleteableMatcherProperty { MatchSeverity, /// Delete `mode` Mode, + /// Delete `expression` + Expression, /// Delete `target` Target, } -- 2.47.3