* [PATCH proxmox v2 01/34] add new proxmox-match-expression crate
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 02/34] notify: log which matchers matched on a notification Lukas Wagner
` (32 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
This new crate is a generic implementation of a serializable expression
language. It supports basic combinators, such as AnyOf, OneOf and AllOf,
constants, Not, as well as custom matcher leave nodes that are injected
via a generic type parameter.
The first user of this implementation will be the notification stack.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
Notes:
Changes since v1:
- all_of([]) evaluates now to true
This conforms to Rust's Iterator::nay or Python's all(...)
The first and only user of this crate, proxmox-notify explicitly
disallows empty combinators/groups, since they do not make
much sense from a UX perspective
- implement From<EvaluatedExpressionWithResult> for
EvaluatedExpression
- Expression now also has a `children` method, allowing
to easily iterate over child nodes
Cargo.toml | 2 +
proxmox-match-expression/Cargo.toml | 18 +
proxmox-match-expression/debian/changelog | 6 +
proxmox-match-expression/debian/control | 34 ++
proxmox-match-expression/debian/copyright | 18 +
proxmox-match-expression/debian/debcargo.toml | 7 +
proxmox-match-expression/src/lib.rs | 524 ++++++++++++++++++
7 files changed, 609 insertions(+)
create mode 100644 proxmox-match-expression/Cargo.toml
create mode 100644 proxmox-match-expression/debian/changelog
create mode 100644 proxmox-match-expression/debian/control
create mode 100644 proxmox-match-expression/debian/copyright
create mode 100644 proxmox-match-expression/debian/debcargo.toml
create mode 100644 proxmox-match-expression/src/lib.rs
diff --git a/Cargo.toml b/Cargo.toml
index ddefaaac..2e07077f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -32,6 +32,7 @@ members = [
"proxmox-log",
"proxmox-login",
"proxmox-metrics",
+ "proxmox-match-expression",
"proxmox-network-api",
"proxmox-network-types",
"proxmox-node-status",
@@ -179,6 +180,7 @@ proxmox-io = { version = "1.2.1", path = "proxmox-io" }
proxmox-lang = { version = "1.5", path = "proxmox-lang" }
proxmox-log = { version = "1.0.0", path = "proxmox-log" }
proxmox-login = { version = "1.0.0", path = "proxmox-login" }
+proxmox-match-expression = { version = "0.1", path = "proxmox-match-expression" }
proxmox-network-types = { version = "1.0.2", path = "proxmox-network-types" }
proxmox-parallel-handler = { version = "1.0.0", path = "proxmox-parallel-handler" }
proxmox-pgp = { version = "1.0.0", path = "proxmox-pgp" }
diff --git a/proxmox-match-expression/Cargo.toml b/proxmox-match-expression/Cargo.toml
new file mode 100644
index 00000000..294537bc
--- /dev/null
+++ b/proxmox-match-expression/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "proxmox-match-expression"
+description = "evaluation logic for nested match expressions"
+version = "0.1.0"
+
+authors.workspace = true
+edition.workspace = true
+exclude.workspace = true
+homepage.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+serde = { workspace = true, features = ["derive"] }
+
+[dev-dependencies]
+serde_json.workspace = true
+pretty_assertions.workspace = true
diff --git a/proxmox-match-expression/debian/changelog b/proxmox-match-expression/debian/changelog
new file mode 100644
index 00000000..44b20a10
--- /dev/null
+++ b/proxmox-match-expression/debian/changelog
@@ -0,0 +1,6 @@
+rust-proxmox-match-expression (0.1.0-1) unstable; urgency=medium
+
+ * initial version.
+
+ -- Proxmox Support Team <support@proxmox.com> Thu, 18 Jun 2026 14:02:03 +0200
+
diff --git a/proxmox-match-expression/debian/control b/proxmox-match-expression/debian/control
new file mode 100644
index 00000000..14bc8999
--- /dev/null
+++ b/proxmox-match-expression/debian/control
@@ -0,0 +1,34 @@
+Source: rust-proxmox-match-expression
+Section: rust
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ dh-sequence-cargo
+Build-Depends-Arch: cargo:native <!nocheck>,
+ rustc:native <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-serde-1+default-dev <!nocheck>,
+ librust-serde-1+derive-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.7.2
+Vcs-Git: git://git.proxmox.com/git/proxmox.git
+Vcs-Browser: https://git.proxmox.com/?p=proxmox.git
+Homepage: https://proxmox.com
+X-Cargo-Crate: proxmox-match-expression
+
+Package: librust-proxmox-match-expression-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-serde-1+default-dev,
+ librust-serde-1+derive-dev
+Provides:
+ librust-proxmox-match-expression+default-dev (= ${binary:Version}),
+ librust-proxmox-match-expression-0-dev (= ${binary:Version}),
+ librust-proxmox-match-expression-0+default-dev (= ${binary:Version}),
+ librust-proxmox-match-expression-0.1-dev (= ${binary:Version}),
+ librust-proxmox-match-expression-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-match-expression-0.1.0-dev (= ${binary:Version}),
+ librust-proxmox-match-expression-0.1.0+default-dev (= ${binary:Version})
+Description: Evaluation logic for nested match expressions - Rust source code
+ Source code for Debianized Rust crate "proxmox-match-expression"
diff --git a/proxmox-match-expression/debian/copyright b/proxmox-match-expression/debian/copyright
new file mode 100644
index 00000000..279c2689
--- /dev/null
+++ b/proxmox-match-expression/debian/copyright
@@ -0,0 +1,18 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+
+Files:
+ *
+Copyright: 2024 - 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License along
+ with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/proxmox-match-expression/debian/debcargo.toml b/proxmox-match-expression/debian/debcargo.toml
new file mode 100644
index 00000000..b7864cdb
--- /dev/null
+++ b/proxmox-match-expression/debian/debcargo.toml
@@ -0,0 +1,7 @@
+overlay = "."
+crate_src_path = ".."
+maintainer = "Proxmox Support Team <support@proxmox.com>"
+
+[source]
+vcs_git = "git://git.proxmox.com/git/proxmox.git"
+vcs_browser = "https://git.proxmox.com/?p=proxmox.git"
diff --git a/proxmox-match-expression/src/lib.rs b/proxmox-match-expression/src/lib.rs
new file mode 100644
index 00000000..da07e1da
--- /dev/null
+++ b/proxmox-match-expression/src/lib.rs
@@ -0,0 +1,524 @@
+//! De/serializable expression evaluation with custom leaf-nodes.
+//!
+//! ## Example:
+//!```
+//! use serde::{Serialize, Deserialize};
+//! use pretty_assertions::{assert_eq, assert_str_eq};
+//!
+//! use proxmox_match_expression::{any_of, Expression, MatchExpression};
+//!
+//! #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
+//! #[serde(rename_all = "kebab-case", tag = "type")]
+//! enum StringMatcher {
+//! AllUppercase,
+//! Content { value: String },
+//! }
+//!
+//! impl MatchExpression for StringMatcher {
+//! type Data = String;
+//! type Error = ();
+//!
+//! fn evaluate(&self, data: &Self::Data) -> Result<bool, ()> {
+//! match self {
+//! StringMatcher::AllUppercase => Ok(&data.to_uppercase() == data),
+//! StringMatcher::Content { value } => Ok(value == data),
+//! }
+//! }
+//! }
+//!
+//! impl From<StringMatcher> for Expression<StringMatcher> {
+//! fn from(matcher: StringMatcher) -> Self {
+//! Expression::Match(matcher)
+//! }
+//! }
+//!
+//! // You can use macros to conveniently construct expressions in code.
+//! let expr = any_of![
+//! StringMatcher::Content {
+//! value: "foo".to_string(),
+//! }
+//! .into(),
+//! StringMatcher::AllUppercase.into(),
+//! ];
+//!
+//! // Expressions implement `[Serialize]` and `[Deserialize]`
+//! let expected_serialized_expr = r#"{
+//! "any-of": [
+//! {
+//! "match": {
+//! "type": "content",
+//! "value": "foo"
+//! }
+//! },
+//! {
+//! "match": {
+//! "type": "all-uppercase"
+//! }
+//! }
+//! ]
+//!}"#;
+//!
+//! assert_eq!(serde_json::to_string_pretty(&expr).unwrap(), expected_serialized_expr);
+//!
+//! assert!(expr.evaluate(&"foo".to_string()).unwrap().is_match());
+//! assert!(!expr.evaluate(&"fo".to_string()).unwrap().is_match());
+//! assert!(!expr.evaluate(&"fooo".to_string()).unwrap().is_match());
+//!
+//! let evaluated_expression = expr.evaluate(&"AAAAA".to_string()).unwrap();
+//! assert!(evaluated_expression.is_match());
+//!
+//! // `[Expression::evaluate]` returns [`EvaluatedExpression`], which contains a full trace
+//! // of all expressions that were evaluated.
+//!
+//! let serialized = serde_json::to_string_pretty(&evaluated_expression).unwrap();
+//! let expected = r#"{
+//! "any-of": [
+//! {
+//! "match": {
+//! "type": "content",
+//! "value": "foo"
+//! },
+//! "matches": false
+//! },
+//! {
+//! "match": {
+//! "type": "all-uppercase"
+//! },
+//! "matches": true
+//! }
+//! ],
+//! "matches": true
+//!}"#;
+//!
+//! assert_str_eq!(serialized, expected);
+//!
+//! assert!(!evaluated_expression.children()[0].is_match());
+//! assert!(evaluated_expression.children()[1].is_match());
+//!
+//! assert_eq!(evaluated_expression.children()[1].children(), &[]);
+//!
+//! ```
+
+use serde::{Deserialize, Serialize};
+
+/// Trait for custom expression leaf-nodes.
+pub trait MatchExpression {
+ /// Use this type to specify the type of the data the expression is evaluated against.
+ type Data;
+
+ /// Error type returned when evaluating this custom expression.
+ type Error;
+
+ /// Evaluate this expression.
+ ///
+ /// If there are no errors, this should return `Ok(true)` or `Ok(false)`, depending on
+ /// whether this leaf-node expression matched the provided data or not.
+ fn evaluate(&self, data: &Self::Data) -> Result<bool, Self::Error>;
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+/// Match expression.
+pub enum Expression<M> {
+ /// Match if all of the sub-expression match.
+ AllOf(Vec<Expression<M>>),
+ /// Match if any of the sub-expressions matches.
+ AnyOf(Vec<Expression<M>>),
+ /// Match if exactly one of the sub-expressions matches.
+ OneOf(Vec<Expression<M>>),
+ /// Match if the sub-expression does not match.
+ Not(Box<Expression<M>>),
+ /// Constant value (true or false).
+ Constant(bool),
+ /// Custom matcher leaf-nodes.
+ Match(M),
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+/// Evaluated match expression.
+pub enum EvaluatedExpression<M> {
+ /// Match if all of the sub-expressions match.
+ AllOf(Vec<EvaluatedExpressionWithResult<M>>),
+ /// Match if any of the sub-expressions matches.
+ AnyOf(Vec<EvaluatedExpressionWithResult<M>>),
+ /// Match if exactly one of the sub-expressions matches.
+ OneOf(Vec<EvaluatedExpressionWithResult<M>>),
+ /// Match if the sub-expression does not match.
+ Not(Box<EvaluatedExpressionWithResult<M>>),
+ /// Constant value (true or false).
+ Constant(bool),
+ /// Custom matcher leaf-nodes.
+ Match(M),
+}
+
+/// Evaluated expression resulting from [`Expression::evaluate`].
+///
+/// This type contains the original expression, augmented with per-node
+/// results. This is useful for recording 'traces', e.g. for recording
+/// exactly why an expression matched on the data or not.
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
+pub struct EvaluatedExpressionWithResult<M> {
+ /// The actual expression.
+ #[serde(flatten)]
+ expression: EvaluatedExpression<M>,
+ /// The evaluated expression matched the provided data.
+ matches: bool,
+}
+
+impl<M> EvaluatedExpressionWithResult<M> {
+ /// Return whether the expression matched the provided data.
+ pub fn is_match(&self) -> bool {
+ self.matches
+ }
+
+ /// Borrow the contained [`EvaluatedExpression`].
+ pub fn expression(&self) -> &EvaluatedExpression<M> {
+ &self.expression
+ }
+
+ /// Return the contained [`EvaluatedExpression`], consuming `self`.
+ pub fn into_expression(self) -> EvaluatedExpression<M> {
+ self.expression
+ }
+
+ /// The direct child nodes, uniform across all combinator kinds.
+ ///
+ /// 'not' yields its single operand as a one-element slice; leaf nodes
+ /// ('constant', 'match') yield an empty slice. This lets a generic tree
+ /// walker recurse without special-casing each variant.
+ pub fn children(&self) -> &[EvaluatedExpressionWithResult<M>] {
+ match &self.expression {
+ EvaluatedExpression::AllOf(expressions)
+ | EvaluatedExpression::AnyOf(expressions)
+ | EvaluatedExpression::OneOf(expressions) => expressions,
+ EvaluatedExpression::Not(expression) => std::slice::from_ref(&**expression),
+ EvaluatedExpression::Constant(_) | EvaluatedExpression::Match(_) => &[],
+ }
+ }
+}
+
+impl<M> From<EvaluatedExpressionWithResult<M>> for EvaluatedExpression<M> {
+ fn from(value: EvaluatedExpressionWithResult<M>) -> Self {
+ value.into_expression()
+ }
+}
+
+impl<M, D, E> Expression<M>
+where
+ M: Clone + MatchExpression<Data = D, Error = E>,
+{
+ /// Evaluate this expression against a provided variable.
+ ///
+ /// This function returns an `EvaluatedExpression`, replicating the exact structure
+ /// of `self`, but annotated with a per-subexpression trace to record which of
+ /// the subexpressions matched or not.
+ ///
+ /// # Note
+ /// This evaluates the expression and *all* sub-expressions fully and does not return
+ /// early if the final result is already determined by the already evaluated sub-expressions
+ /// (for instance, [`Expression::AnyOf`] and the first sub-expression matched).
+ /// This is done so that we can return the full trace via the [`EvaluatedExpressionWithResult`]
+ /// type.
+ ///
+ /// If you need fast evaluation and do not care about the trace, feel free to add a
+ /// `evaluate_fast` (or similar), which does early returns as appropriate and simply returns
+ /// `bool`.
+ pub fn evaluate(&self, data: &D) -> Result<EvaluatedExpressionWithResult<M>, E> {
+ let evaluated_expression = match self {
+ Expression::AllOf(expressions) => {
+ let (matches, evaluated_expressions) =
+ Self::evaluate_subexpressions(data, expressions, true, |a, b| a && b)?;
+
+ EvaluatedExpressionWithResult {
+ expression: EvaluatedExpression::AllOf(evaluated_expressions),
+ matches,
+ }
+ }
+ Expression::AnyOf(expressions) => {
+ let (matches, evaluated_expressions) =
+ Self::evaluate_subexpressions(data, expressions, false, |a, b| a || b)?;
+
+ EvaluatedExpressionWithResult {
+ expression: EvaluatedExpression::AnyOf(evaluated_expressions),
+ matches,
+ }
+ }
+ Expression::OneOf(expressions) => {
+ let mut matched_count = 0;
+ let mut evaluated_expressions = Vec::new();
+
+ for expression in expressions {
+ let evaluated_expression = expression.evaluate(data)?;
+ if evaluated_expression.is_match() {
+ matched_count += 1;
+ }
+ evaluated_expressions.push(evaluated_expression)
+ }
+
+ EvaluatedExpressionWithResult {
+ expression: EvaluatedExpression::OneOf(evaluated_expressions),
+ matches: matched_count == 1,
+ }
+ }
+ Expression::Not(expression) => {
+ let evaluated_expression = expression.evaluate(data)?;
+ let matches = !evaluated_expression.is_match();
+ EvaluatedExpressionWithResult {
+ expression: EvaluatedExpression::Not(Box::new(evaluated_expression)),
+ matches,
+ }
+ }
+ Expression::Constant(value) => EvaluatedExpressionWithResult {
+ expression: EvaluatedExpression::Constant(*value),
+ matches: *value,
+ },
+ Expression::Match(inner) => {
+ let matches = inner.evaluate(data)?;
+ EvaluatedExpressionWithResult {
+ expression: EvaluatedExpression::Match(inner.clone()),
+ matches,
+ }
+ }
+ };
+
+ Ok(evaluated_expression)
+ }
+
+ /// The direct child nodes, uniform across all combinator kinds.
+ ///
+ /// 'not' yields its single operand as a one-element slice; leaf nodes
+ /// ('constant', 'match') yield an empty slice. This lets a generic tree
+ /// walker recurse without special-casing each variant.
+ pub fn children(&self) -> &[Expression<M>] {
+ match &self {
+ Expression::AllOf(expressions)
+ | Expression::AnyOf(expressions)
+ | Expression::OneOf(expressions) => expressions,
+ Expression::Not(expression) => std::slice::from_ref(&**expression),
+ Expression::Constant(_) | Expression::Match(_) => &[],
+ }
+ }
+
+ fn evaluate_subexpressions(
+ data: &D,
+ expressions: &[Expression<M>],
+ initial: bool,
+ op: impl Fn(bool, bool) -> bool,
+ ) -> Result<(bool, Vec<EvaluatedExpressionWithResult<M>>), E> {
+ let mut matches = initial;
+
+ let mut evaluated_expressions = Vec::new();
+
+ for expression in expressions {
+ let evaluated_expression = expression.evaluate(data)?;
+ matches = op(matches, evaluated_expression.is_match());
+ evaluated_expressions.push(evaluated_expression);
+ }
+
+ Ok((matches, evaluated_expressions))
+ }
+}
+
+/// Convenience macro that can be used to create an 'AnyOf' expression.
+///
+/// ```
+/// use proxmox_match_expression::{any_of, Expression};
+///
+/// let x: Expression<()> = any_of![
+/// Expression::Constant(true),
+/// ];
+/// assert_eq!(x, Expression::AnyOf(vec![Expression::Constant(true)]));
+/// ```
+///
+#[macro_export]
+macro_rules! any_of {
+ ($($x:expr),* $(,)?) => (
+ $crate::Expression::AnyOf(vec![$($x),*])
+ );
+}
+
+/// Convenience macro that can be used to create an 'AllOf' expression.
+///
+/// ```
+/// use proxmox_match_expression::{all_of, Expression};
+///
+/// let x: Expression<()> = all_of![
+/// Expression::Constant(true),
+/// ];
+/// assert_eq!(x, Expression::AllOf(vec![Expression::Constant(true)]));
+/// ```
+#[macro_export]
+macro_rules! all_of {
+ ($($x:expr),* $(,)?) => (
+ $crate::Expression::AllOf(vec![$($x),*])
+ );
+}
+
+/// Convenience macro that can be used to create an 'OneOf' expression.
+///
+/// ```
+/// use proxmox_match_expression::{one_of, Expression};
+///
+/// let x: Expression<()> = one_of![
+/// Expression::Constant(true),
+/// ];
+/// assert_eq!(x, Expression::OneOf(vec![Expression::Constant(true)]));
+/// ```
+#[macro_export]
+macro_rules! one_of {
+ ($($x:expr),* $(,)?) => (
+ $crate::Expression::OneOf(vec![$($x),*])
+ );
+}
+
+/// Convenience macro that can be used to create a 'Not' expression;
+///
+/// ```
+/// use proxmox_match_expression::{not, Expression};
+///
+/// let x: Expression<()> = not!(Expression::Constant(true));
+/// assert_eq!(x, Expression::Not(Box::new(Expression::Constant(true))));
+/// ```
+#[macro_export]
+macro_rules! not {
+ ($e:expr) => {
+ $crate::Expression::Not(Box::new($e))
+ };
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[derive(Serialize, Deserialize, Debug, Clone)]
+ #[serde(rename_all = "kebab-case", tag = "type")]
+ pub enum TestMatcher {
+ Failure,
+ True,
+ False,
+ }
+
+ impl MatchExpression for TestMatcher {
+ type Data = ();
+ type Error = ();
+
+ fn evaluate(&self, _data: &Self::Data) -> Result<bool, ()> {
+ match self {
+ TestMatcher::Failure => Err(()),
+ TestMatcher::True => Ok(true),
+ TestMatcher::False => Ok(false),
+ }
+ }
+ }
+
+ fn eval(expr: Expression<TestMatcher>) -> Result<bool, ()> {
+ let e = expr.evaluate(&())?;
+
+ Ok(e.is_match())
+ }
+
+ #[test]
+ fn test_all_of() {
+ let expr = all_of![Expression::Constant(true), Expression::Constant(false)];
+ assert!(!eval(expr).unwrap());
+
+ let expr = all_of![Expression::Constant(true), Expression::Constant(true)];
+ assert!(eval(expr).unwrap());
+
+ let expr = all_of![Expression::Constant(true)];
+ assert!(eval(expr).unwrap());
+
+ let expr: Expression<TestMatcher> = all_of![];
+ assert!(eval(expr).unwrap());
+
+ let expr = all_of![Expression::Match(TestMatcher::Failure)];
+ assert!(eval(expr).is_err());
+ }
+
+ #[test]
+ fn test_any_of() {
+ let expr = any_of![Expression::Constant(true), Expression::Constant(false)];
+ assert!(eval(expr).unwrap());
+
+ let expr = any_of![Expression::Constant(false), Expression::Constant(false)];
+ assert!(!eval(expr).unwrap());
+
+ let expr = any_of![Expression::Constant(true)];
+ assert!(eval(expr).unwrap());
+
+ let expr: Expression<TestMatcher> = any_of![];
+ assert!(!eval(expr).unwrap());
+
+ let expr = any_of![Expression::Match(TestMatcher::Failure)];
+ assert!(eval(expr).is_err());
+ }
+
+ #[test]
+ fn test_one_of() {
+ let expr = one_of![Expression::Constant(true), Expression::Constant(false)];
+ assert!(eval(expr).unwrap());
+
+ let expr = one_of![Expression::Constant(false)];
+ assert!(!eval(expr).unwrap());
+
+ let expr = one_of![Expression::Constant(true)];
+ assert!(eval(expr).unwrap());
+
+ let expr: Expression<TestMatcher> = one_of![];
+ assert!(!eval(expr).unwrap());
+
+ let expr = one_of![Expression::Match(TestMatcher::Failure)];
+ assert!(eval(expr).is_err());
+
+ // Exactly one of three sub-expressions matches.
+ let expr = one_of![
+ Expression::Constant(false),
+ Expression::Constant(true),
+ Expression::Constant(false),
+ ];
+ assert!(eval(expr).unwrap());
+
+ // Two of three match. This must *not* match: it distinguishes
+ // 'exactly one' from XOR-parity, which would report a match for
+ // any odd number of matching sub-expressions.
+ let expr = one_of![
+ Expression::Constant(true),
+ Expression::Constant(true),
+ Expression::Constant(false),
+ ];
+ assert!(!eval(expr).unwrap());
+
+ // Three of three match. XOR-parity would (incorrectly) report a
+ // match here, since three is odd.
+ let expr = one_of![
+ Expression::Constant(true),
+ Expression::Constant(true),
+ Expression::Constant(true),
+ ];
+ assert!(!eval(expr).unwrap());
+
+ // None of three match.
+ let expr = one_of![
+ Expression::Constant(false),
+ Expression::Constant(false),
+ Expression::Constant(false),
+ ];
+ assert!(!eval(expr).unwrap());
+ }
+
+ #[test]
+ fn test_not() {
+ let expr = not!(Expression::Constant(false));
+ assert!(eval(expr).unwrap());
+ }
+
+ #[test]
+ fn test_constant() {
+ let expr: Expression<TestMatcher> = Expression::Constant(true);
+ assert!(eval(expr).unwrap());
+
+ let expr: Expression<TestMatcher> = Expression::Constant(false);
+ assert!(!eval(expr).unwrap());
+ }
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 02/34] notify: log which matchers matched on a notification
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 01/34] add new proxmox-match-expression crate Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 03/34] notify: promote matcher to dir-style module Lukas Wagner
` (31 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
This should make debugging notification issues much easier. Eventually,
this information shall also be recorded in a notification history, but
adding the log output is a valuable addition until then.
Suggested-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/matcher.rs | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/proxmox-notify/src/matcher.rs b/proxmox-notify/src/matcher.rs
index 9964e4bf..cce86b6c 100644
--- a/proxmox-notify/src/matcher.rs
+++ b/proxmox-notify/src/matcher.rs
@@ -442,6 +442,7 @@ pub fn check_matches<'a>(
notification: &Notification,
) -> HashSet<&'a str> {
let mut targets = HashSet::new();
+ let mut matching_matchers = Vec::new();
for matcher in matchers {
if matcher.disable.unwrap_or_default() {
@@ -451,14 +452,27 @@ pub fn check_matches<'a>(
}
match matcher.matches(notification) {
- Ok(t) => {
- let t = t.unwrap_or_default();
+ Ok(Some(t)) => {
+ matching_matchers.push(matcher.name.clone());
targets.extend(t.iter().map(|s| s.as_str()));
}
+ Ok(None) => {}
Err(err) => error!("matcher '{matcher}' failed: {err}", matcher = matcher.name),
}
}
+ if !matching_matchers.is_empty() {
+ let target_list = Vec::from_iter(targets.clone());
+
+ info!(
+ "notification matched the following matchers: {}. The following targets will be notified: {}",
+ matching_matchers.join(", "),
+ target_list.join(", ")
+ );
+ } else {
+ info!("notification was not matched by any matcher");
+ }
+
targets
}
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 03/34] notify: promote matcher to dir-style module
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 01/34] add new proxmox-match-expression crate Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 02/34] notify: log which matchers matched on a notification Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 04/34] notify: fix doc comment Lukas Wagner
` (30 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/{matcher.rs => matcher/mod.rs} | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename proxmox-notify/src/{matcher.rs => matcher/mod.rs} (100%)
diff --git a/proxmox-notify/src/matcher.rs b/proxmox-notify/src/matcher/mod.rs
similarity index 100%
rename from proxmox-notify/src/matcher.rs
rename to proxmox-notify/src/matcher/mod.rs
--
2.47.3
^ permalink raw reply [flat|nested] 35+ messages in thread* [PATCH proxmox v2 04/34] notify: fix doc comment
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (2 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 03/34] notify: promote matcher to dir-style module Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 05/34] notify: matcher: break out severity matcher into submodule Lukas Wagner
` (29 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/matcher/mod.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index cce86b6c..9d9d5d42 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -108,7 +108,7 @@ pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata f
})]
#[derive(Debug, Serialize, Deserialize, Updater, Default)]
#[serde(rename_all = "kebab-case")]
-/// Config for Sendmail notification endpoints
+/// Config for notification matchers.
pub struct MatcherConfig {
/// Name of the matcher.
#[updater(skip)]
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 05/34] notify: matcher: break out severity matcher into submodule
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (3 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 04/34] notify: fix doc comment Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 06/34] notify: matcher: break out field " Lukas Wagner
` (28 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
No functional changes.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/matcher/mod.rs | 56 +++-------------------
proxmox-notify/src/matcher/severity.rs | 66 ++++++++++++++++++++++++++
2 files changed, 73 insertions(+), 49 deletions(-)
create mode 100644 proxmox-notify/src/matcher/severity.rs
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 9d9d5d42..b27f72c0 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -13,7 +13,11 @@ use proxmox_schema::{ApiStringFormat, Schema, StringSchema, Updater, api, const_
use proxmox_time::{DailyDuration, parse_daily_duration};
use crate::schema::ENTITY_NAME_SCHEMA;
-use crate::{Error, Notification, Origin, Severity};
+use crate::{Error, Notification, Origin};
+
+pub mod severity;
+
+use severity::SeverityMatcher;
pub const MATCHER_TYPENAME: &str = "matcher";
@@ -337,46 +341,6 @@ impl MatcherConfig {
}
}
-/// Match severity of the notification.
-#[derive(Clone, Debug)]
-pub struct SeverityMatcher {
- severities: Vec<Severity>,
-}
-
-proxmox_serde::forward_deserialize_to_from_str!(SeverityMatcher);
-proxmox_serde::forward_serialize_to_display!(SeverityMatcher);
-
-/// Common trait implemented by all matching directives
-impl MatchDirective for SeverityMatcher {
- /// Check if this directive matches a given notification
- fn matches(&self, notification: &Notification) -> Result<bool, Error> {
- Ok(self.severities.contains(¬ification.metadata.severity))
- }
-}
-
-impl fmt::Display for SeverityMatcher {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- let severities: Vec<String> = self.severities.iter().map(|s| format!("{s}")).collect();
- f.write_str(&severities.join(","))
- }
-}
-
-impl FromStr for SeverityMatcher {
- 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 { severities })
- }
-}
-
/// Match timestamp of the notification.
#[derive(Clone, Debug)]
pub struct CalendarMatcher {
@@ -478,6 +442,8 @@ pub fn check_matches<'a>(
#[cfg(test)]
mod tests {
+ use crate::Severity;
+
use super::*;
use serde_json::Value;
use std::collections::HashMap;
@@ -519,14 +485,6 @@ mod tests {
assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err());
}
- #[test]
- fn test_severities() {
- let notification =
- Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
-
- let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
- assert!(matcher.matches(¬ification).unwrap());
- }
#[test]
fn test_empty_matcher_matches_always() {
diff --git a/proxmox-notify/src/matcher/severity.rs b/proxmox-notify/src/matcher/severity.rs
new file mode 100644
index 00000000..4b126d85
--- /dev/null
+++ b/proxmox-notify/src/matcher/severity.rs
@@ -0,0 +1,66 @@
+use std::fmt;
+use std::str::FromStr;
+
+use crate::{Error, Notification, Severity};
+
+use super::MatchDirective;
+
+/// Match severity of the notification.
+#[derive(Clone, Debug)]
+pub struct SeverityMatcher {
+ severities: Vec<Severity>,
+}
+
+/// Common trait implemented by all matching directives
+impl MatchDirective for SeverityMatcher {
+ /// Check if this directive matches a given notification
+ fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+ Ok(self.severities.contains(¬ification.metadata.severity))
+ }
+}
+
+impl fmt::Display for SeverityMatcher {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ let severities: Vec<String> = self.severities.iter().map(|s| format!("{s}")).collect();
+ f.write_str(&severities.join(","))
+ }
+}
+
+impl FromStr for SeverityMatcher {
+ 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 { severities })
+ }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(SeverityMatcher);
+proxmox_serde::forward_serialize_to_display!(SeverityMatcher);
+
+#[cfg(test)]
+mod test {
+ use serde_json::Value;
+
+ use crate::{
+ Notification, Severity,
+ matcher::{MatchDirective as _, severity::SeverityMatcher},
+ };
+
+ #[test]
+ fn test_severities() {
+ let notification =
+ Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
+
+ let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
+ assert!(matcher.matches(¬ification).unwrap());
+ }
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 06/34] notify: matcher: break out field matcher into submodule
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (4 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 05/34] notify: matcher: break out severity matcher into submodule Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 07/34] notify: matcher: break out calendar " Lukas Wagner
` (27 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
No functional changes.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/matcher/field.rs | 201 ++++++++++++++++++++++++++++
proxmox-notify/src/matcher/mod.rs | 184 +------------------------
2 files changed, 205 insertions(+), 180 deletions(-)
create mode 100644 proxmox-notify/src/matcher/field.rs
diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs
new file mode 100644
index 00000000..8165b1f4
--- /dev/null
+++ b/proxmox-notify/src/matcher/field.rs
@@ -0,0 +1,201 @@
+use std::{fmt, str::FromStr};
+
+use const_format::concatcp;
+use regex::Regex;
+
+use proxmox_schema::{
+ ApiStringFormat, Schema, StringSchema, api_types::SAFE_ID_REGEX_STR, const_regex,
+};
+
+use crate::{Error, Notification};
+
+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_field_matcher);
+
+fn verify_field_matcher(s: &str) -> Result<(), anyhow::Error> {
+ let _: FieldMatcher = 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)]
+pub enum FieldMatcher {
+ Exact {
+ field: String,
+ matched_values: Vec<String>,
+ },
+ Regex {
+ field: String,
+ matched_regex: Regex,
+ },
+}
+
+impl MatchDirective for FieldMatcher {
+ fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+ Ok(match self {
+ FieldMatcher::Exact {
+ field,
+ matched_values,
+ } => {
+ let value = notification.metadata.additional_fields.get(field);
+
+ if let Some(value) = value {
+ matched_values.contains(value)
+ } else {
+ // Metadata field does not exist, so we do not match
+ false
+ }
+ }
+ FieldMatcher::Regex {
+ field,
+ matched_regex,
+ } => {
+ let value = notification.metadata.additional_fields.get(field);
+
+ if let Some(value) = value {
+ matched_regex.is_match(value)
+ } else {
+ // Metadata field does not exist, so we do not match
+ false
+ }
+ }
+ })
+ }
+}
+
+impl fmt::Display for FieldMatcher {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ // Attention, Display is used to implement Serialize, do not
+ // change the format.
+
+ match self {
+ FieldMatcher::Exact {
+ field,
+ matched_values,
+ } => {
+ let values = matched_values.join(",");
+ write!(f, "exact:{field}={values}")
+ }
+ FieldMatcher::Regex {
+ field,
+ matched_regex,
+ } => {
+ let re = matched_regex.as_str();
+ write!(f, "regex:{field}={re}")
+ }
+ }
+ }
+}
+
+impl FromStr for FieldMatcher {
+ 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::Regex {
+ field: field.into(),
+ matched_regex: 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::Exact {
+ field: field.into(),
+ matched_values: values,
+ })
+ }
+ }
+ } else {
+ Err(Error::FilterFailed(format!(
+ "invalid match-field statement: {s}"
+ )))
+ }
+ }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher);
+proxmox_serde::forward_serialize_to_display!(FieldMatcher);
+
+#[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: FieldMatcher = "exact:foo=bar".parse().unwrap();
+ assert!(matcher.matches(¬ification).unwrap());
+
+ let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap();
+ assert!(matcher.matches(¬ification).unwrap());
+
+ let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap();
+ assert!(!matcher.matches(¬ification).unwrap());
+
+ let matcher: FieldMatcher = "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::<FieldMatcher>().is_err());
+ assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err());
+ }
+}
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index b27f72c0..dd0e25b5 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -15,8 +15,10 @@ use proxmox_time::{DailyDuration, parse_daily_duration};
use crate::schema::ENTITY_NAME_SCHEMA;
use crate::{Error, Notification, Origin};
+pub mod field;
pub mod severity;
+use field::FieldMatcher;
use severity::SeverityMatcher;
pub const MATCHER_TYPENAME: &str = "matcher";
@@ -51,24 +53,6 @@ impl MatchModeOperator {
}
}
-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_field_matcher);
-
-fn verify_field_matcher(s: &str) -> Result<(), anyhow::Error> {
- let _: FieldMatcher = 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();
-
#[api(
properties: {
name: {
@@ -163,128 +147,6 @@ trait MatchDirective {
fn matches(&self, notification: &Notification) -> Result<bool, Error>;
}
-/// Check if the notification metadata fields match
-#[derive(Clone, Debug)]
-pub enum FieldMatcher {
- Exact {
- field: String,
- matched_values: Vec<String>,
- },
- Regex {
- field: String,
- matched_regex: Regex,
- },
-}
-
-proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher);
-proxmox_serde::forward_serialize_to_display!(FieldMatcher);
-
-impl MatchDirective for FieldMatcher {
- fn matches(&self, notification: &Notification) -> Result<bool, Error> {
- Ok(match self {
- FieldMatcher::Exact {
- field,
- matched_values,
- } => {
- let value = notification.metadata.additional_fields.get(field);
-
- if let Some(value) = value {
- matched_values.contains(value)
- } else {
- // Metadata field does not exist, so we do not match
- false
- }
- }
- FieldMatcher::Regex {
- field,
- matched_regex,
- } => {
- let value = notification.metadata.additional_fields.get(field);
-
- if let Some(value) = value {
- matched_regex.is_match(value)
- } else {
- // Metadata field does not exist, so we do not match
- false
- }
- }
- })
- }
-}
-
-impl fmt::Display for FieldMatcher {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- // Attention, Display is used to implement Serialize, do not
- // change the format.
-
- match self {
- FieldMatcher::Exact {
- field,
- matched_values,
- } => {
- let values = matched_values.join(",");
- write!(f, "exact:{field}={values}")
- }
- FieldMatcher::Regex {
- field,
- matched_regex,
- } => {
- let re = matched_regex.as_str();
- write!(f, "regex:{field}={re}")
- }
- }
- }
-}
-
-impl FromStr for FieldMatcher {
- 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::Regex {
- field: field.into(),
- matched_regex: 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::Exact {
- field: field.into(),
- matched_values: values,
- })
- }
- }
- } else {
- Err(Error::FilterFailed(format!(
- "invalid match-field statement: {s}"
- )))
- }
- }
-}
-
impl MatcherConfig {
pub fn matches(&self, notification: &Notification) -> Result<Option<&[String]>, Error> {
let mode = self.mode.unwrap_or_default();
@@ -442,49 +304,11 @@ pub fn check_matches<'a>(
#[cfg(test)]
mod tests {
+ use serde_json::Value;
+
use crate::Severity;
use super::*;
- use serde_json::Value;
- use std::collections::HashMap;
-
- #[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: FieldMatcher = "exact:foo=bar".parse().unwrap();
- assert!(matcher.matches(¬ification).unwrap());
-
- let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap();
- assert!(matcher.matches(¬ification).unwrap());
-
- let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap();
- assert!(!matcher.matches(¬ification).unwrap());
-
- let matcher: FieldMatcher = "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::<FieldMatcher>().is_err());
- assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err());
- }
#[test]
fn test_empty_matcher_matches_always() {
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 07/34] notify: matcher: break out calendar matcher into submodule
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (5 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 06/34] notify: matcher: break out field " Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 08/34] notify: matcher: calendar: add basic unit test Lukas Wagner
` (26 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
No functional changes.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/matcher/calendar.rs | 46 ++++++++++++++++++++++++
proxmox-notify/src/matcher/mod.rs | 48 +++-----------------------
2 files changed, 50 insertions(+), 44 deletions(-)
create mode 100644 proxmox-notify/src/matcher/calendar.rs
diff --git a/proxmox-notify/src/matcher/calendar.rs b/proxmox-notify/src/matcher/calendar.rs
new file mode 100644
index 00000000..5deab13e
--- /dev/null
+++ b/proxmox-notify/src/matcher/calendar.rs
@@ -0,0 +1,46 @@
+use std::fmt;
+use std::str::FromStr;
+
+use proxmox_time::DailyDuration;
+
+use crate::{Error, Notification};
+
+use super::MatchDirective;
+
+/// Match timestamp of the notification.
+#[derive(Clone, Debug)]
+pub struct CalendarMatcher {
+ schedule: DailyDuration,
+ original: String,
+}
+
+impl MatchDirective for CalendarMatcher {
+ fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+ self.schedule
+ .time_match(notification.metadata.timestamp, false)
+ .map_err(|err| Error::Generic(format!("could not match timestamp: {err}")))
+ }
+}
+
+impl fmt::Display for CalendarMatcher {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str(&self.original)
+ }
+}
+
+impl FromStr for CalendarMatcher {
+ type Err = Error;
+
+ fn from_str(s: &str) -> Result<Self, Error> {
+ let schedule = proxmox_time::parse_daily_duration(s)
+ .map_err(|e| Error::Generic(format!("could not parse schedule: {e}")))?;
+
+ Ok(Self {
+ schedule,
+ original: s.to_string(),
+ })
+ }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(CalendarMatcher);
+proxmox_serde::forward_serialize_to_display!(CalendarMatcher);
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index dd0e25b5..48aaa351 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -1,23 +1,20 @@
use std::collections::HashSet;
-use std::fmt;
use std::fmt::Debug;
-use std::str::FromStr;
-use const_format::concatcp;
-use regex::Regex;
use serde::{Deserialize, Serialize};
use tracing::{error, info};
-use proxmox_schema::api_types::{COMMENT_SCHEMA, SAFE_ID_REGEX_STR};
-use proxmox_schema::{ApiStringFormat, Schema, StringSchema, Updater, api, const_regex};
-use proxmox_time::{DailyDuration, parse_daily_duration};
+use proxmox_schema::api_types::COMMENT_SCHEMA;
+use proxmox_schema::{Updater, api};
use crate::schema::ENTITY_NAME_SCHEMA;
use crate::{Error, Notification, Origin};
+pub mod calendar;
pub mod field;
pub mod severity;
+use calendar::CalendarMatcher;
use field::FieldMatcher;
use severity::SeverityMatcher;
@@ -203,43 +200,6 @@ impl MatcherConfig {
}
}
-/// Match timestamp of the notification.
-#[derive(Clone, Debug)]
-pub struct CalendarMatcher {
- schedule: DailyDuration,
- original: String,
-}
-
-proxmox_serde::forward_deserialize_to_from_str!(CalendarMatcher);
-proxmox_serde::forward_serialize_to_display!(CalendarMatcher);
-
-impl MatchDirective for CalendarMatcher {
- fn matches(&self, notification: &Notification) -> Result<bool, Error> {
- self.schedule
- .time_match(notification.metadata.timestamp, false)
- .map_err(|err| Error::Generic(format!("could not match timestamp: {err}")))
- }
-}
-
-impl fmt::Display for CalendarMatcher {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- f.write_str(&self.original)
- }
-}
-
-impl FromStr for CalendarMatcher {
- type Err = Error;
- fn from_str(s: &str) -> Result<Self, Error> {
- let schedule = parse_daily_duration(s)
- .map_err(|e| Error::Generic(format!("could not parse schedule: {e}")))?;
-
- Ok(Self {
- schedule,
- original: s.to_string(),
- })
- }
-}
-
#[api]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 08/34] notify: matcher: calendar: add basic unit test
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (6 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 07/34] notify: matcher: break out calendar " Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 09/34] notify: matcher: add InlineSeverityMatcher Lukas Wagner
` (25 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
Just a basic smoke test to ensure that the matcher can be constructed
from from a calendar expression.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/matcher/calendar.rs | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/proxmox-notify/src/matcher/calendar.rs b/proxmox-notify/src/matcher/calendar.rs
index 5deab13e..970801c5 100644
--- a/proxmox-notify/src/matcher/calendar.rs
+++ b/proxmox-notify/src/matcher/calendar.rs
@@ -44,3 +44,26 @@ impl FromStr for CalendarMatcher {
proxmox_serde::forward_deserialize_to_from_str!(CalendarMatcher);
proxmox_serde::forward_serialize_to_display!(CalendarMatcher);
+
+#[cfg(test)]
+mod test {
+ use serde_json::Value;
+
+ use crate::{Notification, Severity, matcher::MatchDirective as _};
+
+ use super::*;
+
+ #[test]
+ fn test_calendar_matcher() {
+ let mut notification =
+ Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
+
+ // Fr 22 Mai 2026 12:45:00 CEST
+ notification.metadata.timestamp = 1779446700;
+
+ // Match on a wide rage to avoid issues when running this test case
+ // in a different time zone.
+ let matcher: CalendarMatcher = "thu..sat 0-23".parse().unwrap();
+ assert!(matcher.matches(¬ification).unwrap());
+ }
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 09/34] notify: matcher: add InlineSeverityMatcher
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (7 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 08/34] notify: matcher: calendar: add basic unit test Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 10/34] notify: matcher: add InlineFieldMatcher Lukas Wagner
` (24 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
This move the inline serialization format into a new-type wrapper around
SeverityMatcher.
This allows us to keep the existing single-line serialization format for
the old configuration keys (match-calendar), while deriving a regular
Serializer for SeverityMatcher that will be used in the new expression
based matcher.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/matcher/mod.rs | 4 +--
proxmox-notify/src/matcher/severity.rs | 41 ++++++++++++++++++--------
2 files changed, 31 insertions(+), 14 deletions(-)
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 48aaa351..2aef6949 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -16,7 +16,7 @@ pub mod severity;
use calendar::CalendarMatcher;
use field::FieldMatcher;
-use severity::SeverityMatcher;
+use severity::InlineSeverityMatcher;
pub const MATCHER_TYPENAME: &str = "matcher";
@@ -107,7 +107,7 @@ pub struct MatcherConfig {
/// 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<SeverityMatcher>,
+ pub match_severity: Vec<InlineSeverityMatcher>,
/// List of matched severity levels.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
diff --git a/proxmox-notify/src/matcher/severity.rs b/proxmox-notify/src/matcher/severity.rs
index 4b126d85..e12f2260 100644
--- a/proxmox-notify/src/matcher/severity.rs
+++ b/proxmox-notify/src/matcher/severity.rs
@@ -11,7 +11,6 @@ pub struct SeverityMatcher {
severities: Vec<Severity>,
}
-/// Common trait implemented by all matching directives
impl MatchDirective for SeverityMatcher {
/// Check if this directive matches a given notification
fn matches(&self, notification: &Notification) -> Result<bool, Error> {
@@ -19,14 +18,29 @@ impl MatchDirective for SeverityMatcher {
}
}
-impl fmt::Display 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.severities.iter().map(|s| format!("{s}")).collect();
+ let severities: Vec<String> = self.0.severities.iter().map(|s| format!("{s}")).collect();
f.write_str(&severities.join(","))
}
}
-impl FromStr for SeverityMatcher {
+impl FromStr for InlineSeverityMatcher {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
@@ -39,28 +53,31 @@ impl FromStr for SeverityMatcher {
severities.push(severity)
}
- Ok(Self { severities })
+ Ok(Self(SeverityMatcher { severities }))
}
}
-proxmox_serde::forward_deserialize_to_from_str!(SeverityMatcher);
-proxmox_serde::forward_serialize_to_display!(SeverityMatcher);
+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 crate::{
- Notification, Severity,
- matcher::{MatchDirective as _, severity::SeverityMatcher},
- };
+ use super::*;
#[test]
fn test_severities() {
let notification =
Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
- let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
+ let matcher: InlineSeverityMatcher = "info,notice,warning,error".parse().unwrap();
assert!(matcher.matches(¬ification).unwrap());
}
}
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 10/34] notify: matcher: add InlineFieldMatcher
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (8 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 09/34] notify: matcher: add InlineSeverityMatcher Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 11/34] notify: matcher: add InlineCalendarMatcher Lukas Wagner
` (23 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
This move the inline serialization format into a new-type wrapper around
FieldMatcher.
This allows us to keep the existing single-line serialization format for
the old configuration keys (match-field), while deriving a regular
Serializer for FieldMatcher that will be used in the new expression
based matcher.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/matcher/field.rs | 55 +++++++++++++++++++----------
proxmox-notify/src/matcher/mod.rs | 4 +--
2 files changed, 39 insertions(+), 20 deletions(-)
diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs
index 8165b1f4..ad04b388 100644
--- a/proxmox-notify/src/matcher/field.rs
+++ b/proxmox-notify/src/matcher/field.rs
@@ -16,10 +16,10 @@ const_regex! {
}
pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat =
- ApiStringFormat::VerifyFn(verify_field_matcher);
+ ApiStringFormat::VerifyFn(verify_inline_field_matcher);
-fn verify_field_matcher(s: &str) -> Result<(), anyhow::Error> {
- let _: FieldMatcher = s.parse()?;
+fn verify_inline_field_matcher(s: &str) -> Result<(), anyhow::Error> {
+ let _: InlineFieldMatcher = s.parse()?;
Ok(())
}
@@ -75,12 +75,25 @@ impl MatchDirective for FieldMatcher {
}
}
-impl fmt::Display 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);
+
+impl MatchDirective for InlineFieldMatcher {
+ fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+ self.0.matches(notification)
+ }
+}
+
+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 {
+ match &self.0 {
FieldMatcher::Exact {
field,
matched_values,
@@ -99,7 +112,7 @@ impl fmt::Display for FieldMatcher {
}
}
-impl FromStr for FieldMatcher {
+impl FromStr for InlineFieldMatcher {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
if !MATCH_FIELD_ENTRY_REGEX.is_match(s) {
@@ -117,10 +130,10 @@ impl FromStr for FieldMatcher {
let regex = Regex::new(expected_value_regex)
.map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?;
- Ok(Self::Regex {
+ Ok(Self(FieldMatcher::Regex {
field: field.into(),
matched_regex: regex,
- })
+ }))
}
}
} else if let Some(remaining) = s.strip_prefix("exact:") {
@@ -134,10 +147,10 @@ impl FromStr for FieldMatcher {
.map(str::trim)
.map(String::from)
.collect();
- Ok(Self::Exact {
+ Ok(Self(FieldMatcher::Exact {
field: field.into(),
matched_values: values,
- })
+ }))
}
}
} else {
@@ -148,8 +161,14 @@ impl FromStr for FieldMatcher {
}
}
-proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher);
-proxmox_serde::forward_serialize_to_display!(FieldMatcher);
+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 {
@@ -169,16 +188,16 @@ mod tests {
let notification =
Notification::from_template(Severity::Notice, "test", Value::Null, fields);
- let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap();
+ let matcher: InlineFieldMatcher = "exact:foo=bar".parse().unwrap();
assert!(matcher.matches(¬ification).unwrap());
- let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap();
+ let matcher: InlineFieldMatcher = "regex:foo=b.*".parse().unwrap();
assert!(matcher.matches(¬ification).unwrap());
- let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap();
+ let matcher: InlineFieldMatcher = "regex:notthere=b.*".parse().unwrap();
assert!(!matcher.matches(¬ification).unwrap());
- let matcher: FieldMatcher = "exact:foo=bar,test".parse().unwrap();
+ let matcher: InlineFieldMatcher = "exact:foo=bar,test".parse().unwrap();
assert!(matcher.matches(¬ification).unwrap());
let mut fields = HashMap::new();
@@ -195,7 +214,7 @@ mod tests {
Notification::from_template(Severity::Notice, "test", Value::Null, fields);
assert!(!matcher.matches(¬ification).unwrap());
- assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
- assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err());
+ assert!("regex:'3=b.*".parse::<InlineFieldMatcher>().is_err());
+ assert!("invalid:'bar=b.*".parse::<InlineFieldMatcher>().is_err());
}
}
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 2aef6949..32ed9fbf 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -15,7 +15,7 @@ pub mod field;
pub mod severity;
use calendar::CalendarMatcher;
-use field::FieldMatcher;
+use field::InlineFieldMatcher;
use severity::InlineSeverityMatcher;
pub const MATCHER_TYPENAME: &str = "matcher";
@@ -102,7 +102,7 @@ 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<FieldMatcher>,
+ pub match_field: Vec<InlineFieldMatcher>,
/// List of matched severity levels.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 11/34] notify: matcher: add InlineCalendarMatcher
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (9 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 10/34] notify: matcher: add InlineFieldMatcher Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 12/34] notify: matcher: add expression support Lukas Wagner
` (22 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
This move the inline serialization format into a new-type wrapper around
CalendarMatcher.
This allows us to keep the existing single-line serialization format for
the old configuration keys (match-calendar), while deriving a regular
Serializer for CalendarMatcher that will be used in the new expression
based matcher.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/matcher/calendar.rs | 103 +++++++++++++++++++++----
proxmox-notify/src/matcher/mod.rs | 4 +-
2 files changed, 88 insertions(+), 19 deletions(-)
diff --git a/proxmox-notify/src/matcher/calendar.rs b/proxmox-notify/src/matcher/calendar.rs
index 970801c5..c6263eb5 100644
--- a/proxmox-notify/src/matcher/calendar.rs
+++ b/proxmox-notify/src/matcher/calendar.rs
@@ -1,49 +1,104 @@
use std::fmt;
use std::str::FromStr;
+use serde::{Deserialize, Serialize};
+
use proxmox_time::DailyDuration;
use crate::{Error, Notification};
use super::MatchDirective;
-/// Match timestamp of the notification.
+/// Convenience wrapper around [`DailyDuration`] that implements [`Serialize`] and
+/// [`Deserialize`].
#[derive(Clone, Debug)]
+struct DailyDurationWrapper {
+ schedule: String,
+ daily_duration: DailyDuration,
+}
+
+impl fmt::Display for DailyDurationWrapper {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+impl FromStr for DailyDurationWrapper {
+ type Err = Error;
+
+ fn from_str(s: &str) -> Result<Self, Error> {
+ let daily_duration = proxmox_time::parse_daily_duration(s)
+ .map_err(|e| Error::Generic(format!("could not parse schedule: {e}")))?;
+
+ Ok(Self {
+ daily_duration,
+ schedule: s.to_string(),
+ })
+ }
+}
+
+impl DailyDurationWrapper {
+ fn as_str(&self) -> &str {
+ &self.schedule
+ }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(DailyDurationWrapper);
+proxmox_serde::forward_serialize_to_display!(DailyDurationWrapper);
+
+/// Match timestamp of the notification.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
pub struct CalendarMatcher {
- schedule: DailyDuration,
- original: String,
+ schedule: DailyDurationWrapper,
}
impl MatchDirective for CalendarMatcher {
fn matches(&self, notification: &Notification) -> Result<bool, Error> {
self.schedule
+ .daily_duration
.time_match(notification.metadata.timestamp, false)
.map_err(|err| Error::Generic(format!("could not match timestamp: {err}")))
}
}
-impl fmt::Display for CalendarMatcher {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- f.write_str(&self.original)
+/// 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 FromStr for CalendarMatcher {
+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> {
- let schedule = proxmox_time::parse_daily_duration(s)
- .map_err(|e| Error::Generic(format!("could not parse schedule: {e}")))?;
-
- Ok(Self {
- schedule,
- original: s.to_string(),
- })
+ Ok(Self(CalendarMatcher {
+ schedule: s.parse()?,
+ }))
}
}
-proxmox_serde::forward_deserialize_to_from_str!(CalendarMatcher);
-proxmox_serde::forward_serialize_to_display!(CalendarMatcher);
+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(test)]
mod test {
@@ -63,7 +118,21 @@ mod test {
// Match on a wide rage to avoid issues when running this test case
// in a different time zone.
- let matcher: CalendarMatcher = "thu..sat 0-23".parse().unwrap();
+ let matcher: InlineCalendarMatcher = "thu..sat 0-23".parse().unwrap();
assert!(matcher.matches(¬ification).unwrap());
}
+
+ #[test]
+ fn test_calendar_matcher_de_ser_roundtrip() {
+ let calendar_matcher = "{ \"schedule\": \"thu..sat 0-23\" }";
+
+ 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");
+ }
}
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 32ed9fbf..24186cda 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -14,7 +14,7 @@ pub mod calendar;
pub mod field;
pub mod severity;
-use calendar::CalendarMatcher;
+use calendar::InlineCalendarMatcher;
use field::InlineFieldMatcher;
use severity::InlineSeverityMatcher;
@@ -112,7 +112,7 @@ pub struct MatcherConfig {
/// 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<CalendarMatcher>,
+ pub match_calendar: Vec<InlineCalendarMatcher>,
/// Decide if 'all' or 'any' match statements must match.
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<MatchModeOperator>,
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 12/34] notify: matcher: add expression support
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (10 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 11/34] notify: matcher: add InlineCalendarMatcher Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 13/34] notify: api: support new expression parameter Lukas Wagner
` (21 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
This commit adds support for the new 'expression' parameter in the
matcher configuration. It contains a JSON-serialized match expression
(based on the new proxmox-match-expression crate). It is intended as a
replacement for the existing match-field, match-calendar,
match-severity, mode and invert-match keys.
The match expression implements a strict superset of the existing
configuration keys, any existing configuration can be converted into
an expression.
Expressions lift some of limitations of the old approach, namely they
allow arbitrary nesting of match rules and combinators.
The following expressions are supported
- all-of (corresponds to the former mode 'all')
- any-of (corresponds to the former mode 'any')
- one-of (new)
- not (corresponds to former 'invert-match')
- match-calendar (unchanged)
- match-field (unchanged)
- match-severity (unchanged)
The combinators all-of, any-of and one-of support an arbitrary number of
child expressions (where each child can be a combinator, not, or a
match-* expression).
Internally, existing, 'old-style' matchers are converted on the fly to
expressions, meaning there is only a single code path for the matching
logic. If any matcher has any of the old configuration keys and the new
expression set, the old keys will be ignored and a warning will be
logged.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
Cargo.toml | 1 +
proxmox-notify/Cargo.toml | 2 +
proxmox-notify/src/matcher/expression.rs | 84 ++++++++++++
proxmox-notify/src/matcher/field.rs | 31 ++---
proxmox-notify/src/matcher/mod.rs | 163 ++++++++++++++---------
proxmox-notify/src/matcher/severity.rs | 6 +-
6 files changed, 207 insertions(+), 80 deletions(-)
create mode 100644 proxmox-notify/src/matcher/expression.rs
diff --git a/Cargo.toml b/Cargo.toml
index 2e07077f..7b0e80c5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -138,6 +138,7 @@ serde-xml-rs = "0.5"
serde_cbor = "0.11.1"
serde_json = "1.0"
serde_plain = "1.0"
+serde_regex = "1.1"
syn = { version = "2", features = [ "full", "visit-mut" ] }
sync_wrapper = "1"
tar = "0.4"
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index bc63e19d..ae82f649 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -24,11 +24,13 @@ percent-encoding = { workspace = true, optional = true }
regex.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
+serde_regex.workspace = true
proxmox-base64 = { workspace = true, optional = true }
proxmox-http = { workspace = true, features = ["client-sync"], optional = true }
proxmox-http-error.workspace = true
proxmox-human-byte.workspace = true
+proxmox-match-expression.workspace = true
proxmox-schema = { workspace = true, features = ["api-macro", "api-types"] }
proxmox-section-config = { workspace = true }
proxmox-serde.workspace = true
diff --git a/proxmox-notify/src/matcher/expression.rs b/proxmox-notify/src/matcher/expression.rs
new file mode 100644
index 00000000..9bb12f74
--- /dev/null
+++ b/proxmox-notify/src/matcher/expression.rs
@@ -0,0 +1,84 @@
+use serde::{Deserialize, Serialize};
+
+use proxmox_match_expression::MatchExpression;
+
+use crate::{Error, Notification};
+
+use super::{calendar::CalendarMatcher, field::FieldMatcher, severity::SeverityMatcher};
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+#[serde(rename_all = "kebab-case", tag = "type")]
+pub enum NotificationMatcher {
+ /// Match a notification's metadata field.
+ Field(FieldMatcher),
+ /// Match a notification's timestamp.
+ Calendar(CalendarMatcher),
+ /// Match the severity of a notification.
+ Severity(SeverityMatcher),
+}
+
+impl MatchExpression for NotificationMatcher {
+ type Data = Notification;
+ type Error = Error;
+
+ fn evaluate(&self, data: &Notification) -> Result<bool, Error> {
+ use super::MatchDirective;
+
+ match self {
+ NotificationMatcher::Field(field_matcher) => field_matcher.matches(data),
+ NotificationMatcher::Calendar(calendar_matcher) => calendar_matcher.matches(data),
+ NotificationMatcher::Severity(severity_matcher) => severity_matcher.matches(data),
+ }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use proxmox_match_expression::Expression;
+
+ use super::*;
+
+ #[test]
+ fn test_matcher_deser() {
+ let e = r#"
+ {
+ "any-of": [
+ {
+ "match": {
+ "type": "severity",
+ "severities": [
+ "info",
+ "notice"
+ ]
+ }
+ },
+ {
+ "match": {
+ "type": "field",
+ "field": "something",
+ "regex": "^abc$"
+ }
+ },
+ {
+ "match": {
+ "type": "field",
+ "field": "something",
+ "values": [
+ "a",
+ "b",
+ "c"
+ ]
+ }
+ },
+ {
+ "match": {
+ "type": "calendar",
+ "schedule": "sat,sun 10-14"
+ }
+ }
+ ]
+ }"#;
+
+ let _: Expression<NotificationMatcher> = serde_json::from_str(e).unwrap();
+ }
+}
diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs
index ad04b388..fd25939c 100644
--- a/proxmox-notify/src/matcher/field.rs
+++ b/proxmox-notify/src/matcher/field.rs
@@ -6,6 +6,7 @@ use regex::Regex;
use proxmox_schema::{
ApiStringFormat, Schema, StringSchema, api_types::SAFE_ID_REGEX_STR, const_regex,
};
+use serde::{Deserialize, Serialize};
use crate::{Error, Notification};
@@ -30,42 +31,38 @@ pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata f
.schema();
/// Check if the notification metadata fields match
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case", untagged)]
pub enum FieldMatcher {
Exact {
field: String,
- matched_values: Vec<String>,
+ values: Vec<String>,
},
Regex {
field: String,
- matched_regex: Regex,
+ #[serde(with = "serde_regex")]
+ regex: Regex,
},
}
impl MatchDirective for FieldMatcher {
fn matches(&self, notification: &Notification) -> Result<bool, Error> {
Ok(match self {
- FieldMatcher::Exact {
- field,
- matched_values,
- } => {
+ FieldMatcher::Exact { field, values } => {
let value = notification.metadata.additional_fields.get(field);
if let Some(value) = value {
- matched_values.contains(value)
+ values.contains(value)
} else {
// Metadata field does not exist, so we do not match
false
}
}
- FieldMatcher::Regex {
- field,
- matched_regex,
- } => {
+ FieldMatcher::Regex { field, regex } => {
let value = notification.metadata.additional_fields.get(field);
if let Some(value) = value {
- matched_regex.is_match(value)
+ regex.is_match(value)
} else {
// Metadata field does not exist, so we do not match
false
@@ -96,14 +93,14 @@ impl fmt::Display for InlineFieldMatcher {
match &self.0 {
FieldMatcher::Exact {
field,
- matched_values,
+ values: matched_values,
} => {
let values = matched_values.join(",");
write!(f, "exact:{field}={values}")
}
FieldMatcher::Regex {
field,
- matched_regex,
+ regex: matched_regex,
} => {
let re = matched_regex.as_str();
write!(f, "regex:{field}={re}")
@@ -132,7 +129,7 @@ impl FromStr for InlineFieldMatcher {
Ok(Self(FieldMatcher::Regex {
field: field.into(),
- matched_regex: regex,
+ regex,
}))
}
}
@@ -149,7 +146,7 @@ impl FromStr for InlineFieldMatcher {
.collect();
Ok(Self(FieldMatcher::Exact {
field: field.into(),
- matched_values: values,
+ values,
}))
}
}
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 24186cda..4a1beecf 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -1,16 +1,19 @@
use std::collections::HashSet;
use std::fmt::Debug;
+use proxmox_match_expression::Expression;
use serde::{Deserialize, Serialize};
use tracing::{error, info};
use proxmox_schema::api_types::COMMENT_SCHEMA;
use proxmox_schema::{Updater, api};
+use crate::matcher::expression::NotificationMatcher;
use crate::schema::ENTITY_NAME_SCHEMA;
use crate::{Error, Notification, Origin};
pub mod calendar;
+pub mod expression;
pub mod field;
pub mod severity;
@@ -32,24 +35,6 @@ pub enum MatchModeOperator {
Any,
}
-impl MatchModeOperator {
- /// Apply the mode operator to two bools, lhs and rhs
- fn apply(&self, lhs: bool, rhs: bool) -> bool {
- match self {
- MatchModeOperator::All => lhs && rhs,
- MatchModeOperator::Any => lhs || rhs,
- }
- }
-
- // https://en.wikipedia.org/wiki/Identity_element
- fn neutral_element(&self) -> bool {
- match self {
- MatchModeOperator::All => true,
- MatchModeOperator::Any => false,
- }
- }
-}
-
#[api(
properties: {
name: {
@@ -121,6 +106,20 @@ pub struct MatcherConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub invert_match: Option<bool>,
+ /// Match expression as inline JSON. This option is mutually exclusive with
+ /// the following options.
+ ///
+ /// - match-field
+ /// - match-calendar
+ /// - match-severity
+ /// - invert-match
+ /// - mode
+ ///
+ /// All of the above can be represented as (sub)-expressions of this
+ /// expression.
+ #[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"))]
@@ -146,57 +145,99 @@ trait MatchDirective {
impl MatcherConfig {
pub fn matches(&self, notification: &Notification) -> Result<Option<&[String]>, Error> {
- let mode = self.mode.unwrap_or_default();
+ self.warn_about_invalid_configuration();
- let mut is_match = mode.neutral_element();
- // If there are no matching directives, the matcher will always match
- let mut no_matchers = true;
+ let expression = if let Some(expression_str) = &self.expression {
- if !self.match_severity.is_empty() {
- no_matchers = false;
- is_match = mode.apply(
- is_match,
- self.check_matches(notification, &self.match_severity)?,
- );
- }
- if !self.match_field.is_empty() {
- no_matchers = false;
- is_match = mode.apply(
- is_match,
- self.check_matches(notification, &self.match_field)?,
- );
- }
- if !self.match_calendar.is_empty() {
- no_matchers = false;
- is_match = mode.apply(
- is_match,
- self.check_matches(notification, &self.match_calendar)?,
- );
- }
-
- let invert_match = self.invert_match.unwrap_or_default();
-
- Ok(if is_match != invert_match || no_matchers {
- Some(&self.target)
+ serde_json::from_str(expression_str).map_err(|err| {
+ Error::FilterFailed(format!("could not deserialize filter expression: {err:#}"))
+ })?
} else {
- None
- })
+ self.generate_expression_from_legacy_config()
+ };
+
+ // Later, once we have a notification history, we can also save the evaluated expression.
+ // This gives the user a trace of which rules matched and which did not.
+ let evaluated_expression = expression.evaluate(notification)?;
+
+ Ok(evaluated_expression
+ .is_match()
+ .then_some(self.target.as_slice()))
}
- /// Check if given `MatchDirectives` match a notification.
- fn check_matches(
- &self,
- notification: &Notification,
- matchers: &[impl MatchDirective],
- ) -> Result<bool, Error> {
- let mode = self.mode.unwrap_or_default();
- let mut is_match = mode.neutral_element();
+ pub(crate) fn generate_expression_from_legacy_config(&self) -> Expression<NotificationMatcher> {
+ if self.match_severity.is_empty()
+ && self.match_field.is_empty()
+ && self.match_calendar.is_empty()
+ {
+ // No match clauses means that the matcher always matches.
+ // Also, 'invert-match' does not have any effect then.
+ return Expression::Constant(true);
+ }
+ let mut expressions = Vec::new();
- for field_matcher in matchers {
- is_match = mode.apply(is_match, field_matcher.matches(notification)?);
+ for m in self.match_calendar.clone() {
+ expressions.push(Expression::Match(NotificationMatcher::Calendar(
+ m.into_inner(),
+ )));
+ }
+ for m in self.match_field.clone() {
+ expressions.push(Expression::Match(NotificationMatcher::Field(
+ m.into_inner(),
+ )));
+ }
+ for m in self.match_severity.clone() {
+ expressions.push(Expression::Match(NotificationMatcher::Severity(
+ m.into_inner(),
+ )));
}
- Ok(is_match)
+ let root = match self.mode.unwrap_or_default() {
+ MatchModeOperator::All => Expression::AllOf(expressions),
+ MatchModeOperator::Any => Expression::AnyOf(expressions),
+ };
+
+ if self.invert_match.unwrap_or_default() {
+ Expression::Not(Box::new(root))
+ } else {
+ root
+ }
+ }
+
+ fn warn_about_invalid_configuration(&self) {
+ if self.expression.is_some() {
+ let conflicting_properties = self.properties_conflicting_with_expression();
+
+ if !conflicting_properties.is_empty() {
+ let joined = conflicting_properties.join(", ");
+ tracing::warn!(
+ "matcher '{}' has 'expression' set, the following properties are ignored: {joined}",
+ self.name
+ );
+ }
+ }
+ }
+
+ fn properties_conflicting_with_expression(&self) -> Vec<&'static str> {
+ let mut conflicting_properties = Vec::new();
+
+ if self.invert_match.is_some() {
+ conflicting_properties.push("invert-match");
+ }
+ if self.mode.is_some() {
+ conflicting_properties.push("mode");
+ }
+ if !self.match_field.is_empty() {
+ conflicting_properties.push("match-field");
+ }
+ if !self.match_severity.is_empty() {
+ conflicting_properties.push("match-severity");
+ }
+ if !self.match_calendar.is_empty() {
+ conflicting_properties.push("match-calendar");
+ }
+
+ conflicting_properties
}
}
diff --git a/proxmox-notify/src/matcher/severity.rs b/proxmox-notify/src/matcher/severity.rs
index e12f2260..64472fab 100644
--- a/proxmox-notify/src/matcher/severity.rs
+++ b/proxmox-notify/src/matcher/severity.rs
@@ -1,14 +1,16 @@
use std::fmt;
use std::str::FromStr;
+use serde::{Deserialize, Serialize};
+
use crate::{Error, Notification, Severity};
use super::MatchDirective;
/// Match severity of the notification.
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SeverityMatcher {
- severities: Vec<Severity>,
+ pub(crate) severities: Vec<Severity>,
}
impl MatchDirective for SeverityMatcher {
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 13/34] notify: api: support new expression parameter
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (11 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 12/34] notify: matcher: add expression support Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 14/34] notify: api: add `get_matcher_as_expression` Lukas Wagner
` (20 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
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 <l.wagner@proxmox.com>
---
proxmox-notify/src/api/matcher.rs | 119 ++++++++++++++++++++++++++++++
proxmox-notify/src/matcher/mod.rs | 85 ++++++++++++++++++++-
2 files changed, 202 insertions(+), 2 deletions(-)
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::<NotificationMatcher>::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 4a1beecf..12110e95 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 {
@@ -148,7 +148,6 @@ impl MatcherConfig {
self.warn_about_invalid_configuration();
let expression = if let Some(expression_str) = &self.expression {
-
serde_json::from_str(expression_str).map_err(|err| {
Error::FilterFailed(format!("could not deserialize filter expression: {err:#}"))
})?
@@ -204,6 +203,86 @@ impl MatcherConfig {
}
}
+ /// Ensure the validity of this matcher.
+ pub(crate) fn ensure_valid(&self) -> Result<(), Error> {
+ self.ensure_no_ignored_properties()?;
+ self.ensure_valid_expression()?;
+
+ Ok(())
+ }
+
+ fn ensure_no_ignored_properties(&self) -> Result<(), Error> {
+ if self.expression.is_some() {
+ let conflicting_properties = self.properties_conflicting_with_expression();
+
+ if !conflicting_properties.is_empty() {
+ let joined = conflicting_properties.join(", ");
+ return Err(Error::Generic(format!(
+ "'expression' property is mutually exclusive with: {joined}"
+ )));
+ }
+ }
+
+ Ok(())
+ }
+
+ fn ensure_valid_expression(&self) -> Result<(), Error> {
+ if let Some(expression) = &self.expression {
+ match serde_json::from_str::<Expression<NotificationMatcher>>(expression) {
+ Ok(expr) => {
+ Self::verify_match_expression_constraints(&expr)?;
+ }
+ Err(err) => {
+ return Err(Error::Generic(format!(
+ "'expression' is not valid: {err:#}"
+ )));
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Recursively walk expression nodes and verify any additional constraints on them:
+ ///
+ /// - all-of/any-of/one-of *must* have child nodes:
+ /// The generic proxmox-match-expression crates handles empty combinators just fine,
+ /// however, from a UX perspective, it makes sense to require them to have child nodes.
+ fn verify_match_expression_constraints(
+ expr: &Expression<NotificationMatcher>,
+ ) -> Result<(), Error> {
+ match expr {
+ Expression::AllOf(expressions) => {
+ if expressions.is_empty() {
+ return Err(Error::Generic(
+ "'expression' may not contain empty 'all-of' rule".to_string(),
+ ));
+ }
+ }
+ Expression::AnyOf(expressions) => {
+ if expressions.is_empty() {
+ return Err(Error::Generic(
+ "'expression' may not contain empty 'any-of' rule".to_string(),
+ ));
+ }
+ }
+ Expression::OneOf(expressions) => {
+ if expressions.is_empty() {
+ return Err(Error::Generic(
+ "'expression' may not contain empty 'one-of' rule".to_string(),
+ ));
+ }
+ }
+ _ => {}
+ }
+
+ for child_expression in expr.children() {
+ Self::verify_match_expression_constraints(child_expression)?;
+ }
+
+ Ok(())
+ }
+
fn warn_about_invalid_configuration(&self) {
if self.expression.is_some() {
let conflicting_properties = self.properties_conflicting_with_expression();
@@ -260,6 +339,8 @@ pub enum DeleteableMatcherProperty {
MatchSeverity,
/// Delete `mode`
Mode,
+ /// Delete `expression`
+ Expression,
/// Delete `target`
Target,
}
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 14/34] notify: api: add `get_matcher_as_expression`
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (12 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 13/34] notify: api: support new expression parameter Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 15/34] notify: migrate PBS's and PVE's default matcher to expression syntax Lukas Wagner
` (19 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
This one translates an existing matcher into an equivalent
expression-based matcher on the fly. It allows us to keep the migration
logic entirely in the backend; the GUI simply requests any existing
matcher via this new getter and always receives one where the entire
logic is encoded in the 'expression' parameter.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/api/matcher.rs | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/proxmox-notify/src/api/matcher.rs b/proxmox-notify/src/api/matcher.rs
index 3713dfef..be2d9c0e 100644
--- a/proxmox-notify/src/api/matcher.rs
+++ b/proxmox-notify/src/api/matcher.rs
@@ -29,6 +29,37 @@ pub fn get_matcher(config: &Config, name: &str) -> Result<MatcherConfig, HttpErr
.map_err(|_| http_err!(NOT_FOUND, "matcher '{name}' not found"))
}
+/// Get matcher with given `name`, translating any existing 'match-*' keys into an
+/// equivalent 'expression'.
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns the endpoint or a `HttpError` if the matcher was not found (`404 Not found`).
+pub fn get_matcher_as_expression(config: &Config, name: &str) -> Result<MatcherConfig, HttpError> {
+ let mut matcher: MatcherConfig = config
+ .config
+ .lookup(MATCHER_TYPENAME, name)
+ .map_err(|_| http_err!(NOT_FOUND, "matcher '{name}' not found"))?;
+
+ if matcher.expression.is_none() {
+ let expression = matcher.generate_expression_from_legacy_config();
+ let expression_json = serde_json::to_string(&expression).map_err(|err| {
+ http_err!(
+ INTERNAL_SERVER_ERROR,
+ "could not serialize matcher expression: {err}"
+ )
+ })?;
+ matcher.expression = Some(expression_json);
+ }
+
+ matcher.match_calendar.clear();
+ matcher.match_severity.clear();
+ matcher.match_field.clear();
+ matcher.mode = None;
+ matcher.invert_match = None;
+
+ Ok(matcher)
+}
+
/// Add new notification matcher.
///
/// The caller is responsible for any needed permission checks.
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 15/34] notify: migrate PBS's and PVE's default matcher to expression syntax
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (13 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 14/34] notify: api: add `get_matcher_as_expression` Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 16/34] notify: move legacy matcher keys behind feature flag Lukas Wagner
` (18 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
Instead of providing the default matcher expression as a JSON blob, we
programmatically construct the appropricate SectionConfigData entity.
This required a change of the Context trait, now the default_config
method returns &'static SectionConfigData instead of &'static str.
The actual SectionConfigData entity is only constructed once and then
stored in a OnceLock.
A nice side-effect of this change is that the config-parsing code path
is more efficient, since we don't have to deserialize the default config
over and over again.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/Cargo.toml | 4 +-
proxmox-notify/src/context/mod.rs | 4 +-
proxmox-notify/src/context/pbs.rs | 87 ++++++++++++++++++++------
proxmox-notify/src/context/pve.rs | 67 +++++++++++++++-----
proxmox-notify/src/context/test.rs | 9 ++-
proxmox-notify/src/lib.rs | 6 +-
proxmox-notify/src/matcher/calendar.rs | 9 ++-
proxmox-notify/src/matcher/field.rs | 11 +++-
proxmox-notify/src/matcher/severity.rs | 10 ++-
9 files changed, 159 insertions(+), 48 deletions(-)
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index ae82f649..9cbfc89f 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -44,7 +44,7 @@ default = ["sendmail", "gotify", "smtp", "webhook"]
mail-forwarder = ["dep:mail-parser", "dep:proxmox-sys", "proxmox-sendmail/mail-forwarder"]
sendmail = ["dep:proxmox-sys", "dep:proxmox-sendmail"]
gotify = ["dep:proxmox-http", "dep:http"]
-pve-context = ["dep:proxmox-sys"]
-pbs-context = ["dep:proxmox-sys"]
+pve-context = ["dep:proxmox-sys", "sendmail"]
+pbs-context = ["dep:proxmox-sys", "sendmail"]
smtp = ["dep:lettre"]
webhook = ["dep:http", "dep:percent-encoding", "dep:proxmox-base64", "dep:proxmox-http"]
diff --git a/proxmox-notify/src/context/mod.rs b/proxmox-notify/src/context/mod.rs
index 87a2a716..70c3a567 100644
--- a/proxmox-notify/src/context/mod.rs
+++ b/proxmox-notify/src/context/mod.rs
@@ -1,6 +1,8 @@
use std::fmt::Debug;
use std::sync::Mutex;
+use proxmox_section_config::SectionConfigData;
+
use crate::Error;
use crate::renderer::TemplateSource;
@@ -24,7 +26,7 @@ pub trait Context: Send + Sync + Debug {
/// Proxy configuration for the current node
fn http_proxy_config(&self) -> Option<String>;
/// Return default config for built-in targets/matchers.
- fn default_config(&self) -> &'static str;
+ fn default_config(&self) -> &'static SectionConfigData;
/// Return the path of `filename` from `source` and a certain (optional) `namespace`
fn lookup_template(
&self,
diff --git a/proxmox-notify/src/context/pbs.rs b/proxmox-notify/src/context/pbs.rs
index a9121548..e40774da 100644
--- a/proxmox-notify/src/context/pbs.rs
+++ b/proxmox-notify/src/context/pbs.rs
@@ -1,14 +1,19 @@
use std::path::Path;
+use std::sync::OnceLock;
use serde::Deserialize;
use tracing::error;
use proxmox_schema::{ObjectSchema, Schema, StringSchema};
-use proxmox_section_config::{SectionConfig, SectionConfigPlugin};
+use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
-use crate::Error;
use crate::context::{Context, common};
+use crate::endpoints::sendmail::{SENDMAIL_TYPENAME, SendmailConfig};
+use crate::matcher::field::FieldMatcher;
+use crate::matcher::severity::SeverityMatcher;
+use crate::matcher::{MATCHER_TYPENAME, MatcherConfig};
use crate::renderer::TemplateSource;
+use crate::{Error, Severity};
const PBS_USER_CFG_FILENAME: &str = "/etc/proxmox-backup/user.cfg";
const PBS_NODE_CFG_FILENAME: &str = "/etc/proxmox-backup/node.cfg";
@@ -60,21 +65,6 @@ fn lookup_mail_address(content: &str, username: &str) -> Option<String> {
}
}
-const DEFAULT_CONFIG: &str = "\
-sendmail: mail-to-root
- comment Send mails to root@pam's email address
- mailto-user root@pam
-
-
-matcher: default-matcher
- mode all
- invert-match true
- match-field exact:type=prune
- match-severity info
- target mail-to-root
- comment Route everything but successful prune job notifications to mail-to-root
-";
-
#[derive(Debug)]
pub struct PBSContext;
@@ -102,8 +92,67 @@ impl Context for PBSContext {
content.and_then(|content| common::lookup_datacenter_config_key(&content, "http-proxy"))
}
- fn default_config(&self) -> &'static str {
- DEFAULT_CONFIG
+ fn default_config(&self) -> &'static SectionConfigData {
+ static DEFAULT_CONFIG: OnceLock<SectionConfigData> = OnceLock::new();
+ DEFAULT_CONFIG.get_or_init(|| {
+ // FIXME: Long-term we want to move the trait implementation to the product,
+ // maybe add some nice builder to construct the default config.
+ // Moving this as-is to the product would expose a lot of internals
+ // to the product.
+
+ let mut config = SectionConfigData::default();
+ config
+ .set_data(
+ "mail-to-root",
+ SENDMAIL_TYPENAME,
+ SendmailConfig {
+ name: "mail-to-root".into(),
+ mailto_user: vec!["root@pam".into()],
+ comment: Some("Send mails to root@pam's email address".into()),
+ ..Default::default()
+ },
+ )
+ .expect("failed to set 'mail-to-root' in default config");
+
+ use proxmox_match_expression::{not, any_of, all_of};
+
+ let expression = any_of![
+ not!(
+ FieldMatcher::Exact {
+ field: "type".into(),
+ values: vec!["prune".into()],
+ }.into()
+ ),
+ all_of![
+ FieldMatcher::Exact {
+ field: "type".into(),
+ values: vec!["prune".into()],
+ }.into(),
+ SeverityMatcher {
+ severities: vec![Severity::Error, Severity::Warning, Severity::Notice],
+ }.into(),
+ ],
+ ];
+
+ let expression = serde_json::to_string(&expression)
+ .expect("failed serialize expression for 'default-matcher'");
+
+ config
+ .set_data(
+ "default-matcher",
+ MATCHER_TYPENAME,
+ MatcherConfig {
+ name: "default-matcher".into(),
+ expression: Some(expression),
+ target: vec!["mail-to-root".into()],
+ comment: Some("Route everything but successful prune job notifications to mail-to-root".into()),
+ ..Default::default()
+ },
+ )
+ .expect("failed to set 'default-matcher' in default config");
+
+ config
+ })
}
fn lookup_template(
diff --git a/proxmox-notify/src/context/pve.rs b/proxmox-notify/src/context/pve.rs
index 3d9ff92e..00fab322 100644
--- a/proxmox-notify/src/context/pve.rs
+++ b/proxmox-notify/src/context/pve.rs
@@ -1,7 +1,15 @@
+use std::path::Path;
+use std::sync::OnceLock;
+
+use proxmox_match_expression::Expression;
+use proxmox_section_config::SectionConfigData;
+
use crate::Error;
use crate::context::{Context, common};
+use crate::endpoints::sendmail::{SENDMAIL_TYPENAME, SendmailConfig};
+use crate::matcher::expression::NotificationMatcher;
+use crate::matcher::{MATCHER_TYPENAME, MatcherConfig};
use crate::renderer::TemplateSource;
-use std::path::Path;
fn lookup_mail_address(content: &str, user: &str) -> Option<String> {
common::normalize_for_return(content.lines().find_map(|line| {
@@ -14,18 +22,6 @@ fn lookup_mail_address(content: &str, user: &str) -> Option<String> {
}))
}
-const DEFAULT_CONFIG: &str = "\
-sendmail: mail-to-root
- comment Send mails to root@pam's email address
- mailto-user root@pam
-
-
-matcher: default-matcher
- mode all
- target mail-to-root
- comment Route all notifications to mail-to-root
-";
-
#[derive(Debug)]
pub struct PVEContext;
@@ -51,8 +47,49 @@ impl Context for PVEContext {
content.and_then(|content| common::lookup_datacenter_config_key(&content, "http_proxy"))
}
- fn default_config(&self) -> &'static str {
- DEFAULT_CONFIG
+ fn default_config(&self) -> &'static SectionConfigData {
+ static DEFAULT_CONFIG: OnceLock<SectionConfigData> = OnceLock::new();
+ DEFAULT_CONFIG.get_or_init(|| {
+ // FIXME: Long-term we want to move the trait implementation to the product,
+ // maybe add some nice builder to construct the default config.
+ // Moving this as-is to the product would expose a lot of internals
+ // to the product.
+
+ let mut config = SectionConfigData::default();
+ config
+ .set_data(
+ "mail-to-root",
+ SENDMAIL_TYPENAME,
+ SendmailConfig {
+ name: "mail-to-root".into(),
+ mailto_user: vec!["root@pam".into()],
+ comment: Some("Send mails to root@pam's email address".into()),
+ ..Default::default()
+ },
+ )
+ .expect("failed to set 'mail-to-root' in default config");
+
+ let expr: Expression<NotificationMatcher> = Expression::Constant(true);
+
+ let expr_str = serde_json::to_string(&expr)
+ .expect("failed serialize expression for 'default-matcher'");
+
+ config
+ .set_data(
+ "default-matcher",
+ MATCHER_TYPENAME,
+ MatcherConfig {
+ name: "default-matcher".into(),
+ expression: Some(expr_str),
+ target: vec!["mail-to-root".into()],
+ comment: Some("Route all notifications to mail-to-root".into()),
+ ..Default::default()
+ },
+ )
+ .expect("failed to set 'default-matcher' in default config");
+
+ config
+ })
}
fn lookup_template(
diff --git a/proxmox-notify/src/context/test.rs b/proxmox-notify/src/context/test.rs
index 22da38d3..ad8ef696 100644
--- a/proxmox-notify/src/context/test.rs
+++ b/proxmox-notify/src/context/test.rs
@@ -1,3 +1,7 @@
+use std::sync::OnceLock;
+
+use proxmox_section_config::SectionConfigData;
+
use crate::Error;
use crate::context::Context;
use crate::renderer::TemplateSource;
@@ -28,8 +32,9 @@ impl Context for TestContext {
None
}
- fn default_config(&self) -> &'static str {
- ""
+ fn default_config(&self) -> &'static SectionConfigData {
+ static DEFAULT_CONFIG: OnceLock<SectionConfigData> = OnceLock::new();
+ DEFAULT_CONFIG.get_or_init(|| SectionConfigData::default())
}
fn lookup_template(
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index 12d21edc..1d1c03cc 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -285,11 +285,7 @@ impl Config {
let (mut config, digest) = config::config(raw_config)?;
let (private_config, _) = config::private_config(raw_private_config)?;
- let default_config = context().default_config();
-
- let builtin_config = config::config_parser()
- .parse("<builtin>", default_config)
- .map_err(|err| Error::ConfigDeserialization(err.into()))?;
+ let builtin_config = context().default_config();
for (key, (builtin_typename, builtin_value)) in &builtin_config.sections {
if let Some((typename, value)) = config.sections.get_mut(key) {
diff --git a/proxmox-notify/src/matcher/calendar.rs b/proxmox-notify/src/matcher/calendar.rs
index c6263eb5..43805840 100644
--- a/proxmox-notify/src/matcher/calendar.rs
+++ b/proxmox-notify/src/matcher/calendar.rs
@@ -3,9 +3,10 @@ use std::str::FromStr;
use serde::{Deserialize, Serialize};
+use proxmox_match_expression::Expression;
use proxmox_time::DailyDuration;
-use crate::{Error, Notification};
+use crate::{Error, Notification, matcher::expression::NotificationMatcher};
use super::MatchDirective;
@@ -53,6 +54,12 @@ pub struct CalendarMatcher {
schedule: DailyDurationWrapper,
}
+impl From<CalendarMatcher> for Expression<NotificationMatcher> {
+ fn from(value: CalendarMatcher) -> Self {
+ Expression::Match(NotificationMatcher::Calendar(value))
+ }
+}
+
impl MatchDirective for CalendarMatcher {
fn matches(&self, notification: &Notification) -> Result<bool, Error> {
self.schedule
diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs
index fd25939c..a21b00d3 100644
--- a/proxmox-notify/src/matcher/field.rs
+++ b/proxmox-notify/src/matcher/field.rs
@@ -2,13 +2,14 @@ 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 serde::{Deserialize, Serialize};
-use crate::{Error, Notification};
+use crate::{Error, Notification, matcher::expression::NotificationMatcher};
use super::MatchDirective;
@@ -45,6 +46,12 @@ pub enum FieldMatcher {
},
}
+impl From<FieldMatcher> for Expression<NotificationMatcher> {
+ fn from(value: FieldMatcher) -> Self {
+ Expression::Match(NotificationMatcher::Field(value))
+ }
+}
+
impl MatchDirective for FieldMatcher {
fn matches(&self, notification: &Notification) -> Result<bool, Error> {
Ok(match self {
diff --git a/proxmox-notify/src/matcher/severity.rs b/proxmox-notify/src/matcher/severity.rs
index 64472fab..a2538bcc 100644
--- a/proxmox-notify/src/matcher/severity.rs
+++ b/proxmox-notify/src/matcher/severity.rs
@@ -3,7 +3,9 @@ use std::str::FromStr;
use serde::{Deserialize, Serialize};
-use crate::{Error, Notification, Severity};
+use proxmox_match_expression::Expression;
+
+use crate::{Error, Notification, Severity, matcher::expression::NotificationMatcher};
use super::MatchDirective;
@@ -13,6 +15,12 @@ pub struct SeverityMatcher {
pub(crate) severities: Vec<Severity>,
}
+impl From<SeverityMatcher> for Expression<NotificationMatcher> {
+ fn from(value: SeverityMatcher) -> Self {
+ Expression::Match(NotificationMatcher::Severity(value))
+ }
+}
+
impl MatchDirective for SeverityMatcher {
/// Check if this directive matches a given notification
fn matches(&self, notification: &Notification) -> Result<bool, Error> {
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 16/34] notify: move legacy matcher keys behind feature flag
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (14 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 15/34] notify: migrate PBS's and PVE's default matcher to expression syntax Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 17/34] notify: matcher: don't allow empty field/severity matchers Lukas Wagner
` (17 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
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 | 102 ++++++--
proxmox-notify/src/matcher/severity.rs | 132 ++++++-----
7 files changed, 466 insertions(+), 296 deletions(-)
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index 9cbfc89f..9ff3c523 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -48,3 +48,4 @@ pve-context = ["dep:proxmox-sys", "sendmail"]
pbs-context = ["dep:proxmox-sys", "sendmail"]
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 12110e95..7279ef25 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> {
+ #[cfg(feature = "legacy-matchers")]
self.warn_about_invalid_configuration();
- let expression = if let Some(expression_str) = &self.expression {
- 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()
+ let expression: Expression<NotificationMatcher> = {
+ if let Some(expression_str) = &self.expression {
+ 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()
@@ -205,12 +262,14 @@ impl MatcherConfig {
/// Ensure the validity of this matcher.
pub(crate) fn ensure_valid(&self) -> Result<(), Error> {
+ #[cfg(feature = "legacy-matchers")]
self.ensure_no_ignored_properties()?;
self.ensure_valid_expression()?;
Ok(())
}
+ #[cfg(feature = "legacy-matchers")]
fn ensure_no_ignored_properties(&self) -> Result<(), Error> {
if self.expression.is_some() {
let conflicting_properties = self.properties_conflicting_with_expression();
@@ -283,6 +342,7 @@ impl MatcherConfig {
Ok(())
}
+ #[cfg(feature = "legacy-matchers")]
fn warn_about_invalid_configuration(&self) {
if self.expression.is_some() {
let conflicting_properties = self.properties_conflicting_with_expression();
@@ -297,6 +357,7 @@ impl MatcherConfig {
}
}
+ #[cfg(feature = "legacy-matchers")]
fn properties_conflicting_with_expression(&self) -> Vec<&'static str> {
let mut conflicting_properties = Vec::new();
@@ -330,14 +391,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,
@@ -386,14 +452,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
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox v2 17/34] notify: matcher: don't allow empty field/severity matchers
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (15 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 16/34] notify: move legacy matcher keys behind feature flag Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-widget-toolkit v2 18/34] notifications: increase matcher window width Lukas Wagner
` (16 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
Severity and field matcher in 'exact' mode allow matching on multiple
values, which is equivalent to an 'any-of' of multiple matchers matching
on a single value. Since we don't allow empty 'any-of' groups, it makes
sense to reject empty/severity matchers as well.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
Notes:
new in v2
proxmox-notify/src/matcher/calendar.rs | 8 ++++
proxmox-notify/src/matcher/expression.rs | 13 ++++++
proxmox-notify/src/matcher/field.rs | 16 +++++++
proxmox-notify/src/matcher/mod.rs | 55 ++++++++++++++++++++++++
proxmox-notify/src/matcher/severity.rs | 14 ++++++
5 files changed, 106 insertions(+)
diff --git a/proxmox-notify/src/matcher/calendar.rs b/proxmox-notify/src/matcher/calendar.rs
index ceb4db76..7fd18291 100644
--- a/proxmox-notify/src/matcher/calendar.rs
+++ b/proxmox-notify/src/matcher/calendar.rs
@@ -67,6 +67,10 @@ impl MatchDirective for CalendarMatcher {
.time_match(notification.metadata.timestamp, false)
.map_err(|err| Error::Generic(format!("could not match timestamp: {err}")))
}
+
+ fn validate(&self) -> Result<(), Error> {
+ Ok(())
+ }
}
#[cfg(feature = "legacy-matchers")]
@@ -84,6 +88,10 @@ pub mod inline {
fn matches(&self, notification: &Notification) -> Result<bool, Error> {
self.0.matches(notification)
}
+
+ fn validate(&self) -> Result<(), Error> {
+ self.0.validate()
+ }
}
impl fmt::Display for InlineCalendarMatcher {
diff --git a/proxmox-notify/src/matcher/expression.rs b/proxmox-notify/src/matcher/expression.rs
index 9bb12f74..55c71cc4 100644
--- a/proxmox-notify/src/matcher/expression.rs
+++ b/proxmox-notify/src/matcher/expression.rs
@@ -32,6 +32,19 @@ impl MatchExpression for NotificationMatcher {
}
}
+impl NotificationMatcher {
+ /// Validate any additional constraints that cannot be expressed in the type system.
+ pub(crate) fn validate(&self) -> Result<(), Error> {
+ use crate::matcher::MatchDirective;
+
+ match self {
+ NotificationMatcher::Field(field_matcher) => field_matcher.validate(),
+ NotificationMatcher::Calendar(calendar_matcher) => calendar_matcher.validate(),
+ NotificationMatcher::Severity(severity_matcher) => severity_matcher.validate(),
+ }
+ }
+}
+
#[cfg(test)]
mod test {
use proxmox_match_expression::Expression;
diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs
index 4a4b2588..cd23ff9c 100644
--- a/proxmox-notify/src/matcher/field.rs
+++ b/proxmox-notify/src/matcher/field.rs
@@ -53,6 +53,18 @@ impl MatchDirective for FieldMatcher {
}
})
}
+
+ fn validate(&self) -> Result<(), Error> {
+ if let FieldMatcher::Exact { values, .. } = self {
+ if values.is_empty() {
+ return Err(Error::Generic(
+ "field matcher must contain values to match".to_string(),
+ ));
+ }
+ }
+
+ Ok(())
+ }
}
#[cfg(feature = "legacy-matchers")]
@@ -95,6 +107,10 @@ pub mod inline {
fn matches(&self, notification: &Notification) -> Result<bool, Error> {
self.0.matches(notification)
}
+
+ fn validate(&self) -> Result<(), Error> {
+ self.0.validate()
+ }
}
impl fmt::Display for InlineFieldMatcher {
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 7279ef25..0c995819 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -187,6 +187,8 @@ pub struct MatcherConfig {
trait MatchDirective {
fn matches(&self, notification: &Notification) -> Result<bool, Error>;
+ /// Validate any additional constraints that cannot be expressed in the type system.
+ fn validate(&self) -> Result<(), Error>;
}
impl MatcherConfig {
@@ -332,6 +334,9 @@ impl MatcherConfig {
));
}
}
+ Expression::Match(matcher) => {
+ matcher.validate()?;
+ }
_ => {}
}
@@ -452,6 +457,7 @@ pub fn check_matches<'a>(
#[cfg(test)]
mod tests {
+ use super::*;
#[test]
#[cfg(feature = "legacy-matchers")]
@@ -473,4 +479,53 @@ mod tests {
assert!(config.matches(¬ification).unwrap().is_some())
}
}
+
+ #[test]
+ fn test_expression_constraints() {
+ let empty_any_of = r#"
+ {
+ "any-of": []
+ }"#;
+ let empty_all_of = r#"
+ {
+ "all-of": []
+ }"#;
+ let empty_one_of = r#"
+ {
+ "one-of": []
+ }"#;
+
+ let empty_field_matcher = r#"
+ {
+ "match": {
+ "type": "field",
+ "field": "something",
+ "values": []
+ }
+ }"#;
+
+ let empty_severity_matcher = r#"
+ {
+ "match": {
+ "type": "severity",
+ "severities": [],
+ }
+ }"#;
+
+ for expr in [
+ empty_one_of,
+ empty_all_of,
+ empty_any_of,
+ empty_field_matcher,
+ empty_severity_matcher,
+ ] {
+ let config = MatcherConfig {
+ name: "matcher".to_string(),
+ expression: Some(expr.into()),
+ ..Default::default()
+ };
+
+ assert!(config.ensure_valid().is_err());
+ }
+ }
}
diff --git a/proxmox-notify/src/matcher/severity.rs b/proxmox-notify/src/matcher/severity.rs
index 2088ca74..2360dfca 100644
--- a/proxmox-notify/src/matcher/severity.rs
+++ b/proxmox-notify/src/matcher/severity.rs
@@ -23,6 +23,16 @@ impl MatchDirective for SeverityMatcher {
fn matches(&self, notification: &Notification) -> Result<bool, Error> {
Ok(self.severities.contains(¬ification.metadata.severity))
}
+
+ fn validate(&self) -> Result<(), Error> {
+ if self.severities.is_empty() {
+ return Err(Error::Generic(
+ "severity matcher must contain severities to match".to_string(),
+ ));
+ }
+
+ Ok(())
+ }
}
#[cfg(feature = "legacy-matchers")]
@@ -44,6 +54,10 @@ pub mod inline {
fn matches(&self, notification: &Notification) -> Result<bool, Error> {
self.0.matches(notification)
}
+
+ fn validate(&self) -> Result<(), Error> {
+ self.0.validate()
+ }
}
impl fmt::Display for InlineSeverityMatcher {
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox-widget-toolkit v2 18/34] notifications: increase matcher window width
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (16 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox v2 17/34] notify: matcher: don't allow empty field/severity matchers Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-widget-toolkit v2 19/34] notifications: matcher: add support for match expressions Lukas Wagner
` (15 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
The two-column layout of the dialog benefits from a bit more space.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
src/window/NotificationMatcherEdit.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/window/NotificationMatcherEdit.js b/src/window/NotificationMatcherEdit.js
index a1d173c..1999e20 100644
--- a/src/window/NotificationMatcherEdit.js
+++ b/src/window/NotificationMatcherEdit.js
@@ -78,7 +78,7 @@ Ext.define('Proxmox.window.NotificationMatcherEdit', {
labelWidth: 120,
},
- width: 800,
+ width: 1000,
initComponent: function () {
let me = this;
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox-widget-toolkit v2 19/34] notifications: matcher: add support for match expressions
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (17 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox-widget-toolkit v2 18/34] notifications: increase matcher window width Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-widget-toolkit v2 20/34] notifications: matcher: add better calendar editor Lukas Wagner
` (14 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
This patches changes to 'matcher edit window' to support the new
'expression' paramter in the matcher config. Instead of of flat
'match-*' configuration keys, 'expression' contains the match rules as a
serialized JSON blob. This allows us to create arbitrarily nested match
formulas.
Support for match expressions has to be enabled via a feature flag. This
avoids awkward versioned breaks between the widget toolkit package and
the main product.
The entire match rule panel has changed so much that copying the old
component and using that as a base was deemed more useful than modifying
the existing panel in-place. The old component can be removed once we
have migrated all products to this new system.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
Notes:
Changes since v1:
- Don't allow empty all-of/any-of/one-of rules, show an
error below the tree then
src/Makefile | 1 +
src/Schema.js | 10 +
src/css/ext6-pmx.css | 51 ++
.../NotificationMatchExpressionEditPanel.js | 866 ++++++++++++++++++
src/proxmox-dark/scss/extjs/_treepanel.scss | 5 +
src/proxmox-dark/scss/proxmox/_general.scss | 4 +
src/window/NotificationMatcherEdit.js | 13 +-
7 files changed, 945 insertions(+), 5 deletions(-)
create mode 100644 src/panel/NotificationMatchExpressionEditPanel.js
diff --git a/src/Makefile b/src/Makefile
index 9c52339..f11716a 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -64,6 +64,7 @@ JSSRC= \
panel/LogView.js \
panel/NodeInfoRepoStatus.js \
panel/NotificationConfigView.js \
+ panel/NotificationMatchExpressionEditPanel.js \
panel/JournalView.js \
panel/PermissionView.js \
panel/PruneKeepPanel.js \
diff --git a/src/Schema.js b/src/Schema.js
index 5de3f53..e72d301 100644
--- a/src/Schema.js
+++ b/src/Schema.js
@@ -86,6 +86,16 @@ Ext.define('Proxmox.Schema', {
}
},
+ notificationMatcherExpressions: false,
+
+ notificationMatcherPanelType: function () {
+ if (Proxmox.Schema.notificationMatcherExpressions) {
+ return 'pmxNotificationMatchExpressionEditPanel';
+ } else {
+ return 'pmxNotificationMatchRulesEditPanel';
+ }
+ },
+
pxarFileTypes: {
b: { icon: 'cube', label: gettext('Block Device') },
c: { icon: 'tty', label: gettext('Character Device') },
diff --git a/src/css/ext6-pmx.css b/src/css/ext6-pmx.css
index 878dfa0..93010e6 100644
--- a/src/css/ext6-pmx.css
+++ b/src/css/ext6-pmx.css
@@ -248,6 +248,53 @@ div.right-aligned {
background-color: #f5f5f5;
}
+/* the small icons */
+.x-tree-icon-custom:after {
+ position: relative;
+ left: -5px;
+ top: 1px;
+ font-size: 0.75em;
+ text-shadow: -1px 0px 2px #fff;
+ content: "\ ";
+}
+
+/* yellow ! triangle */
+.x-tree-icon-custom.internal-error:after {
+ content: "\f071";
+ color: #ffcc00;
+}
+
+.x-tree-node-text > code {
+ display: inline-block;
+ background-color: #f5f5f5;
+ border-radius: 4px;
+ padding: 1px 4px;
+ margin: 1px 0;
+ font-size: 11px;
+ /* a single long value, e.g. a regex, has to break instead of overflowing */
+ overflow-wrap: anywhere;
+}
+
+/* The notification matcher rule tree wraps its labels, see 'cellWrap'. Laying
+ * the cell out as a flex row keeps the wrapped lines aligned with the first
+ * one, instead of restarting at the left edge under the indentation. */
+.pmx-match-rule-tree .x-grid-cell-inner-treecolumn {
+ display: flex;
+ align-items: flex-start;
+}
+
+/* indentation, elbows and the node icon have a fixed size and must not shrink */
+.pmx-match-rule-tree .x-tree-elbow-img,
+.pmx-match-rule-tree .x-tree-icon {
+ flex: none;
+}
+
+/* 'min-width' lets the label get narrower than its longest value */
+.pmx-match-rule-tree .x-tree-node-text {
+ flex: 1;
+ min-width: 0;
+}
+
/* fix padding for legend in header */
.x-legend-inner {
padding: 0;
@@ -431,3 +478,7 @@ div.right-aligned {
font-style: italic;
}
/* journal log level coloring end */
+
+.pmx-horizontal-separator {
+ border-top: 1px solid #cfcfcf;
+}
diff --git a/src/panel/NotificationMatchExpressionEditPanel.js b/src/panel/NotificationMatchExpressionEditPanel.js
new file mode 100644
index 0000000..773fe81
--- /dev/null
+++ b/src/panel/NotificationMatchExpressionEditPanel.js
@@ -0,0 +1,866 @@
+Ext.define('Proxmox.panel.NotificationMatchExpressionEditPanel', {
+ extend: 'Proxmox.panel.InputPanel',
+ xtype: 'pmxNotificationMatchExpressionEditPanel',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ controller: {
+ xclass: 'Ext.app.ViewController',
+
+ // we want to also set the empty value, but 'bind' does not do that so
+ // we have to set it then (and only then) to get the correct value in
+ // the tree
+ control: {
+ field: {
+ change: function (cmp) {
+ let me = this;
+ let vm = me.getViewModel();
+ if (cmp.field) {
+ let record = vm.get('selectedRecord');
+ if (!record) {
+ return;
+ }
+ let data = Ext.apply({}, record.get('data'));
+ let value = cmp.getValue();
+ // only update if the value is empty (or empty array)
+ if (!value || !value.length) {
+ data[cmp.field] = value;
+ record.set({ data });
+ }
+ }
+ },
+ },
+ },
+ },
+
+ viewModel: {
+ data: {
+ invertMatch: false,
+ selectedRecord: null,
+ matchFieldType: 'exact',
+ matchFieldField: '',
+ matchFieldValue: '',
+ },
+
+ formulas: {
+ showMatcherType: (get) => get('selectedRecord') !== null,
+
+ invertMatch: {
+ bind: {
+ bindTo: '{selectedRecord}',
+ deep: true,
+ },
+ get: function (record) {
+ return record?.get('invert');
+ },
+ set: function (value) {
+ let me = this;
+ let record = me.get('selectedRecord');
+ record.set('invert', value);
+ },
+ },
+ nodeType: {
+ get: function (get) {
+ let record = get('selectedRecord');
+ return record?.get('type');
+ },
+ set: function (value) {
+ let me = this;
+ let record = me.get('selectedRecord');
+
+ let data;
+ let leaf;
+
+ switch (value) {
+ case 'match-severity':
+ data = {
+ value: ['info', 'notice', 'warning', 'error', 'unknown'],
+ };
+ leaf = true;
+ break;
+ case 'match-field':
+ data = {
+ type: 'exact',
+ field: '',
+ value: '',
+ };
+ leaf = true;
+ break;
+ case 'match-calendar':
+ data = {
+ value: '',
+ };
+ leaf = true;
+ break;
+ case 'any-of':
+ data = {};
+ leaf = false;
+ break;
+ case 'all-of':
+ data = {};
+ leaf = false;
+ break;
+ case 'one-of':
+ data = {};
+ leaf = false;
+ break;
+ case 'match-always':
+ value = 'match-always';
+ data = {};
+ leaf = true;
+ break;
+ }
+
+ let node = {
+ type: value,
+ invert: false,
+ data,
+ leaf,
+ };
+
+ if (!leaf) {
+ node.expanded = true;
+ node.expandable = false;
+ } else {
+ record.removeAll();
+ }
+
+ record.set(node);
+ },
+ },
+ },
+ },
+
+ column1: [
+ {
+ xtype: 'pmxNotificationMatchRuleTreeExpression',
+ cbind: {
+ baseUrl: '{baseUrl}',
+ },
+ },
+ ],
+
+ column2: [
+ {
+ xtype: 'pmxNotificationMatchExpressionNodePanel',
+ cbind: {
+ baseUrl: '{baseUrl}',
+ },
+ },
+ ],
+
+ onGetValues: function (values) {
+ let me = this;
+
+ if (!me.isCreate) {
+ Proxmox.Utils.assemble_field_data(values, { delete: 'match-field' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'match-severity' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'match-calendar' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'mode' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'invert-match' });
+ }
+
+ return values;
+ },
+});
+
+Ext.define('Proxmox.panel.NotificationMatchRuleTreeExpression', {
+ extend: 'Ext.panel.Panel',
+ xtype: 'pmxNotificationMatchRuleTreeExpression',
+ mixins: ['Proxmox.Mixin.CBind'],
+ border: false,
+
+ // Returns a message describing why a node is incomplete, or undefined if
+ // the node is fully configured. Incomplete nodes are marked in the tree and
+ // block submitting the form.
+ getNodeError: function (type, data, hasChildren) {
+ switch (type) {
+ case 'match-severity': {
+ let v = Ext.isArray(data.value) ? data.value.join(', ') : data.value;
+ if (!v) {
+ return gettext('Severity rules must match at least one severity.');
+ }
+ break;
+ }
+ case 'match-field': {
+ let field = data.field;
+ let value = data.value;
+ if (!field || !value || (Ext.isArray(value) && !value.length)) {
+ return gettext('Field rules must have a field and a value.');
+ }
+ break;
+ }
+ case 'match-calendar': {
+ let v = data.value;
+ if (!v || !v.length) {
+ return gettext('Calendar rules must have a schedule.');
+ }
+ break;
+ }
+ case 'any-of':
+ case 'all-of':
+ case 'one-of':
+ if (!hasChildren) {
+ return gettext('Rule groups must contain at least one sub-rule.');
+ }
+ break;
+ }
+
+ return undefined;
+ },
+
+ getNodeIcon: function (type, data, invert, hasChildren) {
+ let me = this;
+ let iconCls = 'fa';
+
+ switch (type) {
+ case 'match-severity':
+ iconCls += ' fa-exclamation';
+ break;
+ case 'match-field':
+ iconCls += ' fa-square-o';
+ break;
+ case 'match-calendar':
+ iconCls += ' fa-calendar-o';
+ break;
+ case 'any-of':
+ case 'all-of':
+ case 'one-of':
+ iconCls += ' fa-filter';
+ break;
+ case 'match-always':
+ if (invert) {
+ iconCls += ' fa-ban';
+ } else {
+ iconCls += ' fa-check-circle';
+ }
+ break;
+ }
+
+ if (me.getNodeError(type, data, hasChildren)) {
+ iconCls += ' internal-error';
+ }
+
+ return iconCls;
+ },
+
+ renderTreeLabel: function (_value, meta, record) {
+ let data = record.get('data');
+ let invert = record.get('invert');
+ let type = record.get('type');
+
+ switch (type) {
+ case 'match-severity': {
+ let v;
+
+ if (data.value) {
+ v = data.value.map((x) => `<code>${Ext.String.htmlEncode(x)}</code>`).join(' ');
+ } else {
+ v = '<code>?</code>';
+ }
+
+ if (invert) {
+ return Ext.String.format(gettext('Severity: all except {0}'), v);
+ } else {
+ return Ext.String.format(gettext('Severity: {0}'), v);
+ }
+ }
+ case 'match-field': {
+ let valueIsArray = Ext.isArray(data.value);
+
+ let field = data.field ? data.field : '?';
+ let value =
+ (!valueIsArray && data.value) || (valueIsArray && data.value.length)
+ ? data.value
+ : '?';
+ let matchType = data.type;
+
+ let fieldHtml = `<code>${Ext.String.htmlEncode(field)}</code>`;
+ let valueHtml = `<code>${Ext.String.htmlEncode(value)}</code>`;
+
+ if (matchType === 'exact') {
+ if (Ext.isArray(value) && value.length > 1) {
+ valueHtml = value
+ .map((x) => `<code>${Ext.String.htmlEncode(x)}</code>`)
+ .join(' ');
+
+ if (invert) {
+ // inverted field matcher, matching multiple values
+ return Ext.String.format(
+ gettext('Field: {0} none of {1}'),
+ fieldHtml,
+ valueHtml,
+ );
+ } else {
+ // non-inverted field matcher, matching multiple values
+ return Ext.String.format(
+ gettext('Field: {0} any of {1}'),
+ fieldHtml,
+ valueHtml,
+ );
+ }
+ } else if (invert) {
+ // inverted field matcher, matching a single value
+ return Ext.String.format(
+ gettext('Field: {0} is not {1}'),
+ fieldHtml,
+ valueHtml,
+ );
+ } else {
+ // non-inverted field matcher, matching a single value
+ return Ext.String.format(
+ gettext('Field: {0} is {1}'),
+ fieldHtml,
+ valueHtml,
+ );
+ }
+ } else if (invert) {
+ // inverted regex-based field matcher
+ return Ext.String.format(
+ gettext('Field: {0} does not match {1}'),
+ fieldHtml,
+ valueHtml,
+ );
+ } else {
+ // non-inverted regex-based field matcher
+ return Ext.String.format(
+ gettext('Field: {0} matches {1}'),
+ fieldHtml,
+ valueHtml,
+ );
+ }
+ }
+ case 'match-calendar': {
+ let value = data.value ? data.value : '?';
+ value = `<code>${Ext.String.htmlEncode(value)}</code>`;
+
+ if (invert) {
+ return Ext.String.format(gettext('Calendar: outside {0}'), value);
+ } else {
+ return Ext.String.format(gettext('Calendar: within {0}'), value);
+ }
+ }
+ case 'any-of':
+ if (invert) {
+ return gettext('No sub-rule matches');
+ } else {
+ return gettext('At least one sub-rule matches');
+ }
+
+ case 'all-of':
+ if (invert) {
+ return gettext('At least one sub-rule does not match');
+ } else {
+ return gettext('All sub-rules match');
+ }
+
+ case 'one-of':
+ if (invert) {
+ return gettext('Not: Exactly one sub-rule matches');
+ } else {
+ return gettext('Exactly one sub-rule matches');
+ }
+
+ case 'match-always':
+ if (invert) {
+ return gettext('Never match');
+ } else {
+ return gettext('Always match');
+ }
+ }
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ let treeStore = Ext.create('Ext.data.TreeStore', {
+ root: {
+ expanded: true,
+ expandable: false,
+ text: '',
+ type: 'match-always',
+ data: {},
+ invert: false,
+ leaf: true,
+ children: [],
+ iconCls: 'fa fa-filter',
+ },
+ });
+
+ // The field holding the expression is hidden, so render its validation
+ // errors below the tree instead.
+ let errorLabel = Ext.create({
+ xtype: 'component',
+ hidden: true,
+ cls: 'x-form-invalid-under-default',
+ });
+
+ let getExpressionErrors = function () {
+ let errors = [];
+
+ treeStore.getRoot().cascade(function (node) {
+ let error = me.getNodeError(
+ node.get('type'),
+ node.get('data'),
+ node.hasChildNodes(),
+ );
+
+ if (error && !errors.includes(error)) {
+ errors.push(error);
+ }
+ });
+
+ return errors;
+ };
+
+ let realExpression = Ext.create({
+ xtype: 'hiddenfield',
+ name: 'expression',
+ getErrors: function () {
+ let errors = getExpressionErrors();
+
+ errorLabel.setHtml(
+ errors.map((error) => Ext.String.htmlEncode(error)).join('<br>'),
+ );
+ errorLabel.setHidden(!errors.length);
+
+ return errors;
+ },
+ setValue: function (value) {
+ this.value = value;
+ this.checkChange();
+ },
+ getValue: function () {
+ return this.value;
+ },
+ getSubmitValue: function () {
+ let value = this.value;
+ return value;
+ },
+ });
+
+ let storeChanged = function (store) {
+ store.suspendEvent('datachanged');
+
+ store.each(function (model) {
+ let type = model.get('type');
+ let data = model.get('data');
+ let invert = model.get('invert');
+
+ let iconCls = me.getNodeIcon(type, data, invert, model.hasChildNodes());
+
+ model.set({
+ iconCls,
+ });
+ });
+
+ let root = store.getRoot();
+ let stack = [[]];
+
+ root.cascade({
+ before: function (node) {
+ let type = node.get('type');
+ let data = node.get('data');
+ let invert = node.get('invert');
+
+ switch (type) {
+ case 'all-of':
+ case 'any-of':
+ case 'one-of':
+ stack.push([]);
+ break;
+ case 'match-calendar':
+ {
+ let obj = {
+ match: {
+ type: 'calendar',
+ schedule: data.value,
+ },
+ };
+
+ if (invert) {
+ obj = {
+ not: obj,
+ };
+ }
+
+ stack.at(-1).push(obj);
+ }
+
+ break;
+ case 'match-field':
+ {
+ let obj = {
+ match: {
+ type: 'field',
+ field: data.field,
+ },
+ };
+
+ if (data.type === 'exact') {
+ obj.match.values = data.value;
+ } else {
+ obj.match.regex = data.value;
+ }
+
+ if (invert) {
+ obj = {
+ not: obj,
+ };
+ }
+
+ stack.at(-1).push(obj);
+ }
+ break;
+ case 'match-severity':
+ {
+ let obj = {
+ match: {
+ type: 'severity',
+ severities: data.value,
+ },
+ };
+
+ if (invert) {
+ obj = {
+ not: obj,
+ };
+ }
+
+ stack.at(-1).push(obj);
+ }
+ break;
+ case 'match-always':
+ stack.at(-1).push({
+ constant: !invert,
+ });
+ break;
+ }
+
+ // Visit this node
+ return true;
+ },
+ after: function (node) {
+ let type = node.get('type');
+ let invert = node.get('invert');
+
+ switch (type) {
+ case 'all-of':
+ case 'any-of':
+ case 'one-of':
+ {
+ let children = stack.pop();
+
+ let a = {};
+ a[type] = children;
+
+ if (invert) {
+ a = {
+ not: a,
+ };
+ }
+
+ stack.at(-1).push(a);
+ }
+ break;
+ }
+ },
+ });
+
+ let topLevelNodes = stack.pop();
+ let obj = topLevelNodes[0];
+
+ realExpression.suspendEvent('change');
+ realExpression.setValue(JSON.stringify(obj));
+ realExpression.resumeEvent('change');
+
+ // the expression can stay the same while the tree became (in)valid,
+ // e.g. when the last child of a group is removed
+ realExpression.validate();
+
+ store.resumeEvent('datachanged');
+ };
+
+ realExpression.addListener('change', function (field, value) {
+ let obj;
+
+ try {
+ obj = JSON.parse(value);
+ } catch (e) {
+ Ext.Msg.alert(gettext('Invalid matcher expression'), e);
+ return;
+ }
+
+ function visit(obj) {
+ for (const [key, value] of Object.entries(obj)) {
+ let ret;
+
+ switch (key) {
+ case 'match':
+ {
+ switch (value.type) {
+ case 'calendar':
+ {
+ ret = {
+ type: 'match-calendar',
+ data: {
+ value: value.schedule,
+ },
+ leaf: true,
+ };
+ }
+ break;
+ case 'field':
+ {
+ ret = {
+ type: 'match-field',
+ data: {
+ field: value.field,
+ },
+ leaf: true,
+ };
+
+ if (value.regex) {
+ ret.data.type = 'regex';
+ ret.data.value = value.regex;
+ } else {
+ ret.data.type = 'exact';
+ ret.data.value = value.values;
+ }
+ }
+ break;
+ case 'severity':
+ {
+ ret = {
+ type: 'match-severity',
+ data: {
+ value: value.severities,
+ },
+ leaf: true,
+ };
+ }
+ break;
+ }
+ }
+ break;
+ case 'all-of':
+ case 'any-of':
+ case 'one-of':
+ {
+ let children = [];
+ for (let child of value) {
+ children.push(visit(child));
+ }
+ ret = {
+ type: key,
+ data: {},
+ children,
+ leaf: false,
+ };
+ }
+ break;
+ case 'not':
+ {
+ let child = visit(value);
+ ret = {
+ ...child,
+ invert: true,
+ };
+ }
+ break;
+ case 'constant': {
+ ret = {
+ type: 'match-always',
+ data: {},
+ invert: !value,
+ leaf: true,
+ };
+ }
+ }
+
+ let iconCls = me.getNodeIcon(
+ ret.type,
+ ret.data,
+ ret.invert,
+ !!ret.children?.length,
+ );
+
+ ret.iconCls = iconCls;
+ if (!ret.leaf) {
+ ret.expanded = true;
+ ret.expandable = false;
+ }
+
+ return ret;
+ }
+ }
+
+ let rootNode = visit(obj);
+
+ treeStore.setRootNode(rootNode);
+ });
+
+ treeStore.addListener('datachanged', storeChanged);
+
+ let treePanel = Ext.create({
+ xtype: 'treepanel',
+ store: treeStore,
+ // scope for the label wrapping rules in ext6-pmx.css
+ cls: 'pmx-match-rule-tree',
+ minHeight: 400,
+ maxHeight: 400,
+ scrollable: true,
+ // wrapped labels have no predictable height, so render all rows
+ // instead of estimating the scroll range from a sample row
+ bufferedRenderer: false,
+
+ // Allow drag&drop for tree nodes
+ viewConfig: {
+ plugins: {
+ ptype: 'treeviewdragdrop',
+ containerScroll: true,
+ },
+ },
+
+ bind: {
+ selection: '{selectedRecord}',
+ },
+
+ columns: [
+ {
+ xtype: 'treecolumn',
+ dataIndex: 'text',
+ renderer: me.renderTreeLabel,
+ flex: 1,
+ // rules can list many values, wrap them so that all of
+ // them stay visible; this implies variableRowHeight
+ cellWrap: true,
+ },
+ {
+ xtype: 'actioncolumn',
+ width: 60,
+ items: [
+ {
+ handler: (view, rI, cI, item, e, record) => {
+ let node = record.appendChild({
+ type: 'match-field',
+ data: {
+ type: 'exact',
+ field: '',
+ value: '',
+ },
+ leaf: true,
+ });
+
+ view.setSelection(node);
+ },
+ getTip: (v, m, rec) =>
+ Ext.String.format(gettext('Add new sub-rule'), v),
+ getClass: (v, m, { data }) => (data.leaf ? '' : 'fa fa-plus-circle'),
+ isActionDisabled: (v, r, c, i, rec) => rec.data.leaf,
+ },
+ {
+ handler: function (view, rI, cI, item, e, record) {
+ if (record.hasChildNodes()) {
+ Ext.Msg.confirm(
+ gettext('Remove rule?'),
+ gettext('Remove rule and all of its sub-rules?'),
+ (decision) => {
+ if (decision === 'yes') {
+ record.remove(true);
+ }
+ },
+ );
+ } else {
+ record.remove(true);
+ }
+ },
+ getTip: (v, m, rec) =>
+ Ext.String.format(gettext('Remove this rule'), v),
+ getClass: (v, m, { data }) => 'fa fa-trash-o',
+ isActionDisabled: (v, r, c, i, rec) => rec.isRoot(),
+ },
+ ],
+ },
+ ],
+ });
+
+ Ext.apply(me, {
+ items: [
+ realExpression,
+ treePanel,
+ {
+ // nothing in the tree hints at the nodes being movable
+ xtype: 'container',
+ margin: '5 0 0 0',
+ html: gettext('Rules can be reordered and moved between groups with drag and drop.'),
+ },
+ errorLabel,
+ ],
+ });
+ me.callParent();
+ },
+});
+
+Ext.define('Proxmox.panel.NotificationMatchExpressionNodePanel', {
+ extend: 'Ext.panel.Panel',
+ xtype: 'pmxNotificationMatchExpressionNodePanel',
+ mixins: ['Proxmox.Mixin.CBind'],
+ border: false,
+ layout: 'anchor',
+
+ items: [
+ {
+ xtype: 'proxmoxKVComboBox',
+ fieldLabel: gettext('Rule Type'),
+ isFormField: false,
+ allowBlank: false,
+ // Hide initially to avoid glitches when opening the window
+ hidden: true,
+ bind: {
+ value: '{nodeType}',
+ hidden: '{!showMatcherType}',
+ },
+
+ comboItems: [
+ ['match-always', gettext('Always match')],
+ ['all-of', gettext('All sub-rules match')],
+ ['any-of', gettext('At least one sub-rule matches')],
+ ['one-of', gettext('Exactly one sub-rule matches')],
+ ['match-field', gettext('Match Field')],
+ ['match-severity', gettext('Match Severity')],
+ ['match-calendar', gettext('Match Calendar')],
+ ],
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ isFormField: false,
+ fieldLabel: gettext('Invert Rule'),
+ allowBlank: false,
+ bind: {
+ value: '{invertMatch}',
+ hidden: '{!showMatcherType}',
+ },
+ },
+ {
+ xtype: 'component',
+ height: 1,
+ margin: '20 0 20 0',
+ cls: 'pmx-horizontal-separator',
+ bind: {
+ hidden: '{!showMatcherType}',
+ },
+ },
+ {
+ xtype: 'pmxNotificationMatchFieldSettings',
+ cbind: {
+ baseUrl: '{baseUrl}',
+ },
+ },
+ {
+ xtype: 'pmxNotificationMatchSeveritySettings',
+ },
+ {
+ xtype: 'pmxNotificationMatchCalendarSettings',
+ },
+ ],
+});
diff --git a/src/proxmox-dark/scss/extjs/_treepanel.scss b/src/proxmox-dark/scss/extjs/_treepanel.scss
index 0480371..bf92f0b 100644
--- a/src/proxmox-dark/scss/extjs/_treepanel.scss
+++ b/src/proxmox-dark/scss/extjs/_treepanel.scss
@@ -22,3 +22,8 @@
background-color: $background-darker;
border-color: $border-color;
}
+
+.x-tree-node-text > code {
+ background-color: $background-darkest;
+}
+
diff --git a/src/proxmox-dark/scss/proxmox/_general.scss b/src/proxmox-dark/scss/proxmox/_general.scss
index dcbf049..36ff65c 100644
--- a/src/proxmox-dark/scss/proxmox/_general.scss
+++ b/src/proxmox-dark/scss/proxmox/_general.scss
@@ -51,3 +51,7 @@ div.eol-notice + div[id^="panel-"] > div[id^="panel-"][id$="-bodyWrap"] > div {
.pmx-unclickable {
pointer-events: none;
}
+
+.pmx-horizontal-separator {
+ border-top: 1px solid $border-color-alt;
+}
diff --git a/src/window/NotificationMatcherEdit.js b/src/window/NotificationMatcherEdit.js
index 1999e20..893c3e3 100644
--- a/src/window/NotificationMatcherEdit.js
+++ b/src/window/NotificationMatcherEdit.js
@@ -95,6 +95,9 @@ Ext.define('Proxmox.window.NotificationMatcherEdit', {
me.method = 'POST';
} else {
me.url += `/${me.name}`;
+ if (Proxmox.Schema.notificationMatcherExpressions) {
+ me.loadUrl = me.url + '?migrate-to-expression=1';
+ }
me.method = 'PUT';
}
@@ -119,7 +122,7 @@ Ext.define('Proxmox.window.NotificationMatcherEdit', {
{
name: me.name,
title: gettext('Match Rules'),
- xtype: 'pmxNotificationMatchRulesEditPanel',
+ xtype: Proxmox.Schema.notificationMatcherPanelType(),
isCreate: me.isCreate,
baseUrl: me.baseUrl,
},
@@ -1013,7 +1016,7 @@ Ext.define('Proxmox.panel.MatchCalendarSettings', {
initComponent: function () {
let me = this;
Ext.apply(me.viewModel, {
- parent: me.up('pmxNotificationMatchRulesEditPanel').getViewModel(),
+ parent: me.up(Proxmox.Schema.notificationMatcherPanelType()).getViewModel(),
});
me.callParent();
},
@@ -1091,7 +1094,7 @@ Ext.define('Proxmox.panel.MatchSeveritySettings', {
initComponent: function () {
let me = this;
Ext.apply(me.viewModel, {
- parent: me.up('pmxNotificationMatchRulesEditPanel').getViewModel(),
+ parent: me.up(Proxmox.Schema.notificationMatcherPanelType()).getViewModel(),
});
me.callParent();
},
@@ -1164,7 +1167,7 @@ Ext.define('Proxmox.panel.MatchFieldSettings', {
regexVal += `(${currentData.value.join('|')})`;
}
regexVal += '$';
- newValue.push(regexVal);
+ newValue = regexVal;
}
record.set({
@@ -1282,7 +1285,7 @@ Ext.define('Proxmox.panel.MatchFieldSettings', {
});
Ext.apply(me.viewModel, {
- parent: me.up('pmxNotificationMatchRulesEditPanel').getViewModel(),
+ parent: me.up(Proxmox.Schema.notificationMatcherPanelType()).getViewModel(),
});
Ext.apply(me, {
items: [
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox-widget-toolkit v2 20/34] notifications: matcher: add better calendar editor
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (18 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox-widget-toolkit v2 19/34] notifications: matcher: add support for match expressions Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-widget-toolkit v2 21/34] notifications: matcher: consistently use title case for UI elements Lukas Wagner
` (13 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
This new editor allows one to enter the start time, end time and tick
the matched week-days, instead of having to enter the appropriate string
representation of the time range (e.g. 'mon..tue 08:00-12:00')
The general approach was copied from PBS's traffic rule edit panel, but
it is too different to generalize this into a new, reusable component.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
Notes:
Changes since v1:
- don't allow users to type anything but digits and : into the
timefields
- Show error message/icon in new match expression edit panel if
- start time is after end time
- no day is selected
.../NotificationMatchExpressionEditPanel.js | 26 +-
src/window/NotificationMatcherEdit.js | 335 +++++++++++++++++-
2 files changed, 347 insertions(+), 14 deletions(-)
diff --git a/src/panel/NotificationMatchExpressionEditPanel.js b/src/panel/NotificationMatchExpressionEditPanel.js
index 773fe81..78a99cc 100644
--- a/src/panel/NotificationMatchExpressionEditPanel.js
+++ b/src/panel/NotificationMatchExpressionEditPanel.js
@@ -87,7 +87,7 @@ Ext.define('Proxmox.panel.NotificationMatchExpressionEditPanel', {
break;
case 'match-calendar':
data = {
- value: '',
+ value: '00:00-23:59',
};
leaf = true;
break;
@@ -169,10 +169,19 @@ Ext.define('Proxmox.panel.NotificationMatchRuleTreeExpression', {
mixins: ['Proxmox.Mixin.CBind'],
border: false,
+ // Converts a 'HH:MM' time into minutes since midnight, or NaN if it cannot
+ // be parsed.
+ parseTime: function (time) {
+ let [hours, minutes] = time.split(':');
+ return Number(hours) * 60 + Number(minutes ?? '0');
+ },
+
// Returns a message describing why a node is incomplete, or undefined if
// the node is fully configured. Incomplete nodes are marked in the tree and
// block submitting the form.
getNodeError: function (type, data, hasChildren) {
+ let me = this;
+
switch (type) {
case 'match-severity': {
let v = Ext.isArray(data.value) ? data.value.join(', ') : data.value;
@@ -192,7 +201,15 @@ Ext.define('Proxmox.panel.NotificationMatchRuleTreeExpression', {
case 'match-calendar': {
let v = data.value;
if (!v || !v.length) {
- return gettext('Calendar rules must have a schedule.');
+ return gettext('Calendar rules must match at least one day.');
+ }
+
+ // Only schedules which the editor itself can produce are
+ // checked, any other one is left to the backend. Unparsable
+ // times compare as NaN and are thus not reported either.
+ let [, start, end] = /^(?:\S*\s+)?([0-9:]+)-([0-9:]+)$/.exec(v) ?? [];
+ if (start !== undefined && me.parseTime(start) >= me.parseTime(end)) {
+ return gettext('A calendar rule\'s start time cannot be after the end time.');
}
break;
}
@@ -330,7 +347,10 @@ Ext.define('Proxmox.panel.NotificationMatchRuleTreeExpression', {
}
}
case 'match-calendar': {
- let value = data.value ? data.value : '?';
+ // A schedule which is not fully specified, for instance because
+ // no weekday is selected, renders as blank, so show it as unset
+ // instead.
+ let value = Ext.isString(data.value) && data.value.trim() ? data.value : '?';
value = `<code>${Ext.String.htmlEncode(value)}</code>`;
if (invert) {
diff --git a/src/window/NotificationMatcherEdit.js b/src/window/NotificationMatcherEdit.js
index 893c3e3..7c3decf 100644
--- a/src/window/NotificationMatcherEdit.js
+++ b/src/window/NotificationMatcherEdit.js
@@ -361,7 +361,7 @@ Ext.define('Proxmox.panel.NotificationRulesEditPanel', {
break;
case 'match-calendar':
data = {
- value: '',
+ value: '00:00-23:59',
};
break;
}
@@ -977,6 +977,11 @@ Ext.define('Proxmox.panel.MatchCalendarSettings', {
},
set: function (value) {
let me = this;
+
+ if (!me.get('typeIsMatchCalendar')) {
+ return;
+ }
+
let record = me.get('selectedRecord');
let currentData = record.get('data');
record.set({
@@ -992,23 +997,331 @@ Ext.define('Proxmox.panel.MatchCalendarSettings', {
},
},
},
+ controller: {
+ xclass: 'Ext.app.ViewController',
+ control: {
+ 'grid checkbox': {
+ change: 'dowChanged',
+ },
+ timefield: {
+ change: 'timeChanged',
+ },
+ 'field[reference=timeframe]': {
+ change: 'setGridData',
+ },
+ },
+
+ weekdays: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
+
+ setGridData: function (field, value) {
+ let me = this;
+
+ let record = me.parseTimeframe(value);
+
+ me.lookup('weekdayGrid').getStore().setData([record]);
+ me.lookup('timeStart').setValue(record.start);
+ me.lookup('timeEnd').setValue(record.end);
+ },
+
+ parseTimeframe: function (timeframe) {
+ let me = this;
+ let matches = /^(?:(\S*)\s+)?([0-9:]+)-([0-9:]+)$/.exec(timeframe);
+
+ // Schedules which the editor cannot represent, an empty one in
+ // particular, start out without any selected day and are thus
+ // shown as invalid.
+ if (!matches) {
+ return {
+ start: '00:00',
+ end: '23:59',
+ };
+ }
+
+ let [, days, start, end] = matches;
+
+ if (start === '0') {
+ start = '00:00';
+ }
+
+ let record = {
+ start,
+ end,
+ };
+
+ if (!days) {
+ days = 'mon..sun';
+ }
+
+ days = days.split(',');
+ days.forEach((day) => {
+ if (record[day]) {
+ return;
+ }
+
+ if (me.weekdays.indexOf(day) !== -1) {
+ record[day] = true;
+ } else {
+ // we have a range 'xxx..yyy'
+ let [startDay, endDay] = day.split('..');
+ let startIdx = me.weekdays.indexOf(startDay);
+ let endIdx = me.weekdays.indexOf(endDay);
+
+ if (endIdx < startIdx) {
+ endIdx += me.weekdays.length;
+ }
+
+ for (let dayIdx = startIdx; dayIdx <= endIdx; dayIdx++) {
+ let curDay = me.weekdays[dayIdx % me.weekdays.length];
+ if (!record[curDay]) {
+ record[curDay] = true;
+ }
+ }
+ }
+ });
+
+ return record;
+ },
+
+ dowChanged: function (field, value) {
+ let me = this;
+ let record = field.getWidgetRecord();
+ if (record === undefined) {
+ // this is sometimes called before a record/column is initialized
+ return;
+ }
+ let col = field.getWidgetColumn();
+ record.set(col.dataIndex, value);
+ record.commit();
+
+ let startField = me.lookup('timeStart');
+ let endField = me.lookup('timeEnd');
+
+ me.updateTimeframeField(startField, endField);
+ },
+
+ timeChanged: function (field, value) {
+ let me = this;
+
+ let startField = me.lookup('timeStart');
+ let endField = me.lookup('timeEnd');
+
+ let start = startField.getValue();
+ let end = endField.getValue();
+
+ let valid = !(start && end && start >= end);
+
+ if (!valid) {
+ startField.markInvalid(gettext('Start time must be before end time'));
+ endField.markInvalid(gettext('End time must be after start time'));
+ } else {
+ startField.clearInvalid();
+ endField.clearInvalid();
+ }
+
+ me.updateTimeframeField(startField, endField);
+ },
+
+ updateTimeframeField: function (startField, endField) {
+ let me = this;
+
+ let data = me.lookup('weekdayGrid').getStore().getData().getAt(0);
+
+ let days = me.formatSelectedDays(data.data);
+
+ let start = me.formatTime(startField);
+ let end = me.formatTime(endField);
+
+ // An empty selection cannot be expressed, as a schedule without
+ // weekdays already matches every day. Store an empty schedule
+ // instead, which is reported as invalid by the rule tree.
+ let timeframe = '';
+
+ if (days !== undefined) {
+ timeframe = days ? `${days} ${start}-${end}` : `${start}-${end}`;
+ }
+
+ let field = me.lookup('timeframe');
+ field.suspendEvent('change');
+ field.setValue(timeframe);
+
+ me.getViewModel().set('matchCalendarValue', timeframe);
+
+ field.resumeEvent('change');
+ },
+
+ // Returns the weekday part of a schedule, which is empty if every day is
+ // selected, or `undefined` if no day is selected at all.
+ formatSelectedDays: function (days) {
+ let me = this;
+ let selected = me.weekdays.filter((day) => days[day]);
+
+ if (selected.length === 0) {
+ return undefined;
+ }
+ if (selected.length === me.weekdays.length) {
+ return '';
+ }
+ if (selected.length === 1) {
+ return selected[0];
+ }
+
+ // Check if selected days are a contiguous block in weekday order
+ let indices = selected.map((day) => me.weekdays.indexOf(day));
+ let isContiguous = indices.every((idx, i) => i === 0 || idx === indices[i - 1] + 1);
+
+ if (isContiguous) {
+ return `${selected[0]}..${selected[selected.length - 1]}`;
+ }
+
+ return selected.join(',');
+ },
+
+ formatTime: function (timefield) {
+ let value = timefield.getValue();
+
+ if (!value) {
+ return '';
+ }
+
+ let hours = value.getHours().toString().padStart(2, '0');
+ let minutes = value.getMinutes().toString().padStart(2, '0');
+ return `${hours}:${minutes}`;
+ },
+ },
items: [
{
- xtype: 'proxmoxKVComboBox',
- fieldLabel: gettext('Timespan to match'),
+ xtype: 'hidden',
+ reference: 'timeframe',
isFormField: false,
allowBlank: false,
- editable: true,
- displayField: 'key',
- field: 'value',
bind: {
value: '{matchCalendarValue}',
- disabled: '{!typeIsMatchCalender}',
},
-
- comboItems: [
- ['mon 8-12', ''],
- ['tue..fri,sun 0:00-23:59', ''],
+ },
+ {
+ xtype: 'timefield',
+ reference: 'timeStart',
+ fieldLabel: gettext('Time Start'),
+ isFormField: false,
+ format: 'H:i',
+ formatText: 'HH:MM',
+ maskRe: /[\d:]/,
+ stripCharsRe: /[^\d:]+/g,
+ allowBlank: false,
+ },
+ {
+ xtype: 'timefield',
+ reference: 'timeEnd',
+ fieldLabel: gettext('Time End'),
+ isFormField: false,
+ format: 'H:i',
+ formatText: 'HH:MM',
+ maxValue: '23:59',
+ maskRe: /[\d:]/,
+ stripCharsRe: /[^\d:]+/g,
+ allowBlank: false,
+ },
+ {
+ xtype: 'fieldcontainer',
+ items: [
+ {
+ xtype: 'grid',
+ margin: '10 0 0 0',
+ reference: 'weekdayGrid',
+ store: {
+ fields: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
+ data: [
+ {
+ mon: true,
+ tue: true,
+ wed: true,
+ thu: true,
+ fri: true,
+ sat: true,
+ sun: true,
+ },
+ ],
+ },
+ enableColumnMove: false,
+ columns: {
+ defaults: {
+ menuDisabled: true,
+ resizable: false,
+ },
+ items: [
+ {
+ text: gettext('Mon'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'mon',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ {
+ text: gettext('Tue'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'tue',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ {
+ text: gettext('Wed'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'wed',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ {
+ text: gettext('Thu'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'thu',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ {
+ text: gettext('Fri'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'fri',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ {
+ text: gettext('Sat'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'sat',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ {
+ text: gettext('Sun'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'sun',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ ],
+ },
+ },
],
},
],
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox-widget-toolkit v2 21/34] notifications: matcher: consistently use title case for UI elements
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (19 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox-widget-toolkit v2 20/34] notifications: matcher: add better calendar editor Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-backup v2 22/34] notification: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
` (12 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
src/Utils.js | 2 +-
src/window/NotificationMatcherEdit.js | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/Utils.js b/src/Utils.js
index a2c4269..ca40d8c 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -728,7 +728,7 @@ Ext.define('Proxmox.Utils', {
// Only add product-agnostic fields here!
notificationFieldName: {
- type: gettext('Notification type'),
+ type: gettext('Notification Type'),
hostname: gettext('Hostname'),
},
diff --git a/src/window/NotificationMatcherEdit.js b/src/window/NotificationMatcherEdit.js
index 7c3decf..3beb5fb 100644
--- a/src/window/NotificationMatcherEdit.js
+++ b/src/window/NotificationMatcherEdit.js
@@ -128,7 +128,7 @@ Ext.define('Proxmox.window.NotificationMatcherEdit', {
},
{
name: me.name,
- title: gettext('Targets to notify'),
+ title: gettext('Targets to Notify'),
xtype: 'pmxNotificationMatcherTargetPanel',
isCreate: me.isCreate,
baseUrl: me.baseUrl,
@@ -1381,7 +1381,7 @@ Ext.define('Proxmox.panel.MatchSeveritySettings', {
items: [
{
xtype: 'proxmoxKVComboBox',
- fieldLabel: gettext('Severities to match'),
+ fieldLabel: gettext('Severities to Match'),
isFormField: false,
allowBlank: true,
multiSelect: true,
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox-backup v2 22/34] notification: opt into 'legacy-matchers' feature in proxmox-notify
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (20 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox-widget-toolkit v2 21/34] notifications: matcher: consistently use title case for UI elements Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-backup v2 23/34] api: notification: add 'migrate-to-expression' parameter to get_matcher Lukas Wagner
` (11 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
This allows us to continue to support the older 'match-*', 'mode' and
'invert-match' configuration keys in notifications.cfg.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
Cargo.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Cargo.toml b/Cargo.toml
index 9443f9163..d79f29f3a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -235,7 +235,7 @@ proxmox-ldap.workspace = true
proxmox-metrics.workspace = true
proxmox-network-api = { workspace = true, features = [ "impl" ] }
proxmox-network-types.workspace = true
-proxmox-notify = { workspace = true, features = [ "pbs-context" ] }
+proxmox-notify = { workspace = true, features = [ "pbs-context", "legacy-matchers" ] }
proxmox-openid.workspace = true
proxmox-product-config.workspace = true
proxmox-parallel-handler.workspace = true
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox-backup v2 23/34] api: notification: add 'migrate-to-expression' parameter to get_matcher
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (21 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox-backup v2 22/34] notification: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-backup v2 24/34] ui: notification: enable new matcher UI Lukas Wagner
` (10 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
If set, we return the matcher with any occurrences of the 'old' match-*,
invert-match and mode keys to an equivalent expression.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
src/api2/config/notifications/matchers.rs | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/src/api2/config/notifications/matchers.rs b/src/api2/config/notifications/matchers.rs
index fba1859cf..504d26d40 100644
--- a/src/api2/config/notifications/matchers.rs
+++ b/src/api2/config/notifications/matchers.rs
@@ -40,6 +40,11 @@ pub fn list_matchers(
properties: {
name: {
schema: ENTITY_NAME_SCHEMA,
+ },
+ "migrate-to-expression": {
+ description: "Automatically translate existing 'match-*', 'invert-match' and 'mode' to an equivalent matching 'expression'",
+ optional: true,
+ default: false,
}
},
},
@@ -49,9 +54,18 @@ pub fn list_matchers(
},
)]
/// Get a notification matcher.
-pub fn get_matcher(name: String, rpcenv: &mut dyn RpcEnvironment) -> Result<MatcherConfig, Error> {
+pub fn get_matcher(
+ name: String,
+ migrate_to_expression: bool,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<MatcherConfig, Error> {
let config = pbs_config::notifications::config()?;
- let matcher = proxmox_notify::api::matcher::get_matcher(&config, &name)?;
+
+ let matcher = if migrate_to_expression {
+ proxmox_notify::api::matcher::get_matcher_as_expression(&config, &name)?
+ } else {
+ proxmox_notify::api::matcher::get_matcher(&config, &name)?
+ };
rpcenv["digest"] = hex::encode(config.digest()).into();
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox-backup v2 24/34] ui: notification: enable new matcher UI
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (22 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox-backup v2 23/34] api: notification: add 'migrate-to-expression' parameter to get_matcher Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-perl-rs v2 25/34] notify: move proxmox-notify bindings from common to pve-rs Lukas Wagner
` (9 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
By setting this feature flag, we enable the new improved match rule edit
window. The feature flag allows us to bump the widget toolkit
independently of the rest rest.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
www/Utils.js | 3 +++
1 file changed, 3 insertions(+)
diff --git a/www/Utils.js b/www/Utils.js
index d6bfd459e..6d027e377 100644
--- a/www/Utils.js
+++ b/www/Utils.js
@@ -533,6 +533,9 @@ Ext.define('PBS.Utils', {
iconCls: 'fa-bell-o',
},
};
+
+ // Opt into the new matcher expressions
+ Proxmox.Schema.notificationMatcherExpressions = true;
},
// Convert an ArrayBuffer to a base64url encoded string.
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox-perl-rs v2 25/34] notify: move proxmox-notify bindings from common to pve-rs
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (23 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox-backup v2 24/34] ui: notification: enable new matcher UI Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-perl-rs v2 26/34] notify: matcher: pass matcher config / updater directly Lukas Wagner
` (8 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
PMG neither uses the notification stack, nor is it planned in the near
future. Shipping the bindings in 'common' is awkward, since it then
requires us to use the same feature flags in proxmox-notify for both,
PVE and PMG.
This reduces the number of dependencies for pmg-rs from 297 to 269, all
of the removed ones were unused before.
The module prefix changes from Proxmox::RS::Notify to PVE::RS::Notify,
which is a breaking change that requires changes in the callers.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
common/pkg/Makefile | 1 -
common/src/bindings/mod.rs | 3 ---
pmg-rs/Cargo.toml | 1 -
pve-rs/Makefile | 1 +
pve-rs/src/bindings/mod.rs | 3 +++
{common => pve-rs}/src/bindings/notify.rs | 8 ++++----
6 files changed, 8 insertions(+), 9 deletions(-)
rename {common => pve-rs}/src/bindings/notify.rs (99%)
diff --git a/common/pkg/Makefile b/common/pkg/Makefile
index 5a537f9..1fce22f 100644
--- a/common/pkg/Makefile
+++ b/common/pkg/Makefile
@@ -23,7 +23,6 @@ PERLMOD_GENPACKAGE := /usr/lib/perlmod/genpackage.pl \
PERLMOD_PACKAGES := \
Proxmox::RS::APT::Repositories \
Proxmox::RS::CalendarEvent \
- Proxmox::RS::Notify \
Proxmox::RS::OIDC \
Proxmox::RS::SharedCache \
Proxmox::RS::Subscription
diff --git a/common/src/bindings/mod.rs b/common/src/bindings/mod.rs
index e0d1a3b..f122b9a 100644
--- a/common/src/bindings/mod.rs
+++ b/common/src/bindings/mod.rs
@@ -8,9 +8,6 @@ pub use apt_repositories::proxmox_rs_apt_repositories;
mod calendar_event;
pub use calendar_event::proxmox_rs_calendar_event;
-mod notify;
-pub use notify::proxmox_rs_notify;
-
mod oidc;
pub use oidc::proxmox_rs_oidc;
diff --git a/pmg-rs/Cargo.toml b/pmg-rs/Cargo.toml
index 23be254..f88a204 100644
--- a/pmg-rs/Cargo.toml
+++ b/pmg-rs/Cargo.toml
@@ -35,7 +35,6 @@ proxmox-config-digest = "1"
proxmox-http = { version = "1", features = ["client-sync", "client-trait"] }
proxmox-http-error = "1"
proxmox-log = "1"
-proxmox-notify = "1"
proxmox-openid = "1"
proxmox-shared-cache = "1"
proxmox-subscription = "1.0.2"
diff --git a/pve-rs/Makefile b/pve-rs/Makefile
index bb1cd2d..4c183c8 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -27,6 +27,7 @@ PERLMOD_GENPACKAGE := /usr/lib/perlmod/genpackage.pl \
PERLMOD_PACKAGES := \
PVE::RS::Firewall::SDN \
+ PVE::RS::Notify \
PVE::RS::NVML \
PVE::RS::OCI \
PVE::RS::OpenId \
diff --git a/pve-rs/src/bindings/mod.rs b/pve-rs/src/bindings/mod.rs
index f922982..143a62b 100644
--- a/pve-rs/src/bindings/mod.rs
+++ b/pve-rs/src/bindings/mod.rs
@@ -19,6 +19,9 @@ pub mod firewall;
mod sdn;
pub use sdn::fabrics::pve_rs_sdn_fabrics;
+mod notify;
+pub use notify::pve_rs_notify;
+
#[allow(unused_imports)]
pub use crate::common::bindings::*;
diff --git a/common/src/bindings/notify.rs b/pve-rs/src/bindings/notify.rs
similarity index 99%
rename from common/src/bindings/notify.rs
rename to pve-rs/src/bindings/notify.rs
index 409270a..ba713e3 100644
--- a/common/src/bindings/notify.rs
+++ b/pve-rs/src/bindings/notify.rs
@@ -1,6 +1,6 @@
-#[perlmod::package(name = "Proxmox::RS::Notify")]
-pub mod proxmox_rs_notify {
- //! The `Proxmox::RS::Notify` package.
+#[perlmod::package(name = "PVE::RS::Notify")]
+pub mod pve_rs_notify {
+ //! The `PVE::RS::Notify` package.
//!
//! This implements the new notification API and support code.
//!
@@ -45,7 +45,7 @@ pub mod proxmox_rs_notify {
config: Mutex<Config>,
}
- perlmod::declare_magic!(Box<NotificationConfig> : &NotificationConfig as "Proxmox::RS::Notify");
+ perlmod::declare_magic!(Box<NotificationConfig> : &NotificationConfig as "PVE::RS::Notify");
/// Method: Support `dclone` so this can be put into the `ccache` of `PVE::Cluster`.
#[export(name = "STORABLE_freeze", raw_return)]
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox-perl-rs v2 26/34] notify: matcher: pass matcher config / updater directly
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (24 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox-perl-rs v2 25/34] notify: move proxmox-notify bindings from common to pve-rs Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-perl-rs v2 27/34] notify: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
` (7 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
Instead of enumerating all config properties individually as paramters,
we now pass the config struct or updater as a parameter. This allows us
to simplify the callers and also we don't have to change the binding
code if new parameters are added -- a simple rebuild against
proxmox-notify suffices.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
pve-rs/src/bindings/notify.rs | 200 +++++-----------------------------
1 file changed, 26 insertions(+), 174 deletions(-)
diff --git a/pve-rs/src/bindings/notify.rs b/pve-rs/src/bindings/notify.rs
index ba713e3..820b8c0 100644
--- a/pve-rs/src/bindings/notify.rs
+++ b/pve-rs/src/bindings/notify.rs
@@ -12,7 +12,7 @@ pub mod pve_rs_notify {
use std::collections::HashMap;
use std::sync::Mutex;
- use anyhow::{Error, bail};
+ use anyhow::{bail, Error};
use serde_json::Value as JSONValue;
use perlmod::Value;
@@ -26,17 +26,14 @@ pub mod pve_rs_notify {
DeleteableSendmailProperty, SendmailConfig, SendmailConfigUpdater,
};
use proxmox_notify::endpoints::smtp::{
- DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpMode, SmtpPrivateConfig,
+ DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig,
SmtpPrivateConfigUpdater,
};
use proxmox_notify::endpoints::webhook::{
DeleteableWebhookProperty, WebhookConfig, WebhookConfigUpdater,
};
- use proxmox_notify::matcher::{
- CalendarMatcher, DeleteableMatcherProperty, FieldMatcher, MatchModeOperator, MatcherConfig,
- MatcherConfigUpdater, SeverityMatcher,
- };
- use proxmox_notify::{Config, Notification, Severity, api};
+ use proxmox_notify::matcher::{DeleteableMatcherProperty, MatcherConfig, MatcherConfigUpdater};
+ use proxmox_notify::{api, Config, Notification, Severity};
/// A notification catalog instance.
///
@@ -189,49 +186,23 @@ pub mod pve_rs_notify {
///
/// See [`api::sendmail::add_endpoint`].
#[export(serialize_error)]
- #[allow(clippy::too_many_arguments)]
pub fn add_sendmail_endpoint(
#[try_from_ref] this: &NotificationConfig,
- name: String,
- mailto: Option<Vec<String>>,
- mailto_user: Option<Vec<String>>,
- from_address: Option<String>,
- author: Option<String>,
- comment: Option<String>,
- disable: Option<bool>,
+ sendmail_config: SendmailConfig,
) -> Result<(), HttpError> {
let mut config = this.config.lock().unwrap();
- api::sendmail::add_endpoint(
- &mut config,
- SendmailConfig {
- name,
- mailto: mailto.unwrap_or_default(),
- mailto_user: mailto_user.unwrap_or_default(),
- from_address,
- author,
- comment,
- disable,
- filter: None,
- origin: None,
- },
- )
+ api::sendmail::add_endpoint(&mut config, sendmail_config)
}
/// Method: Update a sendmail endpoint.
///
/// See [`api::sendmail::update_endpoint`].
#[export(serialize_error)]
- #[allow(clippy::too_many_arguments)]
pub fn update_sendmail_endpoint(
#[try_from_ref] this: &NotificationConfig,
name: &str,
- mailto: Option<Vec<String>>,
- mailto_user: Option<Vec<String>>,
- from_address: Option<String>,
- author: Option<String>,
- comment: Option<String>,
- disable: Option<bool>,
+ updater: SendmailConfigUpdater,
delete: Option<Vec<DeleteableSendmailProperty>>,
digest: Option<&str>,
) -> Result<(), HttpError> {
@@ -241,14 +212,7 @@ pub mod pve_rs_notify {
api::sendmail::update_endpoint(
&mut config,
name,
- SendmailConfigUpdater {
- mailto,
- mailto_user,
- from_address,
- author,
- comment,
- disable,
- },
+ updater,
delete.as_deref(),
digest.as_deref(),
)
@@ -295,39 +259,22 @@ pub mod pve_rs_notify {
#[export(serialize_error)]
pub fn add_gotify_endpoint(
#[try_from_ref] this: &NotificationConfig,
- name: String,
- server: String,
- token: String,
- comment: Option<String>,
- disable: Option<bool>,
+ gotify_config: GotifyConfig,
+ gotify_private_config: GotifyPrivateConfig,
) -> Result<(), HttpError> {
let mut config = this.config.lock().unwrap();
- api::gotify::add_endpoint(
- &mut config,
- GotifyConfig {
- name: name.clone(),
- server,
- comment,
- disable,
- filter: None,
- origin: None,
- },
- GotifyPrivateConfig { name, token },
- )
+ api::gotify::add_endpoint(&mut config, gotify_config, gotify_private_config)
}
/// Method: Update a 'gotify' endpoint.
///
/// See [`api::gotify::update_endpoint`].
#[export(serialize_error)]
- #[allow(clippy::too_many_arguments)]
pub fn update_gotify_endpoint(
#[try_from_ref] this: &NotificationConfig,
name: &str,
- server: Option<String>,
- token: Option<String>,
- comment: Option<String>,
- disable: Option<bool>,
+ updater: GotifyConfigUpdater,
+ private_updater: GotifyPrivateConfigUpdater,
delete: Option<Vec<DeleteableGotifyProperty>>,
digest: Option<&str>,
) -> Result<(), HttpError> {
@@ -337,12 +284,8 @@ pub mod pve_rs_notify {
api::gotify::update_endpoint(
&mut config,
name,
- GotifyConfigUpdater {
- server,
- comment,
- disable,
- },
- GotifyPrivateConfigUpdater { token },
+ updater,
+ private_updater,
delete.as_deref(),
digest.as_deref(),
)
@@ -387,62 +330,24 @@ pub mod pve_rs_notify {
///
/// See [`api::smtp::add_endpoint`].
#[export(serialize_error)]
- #[allow(clippy::too_many_arguments)]
pub fn add_smtp_endpoint(
#[try_from_ref] this: &NotificationConfig,
- name: String,
- server: String,
- port: Option<u16>,
- mode: Option<SmtpMode>,
- username: Option<String>,
- password: Option<String>,
- mailto: Option<Vec<String>>,
- mailto_user: Option<Vec<String>>,
- from_address: String,
- author: Option<String>,
- comment: Option<String>,
- disable: Option<bool>,
+ smtp_config: SmtpConfig,
+ smtp_private_config: SmtpPrivateConfig,
) -> Result<(), HttpError> {
let mut config = this.config.lock().unwrap();
- api::smtp::add_endpoint(
- &mut config,
- SmtpConfig {
- name: name.clone(),
- server,
- port,
- mode,
- username,
- mailto: mailto.unwrap_or_default(),
- mailto_user: mailto_user.unwrap_or_default(),
- from_address,
- author,
- comment,
- disable,
- origin: None,
- },
- SmtpPrivateConfig { name, password },
- )
+ api::smtp::add_endpoint(&mut config, smtp_config, smtp_private_config)
}
/// Method: Update an SMTP endpoint.
///
/// See [`api::smtp::update_endpoint`].
#[export(serialize_error)]
- #[allow(clippy::too_many_arguments)]
pub fn update_smtp_endpoint(
#[try_from_ref] this: &NotificationConfig,
name: &str,
- server: Option<String>,
- port: Option<u16>,
- mode: Option<SmtpMode>,
- username: Option<String>,
- password: Option<String>,
- mailto: Option<Vec<String>>,
- mailto_user: Option<Vec<String>>,
- from_address: Option<String>,
- author: Option<String>,
- comment: Option<String>,
- disable: Option<bool>,
+ updater: SmtpConfigUpdater,
+ private_updater: SmtpPrivateConfigUpdater,
delete: Option<Vec<DeleteableSmtpProperty>>,
digest: Option<&str>,
) -> Result<(), HttpError> {
@@ -452,19 +357,8 @@ pub mod pve_rs_notify {
api::smtp::update_endpoint(
&mut config,
name,
- SmtpConfigUpdater {
- server,
- port,
- mode,
- username,
- mailto,
- mailto_user,
- from_address,
- author,
- comment,
- disable,
- },
- SmtpPrivateConfigUpdater { password },
+ updater,
+ private_updater,
delete.as_deref(),
digest.as_deref(),
)
@@ -509,7 +403,6 @@ pub mod pve_rs_notify {
///
/// See [`api::webhook::add_endpoint`].
#[export(serialize_error)]
- #[allow(clippy::too_many_arguments)]
pub fn add_webhook_endpoint(
#[try_from_ref] this: &NotificationConfig,
endpoint_config: WebhookConfig,
@@ -522,7 +415,6 @@ pub mod pve_rs_notify {
///
/// See [`api::webhook::update_endpoint`].
#[export(serialize_error)]
- #[allow(clippy::too_many_arguments)]
pub fn update_webhook_endpoint(
#[try_from_ref] this: &NotificationConfig,
name: &str,
@@ -581,53 +473,22 @@ pub mod pve_rs_notify {
///
/// See [`api::matcher::add_matcher`].
#[export(serialize_error)]
- #[allow(clippy::too_many_arguments)]
pub fn add_matcher(
#[try_from_ref] this: &NotificationConfig,
- name: String,
- target: Option<Vec<String>>,
- match_severity: Option<Vec<SeverityMatcher>>,
- match_field: Option<Vec<FieldMatcher>>,
- match_calendar: Option<Vec<CalendarMatcher>>,
- mode: Option<MatchModeOperator>,
- invert_match: Option<bool>,
- comment: Option<String>,
- disable: Option<bool>,
+ matcher_config: MatcherConfig,
) -> Result<(), HttpError> {
let mut config = this.config.lock().unwrap();
- api::matcher::add_matcher(
- &mut config,
- MatcherConfig {
- name,
- match_severity: match_severity.unwrap_or_default(),
- match_field: match_field.unwrap_or_default(),
- match_calendar: match_calendar.unwrap_or_default(),
- target: target.unwrap_or_default(),
- mode,
- invert_match,
- comment,
- disable,
- origin: None,
- },
- )
+ api::matcher::add_matcher(&mut config, matcher_config)
}
/// Method: Update a matcher.
///
/// See [`api::matcher::update_matcher`].
#[export(serialize_error)]
- #[allow(clippy::too_many_arguments)]
pub fn update_matcher(
#[try_from_ref] this: &NotificationConfig,
name: &str,
- target: Option<Vec<String>>,
- match_severity: Option<Vec<SeverityMatcher>>,
- match_field: Option<Vec<FieldMatcher>>,
- match_calendar: Option<Vec<CalendarMatcher>>,
- mode: Option<MatchModeOperator>,
- invert_match: Option<bool>,
- comment: Option<String>,
- disable: Option<bool>,
+ updater: MatcherConfigUpdater,
delete: Option<Vec<DeleteableMatcherProperty>>,
digest: Option<&str>,
) -> Result<(), HttpError> {
@@ -637,16 +498,7 @@ pub mod pve_rs_notify {
api::matcher::update_matcher(
&mut config,
name,
- MatcherConfigUpdater {
- match_severity,
- match_field,
- match_calendar,
- target,
- mode,
- invert_match,
- comment,
- disable,
- },
+ updater,
delete.as_deref(),
digest.as_deref(),
)
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox-perl-rs v2 27/34] notify: opt into 'legacy-matchers' feature in proxmox-notify
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (25 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox-perl-rs v2 26/34] notify: matcher: pass matcher config / updater directly Lukas Wagner
@ 2026-10-01 8:36 ` Lukas Wagner
2026-10-01 8:37 ` [PATCH proxmox-perl-rs v2 28/34] notify: add 'migrate_to_expression' parameter for get_matcher Lukas Wagner
` (6 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:36 UTC (permalink / raw)
To: pbs-devel, pve-devel
This allows us to continue to support the older 'match-*', 'mode' and
'invert-match' configuration keys in notifications.cfg.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
pve-rs/Cargo.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml
index 5ae9082..ab95f29 100644
--- a/pve-rs/Cargo.toml
+++ b/pve-rs/Cargo.toml
@@ -39,7 +39,7 @@ proxmox-http = { version = "1.0.2", features = ["client-sync", "client-trait"] }
proxmox-http-error = "1"
proxmox-log = "1"
proxmox-network-types = "1.1.2"
-proxmox-notify = { version = "1", features = ["pve-context"] }
+proxmox-notify = { version = "1", features = ["pve-context", "legacy-matchers"] }
proxmox-oci = "0.2.1"
proxmox-openid = "1.0.2"
proxmox-resource-scheduling = "2"
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH proxmox-perl-rs v2 28/34] notify: add 'migrate_to_expression' parameter for get_matcher
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (26 preceding siblings ...)
2026-10-01 8:36 ` [PATCH proxmox-perl-rs v2 27/34] notify: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
@ 2026-10-01 8:37 ` Lukas Wagner
2026-10-01 8:37 ` [PATCH pve-cluster v2 29/34] notify: adapt to changed module prefix for PVE::RS::Notify Lukas Wagner
` (5 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:37 UTC (permalink / raw)
To: pbs-devel, pve-devel
If set, we return the matcher with any occurrences of the 'old' match-*,
invert-match and mode keys to an equivalent expression.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
pve-rs/src/bindings/notify.rs | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/pve-rs/src/bindings/notify.rs b/pve-rs/src/bindings/notify.rs
index 820b8c0..da3c056 100644
--- a/pve-rs/src/bindings/notify.rs
+++ b/pve-rs/src/bindings/notify.rs
@@ -12,7 +12,7 @@ pub mod pve_rs_notify {
use std::collections::HashMap;
use std::sync::Mutex;
- use anyhow::{bail, Error};
+ use anyhow::{Error, bail};
use serde_json::Value as JSONValue;
use perlmod::Value;
@@ -33,7 +33,7 @@ pub mod pve_rs_notify {
DeleteableWebhookProperty, WebhookConfig, WebhookConfigUpdater,
};
use proxmox_notify::matcher::{DeleteableMatcherProperty, MatcherConfig, MatcherConfigUpdater};
- use proxmox_notify::{api, Config, Notification, Severity};
+ use proxmox_notify::{Config, Notification, Severity, api};
/// A notification catalog instance.
///
@@ -464,9 +464,15 @@ pub mod pve_rs_notify {
pub fn get_matcher(
#[try_from_ref] this: &NotificationConfig,
id: &str,
+ migrate_to_expression: bool,
) -> Result<MatcherConfig, HttpError> {
let config = this.config.lock().unwrap();
- api::matcher::get_matcher(&config, id)
+
+ if migrate_to_expression {
+ api::matcher::get_matcher_as_expression(&config, id)
+ } else {
+ api::matcher::get_matcher(&config, id)
+ }
}
/// Method: Add a matcher.
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH pve-cluster v2 29/34] notify: adapt to changed module prefix for PVE::RS::Notify
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (27 preceding siblings ...)
2026-10-01 8:37 ` [PATCH proxmox-perl-rs v2 28/34] notify: add 'migrate_to_expression' parameter for get_matcher Lukas Wagner
@ 2026-10-01 8:37 ` Lukas Wagner
2026-10-01 8:37 ` [PATCH manager v2 30/34] vzdump: " Lukas Wagner
` (4 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:37 UTC (permalink / raw)
To: pbs-devel, pve-devel
The binding code was moved from the 'common' module (which is shared
with PMG) to pve-rs itself, which caused the module prefix to change.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
src/PVE/Notify.pm | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/PVE/Notify.pm b/src/PVE/Notify.pm
index b44b5b3..89be6bc 100644
--- a/src/PVE/Notify.pm
+++ b/src/PVE/Notify.pm
@@ -7,7 +7,7 @@ use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_lock_file cfs_write_file
use PVE::INotify;
use PVE::Tools;
-use Proxmox::RS::Notify;
+use PVE::RS::Notify;
cfs_register_file(
'notifications.cfg', \&parse_notification_config, \&write_notification_config,
@@ -49,7 +49,7 @@ sub read_config {
my $config = cfs_read_file('notifications.cfg');
my $priv_config = cfs_read_file('priv/notifications.cfg');
- my $notification_config = Proxmox::RS::Notify->parse_config($config, $priv_config);
+ my $notification_config = PVE::RS::Notify->parse_config($config, $priv_config);
return $notification_config;
}
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH manager v2 30/34] vzdump: adapt to changed module prefix for PVE::RS::Notify
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (28 preceding siblings ...)
2026-10-01 8:37 ` [PATCH pve-cluster v2 29/34] notify: adapt to changed module prefix for PVE::RS::Notify Lukas Wagner
@ 2026-10-01 8:37 ` Lukas Wagner
2026-10-01 8:37 ` [PATCH manager v2 31/34] api: notification: pass config/updater directly to rust bindings Lukas Wagner
` (3 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:37 UTC (permalink / raw)
To: pbs-devel, pve-devel
The binding code was moved from the 'common' module (which is shared
with PMG) to pve-rs itself, which caused the module prefix to change.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
PVE/VZDump.pm | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm
index defe9e0a..11c5089f 100644
--- a/PVE/VZDump.pm
+++ b/PVE/VZDump.pm
@@ -545,7 +545,7 @@ sub send_notification {
if ($email_configured && ($policy eq "always" || ($policy eq "failure" && $failed))) {
# Start out with an empty config. Might still contain
# built-ins, so we need to disable/remove them.
- my $notification_config = Proxmox::RS::Notify->parse_config('', '');
+ my $notification_config = PVE::RS::Notify->parse_config('', '');
# Remove built-in matchers, since we only want to send an
# email to the specified recipients and nobody else.
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH manager v2 31/34] api: notification: pass config/updater directly to rust bindings
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (29 preceding siblings ...)
2026-10-01 8:37 ` [PATCH manager v2 30/34] vzdump: " Lukas Wagner
@ 2026-10-01 8:37 ` Lukas Wagner
2026-10-01 8:37 ` [PATCH manager v2 32/34] api: notification: get_matcher: add 'migrate-to-expression' parameter Lukas Wagner
` (2 subsequent siblings)
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:37 UTC (permalink / raw)
To: pbs-devel, pve-devel
Instead of having to enumerate function parameters individually, pass
the entire parameter hash to the rust bindings. This reduces potential
for future errors and reduces churn when adding new parameters.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
PVE/API2/Cluster/Notifications.pm | 147 +++++-------------------------
PVE/VZDump.pm | 19 ++--
2 files changed, 33 insertions(+), 133 deletions(-)
diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 8b455227..3f99de08 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -518,21 +518,11 @@ __PACKAGE__->register_method({
code => sub {
my ($param) = @_;
- my $name = extract_param($param, 'name');
- my $mailto = extract_param($param, 'mailto');
- my $mailto_user = extract_param($param, 'mailto-user');
- my $from_address = extract_param($param, 'from-address');
- my $author = extract_param($param, 'author');
- my $comment = extract_param($param, 'comment');
- my $disable = extract_param($param, 'disable');
-
eval {
PVE::Notify::lock_config(sub {
my $config = PVE::Notify::read_config();
- $config->add_sendmail_endpoint(
- $name, $mailto, $mailto_user, $from_address, $author, $comment, $disable,
- );
+ $config->add_sendmail_endpoint($param);
PVE::Notify::write_config($config);
});
@@ -582,13 +572,6 @@ __PACKAGE__->register_method({
my ($param) = @_;
my $name = extract_param($param, 'name');
- my $mailto = extract_param($param, 'mailto');
- my $mailto_user = extract_param($param, 'mailto-user');
- my $from_address = extract_param($param, 'from-address');
- my $author = extract_param($param, 'author');
- my $comment = extract_param($param, 'comment');
- my $disable = extract_param($param, 'disable');
-
my $delete = extract_param($param, 'delete');
my $digest = extract_param($param, 'digest');
@@ -597,15 +580,7 @@ __PACKAGE__->register_method({
my $config = PVE::Notify::read_config();
$config->update_sendmail_endpoint(
- $name,
- $mailto,
- $mailto_user,
- $from_address,
- $author,
- $comment,
- $disable,
- $delete,
- $digest,
+ $name, $param, $delete, $digest,
);
PVE::Notify::write_config($config);
@@ -789,18 +764,19 @@ __PACKAGE__->register_method({
code => sub {
my ($param) = @_;
- my $name = extract_param($param, 'name');
- my $server = extract_param($param, 'server');
my $token = extract_param($param, 'token');
- my $comment = extract_param($param, 'comment');
- my $disable = extract_param($param, 'disable');
+ my $name = $param->{name};
eval {
PVE::Notify::lock_config(sub {
my $config = PVE::Notify::read_config();
$config->add_gotify_endpoint(
- $name, $server, $token, $comment, $disable,
+ $param,
+ {
+ 'name' => $name,
+ 'token' => $token,
+ },
);
PVE::Notify::write_config($config);
@@ -850,10 +826,7 @@ __PACKAGE__->register_method({
my ($param) = @_;
my $name = extract_param($param, 'name');
- my $server = extract_param($param, 'server');
my $token = extract_param($param, 'token');
- my $comment = extract_param($param, 'comment');
- my $disable = extract_param($param, 'disable');
my $delete = extract_param($param, 'delete');
my $digest = extract_param($param, 'digest');
@@ -864,10 +837,10 @@ __PACKAGE__->register_method({
$config->update_gotify_endpoint(
$name,
- $server,
- $token,
- $comment,
- $disable,
+ $param,
+ {
+ token => $token,
+ },
$delete,
$digest,
);
@@ -1102,36 +1075,19 @@ __PACKAGE__->register_method({
code => sub {
my ($param) = @_;
- my $name = extract_param($param, 'name');
- my $server = extract_param($param, 'server');
- my $port = extract_param($param, 'port');
- my $mode = extract_param($param, 'mode');
- my $username = extract_param($param, 'username');
+ my $name = $param->{name};
my $password = extract_param($param, 'password');
- my $mailto = extract_param($param, 'mailto');
- my $mailto_user = extract_param($param, 'mailto-user');
- my $from_address = extract_param($param, 'from-address');
- my $author = extract_param($param, 'author');
- my $comment = extract_param($param, 'comment');
- my $disable = extract_param($param, 'disable');
eval {
PVE::Notify::lock_config(sub {
my $config = PVE::Notify::read_config();
$config->add_smtp_endpoint(
- $name,
- $server,
- $port,
- $mode,
- $username,
- $password,
- $mailto,
- $mailto_user,
- $from_address,
- $author,
- $comment,
- $disable,
+ $param,
+ {
+ name => $name,
+ password => $password,
+ },
);
PVE::Notify::write_config($config);
@@ -1182,17 +1138,7 @@ __PACKAGE__->register_method({
my ($param) = @_;
my $name = extract_param($param, 'name');
- my $server = extract_param($param, 'server');
- my $port = extract_param($param, 'port');
- my $mode = extract_param($param, 'mode');
- my $username = extract_param($param, 'username');
my $password = extract_param($param, 'password');
- my $mailto = extract_param($param, 'mailto');
- my $mailto_user = extract_param($param, 'mailto-user');
- my $from_address = extract_param($param, 'from-address');
- my $author = extract_param($param, 'author');
- my $comment = extract_param($param, 'comment');
- my $disable = extract_param($param, 'disable');
my $delete = extract_param($param, 'delete');
my $digest = extract_param($param, 'digest');
@@ -1203,17 +1149,10 @@ __PACKAGE__->register_method({
$config->update_smtp_endpoint(
$name,
- $server,
- $port,
- $mode,
- $username,
- $password,
- $mailto,
- $mailto_user,
- $from_address,
- $author,
- $comment,
- $disable,
+ $param,
+ {
+ password => $password,
+ },
$delete,
$digest,
);
@@ -1705,31 +1644,11 @@ __PACKAGE__->register_method({
code => sub {
my ($param) = @_;
- my $name = extract_param($param, 'name');
- my $match_severity = extract_param($param, 'match-severity');
- my $match_field = extract_param($param, 'match-field');
- my $match_calendar = extract_param($param, 'match-calendar');
- my $target = extract_param($param, 'target');
- my $mode = extract_param($param, 'mode');
- my $invert_match = extract_param($param, 'invert-match');
- my $comment = extract_param($param, 'comment');
- my $disable = extract_param($param, 'disable');
-
eval {
PVE::Notify::lock_config(sub {
my $config = PVE::Notify::read_config();
- $config->add_matcher(
- $name,
- $target,
- $match_severity,
- $match_field,
- $match_calendar,
- $mode,
- $invert_match,
- $comment,
- $disable,
- );
+ $config->add_matcher($param);
PVE::Notify::write_config($config);
});
@@ -1770,14 +1689,6 @@ __PACKAGE__->register_method({
my ($param) = @_;
my $name = extract_param($param, 'name');
- my $match_severity = extract_param($param, 'match-severity');
- my $match_field = extract_param($param, 'match-field');
- my $match_calendar = extract_param($param, 'match-calendar');
- my $target = extract_param($param, 'target');
- my $mode = extract_param($param, 'mode');
- my $invert_match = extract_param($param, 'invert-match');
- my $comment = extract_param($param, 'comment');
- my $disable = extract_param($param, 'disable');
my $digest = extract_param($param, 'digest');
my $delete = extract_param($param, 'delete');
@@ -1786,17 +1697,7 @@ __PACKAGE__->register_method({
my $config = PVE::Notify::read_config();
$config->update_matcher(
- $name,
- $target,
- $match_severity,
- $match_field,
- $match_calendar,
- $mode,
- $invert_match,
- $comment,
- $disable,
- $delete,
- $digest,
+ $name, $param, $delete, $digest,
);
PVE::Notify::write_config($config);
diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm
index 11c5089f..01c8064d 100644
--- a/PVE/VZDump.pm
+++ b/PVE/VZDump.pm
@@ -557,21 +557,20 @@ sub send_notification {
# verified once the config is serialized. That means that
# we can rely on that fact that no other endpoint with this name exists.
my $endpoint_name = "<" . join(",", @$mailto) . ">";
- $notification_config->add_sendmail_endpoint(
- $endpoint_name,
- $mailto,
- undef,
- undef,
- "vzdump backup tool",
- );
+ $notification_config->add_sendmail_endpoint({
+ name => $endpoint_name,
+ mailto => $mailto,
+ author => "vzdump backup tool",
+ });
my $endpoints = [$endpoint_name];
# Add a matcher that matches all notifications, set our
# newly created target as a target.
- $notification_config->add_matcher(
- "<matcher-$endpoint_name>", $endpoints,
- );
+ $notification_config->add_matcher({
+ name => "<matcher-$endpoint_name>",
+ target => $endpoints,
+ });
PVE::Notify::notify(
$severity, "vzdump", $template_data, $fields, $notification_config,
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH manager v2 32/34] api: notification: get_matcher: add 'migrate-to-expression' parameter
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (30 preceding siblings ...)
2026-10-01 8:37 ` [PATCH manager v2 31/34] api: notification: pass config/updater directly to rust bindings Lukas Wagner
@ 2026-10-01 8:37 ` Lukas Wagner
2026-10-01 8:37 ` [PATCH manager v2 33/34] api: notification: add 'expression' to matcher parameter schema Lukas Wagner
2026-10-01 8:37 ` [PATCH manager v2 34/34] ui: notification: enable new matcher UI Lukas Wagner
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:37 UTC (permalink / raw)
To: pbs-devel, pve-devel
If set, any existing matchers will be transformed so that any 'match-*',
'invert-match' or 'mode' parameter is translated into an equivalent
'expression'.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
PVE/API2/Cluster/Notifications.pm | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 3f99de08..80884af1 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -1603,6 +1603,14 @@ __PACKAGE__->register_method({
type => 'string',
format => 'pve-configid',
},
+ 'migrate-to-expression' => {
+ type => 'boolean',
+ default => 0,
+ description =>
+ "Automatically translate existing 'match-*', 'invert-match' and 'mode' to"
+ . " an equivalent matching 'expression'.",
+ optional => 1,
+ },
},
},
returns => {
@@ -1614,10 +1622,11 @@ __PACKAGE__->register_method({
code => sub {
my ($param) = @_;
my $name = extract_param($param, 'name');
+ my $migrate_to_expression = extract_param($param, 'migrate-to-expression') // 0;
my $config = PVE::Notify::read_config();
- my $matcher = eval { $config->get_matcher($name) };
+ my $matcher = eval { $config->get_matcher($name, $migrate_to_expression) };
raise_api_error($@) if $@;
$matcher->{digest} = $config->digest();
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH manager v2 33/34] api: notification: add 'expression' to matcher parameter schema
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (31 preceding siblings ...)
2026-10-01 8:37 ` [PATCH manager v2 32/34] api: notification: get_matcher: add 'migrate-to-expression' parameter Lukas Wagner
@ 2026-10-01 8:37 ` Lukas Wagner
2026-10-01 8:37 ` [PATCH manager v2 34/34] ui: notification: enable new matcher UI Lukas Wagner
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:37 UTC (permalink / raw)
To: pbs-devel, pve-devel
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
PVE/API2/Cluster/Notifications.pm | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 80884af1..bd3c86cc 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -1480,6 +1480,13 @@ my $matcher_properties = {
type => 'string',
format => 'pve-configid',
},
+ 'expression' => {
+ type => 'string',
+ description => 'Match expression as inline JSON. This option is mutually exclusive with'
+ . ' the following options: match-field, match-calendar, match-severity, invert-match'
+ . ' and mode. All of these can be represented as (sub)-expressions of this expression',
+ optional => 1,
+ },
'match-field' => {
type => 'array',
items => {
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread* [PATCH manager v2 34/34] ui: notification: enable new matcher UI
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
` (32 preceding siblings ...)
2026-10-01 8:37 ` [PATCH manager v2 33/34] api: notification: add 'expression' to matcher parameter schema Lukas Wagner
@ 2026-10-01 8:37 ` Lukas Wagner
33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01 8:37 UTC (permalink / raw)
To: pbs-devel, pve-devel
By setting this feature flag, we enable the new improved match rule edit
window. The feature flag allows us to bump the widget toolkit
independently of the rest rest.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
www/manager6/Utils.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 8b99371d..8c2bb3c4 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -2272,5 +2272,7 @@ Ext.define('PVE.Utils', {
replication: gettext('Replication job notifications'),
fencing: gettext('Node fencing notifications'),
});
+
+ Proxmox.Schema.notificationMatcherExpressions = true;
},
});
--
2.47.3
^ permalink raw reply related [flat|nested] 35+ messages in thread