From: Lukas Wagner <l.wagner@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH v2 proxmox 05/52] notify: replace filters and groups with matcher-based system
Date: Tue, 14 Nov 2023 13:59:13 +0100 [thread overview]
Message-ID: <20231114130000.565122-6-l.wagner@proxmox.com> (raw)
In-Reply-To: <20231114130000.565122-1-l.wagner@proxmox.com>
This shifts notification routing into the matcher-system. Every
notification has associated metadata (key-value fields, severity -
to be extended) that can be match with match directives in
notification matchers. Right now, there are 2 matching directives,
match-field and match-severity. The first one allows one to do a
regex match/exact match on a metadata field, the other one allows one
to match one or more severites.
Every matcher also allows 'target' directives, these decide which
target(s) will be notified if a matcher matches a notification.
Since routing now happens in matchers, the API for sending is
simplified, since we do not need to specify a target any more.
The API routes for filters and groups have been removed completely.
The parser for the configuration file will still accept filter/group
entries, but will delete them once the config is saved again. This is
needed to allow a smooth transition from the old system to the new
system, since the old system was already available on pvetest.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
Notes:
Sorry for the large commit, many of these changes interact with each
other and it would have been significantly more effort to keep
everything nice, tidy and compileable after splitting this apart.
I wantend to get these changes out ASAP.
proxmox-notify/Cargo.toml | 2 +
proxmox-notify/src/api/common.rs | 6 +-
proxmox-notify/src/api/filter.rs | 231 -------------
proxmox-notify/src/api/gotify.rs | 16 -
proxmox-notify/src/api/group.rs | 259 ---------------
proxmox-notify/src/api/matcher.rs | 254 +++++++++++++++
proxmox-notify/src/api/mod.rs | 115 ++-----
proxmox-notify/src/api/sendmail.rs | 15 -
proxmox-notify/src/config.rs | 34 +-
proxmox-notify/src/endpoints/gotify.rs | 19 +-
proxmox-notify/src/endpoints/sendmail.rs | 14 +-
proxmox-notify/src/filter.rs | 195 +----------
proxmox-notify/src/group.rs | 40 +--
proxmox-notify/src/lib.rs | 317 +++++++-----------
proxmox-notify/src/matcher.rs | 395 +++++++++++++++++++++++
proxmox-notify/src/schema.rs | 11 +-
16 files changed, 848 insertions(+), 1075 deletions(-)
delete mode 100644 proxmox-notify/src/api/filter.rs
delete mode 100644 proxmox-notify/src/api/group.rs
create mode 100644 proxmox-notify/src/api/matcher.rs
create mode 100644 proxmox-notify/src/matcher.rs
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index 1541b8b..4812896 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -8,6 +8,7 @@ repository.workspace = true
exclude.workspace = true
[dependencies]
+anyhow.workspace = true
handlebars = { workspace = true }
lazy_static.workspace = true
log.workspace = true
@@ -16,6 +17,7 @@ openssl.workspace = true
proxmox-http = { workspace = true, features = ["client-sync"], optional = true }
proxmox-http-error.workspace = true
proxmox-human-byte.workspace = true
+proxmox-serde.workspace = true
proxmox-schema = { workspace = true, features = ["api-macro", "api-types"]}
proxmox-section-config = { workspace = true }
proxmox-sys = { workspace = true, optional = true }
diff --git a/proxmox-notify/src/api/common.rs b/proxmox-notify/src/api/common.rs
index d17f4db..fa2356e 100644
--- a/proxmox-notify/src/api/common.rs
+++ b/proxmox-notify/src/api/common.rs
@@ -7,7 +7,7 @@ use crate::{Bus, Config, Notification};
///
/// The caller is responsible for any needed permission checks.
/// Returns an `anyhow::Error` in case of an error.
-pub fn send(config: &Config, channel: &str, notification: &Notification) -> Result<(), HttpError> {
+pub fn send(config: &Config, notification: &Notification) -> Result<(), HttpError> {
let bus = Bus::from_config(config).map_err(|err| {
http_err!(
INTERNAL_SERVER_ERROR,
@@ -15,7 +15,7 @@ pub fn send(config: &Config, channel: &str, notification: &Notification) -> Resu
)
})?;
- bus.send(channel, notification);
+ bus.send(notification);
Ok(())
}
@@ -50,5 +50,5 @@ pub fn test_target(config: &Config, endpoint: &str) -> Result<(), HttpError> {
/// If the entity does not exist, the result will only contain the entity.
pub fn get_referenced_entities(config: &Config, entity: &str) -> Result<Vec<String>, HttpError> {
let entities = super::get_referenced_entities(config, entity);
- Ok(Vec::from_iter(entities.into_iter()))
+ Ok(Vec::from_iter(entities))
}
diff --git a/proxmox-notify/src/api/filter.rs b/proxmox-notify/src/api/filter.rs
deleted file mode 100644
index b8682f4..0000000
--- a/proxmox-notify/src/api/filter.rs
+++ /dev/null
@@ -1,231 +0,0 @@
-use proxmox_http_error::HttpError;
-
-use crate::api::http_err;
-use crate::filter::{DeleteableFilterProperty, FilterConfig, FilterConfigUpdater, FILTER_TYPENAME};
-use crate::Config;
-
-/// Get a list of all filters
-///
-/// The caller is responsible for any needed permission checks.
-/// Returns a list of all filters or a `HttpError` if the config is
-/// (`500 Internal server error`).
-pub fn get_filters(config: &Config) -> Result<Vec<FilterConfig>, HttpError> {
- config
- .config
- .convert_to_typed_array(FILTER_TYPENAME)
- .map_err(|e| http_err!(INTERNAL_SERVER_ERROR, "Could not fetch filters: {e}"))
-}
-
-/// Get filter with given `name`
-///
-/// The caller is responsible for any needed permission checks.
-/// Returns the endpoint or a `HttpError` if the filter was not found (`404 Not found`).
-pub fn get_filter(config: &Config, name: &str) -> Result<FilterConfig, HttpError> {
- config
- .config
- .lookup(FILTER_TYPENAME, name)
- .map_err(|_| http_err!(NOT_FOUND, "filter '{name}' not found"))
-}
-
-/// Add new notification filter.
-///
-/// The caller is responsible for any needed permission checks.
-/// The caller also responsible for locking the configuration files.
-/// Returns a `HttpError` if:
-/// - an entity with the same name already exists (`400 Bad request`)
-/// - the configuration could not be saved (`500 Internal server error`)
-pub fn add_filter(config: &mut Config, filter_config: &FilterConfig) -> Result<(), HttpError> {
- super::ensure_unique(config, &filter_config.name)?;
-
- config
- .config
- .set_data(&filter_config.name, FILTER_TYPENAME, filter_config)
- .map_err(|e| {
- http_err!(
- INTERNAL_SERVER_ERROR,
- "could not save filter '{}': {e}",
- filter_config.name
- )
- })?;
-
- Ok(())
-}
-
-/// Update existing notification filter
-///
-/// The caller is responsible for any needed permission checks.
-/// The caller also responsible for locking the configuration files.
-/// Returns a `HttpError` if:
-/// - the configuration could not be saved (`500 Internal server error`)
-/// - an invalid digest was passed (`400 Bad request`)
-pub fn update_filter(
- config: &mut Config,
- name: &str,
- filter_updater: &FilterConfigUpdater,
- delete: Option<&[DeleteableFilterProperty]>,
- digest: Option<&[u8]>,
-) -> Result<(), HttpError> {
- super::verify_digest(config, digest)?;
-
- let mut filter = get_filter(config, name)?;
-
- if let Some(delete) = delete {
- for deleteable_property in delete {
- match deleteable_property {
- DeleteableFilterProperty::MinSeverity => filter.min_severity = None,
- DeleteableFilterProperty::Mode => filter.mode = None,
- DeleteableFilterProperty::InvertMatch => filter.invert_match = None,
- DeleteableFilterProperty::Comment => filter.comment = None,
- }
- }
- }
-
- if let Some(min_severity) = filter_updater.min_severity {
- filter.min_severity = Some(min_severity);
- }
-
- if let Some(mode) = filter_updater.mode {
- filter.mode = Some(mode);
- }
-
- if let Some(invert_match) = filter_updater.invert_match {
- filter.invert_match = Some(invert_match);
- }
-
- if let Some(comment) = &filter_updater.comment {
- filter.comment = Some(comment.into());
- }
-
- config
- .config
- .set_data(name, FILTER_TYPENAME, &filter)
- .map_err(|e| http_err!(INTERNAL_SERVER_ERROR, "could not save filter '{name}': {e}"))?;
-
- Ok(())
-}
-
-/// Delete existing filter
-///
-/// The caller is responsible for any needed permission checks.
-/// The caller also responsible for locking the configuration files.
-/// Returns a `HttpError` if:
-/// - the entity does not exist (`404 Not found`)
-/// - the filter is still referenced by another entity (`400 Bad request`)
-pub fn delete_filter(config: &mut Config, name: &str) -> Result<(), HttpError> {
- // Check if the filter exists
- let _ = get_filter(config, name)?;
- super::ensure_unused(config, name)?;
-
- config.config.sections.remove(name);
-
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::filter::FilterModeOperator;
- use crate::Severity;
-
- fn empty_config() -> Config {
- Config::new("", "").unwrap()
- }
-
- fn config_with_two_filters() -> Config {
- Config::new(
- "
-filter: filter1
- min-severity info
-
-filter: filter2
- min-severity warning
-",
- "",
- )
- .unwrap()
- }
-
- #[test]
- fn test_update_not_existing_returns_error() -> Result<(), HttpError> {
- let mut config = empty_config();
- assert!(update_filter(&mut config, "test", &Default::default(), None, None).is_err());
- Ok(())
- }
-
- #[test]
- fn test_update_invalid_digest_returns_error() -> Result<(), HttpError> {
- let mut config = config_with_two_filters();
- assert!(update_filter(
- &mut config,
- "filter1",
- &Default::default(),
- None,
- Some(&[0u8; 32])
- )
- .is_err());
-
- Ok(())
- }
-
- #[test]
- fn test_filter_update() -> Result<(), HttpError> {
- let mut config = config_with_two_filters();
-
- let digest = config.digest;
-
- update_filter(
- &mut config,
- "filter1",
- &FilterConfigUpdater {
- min_severity: Some(Severity::Error),
- mode: Some(FilterModeOperator::Or),
- invert_match: Some(true),
- comment: Some("new comment".into()),
- },
- None,
- Some(&digest),
- )?;
-
- let filter = get_filter(&config, "filter1")?;
-
- assert!(matches!(filter.mode, Some(FilterModeOperator::Or)));
- assert!(matches!(filter.min_severity, Some(Severity::Error)));
- assert_eq!(filter.invert_match, Some(true));
- assert_eq!(filter.comment, Some("new comment".into()));
-
- // Test property deletion
- update_filter(
- &mut config,
- "filter1",
- &Default::default(),
- Some(&[
- DeleteableFilterProperty::InvertMatch,
- DeleteableFilterProperty::Mode,
- DeleteableFilterProperty::InvertMatch,
- DeleteableFilterProperty::MinSeverity,
- DeleteableFilterProperty::Comment,
- ]),
- Some(&digest),
- )?;
-
- let filter = get_filter(&config, "filter1")?;
-
- assert_eq!(filter.invert_match, None);
- assert_eq!(filter.min_severity, None);
- assert!(matches!(filter.mode, None));
- assert_eq!(filter.comment, None);
-
- Ok(())
- }
-
- #[test]
- fn test_filter_delete() -> Result<(), HttpError> {
- let mut config = config_with_two_filters();
-
- delete_filter(&mut config, "filter1")?;
- assert!(delete_filter(&mut config, "filter1").is_err());
- assert_eq!(get_filters(&config)?.len(), 1);
-
- Ok(())
- }
-}
diff --git a/proxmox-notify/src/api/gotify.rs b/proxmox-notify/src/api/gotify.rs
index 0ec48fd..22d3d2e 100644
--- a/proxmox-notify/src/api/gotify.rs
+++ b/proxmox-notify/src/api/gotify.rs
@@ -36,7 +36,6 @@ pub fn get_endpoint(config: &Config, name: &str) -> Result<GotifyConfig, HttpErr
/// The caller also responsible for locking the configuration files.
/// Returns a `HttpError` if:
/// - an entity with the same name already exists (`400 Bad request`)
-/// - a referenced filter does not exist (`400 Bad request`)
/// - the configuration could not be saved (`500 Internal server error`)
///
/// Panics if the names of the private config and the public config do not match.
@@ -52,11 +51,6 @@ pub fn add_endpoint(
super::ensure_unique(config, &endpoint_config.name)?;
- if let Some(filter) = &endpoint_config.filter {
- // Check if filter exists
- super::filter::get_filter(config, filter)?;
- }
-
set_private_config_entry(config, private_endpoint_config)?;
config
@@ -77,7 +71,6 @@ pub fn add_endpoint(
/// The caller also responsible for locking the configuration files.
/// Returns a `HttpError` if:
/// - an entity with the same name already exists (`400 Bad request`)
-/// - a referenced filter does not exist (`400 Bad request`)
/// - the configuration could not be saved (`500 Internal server error`)
pub fn update_endpoint(
config: &mut Config,
@@ -95,7 +88,6 @@ pub fn update_endpoint(
for deleteable_property in delete {
match deleteable_property {
DeleteableGotifyProperty::Comment => endpoint.comment = None,
- DeleteableGotifyProperty::Filter => endpoint.filter = None,
}
}
}
@@ -118,13 +110,6 @@ pub fn update_endpoint(
endpoint.comment = Some(comment.into());
}
- if let Some(filter) = &endpoint_config_updater.filter {
- // Check if filter exists
- let _ = super::filter::get_filter(config, filter)?;
-
- endpoint.filter = Some(filter.into());
- }
-
config
.config
.set_data(name, GOTIFY_TYPENAME, &endpoint)
@@ -247,7 +232,6 @@ mod tests {
&GotifyConfigUpdater {
server: Some("newhost".into()),
comment: Some("newcomment".into()),
- filter: None,
},
&GotifyPrivateConfigUpdater {
token: Some("changedtoken".into()),
diff --git a/proxmox-notify/src/api/group.rs b/proxmox-notify/src/api/group.rs
deleted file mode 100644
index 6fc71ea..0000000
--- a/proxmox-notify/src/api/group.rs
+++ /dev/null
@@ -1,259 +0,0 @@
-use proxmox_http_error::HttpError;
-
-use crate::api::{http_bail, http_err};
-use crate::group::{DeleteableGroupProperty, GroupConfig, GroupConfigUpdater, GROUP_TYPENAME};
-use crate::Config;
-
-/// Get all notification groups
-///
-/// The caller is responsible for any needed permission checks.
-/// Returns a list of all groups or a `HttpError` if the config is
-/// erroneous (`500 Internal server error`).
-pub fn get_groups(config: &Config) -> Result<Vec<GroupConfig>, HttpError> {
- config
- .config
- .convert_to_typed_array(GROUP_TYPENAME)
- .map_err(|e| http_err!(INTERNAL_SERVER_ERROR, "Could not fetch groups: {e}"))
-}
-
-/// Get group with given `name`
-///
-/// The caller is responsible for any needed permission checks.
-/// Returns the endpoint or an `HttpError` if the group was not found (`404 Not found`).
-pub fn get_group(config: &Config, name: &str) -> Result<GroupConfig, HttpError> {
- config
- .config
- .lookup(GROUP_TYPENAME, name)
- .map_err(|_| http_err!(NOT_FOUND, "group '{name}' not found"))
-}
-
-/// Add a new group.
-///
-/// The caller is responsible for any needed permission checks.
-/// The caller also responsible for locking the configuration files.
-/// Returns a `HttpError` if:
-/// - an entity with the same name already exists (`400 Bad request`)
-/// - a referenced filter does not exist (`400 Bad request`)
-/// - no endpoints were passed (`400 Bad request`)
-/// - referenced endpoints do not exist (`404 Not found`)
-/// - the configuration could not be saved (`500 Internal server error`)
-pub fn add_group(config: &mut Config, group_config: &GroupConfig) -> Result<(), HttpError> {
- super::ensure_unique(config, &group_config.name)?;
-
- if group_config.endpoint.is_empty() {
- http_bail!(BAD_REQUEST, "group must contain at least one endpoint",);
- }
-
- if let Some(filter) = &group_config.filter {
- // Check if filter exists
- super::filter::get_filter(config, filter)?;
- }
-
- super::ensure_endpoints_exist(config, &group_config.endpoint)?;
-
- config
- .config
- .set_data(&group_config.name, GROUP_TYPENAME, group_config)
- .map_err(|e| {
- http_err!(
- INTERNAL_SERVER_ERROR,
- "could not save group '{}': {e}",
- group_config.name
- )
- })
-}
-
-/// Update existing group
-///
-/// The caller is responsible for any needed permission checks.
-/// The caller also responsible for locking the configuration files.
-/// Returns a `HttpError` if:
-/// - a referenced filter does not exist (`400 Bad request`)
-/// - an invalid digest was passed (`400 Bad request`)
-/// - no endpoints were passed (`400 Bad request`)
-/// - referenced endpoints do not exist (`404 Not found`)
-/// - the configuration could not be saved (`500 Internal server error`)
-pub fn update_group(
- config: &mut Config,
- name: &str,
- updater: &GroupConfigUpdater,
- delete: Option<&[DeleteableGroupProperty]>,
- digest: Option<&[u8]>,
-) -> Result<(), HttpError> {
- super::verify_digest(config, digest)?;
-
- let mut group = get_group(config, name)?;
-
- if let Some(delete) = delete {
- for deleteable_property in delete {
- match deleteable_property {
- DeleteableGroupProperty::Comment => group.comment = None,
- DeleteableGroupProperty::Filter => group.filter = None,
- }
- }
- }
-
- if let Some(endpoints) = &updater.endpoint {
- super::ensure_endpoints_exist(config, endpoints)?;
- if endpoints.is_empty() {
- http_bail!(BAD_REQUEST, "group must contain at least one endpoint",);
- }
- group.endpoint = endpoints.iter().map(Into::into).collect()
- }
-
- if let Some(comment) = &updater.comment {
- group.comment = Some(comment.into());
- }
-
- if let Some(filter) = &updater.filter {
- // Check if filter exists
- let _ = super::filter::get_filter(config, filter)?;
- group.filter = Some(filter.into());
- }
-
- config
- .config
- .set_data(name, GROUP_TYPENAME, &group)
- .map_err(|e| http_err!(INTERNAL_SERVER_ERROR, "could not save group '{name}': {e}"))
-}
-
-/// Delete existing group
-///
-/// The caller is responsible for any needed permission checks.
-/// The caller also responsible for locking the configuration files.
-/// Returns a `HttpError` if the group does not exist (`404 Not found`).
-pub fn delete_group(config: &mut Config, name: &str) -> Result<(), HttpError> {
- // Check if the group exists
- let _ = get_group(config, name)?;
-
- config.config.sections.remove(name);
-
- Ok(())
-}
-
-// groups cannot be empty, so only build the tests if we have the
-// sendmail endpoint available
-#[cfg(all(test, feature = "sendmail"))]
-mod tests {
- use super::*;
- use crate::api::sendmail::tests::add_sendmail_endpoint_for_test;
- use crate::api::test_helpers::*;
-
- fn add_default_group(config: &mut Config) -> Result<(), HttpError> {
- add_sendmail_endpoint_for_test(config, "test")?;
-
- add_group(
- config,
- &GroupConfig {
- name: "group1".into(),
- endpoint: vec!["test".to_string()],
- comment: None,
- filter: None,
- },
- )?;
-
- Ok(())
- }
-
- #[test]
- fn test_add_group_fails_if_endpoint_does_not_exist() {
- let mut config = empty_config();
- assert!(add_group(
- &mut config,
- &GroupConfig {
- name: "group1".into(),
- endpoint: vec!["foo".into()],
- comment: None,
- filter: None,
- },
- )
- .is_err());
- }
-
- #[test]
- fn test_add_group() -> Result<(), HttpError> {
- let mut config = empty_config();
- assert!(add_default_group(&mut config).is_ok());
- Ok(())
- }
-
- #[test]
- fn test_update_group_fails_if_endpoint_does_not_exist() -> Result<(), HttpError> {
- let mut config = empty_config();
- add_default_group(&mut config)?;
-
- assert!(update_group(
- &mut config,
- "group1",
- &GroupConfigUpdater {
- endpoint: Some(vec!["foo".into()]),
- ..Default::default()
- },
- None,
- None
- )
- .is_err());
- Ok(())
- }
-
- #[test]
- fn test_update_group_fails_if_digest_invalid() -> Result<(), HttpError> {
- let mut config = empty_config();
- add_default_group(&mut config)?;
-
- assert!(update_group(
- &mut config,
- "group1",
- &Default::default(),
- None,
- Some(&[0u8; 32])
- )
- .is_err());
- Ok(())
- }
-
- #[test]
- fn test_update_group() -> Result<(), HttpError> {
- let mut config = empty_config();
- add_default_group(&mut config)?;
-
- assert!(update_group(
- &mut config,
- "group1",
- &GroupConfigUpdater {
- endpoint: None,
- comment: Some("newcomment".into()),
- filter: None
- },
- None,
- None,
- )
- .is_ok());
- let group = get_group(&config, "group1")?;
- assert_eq!(group.comment, Some("newcomment".into()));
-
- assert!(update_group(
- &mut config,
- "group1",
- &Default::default(),
- Some(&[DeleteableGroupProperty::Comment]),
- None
- )
- .is_ok());
- let group = get_group(&config, "group1")?;
- assert_eq!(group.comment, None);
-
- Ok(())
- }
-
- #[test]
- fn test_group_delete() -> Result<(), HttpError> {
- let mut config = empty_config();
- add_default_group(&mut config)?;
-
- assert!(delete_group(&mut config, "group1").is_ok());
- assert!(delete_group(&mut config, "group1").is_err());
-
- Ok(())
- }
-}
diff --git a/proxmox-notify/src/api/matcher.rs b/proxmox-notify/src/api/matcher.rs
new file mode 100644
index 0000000..e37b74f
--- /dev/null
+++ b/proxmox-notify/src/api/matcher.rs
@@ -0,0 +1,254 @@
+use proxmox_http_error::HttpError;
+
+use crate::api::http_err;
+use crate::matcher::{
+ DeleteableMatcherProperty, MatcherConfig, MatcherConfigUpdater, MATCHER_TYPENAME,
+};
+use crate::Config;
+
+/// Get a list of all matchers
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns a list of all matchers or a `HttpError` if the config is
+/// (`500 Internal server error`).
+pub fn get_matchers(config: &Config) -> Result<Vec<MatcherConfig>, HttpError> {
+ config
+ .config
+ .convert_to_typed_array(MATCHER_TYPENAME)
+ .map_err(|e| http_err!(INTERNAL_SERVER_ERROR, "Could not fetch matchers: {e}"))
+}
+
+/// Get matcher with given `name`
+///
+/// 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(config: &Config, name: &str) -> Result<MatcherConfig, HttpError> {
+ config
+ .config
+ .lookup(MATCHER_TYPENAME, name)
+ .map_err(|_| http_err!(NOT_FOUND, "matcher '{name}' not found"))
+}
+
+/// Add new notification matcher.
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns a `HttpError` if:
+/// - an entity with the same name already exists (`400 Bad request`)
+/// - the configuration could not be saved (`500 Internal server error`)
+pub fn add_matcher(config: &mut Config, matcher_config: &MatcherConfig) -> Result<(), HttpError> {
+ super::ensure_unique(config, &matcher_config.name)?;
+
+ if let Some(targets) = matcher_config.target.as_deref() {
+ super::ensure_endpoints_exist(config, targets)?;
+ }
+
+ config
+ .config
+ .set_data(&matcher_config.name, MATCHER_TYPENAME, matcher_config)
+ .map_err(|e| {
+ http_err!(
+ INTERNAL_SERVER_ERROR,
+ "could not save matcher '{}': {e}",
+ matcher_config.name
+ )
+ })?;
+
+ Ok(())
+}
+
+/// Update existing notification matcher
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns a `HttpError` if:
+/// - the configuration could not be saved (`500 Internal server error`)
+/// - an invalid digest was passed (`400 Bad request`)
+pub fn update_matcher(
+ config: &mut Config,
+ name: &str,
+ matcher_updater: &MatcherConfigUpdater,
+ delete: Option<&[DeleteableMatcherProperty]>,
+ digest: Option<&[u8]>,
+) -> Result<(), HttpError> {
+ super::verify_digest(config, digest)?;
+
+ let mut matcher = get_matcher(config, name)?;
+
+ if let Some(delete) = delete {
+ for deleteable_property in delete {
+ match deleteable_property {
+ DeleteableMatcherProperty::MatchSeverity => matcher.match_severity = None,
+ DeleteableMatcherProperty::MatchField => matcher.match_field = None,
+ DeleteableMatcherProperty::Target => matcher.target = None,
+ DeleteableMatcherProperty::Mode => matcher.mode = None,
+ DeleteableMatcherProperty::InvertMatch => matcher.invert_match = None,
+ DeleteableMatcherProperty::Comment => matcher.comment = None,
+ }
+ }
+ }
+
+ if let Some(match_severity) = &matcher_updater.match_severity {
+ matcher.match_severity = Some(match_severity.clone());
+ }
+
+ if let Some(match_field) = &matcher_updater.match_field {
+ matcher.match_field = Some(match_field.clone());
+ }
+
+ 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(comment) = &matcher_updater.comment {
+ matcher.comment = Some(comment.into());
+ }
+
+ if let Some(target) = &matcher_updater.target {
+ super::ensure_endpoints_exist(config, target.as_slice())?;
+ matcher.target = Some(target.clone());
+ }
+
+ config
+ .config
+ .set_data(name, MATCHER_TYPENAME, &matcher)
+ .map_err(|e| {
+ http_err!(
+ INTERNAL_SERVER_ERROR,
+ "could not save matcher '{name}': {e}"
+ )
+ })?;
+
+ Ok(())
+}
+
+/// Delete existing matcher
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns a `HttpError` if:
+/// - the entity does not exist (`404 Not found`)
+pub fn delete_matcher(config: &mut Config, name: &str) -> Result<(), HttpError> {
+ // Check if the matcher exists
+ let _ = get_matcher(config, name)?;
+
+ config.config.sections.remove(name);
+
+ Ok(())
+}
+
+#[cfg(all(test, feature = "sendmail"))]
+mod tests {
+ use super::*;
+ use crate::matcher::MatchModeOperator;
+
+ fn empty_config() -> Config {
+ Config::new("", "").unwrap()
+ }
+
+ fn config_with_two_matchers() -> Config {
+ Config::new(
+ "
+sendmail: foo
+ mailto test@example.com
+
+matcher: matcher1
+
+matcher: matcher2
+",
+ "",
+ )
+ .unwrap()
+ }
+
+ #[test]
+ fn test_update_not_existing_returns_error() -> Result<(), HttpError> {
+ let mut config = empty_config();
+ assert!(update_matcher(&mut config, "test", &Default::default(), None, None).is_err());
+ Ok(())
+ }
+
+ #[test]
+ fn test_update_invalid_digest_returns_error() -> Result<(), HttpError> {
+ let mut config = config_with_two_matchers();
+ assert!(update_matcher(
+ &mut config,
+ "matcher1",
+ &Default::default(),
+ None,
+ Some(&[0u8; 32])
+ )
+ .is_err());
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_matcher_update() -> Result<(), HttpError> {
+ let mut config = config_with_two_matchers();
+
+ let digest = config.digest;
+
+ update_matcher(
+ &mut config,
+ "matcher1",
+ &MatcherConfigUpdater {
+ mode: Some(MatchModeOperator::Any),
+ match_field: None,
+ match_severity: None,
+ invert_match: Some(true),
+ target: Some(vec!["foo".into()]),
+ comment: Some("new comment".into()),
+ },
+ None,
+ Some(&digest),
+ )?;
+
+ let matcher = get_matcher(&config, "matcher1")?;
+
+ assert!(matches!(matcher.mode, Some(MatchModeOperator::Any)));
+ assert_eq!(matcher.invert_match, Some(true));
+ assert_eq!(matcher.comment, Some("new comment".into()));
+
+ // Test property deletion
+ update_matcher(
+ &mut config,
+ "matcher1",
+ &Default::default(),
+ Some(&[
+ DeleteableMatcherProperty::InvertMatch,
+ DeleteableMatcherProperty::Mode,
+ DeleteableMatcherProperty::MatchField,
+ DeleteableMatcherProperty::Target,
+ DeleteableMatcherProperty::Comment,
+ ]),
+ Some(&digest),
+ )?;
+
+ let matcher = get_matcher(&config, "matcher1")?;
+
+ assert_eq!(matcher.invert_match, None);
+ assert!(matcher.match_severity.is_none());
+ assert!(matches!(matcher.match_field, None));
+ assert_eq!(matcher.target, None);
+ assert!(matcher.mode.is_none());
+ assert_eq!(matcher.comment, None);
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_matcher_delete() -> Result<(), HttpError> {
+ let mut config = config_with_two_matchers();
+
+ delete_matcher(&mut config, "matcher1")?;
+ assert!(delete_matcher(&mut config, "matcher1").is_err());
+ assert_eq!(get_matchers(&config)?.len(), 1);
+
+ Ok(())
+ }
+}
diff --git a/proxmox-notify/src/api/mod.rs b/proxmox-notify/src/api/mod.rs
index 8dc9b4e..8042157 100644
--- a/proxmox-notify/src/api/mod.rs
+++ b/proxmox-notify/src/api/mod.rs
@@ -5,10 +5,9 @@ use proxmox_http_error::HttpError;
use crate::Config;
pub mod common;
-pub mod filter;
#[cfg(feature = "gotify")]
pub mod gotify;
-pub mod group;
+pub mod matcher;
#[cfg(feature = "sendmail")]
pub mod sendmail;
@@ -94,36 +93,13 @@ fn ensure_unique(config: &Config, entity: &str) -> Result<(), HttpError> {
fn get_referrers(config: &Config, entity: &str) -> Result<HashSet<String>, HttpError> {
let mut referrers = HashSet::new();
- for group in group::get_groups(config)? {
- if group.endpoint.iter().any(|endpoint| endpoint == entity) {
- referrers.insert(group.name.clone());
- }
-
- if let Some(filter) = group.filter {
- if filter == entity {
- referrers.insert(group.name);
- }
- }
- }
-
- #[cfg(feature = "sendmail")]
- for endpoint in sendmail::get_endpoints(config)? {
- if let Some(filter) = endpoint.filter {
- if filter == entity {
- referrers.insert(endpoint.name);
+ for matcher in matcher::get_matchers(config)? {
+ if let Some(targets) = matcher.target {
+ if targets.iter().any(|target| target == entity) {
+ referrers.insert(matcher.name.clone());
}
}
}
-
- #[cfg(feature = "gotify")]
- for endpoint in gotify::get_endpoints(config)? {
- if let Some(filter) = endpoint.filter {
- if filter == entity {
- referrers.insert(endpoint.name);
- }
- }
- }
-
Ok(referrers)
}
@@ -151,23 +127,11 @@ fn get_referenced_entities(config: &Config, entity: &str) -> HashSet<String> {
let mut new = HashSet::new();
for entity in entities {
- if let Ok(group) = group::get_group(config, entity) {
- for target in group.endpoint {
- new.insert(target.clone());
- }
- }
-
- #[cfg(feature = "sendmail")]
- if let Ok(target) = sendmail::get_endpoint(config, entity) {
- if let Some(filter) = target.filter {
- new.insert(filter.clone());
- }
- }
-
- #[cfg(feature = "gotify")]
- if let Ok(target) = gotify::get_endpoint(config, entity) {
- if let Some(filter) = target.filter {
- new.insert(filter.clone());
+ if let Ok(group) = matcher::get_matcher(config, entity) {
+ if let Some(targets) = group.target {
+ for target in targets {
+ new.insert(target.clone());
+ }
}
}
}
@@ -205,11 +169,12 @@ mod tests {
fn prepare_config() -> Result<Config, HttpError> {
let mut config = super::test_helpers::empty_config();
- filter::add_filter(
+ matcher::add_matcher(
&mut config,
- &FilterConfig {
- name: "filter".to_string(),
- ..Default::default()
+ &MatcherConfig {
+ name: "matcher".to_string(),
+ target: Some(vec!["sendmail".to_string(), "gotify".to_string()])
+ ..Default::default(),
},
)?;
@@ -218,7 +183,6 @@ mod tests {
&SendmailConfig {
name: "sendmail".to_string(),
mailto: Some(vec!["foo@example.com".to_string()]),
- filter: Some("filter".to_string()),
..Default::default()
},
)?;
@@ -228,7 +192,6 @@ mod tests {
&GotifyConfig {
name: "gotify".to_string(),
server: "localhost".to_string(),
- filter: Some("filter".to_string()),
..Default::default()
},
&GotifyPrivateConfig {
@@ -237,16 +200,6 @@ mod tests {
},
)?;
- group::add_group(
- &mut config,
- &GroupConfig {
- name: "group".to_string(),
- endpoint: vec!["gotify".to_string(), "sendmail".to_string()],
- filter: Some("filter".to_string()),
- ..Default::default()
- },
- )?;
-
Ok(config)
}
@@ -255,24 +208,11 @@ mod tests {
let config = prepare_config().unwrap();
assert_eq!(
- get_referenced_entities(&config, "filter"),
- HashSet::from(["filter".to_string()])
- );
- assert_eq!(
- get_referenced_entities(&config, "sendmail"),
- HashSet::from(["filter".to_string(), "sendmail".to_string()])
- );
- assert_eq!(
- get_referenced_entities(&config, "gotify"),
- HashSet::from(["filter".to_string(), "gotify".to_string()])
- );
- assert_eq!(
- get_referenced_entities(&config, "group"),
+ get_referenced_entities(&config, "matcher"),
HashSet::from([
- "filter".to_string(),
- "gotify".to_string(),
+ "matcher".to_string(),
"sendmail".to_string(),
- "group".to_string()
+ "gotify".to_string()
])
);
}
@@ -281,27 +221,16 @@ mod tests {
fn test_get_referrers_for_entity() -> Result<(), HttpError> {
let config = prepare_config().unwrap();
- assert_eq!(
- get_referrers(&config, "filter")?,
- HashSet::from([
- "gotify".to_string(),
- "sendmail".to_string(),
- "group".to_string()
- ])
- );
-
assert_eq!(
get_referrers(&config, "sendmail")?,
- HashSet::from(["group".to_string()])
+ HashSet::from(["matcher".to_string()])
);
assert_eq!(
get_referrers(&config, "gotify")?,
- HashSet::from(["group".to_string()])
+ HashSet::from(["matcher".to_string()])
);
- assert!(get_referrers(&config, "group")?.is_empty(),);
-
Ok(())
}
@@ -309,10 +238,9 @@ mod tests {
fn test_ensure_unused() {
let config = prepare_config().unwrap();
- assert!(ensure_unused(&config, "filter").is_err());
assert!(ensure_unused(&config, "gotify").is_err());
assert!(ensure_unused(&config, "sendmail").is_err());
- assert!(ensure_unused(&config, "group").is_ok());
+ assert!(ensure_unused(&config, "matcher").is_ok());
}
#[test]
@@ -329,6 +257,5 @@ mod tests {
let config = prepare_config().unwrap();
assert!(ensure_endpoints_exist(&config, &vec!["sendmail", "gotify"]).is_ok());
- assert!(ensure_endpoints_exist(&config, &vec!["group", "filter"]).is_err());
}
}
diff --git a/proxmox-notify/src/api/sendmail.rs b/proxmox-notify/src/api/sendmail.rs
index ac8737c..dbd9559 100644
--- a/proxmox-notify/src/api/sendmail.rs
+++ b/proxmox-notify/src/api/sendmail.rs
@@ -35,17 +35,11 @@ pub fn get_endpoint(config: &Config, name: &str) -> Result<SendmailConfig, HttpE
/// The caller also responsible for locking the configuration files.
/// Returns a `HttpError` if:
/// - an entity with the same name already exists (`400 Bad request`)
-/// - a referenced filter does not exist (`400 Bad request`)
/// - the configuration could not be saved (`500 Internal server error`)
/// - mailto *and* mailto_user are both set to `None`
pub fn add_endpoint(config: &mut Config, endpoint: &SendmailConfig) -> Result<(), HttpError> {
super::ensure_unique(config, &endpoint.name)?;
- if let Some(filter) = &endpoint.filter {
- // Check if filter exists
- super::filter::get_filter(config, filter)?;
- }
-
if endpoint.mailto.is_none() && endpoint.mailto_user.is_none() {
http_bail!(
BAD_REQUEST,
@@ -70,7 +64,6 @@ pub fn add_endpoint(config: &mut Config, endpoint: &SendmailConfig) -> Result<()
/// The caller is responsible for any needed permission checks.
/// The caller also responsible for locking the configuration files.
/// Returns a `HttpError` if:
-/// - a referenced filter does not exist (`400 Bad request`)
/// - the configuration could not be saved (`500 Internal server error`)
/// - mailto *and* mailto_user are both set to `None`
pub fn update_endpoint(
@@ -90,7 +83,6 @@ pub fn update_endpoint(
DeleteableSendmailProperty::FromAddress => endpoint.from_address = None,
DeleteableSendmailProperty::Author => endpoint.author = None,
DeleteableSendmailProperty::Comment => endpoint.comment = None,
- DeleteableSendmailProperty::Filter => endpoint.filter = None,
DeleteableSendmailProperty::Mailto => endpoint.mailto = None,
DeleteableSendmailProperty::MailtoUser => endpoint.mailto_user = None,
}
@@ -117,11 +109,6 @@ pub fn update_endpoint(
endpoint.comment = Some(comment.into());
}
- if let Some(filter) = &updater.filter {
- let _ = super::filter::get_filter(config, filter)?;
- endpoint.filter = Some(filter.into());
- }
-
if endpoint.mailto.is_none() && endpoint.mailto_user.is_none() {
http_bail!(
BAD_REQUEST,
@@ -221,7 +208,6 @@ pub mod tests {
from_address: Some("root@example.com".into()),
author: Some("newauthor".into()),
comment: Some("new comment".into()),
- filter: None,
},
None,
Some(&[0; 32]),
@@ -247,7 +233,6 @@ pub mod tests {
from_address: Some("root@example.com".into()),
author: Some("newauthor".into()),
comment: Some("new comment".into()),
- filter: None,
},
None,
Some(&digest),
diff --git a/proxmox-notify/src/config.rs b/proxmox-notify/src/config.rs
index cdbf42a..a86995e 100644
--- a/proxmox-notify/src/config.rs
+++ b/proxmox-notify/src/config.rs
@@ -5,6 +5,7 @@ use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlug
use crate::filter::{FilterConfig, FILTER_TYPENAME};
use crate::group::{GroupConfig, GROUP_TYPENAME};
+use crate::matcher::{MatcherConfig, MATCHER_TYPENAME};
use crate::schema::BACKEND_NAME_SCHEMA;
use crate::Error;
@@ -39,8 +40,14 @@ fn config_init() -> SectionConfig {
));
}
- const GROUP_SCHEMA: &ObjectSchema = GroupConfig::API_SCHEMA.unwrap_object_schema();
+ const MATCHER_SCHEMA: &ObjectSchema = MatcherConfig::API_SCHEMA.unwrap_object_schema();
+ config.register_plugin(SectionConfigPlugin::new(
+ MATCHER_TYPENAME.to_string(),
+ Some(String::from("name")),
+ MATCHER_SCHEMA,
+ ));
+ const GROUP_SCHEMA: &ObjectSchema = GroupConfig::API_SCHEMA.unwrap_object_schema();
config.register_plugin(SectionConfigPlugin::new(
GROUP_TYPENAME.to_string(),
Some(String::from("name")),
@@ -78,9 +85,32 @@ fn private_config_init() -> SectionConfig {
pub fn config(raw_config: &str) -> Result<(SectionConfigData, [u8; 32]), Error> {
let digest = openssl::sha::sha256(raw_config.as_bytes());
- let data = CONFIG
+ let mut data = CONFIG
.parse("notifications.cfg", raw_config)
.map_err(|err| Error::ConfigDeserialization(err.into()))?;
+
+ // TODO: Remove this once this has been in production for a while.
+ // 'group' and 'filter' sections are remnants of the 'old'
+ // notification routing approach that already hit pvetest...
+ // This mechanism cleans out left-over entries.
+ let entries: Vec<GroupConfig> = data.convert_to_typed_array("group").unwrap_or_default();
+ if !entries.is_empty() {
+ log::warn!("clearing left-over 'group' entries from notifications.cfg");
+ }
+
+ for entry in entries {
+ data.sections.remove(&entry.name);
+ }
+
+ let entries: Vec<FilterConfig> = data.convert_to_typed_array("filter").unwrap_or_default();
+ if !entries.is_empty() {
+ log::warn!("clearing left-over 'filter' entries from notifications.cfg");
+ }
+
+ for entry in entries {
+ data.sections.remove(&entry.name);
+ }
+
Ok((data, digest))
}
diff --git a/proxmox-notify/src/endpoints/gotify.rs b/proxmox-notify/src/endpoints/gotify.rs
index af86f9c..1c307a4 100644
--- a/proxmox-notify/src/endpoints/gotify.rs
+++ b/proxmox-notify/src/endpoints/gotify.rs
@@ -33,10 +33,6 @@ pub(crate) const GOTIFY_TYPENAME: &str = "gotify";
optional: true,
schema: COMMENT_SCHEMA,
},
- filter: {
- optional: true,
- schema: ENTITY_NAME_SCHEMA,
- },
}
)]
#[derive(Serialize, Deserialize, Updater, Default)]
@@ -51,8 +47,9 @@ pub struct GotifyConfig {
/// Comment
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
- /// Filter to apply
- #[serde(skip_serializing_if = "Option::is_none")]
+ /// Deprecated.
+ #[serde(skip_serializing)]
+ #[updater(skip)]
pub filter: Option<String>,
}
@@ -80,17 +77,15 @@ pub struct GotifyEndpoint {
#[serde(rename_all = "kebab-case")]
pub enum DeleteableGotifyProperty {
Comment,
- Filter,
}
impl Endpoint for GotifyEndpoint {
fn send(&self, notification: &Notification) -> Result<(), Error> {
-
let (title, message) = match ¬ification.content {
Content::Template {
title_template,
body_template,
- data
+ data,
} => {
let rendered_title =
renderer::render_template(TemplateRenderer::Plaintext, title_template, data)?;
@@ -108,7 +103,7 @@ impl Endpoint for GotifyEndpoint {
let body = json!({
"title": &title,
"message": &message,
- "priority": severity_to_priority(notification.severity),
+ "priority": severity_to_priority(notification.metadata.severity),
"extras": {
"client::display": {
"contentType": "text/markdown"
@@ -152,8 +147,4 @@ impl Endpoint for GotifyEndpoint {
fn name(&self) -> &str {
&self.config.name
}
-
- fn filter(&self) -> Option<&str> {
- self.config.filter.as_deref()
- }
}
diff --git a/proxmox-notify/src/endpoints/sendmail.rs b/proxmox-notify/src/endpoints/sendmail.rs
index c540925..a601744 100644
--- a/proxmox-notify/src/endpoints/sendmail.rs
+++ b/proxmox-notify/src/endpoints/sendmail.rs
@@ -35,10 +35,6 @@ pub(crate) const SENDMAIL_TYPENAME: &str = "sendmail";
optional: true,
schema: COMMENT_SCHEMA,
},
- filter: {
- optional: true,
- schema: ENTITY_NAME_SCHEMA,
- },
},
)]
#[derive(Debug, Serialize, Deserialize, Updater, Default)]
@@ -63,8 +59,9 @@ pub struct SendmailConfig {
/// Comment
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
- /// Filter to apply
- #[serde(skip_serializing_if = "Option::is_none")]
+ /// Deprecated.
+ #[serde(skip_serializing)]
+ #[updater(skip)]
pub filter: Option<String>,
}
@@ -74,7 +71,6 @@ pub enum DeleteableSendmailProperty {
FromAddress,
Author,
Comment,
- Filter,
Mailto,
MailtoUser,
}
@@ -144,8 +140,4 @@ impl Endpoint for SendmailEndpoint {
fn name(&self) -> &str {
&self.config.name
}
-
- fn filter(&self) -> Option<&str> {
- self.config.filter.as_deref()
- }
}
diff --git a/proxmox-notify/src/filter.rs b/proxmox-notify/src/filter.rs
index e014a59..c9b152b 100644
--- a/proxmox-notify/src/filter.rs
+++ b/proxmox-notify/src/filter.rs
@@ -1,202 +1,23 @@
-use std::collections::{HashMap, HashSet};
-
use serde::{Deserialize, Serialize};
-use proxmox_schema::api_types::COMMENT_SCHEMA;
-use proxmox_schema::{api, Updater};
+use proxmox_schema::api;
use crate::schema::ENTITY_NAME_SCHEMA;
-use crate::{Error, Notification, Severity};
-
-pub const FILTER_TYPENAME: &str = "filter";
-
-#[api]
-#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)]
-#[serde(rename_all = "kebab-case")]
-pub enum FilterModeOperator {
- /// All filter properties have to match (AND)
- #[default]
- And,
- /// At least one filter property has to match (OR)
- Or,
-}
-impl FilterModeOperator {
- /// Apply the mode operator to two bools, lhs and rhs
- fn apply(&self, lhs: bool, rhs: bool) -> bool {
- match self {
- FilterModeOperator::And => lhs && rhs,
- FilterModeOperator::Or => lhs || rhs,
- }
- }
-
- fn neutral_element(&self) -> bool {
- match self {
- FilterModeOperator::And => true,
- FilterModeOperator::Or => false,
- }
- }
-}
+pub(crate) const FILTER_TYPENAME: &str = "filter";
#[api(
properties: {
name: {
schema: ENTITY_NAME_SCHEMA,
},
- comment: {
- optional: true,
- schema: COMMENT_SCHEMA,
- },
- })]
-#[derive(Debug, Serialize, Deserialize, Updater, Default)]
+ },
+ additional_properties: true,
+)]
+#[derive(Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
-/// Config for Sendmail notification endpoints
+/// Config for the old filter system - can be removed at some point.
pub struct FilterConfig {
- /// Name of the filter
- #[updater(skip)]
+ /// Name of the group
pub name: String,
-
- /// Minimum severity to match
- #[serde(skip_serializing_if = "Option::is_none")]
- pub min_severity: Option<Severity>,
-
- /// Choose between 'and' and 'or' for when multiple properties are specified
- #[serde(skip_serializing_if = "Option::is_none")]
- pub mode: Option<FilterModeOperator>,
-
- /// Invert match of the whole filter
- #[serde(skip_serializing_if = "Option::is_none")]
- pub invert_match: Option<bool>,
-
- /// Comment
- #[serde(skip_serializing_if = "Option::is_none")]
- pub comment: Option<String>,
-}
-
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "kebab-case")]
-pub enum DeleteableFilterProperty {
- MinSeverity,
- Mode,
- InvertMatch,
- Comment,
-}
-
-/// A caching, lazily-evaluating notification filter. Parameterized with the notification itself,
-/// since there are usually multiple filters to check for a single notification that is to be sent.
-pub(crate) struct FilterMatcher<'a> {
- filters: HashMap<&'a str, &'a FilterConfig>,
- cached_results: HashMap<&'a str, bool>,
- notification: &'a Notification,
-}
-
-impl<'a> FilterMatcher<'a> {
- pub(crate) fn new(filters: &'a [FilterConfig], notification: &'a Notification) -> Self {
- let filters = filters.iter().map(|f| (f.name.as_str(), f)).collect();
-
- Self {
- filters,
- cached_results: Default::default(),
- notification,
- }
- }
-
- /// Check if the notification that was used to instantiate Self matches a given filter
- pub(crate) fn check_filter_match(&mut self, filter_name: &str) -> Result<bool, Error> {
- let mut visited = HashSet::new();
-
- self.do_check_filter(filter_name, &mut visited)
- }
-
- fn do_check_filter(
- &mut self,
- filter_name: &str,
- visited: &mut HashSet<String>,
- ) -> Result<bool, Error> {
- if visited.contains(filter_name) {
- return Err(Error::FilterFailed(format!(
- "recursive filter definition: {filter_name}"
- )));
- }
-
- if let Some(is_match) = self.cached_results.get(filter_name) {
- return Ok(*is_match);
- }
-
- visited.insert(filter_name.into());
-
- let filter_config =
- self.filters.get(filter_name).copied().ok_or_else(|| {
- Error::FilterFailed(format!("filter '{filter_name}' does not exist"))
- })?;
-
- let invert_match = filter_config.invert_match.unwrap_or_default();
-
- let mode_operator = filter_config.mode.unwrap_or_default();
-
- let mut notification_matches = mode_operator.neutral_element();
-
- notification_matches = mode_operator.apply(
- notification_matches,
- self.check_severity_match(filter_config, mode_operator),
- );
-
- Ok(notification_matches != invert_match)
- }
-
- fn check_severity_match(
- &self,
- filter_config: &FilterConfig,
- mode_operator: FilterModeOperator,
- ) -> bool {
- if let Some(min_severity) = filter_config.min_severity {
- self.notification.severity >= min_severity
- } else {
- mode_operator.neutral_element()
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::{config, Content};
-
- fn parse_filters(config: &str) -> Result<Vec<FilterConfig>, Error> {
- let (config, _) = config::config(config)?;
- Ok(config.convert_to_typed_array("filter").unwrap())
- }
-
- fn empty_notification_with_severity(severity: Severity) -> Notification {
- Notification {
- content: Content::Template {
- title_template: String::new(),
- body_template: String::new(),
- data: Default::default(),
- },
- severity,
- }
- }
-
- #[test]
- fn test_trivial_severity_filters() -> Result<(), Error> {
- let config = "
-filter: test
- min-severity warning
-";
-
- let filters = parse_filters(config)?;
-
- let is_match = |severity| {
- let notification = empty_notification_with_severity(severity);
- let mut results = FilterMatcher::new(&filters, ¬ification);
- results.check_filter_match("test")
- };
-
- assert!(is_match(Severity::Warning)?);
- assert!(!is_match(Severity::Notice)?);
- assert!(is_match(Severity::Error)?);
-
- Ok(())
- }
}
diff --git a/proxmox-notify/src/group.rs b/proxmox-notify/src/group.rs
index 713e52e..46458db 100644
--- a/proxmox-notify/src/group.rs
+++ b/proxmox-notify/src/group.rs
@@ -1,7 +1,6 @@
use serde::{Deserialize, Serialize};
-use proxmox_schema::api_types::COMMENT_SCHEMA;
-use proxmox_schema::{api, Updater};
+use proxmox_schema::api;
use crate::schema::ENTITY_NAME_SCHEMA;
@@ -9,43 +8,16 @@ pub(crate) const GROUP_TYPENAME: &str = "group";
#[api(
properties: {
- "endpoint": {
- type: Array,
- items: {
- description: "Name of the included endpoint(s)",
- type: String,
- },
- },
- comment: {
- optional: true,
- schema: COMMENT_SCHEMA,
- },
- filter: {
- optional: true,
+ name: {
schema: ENTITY_NAME_SCHEMA,
},
},
+ additional_properties: true,
)]
-#[derive(Debug, Serialize, Deserialize, Updater, Default)]
+#[derive(Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
-/// Config for notification channels
+/// Config for the old target groups - can be removed at some point.
pub struct GroupConfig {
- /// Name of the channel
- #[updater(skip)]
+ /// Name of the group
pub name: String,
- /// Endpoints for this channel
- pub endpoint: Vec<String>,
- /// Comment
- #[serde(skip_serializing_if = "Option::is_none")]
- pub comment: Option<String>,
- /// Filter to apply
- #[serde(skip_serializing_if = "Option::is_none")]
- pub filter: Option<String>,
-}
-
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "kebab-case")]
-pub enum DeleteableGroupProperty {
- Comment,
- Filter,
}
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index d40d017..1f95ae0 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::error::Error as StdError;
use std::fmt::Display;
+use std::str::FromStr;
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -9,15 +10,14 @@ use serde_json::Value;
use proxmox_schema::api;
use proxmox_section_config::SectionConfigData;
-pub mod filter;
-use filter::{FilterConfig, FilterMatcher, FILTER_TYPENAME};
-
-pub mod group;
-use group::{GroupConfig, GROUP_TYPENAME};
+pub mod matcher;
+use matcher::{MatcherConfig, MATCHER_TYPENAME};
pub mod api;
pub mod context;
pub mod endpoints;
+pub mod filter;
+pub mod group;
pub mod renderer;
pub mod schema;
@@ -104,6 +104,30 @@ pub enum Severity {
Error,
}
+impl Display for Severity {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> {
+ match self {
+ Severity::Info => f.write_str("info"),
+ Severity::Notice => f.write_str("notice"),
+ Severity::Warning => f.write_str("warning"),
+ Severity::Error => f.write_str("error"),
+ }
+ }
+}
+
+impl FromStr for Severity {
+ type Err = Error;
+ fn from_str(s: &str) -> Result<Self, Error> {
+ match s {
+ "info" => Ok(Self::Info),
+ "notice" => Ok(Self::Notice),
+ "warning" => Ok(Self::Warning),
+ "error" => Ok(Self::Error),
+ _ => Err(Error::Generic(format!("invalid severity {s}"))),
+ }
+ }
+}
+
/// Notification endpoint trait, implemented by all endpoint plugins
pub trait Endpoint {
/// Send a documentation
@@ -111,9 +135,6 @@ pub trait Endpoint {
/// The name/identifier for this endpoint
fn name(&self) -> &str;
-
- /// The name of the filter to use
- fn filter(&self) -> Option<&str>;
}
#[derive(Debug, Clone)]
@@ -130,12 +151,20 @@ pub enum Content {
}
#[derive(Debug, Clone)]
-/// Notification which can be sent
-pub struct Notification {
+pub struct Metadata {
/// Notification severity
severity: Severity,
+ /// Additional fields for additional key-value metadata
+ additional_fields: HashMap<String, String>,
+}
+
+#[derive(Debug, Clone)]
+/// Notification which can be sent
+pub struct Notification {
/// Notification content
content: Content,
+ /// Metadata
+ metadata: Metadata,
}
impl Notification {
@@ -143,14 +172,18 @@ impl Notification {
severity: Severity,
title: S,
body: S,
- properties: Value,
+ template_data: Value,
+ fields: HashMap<String, String>,
) -> Self {
Self {
- severity,
+ metadata: Metadata {
+ severity,
+ additional_fields: fields,
+ },
content: Content::Template {
title_template: title.as_ref().to_string(),
body_template: body.as_ref().to_string(),
- data: properties,
+ data: template_data,
},
}
}
@@ -198,8 +231,7 @@ impl Config {
#[derive(Default)]
pub struct Bus {
endpoints: HashMap<String, Box<dyn Endpoint>>,
- groups: HashMap<String, GroupConfig>,
- filters: Vec<FilterConfig>,
+ matchers: Vec<MatcherConfig>,
}
#[allow(unused_macros)]
@@ -304,23 +336,14 @@ impl Bus {
);
}
- let groups: HashMap<String, GroupConfig> = config
+ let matchers = config
.config
- .convert_to_typed_array(GROUP_TYPENAME)
- .map_err(|err| Error::ConfigDeserialization(err.into()))?
- .into_iter()
- .map(|group: GroupConfig| (group.name.clone(), group))
- .collect();
-
- let filters = config
- .config
- .convert_to_typed_array(FILTER_TYPENAME)
+ .convert_to_typed_array(MATCHER_TYPENAME)
.map_err(|err| Error::ConfigDeserialization(err.into()))?;
Ok(Bus {
endpoints,
- groups,
- filters,
+ matchers,
})
}
@@ -330,77 +353,33 @@ impl Bus {
}
#[cfg(test)]
- pub fn add_group(&mut self, group: GroupConfig) {
- self.groups.insert(group.name.clone(), group);
- }
-
- #[cfg(test)]
- pub fn add_filter(&mut self, filter: FilterConfig) {
- self.filters.push(filter)
+ pub fn add_matcher(&mut self, filter: MatcherConfig) {
+ self.matchers.push(filter)
}
- /// Send a notification to a given target (endpoint or group).
+ /// Send a notification. Notification matchers will determine which targets will receive
+ /// the notification.
///
/// Any errors will not be returned but only logged.
- pub fn send(&self, endpoint_or_group: &str, notification: &Notification) {
- let mut filter_matcher = FilterMatcher::new(&self.filters, notification);
-
- if let Some(group) = self.groups.get(endpoint_or_group) {
- if !Bus::check_filter(&mut filter_matcher, group.filter.as_deref()) {
- log::info!("skipped target '{endpoint_or_group}', filter did not match");
- return;
- }
-
- log::info!("target '{endpoint_or_group}' is a group, notifying all members...");
-
- for endpoint in &group.endpoint {
- self.send_via_single_endpoint(endpoint, notification, &mut filter_matcher);
- }
- } else {
- self.send_via_single_endpoint(endpoint_or_group, notification, &mut filter_matcher);
- }
- }
+ pub fn send(&self, notification: &Notification) {
+ let targets = matcher::check_matches(self.matchers.as_slice(), notification);
- fn check_filter(filter_matcher: &mut FilterMatcher, filter: Option<&str>) -> bool {
- if let Some(filter) = filter {
- match filter_matcher.check_filter_match(filter) {
- // If the filter does not match, do nothing
- Ok(r) => r,
- Err(err) => {
- // If there is an error, only log it and still send
- log::error!("could not apply filter '{filter}': {err}");
- true
- }
- }
- } else {
- true
- }
- }
-
- fn send_via_single_endpoint(
- &self,
- endpoint: &str,
- notification: &Notification,
- filter_matcher: &mut FilterMatcher,
- ) {
- if let Some(endpoint) = self.endpoints.get(endpoint) {
- let name = endpoint.name();
- if !Bus::check_filter(filter_matcher, endpoint.filter()) {
- log::info!("skipped target '{name}', filter did not match");
- return;
- }
+ for target in targets {
+ if let Some(endpoint) = self.endpoints.get(target) {
+ let name = endpoint.name();
- match endpoint.send(notification) {
- Ok(_) => {
- log::info!("notified via target `{name}`");
- }
- Err(e) => {
- // Only log on errors, do not propagate fail to the caller.
- log::error!("could not notify via target `{name}`: {e}");
+ match endpoint.send(notification) {
+ Ok(_) => {
+ log::info!("notified via target `{name}`");
+ }
+ Err(e) => {
+ // Only log on errors, do not propagate fail to the caller.
+ log::error!("could not notify via target `{name}`: {e}");
+ }
}
+ } else {
+ log::error!("could not notify via target '{target}', it does not exist");
}
- } else {
- log::error!("could not notify via target '{endpoint}', it does not exist");
}
}
@@ -410,7 +389,11 @@ impl Bus {
/// any errors to the caller.
pub fn test_target(&self, target: &str) -> Result<(), Error> {
let notification = Notification {
- severity: Severity::Info,
+ metadata: Metadata {
+ severity: Severity::Info,
+ // TODO: what fields would make sense for test notifications?
+ additional_fields: Default::default(),
+ },
content: Content::Template {
title_template: "Test notification".into(),
body_template: "This is a test of the notification target '{{ target }}'".into(),
@@ -418,29 +401,10 @@ impl Bus {
},
};
- let mut errors: Vec<Box<dyn StdError + Send + Sync>> = Vec::new();
-
- let mut my_send = |target: &str| -> Result<(), Error> {
- if let Some(endpoint) = self.endpoints.get(target) {
- if let Err(e) = endpoint.send(¬ification) {
- errors.push(Box::new(e));
- }
- } else {
- return Err(Error::TargetDoesNotExist(target.to_string()));
- }
- Ok(())
- };
-
- if let Some(group) = self.groups.get(target) {
- for endpoint_name in &group.endpoint {
- my_send(endpoint_name)?;
- }
+ if let Some(endpoint) = self.endpoints.get(target) {
+ endpoint.send(¬ification)?;
} else {
- my_send(target)?;
- }
-
- if !errors.is_empty() {
- return Err(Error::TargetTestFailed(errors));
+ return Err(Error::TargetDoesNotExist(target.to_string()));
}
Ok(())
@@ -459,7 +423,6 @@ mod tests {
// Needs to be an Rc so that we can clone MockEndpoint before
// passing it to Bus, while still retaining a handle to the Vec
messages: Rc<RefCell<Vec<Notification>>>,
- filter: Option<String>,
}
impl Endpoint for MockEndpoint {
@@ -472,17 +435,12 @@ mod tests {
fn name(&self) -> &str {
self.name
}
-
- fn filter(&self) -> Option<&str> {
- self.filter.as_deref()
- }
}
impl MockEndpoint {
- fn new(name: &'static str, filter: Option<String>) -> Self {
+ fn new(name: &'static str) -> Self {
Self {
name,
- filter,
..Default::default()
}
}
@@ -494,113 +452,66 @@ mod tests {
#[test]
fn test_add_mock_endpoint() -> Result<(), Error> {
- let mock = MockEndpoint::new("endpoint", None);
+ let mock = MockEndpoint::new("endpoint");
let mut bus = Bus::default();
bus.add_endpoint(Box::new(mock.clone()));
- // Send directly to endpoint
- bus.send(
- "endpoint",
- &Notification::new_templated(Severity::Info, "Title", "Body", Default::default()),
- );
- let messages = mock.messages();
- assert_eq!(messages.len(), 1);
-
- Ok(())
- }
-
- #[test]
- fn test_groups() -> Result<(), Error> {
- let endpoint1 = MockEndpoint::new("mock1", None);
- let endpoint2 = MockEndpoint::new("mock2", None);
-
- let mut bus = Bus::default();
-
- bus.add_group(GroupConfig {
- name: "group1".to_string(),
- endpoint: vec!["mock1".into()],
- comment: None,
- filter: None,
- });
-
- bus.add_group(GroupConfig {
- name: "group2".to_string(),
- endpoint: vec!["mock2".into()],
- comment: None,
- filter: None,
- });
-
- bus.add_endpoint(Box::new(endpoint1.clone()));
- bus.add_endpoint(Box::new(endpoint2.clone()));
-
- let send_to_group = |channel| {
- let notification =
- Notification::new_templated(Severity::Info, "Title", "Body", Default::default());
- bus.send(channel, ¬ification)
+ let matcher = MatcherConfig {
+ target: Some(vec!["endpoint".into()]),
+ ..Default::default()
};
- send_to_group("group1");
- assert_eq!(endpoint1.messages().len(), 1);
- assert_eq!(endpoint2.messages().len(), 0);
+ bus.add_matcher(matcher);
- send_to_group("group2");
- assert_eq!(endpoint1.messages().len(), 1);
- assert_eq!(endpoint2.messages().len(), 1);
+ // Send directly to endpoint
+ bus.send(&Notification::new_templated(
+ Severity::Info,
+ "Title",
+ "Body",
+ Default::default(),
+ Default::default(),
+ ));
+ let messages = mock.messages();
+ assert_eq!(messages.len(), 1);
Ok(())
}
#[test]
- fn test_severity_ordering() {
- // Not intended to be exhaustive, just a quick
- // sanity check ;)
-
- assert!(Severity::Info < Severity::Notice);
- assert!(Severity::Info < Severity::Warning);
- assert!(Severity::Info < Severity::Error);
- assert!(Severity::Error > Severity::Warning);
- assert!(Severity::Warning > Severity::Notice);
- }
-
- #[test]
- fn test_multiple_endpoints_with_different_filters() -> Result<(), Error> {
- let endpoint1 = MockEndpoint::new("mock1", Some("filter1".into()));
- let endpoint2 = MockEndpoint::new("mock2", Some("filter2".into()));
+ fn test_multiple_endpoints_with_different_matchers() -> Result<(), Error> {
+ let endpoint1 = MockEndpoint::new("mock1");
+ let endpoint2 = MockEndpoint::new("mock2");
let mut bus = Bus::default();
bus.add_endpoint(Box::new(endpoint1.clone()));
bus.add_endpoint(Box::new(endpoint2.clone()));
- bus.add_group(GroupConfig {
- name: "channel1".to_string(),
- endpoint: vec!["mock1".into(), "mock2".into()],
- comment: None,
- filter: None,
+ bus.add_matcher(MatcherConfig {
+ name: "matcher1".into(),
+ match_severity: Some(vec!["warning,error".parse()?]),
+ target: Some(vec!["mock1".into()]),
+ ..Default::default()
});
- bus.add_filter(FilterConfig {
- name: "filter1".into(),
- min_severity: Some(Severity::Warning),
- mode: None,
- invert_match: None,
- comment: None,
- });
-
- bus.add_filter(FilterConfig {
- name: "filter2".into(),
- min_severity: Some(Severity::Error),
- mode: None,
- invert_match: None,
- comment: None,
+ bus.add_matcher(MatcherConfig {
+ name: "matcher2".into(),
+ match_severity: Some(vec!["error".parse()?]),
+ target: Some(vec!["mock2".into()]),
+ ..Default::default()
});
let send_with_severity = |severity| {
- let notification =
- Notification::new_templated(severity, "Title", "Body", Default::default());
+ let notification = Notification::new_templated(
+ severity,
+ "Title",
+ "Body",
+ Default::default(),
+ Default::default(),
+ );
- bus.send("channel1", ¬ification);
+ bus.send(¬ification);
};
send_with_severity(Severity::Info);
diff --git a/proxmox-notify/src/matcher.rs b/proxmox-notify/src/matcher.rs
new file mode 100644
index 0000000..c24726d
--- /dev/null
+++ b/proxmox-notify/src/matcher.rs
@@ -0,0 +1,395 @@
+use regex::Regex;
+use std::collections::HashSet;
+use std::fmt;
+use std::fmt::Debug;
+use std::str::FromStr;
+
+use serde::{Deserialize, Serialize};
+
+use proxmox_schema::api_types::COMMENT_SCHEMA;
+use proxmox_schema::{
+ api, const_regex, ApiStringFormat, Schema, StringSchema, Updater, SAFE_ID_REGEX_STR,
+};
+
+use crate::schema::ENTITY_NAME_SCHEMA;
+use crate::{Error, Notification, Severity};
+
+pub const MATCHER_TYPENAME: &str = "matcher";
+
+#[api]
+#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)]
+#[serde(rename_all = "kebab-case")]
+pub enum MatchModeOperator {
+ /// All match statements have to match (AND)
+ #[default]
+ All,
+ /// At least one filter property has to match (OR)
+ 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,
+ }
+ }
+}
+
+const_regex! {
+ pub MATCH_FIELD_ENTRY_REGEX = concat!(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: {
+ schema: ENTITY_NAME_SCHEMA,
+ },
+ comment: {
+ optional: true,
+ schema: COMMENT_SCHEMA,
+ },
+ "match-field": {
+ type: Array,
+ items: {
+ description: "Fields to match",
+ type: String
+ },
+ optional: true,
+ },
+ "match-severity": {
+ type: Array,
+ items: {
+ description: "Severity level to match.",
+ type: String
+ },
+ optional: true,
+ },
+ "target": {
+ type: Array,
+ items: {
+ schema: ENTITY_NAME_SCHEMA,
+ },
+ optional: true,
+ },
+ })]
+#[derive(Debug, Serialize, Deserialize, Updater, Default)]
+#[serde(rename_all = "kebab-case")]
+/// Config for Sendmail notification endpoints
+pub struct MatcherConfig {
+ /// Name of the matcher
+ #[updater(skip)]
+ pub name: String,
+
+ /// List of matched metadata fields
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub match_field: Option<Vec<FieldMatcher>>,
+
+ /// List of matched severity levels
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub match_severity: Option<Vec<SeverityMatcher>>,
+
+ /// Decide if 'all' or 'any' match statements must match
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub mode: Option<MatchModeOperator>,
+
+ /// Invert match of the whole filter
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub invert_match: Option<bool>,
+
+ /// Targets to notify
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub target: Option<Vec<String>>,
+
+ /// Comment
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub comment: Option<String>,
+}
+
+#[derive(Clone, Debug)]
+pub enum FieldMatcher {
+ Exact {
+ field: String,
+ matched_value: String,
+ },
+ Regex {
+ field: String,
+ matched_regex: Regex,
+ },
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher);
+proxmox_serde::forward_serialize_to_display!(FieldMatcher);
+
+impl FieldMatcher {
+ fn matches(&self, notification: &Notification) -> bool {
+ match self {
+ FieldMatcher::Exact {
+ field,
+ matched_value,
+ } => {
+ let value = notification.metadata.additional_fields.get(field);
+
+ if let Some(value) = value {
+ matched_value == 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_value,
+ } => {
+ write!(f, "exact:{field}={matched_value}")
+ }
+ 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_value)) => Ok(Self::Exact {
+ field: field.into(),
+ matched_value: expected_value.into(),
+ }),
+ }
+ } 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();
+
+ let mut is_match = mode.neutral_element();
+ is_match = mode.apply(is_match, self.check_severity_match(notification));
+ is_match = mode.apply(is_match, self.check_field_match(notification)?);
+
+ let invert_match = self.invert_match.unwrap_or_default();
+
+ Ok(if is_match != invert_match {
+ Some(self.target.as_deref().unwrap_or_default())
+ } else {
+ None
+ })
+ }
+
+ fn check_field_match(&self, notification: &Notification) -> Result<bool, Error> {
+ let mode = self.mode.unwrap_or_default();
+ let mut is_match = mode.neutral_element();
+
+ if let Some(match_field) = self.match_field.as_deref() {
+ for field_matcher in match_field {
+ // let field_matcher: FieldMatcher = match_stmt.parse()?;
+ is_match = mode.apply(is_match, field_matcher.matches(notification));
+ }
+ }
+
+ Ok(is_match)
+ }
+
+ fn check_severity_match(&self, notification: &Notification) -> bool {
+ let mode = self.mode.unwrap_or_default();
+ let mut is_match = mode.neutral_element();
+
+ if let Some(matchers) = self.match_severity.as_ref() {
+ for severity_matcher in matchers {
+ is_match = mode.apply(is_match, severity_matcher.matches(notification));
+ }
+ }
+
+ is_match
+ }
+}
+#[derive(Clone, Debug)]
+pub struct SeverityMatcher {
+ severities: Vec<Severity>,
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(SeverityMatcher);
+proxmox_serde::forward_serialize_to_display!(SeverityMatcher);
+
+impl SeverityMatcher {
+ fn matches(&self, notification: &Notification) -> bool {
+ 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 })
+ }
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum DeleteableMatcherProperty {
+ MatchSeverity,
+ MatchField,
+ Target,
+ Mode,
+ InvertMatch,
+ Comment,
+}
+
+pub fn check_matches<'a>(
+ matchers: &'a [MatcherConfig],
+ notification: &Notification,
+) -> HashSet<&'a str> {
+ let mut targets = HashSet::new();
+
+ for matcher in matchers {
+ match matcher.matches(notification) {
+ Ok(t) => {
+ let t = t.unwrap_or_default();
+ targets.extend(t.iter().map(|s| s.as_str()));
+ }
+ Err(err) => log::error!("matcher '{matcher}' failed: {err}", matcher = matcher.name),
+ }
+ }
+
+ targets
+}
+
+#[cfg(test)]
+mod tests {
+ 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::new_templated(Severity::Notice, "test", "test", Value::Null, fields);
+
+ let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap();
+ assert!(matcher.matches(¬ification));
+
+ let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap();
+ assert!(matcher.matches(¬ification));
+
+ let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap();
+ assert!(!matcher.matches(¬ification));
+
+ assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
+ assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err());
+ }
+ #[test]
+ fn test_severities() {
+ let notification = Notification::new_templated(
+ Severity::Notice,
+ "test",
+ "test",
+ Value::Null,
+ Default::default(),
+ );
+
+ let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
+ assert!(matcher.matches(¬ification));
+ }
+}
diff --git a/proxmox-notify/src/schema.rs b/proxmox-notify/src/schema.rs
index fc6c46c..93347a5 100644
--- a/proxmox-notify/src/schema.rs
+++ b/proxmox-notify/src/schema.rs
@@ -19,9 +19,8 @@ pub const BACKEND_NAME_SCHEMA: Schema = StringSchema::new("Notification backend
.max_length(32)
.schema();
-pub const ENTITY_NAME_SCHEMA: Schema =
- StringSchema::new("Name schema for endpoints, filters and groups")
- .format(&SAFE_ID_FORMAT)
- .min_length(2)
- .max_length(32)
- .schema();
+pub const ENTITY_NAME_SCHEMA: Schema = StringSchema::new("Name schema for targets and matchers")
+ .format(&SAFE_ID_FORMAT)
+ .min_length(2)
+ .max_length(32)
+ .schema();
--
2.39.2
next prev parent reply other threads:[~2023-11-14 13:01 UTC|newest]
Thread overview: 65+ messages / expand[flat|nested] mbox.gz Atom feed top
2023-11-14 12:59 [pve-devel] [PATCH v2 many 00/52] revamp notifications; smtp endpoints; system mail Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 debcargo-conf 01/52] cherry-pick chumsky 0.9.2 from debian unstable Lukas Wagner
2023-11-14 16:15 ` [pve-devel] applied: " Thomas Lamprecht
2023-11-14 12:59 ` [pve-devel] [PATCH v2 debcargo-conf 02/52] update lettre to 0.11.1 Lukas Wagner
2023-11-14 16:15 ` [pve-devel] applied: " Thomas Lamprecht
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox 03/52] notify: introduce Error::Generic Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox 04/52] notify: factor out notification content into its own type Lukas Wagner
2023-11-14 12:59 ` Lukas Wagner [this message]
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox 06/52] notify: add calendar matcher Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox 07/52] notify: matcher: introduce common trait for match directives Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox 08/52] notify: let a matcher always match if it has no matching directives Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox 09/52] sys: email: add `forward` Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox 10/52] notify: add mechanisms for email message forwarding Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox 11/52] notify: add PVE/PBS context Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox 12/52] notify: add 'smtp' endpoint Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox 13/52] notify: add api for smtp endpoints Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox 14/52] notify: add 'disable' parameter for matchers and targets Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox 15/52] notify: add built-in config and 'origin' parameter Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-perl-rs 16/52] notify: adapt to new matcher-based notification routing Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-perl-rs 17/52] notify: add bindings for smtp API calls Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-perl-rs 18/52] pve-rs: notify: remove notify_context for PVE Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-perl-rs 19/52] notify: add 'disable' parameter Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-perl-rs 20/52] notify: support 'origin' paramter Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-cluster 21/52] notify: adapt to matcher based notification system Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-guest-common 22/52] vzdump: deprecate mailto/mailnotification/notification-{target, policy} Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-ha-manager 23/52] env: switch to matcher-based notification system Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-manager 24/52] api: notification: remove notification groups Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-manager 25/52] api: notification: add new matcher-based notification API Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-manager 26/52] ui: dc: remove unneeded notification events panel Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-manager 27/52] vzdump: adapt to new matcher based notification system Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-manager 28/52] api: apt: adapt to matcher-based notifications Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-manager 29/52] api: replication: adapt to matcher-based notification system Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-manager 30/52] test: fix vzdump notification test Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-manager 31/52] ui: vzdump: remove left-overs from target/policy based notifications Lukas Wagner
2023-11-16 11:52 ` [pve-devel] [PATCH manager] ui: fix backup job create Dominik Csapak
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-manager 32/52] ui: dc: config: show notification panel again Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-manager 33/52] notify: add API routes for smtp endpoints Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-manager 34/52] api: notification: add disable and origin params Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-manager 35/52] api: notification: simplify ACLs for notification Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-widget-toolkit 36/52] notification ui: add target selector for matcher Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-widget-toolkit 37/52] notification ui: remove filter setting for targets Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-widget-toolkit 38/52] notification ui: remove notification groups Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-widget-toolkit 39/52] notification ui: rename filter to matcher Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-widget-toolkit 40/52] notification: matcher: add UI for matcher editing Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-widget-toolkit 41/52] notification ui: unprotected mailto-root target Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-widget-toolkit 42/52] noficiation: matcher edit: make 'field' an editable combobox Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-widget-toolkit 43/52] panel: notification: add gui for SMTP endpoints Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-widget-toolkit 44/52] notification ui: add enable checkbox for targets/matchers Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-widget-toolkit 45/52] notification ui: add column for 'origin' Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-docs 46/52] notifications: update docs to for matcher-based notifications Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-docs 47/52] notifications: document SMTP endpoints Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-docs 48/52] notifications: document 'comment' option for targets/matchers Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-docs 49/52] notifications: add documentation for system mail forwarding Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 pve-docs 50/52] notifications: change to simplified ACL structure Lukas Wagner
2023-11-14 12:59 ` [pve-devel] [PATCH v2 proxmox-mail-forward 51/52] feed forwarded mails into proxmox_notify Lukas Wagner
2023-11-14 13:00 ` [pve-devel] [PATCH v2 proxmox-mail-forward 52/52] update d/control Lukas Wagner
2023-11-16 11:57 ` [pve-devel] [PATCH widget-toolkit 0/2] follow-ups for notification series Dominik Csapak
2023-11-16 11:57 ` [pve-devel] [PATCH widget-toolkit 1/2] notification matcher: improve handling empty and invalid values Dominik Csapak
2023-11-16 11:57 ` [pve-devel] [PATCH widget-toolkit 2/2] notification matcher: improve wording for mode Dominik Csapak
2023-11-17 8:41 ` [pve-devel] [PATCH v2 many 00/52] revamp notifications; smtp endpoints; system mail Dominik Csapak
2023-11-20 9:03 ` Lukas Wagner
2023-11-20 9:11 ` Dominik Csapak
2023-11-20 9:49 ` Lukas Wagner
2023-11-20 9:11 ` Thomas Lamprecht
2023-11-17 15:31 ` [pve-devel] applied-series: " Thomas Lamprecht
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20231114130000.565122-6-l.wagner@proxmox.com \
--to=l.wagner@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox