public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module
@ 2023-05-24 13:56 Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 01/42] add `proxmox-human-byte` crate Lukas Wagner
                   ` (42 more replies)
  0 siblings, 43 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

The purpose of this patch series is to overhaul the existing mail
notification infrastructure in Proxmox VE.
The series replaces calls to 'sendmail' with calls to a
new, configurable notification module. The module was designed to
support multiple notification endpoints, 'sendmail' using the system's
sendmail command being the first one. As a proof of the extensibility
of the current approach, the 'gotify' [1] plugin was also implemented
in this series.

Concepts:
  - Endpoints: 
    An endpoint is responsible for sending a notification to some external
    entity, e.g. by calling `sendmail` to send a mail, or by performing REST
    API calls to a gotify server.
    Currently, there are two types of endpoints, `sendmail` and 
    `gotify`.

  - Channels:
    Logically, channel can be thought of as a 'group of endpoints'. Each
    endpoint can be included in one or more channels. If one is using the 
    notification API to send a notification, a channel has to be specified. 
    The notification will then be forwarded to all endpoints included in that
    channel.
    Logically they decouple endpoints from notification senders - for instance,
    a backup job configuration would need to contain references to potentially
    multiple  endpoints, or, a alternatively, always notify via *all* endpoints. 
    The latter would potentially shift more configuration effort to filters, for
    instance if some backup jobs should only notify via *some* endpoints.
    I think the group/channel-based approach provides a relatively nice middle
    ground.

  - Filters:
    Every endpoint can also have a filter. Filters allow filtering
    notifications based on severity (info, notice, warning, error) or
    notification properties (metadata included in a notification, they are
    also the base for the template rendering).
    Filters allow AND/OR/NOT conditions and using sub-filters to allow
    arbitrarily complex filter structures.

Conceptually, the new notification backend consists of three separate parts:
  - A new `proxmox-notify` crate, implemented in Rust. The crate contains 
    the endpoint/filter/channel implementations, configuration parsing/writing
    (passed in/out as a string), template rendering, etc.

  - Glue code in `proxmox-perl-rs`, in order to be able to make calls to the 
    `proxmox-notify` crate from Perl

  - A light-weight wrapper module `PVE::Notify`, implemented in Perl and
    living in `pve-manager` for now. It provides some helper functions and 
    is responsible for reading/writing the configuration files, passing the 
    configuration to the Rust part as a string.

As of now, there were four different event sources:
  - Backup Jobs/One-off backups
  - APT update notifications
  - Replication failures
  - Node Fencing

As a part of this patch series, all four were switched over to use the new
`PVE::Notify` package to send notifications.
For backup jobs, it is now possible to choose between 'E-Mail' or 
'channel-based' notifications. 
This was done so that 
  - we don't break existing configurations where the `mailto` option is set
  - there is a shortcut in case somebody really only ever cares about email
    notifications.
Under the hood, both use the new notification backend. The 'E-Mail' option 
simply creates a temporary channel as well a temporary 'sendmail' endpoint.

Since there is no way to configure endpoints/channels from the GUI yet,
the control field for backup jobs where one can choose between
"E-Mail" and "Channel" based notifications is disabled right now and always
set to email. IMO it felt a bit weird being able to select a notification
without being able to create/configure one from the GUI.

APT/Replication/Node fencing do not yet have a way to configure a notification
channel, so they use the same 'E-Mail' approach, sending mails to `root` via
a temporary channel.

Follow-up work (in no particular order):
  - Documentation (once the current approach has been approved)
  - Add a GUI/CLI for managing channels/endpoints, later also filters
  - Allow configuring a notification channel for APT/Repl/Fencing
  - In the future, the API might be changed/extended so that supports
    "registering" notifications. This allows us to a.) generate a
    list of all possible notification sources in the system b.) allows
    users to easily create filters for specific notification events.
    In my head, using the notification module could look like this
    then:

    # Global context
    my backup_failed_notification = PVE::Notify::register({
      'id' => 'backup-failed',
      'severity' => 'error',
      'properties' => ['host', 'vmlist', 'logs'],
      'title' => '{{ host }}: Backup failed'
      'body' => <<'EOF'
    A backup has failed for the following VMs: {{ vmlist }}

    {{ logs }}
    EOF
    });

    # Later, to send the notification:
    PVE::Notify::send(backup_failed_notification->instantiate({
      'host' => 'earth',
      'vmlist' => ... ,
      'logs' => ... ,
    }));

  - proxmox-mail-forward could be integrated as well. This would feed
    e.g. zfs-zed events into our notification infrastructure. Special
    care must be taken to not create recursive notification loops
    (e.g. zed sends to root, forwarder uses notification module, a
    configured sendmail endpoint sends to root, forwarder uses module
    --> loop)

  - Maybe add some CLI so that admins can send notifications in
    scripts (an API endpoint callable via pvesh might be enough for a
    start). This should be done once everything is sufficiently stable 
    (e.g. templating helpers, etc.)

  - Add more notification events
  - Add other endpoints, e.g. webhook, a generic SMTP, etc.
  - Integrate the new module into the other products

[1] https://gotify.net/
[2] https://bugzilla.proxmox.com/show_bug.cgi?id=4526

Changes from v1:
  - Some renaming:
    - PVE::Notification -> PVE::Notify
    - proxmox-notification -> proxmox-notify
  - Split configuration for gotify endpoints into a public part in
    `notifications.cfg` and a private part for the token in 
    `priv/notifications.cfg`
  - Add template-based notification rendering (`proxmox`), including helpers 
    for: 
    - tables
    - pretty printed JSON
    - duration, timestamps
    - byte sizes
  - Add notification channels (repo `proxmox`)
  - Add API routes for channels, endpoints, filters (implementation in 
    `proxmox-notify`, glue code in `proxmox-perl-rs` and handler in 
    `pve-manager`)
  - Integrated new notification channels in backup jobs/one-off backups (repo 
    `pve-manager`)
  - Replication/APT/Fencing use an 'anonymous' channel with a temporary 
    sendmail endpoint, sending mails to `root`
  - Added new options for backup jobs
  - Reworked git history

Versions of this patch series:
v1: https://lists.proxmox.com/pipermail/pve-devel/2023-March/056445.html


proxmox:

Lukas Wagner (17):
  add `proxmox-human-byte` crate
  human-byte: move tests to their own sub-module
  add proxmox-notify crate
  notify: add debian packaging
  notify: preparation for the first endpoint plugin
  notify: preparation for the API
  notify: api: add API for sending notifications/testing endpoints
  notify: add notification channels
  notify: api: add API for channels
  notify: add sendmail plugin
  notify: api: add API for sendmail endpoints
  notify: add gotify endpoint
  notify: api: add API for gotify endpoints
  notify: add notification filter mechanism
  notify: api: add API for filters
  notify: add template rendering
  notify: add example for template rendering

 Cargo.toml                               |   4 +
 proxmox-human-byte/Cargo.toml            |  15 +
 proxmox-human-byte/debian/changelog      |   5 +
 proxmox-human-byte/debian/control        |  43 ++
 proxmox-human-byte/debian/copyright      |  16 +
 proxmox-human-byte/debian/debcargo.toml  |   7 +
 proxmox-human-byte/src/lib.rs            | 363 +++++++++++++++
 proxmox-notify/Cargo.toml                |  28 ++
 proxmox-notify/debian/changelog          |   5 +
 proxmox-notify/debian/control            |  31 ++
 proxmox-notify/debian/copyright          |  16 +
 proxmox-notify/debian/debcargo.toml      |   7 +
 proxmox-notify/examples/render.rs        |  63 +++
 proxmox-notify/src/api/channel.rs        | 253 ++++++++++
 proxmox-notify/src/api/common.rs         |  46 ++
 proxmox-notify/src/api/filter.rs         | 366 +++++++++++++++
 proxmox-notify/src/api/gotify.rs         | 294 ++++++++++++
 proxmox-notify/src/api/mod.rs            | 111 +++++
 proxmox-notify/src/api/sendmail.rs       | 263 +++++++++++
 proxmox-notify/src/channel.rs            |  53 +++
 proxmox-notify/src/config.rs             | 103 ++++
 proxmox-notify/src/endpoints/gotify.rs   | 139 ++++++
 proxmox-notify/src/endpoints/mod.rs      |   4 +
 proxmox-notify/src/endpoints/sendmail.rs | 106 +++++
 proxmox-notify/src/filter.rs             | 498 ++++++++++++++++++++
 proxmox-notify/src/lib.rs                | 567 +++++++++++++++++++++++
 proxmox-notify/src/renderer/html.rs      | 100 ++++
 proxmox-notify/src/renderer/mod.rs       | 359 ++++++++++++++
 proxmox-notify/src/renderer/plaintext.rs | 141 ++++++
 proxmox-notify/src/renderer/table.rs     |  24 +
 proxmox-notify/src/schema.rs             |  43 ++
 31 files changed, 4073 insertions(+)
 create mode 100644 proxmox-human-byte/Cargo.toml
 create mode 100644 proxmox-human-byte/debian/changelog
 create mode 100644 proxmox-human-byte/debian/control
 create mode 100644 proxmox-human-byte/debian/copyright
 create mode 100644 proxmox-human-byte/debian/debcargo.toml
 create mode 100644 proxmox-human-byte/src/lib.rs
 create mode 100644 proxmox-notify/Cargo.toml
 create mode 100644 proxmox-notify/debian/changelog
 create mode 100644 proxmox-notify/debian/control
 create mode 100644 proxmox-notify/debian/copyright
 create mode 100644 proxmox-notify/debian/debcargo.toml
 create mode 100644 proxmox-notify/examples/render.rs
 create mode 100644 proxmox-notify/src/api/channel.rs
 create mode 100644 proxmox-notify/src/api/common.rs
 create mode 100644 proxmox-notify/src/api/filter.rs
 create mode 100644 proxmox-notify/src/api/gotify.rs
 create mode 100644 proxmox-notify/src/api/mod.rs
 create mode 100644 proxmox-notify/src/api/sendmail.rs
 create mode 100644 proxmox-notify/src/channel.rs
 create mode 100644 proxmox-notify/src/config.rs
 create mode 100644 proxmox-notify/src/endpoints/gotify.rs
 create mode 100644 proxmox-notify/src/endpoints/mod.rs
 create mode 100644 proxmox-notify/src/endpoints/sendmail.rs
 create mode 100644 proxmox-notify/src/filter.rs
 create mode 100644 proxmox-notify/src/lib.rs
 create mode 100644 proxmox-notify/src/renderer/html.rs
 create mode 100644 proxmox-notify/src/renderer/mod.rs
 create mode 100644 proxmox-notify/src/renderer/plaintext.rs
 create mode 100644 proxmox-notify/src/renderer/table.rs
 create mode 100644 proxmox-notify/src/schema.rs


proxmox-perl-rs:

Lukas Wagner (7):
  log: set default log level to 'info', add product specific logging env
    var
  add PVE::RS::Notify module
  notify: add api for sending notifications/testing endpoints
  notify: add api for notification channels
  notify: add api for sendmail endpoints
  notify: add api for gotify endpoints
  notify: add api for notification filters

 common/src/logger.rs |  12 +-
 pmg-rs/src/lib.rs    |   2 +-
 pve-rs/Cargo.toml    |   1 +
 pve-rs/Makefile      |   1 +
 pve-rs/src/lib.rs    |   3 +-
 pve-rs/src/notify.rs | 411 +++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 426 insertions(+), 4 deletions(-)
 create mode 100644 pve-rs/src/notify.rs


pve-cluster:

Lukas Wagner (1):
  cluster files: add notifications.cfg

 src/PVE/Cluster.pm  | 2 ++
 src/pmxcfs/status.c | 2 ++
 2 files changed, 4 insertions(+)


pve-guest-common:

Lukas Wagner (1):
  vzdump: add config options for new notification backend

 src/PVE/VZDump/Common.pm | 28 ++++++++++++++++++++++++++--
 1 file changed, 26 insertions(+), 2 deletions(-)


pve-manager:

Lukas Wagner (15):
  test: fix names of .PHONY targets
  add PVE::Notify module
  vzdump: send notifications via new notification module
  test: rename mail_test.pl to vzdump_notification_test.pl
  api: apt: send notification via new notification module
  api: replication: send notifications via new notification module
  ui: backup: allow to select notification channel for notifications
  ui: backup: adapt backup job details to new notification params
  ui: backup: allow to set notification-{channel,mode} for one-off
    backups
  api: prepare api handler module for notification config
  api: add api routes for notification channels
  api: add api routes for sendmail endpoints
  api: add api routes for gotify endpoints
  api: add api routes for notification filters
  ui: backup: disable notification mode selector for now

 PVE/API2/APT.pm                               |   73 +-
 PVE/API2/Cluster.pm                           |    7 +
 PVE/API2/Cluster/Makefile                     |    1 +
 PVE/API2/Cluster/Notifications.pm             | 1262 +++++++++++++++++
 PVE/API2/Replication.pm                       |   75 +-
 PVE/API2/VZDump.pm                            |    2 +-
 PVE/Makefile                                  |    1 +
 PVE/Notify.pm                                 |   84 ++
 PVE/VZDump.pm                                 |  323 +++--
 test/Makefile                                 |   16 +-
 ...il_test.pl => vzdump_notification_test.pl} |   36 +-
 www/manager6/Makefile                         |    4 +-
 www/manager6/dc/Backup.js                     |   78 +-
 www/manager6/dc/BackupJobDetail.js            |   24 +-
 .../form/NotificationChannelSelector.js       |   47 +
 www/manager6/form/NotificationModeSelector.js |    8 +
 ...ector.js => NotificationPolicySelector.js} |    1 +
 www/manager6/window/Backup.js                 |   35 +-
 18 files changed, 1863 insertions(+), 214 deletions(-)
 create mode 100644 PVE/API2/Cluster/Notifications.pm
 create mode 100644 PVE/Notify.pm
 rename test/{mail_test.pl => vzdump_notification_test.pl} (62%)
 create mode 100644 www/manager6/form/NotificationChannelSelector.js
 create mode 100644 www/manager6/form/NotificationModeSelector.js
 rename www/manager6/form/{EmailNotificationSelector.js => NotificationPolicySelector.js} (87%)


pve-ha-manager:

Lukas Wagner (1):
  manager: send notifications via new notification module

 src/PVE/HA/Env.pm        |  6 ++---
 src/PVE/HA/Env/PVE2.pm   | 27 ++++++++++++++++++---
 src/PVE/HA/NodeStatus.pm | 52 ++++++++++++++++++++++++----------------
 src/PVE/HA/Sim/Env.pm    | 10 ++++++--
 4 files changed, 66 insertions(+), 29 deletions(-)


Summary over all repositories:
  62 files changed, 6458 insertions(+), 249 deletions(-)

Generated by murpp v0.3.0
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 01/42] add `proxmox-human-byte` crate
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-06-26 11:58   ` Wolfgang Bumiller
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 02/42] human-byte: move tests to their own sub-module Lukas Wagner
                   ` (41 subsequent siblings)
  42 siblings, 1 reply; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

The module previously lived in `pbs-api-types`, however turned out to
be useful in other places as well (POM, proxmox-notify), so it is moved
here as its own micro-crate.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---

Notes:
    Already posted to pbs-devel, but included here as well since we
    depend on it and it has not yet been applied.
    
    https://lists.proxmox.com/pipermail/pbs-devel/2023-May/006126.html

 Cargo.toml                              |   1 +
 proxmox-human-byte/Cargo.toml           |  15 +
 proxmox-human-byte/debian/changelog     |   5 +
 proxmox-human-byte/debian/control       |  43 +++
 proxmox-human-byte/debian/copyright     |  16 ++
 proxmox-human-byte/debian/debcargo.toml |   7 +
 proxmox-human-byte/src/lib.rs           | 358 ++++++++++++++++++++++++
 7 files changed, 445 insertions(+)
 create mode 100644 proxmox-human-byte/Cargo.toml
 create mode 100644 proxmox-human-byte/debian/changelog
 create mode 100644 proxmox-human-byte/debian/control
 create mode 100644 proxmox-human-byte/debian/copyright
 create mode 100644 proxmox-human-byte/debian/debcargo.toml
 create mode 100644 proxmox-human-byte/src/lib.rs

diff --git a/Cargo.toml b/Cargo.toml
index 3f3db69c..35e67ad4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,6 +7,7 @@ members = [
     "proxmox-borrow",
     "proxmox-compression",
     "proxmox-http",
+    "proxmox-human-byte",
     "proxmox-io",
     "proxmox-lang",
     "proxmox-ldap",
diff --git a/proxmox-human-byte/Cargo.toml b/proxmox-human-byte/Cargo.toml
new file mode 100644
index 00000000..4cdbe6c0
--- /dev/null
+++ b/proxmox-human-byte/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "proxmox-human-byte"
+version = "0.1.0"
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+exclude.workspace = true
+description = "Proxmox library for formatting byte sizes (IEC or SI)"
+
+[dependencies]
+anyhow.workspace = true
+proxmox-schema = { workspace = true, features = ["api-macro"]}
+proxmox-serde.workspace = true
+serde.workspace = true
diff --git a/proxmox-human-byte/debian/changelog b/proxmox-human-byte/debian/changelog
new file mode 100644
index 00000000..7f7603b5
--- /dev/null
+++ b/proxmox-human-byte/debian/changelog
@@ -0,0 +1,5 @@
+rust-proxmox-human-byte (0.1.0-1) stable; urgency=medium
+
+  * Initial release.
+
+ --  Proxmox Support Team <support@proxmox.com>  Thu, 12 Jan 2023 11:42:11 +0200
diff --git a/proxmox-human-byte/debian/control b/proxmox-human-byte/debian/control
new file mode 100644
index 00000000..6aae2a51
--- /dev/null
+++ b/proxmox-human-byte/debian/control
@@ -0,0 +1,43 @@
+Source: rust-proxmox-human-byte
+Section: rust
+Priority: optional
+Build-Depends: debhelper (>= 12),
+ dh-cargo (>= 25),
+ cargo:native <!nocheck>,
+ rustc:native <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-anyhow-1+default-dev <!nocheck>,
+ librust-proxmox-schema-1+api-macro-dev (>= 1.3.7-~~) <!nocheck>,
+ librust-proxmox-schema-1+default-dev (>= 1.3.7-~~) <!nocheck>,
+ librust-proxmox-serde-0.1+default-dev (>= 0.1.1-~~) <!nocheck>,
+ librust-proxmox-serde-0.1+serde-json-dev (>= 0.1.1-~~) <!nocheck>,
+ librust-serde-1+default-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.6.1
+Vcs-Git: git://git.proxmox.com/git/proxmox.git
+Vcs-Browser: https://git.proxmox.com/?p=proxmox.git
+X-Cargo-Crate: proxmox-human-byte
+Rules-Requires-Root: no
+
+Package: librust-proxmox-human-byte-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-anyhow-1+default-dev,
+ librust-proxmox-schema-1+api-macro-dev (>= 1.3.7-~~),
+ librust-proxmox-schema-1+default-dev (>= 1.3.7-~~),
+ librust-proxmox-serde-0.1+default-dev (>= 0.1.1-~~),
+ librust-proxmox-serde-0.1+serde-json-dev (>= 0.1.1-~~),
+ librust-serde-1+default-dev
+Provides:
+ librust-proxmox-human-byte+default-dev (= ${binary:Version}),
+ librust-proxmox-human-byte-0-dev (= ${binary:Version}),
+ librust-proxmox-human-byte-0+default-dev (= ${binary:Version}),
+ librust-proxmox-human-byte-0.1-dev (= ${binary:Version}),
+ librust-proxmox-human-byte-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-human-byte-0.1.0-dev (= ${binary:Version}),
+ librust-proxmox-human-byte-0.1.0+default-dev (= ${binary:Version})
+Description: Proxmox library for formatting byte sizes (IEC or SI) - Rust source code
+ This package contains the source for the Rust proxmox-human-byte crate,
+ packaged by debcargo for use with cargo and dh-cargo.
diff --git a/proxmox-human-byte/debian/copyright b/proxmox-human-byte/debian/copyright
new file mode 100644
index 00000000..4fce23a5
--- /dev/null
+++ b/proxmox-human-byte/debian/copyright
@@ -0,0 +1,16 @@
+Copyright (C) 2023 Proxmox Server Solutions GmbH
+
+This software is written by Proxmox Server Solutions GmbH <support@proxmox.com>
+
+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 <http://www.gnu.org/licenses/>.
diff --git a/proxmox-human-byte/debian/debcargo.toml b/proxmox-human-byte/debian/debcargo.toml
new file mode 100644
index 00000000..b7864cdb
--- /dev/null
+++ b/proxmox-human-byte/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-human-byte/src/lib.rs b/proxmox-human-byte/src/lib.rs
new file mode 100644
index 00000000..7be16a51
--- /dev/null
+++ b/proxmox-human-byte/src/lib.rs
@@ -0,0 +1,358 @@
+use anyhow::{bail, Error};
+
+use proxmox_schema::{ApiStringFormat, ApiType, Schema, StringSchema, UpdaterType};
+
+/// Size units for byte sizes
+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+pub enum SizeUnit {
+    Byte,
+    // SI (base 10)
+    KByte,
+    MByte,
+    GByte,
+    TByte,
+    PByte,
+    // IEC (base 2)
+    Kibi,
+    Mebi,
+    Gibi,
+    Tebi,
+    Pebi,
+}
+
+impl SizeUnit {
+    /// Returns the scaling factor
+    pub fn factor(&self) -> f64 {
+        match self {
+            SizeUnit::Byte => 1.0,
+            // SI (base 10)
+            SizeUnit::KByte => 1_000.0,
+            SizeUnit::MByte => 1_000_000.0,
+            SizeUnit::GByte => 1_000_000_000.0,
+            SizeUnit::TByte => 1_000_000_000_000.0,
+            SizeUnit::PByte => 1_000_000_000_000_000.0,
+            // IEC (base 2)
+            SizeUnit::Kibi => 1024.0,
+            SizeUnit::Mebi => 1024.0 * 1024.0,
+            SizeUnit::Gibi => 1024.0 * 1024.0 * 1024.0,
+            SizeUnit::Tebi => 1024.0 * 1024.0 * 1024.0 * 1024.0,
+            SizeUnit::Pebi => 1024.0 * 1024.0 * 1024.0 * 1024.0 * 1024.0,
+        }
+    }
+
+    /// gets the biggest possible unit still having a value greater zero before the decimal point
+    /// 'binary' specifies if IEC (base 2) units should be used or SI (base 10) ones
+    pub fn auto_scale(size: f64, binary: bool) -> SizeUnit {
+        if binary {
+            let bits = 64 - (size as u64).leading_zeros();
+            match bits {
+                51.. => SizeUnit::Pebi,
+                41..=50 => SizeUnit::Tebi,
+                31..=40 => SizeUnit::Gibi,
+                21..=30 => SizeUnit::Mebi,
+                11..=20 => SizeUnit::Kibi,
+                _ => SizeUnit::Byte,
+            }
+        } else if size >= 1_000_000_000_000_000.0 {
+            SizeUnit::PByte
+        } else if size >= 1_000_000_000_000.0 {
+            SizeUnit::TByte
+        } else if size >= 1_000_000_000.0 {
+            SizeUnit::GByte
+        } else if size >= 1_000_000.0 {
+            SizeUnit::MByte
+        } else if size >= 1_000.0 {
+            SizeUnit::KByte
+        } else {
+            SizeUnit::Byte
+        }
+    }
+}
+
+/// Returns the string representation
+impl std::fmt::Display for SizeUnit {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            SizeUnit::Byte => write!(f, "B"),
+            // SI (base 10)
+            SizeUnit::KByte => write!(f, "KB"),
+            SizeUnit::MByte => write!(f, "MB"),
+            SizeUnit::GByte => write!(f, "GB"),
+            SizeUnit::TByte => write!(f, "TB"),
+            SizeUnit::PByte => write!(f, "PB"),
+            // IEC (base 2)
+            SizeUnit::Kibi => write!(f, "KiB"),
+            SizeUnit::Mebi => write!(f, "MiB"),
+            SizeUnit::Gibi => write!(f, "GiB"),
+            SizeUnit::Tebi => write!(f, "TiB"),
+            SizeUnit::Pebi => write!(f, "PiB"),
+        }
+    }
+}
+
+/// Strips a trailing SizeUnit inclusive trailing whitespace
+/// Supports both IEC and SI based scales, the B/b byte symbol is optional.
+fn strip_unit(v: &str) -> (&str, SizeUnit) {
+    let v = v.strip_suffix(&['b', 'B'][..]).unwrap_or(v); // byte is implied anyway
+
+    let (v, binary) = match v.strip_suffix('i') {
+        Some(n) => (n, true),
+        None => (v, false),
+    };
+
+    let mut unit = SizeUnit::Byte;
+    #[rustfmt::skip]
+        let value = v.strip_suffix(|c: char| match c {
+        'k' | 'K' if !binary => { unit = SizeUnit::KByte; true }
+        'm' | 'M' if !binary => { unit = SizeUnit::MByte; true }
+        'g' | 'G' if !binary => { unit = SizeUnit::GByte; true }
+        't' | 'T' if !binary => { unit = SizeUnit::TByte; true }
+        'p' | 'P' if !binary => { unit = SizeUnit::PByte; true }
+        // binary (IEC recommended) variants
+        'k' | 'K' if binary => { unit = SizeUnit::Kibi; true }
+        'm' | 'M' if binary => { unit = SizeUnit::Mebi; true }
+        'g' | 'G' if binary => { unit = SizeUnit::Gibi; true }
+        't' | 'T' if binary => { unit = SizeUnit::Tebi; true }
+        'p' | 'P' if binary => { unit = SizeUnit::Pebi; true }
+        _ => false
+    }).unwrap_or(v).trim_end();
+
+    (value, unit)
+}
+
+/// Byte size which can be displayed in a human friendly way
+#[derive(Debug, Copy, Clone, UpdaterType, PartialEq)]
+pub struct HumanByte {
+    /// The siginficant value, it does not includes any factor of the `unit`
+    size: f64,
+    /// The scale/unit of the value
+    unit: SizeUnit,
+}
+
+fn verify_human_byte(s: &str) -> Result<(), Error> {
+    match s.parse::<HumanByte>() {
+        Ok(_) => Ok(()),
+        Err(err) => bail!("byte-size parse error for '{}': {}", s, err),
+    }
+}
+impl ApiType for HumanByte {
+    const API_SCHEMA: Schema = StringSchema::new(
+        "Byte size with optional unit (B, KB (base 10), MB, GB, ..., KiB (base 2), MiB, Gib, ...).",
+    )
+    .format(&ApiStringFormat::VerifyFn(verify_human_byte))
+    .min_length(1)
+    .max_length(64)
+    .schema();
+}
+
+impl HumanByte {
+    /// Create instance with size and unit (size must be positive)
+    pub fn with_unit(size: f64, unit: SizeUnit) -> Result<Self, Error> {
+        if size < 0.0 {
+            bail!("byte size may not be negative");
+        }
+        Ok(HumanByte { size, unit })
+    }
+
+    /// Create a new instance with optimal binary unit computed
+    pub fn new_binary(size: f64) -> Self {
+        let unit = SizeUnit::auto_scale(size, true);
+        HumanByte {
+            size: size / unit.factor(),
+            unit,
+        }
+    }
+
+    /// Create a new instance with optimal decimal unit computed
+    pub fn new_decimal(size: f64) -> Self {
+        let unit = SizeUnit::auto_scale(size, false);
+        HumanByte {
+            size: size / unit.factor(),
+            unit,
+        }
+    }
+
+    /// Returns the size as u64 number of bytes
+    pub fn as_u64(&self) -> u64 {
+        self.as_f64() as u64
+    }
+
+    /// Returns the size as f64 number of bytes
+    pub fn as_f64(&self) -> f64 {
+        self.size * self.unit.factor()
+    }
+
+    /// Returns a copy with optimal binary unit computed
+    pub fn auto_scale_binary(self) -> Self {
+        HumanByte::new_binary(self.as_f64())
+    }
+
+    /// Returns a copy with optimal decimal unit computed
+    pub fn auto_scale_decimal(self) -> Self {
+        HumanByte::new_decimal(self.as_f64())
+    }
+}
+
+impl From<u64> for HumanByte {
+    fn from(v: u64) -> Self {
+        HumanByte::new_binary(v as f64)
+    }
+}
+impl From<usize> for HumanByte {
+    fn from(v: usize) -> Self {
+        HumanByte::new_binary(v as f64)
+    }
+}
+
+impl std::fmt::Display for HumanByte {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let precision = f.precision().unwrap_or(3) as f64;
+        let precision_factor = 1.0 * 10.0_f64.powf(precision);
+        // this could cause loss of information, rust has sadly no shortest-max-X flt2dec fmt yet
+        let size = ((self.size * precision_factor).round()) / precision_factor;
+        write!(f, "{} {}", size, self.unit)
+    }
+}
+
+impl std::str::FromStr for HumanByte {
+    type Err = Error;
+
+    fn from_str(v: &str) -> Result<Self, Error> {
+        let (v, unit) = strip_unit(v);
+        HumanByte::with_unit(v.parse()?, unit)
+    }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(HumanByte);
+proxmox_serde::forward_serialize_to_display!(HumanByte);
+
+#[test]
+fn test_human_byte_parser() -> Result<(), Error> {
+    assert!("-10".parse::<HumanByte>().is_err()); // negative size
+
+    fn do_test(v: &str, size: f64, unit: SizeUnit, as_str: &str) -> Result<(), Error> {
+        let h: HumanByte = v.parse()?;
+
+        if h.size != size {
+            bail!("got unexpected size for '{}' ({} != {})", v, h.size, size);
+        }
+        if h.unit != unit {
+            bail!(
+                "got unexpected unit for '{}' ({:?} != {:?})",
+                v,
+                h.unit,
+                unit
+            );
+        }
+
+        let new = h.to_string();
+        if new != *as_str {
+            bail!("to_string failed for '{}' ({:?} != {:?})", v, new, as_str);
+        }
+        Ok(())
+    }
+    fn test(v: &str, size: f64, unit: SizeUnit, as_str: &str) -> bool {
+        match do_test(v, size, unit, as_str) {
+            Ok(_) => true,
+            Err(err) => {
+                eprintln!("{}", err); // makes debugging easier
+                false
+            }
+        }
+    }
+
+    assert!(test("14", 14.0, SizeUnit::Byte, "14 B"));
+    assert!(test("14.4", 14.4, SizeUnit::Byte, "14.4 B"));
+    assert!(test("14.45", 14.45, SizeUnit::Byte, "14.45 B"));
+    assert!(test("14.456", 14.456, SizeUnit::Byte, "14.456 B"));
+    assert!(test("14.4567", 14.4567, SizeUnit::Byte, "14.457 B"));
+
+    let h: HumanByte = "1.2345678".parse()?;
+    assert_eq!(&format!("{:.0}", h), "1 B");
+    assert_eq!(&format!("{:.0}", h.as_f64()), "1"); // use as_f64 to get raw bytes without unit
+    assert_eq!(&format!("{:.1}", h), "1.2 B");
+    assert_eq!(&format!("{:.2}", h), "1.23 B");
+    assert_eq!(&format!("{:.3}", h), "1.235 B");
+    assert_eq!(&format!("{:.4}", h), "1.2346 B");
+    assert_eq!(&format!("{:.5}", h), "1.23457 B");
+    assert_eq!(&format!("{:.6}", h), "1.234568 B");
+    assert_eq!(&format!("{:.7}", h), "1.2345678 B");
+    assert_eq!(&format!("{:.8}", h), "1.2345678 B");
+
+    assert!(test(
+        "987654321",
+        987654321.0,
+        SizeUnit::Byte,
+        "987654321 B"
+    ));
+
+    assert!(test("1300b", 1300.0, SizeUnit::Byte, "1300 B"));
+    assert!(test("1300B", 1300.0, SizeUnit::Byte, "1300 B"));
+    assert!(test("1300 B", 1300.0, SizeUnit::Byte, "1300 B"));
+    assert!(test("1300 b", 1300.0, SizeUnit::Byte, "1300 B"));
+
+    assert!(test("1.5KB", 1.5, SizeUnit::KByte, "1.5 KB"));
+    assert!(test("1.5kb", 1.5, SizeUnit::KByte, "1.5 KB"));
+    assert!(test("1.654321MB", 1.654_321, SizeUnit::MByte, "1.654 MB"));
+
+    assert!(test("2.0GB", 2.0, SizeUnit::GByte, "2 GB"));
+
+    assert!(test("1.4TB", 1.4, SizeUnit::TByte, "1.4 TB"));
+    assert!(test("1.4tb", 1.4, SizeUnit::TByte, "1.4 TB"));
+
+    assert!(test("2KiB", 2.0, SizeUnit::Kibi, "2 KiB"));
+    assert!(test("2Ki", 2.0, SizeUnit::Kibi, "2 KiB"));
+    assert!(test("2kib", 2.0, SizeUnit::Kibi, "2 KiB"));
+
+    assert!(test("2.3454MiB", 2.3454, SizeUnit::Mebi, "2.345 MiB"));
+    assert!(test("2.3456MiB", 2.3456, SizeUnit::Mebi, "2.346 MiB"));
+
+    assert!(test("4gib", 4.0, SizeUnit::Gibi, "4 GiB"));
+
+    Ok(())
+}
+
+#[test]
+fn test_human_byte_auto_unit_decimal() {
+    fn convert(b: u64) -> String {
+        HumanByte::new_decimal(b as f64).to_string()
+    }
+    assert_eq!(convert(987), "987 B");
+    assert_eq!(convert(1022), "1.022 KB");
+    assert_eq!(convert(9_000), "9 KB");
+    assert_eq!(convert(1_000), "1 KB");
+    assert_eq!(convert(1_000_000), "1 MB");
+    assert_eq!(convert(1_000_000_000), "1 GB");
+    assert_eq!(convert(1_000_000_000_000), "1 TB");
+    assert_eq!(convert(1_000_000_000_000_000), "1 PB");
+
+    assert_eq!(convert((1 << 30) + 103 * (1 << 20)), "1.182 GB");
+    assert_eq!(convert((1 << 30) + 128 * (1 << 20)), "1.208 GB");
+    assert_eq!(convert((2 << 50) + 500 * (1 << 40)), "2.802 PB");
+}
+
+#[test]
+fn test_human_byte_auto_unit_binary() {
+    fn convert(b: u64) -> String {
+        HumanByte::from(b).to_string()
+    }
+    assert_eq!(convert(0), "0 B");
+    assert_eq!(convert(987), "987 B");
+    assert_eq!(convert(1022), "1022 B");
+    assert_eq!(convert(9_000), "8.789 KiB");
+    assert_eq!(convert(10_000_000), "9.537 MiB");
+    assert_eq!(convert(10_000_000_000), "9.313 GiB");
+    assert_eq!(convert(10_000_000_000_000), "9.095 TiB");
+
+    assert_eq!(convert(1 << 10), "1 KiB");
+    assert_eq!(convert((1 << 10) * 10), "10 KiB");
+    assert_eq!(convert(1 << 20), "1 MiB");
+    assert_eq!(convert(1 << 30), "1 GiB");
+    assert_eq!(convert(1 << 40), "1 TiB");
+    assert_eq!(convert(1 << 50), "1 PiB");
+
+    assert_eq!(convert((1 << 30) + 103 * (1 << 20)), "1.101 GiB");
+    assert_eq!(convert((1 << 30) + 128 * (1 << 20)), "1.125 GiB");
+    assert_eq!(convert((1 << 40) + 128 * (1 << 30)), "1.125 TiB");
+    assert_eq!(convert((2 << 50) + 512 * (1 << 40)), "2.5 PiB");
+}
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 02/42] human-byte: move tests to their own sub-module
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 01/42] add `proxmox-human-byte` crate Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 03/42] add proxmox-notify crate Lukas Wagner
                   ` (40 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

The `#[cfg(test)]` directive ensures that the tests are not compiled
for non-test builds.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---

Notes:
    Already posted to pbs-devel, but included here as well since we
    depend on it and it has not yet been applied.
    Changes from the version posted there: Fixed typo in commit message.
    
    https://lists.proxmox.com/pipermail/pbs-devel/2023-May/006127.html

 proxmox-human-byte/src/lib.rs | 241 +++++++++++++++++-----------------
 1 file changed, 123 insertions(+), 118 deletions(-)

diff --git a/proxmox-human-byte/src/lib.rs b/proxmox-human-byte/src/lib.rs
index 7be16a51..ef680b4e 100644
--- a/proxmox-human-byte/src/lib.rs
+++ b/proxmox-human-byte/src/lib.rs
@@ -226,133 +226,138 @@ impl std::str::FromStr for HumanByte {
 proxmox_serde::forward_deserialize_to_from_str!(HumanByte);
 proxmox_serde::forward_serialize_to_display!(HumanByte);
 
-#[test]
-fn test_human_byte_parser() -> Result<(), Error> {
-    assert!("-10".parse::<HumanByte>().is_err()); // negative size
+#[cfg(test)]
+mod tests {
+    use super::*;
 
-    fn do_test(v: &str, size: f64, unit: SizeUnit, as_str: &str) -> Result<(), Error> {
-        let h: HumanByte = v.parse()?;
+    #[test]
+    fn test_human_byte_parser() -> Result<(), Error> {
+        assert!("-10".parse::<HumanByte>().is_err()); // negative size
 
-        if h.size != size {
-            bail!("got unexpected size for '{}' ({} != {})", v, h.size, size);
-        }
-        if h.unit != unit {
-            bail!(
-                "got unexpected unit for '{}' ({:?} != {:?})",
-                v,
-                h.unit,
-                unit
-            );
-        }
+        fn do_test(v: &str, size: f64, unit: SizeUnit, as_str: &str) -> Result<(), Error> {
+            let h: HumanByte = v.parse()?;
+
+            if h.size != size {
+                bail!("got unexpected size for '{}' ({} != {})", v, h.size, size);
+            }
+            if h.unit != unit {
+                bail!(
+                    "got unexpected unit for '{}' ({:?} != {:?})",
+                    v,
+                    h.unit,
+                    unit
+                );
+            }
 
-        let new = h.to_string();
-        if new != *as_str {
-            bail!("to_string failed for '{}' ({:?} != {:?})", v, new, as_str);
+            let new = h.to_string();
+            if new != *as_str {
+                bail!("to_string failed for '{}' ({:?} != {:?})", v, new, as_str);
+            }
+            Ok(())
         }
-        Ok(())
-    }
-    fn test(v: &str, size: f64, unit: SizeUnit, as_str: &str) -> bool {
-        match do_test(v, size, unit, as_str) {
-            Ok(_) => true,
-            Err(err) => {
-                eprintln!("{}", err); // makes debugging easier
-                false
+        fn test(v: &str, size: f64, unit: SizeUnit, as_str: &str) -> bool {
+            match do_test(v, size, unit, as_str) {
+                Ok(_) => true,
+                Err(err) => {
+                    eprintln!("{}", err); // makes debugging easier
+                    false
+                }
             }
         }
-    }
 
-    assert!(test("14", 14.0, SizeUnit::Byte, "14 B"));
-    assert!(test("14.4", 14.4, SizeUnit::Byte, "14.4 B"));
-    assert!(test("14.45", 14.45, SizeUnit::Byte, "14.45 B"));
-    assert!(test("14.456", 14.456, SizeUnit::Byte, "14.456 B"));
-    assert!(test("14.4567", 14.4567, SizeUnit::Byte, "14.457 B"));
-
-    let h: HumanByte = "1.2345678".parse()?;
-    assert_eq!(&format!("{:.0}", h), "1 B");
-    assert_eq!(&format!("{:.0}", h.as_f64()), "1"); // use as_f64 to get raw bytes without unit
-    assert_eq!(&format!("{:.1}", h), "1.2 B");
-    assert_eq!(&format!("{:.2}", h), "1.23 B");
-    assert_eq!(&format!("{:.3}", h), "1.235 B");
-    assert_eq!(&format!("{:.4}", h), "1.2346 B");
-    assert_eq!(&format!("{:.5}", h), "1.23457 B");
-    assert_eq!(&format!("{:.6}", h), "1.234568 B");
-    assert_eq!(&format!("{:.7}", h), "1.2345678 B");
-    assert_eq!(&format!("{:.8}", h), "1.2345678 B");
-
-    assert!(test(
-        "987654321",
-        987654321.0,
-        SizeUnit::Byte,
-        "987654321 B"
-    ));
-
-    assert!(test("1300b", 1300.0, SizeUnit::Byte, "1300 B"));
-    assert!(test("1300B", 1300.0, SizeUnit::Byte, "1300 B"));
-    assert!(test("1300 B", 1300.0, SizeUnit::Byte, "1300 B"));
-    assert!(test("1300 b", 1300.0, SizeUnit::Byte, "1300 B"));
-
-    assert!(test("1.5KB", 1.5, SizeUnit::KByte, "1.5 KB"));
-    assert!(test("1.5kb", 1.5, SizeUnit::KByte, "1.5 KB"));
-    assert!(test("1.654321MB", 1.654_321, SizeUnit::MByte, "1.654 MB"));
-
-    assert!(test("2.0GB", 2.0, SizeUnit::GByte, "2 GB"));
-
-    assert!(test("1.4TB", 1.4, SizeUnit::TByte, "1.4 TB"));
-    assert!(test("1.4tb", 1.4, SizeUnit::TByte, "1.4 TB"));
-
-    assert!(test("2KiB", 2.0, SizeUnit::Kibi, "2 KiB"));
-    assert!(test("2Ki", 2.0, SizeUnit::Kibi, "2 KiB"));
-    assert!(test("2kib", 2.0, SizeUnit::Kibi, "2 KiB"));
-
-    assert!(test("2.3454MiB", 2.3454, SizeUnit::Mebi, "2.345 MiB"));
-    assert!(test("2.3456MiB", 2.3456, SizeUnit::Mebi, "2.346 MiB"));
-
-    assert!(test("4gib", 4.0, SizeUnit::Gibi, "4 GiB"));
-
-    Ok(())
-}
+        assert!(test("14", 14.0, SizeUnit::Byte, "14 B"));
+        assert!(test("14.4", 14.4, SizeUnit::Byte, "14.4 B"));
+        assert!(test("14.45", 14.45, SizeUnit::Byte, "14.45 B"));
+        assert!(test("14.456", 14.456, SizeUnit::Byte, "14.456 B"));
+        assert!(test("14.4567", 14.4567, SizeUnit::Byte, "14.457 B"));
+
+        let h: HumanByte = "1.2345678".parse()?;
+        assert_eq!(&format!("{:.0}", h), "1 B");
+        assert_eq!(&format!("{:.0}", h.as_f64()), "1"); // use as_f64 to get raw bytes without unit
+        assert_eq!(&format!("{:.1}", h), "1.2 B");
+        assert_eq!(&format!("{:.2}", h), "1.23 B");
+        assert_eq!(&format!("{:.3}", h), "1.235 B");
+        assert_eq!(&format!("{:.4}", h), "1.2346 B");
+        assert_eq!(&format!("{:.5}", h), "1.23457 B");
+        assert_eq!(&format!("{:.6}", h), "1.234568 B");
+        assert_eq!(&format!("{:.7}", h), "1.2345678 B");
+        assert_eq!(&format!("{:.8}", h), "1.2345678 B");
+
+        assert!(test(
+            "987654321",
+            987654321.0,
+            SizeUnit::Byte,
+            "987654321 B"
+        ));
+
+        assert!(test("1300b", 1300.0, SizeUnit::Byte, "1300 B"));
+        assert!(test("1300B", 1300.0, SizeUnit::Byte, "1300 B"));
+        assert!(test("1300 B", 1300.0, SizeUnit::Byte, "1300 B"));
+        assert!(test("1300 b", 1300.0, SizeUnit::Byte, "1300 B"));
+
+        assert!(test("1.5KB", 1.5, SizeUnit::KByte, "1.5 KB"));
+        assert!(test("1.5kb", 1.5, SizeUnit::KByte, "1.5 KB"));
+        assert!(test("1.654321MB", 1.654_321, SizeUnit::MByte, "1.654 MB"));
+
+        assert!(test("2.0GB", 2.0, SizeUnit::GByte, "2 GB"));
+
+        assert!(test("1.4TB", 1.4, SizeUnit::TByte, "1.4 TB"));
+        assert!(test("1.4tb", 1.4, SizeUnit::TByte, "1.4 TB"));
+
+        assert!(test("2KiB", 2.0, SizeUnit::Kibi, "2 KiB"));
+        assert!(test("2Ki", 2.0, SizeUnit::Kibi, "2 KiB"));
+        assert!(test("2kib", 2.0, SizeUnit::Kibi, "2 KiB"));
+
+        assert!(test("2.3454MiB", 2.3454, SizeUnit::Mebi, "2.345 MiB"));
+        assert!(test("2.3456MiB", 2.3456, SizeUnit::Mebi, "2.346 MiB"));
+
+        assert!(test("4gib", 4.0, SizeUnit::Gibi, "4 GiB"));
 
-#[test]
-fn test_human_byte_auto_unit_decimal() {
-    fn convert(b: u64) -> String {
-        HumanByte::new_decimal(b as f64).to_string()
+        Ok(())
     }
-    assert_eq!(convert(987), "987 B");
-    assert_eq!(convert(1022), "1.022 KB");
-    assert_eq!(convert(9_000), "9 KB");
-    assert_eq!(convert(1_000), "1 KB");
-    assert_eq!(convert(1_000_000), "1 MB");
-    assert_eq!(convert(1_000_000_000), "1 GB");
-    assert_eq!(convert(1_000_000_000_000), "1 TB");
-    assert_eq!(convert(1_000_000_000_000_000), "1 PB");
-
-    assert_eq!(convert((1 << 30) + 103 * (1 << 20)), "1.182 GB");
-    assert_eq!(convert((1 << 30) + 128 * (1 << 20)), "1.208 GB");
-    assert_eq!(convert((2 << 50) + 500 * (1 << 40)), "2.802 PB");
-}
 
-#[test]
-fn test_human_byte_auto_unit_binary() {
-    fn convert(b: u64) -> String {
-        HumanByte::from(b).to_string()
+    #[test]
+    fn test_human_byte_auto_unit_decimal() {
+        fn convert(b: u64) -> String {
+            HumanByte::new_decimal(b as f64).to_string()
+        }
+        assert_eq!(convert(987), "987 B");
+        assert_eq!(convert(1022), "1.022 KB");
+        assert_eq!(convert(9_000), "9 KB");
+        assert_eq!(convert(1_000), "1 KB");
+        assert_eq!(convert(1_000_000), "1 MB");
+        assert_eq!(convert(1_000_000_000), "1 GB");
+        assert_eq!(convert(1_000_000_000_000), "1 TB");
+        assert_eq!(convert(1_000_000_000_000_000), "1 PB");
+
+        assert_eq!(convert((1 << 30) + 103 * (1 << 20)), "1.182 GB");
+        assert_eq!(convert((1 << 30) + 128 * (1 << 20)), "1.208 GB");
+        assert_eq!(convert((2 << 50) + 500 * (1 << 40)), "2.802 PB");
+    }
+
+    #[test]
+    fn test_human_byte_auto_unit_binary() {
+        fn convert(b: u64) -> String {
+            HumanByte::from(b).to_string()
+        }
+        assert_eq!(convert(0), "0 B");
+        assert_eq!(convert(987), "987 B");
+        assert_eq!(convert(1022), "1022 B");
+        assert_eq!(convert(9_000), "8.789 KiB");
+        assert_eq!(convert(10_000_000), "9.537 MiB");
+        assert_eq!(convert(10_000_000_000), "9.313 GiB");
+        assert_eq!(convert(10_000_000_000_000), "9.095 TiB");
+
+        assert_eq!(convert(1 << 10), "1 KiB");
+        assert_eq!(convert((1 << 10) * 10), "10 KiB");
+        assert_eq!(convert(1 << 20), "1 MiB");
+        assert_eq!(convert(1 << 30), "1 GiB");
+        assert_eq!(convert(1 << 40), "1 TiB");
+        assert_eq!(convert(1 << 50), "1 PiB");
+
+        assert_eq!(convert((1 << 30) + 103 * (1 << 20)), "1.101 GiB");
+        assert_eq!(convert((1 << 30) + 128 * (1 << 20)), "1.125 GiB");
+        assert_eq!(convert((1 << 40) + 128 * (1 << 30)), "1.125 TiB");
+        assert_eq!(convert((2 << 50) + 512 * (1 << 40)), "2.5 PiB");
     }
-    assert_eq!(convert(0), "0 B");
-    assert_eq!(convert(987), "987 B");
-    assert_eq!(convert(1022), "1022 B");
-    assert_eq!(convert(9_000), "8.789 KiB");
-    assert_eq!(convert(10_000_000), "9.537 MiB");
-    assert_eq!(convert(10_000_000_000), "9.313 GiB");
-    assert_eq!(convert(10_000_000_000_000), "9.095 TiB");
-
-    assert_eq!(convert(1 << 10), "1 KiB");
-    assert_eq!(convert((1 << 10) * 10), "10 KiB");
-    assert_eq!(convert(1 << 20), "1 MiB");
-    assert_eq!(convert(1 << 30), "1 GiB");
-    assert_eq!(convert(1 << 40), "1 TiB");
-    assert_eq!(convert(1 << 50), "1 PiB");
-
-    assert_eq!(convert((1 << 30) + 103 * (1 << 20)), "1.101 GiB");
-    assert_eq!(convert((1 << 30) + 128 * (1 << 20)), "1.125 GiB");
-    assert_eq!(convert((1 << 40) + 128 * (1 << 30)), "1.125 TiB");
-    assert_eq!(convert((2 << 50) + 512 * (1 << 40)), "2.5 PiB");
 }
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 03/42] add proxmox-notify crate
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 01/42] add `proxmox-human-byte` crate Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 02/42] human-byte: move tests to their own sub-module Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 04/42] notify: add debian packaging Lukas Wagner
                   ` (39 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---

Notes:
    Changes v1 -> v2:
        - Renamed crate from 'proxmox-notification' to 'proxmox-notify'

 Cargo.toml                |  1 +
 proxmox-notify/Cargo.toml | 10 ++++++++++
 proxmox-notify/src/lib.rs |  0
 3 files changed, 11 insertions(+)
 create mode 100644 proxmox-notify/Cargo.toml
 create mode 100644 proxmox-notify/src/lib.rs

diff --git a/Cargo.toml b/Cargo.toml
index 35e67ad4..f8a691a1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,7 @@ members = [
     "proxmox-login",
     "proxmox-metrics",
     "proxmox-openid",
+    "proxmox-notify",
     "proxmox-rest-server",
     "proxmox-router",
     "proxmox-schema",
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
new file mode 100644
index 00000000..2e69d5b0
--- /dev/null
+++ b/proxmox-notify/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "proxmox-notify"
+version = "0.1.0"
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+exclude.workspace = true
+
+[dependencies]
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
new file mode 100644
index 00000000..e69de29b
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 04/42] notify: add debian packaging
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (2 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 03/42] add proxmox-notify crate Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 05/42] notify: preparation for the first endpoint plugin Lukas Wagner
                   ` (38 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/debian/changelog     |  5 +++++
 proxmox-notify/debian/control       | 31 +++++++++++++++++++++++++++++
 proxmox-notify/debian/copyright     | 16 +++++++++++++++
 proxmox-notify/debian/debcargo.toml |  7 +++++++
 4 files changed, 59 insertions(+)
 create mode 100644 proxmox-notify/debian/changelog
 create mode 100644 proxmox-notify/debian/control
 create mode 100644 proxmox-notify/debian/copyright
 create mode 100644 proxmox-notify/debian/debcargo.toml

diff --git a/proxmox-notify/debian/changelog b/proxmox-notify/debian/changelog
new file mode 100644
index 00000000..9d0a4d03
--- /dev/null
+++ b/proxmox-notify/debian/changelog
@@ -0,0 +1,5 @@
+rust-proxmox-notify (0.1.0-1) stable; urgency=medium
+
+  * Initial release.
+
+ --  Proxmox Support Team <support@proxmox.com>  Thu, 12 Jan 2023 11:42:11 +0200
diff --git a/proxmox-notify/debian/control b/proxmox-notify/debian/control
new file mode 100644
index 00000000..077a9f9d
--- /dev/null
+++ b/proxmox-notify/debian/control
@@ -0,0 +1,31 @@
+Source: rust-proxmox-notify
+Section: rust
+Priority: optional
+Build-Depends: debhelper (>= 12),
+ dh-cargo (>= 25),
+ cargo:native <!nocheck>,
+ rustc:native <!nocheck>,
+ libstd-rust-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.6.1
+Vcs-Git: git://git.proxmox.com/git/proxmox.git
+Vcs-Browser: https://git.proxmox.com/?p=proxmox.git
+X-Cargo-Crate: proxmox-notify
+Rules-Requires-Root: no
+
+Package: librust-proxmox-notify-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends}
+Provides:
+ librust-proxmox-notify+default-dev (= ${binary:Version}),
+ librust-proxmox-notify-0-dev (= ${binary:Version}),
+ librust-proxmox-notify-0+default-dev (= ${binary:Version}),
+ librust-proxmox-notify-0.1-dev (= ${binary:Version}),
+ librust-proxmox-notify-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-notify-0.1.0-dev (= ${binary:Version}),
+ librust-proxmox-notify-0.1.0+default-dev (= ${binary:Version})
+Description: Rust crate "proxmox-notify" - Rust source code
+ This package contains the source for the Rust proxmox-notify crate, packaged by
+ debcargo for use with cargo and dh-cargo.
diff --git a/proxmox-notify/debian/copyright b/proxmox-notify/debian/copyright
new file mode 100644
index 00000000..4fce23a5
--- /dev/null
+++ b/proxmox-notify/debian/copyright
@@ -0,0 +1,16 @@
+Copyright (C) 2023 Proxmox Server Solutions GmbH
+
+This software is written by Proxmox Server Solutions GmbH <support@proxmox.com>
+
+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 <http://www.gnu.org/licenses/>.
diff --git a/proxmox-notify/debian/debcargo.toml b/proxmox-notify/debian/debcargo.toml
new file mode 100644
index 00000000..b7864cdb
--- /dev/null
+++ b/proxmox-notify/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"
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 05/42] notify: preparation for the first endpoint plugin
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (3 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 04/42] notify: add debian packaging Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 06/42] notify: preparation for the API Lukas Wagner
                   ` (37 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 Cargo.toml                          |   1 +
 proxmox-notify/Cargo.toml           |   9 +
 proxmox-notify/src/config.rs        |  51 +++++
 proxmox-notify/src/endpoints/mod.rs |   0
 proxmox-notify/src/lib.rs           | 299 ++++++++++++++++++++++++++++
 proxmox-notify/src/schema.rs        |  43 ++++
 6 files changed, 403 insertions(+)
 create mode 100644 proxmox-notify/src/config.rs
 create mode 100644 proxmox-notify/src/endpoints/mod.rs
 create mode 100644 proxmox-notify/src/schema.rs

diff --git a/Cargo.toml b/Cargo.toml
index f8a691a1..1003022e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -94,6 +94,7 @@ proxmox-lang = { version = "1.1", path = "proxmox-lang" }
 proxmox-rest-server = { version = "0.4.0", path = "proxmox-rest-server" }
 proxmox-router = { version = "1.3.1", path = "proxmox-router" }
 proxmox-schema = { version = "1.3.7", path = "proxmox-schema" }
+proxmox-section-config = { version = "1.0.2", path = "proxmox-section-config" }
 proxmox-serde = { version = "0.1.1", path = "proxmox-serde", features = [ "serde_json" ] }
 proxmox-sortable-macro = { version = "0.1.2", path = "proxmox-sortable-macro" }
 proxmox-sys = { version = "0.5.0", path = "proxmox-sys" }
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index 2e69d5b0..37d175f0 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -8,3 +8,12 @@ repository.workspace = true
 exclude.workspace = true
 
 [dependencies]
+lazy_static.workspace = true
+log.workspace = true
+openssl.workspace = true
+proxmox-schema = { workspace = true, features = ["api-macro"]}
+proxmox-section-config = { workspace = true }
+proxmox-sys.workspace = true
+regex.workspace = true
+serde.workspace = true
+serde_json.workspace = true
diff --git a/proxmox-notify/src/config.rs b/proxmox-notify/src/config.rs
new file mode 100644
index 00000000..362ca0fc
--- /dev/null
+++ b/proxmox-notify/src/config.rs
@@ -0,0 +1,51 @@
+use lazy_static::lazy_static;
+use proxmox_schema::{ApiType, ObjectSchema};
+use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
+
+use crate::schema::BACKEND_NAME_SCHEMA;
+use crate::Error;
+
+lazy_static! {
+    pub static ref CONFIG: SectionConfig = config_init();
+    pub static ref PRIVATE_CONFIG: SectionConfig = private_config_init();
+}
+
+fn config_init() -> SectionConfig {
+    let mut config = SectionConfig::new(&BACKEND_NAME_SCHEMA);
+
+    config
+}
+
+fn private_config_init() -> SectionConfig {
+    let mut config = SectionConfig::new(&BACKEND_NAME_SCHEMA);
+
+    config
+}
+
+pub fn config(raw_config: &str) -> Result<(SectionConfigData, [u8; 32]), Error> {
+    let digest = openssl::sha::sha256(raw_config.as_bytes());
+    let data = CONFIG
+        .parse("notifications.cfg", raw_config)
+        .map_err(|err| Error::ConfigDeserialization(err.into()))?;
+    Ok((data, digest))
+}
+
+pub fn private_config(raw_config: &str) -> Result<(SectionConfigData, [u8; 32]), Error> {
+    let digest = openssl::sha::sha256(raw_config.as_bytes());
+    let data = PRIVATE_CONFIG
+        .parse("priv/notifications.cfg", raw_config)
+        .map_err(|err| Error::ConfigDeserialization(err.into()))?;
+    Ok((data, digest))
+}
+
+pub fn write(config: &SectionConfigData) -> Result<String, Error> {
+    CONFIG
+        .write("notifications.cfg", config)
+        .map_err(|err| Error::ConfigSerialization(err.into()))
+}
+
+pub fn write_private(config: &SectionConfigData) -> Result<String, Error> {
+    PRIVATE_CONFIG
+        .write("priv/notifications.cfg", config)
+        .map_err(|err| Error::ConfigSerialization(err.into()))
+}
diff --git a/proxmox-notify/src/endpoints/mod.rs b/proxmox-notify/src/endpoints/mod.rs
new file mode 100644
index 00000000..e69de29b
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index e69de29b..a55d4e33 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -0,0 +1,299 @@
+use std::fmt::Display;
+
+use proxmox_schema::api;
+use proxmox_section_config::SectionConfigData;
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use serde_json::Value;
+
+use std::error::Error as StdError;
+
+mod config;
+pub mod endpoints;
+pub mod schema;
+
+#[derive(Debug)]
+pub enum Error {
+    ConfigSerialization(Box<dyn StdError + Send + Sync + 'static>),
+    ConfigDeserialization(Box<dyn StdError + Send + Sync + 'static>),
+    NotifyFailed(String, Box<dyn StdError + Send + Sync + 'static>),
+    EndpointDoesNotExist(String),
+}
+
+impl Display for Error {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Error::ConfigSerialization(err) => {
+                write!(f, "could not serialize configuration: {err}")
+            }
+            Error::ConfigDeserialization(err) => {
+                write!(f, "could not deserialize configuration: {err}")
+            }
+            Error::NotifyFailed(endpoint, err) => {
+                write!(f, "could not notify via endpoint(s): {endpoint}: {err}")
+            }
+            Error::EndpointDoesNotExist(endpoint) => {
+                write!(f, "endpoint '{endpoint}' does not exist")
+            }
+        }
+    }
+}
+
+impl StdError for Error {
+    fn source(&self) -> Option<&(dyn StdError + 'static)> {
+        match self {
+            Error::ConfigSerialization(err) => Some(&**err),
+            Error::ConfigDeserialization(err) => Some(&**err),
+            Error::NotifyFailed(_, err) => Some(&**err),
+            Error::EndpointDoesNotExist(_) => None,
+        }
+    }
+}
+
+#[api()]
+#[derive(Clone, Debug, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd)]
+#[serde(rename_all = "kebab-case")]
+/// Severity of a notification
+pub enum Severity {
+    /// General information
+    Info,
+    /// A noteworthy event
+    Notice,
+    /// Warning
+    Warning,
+    /// Error
+    Error,
+}
+
+/// Notification endpoint trait, implemented by all endpoint plugins
+pub trait Endpoint {
+    /// Send a documentation
+    fn send(&self, notification: &Notification) -> Result<(), Error>;
+
+    /// The name/identifier for this endpoint
+    fn name(&self) -> &str;
+}
+
+#[derive(Debug, Clone)]
+/// Notification which can be sent
+pub struct Notification {
+    /// Notification severity
+    pub severity: Severity,
+    /// The title of the notification
+    pub title: String,
+    /// Notification text
+    pub body: String,
+    /// Additional metadata for the notification
+    pub properties: Option<Value>,
+}
+
+/// Notification configuration
+pub struct Config {
+    config: SectionConfigData,
+    private_config: SectionConfigData,
+    digest: [u8; 32],
+    private_digest: [u8; 32],
+}
+
+impl Clone for Config {
+    fn clone(&self) -> Self {
+        Self {
+            config: SectionConfigData {
+                sections: self.config.sections.clone(),
+                order: self.config.order.clone(),
+            },
+            private_config: SectionConfigData {
+                sections: self.private_config.sections.clone(),
+                order: self.private_config.order.clone(),
+            },
+            digest: self.digest,
+            private_digest: self.private_digest,
+        }
+    }
+}
+
+impl Config {
+    /// Parse raw config
+    pub fn new(raw_config: &str, raw_private_config: &str) -> Result<Self, Error> {
+        let (config, digest) = config::config(raw_config)?;
+        let (private_config, private_digest) = config::private_config(raw_private_config)?;
+
+        Ok(Self {
+            config,
+            digest,
+            private_config,
+            private_digest,
+        })
+    }
+
+    /// Serialize config
+    pub fn write(&self) -> Result<(String, String), Error> {
+        Ok((
+            config::write(&self.config)?,
+            config::write_private(&self.private_config)?,
+        ))
+    }
+}
+
+/// Notification bus - distributes notifications to all registered endpoints
+// The reason for the split between `Config` and this struct is to make testing with mocked
+// endpoints a bit easier.
+#[derive(Default)]
+pub struct Bus {
+    endpoints: Vec<Box<dyn Endpoint>>,
+}
+
+#[allow(unused_macros)]
+macro_rules! parse_endpoints_with_private_config {
+    ($config:ident, $public_config:ty, $private_config:ty, $endpoint_type:ident, $type_name:expr) => {
+        (|| -> Result<Vec<Box<dyn Endpoint>>, Error> {
+            let mut endpoints: Vec<Box<dyn Endpoint>> = Vec::new();
+
+            let configs: Vec<$public_config> = $config
+                .config
+                .convert_to_typed_array($type_name)
+                .map_err(|err| Error::ConfigDeserialization(err.into()))?;
+
+            let private_configs: Vec<$private_config> = $config
+                .private_config
+                .convert_to_typed_array($type_name)
+                .map_err(|err| Error::ConfigDeserialization(err.into()))?;
+
+            for config in configs {
+                if let Some(private_config) = private_configs.iter().find(|p| p.name == config.name)
+                {
+                    endpoints.push(Box::new($endpoint_type {
+                        config,
+                        private_config: private_config.clone(),
+                    }));
+                } else {
+                    log::error!(
+                        "Could not instantiate endpoint '{name}': private config does not exist",
+                        name = config.name
+                    );
+                }
+            }
+
+            Ok(endpoints)
+        })()
+    };
+}
+
+#[allow(unused_macros)]
+macro_rules! parse_endpoints_without_private_config {
+    ($config:ident, $public_config:ty, $endpoint_type:ident, $type_name:expr) => {
+        (|| -> Result<Vec<Box<dyn Endpoint>>, Error> {
+            let mut endpoints: Vec<Box<dyn Endpoint>> = Vec::new();
+
+            let configs: Vec<$public_config> = $config
+                .config
+                .convert_to_typed_array($type_name)
+                .map_err(|err| Error::ConfigDeserialization(err.into()))?;
+
+            for config in configs {
+                endpoints.push(Box::new($endpoint_type { config }));
+            }
+
+            Ok(endpoints)
+        })()
+    };
+}
+
+impl Bus {
+    pub fn from_config(config: &Config) -> Result<Self, Error> {
+        let mut endpoints = Vec::new();
+
+        Ok(Bus { endpoints })
+    }
+
+    #[cfg(test)]
+    pub fn add_endpoint(&mut self, endpoint: Box<dyn Endpoint>) {
+        self.endpoints.push(endpoint);
+    }
+
+    /// Send a notification to all registered endpoints
+    pub fn send(&self, notification: &Notification) -> Result<(), Error> {
+        log::info!(
+            "sending notification with title '{title}'",
+            title = notification.title
+        );
+
+        for endpoint in &self.endpoints {
+            endpoint.send(notification).unwrap_or_else(|e| {
+                log::error!(
+                    "could not notfiy via endpoint `{name}`: {e}",
+                    name = endpoint.name()
+                )
+            })
+        }
+
+        Ok(())
+    }
+
+    pub fn test_endpoint(&self, endpoint_name: &str) -> Result<(), Error> {
+        let endpoint = self
+            .endpoints
+            .iter()
+            .find(|e| e.name() == endpoint_name)
+            .ok_or(Error::EndpointDoesNotExist(endpoint_name.into()))?;
+
+        endpoint.send(&Notification {
+            severity: Severity::Info,
+            title: "Test".into(),
+            body: "This is a test of the notification endpoint '{{ endpoint }}'".into(),
+            properties: Some(json!({ "endpoint": endpoint_name })),
+        })?;
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{cell::RefCell, rc::Rc};
+
+    use super::*;
+
+    #[derive(Default, Clone)]
+    struct MockEndpoint {
+        messages: Rc<RefCell<Vec<Notification>>>,
+    }
+
+    impl Endpoint for MockEndpoint {
+        fn send(&self, message: &Notification) -> Result<(), Error> {
+            self.messages.borrow_mut().push(message.clone());
+
+            Ok(())
+        }
+
+        fn name(&self) -> &str {
+            "mock-endpoint"
+        }
+    }
+
+    impl MockEndpoint {
+        fn messages(&self) -> Vec<Notification> {
+            self.messages.borrow().clone()
+        }
+    }
+
+    #[test]
+    fn test_add_mock_endpoint() -> Result<(), Error> {
+        let mock = MockEndpoint::default();
+
+        let mut bus = Bus::default();
+
+        bus.add_endpoint(Box::new(mock.clone()));
+
+        bus.send(&Notification {
+            title: "Title".into(),
+            body: "Body".into(),
+            severity: Severity::Info,
+            properties: Default::default(),
+        })?;
+        let messages = mock.messages();
+        assert_eq!(messages.len(), 1);
+
+        Ok(())
+    }
+}
diff --git a/proxmox-notify/src/schema.rs b/proxmox-notify/src/schema.rs
new file mode 100644
index 00000000..dea1fdd0
--- /dev/null
+++ b/proxmox-notify/src/schema.rs
@@ -0,0 +1,43 @@
+use proxmox_schema::{const_regex, ApiStringFormat, Schema, StringSchema};
+
+// Copied from PBS
+macro_rules! proxmox_safe_id_regex_str {
+    () => {
+        r"(?:[A-Za-z0-9_][A-Za-z0-9._\-]*)"
+    };
+}
+
+const_regex! {
+    pub SINGLE_LINE_COMMENT_REGEX = r"^[[:^cntrl:]]*$";
+    pub PROXMOX_SAFE_ID_REGEX = concat!(r"^", proxmox_safe_id_regex_str!(), r"$");
+}
+
+const SINGLE_LINE_COMMENT_FORMAT: ApiStringFormat =
+    ApiStringFormat::Pattern(&SINGLE_LINE_COMMENT_REGEX);
+
+pub const COMMENT_SCHEMA: Schema = StringSchema::new("Comment.")
+    .format(&SINGLE_LINE_COMMENT_FORMAT)
+    .max_length(128)
+    .schema();
+
+pub const EMAIL_SCHEMA: Schema = StringSchema::new("E-Mail Address.")
+    .format(&SINGLE_LINE_COMMENT_FORMAT)
+    .min_length(2)
+    .max_length(64)
+    .schema();
+
+pub const PROXMOX_SAFE_ID_FORMAT: ApiStringFormat =
+    ApiStringFormat::Pattern(&PROXMOX_SAFE_ID_REGEX);
+
+pub const BACKEND_NAME_SCHEMA: Schema = StringSchema::new("Notification backend name.")
+    .format(&PROXMOX_SAFE_ID_FORMAT)
+    .min_length(3)
+    .max_length(32)
+    .schema();
+
+pub const ENTITY_NAME_SCHEMA: Schema =
+    StringSchema::new("Name schema for endpoints, filters and channels")
+        .format(&PROXMOX_SAFE_ID_FORMAT)
+        .min_length(2)
+        .max_length(32)
+        .schema();
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 06/42] notify: preparation for the API
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (4 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 05/42] notify: preparation for the first endpoint plugin Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 07/42] notify: api: add API for sending notifications/testing endpoints Lukas Wagner
                   ` (36 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/api/mod.rs | 94 +++++++++++++++++++++++++++++++++++
 proxmox-notify/src/lib.rs     |  1 +
 2 files changed, 95 insertions(+)
 create mode 100644 proxmox-notify/src/api/mod.rs

diff --git a/proxmox-notify/src/api/mod.rs b/proxmox-notify/src/api/mod.rs
new file mode 100644
index 00000000..db8623ea
--- /dev/null
+++ b/proxmox-notify/src/api/mod.rs
@@ -0,0 +1,94 @@
+use std::error::Error as StdError;
+use std::fmt::Display;
+
+use crate::Config;
+use serde::Serialize;
+
+#[derive(Debug, Serialize)]
+pub struct ApiError {
+    /// HTTP Error code
+    code: u16,
+    /// Error message
+    message: String,
+    #[serde(skip_serializing)]
+    /// The underlying cause of the error
+    source: Option<Box<dyn StdError + Send + Sync + 'static>>,
+}
+
+impl ApiError {
+    fn new<S: AsRef<str>>(
+        message: S,
+        code: u16,
+        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
+    ) -> Self {
+        Self {
+            message: message.as_ref().into(),
+            code,
+            source,
+        }
+    }
+
+    fn bad_request<S: AsRef<str>>(
+        message: S,
+        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
+    ) -> Self {
+        Self::new(message, 400, source)
+    }
+
+    fn not_found<S: AsRef<str>>(
+        message: S,
+        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
+    ) -> Self {
+        Self::new(message, 404, source)
+    }
+
+    fn internal_server_error<S: AsRef<str>>(
+        message: S,
+        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
+    ) -> Self {
+        Self::new(message, 500, source)
+    }
+}
+
+impl Display for ApiError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(&format!("{} {}", self.code, self.message))
+    }
+}
+
+impl StdError for ApiError {
+    fn source(&self) -> Option<&(dyn StdError + 'static)> {
+        match &self.source {
+            None => None,
+            Some(source) => Some(&**source),
+        }
+    }
+}
+
+fn verify_digest(config: &Config, digest: Option<&[u8]>) -> Result<(), ApiError> {
+    if let Some(digest) = digest {
+        if config.digest != *digest {
+            return Err(ApiError::bad_request(
+                "detected modified configuration - file changed by other user? Try again.",
+                None,
+            ));
+        }
+    }
+
+    Ok(())
+}
+
+fn endpoint_exists(config: &Config, name: &str) -> bool {
+    let mut exists = false;
+
+    exists
+}
+
+#[cfg(test)]
+mod test_helpers {
+    use crate::Config;
+
+    pub fn empty_config() -> Config {
+        Config::new("", "").unwrap()
+    }
+}
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index a55d4e33..f2d0e16c 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -8,6 +8,7 @@ use serde_json::Value;
 
 use std::error::Error as StdError;
 
+pub mod api;
 mod config;
 pub mod endpoints;
 pub mod schema;
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 07/42] notify: api: add API for sending notifications/testing endpoints
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (5 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 06/42] notify: preparation for the API Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 08/42] notify: add notification channels Lukas Wagner
                   ` (35 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/api/common.rs | 46 ++++++++++++++++++++++++++++++++
 proxmox-notify/src/api/mod.rs    |  2 ++
 2 files changed, 48 insertions(+)
 create mode 100644 proxmox-notify/src/api/common.rs

diff --git a/proxmox-notify/src/api/common.rs b/proxmox-notify/src/api/common.rs
new file mode 100644
index 00000000..2465279e
--- /dev/null
+++ b/proxmox-notify/src/api/common.rs
@@ -0,0 +1,46 @@
+use crate::api::ApiError;
+use crate::{Bus, Config, Notification};
+
+/// Send a notifcation to a given channel.
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns an `ApiError` in case of an error.
+pub fn send(config: &Config, channel: &str, notification: &Notification) -> Result<(), ApiError> {
+    let bus = Bus::from_config(config).map_err(|err| {
+        ApiError::internal_server_error(
+            "Could not instantiate notification bus",
+            Some(Box::new(err)),
+        )
+    })?;
+
+    bus.send(channel, notification).map_err(|err| match err {
+        crate::Error::ChannelDoesNotExist(channel) => {
+            ApiError::not_found(format!("channel '{channel}' does not exist"), None)
+        }
+        _ => ApiError::internal_server_error("Could not send notification", Some(Box::new(err))),
+    })?;
+
+    Ok(())
+}
+
+/// Test an endpoint identified by its `name`.
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns an `ApiError` if sending via the endpoint failed.
+pub fn test_endpoint(config: &Config, endpoint: &str) -> Result<(), ApiError> {
+    let bus = Bus::from_config(config).map_err(|err| {
+        ApiError::internal_server_error(
+            "Could not instantiate notification bus",
+            Some(Box::new(err)),
+        )
+    })?;
+
+    bus.test_endpoint(endpoint).map_err(|err| match err {
+        crate::Error::EndpointDoesNotExist(endpoint) => {
+            ApiError::not_found(format!("endpoint '{endpoint}' does not exist"), None)
+        }
+        _ => ApiError::internal_server_error("Could not test endpoint", Some(Box::new(err))),
+    })?;
+
+    Ok(())
+}
diff --git a/proxmox-notify/src/api/mod.rs b/proxmox-notify/src/api/mod.rs
index db8623ea..839a75cc 100644
--- a/proxmox-notify/src/api/mod.rs
+++ b/proxmox-notify/src/api/mod.rs
@@ -4,6 +4,8 @@ use std::fmt::Display;
 use crate::Config;
 use serde::Serialize;
 
+pub mod common;
+
 #[derive(Debug, Serialize)]
 pub struct ApiError {
     /// HTTP Error code
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 08/42] notify: add notification channels
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (6 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 07/42] notify: api: add API for sending notifications/testing endpoints Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 09/42] notify: api: add API for channels Lukas Wagner
                   ` (34 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

A notification channel is basically a 'group' of endpoints.
When notifying, a notification is now sent to a 'channel',
and forwared to all included endpoints.

To illustrate why the channel concept is useful, consider a backup job.
The plan is to provide a new option there where the user can choose a
notification channel that should be used for any notifications.
The channel decouples the job configuration from any
endpoints present in the system.
I expected this to be nicer than:
  - notifying via *all* endpoints. If this is not desired, the user
    would be forced to configure notification filtering (to be
    introduced in a later patch). The filtering approach is a bit
    cumbersome, since it requires the filter to be adapted for each and
    every new backup job.
  - adding the endpoints directly to the job configuration. This would
    mean that new/removed endpoints have to be added/removed from *all*
    affected backup job configurations.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/channel.rs |  53 ++++++++++++++
 proxmox-notify/src/config.rs  |   9 +++
 proxmox-notify/src/lib.rs     | 133 ++++++++++++++++++++++++++++++----
 3 files changed, 179 insertions(+), 16 deletions(-)
 create mode 100644 proxmox-notify/src/channel.rs

diff --git a/proxmox-notify/src/channel.rs b/proxmox-notify/src/channel.rs
new file mode 100644
index 00000000..dc9edf98
--- /dev/null
+++ b/proxmox-notify/src/channel.rs
@@ -0,0 +1,53 @@
+use crate::schema::COMMENT_SCHEMA;
+use proxmox_schema::{api, Updater};
+use serde::{Deserialize, Serialize};
+
+pub(crate) const CHANNEL_TYPENAME: &str = "channel";
+
+#[api(
+    properties: {
+        "endpoint": {
+            optional: true,
+            type: Array,
+            items: {
+                description: "Name of the included endpoint(s)",
+                type: String,
+            },
+        },
+        comment: {
+            optional: true,
+            schema: COMMENT_SCHEMA,
+        },
+    },
+)]
+#[derive(Debug, Serialize, Deserialize, Updater)]
+#[serde(rename_all = "kebab-case")]
+/// Config for notification channels
+pub struct ChannelConfig {
+    /// Name of the channel
+    #[updater(skip)]
+    pub name: String,
+    /// Endpoints for this channel
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub endpoint: Option<Vec<String>>,
+    /// Comment
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub comment: Option<String>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum DeleteableChannelProperty {
+    Endpoint,
+    Comment,
+}
+
+impl ChannelConfig {
+    pub fn should_notify_via_endpoint(&self, endpoint: &str) -> bool {
+        if let Some(endpoints) = &self.endpoint {
+            endpoints.iter().any(|e| *e == endpoint)
+        } else {
+            false
+        }
+    }
+}
diff --git a/proxmox-notify/src/config.rs b/proxmox-notify/src/config.rs
index 362ca0fc..3064065b 100644
--- a/proxmox-notify/src/config.rs
+++ b/proxmox-notify/src/config.rs
@@ -2,6 +2,7 @@ use lazy_static::lazy_static;
 use proxmox_schema::{ApiType, ObjectSchema};
 use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
 
+use crate::channel::{ChannelConfig, CHANNEL_TYPENAME};
 use crate::schema::BACKEND_NAME_SCHEMA;
 use crate::Error;
 
@@ -13,6 +14,14 @@ lazy_static! {
 fn config_init() -> SectionConfig {
     let mut config = SectionConfig::new(&BACKEND_NAME_SCHEMA);
 
+    const CHANNEL_SCHEMA: &ObjectSchema = ChannelConfig::API_SCHEMA.unwrap_object_schema();
+
+    config.register_plugin(SectionConfigPlugin::new(
+        CHANNEL_TYPENAME.to_string(),
+        Some(String::from("name")),
+        CHANNEL_SCHEMA,
+    ));
+
     config
 }
 
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index f2d0e16c..3b881e26 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -1,5 +1,6 @@
 use std::fmt::Display;
 
+use channel::{ChannelConfig, CHANNEL_TYPENAME};
 use proxmox_schema::api;
 use proxmox_section_config::SectionConfigData;
 use serde::{Deserialize, Serialize};
@@ -9,6 +10,7 @@ use serde_json::Value;
 use std::error::Error as StdError;
 
 pub mod api;
+pub mod channel;
 mod config;
 pub mod endpoints;
 pub mod schema;
@@ -19,6 +21,7 @@ pub enum Error {
     ConfigDeserialization(Box<dyn StdError + Send + Sync + 'static>),
     NotifyFailed(String, Box<dyn StdError + Send + Sync + 'static>),
     EndpointDoesNotExist(String),
+    ChannelDoesNotExist(String),
 }
 
 impl Display for Error {
@@ -36,6 +39,9 @@ impl Display for Error {
             Error::EndpointDoesNotExist(endpoint) => {
                 write!(f, "endpoint '{endpoint}' does not exist")
             }
+            Error::ChannelDoesNotExist(channel) => {
+                write!(f, "channel '{channel}' does not exist")
+            }
         }
     }
 }
@@ -47,6 +53,7 @@ impl StdError for Error {
             Error::ConfigDeserialization(err) => Some(&**err),
             Error::NotifyFailed(_, err) => Some(&**err),
             Error::EndpointDoesNotExist(_) => None,
+            Error::ChannelDoesNotExist(_) => None,
         }
     }
 }
@@ -142,6 +149,7 @@ impl Config {
 #[derive(Default)]
 pub struct Bus {
     endpoints: Vec<Box<dyn Endpoint>>,
+    channels: Vec<ChannelConfig>,
 }
 
 #[allow(unused_macros)]
@@ -204,7 +212,15 @@ impl Bus {
     pub fn from_config(config: &Config) -> Result<Self, Error> {
         let mut endpoints = Vec::new();
 
-        Ok(Bus { endpoints })
+        let channels = config
+            .config
+            .convert_to_typed_array(CHANNEL_TYPENAME)
+            .map_err(|err| Error::ConfigDeserialization(err.into()))?;
+
+        Ok(Bus {
+            endpoints,
+            channels,
+        })
     }
 
     #[cfg(test)]
@@ -212,20 +228,43 @@ impl Bus {
         self.endpoints.push(endpoint);
     }
 
-    /// Send a notification to all registered endpoints
-    pub fn send(&self, notification: &Notification) -> Result<(), Error> {
-        log::info!(
-            "sending notification with title '{title}'",
+    #[cfg(test)]
+    pub fn add_channel(&mut self, channel: ChannelConfig) {
+        self.channels.push(channel);
+    }
+
+    pub fn send(&self, channel: &str, notification: &Notification) -> Result<(), Error> {
+        log::debug!(
+            "sending notification with title `{title}`",
             title = notification.title
         );
 
+        // TODO: Maybe fallback to some default channel (e.g. send to all endpoints) in case
+        // the channel does not exist? Just to ensure that notifications are *never* swallowed...
+        let channel = self
+            .channels
+            .iter()
+            .find(|c| c.name == channel)
+            .ok_or(Error::ChannelDoesNotExist(channel.into()))?;
+
         for endpoint in &self.endpoints {
-            endpoint.send(notification).unwrap_or_else(|e| {
+            if !channel.should_notify_via_endpoint(endpoint.name()) {
+                log::debug!(
+                    "channel '{channel}' does not notify via endpoint '{endpoint}', skipping",
+                    channel = channel.name,
+                    endpoint = endpoint.name()
+                );
+                continue;
+            }
+
+            if let Err(e) = endpoint.send(notification) {
                 log::error!(
                     "could not notfiy via endpoint `{name}`: {e}",
                     name = endpoint.name()
-                )
-            })
+                );
+            } else {
+                log::info!("notified via endpoint `{name}`", name = endpoint.name());
+            }
         }
 
         Ok(())
@@ -257,6 +296,7 @@ mod tests {
 
     #[derive(Default, Clone)]
     struct MockEndpoint {
+        name: &'static str,
         messages: Rc<RefCell<Vec<Notification>>>,
     }
 
@@ -268,11 +308,18 @@ mod tests {
         }
 
         fn name(&self) -> &str {
-            "mock-endpoint"
+            self.name
         }
     }
 
     impl MockEndpoint {
+        fn new(name: &'static str, filter: Option<String>) -> Self {
+            Self {
+                name,
+                ..Default::default()
+            }
+        }
+
         fn messages(&self) -> Vec<Notification> {
             self.messages.borrow().clone()
         }
@@ -283,18 +330,72 @@ mod tests {
         let mock = MockEndpoint::default();
 
         let mut bus = Bus::default();
-
         bus.add_endpoint(Box::new(mock.clone()));
+        bus.add_channel(ChannelConfig {
+            name: "channel".to_string(),
+            endpoint: Some(vec!["".into()]),
+            comment: None,
+        });
+
+        bus.send(
+            "channel",
+            &Notification {
+                title: "Title".into(),
+                body: "Body".into(),
+                severity: Severity::Info,
+                properties: Default::default(),
+            },
+        )?;
 
-        bus.send(&Notification {
-            title: "Title".into(),
-            body: "Body".into(),
-            severity: Severity::Info,
-            properties: Default::default(),
-        })?;
         let messages = mock.messages();
         assert_eq!(messages.len(), 1);
 
         Ok(())
     }
+
+    #[test]
+    fn test_channels() -> Result<(), Error> {
+        let endpoint1 = MockEndpoint::new("mock1", None);
+        let endpoint2 = MockEndpoint::new("mock2", None);
+
+        let mut bus = Bus::default();
+
+        bus.add_channel(ChannelConfig {
+            name: "channel1".to_string(),
+            endpoint: Some(vec!["mock1".into()]),
+            comment: None,
+        });
+
+        bus.add_channel(ChannelConfig {
+            name: "channel2".to_string(),
+            endpoint: Some(vec!["mock2".into()]),
+            comment: None,
+        });
+
+        bus.add_endpoint(Box::new(endpoint1.clone()));
+        bus.add_endpoint(Box::new(endpoint2.clone()));
+
+        let send_to_channel = |channel| {
+            bus.send(
+                channel,
+                &Notification {
+                    title: "Title".into(),
+                    body: "Body".into(),
+                    severity: Severity::Info,
+                    properties: Default::default(),
+                },
+            )
+            .unwrap();
+        };
+
+        send_to_channel("channel1");
+        assert_eq!(endpoint1.messages().len(), 1);
+        assert_eq!(endpoint2.messages().len(), 0);
+
+        send_to_channel("channel2");
+        assert_eq!(endpoint1.messages().len(), 1);
+        assert_eq!(endpoint2.messages().len(), 1);
+
+        Ok(())
+    }
 }
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 09/42] notify: api: add API for channels
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (7 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 08/42] notify: add notification channels Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 10/42] notify: add sendmail plugin Lukas Wagner
                   ` (33 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/api/channel.rs | 253 ++++++++++++++++++++++++++++++
 proxmox-notify/src/api/mod.rs     |   1 +
 2 files changed, 254 insertions(+)
 create mode 100644 proxmox-notify/src/api/channel.rs

diff --git a/proxmox-notify/src/api/channel.rs b/proxmox-notify/src/api/channel.rs
new file mode 100644
index 00000000..a763fdfa
--- /dev/null
+++ b/proxmox-notify/src/api/channel.rs
@@ -0,0 +1,253 @@
+use crate::api::ApiError;
+use crate::channel::{
+    ChannelConfig, ChannelConfigUpdater, DeleteableChannelProperty, CHANNEL_TYPENAME,
+};
+use crate::Config;
+
+/// Add new channel
+/// Get all notification channels
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns a list of all channels or an `ApiError` if the config is erroneous.
+pub fn get_channels(config: &Config) -> Result<Vec<ChannelConfig>, ApiError> {
+    config
+        .config
+        .convert_to_typed_array(CHANNEL_TYPENAME)
+        .map_err(|e| ApiError::internal_server_error("Could not fetch channels", Some(e.into())))
+}
+
+/// Get channel with given `name`
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns the endpoint or an `ApiError` if the channel was not found.
+pub fn get_channel(config: &Config, name: &str) -> Result<ChannelConfig, ApiError> {
+    config
+        .config
+        .lookup(CHANNEL_TYPENAME, name)
+        .map_err(|_| ApiError::not_found(format!("channel '{name}' not found"), None))
+}
+
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns an `ApiError` if a channel with the same name already exists, or
+/// if the channel could not be saved
+pub fn add_channel(config: &mut Config, channel_config: &ChannelConfig) -> Result<(), ApiError> {
+    if get_channel(config, &channel_config.name).is_ok() {
+        return Err(ApiError::bad_request(
+            format!("channel '{}' already exists", channel_config.name),
+            None,
+        ));
+    }
+
+    if let Some(endpoints) = &channel_config.endpoint {
+        check_if_endpoints_exist(config, endpoints)?;
+    }
+
+    config
+        .config
+        .set_data(&channel_config.name, CHANNEL_TYPENAME, channel_config)
+        .map_err(|e| {
+            ApiError::internal_server_error(
+                format!("could not save channel '{}'", channel_config.name),
+                Some(e.into()),
+            )
+        })?;
+
+    Ok(())
+}
+
+/// Update existing channel
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns an `ApiError` if the config could not be saved.
+pub fn update_channel(
+    config: &mut Config,
+    name: &str,
+    // endpoints: Option<Vec<&str>>,
+    // comment: Option<&str>,
+    updater: &ChannelConfigUpdater,
+    delete: Option<&[DeleteableChannelProperty]>,
+    digest: Option<&[u8]>,
+) -> Result<(), ApiError> {
+    super::verify_digest(config, digest)?;
+
+    let mut channel = get_channel(config, name)?;
+
+    if let Some(delete) = delete {
+        for deleteable_property in delete {
+            match deleteable_property {
+                DeleteableChannelProperty::Endpoint => channel.endpoint = None,
+                DeleteableChannelProperty::Comment => channel.comment = None,
+            }
+        }
+    }
+
+    if let Some(endpoints) = &updater.endpoint {
+        check_if_endpoints_exist(config, endpoints)?;
+        channel.endpoint = Some(endpoints.iter().map(Into::into).collect())
+    }
+
+    if let Some(comment) = &updater.comment {
+        channel.comment = Some(comment.into());
+    }
+
+    config
+        .config
+        .set_data(name, CHANNEL_TYPENAME, &channel)
+        .map_err(|e| {
+            ApiError::internal_server_error(
+                format!("could not save channel '{name}'"),
+                Some(e.into()),
+            )
+        })?;
+
+    Ok(())
+}
+
+/// Delete existing channel
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns an `ApiError` if the channel does not exist.
+pub fn delete_channel(config: &mut Config, name: &str) -> Result<(), ApiError> {
+    // Check if the channel exists
+    let _ = get_channel(config, name)?;
+
+    config.config.sections.remove(name);
+
+    Ok(())
+}
+
+fn check_if_endpoints_exist(config: &Config, endpoints: &[String]) -> Result<(), ApiError> {
+    for endpoint in endpoints {
+        if !super::endpoint_exists(config, endpoint) {
+            return Err(ApiError::not_found(
+                format!("endoint '{endpoint}' does not exist"),
+                None,
+            ));
+        }
+    }
+
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::api::test_helpers::*;
+
+    fn add_default_channel(config: &mut Config) -> Result<(), ApiError> {
+        add_channel(
+            config,
+            &ChannelConfig {
+                name: "channel1".into(),
+                endpoint: None,
+                comment: None,
+            },
+        )?;
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_add_channel_fails_if_endpoint_does_not_exist() {
+        let mut config = empty_config();
+        assert!(add_channel(
+            &mut config,
+            &ChannelConfig {
+                name: "channel1".into(),
+                endpoint: Some(vec!["foo".into()]),
+                comment: None,
+            },
+        )
+        .is_err());
+    }
+
+    #[test]
+    fn test_add_channel() -> Result<(), ApiError> {
+        let mut config = empty_config();
+        assert!(add_default_channel(&mut config).is_ok());
+        Ok(())
+    }
+
+    #[test]
+    fn test_update_channel_fails_if_endpoint_does_not_exist() -> Result<(), ApiError> {
+        let mut config = empty_config();
+        add_default_channel(&mut config)?;
+
+        assert!(update_channel(
+            &mut config,
+            "channel1",
+            &ChannelConfigUpdater {
+                endpoint: Some(vec!["foo".into()]),
+                ..Default::default()
+            },
+            None,
+            None
+        )
+        .is_err());
+        Ok(())
+    }
+
+    #[test]
+    fn test_update_channel_fails_if_digest_invalid() -> Result<(), ApiError> {
+        let mut config = empty_config();
+        add_default_channel(&mut config)?;
+
+        assert!(update_channel(
+            &mut config,
+            "channel1",
+            &Default::default(),
+            None,
+            Some(&[0u8; 32])
+        )
+        .is_err());
+        Ok(())
+    }
+
+    #[test]
+    fn test_update_channel() -> Result<(), ApiError> {
+        let mut config = empty_config();
+        add_default_channel(&mut config)?;
+
+        assert!(update_channel(
+            &mut config,
+            "channel1",
+            &ChannelConfigUpdater {
+                endpoint: None,
+                comment: Some("newcomment".into())
+            },
+            None,
+            None,
+        )
+        .is_ok());
+        let channel = get_channel(&config, "channel1")?;
+        assert_eq!(channel.comment, Some("newcomment".into()));
+
+        assert!(update_channel(
+            &mut config,
+            "channel1",
+            &Default::default(),
+            Some(&[DeleteableChannelProperty::Comment]),
+            None
+        )
+        .is_ok());
+        let channel = get_channel(&config, "channel1")?;
+        assert_eq!(channel.comment, None);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_channel_delete() -> Result<(), ApiError> {
+        let mut config = empty_config();
+        add_default_channel(&mut config)?;
+
+        assert!(delete_channel(&mut config, "channel1").is_ok());
+        assert!(delete_channel(&mut config, "channel1").is_err());
+
+        Ok(())
+    }
+}
diff --git a/proxmox-notify/src/api/mod.rs b/proxmox-notify/src/api/mod.rs
index 839a75cc..9e5f15b8 100644
--- a/proxmox-notify/src/api/mod.rs
+++ b/proxmox-notify/src/api/mod.rs
@@ -4,6 +4,7 @@ use std::fmt::Display;
 use crate::Config;
 use serde::Serialize;
 
+pub mod channel;
 pub mod common;
 
 #[derive(Debug, Serialize)]
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 10/42] notify: add sendmail plugin
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (8 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 09/42] notify: api: add API for channels Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 11/42] notify: api: add API for sendmail endpoints Lukas Wagner
                   ` (32 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

This plugin uses the 'sendmail' command to send an email
to one or more recipients.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/Cargo.toml                |  9 ++-
 proxmox-notify/src/config.rs             | 12 ++++
 proxmox-notify/src/endpoints/mod.rs      |  2 +
 proxmox-notify/src/endpoints/sendmail.rs | 88 ++++++++++++++++++++++++
 proxmox-notify/src/lib.rs                | 17 +++++
 5 files changed, 126 insertions(+), 2 deletions(-)
 create mode 100644 proxmox-notify/src/endpoints/sendmail.rs

diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index 37d175f0..f7329295 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -8,12 +8,17 @@ repository.workspace = true
 exclude.workspace = true
 
 [dependencies]
+handlebars = { workspace = true, optional = true }
 lazy_static.workspace = true
 log.workspace = true
 openssl.workspace = true
 proxmox-schema = { workspace = true, features = ["api-macro"]}
 proxmox-section-config = { workspace = true }
-proxmox-sys.workspace = true
+proxmox-sys = { workspace = true, optional = true }
 regex.workspace = true
-serde.workspace = true
+serde = { workspace = true, features = ["derive"]}
 serde_json.workspace = true
+
+[features]
+default = ["sendmail"]
+sendmail = ["dep:handlebars", "dep:proxmox-sys"]
\ No newline at end of file
diff --git a/proxmox-notify/src/config.rs b/proxmox-notify/src/config.rs
index 3064065b..2ef237fa 100644
--- a/proxmox-notify/src/config.rs
+++ b/proxmox-notify/src/config.rs
@@ -14,6 +14,18 @@ lazy_static! {
 fn config_init() -> SectionConfig {
     let mut config = SectionConfig::new(&BACKEND_NAME_SCHEMA);
 
+    #[cfg(feature = "sendmail")]
+    {
+        use crate::endpoints::sendmail::{SendmailConfig, SENDMAIL_TYPENAME};
+
+        const SENDMAIL_SCHEMA: &ObjectSchema = SendmailConfig::API_SCHEMA.unwrap_object_schema();
+        config.register_plugin(SectionConfigPlugin::new(
+            SENDMAIL_TYPENAME.to_string(),
+            Some(String::from("name")),
+            SENDMAIL_SCHEMA,
+        ));
+    }
+
     const CHANNEL_SCHEMA: &ObjectSchema = ChannelConfig::API_SCHEMA.unwrap_object_schema();
 
     config.register_plugin(SectionConfigPlugin::new(
diff --git a/proxmox-notify/src/endpoints/mod.rs b/proxmox-notify/src/endpoints/mod.rs
index e69de29b..dd80d9bc 100644
--- a/proxmox-notify/src/endpoints/mod.rs
+++ b/proxmox-notify/src/endpoints/mod.rs
@@ -0,0 +1,2 @@
+#[cfg(feature = "sendmail")]
+pub mod sendmail;
diff --git a/proxmox-notify/src/endpoints/sendmail.rs b/proxmox-notify/src/endpoints/sendmail.rs
new file mode 100644
index 00000000..f9b3df83
--- /dev/null
+++ b/proxmox-notify/src/endpoints/sendmail.rs
@@ -0,0 +1,88 @@
+use crate::schema::{COMMENT_SCHEMA, EMAIL_SCHEMA, ENTITY_NAME_SCHEMA};
+use crate::{Endpoint, Error, Notification};
+
+use proxmox_schema::{api, Updater};
+use serde::{Deserialize, Serialize};
+
+pub(crate) const SENDMAIL_TYPENAME: &str = "sendmail";
+
+#[api(
+    properties: {
+        name: {
+            schema: ENTITY_NAME_SCHEMA,
+        },
+        recipient: {
+            type: Array,
+            items: {
+                schema: EMAIL_SCHEMA,
+            },
+        },
+        comment: {
+            optional: true,
+            schema: COMMENT_SCHEMA,
+        },
+    },
+)]
+#[derive(Debug, Serialize, Deserialize, Updater)]
+#[serde(rename_all = "kebab-case")]
+/// Config for Sendmail notification endpoints
+pub struct SendmailConfig {
+    /// Name of the endpoint
+    #[updater(skip)]
+    pub name: String,
+    /// Mail recipients
+    pub recipient: Vec<String>,
+    /// `From` address for the mail
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub from_address: Option<String>,
+    /// Author of the mail
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub author: Option<String>,
+    /// Comment
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub comment: Option<String>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum DeleteableSendmailProperty {
+    FromAddress,
+    Author,
+    Comment,
+}
+
+/// A sendmail notification endpoint.
+pub struct SendmailEndpoint {
+    pub config: SendmailConfig,
+}
+
+impl Endpoint for SendmailEndpoint {
+    fn send(&self, notification: &Notification) -> Result<(), Error> {
+        let recipients: Vec<&str> = self.config.recipient.iter().map(String::as_str).collect();
+
+        // Note: OX has serious problems displaying text mails,
+        // so we include html as well
+        let html = format!(
+            "<html><body><pre>\n{}\n<pre>",
+            handlebars::html_escape(&notification.body)
+        );
+
+        // proxmox_sys::email::sendmail will set the author to
+        // "Proxmox Backup Server" if it is not set.
+        let author = self.config.author.as_deref().or(Some(""));
+
+        proxmox_sys::email::sendmail(
+            &recipients,
+            &notification.title,
+            Some(&notification.body),
+            Some(&html),
+            self.config.from_address.as_deref(),
+            author,
+        )
+        .map_err(|err| Error::NotifyFailed(self.config.name.clone(), err.into()))
+    }
+
+    fn name(&self) -> &str {
+        &self.config.name
+    }
+}
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index 3b881e26..ee89d100 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -212,6 +212,23 @@ impl Bus {
     pub fn from_config(config: &Config) -> Result<Self, Error> {
         let mut endpoints = Vec::new();
 
+        // Instantiate endpoints
+
+        #[cfg(feature = "sendmail")]
+        {
+            use endpoints::sendmail::SENDMAIL_TYPENAME;
+            use endpoints::sendmail::{SendmailConfig, SendmailEndpoint};
+            endpoints.extend(
+                parse_endpoints_without_private_config!(
+                    config,
+                    SendmailConfig,
+                    SendmailEndpoint,
+                    SENDMAIL_TYPENAME
+                )?
+                .into_iter(),
+            );
+        }
+
         let channels = config
             .config
             .convert_to_typed_array(CHANNEL_TYPENAME)
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 11/42] notify: api: add API for sendmail endpoints
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (9 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 10/42] notify: add sendmail plugin Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 12/42] notify: add gotify endpoint Lukas Wagner
                   ` (31 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/api/mod.rs      |   7 +
 proxmox-notify/src/api/sendmail.rs | 254 +++++++++++++++++++++++++++++
 2 files changed, 261 insertions(+)
 create mode 100644 proxmox-notify/src/api/sendmail.rs

diff --git a/proxmox-notify/src/api/mod.rs b/proxmox-notify/src/api/mod.rs
index 9e5f15b8..76c98d09 100644
--- a/proxmox-notify/src/api/mod.rs
+++ b/proxmox-notify/src/api/mod.rs
@@ -6,6 +6,8 @@ use serde::Serialize;
 
 pub mod channel;
 pub mod common;
+#[cfg(feature = "sendmail")]
+pub mod sendmail;
 
 #[derive(Debug, Serialize)]
 pub struct ApiError {
@@ -84,6 +86,11 @@ fn verify_digest(config: &Config, digest: Option<&[u8]>) -> Result<(), ApiError>
 fn endpoint_exists(config: &Config, name: &str) -> bool {
     let mut exists = false;
 
+    #[cfg(feature = "sendmail")]
+    {
+        exists = exists || sendmail::get_endpoint(config, name).is_ok();
+    }
+
     exists
 }
 
diff --git a/proxmox-notify/src/api/sendmail.rs b/proxmox-notify/src/api/sendmail.rs
new file mode 100644
index 00000000..458893ae
--- /dev/null
+++ b/proxmox-notify/src/api/sendmail.rs
@@ -0,0 +1,254 @@
+use crate::api::ApiError;
+use crate::endpoints::sendmail::{
+    DeleteableSendmailProperty, SendmailConfig, SendmailConfigUpdater, SENDMAIL_TYPENAME,
+};
+use crate::Config;
+
+/// Get a list of all sendmail endpoints.
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns a list of all sendmail endpoints or an `ApiError` if the config is erroneous.
+pub fn get_endpoints(config: &Config) -> Result<Vec<SendmailConfig>, ApiError> {
+    config
+        .config
+        .convert_to_typed_array(SENDMAIL_TYPENAME)
+        .map_err(|e| ApiError::internal_server_error("Could not fetch endpoints", Some(e.into())))
+}
+
+/// Get sendmail endpoint with given `name`.
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns the endpoint or an `ApiError` if the endpoint was not found.
+pub fn get_endpoint(config: &Config, name: &str) -> Result<SendmailConfig, ApiError> {
+    config
+        .config
+        .lookup(SENDMAIL_TYPENAME, name)
+        .map_err(|_| ApiError::not_found(format!("endpoint '{name}' not found"), None))
+}
+
+/// Add a new sendmail endpoint.
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns an `ApiError` if an endpoint with the same name already exists,
+/// or if the endpoint could not be saved.
+pub fn add_endpoint(config: &mut Config, endpoint: &SendmailConfig) -> Result<(), ApiError> {
+    if super::endpoint_exists(config, &endpoint.name) {
+        return Err(ApiError::bad_request(
+            format!("endpoint with name '{}' already exists!", &endpoint.name),
+            None,
+        ));
+    }
+
+    config
+        .config
+        .set_data(&endpoint.name, SENDMAIL_TYPENAME, endpoint)
+        .map_err(|e| {
+            ApiError::internal_server_error(
+                format!("could not save endpoint '{}'", endpoint.name),
+                Some(e.into()),
+            )
+        })?;
+
+    Ok(())
+}
+
+/// Update existing sendmail endpoint
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns an `ApiError` if the config could not be saved.
+pub fn update_endpoint(
+    config: &mut Config,
+    name: &str,
+    updater: &SendmailConfigUpdater,
+    delete: Option<&[DeleteableSendmailProperty]>,
+    digest: Option<&[u8]>,
+) -> Result<(), ApiError> {
+    super::verify_digest(config, digest)?;
+
+    let mut endpoint = get_endpoint(config, name)?;
+
+    if let Some(delete) = delete {
+        for deleteable_property in delete {
+            match deleteable_property {
+                DeleteableSendmailProperty::FromAddress => endpoint.from_address = None,
+                DeleteableSendmailProperty::Author => endpoint.author = None,
+                DeleteableSendmailProperty::Comment => endpoint.comment = None,
+            }
+        }
+    }
+
+    if let Some(recipient) = &updater.recipient {
+        endpoint.recipient = recipient.iter().map(String::from).collect();
+    }
+
+    if let Some(from_address) = &updater.from_address {
+        endpoint.from_address = Some(from_address.into());
+    }
+
+    if let Some(author) = &updater.author {
+        endpoint.author = Some(author.into());
+    }
+
+    if let Some(comment) = &updater.comment {
+        endpoint.comment = Some(comment.into());
+    }
+
+    config
+        .config
+        .set_data(name, SENDMAIL_TYPENAME, &endpoint)
+        .map_err(|e| {
+            ApiError::internal_server_error(
+                format!("could not save endpoint '{name}'"),
+                Some(e.into()),
+            )
+        })?;
+
+    Ok(())
+}
+
+/// Delete existing sendmail endpoint
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns an `ApiError` if the endpoint does not exist.
+pub fn delete_endpoint(config: &mut Config, name: &str) -> Result<(), ApiError> {
+    // Check if the endpoint exists
+    let _ = get_endpoint(config, name)?;
+
+    config.config.sections.remove(name);
+
+    Ok(())
+}
+
+#[cfg(test)]
+pub mod tests {
+    use super::*;
+    use crate::api::test_helpers::*;
+
+    pub fn add_sendmail_endpoint_for_test(config: &mut Config, name: &str) -> Result<(), ApiError> {
+        add_endpoint(
+            config,
+            &SendmailConfig {
+                name: name.into(),
+                recipient: vec!["user1@example.com".into()],
+                from_address: Some("from@example.com".into()),
+                author: Some("root".into()),
+                comment: Some("Comment".into()),
+            },
+        )?;
+
+        assert!(get_endpoint(config, name).is_ok());
+        Ok(())
+    }
+
+    #[test]
+    fn test_sendmail_create() -> Result<(), ApiError> {
+        let mut config = empty_config();
+
+        assert_eq!(get_endpoints(&config)?.len(), 0);
+        add_sendmail_endpoint_for_test(&mut config, "sendmail-endpoint")?;
+
+        // Endpoints must have a unique name
+        assert!(add_sendmail_endpoint_for_test(&mut config, "sendmail-endpoint").is_err());
+        assert_eq!(get_endpoints(&config)?.len(), 1);
+        Ok(())
+    }
+
+    #[test]
+    fn test_update_not_existing_returns_error() -> Result<(), ApiError> {
+        let mut config = empty_config();
+
+        assert!(update_endpoint(&mut config, "test", &Default::default(), None, None,).is_err());
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_update_invalid_digest_returns_error() -> Result<(), ApiError> {
+        let mut config = empty_config();
+        add_sendmail_endpoint_for_test(&mut config, "sendmail-endpoint")?;
+
+        assert!(update_endpoint(
+            &mut config,
+            "sendmail-endpoint",
+            &SendmailConfigUpdater {
+                recipient: Some(vec!["user2@example.com".into(), "user3@example.com".into()]),
+                from_address: Some("root@example.com".into()),
+                author: Some("newauthor".into()),
+                comment: Some("new comment".into()),
+            },
+            None,
+            Some(&[0; 32]),
+        )
+        .is_err());
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_sendmail_update() -> Result<(), ApiError> {
+        let mut config = empty_config();
+        add_sendmail_endpoint_for_test(&mut config, "sendmail-endpoint")?;
+
+        let digest = config.digest;
+
+        update_endpoint(
+            &mut config,
+            "sendmail-endpoint",
+            &SendmailConfigUpdater {
+                recipient: Some(vec!["user2@example.com".into(), "user3@example.com".into()]),
+                from_address: Some("root@example.com".into()),
+                author: Some("newauthor".into()),
+                comment: Some("new comment".into()),
+            },
+            None,
+            Some(&digest),
+        )?;
+
+        let endpoint = get_endpoint(&config, "sendmail-endpoint")?;
+
+        assert_eq!(
+            endpoint.recipient,
+            vec![
+                "user2@example.com".to_string(),
+                "user3@example.com".to_string()
+            ]
+        );
+        assert_eq!(endpoint.from_address, Some("root@example.com".to_string()));
+        assert_eq!(endpoint.author, Some("newauthor".to_string()));
+        assert_eq!(endpoint.comment, Some("new comment".to_string()));
+
+        // Test property deletion
+        update_endpoint(
+            &mut config,
+            "sendmail-endpoint",
+            &Default::default(),
+            Some(&[
+                DeleteableSendmailProperty::FromAddress,
+                DeleteableSendmailProperty::Author,
+            ]),
+            None,
+        )?;
+
+        let endpoint = get_endpoint(&config, "sendmail-endpoint")?;
+
+        assert_eq!(endpoint.from_address, None);
+        assert_eq!(endpoint.author, None);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_sendmail_delete() -> Result<(), ApiError> {
+        let mut config = empty_config();
+        add_sendmail_endpoint_for_test(&mut config, "sendmail-endpoint")?;
+
+        delete_endpoint(&mut config, "sendmail-endpoint")?;
+        assert!(delete_endpoint(&mut config, "sendmail-endpoint").is_err());
+        assert_eq!(get_endpoints(&config)?.len(), 0);
+
+        Ok(())
+    }
+}
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 12/42] notify: add gotify endpoint
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (10 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 11/42] notify: api: add API for sendmail endpoints Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 13/42] notify: api: add API for gotify endpoints Lukas Wagner
                   ` (30 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Add an endpoint for Gotify [1], showing the how easy it is to add new
endpoint implementations.

[1] https://gotify.net/

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/Cargo.toml              |   6 +-
 proxmox-notify/src/config.rs           |  22 +++++
 proxmox-notify/src/endpoints/gotify.rs | 116 +++++++++++++++++++++++++
 proxmox-notify/src/endpoints/mod.rs    |   2 +
 proxmox-notify/src/lib.rs              |  17 +++-
 5 files changed, 160 insertions(+), 3 deletions(-)
 create mode 100644 proxmox-notify/src/endpoints/gotify.rs

diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index f7329295..738674ae 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -12,6 +12,7 @@ handlebars = { workspace = true, optional = true }
 lazy_static.workspace = true
 log.workspace = true
 openssl.workspace = true
+proxmox-http = { workspace = true, features = ["client-sync"], optional = true }
 proxmox-schema = { workspace = true, features = ["api-macro"]}
 proxmox-section-config = { workspace = true }
 proxmox-sys = { workspace = true, optional = true }
@@ -20,5 +21,6 @@ serde = { workspace = true, features = ["derive"]}
 serde_json.workspace = true
 
 [features]
-default = ["sendmail"]
-sendmail = ["dep:handlebars", "dep:proxmox-sys"]
\ No newline at end of file
+default = ["sendmail", "gotify"]
+sendmail = ["dep:handlebars", "dep:proxmox-sys"]
+gotify = ["dep:proxmox-http"]
diff --git a/proxmox-notify/src/config.rs b/proxmox-notify/src/config.rs
index 2ef237fa..a73b7849 100644
--- a/proxmox-notify/src/config.rs
+++ b/proxmox-notify/src/config.rs
@@ -25,7 +25,17 @@ fn config_init() -> SectionConfig {
             SENDMAIL_SCHEMA,
         ));
     }
+    #[cfg(feature = "gotify")]
+    {
+        use crate::endpoints::gotify::{GotifyConfig, GOTIFY_TYPENAME};
 
+        const GOTIFY_SCHEMA: &ObjectSchema = GotifyConfig::API_SCHEMA.unwrap_object_schema();
+        config.register_plugin(SectionConfigPlugin::new(
+            GOTIFY_TYPENAME.to_string(),
+            Some(String::from("name")),
+            GOTIFY_SCHEMA,
+        ));
+    }
     const CHANNEL_SCHEMA: &ObjectSchema = ChannelConfig::API_SCHEMA.unwrap_object_schema();
 
     config.register_plugin(SectionConfigPlugin::new(
@@ -40,6 +50,18 @@ fn config_init() -> SectionConfig {
 fn private_config_init() -> SectionConfig {
     let mut config = SectionConfig::new(&BACKEND_NAME_SCHEMA);
 
+    #[cfg(feature = "gotify")]
+    {
+        use crate::endpoints::gotify::{GotifyPrivateConfig, GOTIFY_TYPENAME};
+
+        const GOTIFY_SCHEMA: &ObjectSchema = GotifyPrivateConfig::API_SCHEMA.unwrap_object_schema();
+        config.register_plugin(SectionConfigPlugin::new(
+            GOTIFY_TYPENAME.to_string(),
+            Some(String::from("name")),
+            GOTIFY_SCHEMA,
+        ));
+    }
+
     config
 }
 
diff --git a/proxmox-notify/src/endpoints/gotify.rs b/proxmox-notify/src/endpoints/gotify.rs
new file mode 100644
index 00000000..74dd4868
--- /dev/null
+++ b/proxmox-notify/src/endpoints/gotify.rs
@@ -0,0 +1,116 @@
+use std::collections::HashMap;
+
+use crate::schema::{COMMENT_SCHEMA, ENTITY_NAME_SCHEMA};
+use crate::{Endpoint, Error, Notification, Severity};
+
+use serde::{Deserialize, Serialize};
+
+use proxmox_http::client::sync::Client;
+use proxmox_http::{HttpClient, HttpOptions};
+use proxmox_schema::{api, Updater};
+
+#[derive(Serialize)]
+struct GotifyMessageBody<'a> {
+    title: &'a str,
+    message: &'a str,
+    priority: u32,
+}
+
+fn severity_to_priority(level: Severity) -> u32 {
+    match level {
+        Severity::Info => 1,
+        Severity::Notice => 3,
+        Severity::Warning => 5,
+        Severity::Error => 9,
+    }
+}
+
+pub(crate) const GOTIFY_TYPENAME: &str = "gotify";
+
+#[api(
+    properties: {
+        name: {
+            schema: ENTITY_NAME_SCHEMA,
+        },
+        comment: {
+            optional: true,
+            schema: COMMENT_SCHEMA,
+        },
+    }
+)]
+#[derive(Serialize, Deserialize, Updater)]
+#[serde(rename_all = "kebab-case")]
+/// Config for  Gotify notification endpoints
+pub struct GotifyConfig {
+    /// Name of the endpoint
+    #[updater(skip)]
+    pub name: String,
+    /// Gotify Server URL
+    pub server: String,
+    /// Comment
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub comment: Option<String>,
+}
+
+#[api()]
+#[derive(Serialize, Deserialize, Clone, Updater)]
+#[serde(rename_all = "kebab-case")]
+/// Private configuration for Gotify notification endpoints.
+/// This config will be saved to a separate configuration file with stricter
+/// permissions (root:root 0600)
+pub struct GotifyPrivateConfig {
+    /// Name of the endpoint
+    #[updater(skip)]
+    pub name: String,
+    /// Authentication token
+    pub token: String,
+}
+
+/// A Gotify notification endpoint.
+pub struct GotifyEndpoint {
+    pub config: GotifyConfig,
+    pub private_config: GotifyPrivateConfig,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum DeleteableGotifyProperty {
+    Comment,
+}
+
+impl Endpoint for GotifyEndpoint {
+    fn send(&self, notification: &Notification) -> Result<(), Error> {
+        // TODO: What about proxy configuration?
+        let client = Client::new(HttpOptions::default());
+
+        let uri = format!("{}/message", self.config.server);
+
+        let body = GotifyMessageBody {
+            title: &notification.title,
+            message: &notification.body,
+            priority: severity_to_priority(notification.severity),
+        };
+
+        let body = serde_json::to_vec(&body)
+            .map_err(|err| Error::NotifyFailed(self.name().to_string(), err.into()))?;
+        let extra_headers = HashMap::from([(
+            "Authorization".into(),
+            format!("Bearer {}", self.private_config.token),
+        )]);
+
+        client
+            .post(
+                &uri,
+                Some(body.as_slice()),
+                Some("application/json"),
+                Some(&extra_headers),
+            )
+            .map_err(|err| Error::NotifyFailed(self.name().to_string(), err.into()))?;
+
+        Ok(())
+    }
+
+    fn name(&self) -> &str {
+        &self.config.name
+    }
+}
diff --git a/proxmox-notify/src/endpoints/mod.rs b/proxmox-notify/src/endpoints/mod.rs
index dd80d9bc..d1cec654 100644
--- a/proxmox-notify/src/endpoints/mod.rs
+++ b/proxmox-notify/src/endpoints/mod.rs
@@ -1,2 +1,4 @@
+#[cfg(feature = "gotify")]
+pub mod gotify;
 #[cfg(feature = "sendmail")]
 pub mod sendmail;
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index ee89d100..147dbc3c 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -213,7 +213,6 @@ impl Bus {
         let mut endpoints = Vec::new();
 
         // Instantiate endpoints
-
         #[cfg(feature = "sendmail")]
         {
             use endpoints::sendmail::SENDMAIL_TYPENAME;
@@ -229,6 +228,22 @@ impl Bus {
             );
         }
 
+        #[cfg(features = "gotify")]
+        {
+            use endpoints::sendmail::GOTIFY_TYPENAME;
+            use endpoints::sendmail::{GotifyConfig, GotifyEndpoint, GotifyPriateConfig};
+            endpoints.extend(
+                parse_endpoints_with_private_config!(
+                    config,
+                    GotifyConfig,
+                    GotifyPrivateConfig,
+                    GotifyEndpoint,
+                    GOTIFY_TYPENAME
+                )?
+                .into_iter(),
+            );
+        }
+
         let channels = config
             .config
             .convert_to_typed_array(CHANNEL_TYPENAME)
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 13/42] notify: api: add API for gotify endpoints
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (11 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 12/42] notify: add gotify endpoint Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 14/42] notify: add notification filter mechanism Lukas Wagner
                   ` (29 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/api/gotify.rs | 284 +++++++++++++++++++++++++++++++
 proxmox-notify/src/api/mod.rs    |   6 +
 2 files changed, 290 insertions(+)
 create mode 100644 proxmox-notify/src/api/gotify.rs

diff --git a/proxmox-notify/src/api/gotify.rs b/proxmox-notify/src/api/gotify.rs
new file mode 100644
index 00000000..fcc1aabf
--- /dev/null
+++ b/proxmox-notify/src/api/gotify.rs
@@ -0,0 +1,284 @@
+use crate::api::ApiError;
+use crate::endpoints::gotify::{
+    DeleteableGotifyProperty, GotifyConfig, GotifyConfigUpdater, GotifyPrivateConfig,
+    GotifyPrivateConfigUpdater, GOTIFY_TYPENAME,
+};
+use crate::Config;
+
+/// Get a list of all gotify endpoints.
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns a list of all gotify endpoints or an `ApiError` if the config is erroneous.
+pub fn get_endpoints(config: &Config) -> Result<Vec<GotifyConfig>, ApiError> {
+    config
+        .config
+        .convert_to_typed_array(GOTIFY_TYPENAME)
+        .map_err(|e| ApiError::internal_server_error("Could not fetch endpoints", Some(e.into())))
+}
+
+/// Get gotify endpoint with given `name`
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns the endpoint or an `ApiError` if the endpoint was not found.
+pub fn get_endpoint(config: &Config, name: &str) -> Result<GotifyConfig, ApiError> {
+    config
+        .config
+        .lookup(GOTIFY_TYPENAME, name)
+        .map_err(|_| ApiError::not_found(format!("endpoint '{name}' not found"), None))
+}
+
+/// Add a new gotify endpoint.
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns an `ApiError` if an endpoint with the same name already exists,
+/// or if the endpoint could not be saved.
+pub fn add_endpoint(
+    config: &mut Config,
+    endpoint_config: &GotifyConfig,
+    private_endpoint_config: &GotifyPrivateConfig,
+) -> Result<(), ApiError> {
+    if endpoint_config.name != private_endpoint_config.name {
+        // Programming error by the user of the crate, thus we panic
+        panic!("name for endpoint config and private config must be identical");
+    }
+
+    if super::endpoint_exists(config, &endpoint_config.name) {
+        return Err(ApiError::bad_request(
+            format!(
+                "endpoint with name '{}' already exists!",
+                endpoint_config.name
+            ),
+            None,
+        ));
+    }
+
+    set_private_config_entry(config, private_endpoint_config)?;
+
+    config
+        .config
+        .set_data(&endpoint_config.name, GOTIFY_TYPENAME, endpoint_config)
+        .map_err(|e| {
+            ApiError::internal_server_error(
+                format!("could not save endpoint '{}'", endpoint_config.name),
+                Some(e.into()),
+            )
+        })?;
+
+    Ok(())
+}
+
+/// Update existing gotify endpoint
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns an `ApiError` if the config could not be saved.
+pub fn update_endpoint(
+    config: &mut Config,
+    name: &str,
+    endpoint_config_updater: &GotifyConfigUpdater,
+    private_endpoint_config_updater: &GotifyPrivateConfigUpdater,
+    delete: Option<&[DeleteableGotifyProperty]>,
+    digest: Option<&[u8]>,
+) -> Result<(), ApiError> {
+    super::verify_digest(config, digest)?;
+
+    let mut endpoint = get_endpoint(config, name)?;
+
+    if let Some(delete) = delete {
+        for deleteable_property in delete {
+            match deleteable_property {
+                DeleteableGotifyProperty::Comment => endpoint.comment = None,
+            }
+        }
+    }
+
+    if let Some(server) = &endpoint_config_updater.server {
+        endpoint.server = server.into();
+    }
+
+    if let Some(token) = &private_endpoint_config_updater.token {
+        set_private_config_entry(
+            config,
+            &GotifyPrivateConfig {
+                name: name.into(),
+                token: token.into(),
+            },
+        )?;
+    }
+
+    if let Some(comment) = &endpoint_config_updater.comment {
+        endpoint.comment = Some(comment.into());
+    }
+
+    config
+        .config
+        .set_data(name, GOTIFY_TYPENAME, &endpoint)
+        .map_err(|e| {
+            ApiError::internal_server_error(
+                format!("could not save endpoint '{name}'"),
+                Some(e.into()),
+            )
+        })?;
+
+    Ok(())
+}
+
+/// Delete existing gotify endpoint
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns an `ApiError` if the endpoint does not exist.
+pub fn delete_gotify_endpoint(config: &mut Config, name: &str) -> Result<(), ApiError> {
+    // Check if the endpoint exists
+    let _ = get_endpoint(config, name)?;
+
+    remove_private_config_entry(config, name)?;
+    config.config.sections.remove(name);
+
+    Ok(())
+}
+
+fn set_private_config_entry(
+    config: &mut Config,
+    private_config: &GotifyPrivateConfig,
+) -> Result<(), ApiError> {
+    config
+        .private_config
+        .set_data(&private_config.name, GOTIFY_TYPENAME, private_config)
+        .map_err(|e| {
+            ApiError::internal_server_error(
+                format!(
+                    "could not save private config for endpoint '{}'",
+                    private_config.name
+                ),
+                Some(e.into()),
+            )
+        })
+}
+
+fn remove_private_config_entry(config: &mut Config, name: &str) -> Result<(), ApiError> {
+    config.private_config.sections.remove(name);
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::api::test_helpers::empty_config;
+
+    pub fn add_default_gotify_endpoint(config: &mut Config) -> Result<(), ApiError> {
+        add_endpoint(
+            config,
+            &GotifyConfig {
+                name: "gotify-endpoint".into(),
+                server: "localhost".into(),
+                comment: Some("comment".into()),
+            },
+            &GotifyPrivateConfig {
+                name: "gotify-endpoint".into(),
+                token: "supersecrettoken".into(),
+            },
+        )?;
+
+        assert!(get_endpoint(config, "gotify-endpoint").is_ok());
+        Ok(())
+    }
+
+    #[test]
+    fn test_update_not_existing_returns_error() -> Result<(), ApiError> {
+        let mut config = empty_config();
+
+        assert!(update_endpoint(
+            &mut config,
+            "test",
+            &Default::default(),
+            &Default::default(),
+            None,
+            None
+        )
+        .is_err());
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_update_invalid_digest_returns_error() -> Result<(), ApiError> {
+        let mut config = empty_config();
+        add_default_gotify_endpoint(&mut config)?;
+
+        assert!(update_endpoint(
+            &mut config,
+            "gotify-endpoint",
+            &Default::default(),
+            &Default::default(),
+            None,
+            Some(&[0; 32])
+        )
+        .is_err());
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_gotify_update() -> Result<(), ApiError> {
+        let mut config = empty_config();
+        add_default_gotify_endpoint(&mut config)?;
+
+        let digest = config.digest;
+
+        update_endpoint(
+            &mut config,
+            "gotify-endpoint",
+            &GotifyConfigUpdater {
+                server: Some("newhost".into()),
+                comment: Some("newcomment".into()),
+            },
+            &GotifyPrivateConfigUpdater {
+                token: Some("changedtoken".into()),
+            },
+            None,
+            Some(&digest),
+        )?;
+
+        let endpoint = get_endpoint(&config, "gotify-endpoint")?;
+
+        assert_eq!(endpoint.server, "newhost".to_string());
+
+        let token = config
+            .private_config
+            .lookup::<GotifyPrivateConfig>(GOTIFY_TYPENAME, "gotify-endpoint")
+            .unwrap()
+            .token;
+
+        assert_eq!(token, "changedtoken".to_string());
+        assert_eq!(endpoint.comment, Some("newcomment".to_string()));
+
+        // Test property deletion
+        update_endpoint(
+            &mut config,
+            "gotify-endpoint",
+            &Default::default(),
+            &Default::default(),
+            Some(&[DeleteableGotifyProperty::Comment]),
+            None,
+        )?;
+
+        let endpoint = get_endpoint(&config, "gotify-endpoint")?;
+        assert_eq!(endpoint.comment, None);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_gotify_endpoint_delete() -> Result<(), ApiError> {
+        let mut config = empty_config();
+        add_default_gotify_endpoint(&mut config)?;
+
+        delete_gotify_endpoint(&mut config, "gotify-endpoint")?;
+        assert!(delete_gotify_endpoint(&mut config, "gotify-endpoint").is_err());
+        assert_eq!(get_endpoints(&config)?.len(), 0);
+
+        Ok(())
+    }
+}
diff --git a/proxmox-notify/src/api/mod.rs b/proxmox-notify/src/api/mod.rs
index 76c98d09..1d249024 100644
--- a/proxmox-notify/src/api/mod.rs
+++ b/proxmox-notify/src/api/mod.rs
@@ -6,6 +6,8 @@ use serde::Serialize;
 
 pub mod channel;
 pub mod common;
+#[cfg(feature = "gotify")]
+pub mod gotify;
 #[cfg(feature = "sendmail")]
 pub mod sendmail;
 
@@ -90,6 +92,10 @@ fn endpoint_exists(config: &Config, name: &str) -> bool {
     {
         exists = exists || sendmail::get_endpoint(config, name).is_ok();
     }
+    #[cfg(feature = "gotify")]
+    {
+        exists = exists || gotify::get_endpoint(config, name).is_ok();
+    }
 
     exists
 }
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 14/42] notify: add notification filter mechanism
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (12 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 13/42] notify: api: add API for gotify endpoints Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 15/42] notify: api: add API for filters Lukas Wagner
                   ` (28 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

This commit adds a way to filter notifications based on a.) severity and
b.) arbitrary metadata property fields. For better demonstration, an example
configuration file follows:

  sendmail: mail
      recipient root@example.org
      filter only-certain-vms-and-errors

  filter: only-certain-vms-or-errors
      mode or
      min-severity error
      sub-filter only-certain-vms
      sub-filter all-but-one-ct

  filter: only-certain-vms
      mode and
      match-property object_type=vm
      sub-filter vm-ids

  filter: vm-ids
      mode or
      match-property object_id=103
      match-property object_id=104

  filter: all-but-one-ct
      mode and
      invert-match true
      match-property object_type=ct
      match-property object_id=110

In plain English, this translates to: "Send mails for all errors, as
well as all events related to VM with the IDs 103 and 104, and also
all events for any container except the one with ID 110".
The example demonstrates how sub-filters and and/or/not operators can be
used to construct filters with high granularity.

Filters are lazily evaluated, and at most once, in case multiple
endpoints/filters use the same (sub-)filter. Furthermore, there
are checks in place so that recursive sub-filter definitions are
detected.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/api/gotify.rs         |   3 +
 proxmox-notify/src/api/sendmail.rs       |   4 +
 proxmox-notify/src/config.rs             |   9 +
 proxmox-notify/src/endpoints/gotify.rs   |  12 +
 proxmox-notify/src/endpoints/sendmail.rs |  12 +
 proxmox-notify/src/filter.rs             | 498 +++++++++++++++++++++++
 proxmox-notify/src/lib.rs                | 142 ++++++-
 7 files changed, 674 insertions(+), 6 deletions(-)
 create mode 100644 proxmox-notify/src/filter.rs

diff --git a/proxmox-notify/src/api/gotify.rs b/proxmox-notify/src/api/gotify.rs
index fcc1aabf..fdb9cf53 100644
--- a/proxmox-notify/src/api/gotify.rs
+++ b/proxmox-notify/src/api/gotify.rs
@@ -89,6 +89,7 @@ pub fn update_endpoint(
         for deleteable_property in delete {
             match deleteable_property {
                 DeleteableGotifyProperty::Comment => endpoint.comment = None,
+                DeleteableGotifyProperty::Filter => endpoint.filter = None,
             }
         }
     }
@@ -174,6 +175,7 @@ mod tests {
                 name: "gotify-endpoint".into(),
                 server: "localhost".into(),
                 comment: Some("comment".into()),
+                filter: None,
             },
             &GotifyPrivateConfig {
                 name: "gotify-endpoint".into(),
@@ -233,6 +235,7 @@ 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/sendmail.rs b/proxmox-notify/src/api/sendmail.rs
index 458893ae..a5379cd3 100644
--- a/proxmox-notify/src/api/sendmail.rs
+++ b/proxmox-notify/src/api/sendmail.rs
@@ -75,6 +75,7 @@ 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,
             }
         }
     }
@@ -136,6 +137,7 @@ pub mod tests {
                 from_address: Some("from@example.com".into()),
                 author: Some("root".into()),
                 comment: Some("Comment".into()),
+                filter: None,
             },
         )?;
 
@@ -178,6 +180,7 @@ 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]),
@@ -202,6 +205,7 @@ 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 a73b7849..6f96446a 100644
--- a/proxmox-notify/src/config.rs
+++ b/proxmox-notify/src/config.rs
@@ -3,6 +3,7 @@ use proxmox_schema::{ApiType, ObjectSchema};
 use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
 
 use crate::channel::{ChannelConfig, CHANNEL_TYPENAME};
+use crate::filter::{FilterConfig, FILTER_TYPENAME};
 use crate::schema::BACKEND_NAME_SCHEMA;
 use crate::Error;
 
@@ -44,6 +45,14 @@ fn config_init() -> SectionConfig {
         CHANNEL_SCHEMA,
     ));
 
+    const FILTER_SCHEMA: &ObjectSchema = FilterConfig::API_SCHEMA.unwrap_object_schema();
+
+    config.register_plugin(SectionConfigPlugin::new(
+        FILTER_TYPENAME.to_string(),
+        Some(String::from("name")),
+        FILTER_SCHEMA,
+    ));
+
     config
 }
 
diff --git a/proxmox-notify/src/endpoints/gotify.rs b/proxmox-notify/src/endpoints/gotify.rs
index 74dd4868..0d306964 100644
--- a/proxmox-notify/src/endpoints/gotify.rs
+++ b/proxmox-notify/src/endpoints/gotify.rs
@@ -36,6 +36,10 @@ pub(crate) const GOTIFY_TYPENAME: &str = "gotify";
             optional: true,
             schema: COMMENT_SCHEMA,
         },
+        filter: {
+            optional: true,
+            schema: ENTITY_NAME_SCHEMA,
+        },
     }
 )]
 #[derive(Serialize, Deserialize, Updater)]
@@ -50,6 +54,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")]
+    pub filter: Option<String>,
 }
 
 #[api()]
@@ -76,6 +83,7 @@ pub struct GotifyEndpoint {
 #[serde(rename_all = "kebab-case")]
 pub enum DeleteableGotifyProperty {
     Comment,
+    Filter,
 }
 
 impl Endpoint for GotifyEndpoint {
@@ -113,4 +121,8 @@ 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 f9b3df83..ee96c10a 100644
--- a/proxmox-notify/src/endpoints/sendmail.rs
+++ b/proxmox-notify/src/endpoints/sendmail.rs
@@ -21,6 +21,10 @@ pub(crate) const SENDMAIL_TYPENAME: &str = "sendmail";
             optional: true,
             schema: COMMENT_SCHEMA,
         },
+        filter: {
+            optional: true,
+            schema: ENTITY_NAME_SCHEMA,
+        },
     },
 )]
 #[derive(Debug, Serialize, Deserialize, Updater)]
@@ -41,6 +45,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")]
+    pub filter: Option<String>,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -49,6 +56,7 @@ pub enum DeleteableSendmailProperty {
     FromAddress,
     Author,
     Comment,
+    Filter,
 }
 
 /// A sendmail notification endpoint.
@@ -85,4 +93,8 @@ 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
new file mode 100644
index 00000000..00614ff1
--- /dev/null
+++ b/proxmox-notify/src/filter.rs
@@ -0,0 +1,498 @@
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::{HashMap, HashSet};
+
+use proxmox_schema::{api, property_string::PropertyIterator, Updater};
+
+use crate::schema::{COMMENT_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,
+        }
+    }
+
+    /// Check if we need to evaluate any other properties, or if we can return early, since
+    /// false AND (...) = false
+    /// true OR (...) = true
+    fn short_circuit_return_possible(&self, value: bool) -> bool {
+        matches!(
+            (self, value),
+            (FilterModeOperator::And, false) | (FilterModeOperator::Or, true)
+        )
+    }
+}
+
+#[api(
+    properties: {
+        name: {
+            schema: ENTITY_NAME_SCHEMA,
+        },
+        "sub-filter": {
+            optional: true,
+            type: Array,
+            items: {
+                schema: ENTITY_NAME_SCHEMA,
+            },
+        },
+        "match-property": {
+            optional: true,
+            type: Array,
+            items: {
+                description: "Notification properties to match",
+                type: String,
+            },
+        },
+        comment: {
+            optional: true,
+            schema: COMMENT_SCHEMA,
+        },
+    })]
+#[derive(Debug, Serialize, Deserialize, Updater)]
+#[serde(rename_all = "kebab-case")]
+/// Config for Sendmail notification endpoints
+pub struct FilterConfig {
+    /// Name of the filter
+    #[updater(skip)]
+    pub name: String,
+
+    /// Minimum severity to match
+    pub min_severity: Option<Severity>,
+
+    /// Sub-filter, allows arbitrary nesting (no recursion allowed)
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub sub_filter: Option<Vec<String>>,
+
+    /// Choose between 'and' and 'or' for when multiple properties are specified
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub mode: Option<FilterModeOperator>,
+
+    /// Notification properties to match.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub match_property: Option<Vec<String>>,
+
+    /// 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,
+    SubFilter,
+    Mode,
+    MatchProperty,
+    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),
+        );
+
+        if mode_operator.short_circuit_return_possible(notification_matches) {
+            return Ok(notification_matches != invert_match);
+        }
+
+        notification_matches = mode_operator.apply(
+            notification_matches,
+            self.check_property_match(filter_config, mode_operator)?,
+        );
+
+        if mode_operator.short_circuit_return_possible(notification_matches) {
+            return Ok(notification_matches != invert_match);
+        }
+
+        if let Some(sub_filters) = &filter_config.sub_filter {
+            for filter in sub_filters {
+                let is_match = self.do_check_filter(filter, visited)?;
+
+                self.cached_results.insert(filter.as_str(), is_match);
+
+                notification_matches = mode_operator.apply(notification_matches, is_match);
+
+                if mode_operator.short_circuit_return_possible(notification_matches) {
+                    return Ok(notification_matches != invert_match);
+                }
+            }
+        }
+
+        Ok(notification_matches != invert_match)
+    }
+
+    fn check_property_match(
+        &self,
+        filter_config: &FilterConfig,
+        mode_operator: FilterModeOperator,
+    ) -> Result<bool, Error> {
+        let mut notification_matches = mode_operator.neutral_element();
+
+        if let Some(match_property_operators) = filter_config.match_property.as_ref() {
+            for op in match_property_operators {
+                for prop in PropertyIterator::new(op) {
+                    let prop = prop.map_err(|err| {
+                        Error::FilterFailed(format!(
+                            "invalid match-property statement '{op}': {err}"
+                        ))
+                    })?;
+
+                    if let (Some(key), expected_value) = prop {
+                        let value = self
+                            .notification
+                            .properties
+                            .as_ref()
+                            .and_then(|v| v.as_object())
+                            .and_then(|m| m.get(key))
+                            .unwrap_or(&Value::Null);
+
+                        let actual_value = match value {
+                            Value::String(s) => Ok(s.clone()),
+                            Value::Array(_) => Err(Error::FilterFailed(
+                                "match-property cannot match arrays".into(),
+                            )),
+                            Value::Object(_) => Err(Error::FilterFailed(
+                                "match-property cannot match objects".into(),
+                            )),
+                            v => Ok(v.to_string()),
+                        }?;
+
+                        notification_matches = mode_operator.apply(
+                            notification_matches,
+                            actual_value.as_str() == expected_value,
+                        );
+
+                        if mode_operator.short_circuit_return_possible(notification_matches) {
+                            return Ok(notification_matches);
+                        }
+                    }
+                }
+            }
+        }
+
+        Ok(notification_matches)
+    }
+
+    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;
+    use serde_json::json;
+
+    #[test]
+    fn test_filter_config_parses_correctly() -> Result<(), Error> {
+        let (c, _) = config::config(
+            r"
+filter: foo
+    min-severity info
+    match-property object_type=vm
+    match-property object_id=103
+    invert-match true
+    mode and
+
+filter: bar
+    min-severity warning
+    match-property object_type=ct,object_id=104
+    sub-filter foo
+    mode or
+",
+        )?;
+
+        let filters: Vec<FilterConfig> = c.convert_to_typed_array("filter").unwrap();
+
+        assert_eq!(filters.len(), 2);
+
+        Ok(())
+    }
+
+    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 {
+            title: String::new(),
+            body: String::new(),
+            severity,
+            properties: Default::default(),
+        }
+    }
+
+    fn empty_notification_with_metadata(metadata: Value) -> Notification {
+        Notification {
+            title: String::new(),
+            body: String::new(),
+            severity: Severity::Error,
+            properties: Some(metadata),
+        }
+    }
+
+    #[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, &notification);
+            results.check_filter_match("test")
+        };
+
+        assert!(is_match(Severity::Warning)?);
+        assert!(!is_match(Severity::Notice)?);
+        assert!(is_match(Severity::Error)?);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_recursive_filter_loop() -> Result<(), Error> {
+        let config = "
+filter: direct-a
+    sub-filter direct-b
+
+filter: direct-b
+    sub-filter direct-a
+
+filter: indirect-c
+    sub-filter indirect-d
+
+filter: indirect-d
+    sub-filter indirect-e
+
+filter: indirect-e
+    sub-filter indirect-c
+";
+
+        let filters = parse_filters(config)?;
+
+        let notification = empty_notification_with_severity(Severity::Info);
+        let mut results = FilterMatcher::new(&filters, &notification);
+        assert!(results.check_filter_match("direct-a").is_err());
+        assert!(results.check_filter_match("indirect-c").is_err());
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_property_matches() -> Result<(), Error> {
+        let config = "
+filter: test
+    match-property object_type=vm
+
+filter: multiple-and
+    mode and
+    match-property a=foo,b=bar
+    match-property c=lorem,d=ipsum
+
+filter: multiple-or
+    mode or
+    match-property a=foo,b=bar
+    match-property c=lorem,d=ipsum
+    ";
+        let filters = parse_filters(config)?;
+
+        let is_match = |filter, metadata| -> Result<bool, Error> {
+            let notification = empty_notification_with_metadata(metadata);
+            let mut results = FilterMatcher::new(&filters, &notification);
+            results.check_filter_match(filter)
+        };
+
+        assert!(is_match(
+            "test",
+            json!({
+                "object_type": "vm"
+            })
+        )?);
+        assert!(!is_match("test", json!({"object_type": "ct"}))?);
+        assert!(is_match(
+            "multiple-and",
+            json!({"a": "foo", "b": "bar", "c": "lorem", "d": "ipsum"}),
+        )?);
+        assert!(!is_match(
+            "multiple-and",
+            json!({
+                "a": "invalid",
+                "b": "bar",
+                "c": "lorem",
+                "d": "ipsum"
+            }),
+        )?);
+        assert!(!is_match("multiple-and", json!({"a": "foo", "b": "bar"}))?);
+        assert!(is_match("multiple-or", json!({"a": "foo"}))?);
+        assert!(is_match("multiple-or", json!({"a": "foo"}))?);
+        assert!(is_match("multiple-or", json!({"b": "bar"}))?);
+        assert!(is_match("multiple-or", json!({"d": "ipsum"}))?);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_invert_match() -> Result<(), Error> {
+        let config = "
+filter: test
+    match-property object_type=vm
+    invert-match true
+    ";
+        let filters = parse_filters(config)?;
+
+        let notification = empty_notification_with_metadata(json!({"object_type": "vm"}));
+        let mut results = FilterMatcher::new(&filters, &notification);
+        assert!(!results.check_filter_match("test")?);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_invert_match_early_return() -> Result<(), Error> {
+        let config = "
+filter: sub1
+    match-property object_type=vm
+
+filter: sub2
+    match-property object_type=vm
+
+filter: test
+    mode or
+    sub-filter sub1
+    sub-filter sub2
+    invert-match true
+    ";
+        let filters = parse_filters(config)?;
+
+        let notification = empty_notification_with_metadata(json!({"object_type": "vm"}));
+        let mut results = FilterMatcher::new(&filters, &notification);
+        assert!(!results.check_filter_match("test")?);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_sub_filter_matches() -> Result<(), Error> {
+        let config = "
+filter: test
+    match-property object_type=vm
+    sub-filter vm-ids
+
+filter: vm-ids
+    mode or
+    match-property object_id=100
+    match-property object_id=101
+    ";
+        let filters = parse_filters(config)?;
+
+        let is_match = |metadata| -> Result<bool, Error> {
+            let notification = empty_notification_with_metadata(metadata);
+            let mut results = FilterMatcher::new(&filters, &notification);
+            results.check_filter_match("test")
+        };
+
+        assert!(is_match(json!({"object_type": "vm", "object_id": "100"}))?);
+        assert!(is_match(json!({"object_type": "vm", "object_id": "101"}))?);
+        assert!(!is_match(json!({"object_type": "ct", "object_id": "101"}))?);
+        assert!(!is_match(json!({"object_type": "vm", "object_id": "111"}))?);
+
+        Ok(())
+    }
+}
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index 147dbc3c..33d3dbc7 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -1,6 +1,7 @@
 use std::fmt::Display;
 
 use channel::{ChannelConfig, CHANNEL_TYPENAME};
+use filter::{FilterConfig, FilterMatcher, FILTER_TYPENAME};
 use proxmox_schema::api;
 use proxmox_section_config::SectionConfigData;
 use serde::{Deserialize, Serialize};
@@ -13,6 +14,7 @@ pub mod api;
 pub mod channel;
 mod config;
 pub mod endpoints;
+mod filter;
 pub mod schema;
 
 #[derive(Debug)]
@@ -22,6 +24,7 @@ pub enum Error {
     NotifyFailed(String, Box<dyn StdError + Send + Sync + 'static>),
     EndpointDoesNotExist(String),
     ChannelDoesNotExist(String),
+    FilterFailed(String),
 }
 
 impl Display for Error {
@@ -42,6 +45,9 @@ impl Display for Error {
             Error::ChannelDoesNotExist(channel) => {
                 write!(f, "channel '{channel}' does not exist")
             }
+            Error::FilterFailed(message) => {
+                write!(f, "could not apply filter: {message}")
+            }
         }
     }
 }
@@ -54,6 +60,7 @@ impl StdError for Error {
             Error::NotifyFailed(_, err) => Some(&**err),
             Error::EndpointDoesNotExist(_) => None,
             Error::ChannelDoesNotExist(_) => None,
+            Error::FilterFailed(_) => None,
         }
     }
 }
@@ -80,6 +87,9 @@ 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)]
@@ -150,6 +160,7 @@ impl Config {
 pub struct Bus {
     endpoints: Vec<Box<dyn Endpoint>>,
     channels: Vec<ChannelConfig>,
+    filters: Vec<FilterConfig>,
 }
 
 #[allow(unused_macros)]
@@ -249,9 +260,15 @@ impl Bus {
             .convert_to_typed_array(CHANNEL_TYPENAME)
             .map_err(|err| Error::ConfigDeserialization(err.into()))?;
 
+        let filters = config
+            .config
+            .convert_to_typed_array(FILTER_TYPENAME)
+            .map_err(|err| Error::ConfigDeserialization(err.into()))?;
+
         Ok(Bus {
             endpoints,
             channels,
+            filters,
         })
     }
 
@@ -265,6 +282,11 @@ impl Bus {
         self.channels.push(channel);
     }
 
+    #[cfg(test)]
+    pub fn add_filter(&mut self, filter: FilterConfig) {
+        self.filters.push(filter)
+    }
+
     pub fn send(&self, channel: &str, notification: &Notification) -> Result<(), Error> {
         log::debug!(
             "sending notification with title `{title}`",
@@ -279,6 +301,8 @@ impl Bus {
             .find(|c| c.name == channel)
             .ok_or(Error::ChannelDoesNotExist(channel.into()))?;
 
+        let mut notification_filter = FilterMatcher::new(&self.filters, notification);
+
         for endpoint in &self.endpoints {
             if !channel.should_notify_via_endpoint(endpoint.name()) {
                 log::debug!(
@@ -289,13 +313,37 @@ impl Bus {
                 continue;
             }
 
-            if let Err(e) = endpoint.send(notification) {
-                log::error!(
-                    "could not notfiy via endpoint `{name}`: {e}",
-                    name = endpoint.name()
-                );
+            let should_notify = if let Some(filter) = endpoint.filter() {
+                notification_filter
+                    .check_filter_match(filter)
+                    .unwrap_or_else(|e| {
+                        log::error!(
+                            "could not apply filter `{filter}` for endpoint `{name}: {e}`",
+                            name = endpoint.name()
+                        );
+                        // If the filter is somehow erroneous, we send a notification by default,
+                        // so no events are missed
+                        true
+                    })
+            } else {
+                true
+            };
+
+            if should_notify {
+                if let Err(e) = endpoint.send(notification) {
+                    log::error!(
+                        "could not notify via endpoint `{name}`: {e}",
+                        name = endpoint.name()
+                    );
+                } else {
+                    log::info!("notified via endpoint `{name}`", name = endpoint.name());
+                }
             } else {
-                log::info!("notified via endpoint `{name}`", name = endpoint.name());
+                log::debug!(
+                    "skipped endpoint `{name}`, filter `{filter}` did not match",
+                    name = endpoint.name(),
+                    filter = endpoint.filter().unwrap_or_default()
+                );
             }
         }
 
@@ -330,6 +378,7 @@ mod tests {
     struct MockEndpoint {
         name: &'static str,
         messages: Rc<RefCell<Vec<Notification>>>,
+        filter: Option<String>,
     }
 
     impl Endpoint for MockEndpoint {
@@ -342,12 +391,17 @@ 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 {
             Self {
                 name,
+                filter,
                 ..Default::default()
             }
         }
@@ -430,4 +484,80 @@ mod tests {
 
         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()));
+
+        let mut bus = Bus::default();
+
+        bus.add_endpoint(Box::new(endpoint1.clone()));
+        bus.add_endpoint(Box::new(endpoint2.clone()));
+
+        bus.add_channel(ChannelConfig {
+            name: "channel1".to_string(),
+            endpoint: Some(vec!["mock1".into(), "mock2".into()]),
+            comment: None,
+        });
+
+        bus.add_filter(FilterConfig {
+            name: "filter1".into(),
+            min_severity: Some(Severity::Warning),
+            sub_filter: None,
+            mode: None,
+            match_property: None,
+            invert_match: None,
+            comment: None,
+        });
+
+        bus.add_filter(FilterConfig {
+            name: "filter2".into(),
+            min_severity: Some(Severity::Error),
+            sub_filter: None,
+            mode: None,
+            match_property: None,
+            invert_match: None,
+            comment: None,
+        });
+
+        let send_with_severity = |severity| {
+            bus.send(
+                "channel1",
+                &Notification {
+                    title: "Title".into(),
+                    body: "Body".into(),
+                    severity,
+                    properties: Default::default(),
+                },
+            )
+            .unwrap();
+        };
+
+        send_with_severity(Severity::Info);
+        assert_eq!(endpoint1.messages().len(), 0);
+        assert_eq!(endpoint2.messages().len(), 0);
+
+        send_with_severity(Severity::Warning);
+        assert_eq!(endpoint1.messages().len(), 1);
+        assert_eq!(endpoint2.messages().len(), 0);
+
+        send_with_severity(Severity::Error);
+        assert_eq!(endpoint1.messages().len(), 2);
+        assert_eq!(endpoint2.messages().len(), 1);
+
+        Ok(())
+    }
 }
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 15/42] notify: api: add API for filters
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (13 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 14/42] notify: add notification filter mechanism Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 16/42] notify: add template rendering Lukas Wagner
                   ` (27 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/api/filter.rs   | 366 +++++++++++++++++++++++++++++
 proxmox-notify/src/api/gotify.rs   |   7 +
 proxmox-notify/src/api/mod.rs      |   1 +
 proxmox-notify/src/api/sendmail.rs |   5 +
 4 files changed, 379 insertions(+)
 create mode 100644 proxmox-notify/src/api/filter.rs

diff --git a/proxmox-notify/src/api/filter.rs b/proxmox-notify/src/api/filter.rs
new file mode 100644
index 00000000..3d80778f
--- /dev/null
+++ b/proxmox-notify/src/api/filter.rs
@@ -0,0 +1,366 @@
+use crate::api::ApiError;
+use crate::filter::{DeleteableFilterProperty, FilterConfig, FilterConfigUpdater, FILTER_TYPENAME};
+use crate::Config;
+use std::collections::HashSet;
+
+/// Get a list of all filters
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns a list of all filters or an `ApiError` if the config is erroneous.
+pub fn get_filters(config: &Config) -> Result<Vec<FilterConfig>, ApiError> {
+    config
+        .config
+        .convert_to_typed_array(FILTER_TYPENAME)
+        .map_err(|e| ApiError::internal_server_error("Could not fetch filters", Some(e.into())))
+}
+
+/// Get filter with given `name`
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns the endpoint or an `ApiError` if the filter was not found.
+pub fn get_filter(config: &Config, name: &str) -> Result<FilterConfig, ApiError> {
+    config
+        .config
+        .lookup(FILTER_TYPENAME, name)
+        .map_err(|_| ApiError::not_found(format!("filter '{name}' not found"), None))
+}
+
+/// Add new notification filter.
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns an `ApiError` if a filter with the same name already exists,
+/// if the filter could not be saved, or if the included sub-filter leads to
+/// a filter recursion.
+pub fn add_filter(config: &mut Config, filter_config: &FilterConfig) -> Result<(), ApiError> {
+    if get_filter(config, &filter_config.name).is_ok() {
+        return Err(ApiError::bad_request(
+            format!("filter '{}' already exists", filter_config.name),
+            None,
+        ));
+    }
+
+    if let Some(sub_filters) = filter_config.sub_filter.as_ref() {
+        let sub_filters = sub_filters
+            .iter()
+            .map(|s| s.as_str())
+            .collect::<Vec<&str>>();
+        check_for_filter_recursion(config, &filter_config.name, &sub_filters)?;
+    }
+
+    config
+        .config
+        .set_data(&filter_config.name, FILTER_TYPENAME, filter_config)
+        .map_err(|e| {
+            ApiError::internal_server_error(
+                format!("could not save filter '{}'", filter_config.name),
+                Some(e.into()),
+            )
+        })?;
+
+    Ok(())
+}
+
+/// Update existing filter
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns an `ApiError` if the config could not be saved, or if one of
+/// the sub-filters leads to a recursive filter definition.
+pub fn update_filter(
+    config: &mut Config,
+    name: &str,
+    filter_updater: &FilterConfigUpdater,
+    delete: Option<&[DeleteableFilterProperty]>,
+    digest: Option<&[u8]>,
+) -> Result<(), ApiError> {
+    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::SubFilter => filter.sub_filter = None,
+                DeleteableFilterProperty::Mode => filter.mode = None,
+                DeleteableFilterProperty::MatchProperty => filter.match_property = 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(sub_filter) = &filter_updater.sub_filter {
+        let sub_filters = sub_filter.iter().map(|s| s.as_str()).collect::<Vec<&str>>();
+        check_for_filter_recursion(config, name, &sub_filters)?;
+        filter.sub_filter = Some(sub_filter.iter().map(String::from).collect());
+    }
+
+    if let Some(mode) = filter_updater.mode {
+        filter.mode = Some(mode);
+    }
+
+    if let Some(match_property) = &filter_updater.match_property {
+        filter.match_property = Some(match_property.iter().map(String::from).collect());
+    }
+
+    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| {
+            ApiError::internal_server_error(
+                format!("could not save filter '{name}'"),
+                Some(e.into()),
+            )
+        })?;
+
+    Ok(())
+}
+
+/// Delete existing filter
+///
+/// The caller is responsible for any needed permission checks.
+/// The caller also responsible for locking the configuration files.
+/// Returns an `ApiError` if the filter does not exist.
+pub fn delete_filter(config: &mut Config, name: &str) -> Result<(), ApiError> {
+    // Check if the filter exists
+    let _ = get_filter(config, name)?;
+
+    config.config.sections.remove(name);
+
+    Ok(())
+}
+
+fn check_for_filter_recursion(
+    config: &Config,
+    filter: &str,
+    new_sub_filters: &[&str],
+) -> Result<(), ApiError> {
+    for sub_filter in new_sub_filters {
+        let mut visited = HashSet::new();
+
+        // Add the the filter we're currently adding/updating as a starting point,
+        // since it has not been saved in the configuration
+        visited.insert(filter.to_string());
+        do_check_for_filter_recursion(config, sub_filter, &mut visited)?;
+    }
+
+    Ok(())
+}
+
+fn do_check_for_filter_recursion(
+    config: &Config,
+    filter: &str,
+    visited: &mut HashSet<String>,
+) -> Result<(), ApiError> {
+    if visited.contains(filter) {
+        return Err(ApiError::bad_request(
+            format!("recursion in sub-filter detected: {filter}"),
+            None,
+        ));
+    }
+
+    visited.insert(filter.to_string());
+
+    let filter = get_filter(config, filter)?;
+
+    if let Some(sub_filters) = &filter.sub_filter {
+        for sub_filter in sub_filters {
+            do_check_for_filter_recursion(config, sub_filter, visited)?;
+        }
+    }
+
+    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<(), ApiError> {
+        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<(), ApiError> {
+        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<(), ApiError> {
+        let mut config = config_with_two_filters();
+
+        let digest = config.digest;
+
+        update_filter(
+            &mut config,
+            "filter1",
+            &FilterConfigUpdater {
+                min_severity: Some(Severity::Error),
+                sub_filter: Some(vec!["filter2".into()]),
+                mode: Some(FilterModeOperator::Or),
+                match_property: Some(vec!["foo=bar".into()]),
+                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.match_property, Some(vec!["foo=bar".into()]));
+        assert_eq!(filter.invert_match, Some(true));
+        assert_eq!(filter.sub_filter, Some(vec!["filter2".into()]));
+        assert_eq!(filter.comment, Some("new comment".into()));
+
+        // Test property deletion
+        update_filter(
+            &mut config,
+            "filter1",
+            &Default::default(),
+            Some(&[
+                DeleteableFilterProperty::InvertMatch,
+                DeleteableFilterProperty::SubFilter,
+                DeleteableFilterProperty::Mode,
+                DeleteableFilterProperty::InvertMatch,
+                DeleteableFilterProperty::MinSeverity,
+                DeleteableFilterProperty::MatchProperty,
+                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.match_property, None);
+        assert_eq!(filter.sub_filter, None);
+        assert_eq!(filter.comment, None);
+
+        // Adding a non-existing sub-filter must fail
+        assert!(update_filter(
+            &mut config,
+            "filter1",
+            &FilterConfigUpdater {
+                sub_filter: Some(vec!["filter3".into()]),
+                ..Default::default()
+            },
+            None,
+            Some(&digest),
+        )
+        .is_err());
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_filter_delete() -> Result<(), ApiError> {
+        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(())
+    }
+
+    #[test]
+    fn test_recursive_subfilter_definition() -> Result<(), ApiError> {
+        let mut config = Config::new(
+            "
+filter: filter-a
+    sub-filter filter-b
+
+filter: filter-b
+
+filter: filter-e
+    sub-filter filter-f
+
+filter: filter-f
+    sub-filter filter-e
+        ",
+            "",
+        )
+        .unwrap();
+
+        // Newly created recursion should be detected
+        assert!(update_filter(
+            &mut config,
+            "filter-b",
+            &FilterConfigUpdater {
+                sub_filter: Some(vec!["filter-a".into()]),
+                ..Default::default()
+            },
+            None,
+            None,
+        )
+        .is_err());
+
+        // Existing recursions should also be detected, in case the
+        // configuration file was modified by hand.
+        assert!(update_filter(
+            &mut config,
+            "filter-c",
+            &FilterConfigUpdater {
+                sub_filter: Some(vec!["filter-e".into()]),
+                ..Default::default()
+            },
+            None,
+            None,
+        )
+        .is_err());
+
+        Ok(())
+    }
+}
diff --git a/proxmox-notify/src/api/gotify.rs b/proxmox-notify/src/api/gotify.rs
index fdb9cf53..48051200 100644
--- a/proxmox-notify/src/api/gotify.rs
+++ b/proxmox-notify/src/api/gotify.rs
@@ -112,6 +112,13 @@ 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)
diff --git a/proxmox-notify/src/api/mod.rs b/proxmox-notify/src/api/mod.rs
index 1d249024..65dbc97c 100644
--- a/proxmox-notify/src/api/mod.rs
+++ b/proxmox-notify/src/api/mod.rs
@@ -6,6 +6,7 @@ use serde::Serialize;
 
 pub mod channel;
 pub mod common;
+pub mod filter;
 #[cfg(feature = "gotify")]
 pub mod gotify;
 #[cfg(feature = "sendmail")]
diff --git a/proxmox-notify/src/api/sendmail.rs b/proxmox-notify/src/api/sendmail.rs
index a5379cd3..85b73a39 100644
--- a/proxmox-notify/src/api/sendmail.rs
+++ b/proxmox-notify/src/api/sendmail.rs
@@ -96,6 +96,11 @@ 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());
+    }
+
     config
         .config
         .set_data(name, SENDMAIL_TYPENAME, &endpoint)
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 16/42] notify: add template rendering
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (14 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 15/42] notify: api: add API for filters Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 17/42] notify: add example for " Lukas Wagner
                   ` (26 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

This commit adds template rendering to the `proxmox-notify` crate, based
on the `handlebars` crate.

Title and body of a notification are rendered using any `properties`
passed along with the notification. There are also a few helpers,
allowing to render tables from `serde_json::Value`.

'Value' renderers. These can also be used in table cells using the
'renderer' property in a table schema:
  - {{human-bytes val}}
    Render bytes with human-readable units (base 2)
  - {{duration val}}
    Render a duration (based on seconds)
  - {{timestamp val}}
    Render a unix-epoch (based on seconds)

There are also a few 'block-level' helpers.
  - {{table val}}
    Render a table from given val (containing a schema for the columns,
    as well as the table data)
  - {{object val}}
    Render a value as a pretty-printed json
  - {{heading_1 val}}
    Render a top-level heading
  - {{heading_2 val}}
    Render a not-so-top-level heading
  - {{verbatim val}} or {{/verbatim}}<content>{{#verbatim}}
    Do not reflow text. NOP for plain text, but for HTML output the text
    will be contained in a <pre> with a regular font.
  - {{verbatim-monospaced val}} or
      {{/verbatim-monospaced}}<content>{{#verbatim-monospaced}}
    Do not reflow text. NOP for plain text, but for HTML output the text
    will be contained in a <pre> with a monospaced font.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 Cargo.toml                               |   1 +
 proxmox-notify/Cargo.toml                |   6 +-
 proxmox-notify/src/endpoints/gotify.rs   |  17 +-
 proxmox-notify/src/endpoints/sendmail.rs |  26 +-
 proxmox-notify/src/lib.rs                |   6 +-
 proxmox-notify/src/renderer/html.rs      | 100 +++++++
 proxmox-notify/src/renderer/mod.rs       | 359 +++++++++++++++++++++++
 proxmox-notify/src/renderer/plaintext.rs | 141 +++++++++
 proxmox-notify/src/renderer/table.rs     |  24 ++
 9 files changed, 664 insertions(+), 16 deletions(-)
 create mode 100644 proxmox-notify/src/renderer/html.rs
 create mode 100644 proxmox-notify/src/renderer/mod.rs
 create mode 100644 proxmox-notify/src/renderer/plaintext.rs
 create mode 100644 proxmox-notify/src/renderer/table.rs

diff --git a/Cargo.toml b/Cargo.toml
index 1003022e..20b530d8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -89,6 +89,7 @@ proxmox-api-macro = { version = "1.0.4", path = "proxmox-api-macro" }
 proxmox-async = { version = "0.4.1", path = "proxmox-async" }
 proxmox-compression = { version = "0.2.0", path = "proxmox-compression" }
 proxmox-http = { version = "0.9.0", path = "proxmox-http" }
+proxmox-human-byte = { version = "0.1.0", path = "proxmox-human-byte" }
 proxmox-io = { version = "1.0.0", path = "proxmox-io" }
 proxmox-lang = { version = "1.1", path = "proxmox-lang" }
 proxmox-rest-server = { version = "0.4.0", path = "proxmox-rest-server" }
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index 738674ae..a635798b 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -8,19 +8,21 @@ repository.workspace = true
 exclude.workspace = true
 
 [dependencies]
-handlebars = { workspace = true, optional = true }
+handlebars = { workspace = true }
 lazy_static.workspace = true
 log.workspace = true
 openssl.workspace = true
 proxmox-http = { workspace = true, features = ["client-sync"], optional = true }
+proxmox-human-byte.workspace = true
 proxmox-schema = { workspace = true, features = ["api-macro"]}
 proxmox-section-config = { workspace = true }
 proxmox-sys = { workspace = true, optional = true }
+proxmox-time.workspace = true
 regex.workspace = true
 serde = { workspace = true, features = ["derive"]}
 serde_json.workspace = true
 
 [features]
 default = ["sendmail", "gotify"]
-sendmail = ["dep:handlebars", "dep:proxmox-sys"]
+sendmail = ["dep:proxmox-sys"]
 gotify = ["dep:proxmox-http"]
diff --git a/proxmox-notify/src/endpoints/gotify.rs b/proxmox-notify/src/endpoints/gotify.rs
index 0d306964..504b2f24 100644
--- a/proxmox-notify/src/endpoints/gotify.rs
+++ b/proxmox-notify/src/endpoints/gotify.rs
@@ -1,7 +1,8 @@
 use std::collections::HashMap;
 
+use crate::renderer::TemplateRenderer;
 use crate::schema::{COMMENT_SCHEMA, ENTITY_NAME_SCHEMA};
-use crate::{Endpoint, Error, Notification, Severity};
+use crate::{renderer, Endpoint, Error, Notification, Severity};
 
 use serde::{Deserialize, Serialize};
 
@@ -93,9 +94,19 @@ impl Endpoint for GotifyEndpoint {
 
         let uri = format!("{}/message", self.config.server);
 
+        let properties = notification.properties.as_ref();
+
+        let title = renderer::render_template(
+            TemplateRenderer::Plaintext,
+            &notification.title,
+            properties,
+        )?;
+        let message =
+            renderer::render_template(TemplateRenderer::Plaintext, &notification.body, properties)?;
+
         let body = GotifyMessageBody {
-            title: &notification.title,
-            message: &notification.body,
+            title: &title,
+            message: &message,
             priority: severity_to_priority(notification.severity),
         };
 
diff --git a/proxmox-notify/src/endpoints/sendmail.rs b/proxmox-notify/src/endpoints/sendmail.rs
index ee96c10a..41da0642 100644
--- a/proxmox-notify/src/endpoints/sendmail.rs
+++ b/proxmox-notify/src/endpoints/sendmail.rs
@@ -1,5 +1,6 @@
+use crate::renderer::TemplateRenderer;
 use crate::schema::{COMMENT_SCHEMA, EMAIL_SCHEMA, ENTITY_NAME_SCHEMA};
-use crate::{Endpoint, Error, Notification};
+use crate::{renderer, Endpoint, Error, Notification};
 
 use proxmox_schema::{api, Updater};
 use serde::{Deserialize, Serialize};
@@ -68,12 +69,17 @@ impl Endpoint for SendmailEndpoint {
     fn send(&self, notification: &Notification) -> Result<(), Error> {
         let recipients: Vec<&str> = self.config.recipient.iter().map(String::as_str).collect();
 
-        // Note: OX has serious problems displaying text mails,
-        // so we include html as well
-        let html = format!(
-            "<html><body><pre>\n{}\n<pre>",
-            handlebars::html_escape(&notification.body)
-        );
+        let properties = notification.properties.as_ref();
+
+        let subject = renderer::render_template(
+            TemplateRenderer::Plaintext,
+            &notification.title,
+            properties,
+        )?;
+        let html_part =
+            renderer::render_template(TemplateRenderer::Html, &notification.body, properties)?;
+        let text_part =
+            renderer::render_template(TemplateRenderer::Plaintext, &notification.body, properties)?;
 
         // proxmox_sys::email::sendmail will set the author to
         // "Proxmox Backup Server" if it is not set.
@@ -81,9 +87,9 @@ impl Endpoint for SendmailEndpoint {
 
         proxmox_sys::email::sendmail(
             &recipients,
-            &notification.title,
-            Some(&notification.body),
-            Some(&html),
+            &subject,
+            Some(&text_part),
+            Some(&html_part),
             self.config.from_address.as_deref(),
             author,
         )
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index 33d3dbc7..23bd7342 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -14,7 +14,8 @@ pub mod api;
 pub mod channel;
 mod config;
 pub mod endpoints;
-mod filter;
+pub mod filter;
+pub mod renderer;
 pub mod schema;
 
 #[derive(Debug)]
@@ -25,6 +26,7 @@ pub enum Error {
     EndpointDoesNotExist(String),
     ChannelDoesNotExist(String),
     FilterFailed(String),
+    RenderError(Box<dyn StdError + Send + Sync + 'static>),
 }
 
 impl Display for Error {
@@ -48,6 +50,7 @@ impl Display for Error {
             Error::FilterFailed(message) => {
                 write!(f, "could not apply filter: {message}")
             }
+            Error::RenderError(err) => write!(f, "could not render notification template: {err}"),
         }
     }
 }
@@ -61,6 +64,7 @@ impl StdError for Error {
             Error::EndpointDoesNotExist(_) => None,
             Error::ChannelDoesNotExist(_) => None,
             Error::FilterFailed(_) => None,
+            Error::RenderError(err) => Some(&**err),
         }
     }
 }
diff --git a/proxmox-notify/src/renderer/html.rs b/proxmox-notify/src/renderer/html.rs
new file mode 100644
index 00000000..7a41e873
--- /dev/null
+++ b/proxmox-notify/src/renderer/html.rs
@@ -0,0 +1,100 @@
+use crate::define_helper_with_prefix_and_postfix;
+use crate::renderer::BlockRenderFunctions;
+use handlebars::{
+    Context, Handlebars, Helper, HelperResult, Output, RenderContext,
+    RenderError as HandlebarsRenderError,
+};
+use serde_json::Value;
+
+use super::{table::Table, value_to_string};
+
+fn render_html_table(
+    h: &Helper,
+    _: &Handlebars,
+    _: &Context,
+    _: &mut RenderContext,
+    out: &mut dyn Output,
+) -> HelperResult {
+    let param = h
+        .param(0)
+        .ok_or_else(|| HandlebarsRenderError::new("parameter not found"))?;
+
+    let value = param.value();
+
+    let table: Table = serde_json::from_value(value.clone())?;
+
+    out.write("<table style=\"border: 1px solid\";border-style=\"collapse\">\n")?;
+
+    // Write header
+    out.write("  <tr>\n")?;
+    for column in &table.schema.columns {
+        out.write("    <th style=\"border: 1px solid\">")?;
+        out.write(&handlebars::html_escape(&column.label))?;
+        out.write("</th>\n")?;
+    }
+    out.write("  </tr>\n")?;
+
+    // Write individual rows
+    for row in &table.data {
+        out.write("  <tr>\n")?;
+
+        for column in &table.schema.columns {
+            let entry = row.get(&column.id).unwrap_or(&Value::Null);
+
+            let text = if let Some(renderer) = &column.renderer {
+                renderer.render(entry)?
+            } else {
+                value_to_string(entry)
+            };
+
+            out.write("    <td style=\"border: 1px solid\">")?;
+            out.write(&handlebars::html_escape(&text))?;
+            out.write("</td>\n")?;
+        }
+        out.write("  </tr>\n")?;
+    }
+
+    out.write("</table>\n")?;
+
+    Ok(())
+}
+
+fn render_object(
+    h: &Helper,
+    _: &Handlebars,
+    _: &Context,
+    _: &mut RenderContext,
+    out: &mut dyn Output,
+) -> HelperResult {
+    let param = h
+        .param(0)
+        .ok_or_else(|| HandlebarsRenderError::new("parameter not found"))?;
+
+    let value = param.value();
+
+    out.write("\n<pre>")?;
+    out.write(&serde_json::to_string_pretty(&value)?)?;
+    out.write("\n</pre>\n")?;
+
+    Ok(())
+}
+
+define_helper_with_prefix_and_postfix!(verbatim_monospaced, "<pre>", "</pre>");
+define_helper_with_prefix_and_postfix!(heading_1, "<h1 style=\"font-size: 1.2em\">", "</h1>");
+define_helper_with_prefix_and_postfix!(heading_2, "<h2 style=\"font-size: 1em\">", "</h2>");
+define_helper_with_prefix_and_postfix!(
+    verbatim,
+    "<pre style=\"font-family: sans-serif\">",
+    "</pre>"
+);
+
+pub(super) fn block_render_functions() -> BlockRenderFunctions {
+    BlockRenderFunctions {
+        table: Box::new(render_html_table),
+        verbatim_monospaced: Box::new(verbatim_monospaced),
+        object: Box::new(render_object),
+        heading_1: Box::new(heading_1),
+        heading_2: Box::new(heading_2),
+        verbatim: Box::new(verbatim),
+    }
+}
diff --git a/proxmox-notify/src/renderer/mod.rs b/proxmox-notify/src/renderer/mod.rs
new file mode 100644
index 00000000..452b26a9
--- /dev/null
+++ b/proxmox-notify/src/renderer/mod.rs
@@ -0,0 +1,359 @@
+//! Module for rendering notification templates.
+
+use handlebars::{
+    Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext,
+    RenderError as HandlebarsRenderError,
+};
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::Error;
+use proxmox_human_byte::HumanByte;
+use proxmox_time::TimeSpan;
+
+mod html;
+mod plaintext;
+mod table;
+
+/// Convert a serde_json::Value to a String.
+///
+/// The main difference between this and simply calling Value::to_string is that
+/// this will print strings without double quotes
+fn value_to_string(value: &Value) -> String {
+    match value {
+        Value::String(s) => s.clone(),
+        v => v.to_string(),
+    }
+}
+
+/// Render a serde_json::Value as a byte size with proper units (IEC, base 2)
+///
+/// Will return `None` if `val` does not contain a number.
+fn value_to_byte_size(val: &Value) -> Option<String> {
+    let size = val.as_f64()?;
+    Some(format!("{}", HumanByte::new_binary(size)))
+}
+
+/// Render a serde_json::Value as a duration.
+/// The value is expected to contain the duration in seconds.
+///
+/// Will return `None` if `val` does not contain a number.
+fn value_to_duration(val: &Value) -> Option<String> {
+    let duration = val.as_u64()?;
+    let time_span = TimeSpan::from(Duration::from_secs(duration));
+
+    Some(format!("{time_span}"))
+}
+
+/// Render as serde_json::Value as a timestamp.
+/// The value is expected to contain the timestamp as a unix epoch.
+///
+/// Will return `None` if `val` does not contain a number.
+fn value_to_timestamp(val: &Value) -> Option<String> {
+    let timestamp = val.as_i64()?;
+    proxmox_time::strftime_local("%F %H:%M:%S", timestamp).ok()
+}
+
+/// Available render functions for `serde_json::Values``
+///
+/// May be used as a handlebars helper, e.g.
+/// ```text
+/// {{human-bytes 1024}}
+/// ```
+///
+/// Value renderer can also be used for rendering values in table columns:
+/// ```text
+/// let properties = json!({
+///     "table": {
+///         "schema": {
+///             "columns": [
+///                 {
+///                     "label": "Size",
+///                     "id": "size",
+///                     "renderer": "human-bytes"
+///                 }
+///             ],
+///         },
+///         "data" : [
+///             {
+///                 "size": 1024 * 1024,
+///             },
+///         ]
+///     }
+/// });
+/// ```
+///
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ValueRenderFunction {
+    HumanBytes,
+    Duration,
+    Timestamp,
+}
+
+impl ValueRenderFunction {
+    fn render(&self, value: &Value) -> Result<String, HandlebarsRenderError> {
+        match self {
+            ValueRenderFunction::HumanBytes => value_to_byte_size(value),
+            ValueRenderFunction::Duration => value_to_duration(value),
+            ValueRenderFunction::Timestamp => value_to_timestamp(value),
+        }
+        .ok_or_else(|| {
+            HandlebarsRenderError::new(format!(
+                "could not render value {value} with renderer {self:?}"
+            ))
+        })
+    }
+
+    fn register_helpers(handlebars: &mut Handlebars) {
+        ValueRenderFunction::HumanBytes.register_handlebars_helper(handlebars);
+        ValueRenderFunction::Duration.register_handlebars_helper(handlebars);
+        ValueRenderFunction::Timestamp.register_handlebars_helper(handlebars);
+    }
+
+    fn register_handlebars_helper(&'static self, handlebars: &mut Handlebars) {
+        // Use serde to get own kebab-case representation that is later used
+        // to register the helper, e.g. HumanBytes -> human-bytes
+        let tag = serde_json::to_string(self)
+            .expect("serde failed to serialize ValueRenderFunction enum");
+
+        // But as it's a string value, the generated string is quoted,
+        // so remove leading/trailing double quotes
+        let tag = tag
+            .strip_prefix('\"')
+            .and_then(|t| t.strip_suffix('\"'))
+            .expect("serde serialized string representation was not contained in double quotes");
+
+        handlebars.register_helper(
+            tag,
+            Box::new(
+                |h: &Helper,
+                 _r: &Handlebars,
+                 _: &Context,
+                 _rc: &mut RenderContext,
+                 out: &mut dyn Output|
+                 -> HelperResult {
+                    let param = h
+                        .param(0)
+                        .ok_or(HandlebarsRenderError::new("parameter not found"))?;
+
+                    let value = param.value();
+                    out.write(&self.render(value)?)?;
+
+                    Ok(())
+                },
+            ),
+        );
+    }
+}
+
+/// Available renderers for notification templates.
+pub enum TemplateRenderer {
+    /// Render to HTML code
+    Html,
+    /// Render to plain text
+    Plaintext,
+}
+
+impl TemplateRenderer {
+    fn prefix(&self) -> &str {
+        match self {
+            TemplateRenderer::Html => "<html>\n<body>\n",
+            TemplateRenderer::Plaintext => "",
+        }
+    }
+
+    fn postfix(&self) -> &str {
+        match self {
+            TemplateRenderer::Html => "\n</body>\n</html>",
+            TemplateRenderer::Plaintext => "",
+        }
+    }
+
+    fn block_render_fns(&self) -> BlockRenderFunctions {
+        match self {
+            TemplateRenderer::Html => html::block_render_functions(),
+            TemplateRenderer::Plaintext => plaintext::block_render_functions(),
+        }
+    }
+}
+
+type HelperFn = dyn HelperDef + Send + Sync;
+
+struct BlockRenderFunctions {
+    table: Box<HelperFn>,
+    verbatim_monospaced: Box<HelperFn>,
+    object: Box<HelperFn>,
+    heading_1: Box<HelperFn>,
+    heading_2: Box<HelperFn>,
+    verbatim: Box<HelperFn>,
+}
+
+impl BlockRenderFunctions {
+    fn register_helpers(self, handlebars: &mut Handlebars) {
+        handlebars.register_helper("table", self.table);
+        handlebars.register_helper("verbatim", self.verbatim);
+        handlebars.register_helper("verbatim-monospaced", self.verbatim_monospaced);
+        handlebars.register_helper("object", self.object);
+        handlebars.register_helper("heading-1", self.heading_1);
+        handlebars.register_helper("heading-2", self.heading_2);
+    }
+}
+
+fn render_template_impl(
+    template: &str,
+    properties: Option<&Value>,
+    block_render_fns: BlockRenderFunctions,
+) -> Result<String, Error> {
+    let properties = properties.unwrap_or(&Value::Null);
+
+    let mut handlebars = Handlebars::new();
+    block_render_fns.register_helpers(&mut handlebars);
+
+    ValueRenderFunction::register_helpers(&mut handlebars);
+
+    let rendered_template = handlebars
+        .render_template(template, properties)
+        .map_err(|err| Error::RenderError(err.into()))?;
+
+    Ok(rendered_template)
+}
+
+/// Render a template string.
+///
+/// The output format can be chosen via the `renderer` parameter (see [TemplateRenderer]
+/// for available options).
+pub fn render_template(
+    renderer: TemplateRenderer,
+    template: &str,
+    properties: Option<&Value>,
+) -> Result<String, Error> {
+    let mut rendered_template = String::from(renderer.prefix());
+
+    let block_helpers = renderer.block_render_fns();
+    rendered_template.push_str(&render_template_impl(template, properties, block_helpers)?);
+    rendered_template.push_str(renderer.postfix());
+
+    Ok(rendered_template)
+}
+
+#[macro_export]
+macro_rules! define_helper_with_prefix_and_postfix {
+    ($name:ident, $pre:expr, $post:expr) => {
+        fn $name<'reg, 'rc>(
+            h: &Helper<'reg, 'rc>,
+            handlebars: &'reg Handlebars,
+            context: &'rc Context,
+            render_context: &mut RenderContext<'reg, 'rc>,
+            out: &mut dyn Output,
+        ) -> HelperResult {
+            use handlebars::Renderable;
+
+            let block_text = h.template();
+            let param = h.param(0);
+
+            out.write($pre)?;
+            match (param, block_text) {
+                (None, Some(block_text)) => {
+                    block_text.render(handlebars, context, render_context, out)
+                }
+                (Some(param), None) => {
+                    let value = param.value();
+                    let text = value.as_str().ok_or_else(|| {
+                        HandlebarsRenderError::new(format!("value {value} is not a string"))
+                    })?;
+
+                    out.write(text)?;
+                    Ok(())
+                }
+                (Some(_), Some(_)) => Err(HandlebarsRenderError::new(
+                    "Cannot use parameter and template at the same time",
+                )),
+                (None, None) => Err(HandlebarsRenderError::new(
+                    "Neither parameter nor template was provided",
+                )),
+            }?;
+            out.write($post)?;
+            Ok(())
+        }
+    };
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+
+    #[test]
+    fn test_render_template() -> Result<(), Error> {
+        let properties = json!({
+            "ts": 1684333717,
+            "dur": 12345,
+            "size": 1024 * 15,
+
+            "table": {
+                "schema": {
+                    "columns": [
+                        {
+                            "id": "col1",
+                            "label": "Column 1"
+                        },
+                        {
+                            "id": "col2",
+                            "label": "Column 2"
+                        }
+                    ]
+                },
+                "data": [
+                    {
+                        "col1": "val1",
+                        "col2": "val2"
+                    },
+                    {
+                        "col1": "val3",
+                        "col2": "val4"
+                    },
+                ]
+            }
+
+        });
+
+        let template = r#"
+{{heading-1 "Hello World"}}
+
+{{heading-2 "Hello World"}}
+
+{{human-bytes size}}
+{{duration dur}}
+{{timestamp ts}}
+
+{{table table}}"#;
+
+        let expected_plaintext = r#"
+Hello World
+===========
+
+Hello World
+-----------
+
+15 KiB
+3h 25min 45s
+2023-05-17 16:28:37
+
+Column 1    Column 2    
+val1        val2        
+val3        val4        
+"#;
+
+        let rendered_plaintext =
+            render_template(TemplateRenderer::Plaintext, template, Some(&properties))?;
+
+        // Let's not bother about testing the HTML output, too fragile.
+
+        assert_eq!(rendered_plaintext, expected_plaintext);
+
+        Ok(())
+    }
+}
diff --git a/proxmox-notify/src/renderer/plaintext.rs b/proxmox-notify/src/renderer/plaintext.rs
new file mode 100644
index 00000000..58c51599
--- /dev/null
+++ b/proxmox-notify/src/renderer/plaintext.rs
@@ -0,0 +1,141 @@
+use crate::define_helper_with_prefix_and_postfix;
+use crate::renderer::BlockRenderFunctions;
+use handlebars::{
+    Context, Handlebars, Helper, HelperResult, Output, RenderContext,
+    RenderError as HandlebarsRenderError,
+};
+use serde_json::Value;
+use std::collections::HashMap;
+
+use super::{table::Table, value_to_string};
+
+fn optimal_column_widths(table: &Table) -> HashMap<&str, usize> {
+    let mut widths = HashMap::new();
+
+    for column in &table.schema.columns {
+        let mut min_width = column.label.len();
+
+        for row in &table.data {
+            let entry = row.get(&column.id).unwrap_or(&Value::Null);
+
+            let text = if let Some(renderer) = &column.renderer {
+                renderer.render(entry).unwrap_or_default()
+            } else {
+                value_to_string(entry)
+            };
+
+            min_width = std::cmp::max(text.len(), min_width);
+        }
+
+        widths.insert(column.label.as_str(), min_width + 4);
+    }
+
+    widths
+}
+
+fn render_plaintext_table(
+    h: &Helper,
+    _: &Handlebars,
+    _: &Context,
+    _: &mut RenderContext,
+    out: &mut dyn Output,
+) -> HelperResult {
+    let param = h
+        .param(0)
+        .ok_or_else(|| HandlebarsRenderError::new("parameter not found"))?;
+    let value = param.value();
+    let table: Table = serde_json::from_value(value.clone())?;
+    let widths = optimal_column_widths(&table);
+
+    // Write header
+    for column in &table.schema.columns {
+        let width = widths.get(column.label.as_str()).unwrap_or(&0);
+        out.write(&format!("{label:width$}", label = column.label))?;
+    }
+
+    out.write("\n")?;
+
+    // Write individual rows
+    for row in &table.data {
+        for column in &table.schema.columns {
+            let entry = row.get(&column.id).unwrap_or(&Value::Null);
+            let width = widths.get(column.label.as_str()).unwrap_or(&0);
+
+            let text = if let Some(renderer) = &column.renderer {
+                renderer.render(entry)?
+            } else {
+                value_to_string(entry)
+            };
+
+            out.write(&format!("{text:width$}",))?;
+        }
+        out.write("\n")?;
+    }
+
+    Ok(())
+}
+
+macro_rules! define_underlining_heading_fn {
+    ($name:ident, $underline:expr) => {
+        fn $name<'reg, 'rc>(
+            h: &Helper<'reg, 'rc>,
+            _handlebars: &'reg Handlebars,
+            _context: &'rc Context,
+            _render_context: &mut RenderContext<'reg, 'rc>,
+            out: &mut dyn Output,
+        ) -> HelperResult {
+            let param = h
+                .param(0)
+                .ok_or_else(|| HandlebarsRenderError::new("No parameter provided"))?;
+
+            let value = param.value();
+            let text = value.as_str().ok_or_else(|| {
+                HandlebarsRenderError::new(format!("value {value} is not a string"))
+            })?;
+
+            out.write(text)?;
+            out.write("\n")?;
+
+            for _ in 0..text.len() {
+                out.write($underline)?;
+            }
+            Ok(())
+        }
+    };
+}
+
+define_helper_with_prefix_and_postfix!(verbatim_monospaced, "", "");
+define_underlining_heading_fn!(heading_1, "=");
+define_underlining_heading_fn!(heading_2, "-");
+define_helper_with_prefix_and_postfix!(verbatim, "", "");
+
+fn render_object(
+    h: &Helper,
+    _: &Handlebars,
+    _: &Context,
+    _: &mut RenderContext,
+    out: &mut dyn Output,
+) -> HelperResult {
+    let param = h
+        .param(0)
+        .ok_or_else(|| HandlebarsRenderError::new("parameter not found"))?;
+
+    let value = param.value();
+
+    out.write("\n")?;
+    out.write(&serde_json::to_string_pretty(&value)?)?;
+    out.write("\n")?;
+
+    Ok(())
+}
+
+pub(super) fn block_render_functions() -> BlockRenderFunctions {
+    BlockRenderFunctions {
+        table: Box::new(render_plaintext_table),
+        verbatim_monospaced: Box::new(verbatim_monospaced),
+        verbatim: Box::new(verbatim),
+        object: Box::new(render_object),
+        heading_1: Box::new(heading_1),
+        heading_2: Box::new(heading_2),
+    }
+}
diff --git a/proxmox-notify/src/renderer/table.rs b/proxmox-notify/src/renderer/table.rs
new file mode 100644
index 00000000..74f68482
--- /dev/null
+++ b/proxmox-notify/src/renderer/table.rs
@@ -0,0 +1,24 @@
+use std::collections::HashMap;
+
+use serde::Deserialize;
+use serde_json::Value;
+
+use super::ValueRenderFunction;
+
+#[derive(Debug, Deserialize)]
+pub struct ColumnSchema {
+    pub label: String,
+    pub id: String,
+    pub renderer: Option<ValueRenderFunction>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct TableSchema {
+    pub columns: Vec<ColumnSchema>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Table {
+    pub schema: TableSchema,
+    pub data: Vec<HashMap<String, Value>>,
+}
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox 17/42] notify: add example for template rendering
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (15 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 16/42] notify: add template rendering Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 18/42] log: set default log level to 'info', add product specific logging env var Lukas Wagner
                   ` (25 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/examples/render.rs | 63 +++++++++++++++++++++++++++++++
 1 file changed, 63 insertions(+)
 create mode 100644 proxmox-notify/examples/render.rs

diff --git a/proxmox-notify/examples/render.rs b/proxmox-notify/examples/render.rs
new file mode 100644
index 00000000..5c1f2b2d
--- /dev/null
+++ b/proxmox-notify/examples/render.rs
@@ -0,0 +1,63 @@
+use proxmox_notify::renderer::{render_template, TemplateRenderer};
+use proxmox_notify::Error;
+
+use serde_json::json;
+
+const TEMPLATE: &str = r#"
+{{ heading-1 "Backup Report"}}
+A backup job on host {{host}} was run.
+
+{{ heading-2 "Guests"}}
+{{ table table }}
+The total size of all backups is {{human-bytes total-size}}.
+
+The backup job took {{duration total-time}}.
+
+{{ heading-2 "Logs"}}
+{{ verbatim-monospaced logs}}
+
+{{ heading-2 "Objects"}}
+{{ object table }}
+"#;
+
+fn main() -> Result<(), Error> {
+    let properties = json!({
+        "host": "pali",
+        "logs": "100: starting backup\n100: backup failed",
+        "total-size": 1024 * 1024 + 2048 * 1024,
+        "total-time": 100,
+        "table": {
+            "schema": {
+                "columns": [
+                    {
+                        "label": "VMID",
+                        "id": "vmid"
+                    },
+                    {
+                        "label": "Size",
+                        "id": "size",
+                        "renderer": "human-bytes"
+                    }
+                ],
+            },
+            "data" : [
+                {
+                    "vmid": 1001,
+                    "size": 1024 * 1024,
+                },
+                {
+                    "vmid": 1002,
+                    "size": 2048 * 1024,
+                }
+            ]
+        }
+    });
+
+    let output = render_template(TemplateRenderer::Html, TEMPLATE, Some(&properties))?;
+    println!("{output}");
+
+    let output = render_template(TemplateRenderer::Plaintext, TEMPLATE, Some(&properties))?;
+    println!("{output}");
+
+    Ok(())
+}
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox-perl-rs 18/42] log: set default log level to 'info', add product specific logging env var
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (16 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 17/42] notify: add example for " Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-06-05  7:27   ` Wolfgang Bumiller
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 19/42] add PVE::RS::Notify module Lukas Wagner
                   ` (24 subsequent siblings)
  42 siblings, 1 reply; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Logging behaviour can be overridden by the {PMG,PVE}_LOG environment
variable.

This commit also disables styled output and  timestamps in log messages,
since we usually log to the journal anyway. The log output is configured
to match with other log messages in task logs.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 common/src/logger.rs | 12 ++++++++++--
 pmg-rs/src/lib.rs    |  2 +-
 pve-rs/src/lib.rs    |  2 +-
 3 files changed, 12 insertions(+), 4 deletions(-)

diff --git a/common/src/logger.rs b/common/src/logger.rs
index 36dc856..3c9a075 100644
--- a/common/src/logger.rs
+++ b/common/src/logger.rs
@@ -1,6 +1,14 @@
+use env_logger::{Builder, Env};
+use std::io::Write;
+
 /// Initialize logging. Should only be called once
-pub fn init() {
-    if let Err(e) = env_logger::try_init() {
+pub fn init(env_var_name: &str, default_log_level: &str) {
+    if let Err(e) = Builder::from_env(Env::new().filter_or(env_var_name, default_log_level))
+        .format(|buf, record| writeln!(buf, "{}: {}", record.level(), record.args()))
+        .write_style(env_logger::WriteStyle::Never)
+        .format_timestamp(None)
+        .try_init()
+    {
         eprintln!("could not set up env_logger: {e}");
     }
 }
diff --git a/pmg-rs/src/lib.rs b/pmg-rs/src/lib.rs
index 8633136..6b7ee4c 100644
--- a/pmg-rs/src/lib.rs
+++ b/pmg-rs/src/lib.rs
@@ -12,6 +12,6 @@ mod export {
 
     #[export]
     pub fn init() {
-        common::logger::init();
+        common::logger::init("PMG_LOG", "info");
     }
 }
diff --git a/pve-rs/src/lib.rs b/pve-rs/src/lib.rs
index fc31b3a..eb6ae02 100644
--- a/pve-rs/src/lib.rs
+++ b/pve-rs/src/lib.rs
@@ -14,6 +14,6 @@ mod export {
 
     #[export]
     pub fn init() {
-        common::logger::init();
+        common::logger::init("PVE_LOG", "info");
     }
 }
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox-perl-rs 19/42] add PVE::RS::Notify module
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (17 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 18/42] log: set default log level to 'info', add product specific logging env var Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 20/42] notify: add api for sending notifications/testing endpoints Lukas Wagner
                   ` (23 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pve-rs/Cargo.toml    |  1 +
 pve-rs/Makefile      |  1 +
 pve-rs/src/lib.rs    |  1 +
 pve-rs/src/notify.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 68 insertions(+)
 create mode 100644 pve-rs/src/notify.rs

diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml
index 9012909..05ed90b 100644
--- a/pve-rs/Cargo.toml
+++ b/pve-rs/Cargo.toml
@@ -34,6 +34,7 @@ perlmod = { version = "0.13", features = [ "exporter" ] }
 
 proxmox-apt = "0.9"
 proxmox-http = { version = "0.8", features = ["client-sync", "client-trait"] }
+proxmox-notify = "0.1"
 proxmox-openid = "0.9.8"
 proxmox-resource-scheduling = "0.3.0"
 proxmox-subscription = "0.3"
diff --git a/pve-rs/Makefile b/pve-rs/Makefile
index de35c69..9d737c0 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -27,6 +27,7 @@ PERLMOD_GENPACKAGE := /usr/lib/perlmod/genpackage.pl \
 
 PERLMOD_PACKAGES := \
 	  PVE::RS::APT::Repositories \
+	  PVE::RS::Notify \
 	  PVE::RS::OpenId \
 	  PVE::RS::ResourceScheduling::Static \
 	  PVE::RS::TFA
diff --git a/pve-rs/src/lib.rs b/pve-rs/src/lib.rs
index eb6ae02..0d63c28 100644
--- a/pve-rs/src/lib.rs
+++ b/pve-rs/src/lib.rs
@@ -4,6 +4,7 @@
 pub mod common;
 
 pub mod apt;
+pub mod notify;
 pub mod openid;
 pub mod resource_scheduling;
 pub mod tfa;
diff --git a/pve-rs/src/notify.rs b/pve-rs/src/notify.rs
new file mode 100644
index 0000000..db573ef
--- /dev/null
+++ b/pve-rs/src/notify.rs
@@ -0,0 +1,65 @@
+#[perlmod::package(name = "PVE::RS::Notify")]
+mod export {
+    use anyhow::{bail, Error};
+    use perlmod::Value;
+
+    use std::sync::Mutex;
+
+    use proxmox_notify::Config;
+
+    pub struct NotificationConfig {
+        config: Mutex<Config>,
+    }
+
+    perlmod::declare_magic!(Box<NotificationConfig> : &NotificationConfig as "PVE::RS::Notify");
+
+    /// Support `dclone` so this can be put into the `ccache` of `PVE::Cluster`.
+    #[export(name = "STORABLE_freeze", raw_return)]
+    fn storable_freeze(
+        #[try_from_ref] this: &NotificationConfig,
+        cloning: bool,
+    ) -> Result<Value, Error> {
+        if !cloning {
+            bail!("freezing Notification config not supported!");
+        }
+
+        let mut cloned = Box::new(NotificationConfig {
+            config: Mutex::new(this.config.lock().unwrap().clone()),
+        });
+        let value = Value::new_pointer::<NotificationConfig>(&mut *cloned);
+        let _perl = Box::leak(cloned);
+        Ok(value)
+    }
+
+    /// Instead of `thaw` we implement `attach` for `dclone`.
+    #[export(name = "STORABLE_attach", raw_return)]
+    fn storable_attach(
+        #[raw] class: Value,
+        cloning: bool,
+        #[raw] serialized: Value,
+    ) -> Result<Value, Error> {
+        if !cloning {
+            bail!("STORABLE_attach called with cloning=false");
+        }
+        let data = unsafe { Box::from_raw(serialized.pv_raw::<NotificationConfig>()?) };
+        Ok(perlmod::instantiate_magic!(&class, MAGIC => data))
+    }
+
+    #[export(raw_return)]
+    fn parse_config(
+        #[raw] class: Value,
+        raw_config: &str,
+        raw_private_config: &str,
+    ) -> Result<Value, Error> {
+        Ok(perlmod::instantiate_magic!(&class, MAGIC => Box::new(
+            NotificationConfig {
+                config: Mutex::new(Config::new(raw_config, raw_private_config)?)
+            }
+        )))
+    }
+
+    #[export]
+    fn write_config(#[try_from_ref] this: &NotificationConfig) -> Result<(String, String), Error> {
+        Ok(this.config.lock().unwrap().write()?)
+    }
+}
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox-perl-rs 20/42] notify: add api for sending notifications/testing endpoints
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (18 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 19/42] add PVE::RS::Notify module Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 21/42] notify: add api for notification channels Lukas Wagner
                   ` (22 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pve-rs/src/notify.rs | 34 ++++++++++++++++++++++++++++++++--
 1 file changed, 32 insertions(+), 2 deletions(-)

diff --git a/pve-rs/src/notify.rs b/pve-rs/src/notify.rs
index db573ef..a101416 100644
--- a/pve-rs/src/notify.rs
+++ b/pve-rs/src/notify.rs
@@ -2,10 +2,10 @@
 mod export {
     use anyhow::{bail, Error};
     use perlmod::Value;
-
+    use serde_json::Value as JSONValue;
     use std::sync::Mutex;
 
-    use proxmox_notify::Config;
+    use proxmox_notify::{api, api::ApiError, Config, Notification, Severity};
 
     pub struct NotificationConfig {
         config: Mutex<Config>,
@@ -62,4 +62,34 @@ mod export {
     fn write_config(#[try_from_ref] this: &NotificationConfig) -> Result<(String, String), Error> {
         Ok(this.config.lock().unwrap().write()?)
     }
+
+    #[export(serialize_error)]
+    fn send(
+        #[try_from_ref] this: &NotificationConfig,
+        channel: &str,
+        severity: Severity,
+        title: String,
+        body: String,
+        properties: Option<JSONValue>,
+    ) -> Result<(), ApiError> {
+        let config = this.config.lock().unwrap();
+
+        let notification = Notification {
+            severity,
+            title,
+            body,
+            properties,
+        };
+
+        api::common::send(&config, channel, &notification)
+    }
+
+    #[export(serialize_error)]
+    fn test_endpoint(
+        #[try_from_ref] this: &NotificationConfig,
+        endpoint: &str,
+    ) -> Result<(), ApiError> {
+        let config = this.config.lock().unwrap();
+        api::common::test_endpoint(&config, endpoint)
+    }
 }
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox-perl-rs 21/42] notify: add api for notification channels
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (19 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 20/42] notify: add api for sending notifications/testing endpoints Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 22/42] notify: add api for sendmail endpoints Lukas Wagner
                   ` (21 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pve-rs/src/notify.rs | 67 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 67 insertions(+)

diff --git a/pve-rs/src/notify.rs b/pve-rs/src/notify.rs
index a101416..c6483dc 100644
--- a/pve-rs/src/notify.rs
+++ b/pve-rs/src/notify.rs
@@ -5,6 +5,7 @@ mod export {
     use serde_json::Value as JSONValue;
     use std::sync::Mutex;
 
+    use proxmox_notify::channel::{ChannelConfig, ChannelConfigUpdater, DeleteableChannelProperty};
     use proxmox_notify::{api, api::ApiError, Config, Notification, Severity};
 
     pub struct NotificationConfig {
@@ -92,4 +93,70 @@ mod export {
         let config = this.config.lock().unwrap();
         api::common::test_endpoint(&config, endpoint)
     }
+
+    #[export(serialize_error)]
+    fn get_channels(
+        #[try_from_ref] this: &NotificationConfig,
+    ) -> Result<Vec<ChannelConfig>, ApiError> {
+        let config = this.config.lock().unwrap();
+        api::channel::get_channels(&config)
+    }
+
+    #[export(serialize_error)]
+    fn get_channel(
+        #[try_from_ref] this: &NotificationConfig,
+        id: &str,
+    ) -> Result<ChannelConfig, ApiError> {
+        let config = this.config.lock().unwrap();
+        api::channel::get_channel(&config, id)
+    }
+
+    #[export(serialize_error)]
+    fn add_channel(
+        #[try_from_ref] this: &NotificationConfig,
+        name: String,
+        endpoints: Option<Vec<String>>,
+        comment: Option<String>,
+    ) -> Result<(), ApiError> {
+        let mut config = this.config.lock().unwrap();
+        api::channel::add_channel(
+            &mut config,
+            &ChannelConfig {
+                name,
+                endpoint: endpoints,
+                comment,
+            },
+        )
+    }
+
+    #[export(serialize_error)]
+    fn update_channel(
+        #[try_from_ref] this: &NotificationConfig,
+        name: &str,
+        endpoints: Option<Vec<String>>,
+        comment: Option<String>,
+        delete: Option<Vec<DeleteableChannelProperty>>,
+        digest: Option<&str>,
+    ) -> Result<(), ApiError> {
+        let mut config = this.config.lock().unwrap();
+        api::channel::update_channel(
+            &mut config,
+            name,
+            &ChannelConfigUpdater {
+                endpoint: endpoints,
+                comment,
+            },
+            delete.as_deref(),
+            digest.map(|d| d.as_bytes()),
+        )
+    }
+
+    #[export(serialize_error)]
+    fn delete_channel(
+        #[try_from_ref] this: &NotificationConfig,
+        name: &str,
+    ) -> Result<(), ApiError> {
+        let mut config = this.config.lock().unwrap();
+        api::channel::delete_channel(&mut config, name)
+    }
 }
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox-perl-rs 22/42] notify: add api for sendmail endpoints
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (20 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 21/42] notify: add api for notification channels Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 23/42] notify: add api for gotify endpoints Lukas Wagner
                   ` (20 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pve-rs/src/notify.rs | 83 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 83 insertions(+)

diff --git a/pve-rs/src/notify.rs b/pve-rs/src/notify.rs
index c6483dc..177e01d 100644
--- a/pve-rs/src/notify.rs
+++ b/pve-rs/src/notify.rs
@@ -6,6 +6,9 @@ mod export {
     use std::sync::Mutex;
 
     use proxmox_notify::channel::{ChannelConfig, ChannelConfigUpdater, DeleteableChannelProperty};
+    use proxmox_notify::endpoints::sendmail::{
+        DeleteableSendmailProperty, SendmailConfig, SendmailConfigUpdater,
+    };
     use proxmox_notify::{api, api::ApiError, Config, Notification, Severity};
 
     pub struct NotificationConfig {
@@ -159,4 +162,84 @@ mod export {
         let mut config = this.config.lock().unwrap();
         api::channel::delete_channel(&mut config, name)
     }
+
+    #[export(serialize_error)]
+    fn get_sendmail_endpoints(
+        #[try_from_ref] this: &NotificationConfig,
+    ) -> Result<Vec<SendmailConfig>, ApiError> {
+        let config = this.config.lock().unwrap();
+        api::sendmail::get_endpoints(&config)
+    }
+
+    #[export(serialize_error)]
+    fn get_sendmail_endpoint(
+        #[try_from_ref] this: &NotificationConfig,
+        id: &str,
+    ) -> Result<SendmailConfig, ApiError> {
+        let config = this.config.lock().unwrap();
+        api::sendmail::get_endpoint(&config, id)
+    }
+
+    #[export(serialize_error)]
+    fn add_sendmail_endpoint(
+        #[try_from_ref] this: &NotificationConfig,
+        name: String,
+        recipient: Vec<String>,
+        from_address: Option<String>,
+        author: Option<String>,
+        comment: Option<String>,
+        filter: Option<String>,
+    ) -> Result<(), ApiError> {
+        let mut config = this.config.lock().unwrap();
+
+        api::sendmail::add_endpoint(
+            &mut config,
+            &SendmailConfig {
+                name,
+                recipient,
+                from_address,
+                author,
+                comment,
+                filter,
+            },
+        )
+    }
+
+    #[export(serialize_error)]
+    #[allow(clippy::too_many_arguments)]
+    fn update_sendmail_endpoint(
+        #[try_from_ref] this: &NotificationConfig,
+        name: &str,
+        recipient: Option<Vec<String>>,
+        from_address: Option<String>,
+        author: Option<String>,
+        comment: Option<String>,
+        filter: Option<String>,
+        delete: Option<Vec<DeleteableSendmailProperty>>,
+        digest: Option<&str>,
+    ) -> Result<(), ApiError> {
+        let mut config = this.config.lock().unwrap();
+        api::sendmail::update_endpoint(
+            &mut config,
+            name,
+            &SendmailConfigUpdater {
+                recipient,
+                from_address,
+                author,
+                comment,
+                filter,
+            },
+            delete.as_deref(),
+            digest.map(|d| d.as_bytes()),
+        )
+    }
+
+    #[export(serialize_error)]
+    fn delete_sendmail_endpoint(
+        #[try_from_ref] this: &NotificationConfig,
+        name: &str,
+    ) -> Result<(), ApiError> {
+        let mut config = this.config.lock().unwrap();
+        api::sendmail::delete_endpoint(&mut config, name)
+    }
 }
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox-perl-rs 23/42] notify: add api for gotify endpoints
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (21 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 22/42] notify: add api for sendmail endpoints Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 24/42] notify: add api for notification filters Lukas Wagner
                   ` (19 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pve-rs/src/notify.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 79 insertions(+)

diff --git a/pve-rs/src/notify.rs b/pve-rs/src/notify.rs
index 177e01d..84bc184 100644
--- a/pve-rs/src/notify.rs
+++ b/pve-rs/src/notify.rs
@@ -6,6 +6,10 @@ mod export {
     use std::sync::Mutex;
 
     use proxmox_notify::channel::{ChannelConfig, ChannelConfigUpdater, DeleteableChannelProperty};
+    use proxmox_notify::endpoints::gotify::{
+        DeleteableGotifyProperty, GotifyConfig, GotifyConfigUpdater, GotifyPrivateConfig,
+        GotifyPrivateConfigUpdater,
+    };
     use proxmox_notify::endpoints::sendmail::{
         DeleteableSendmailProperty, SendmailConfig, SendmailConfigUpdater,
     };
@@ -242,4 +246,79 @@ mod export {
         let mut config = this.config.lock().unwrap();
         api::sendmail::delete_endpoint(&mut config, name)
     }
+
+    #[export(serialize_error)]
+    fn get_gotify_endpoints(
+        #[try_from_ref] this: &NotificationConfig,
+    ) -> Result<Vec<GotifyConfig>, ApiError> {
+        let config = this.config.lock().unwrap();
+        api::gotify::get_endpoints(&config)
+    }
+
+    #[export(serialize_error)]
+    fn get_gotify_endpoint(
+        #[try_from_ref] this: &NotificationConfig,
+        id: &str,
+    ) -> Result<GotifyConfig, ApiError> {
+        let config = this.config.lock().unwrap();
+        api::gotify::get_endpoint(&config, id)
+    }
+
+    #[export(serialize_error)]
+    fn add_gotify_endpoint(
+        #[try_from_ref] this: &NotificationConfig,
+        name: String,
+        server: String,
+        token: String,
+        comment: Option<String>,
+        filter: Option<String>,
+    ) -> Result<(), ApiError> {
+        let mut config = this.config.lock().unwrap();
+        api::gotify::add_endpoint(
+            &mut config,
+            &GotifyConfig {
+                name: name.clone(),
+                server,
+                comment,
+                filter,
+            },
+            &GotifyPrivateConfig { name, token },
+        )
+    }
+
+    #[export(serialize_error)]
+    #[allow(clippy::too_many_arguments)]
+    fn update_gotify_endpoint(
+        #[try_from_ref] this: &NotificationConfig,
+        name: &str,
+        server: Option<String>,
+        token: Option<String>,
+        comment: Option<String>,
+        filter: Option<String>,
+        delete: Option<Vec<DeleteableGotifyProperty>>,
+        digest: Option<&str>,
+    ) -> Result<(), ApiError> {
+        let mut config = this.config.lock().unwrap();
+        api::gotify::update_endpoint(
+            &mut config,
+            name,
+            &GotifyConfigUpdater {
+                server,
+                comment,
+                filter,
+            },
+            &GotifyPrivateConfigUpdater { token },
+            delete.as_deref(),
+            digest.map(|d| d.as_bytes()),
+        )
+    }
+
+    #[export(serialize_error)]
+    fn delete_gotify_endpoint(
+        #[try_from_ref] this: &NotificationConfig,
+        name: &str,
+    ) -> Result<(), ApiError> {
+        let mut config = this.config.lock().unwrap();
+        api::gotify::delete_gotify_endpoint(&mut config, name)
+    }
 }
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 proxmox-perl-rs 24/42] notify: add api for notification filters
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (22 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 23/42] notify: add api for gotify endpoints Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-cluster 25/42] cluster files: add notifications.cfg Lukas Wagner
                   ` (18 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pve-rs/src/notify.rs | 87 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 87 insertions(+)

diff --git a/pve-rs/src/notify.rs b/pve-rs/src/notify.rs
index 84bc184..9adeff5 100644
--- a/pve-rs/src/notify.rs
+++ b/pve-rs/src/notify.rs
@@ -13,6 +13,9 @@ mod export {
     use proxmox_notify::endpoints::sendmail::{
         DeleteableSendmailProperty, SendmailConfig, SendmailConfigUpdater,
     };
+    use proxmox_notify::filter::{
+        DeleteableFilterProperty, FilterConfig, FilterConfigUpdater, FilterModeOperator,
+    };
     use proxmox_notify::{api, api::ApiError, Config, Notification, Severity};
 
     pub struct NotificationConfig {
@@ -321,4 +324,88 @@ mod export {
         let mut config = this.config.lock().unwrap();
         api::gotify::delete_gotify_endpoint(&mut config, name)
     }
+
+    #[export(serialize_error)]
+    fn get_filters(
+        #[try_from_ref] this: &NotificationConfig,
+    ) -> Result<Vec<FilterConfig>, ApiError> {
+        let config = this.config.lock().unwrap();
+        api::filter::get_filters(&config)
+    }
+
+    #[export(serialize_error)]
+    fn get_filter(
+        #[try_from_ref] this: &NotificationConfig,
+        id: &str,
+    ) -> Result<FilterConfig, ApiError> {
+        let config = this.config.lock().unwrap();
+        api::filter::get_filter(&config, id)
+    }
+
+    #[export(serialize_error)]
+    #[allow(clippy::too_many_arguments)]
+    fn add_filter(
+        #[try_from_ref] this: &NotificationConfig,
+        name: String,
+        min_severity: Option<Severity>,
+        sub_filter: Option<Vec<String>>,
+        mode: Option<FilterModeOperator>,
+        match_property: Option<Vec<String>>,
+        invert_match: Option<bool>,
+        comment: Option<String>,
+    ) -> Result<(), ApiError> {
+        let mut config = this.config.lock().unwrap();
+        api::filter::add_filter(
+            &mut config,
+            &FilterConfig {
+                name,
+                min_severity,
+                sub_filter,
+                mode,
+                match_property,
+                invert_match,
+                comment,
+            },
+        )
+    }
+
+    #[export(serialize_error)]
+    #[allow(clippy::too_many_arguments)]
+    fn update_filter(
+        #[try_from_ref] this: &NotificationConfig,
+        name: &str,
+        min_severity: Option<Severity>,
+        sub_filter: Option<Vec<String>>,
+        mode: Option<FilterModeOperator>,
+        match_property: Option<Vec<String>>,
+        invert_match: Option<bool>,
+        comment: Option<String>,
+        delete: Option<Vec<DeleteableFilterProperty>>,
+        digest: Option<&str>,
+    ) -> Result<(), ApiError> {
+        let mut config = this.config.lock().unwrap();
+        api::filter::update_filter(
+            &mut config,
+            name,
+            &FilterConfigUpdater {
+                min_severity,
+                sub_filter,
+                mode,
+                match_property,
+                invert_match,
+                comment,
+            },
+            delete.as_deref(),
+            digest.map(|d| d.as_bytes()),
+        )
+    }
+
+    #[export(serialize_error)]
+    fn delete_filter(
+        #[try_from_ref] this: &NotificationConfig,
+        name: &str,
+    ) -> Result<(), ApiError> {
+        let mut config = this.config.lock().unwrap();
+        api::filter::delete_filter(&mut config, name)
+    }
 }
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-cluster 25/42] cluster files: add notifications.cfg
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (23 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 24/42] notify: add api for notification filters Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-guest-common 26/42] vzdump: add config options for new notification backend Lukas Wagner
                   ` (17 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/PVE/Cluster.pm  | 2 ++
 src/pmxcfs/status.c | 2 ++
 2 files changed, 4 insertions(+)

diff --git a/src/PVE/Cluster.pm b/src/PVE/Cluster.pm
index efca58f..6215c9c 100644
--- a/src/PVE/Cluster.pm
+++ b/src/PVE/Cluster.pm
@@ -55,6 +55,8 @@ my $observed = {
     'firewall/cluster.fw' => 1,
     'user.cfg' => 1,
     'domains.cfg' => 1,
+    'notifications.cfg' => 1,
+    'priv/notifications.cfg' => 1,
     'priv/shadow.cfg' => 1,
     'priv/tfa.cfg' => 1,
     'priv/token.cfg' => 1,
diff --git a/src/pmxcfs/status.c b/src/pmxcfs/status.c
index 8d62986..f1dd2ac 100644
--- a/src/pmxcfs/status.c
+++ b/src/pmxcfs/status.c
@@ -82,6 +82,8 @@ static memdb_change_t memdb_change_array[] = {
 	{ .path = "storage.cfg" },
 	{ .path = "user.cfg" },
 	{ .path = "domains.cfg" },
+	{ .path = "notifications.cfg"},
+	{ .path = "priv/notifications.cfg"},
 	{ .path = "priv/shadow.cfg" },
 	{ .path = "priv/acme/plugins.cfg" },
 	{ .path = "priv/tfa.cfg" },
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-guest-common 26/42] vzdump: add config options for new notification backend
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (24 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-cluster 25/42] cluster files: add notifications.cfg Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 27/42] test: fix names of .PHONY targets Lukas Wagner
                   ` (16 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

- Add new option 'notification-mode'
  Can either be 'mail' or 'channel', selects wheter notifications should
  be sent via email or via a notification channel
- Add new option 'notification-channel'
  Allows to select a channel via which notifications shall be sent (if
  'notification-mode' is set to 'channel')
- Add new option 'notification-policy'
  Replacement for the now deprecated 'mailnotification' option. Mostly
  just a rename for consistency, but also adds the 'never' option.
- Mark 'mailnotification' as deprecated in favor of 'notification-policy'

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/PVE/VZDump/Common.pm | 28 ++++++++++++++++++++++++++--
 1 file changed, 26 insertions(+), 2 deletions(-)

diff --git a/src/PVE/VZDump/Common.pm b/src/PVE/VZDump/Common.pm
index 4b0e8e0..d3cf8a7 100644
--- a/src/PVE/VZDump/Common.pm
+++ b/src/PVE/VZDump/Common.pm
@@ -164,16 +164,40 @@ my $confdesc = {
 	type => 'string',
 	format => 'email-or-username-list',
 	description => "Comma-separated list of email addresses or users that should" .
-	    " receive email notifications.",
+	    " receive email notifications. Only has an effect if 'notification-mode' is".
+	    " set to 'mail'.",
 	optional => 1,
     },
     mailnotification => {
 	type => 'string',
-	description => "Specify when to send an email",
+	description => "Deprecated: use 'notification-policy' instead.",
 	optional => 1,
 	enum => [ 'always', 'failure' ],
 	default => 'always',
     },
+    'notification-policy' => {
+	type => 'string',
+	description => "Specify when to send a notification",
+	optional => 1,
+	enum => [ 'always', 'failure', 'never'],
+	default => 'always',
+    },
+    'notification-mode' => {
+	type => 'string',
+	description => "Determine whether to notify via email of via a notification channel.",
+	optional => 1,
+	enum => [ 'mail', 'channel' ],
+	default => 'mail',
+    },
+    'notification-channel' => {
+	type => 'string',
+	format => 'pve-configid',
+	description => "Determine the channel via which notifications should be sent." .
+	    " Only has an effect if 'notification-mode' is set to 'channel'." .
+	    " If 'notification-mode' is set to 'channel' and 'notification-channel' is " .
+	    " not set, then no notification will be sent.",
+	optional => 1,
+    },
     tmpdir => {
 	type => 'string',
 	description => "Store temporary files to specified directory.",
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 27/42] test: fix names of .PHONY targets
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (25 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-guest-common 26/42] vzdump: add config options for new notification backend Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 28/42] add PVE::Notify module Lukas Wagner
                   ` (15 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

They need to have the same name as the target.
Took the opportunity to move the .PHONY right next to the target recipe,
so that mistakes like these are hopefully easier caught.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 test/Makefile | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/test/Makefile b/test/Makefile
index 670a3611..cccdc1c9 100644
--- a/test/Makefile
+++ b/test/Makefile
@@ -4,29 +4,35 @@ all:
 
 export PERLLIB=..
 
-.PHONY: check balloon-test replication-test mail-test vzdump-test
+.PHONY: check
 check: test-replication test-balloon test-mail test-vzdump test-osd
 
+.PHONY: test-balloon
 test-balloon:
 	./balloontest.pl
 
+.PHONY: test-replication
 test-replication: replication1.t replication2.t replication3.t replication4.t replication5.t replication6.t
 
 replication%.t: replication_test%.pl
 	./$<
 
+.PHONY: test-mail
 test-mail:
 	./mail_test.pl
 
+.PHONY: test-vzdump
 test-vzdump: test-vzdump-guest-included test-vzdump-new
 
-.PHONY: test-vzdump-guest-included test-vzdump-new
+.PHONY: test-vzdump-guest-included
 test-vzdump-guest-included:
 	./vzdump_guest_included_test.pl
 
+.PHONY: test-vzdump-new
 test-vzdump-new:
 	./vzdump_new_test.pl
 
+.PHONY: test-osd
 test-osd:
 	./OSD_test.pl
 
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 28/42] add PVE::Notify module
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (26 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 27/42] test: fix names of .PHONY targets Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 29/42] vzdump: send notifications via new notification module Lukas Wagner
                   ` (14 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

The PVE::Notify module is a very thin wrapper around the
Proxmox::RS::Notify module, feeding the configuration from the
new 'notifications.cfg' and 'priv/notifications.cfg' files into
it.

Also, PVE::Notify contains some useful helpers.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/Makefile  |  1 +
 PVE/Notify.pm | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 85 insertions(+)
 create mode 100644 PVE/Notify.pm

diff --git a/PVE/Makefile b/PVE/Makefile
index 48b85d33..d6317dde 100644
--- a/PVE/Makefile
+++ b/PVE/Makefile
@@ -13,6 +13,7 @@ PERLSOURCE = 			\
 	HTTPServer.pm		\
 	Jobs.pm			\
 	NodeConfig.pm		\
+	Notify.pm		\
 	Report.pm		\
 	VZDump.pm
 
diff --git a/PVE/Notify.pm b/PVE/Notify.pm
new file mode 100644
index 00000000..01609b83
--- /dev/null
+++ b/PVE/Notify.pm
@@ -0,0 +1,84 @@
+package PVE::Notify;
+
+use strict;
+use warnings;
+
+use PVE::Cluster;
+use PVE::RS::Notify;
+
+PVE::Cluster::cfs_register_file(
+    'notifications.cfg',
+    \&parse_notification_config,
+    \&write_notification_config,
+);
+
+PVE::Cluster::cfs_register_file(
+    'priv/notifications.cfg',
+    \&parse_notification_config,
+    \&write_notification_config,
+);
+
+sub parse_notification_config {
+    my ($filename, $raw) = @_;
+
+    $raw = '' if !defined($raw);
+    return $raw;
+}
+
+sub write_notification_config {
+    my ($filename, $config) = @_;
+    return $config;
+}
+
+sub lock_config {
+    my ($code, $timeout) = @_;
+
+    PVE::Cluster::cfs_lock_file('notifications.cfg', $timeout, sub {
+        PVE::Cluster::cfs_lock_file('priv/notifications.cfg', $timeout, $code);
+        die $@ if $@;
+    });
+    die $@ if $@;
+}
+
+sub read_config {
+    my $config = PVE::Cluster::cfs_read_file('notifications.cfg');
+    my $priv_config = PVE::Cluster::cfs_read_file('priv/notifications.cfg');
+
+    return PVE::RS::Notify->parse_config($config, $priv_config);
+}
+
+sub write_config {
+    my ($rust_config) = @_;
+
+    my ($config, $priv_config) = $rust_config->write_config();
+    PVE::Cluster::cfs_write_file('notifications.cfg', $config);
+    PVE::Cluster::cfs_write_file('priv/notifications.cfg', $priv_config);
+}
+
+sub send_notification {
+    my ($channel, $severity, $title, $message, $properties, $config) = @_;
+    $config = read_config() if !defined($config);
+    $config->send($channel, $severity, $title, $message, $properties);
+}
+
+sub info {
+    my ($channel, $title, $message, $properties, $config) = @_;
+    send_notification($channel, 'info', $title, $message, $properties, $config);
+}
+
+sub notice {
+    my ($channel, $title, $message, $properties, $config) = @_;
+    send_notification($channel, 'notice', $title, $message, $properties, $config);
+}
+
+sub warning {
+    my ($channel, $title, $message, $properties, $config) = @_;
+    send_notification($channel, 'warning', $title, $message, $properties, $config);
+}
+
+sub error {
+    my ($channel, $title, $message, $properties, $config) = @_;
+    send_notification($channel, 'error', $title, $message, $properties, $config);
+}
+
+1;
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 29/42] vzdump: send notifications via new notification module
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (27 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 28/42] add PVE::Notify module Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 30/42] test: rename mail_test.pl to vzdump_notification_test.pl Lukas Wagner
                   ` (13 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

... instead of using sendmail directly.

In case the (new) 'notification-mode' parameter is set to 'mail',
we add an anonymous notification channel with a sendmail endpoint
sending mails to the addresses configured in the 'mailto' paramter.

If set to 'channel', we notify via the configured channel.

This commit also refactors the old 'sendmail' sub heavily:
  - Use template-based notification text instead of endless
    string concatenations
  - Removing the old plaintext/HTML table rendering in favor of
    the new template/property-based approach offered by the
    `proxmox-notify` crate.
  - Rename `sendmail` sub to `send_notification`
  - Breaking out some of the code into helper subs, hopefully
    reducing the spaghetti factor a bit

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/API2/VZDump.pm |   2 +-
 PVE/VZDump.pm      | 323 ++++++++++++++++++++++++---------------------
 test/mail_test.pl  |  36 ++---
 3 files changed, 195 insertions(+), 166 deletions(-)

diff --git a/PVE/API2/VZDump.pm b/PVE/API2/VZDump.pm
index 8e873c05..83772e73 100644
--- a/PVE/API2/VZDump.pm
+++ b/PVE/API2/VZDump.pm
@@ -126,7 +126,7 @@ __PACKAGE__->register_method ({
 		$vzdump->getlock($upid); # only one process allowed
 	    };
 	    if (my $err = $@) {
-		$vzdump->sendmail([], 0, $err);
+		$vzdump->send_notification([], 0, $err);
 		exit(-1);
 	    }
 
diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm
index a04837e7..21a443aa 100644
--- a/PVE/VZDump.pm
+++ b/PVE/VZDump.pm
@@ -19,6 +19,7 @@ use PVE::Exception qw(raise_param_exc);
 use PVE::HA::Config;
 use PVE::HA::Env::PVE2;
 use PVE::JSONSchema qw(get_standard_option);
+use PVE::Notify;
 use PVE::RPCEnvironment;
 use PVE::Storage;
 use PVE::VZDump::Common;
@@ -300,21 +301,90 @@ sub read_vzdump_defaults {
     return $res;
 }
 
-use constant MAX_MAIL_SIZE => 1024*1024;
-sub sendmail {
-    my ($self, $tasklist, $totaltime, $err, $detail_pre, $detail_post) = @_;
+sub read_backup_task_logs {
+    my ($task_list) = @_;
 
-    my $opts = $self->{opts};
+    my $task_logs = "";
 
-    my $mailto = $opts->{mailto};
+    for my $task (@$task_list) {
+	my $vmid = $task->{vmid};
+	my $log_file = $task->{tmplog};
+	if (!$task->{tmplog}) {
+	    $task_logs .= "$vmid: no log available\n\n";
+	    next;
+	}
+	if (open (my $TMP, '<', "$log_file")) {
+	    while (my $line = <$TMP>) {
+		next if $line =~ /^status: \d+/; # not useful in mails
+		$task_logs .= encode8bit ("$vmid: $line");
+	    }
+	    close ($TMP);
+	} else {
+	    $task_logs .= "$vmid: Could not open log file\n\n";
+	}
+	$task_logs .= "\n";
+    }
 
-    return if !($mailto && scalar(@$mailto));
+    return $task_logs;
+}
 
-    my $cmdline = $self->{cmdline};
+sub build_guest_table {
+    my ($task_list) = @_;
+
+    my $table = {
+	schema => {
+	    columns => [
+		{
+		    label => "VMID",
+		    id  => "vmid"
+		},
+		{
+		    label => "Name",
+		    id  => "name"
+		},
+		{
+		    label => "Status",
+		    id  => "status"
+		},
+		{
+		    label => "Time",
+		    id  => "time",
+		    renderer => "duration"
+		},
+		{
+		    label => "Size",
+		    id  => "size",
+		    renderer => "human-bytes"
+		},
+		{
+		    label => "Filename",
+		    id  => "filename"
+		},
+	    ]
+	},
+	data => []
+    };
+
+    for my $task (@$task_list) {
+	my $successful = $task->{state} eq 'ok';
+	my $size = $successful ? $task->{size} : 0;
+	my $filename = $successful ? $task->{target} : undef;
+	push @{$table->{data}}, {
+	    "vmid" => $task->{vmid},
+	    "name" => $task->{hostname},
+	    "status" => $task->{state},
+	    "time" => $task->{backuptime},
+	    "size" => $size,
+	    "filename" => $filename,
+	};
+    }
+
+    return $table;
+}
 
-    my $ecount = 0;
-    foreach my $task (@$tasklist) {
-	$ecount++ if $task->{state} ne 'ok';
+sub sanitize_task_list {
+    my ($task_list) = @_;
+    for my $task (@$task_list) {
 	chomp $task->{msg} if $task->{msg};
 	$task->{backuptime} = 0 if !$task->{backuptime};
 	$task->{size} = 0 if !$task->{size};
@@ -325,164 +395,121 @@ sub sendmail {
 	    $task->{msg} = 'aborted';
 	}
     }
+}
 
-    my $notify = $opts->{mailnotification} || 'always';
-    return if (!$ecount && !$err && ($notify eq 'failure'));
+sub count_failed_tasks {
+    my ($tasklist) = @_;
 
-    my $stat = ($ecount || $err) ? 'backup failed' : 'backup successful';
-    if ($err) {
-	if ($err =~ /\n/) {
-	    $stat .= ": multiple problems";
-	} else {
-	    $stat .= ": $err";
-	    $err = undef;
-	}
+    my $error_count = 0;
+    for my $task (@$tasklist) {
+	$error_count++ if $task->{state} ne 'ok';
     }
 
+    return $error_count;
+}
+
+sub get_hostname {
     my $hostname = `hostname -f` || PVE::INotify::nodename();
     chomp $hostname;
+    return $hostname;
+}
 
-    # text part
-    my $text = $err ? "$err\n\n" : '';
-    my $namelength = 20;
-    $text .= sprintf (
-	"%-10s %-${namelength}s %-6s %10s %10s  %s\n",
-	qw(VMID NAME STATUS TIME SIZE FILENAME)
-    );
-    foreach my $task (@$tasklist) {
-	my $name = substr($task->{hostname}, 0, $namelength);
-	my $successful = $task->{state} eq 'ok';
-	my $size = $successful ? format_size ($task->{size}) : 0;
-	my $filename = $successful ? $task->{target} : '-';
-	my $size_fmt = $successful ? "%10s": "%8.2fMB";
-	$text .= sprintf(
-	    "%-10s %-${namelength}s %-6s %10s $size_fmt  %s\n",
-	    $task->{vmid},
-	    $name,
-	    $task->{state},
-	    format_time($task->{backuptime}),
-	    $size,
-	    $filename,
-	);
-    }
+my $subject_template = "vzdump backup status ({{hostname}}): {{status-text}}";
 
-    my $text_log_part;
-    $text_log_part .= "\nDetailed backup logs:\n\n";
-    $text_log_part .= "$cmdline\n\n";
+my $body_template = <<EOT;
+{{error-message}}
+{{heading-1 "Details"}}
+{{table guest-table}}
 
-    $text_log_part .= $detail_pre . "\n" if defined($detail_pre);
-    foreach my $task (@$tasklist) {
-	my $vmid = $task->{vmid};
-	my $log = $task->{tmplog};
-	if (!$log) {
-	    $text_log_part .= "$vmid: no log available\n\n";
-	    next;
-	}
-	if (open (my $TMP, '<', "$log")) {
-	    while (my $line = <$TMP>) {
-		next if $line =~ /^status: \d+/; # not useful in mails
-		$text_log_part .= encode8bit ("$vmid: $line");
-	    }
-	    close ($TMP);
-	} else {
-	    $text_log_part .= "$vmid: Could not open log file\n\n";
-	}
-	$text_log_part .= "\n";
-    }
-    $text_log_part .= $detail_post if defined($detail_post);
+Total running time: {{duration total-time}}
 
-    # html part
-    my $html = "<html><body>\n";
-    $html .= "<p>" . (escape_html($err) =~ s/\n/<br>/gr) . "</p>\n" if $err;
-    $html .= "<table border=1 cellpadding=3>\n";
-    $html .= "<tr><td>VMID<td>NAME<td>STATUS<td>TIME<td>SIZE<td>FILENAME</tr>\n";
+{{heading-1 "Logs"}}
+{{verbatim-monospaced logs}}
+EOT
 
-    my $ssize = 0;
-    foreach my $task (@$tasklist) {
-	my $vmid = $task->{vmid};
-	my $name = $task->{hostname};
-
-	if  ($task->{state} eq 'ok') {
-	    $ssize += $task->{size};
-
-	    $html .= sprintf (
-	        "<tr><td>%s<td>%s<td>OK<td>%s<td align=right>%s<td>%s</tr>\n",
-	        $vmid,
-	        $name,
-	        format_time($task->{backuptime}),
-	        format_size ($task->{size}),
-	        escape_html ($task->{target}),
-	    );
-	} else {
-	    $html .= sprintf (
-	        "<tr><td>%s<td>%s<td><font color=red>FAILED<td>%s<td colspan=2>%s</tr>\n",
-	        $vmid,
-	        $name,
-	        format_time($task->{backuptime}),
-	        escape_html ($task->{msg}),
-	    );
-	}
-    }
+use constant MAX_LOG_SIZE => 1024*1024;
 
-    $html .= sprintf ("<tr><td align=left colspan=3>TOTAL<td>%s<td>%s<td></tr>",
- format_time ($totaltime), format_size ($ssize));
+sub send_notification {
+    my ($self, $tasklist, $total_time, $err, $detail_pre, $detail_post) = @_;
 
-    $html .= "\n</table><br><br>\n";
-    my $html_log_part;
-    $html_log_part .= "Detailed backup logs:<br /><br />\n";
-    $html_log_part .= "<pre>\n";
-    $html_log_part .= escape_html($cmdline) . "\n\n";
+    my $opts = $self->{opts};
+    my $mailto = $opts->{mailto};
+    my $cmdline = $self->{cmdline};
+    my $notification_mode = $opts->{"notification-mode"} // "mail";
+    my $channel = $opts->{"notification-channel"};
+    # Fall back to 'mailnotification' if 'notification-policy' is not set.
+    # If both are set, 'notification-policy' takes precedence
+    my $policy = $opts->{"notification-policy"} // $opts->{mailnotification} // 'always';
 
-    $html_log_part .= escape_html($detail_pre) . "\n" if defined($detail_pre);
-    foreach my $task (@$tasklist) {
-	my $vmid = $task->{vmid};
-	my $log = $task->{tmplog};
-	if (!$log) {
-	    $html_log_part .= "$vmid: no log available\n\n";
-	    next;
-	}
-	if (open (my $TMP, '<', "$log")) {
-	    while (my $line = <$TMP>) {
-		next if $line =~ /^status: \d+/; # not useful in mails
-		if ($line =~ m/^\S+\s\d+\s+\d+:\d+:\d+\s+(ERROR|WARN):/) {
-		    $html_log_part .= encode8bit ("$vmid: <font color=red>".
-			escape_html ($line) . "</font>");
-		} else {
-		    $html_log_part .= encode8bit ("$vmid: " . escape_html ($line));
-		}
-	    }
-	    close ($TMP);
+    return if ($policy eq 'never');
+
+    sanitize_task_list($tasklist);
+    my $error_count = count_failed_tasks($tasklist);
+
+    my $failed = ($error_count || $err);
+
+    return if (!$failed && ($policy eq 'failure'));
+
+    my $status_text = $failed ? 'backup failed' : 'backup successful';
+
+    if ($err) {
+	if ($err =~ /\n/) {
+	    $status_text .= ": multiple problems";
 	} else {
-	    $html_log_part .= "$vmid: Could not open log file\n\n";
+	    $status_text .= ": $err";
+	    $err = undef;
 	}
-	$html_log_part .= "\n";
     }
-    $html_log_part .= escape_html($detail_post) if defined($detail_post);
-    $html_log_part .= "</pre>";
-    my $html_end = "\n</body></html>\n";
-    # end html part
-
-    if (length($text) + length($text_log_part) +
-	length($html) + length($html_log_part) +
-	length($html_end) < MAX_MAIL_SIZE)
+
+    my $text_log_part = "$cmdline\n\n";
+    $text_log_part .= $detail_pre . "\n" if defined($detail_pre);
+    $text_log_part .= read_backup_task_logs($tasklist);
+    $text_log_part .= $detail_post if defined($detail_post);
+
+    if (length($text_log_part)  > MAX_LOG_SIZE)
     {
-	$html .= $html_log_part;
-	$html .= $html_end;
-	$text .= $text_log_part;
-    } else {
-	my $msg = "Log output was too long to be sent by mail. ".
+	# Let's limit the maximum length of included logs
+	$text_log_part = "Log output was too long to be sent. ".
 	    "See Task History for details!\n";
-	$text .= $msg;
-	$html .= "<p>$msg</p>";
-	$html .= $html_end;
+    };
+
+    my $notification_props = {
+	"hostname"      => get_hostname(),
+	"error-message" => $err,
+	"guest-table"   => build_guest_table($tasklist),
+	"logs"          => $text_log_part,
+	"status-text"   => $status_text,
+	"total-time"    => $total_time,
+    };
+
+    my $notification_config = PVE::Notify::read_config();
+
+    if ($mailto && scalar(@$mailto) && $notification_mode eq "mail") {
+	my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg');
+	my $mailfrom = $dcconf->{email_from} || "root";
+
+	$channel = "anonymous-sendmail-channel";
+	$notification_config->add_sendmail_endpoint(
+	    "anonymous-vzdump-sendmail",
+	    $mailto,
+	    $mailfrom,
+	    "vzdump backup tool",
+	    undef);
+	$notification_config->add_channel($channel, ["anonymous-vzdump-sendmail"]);
     }
 
-    my $subject = "vzdump backup status ($hostname) : $stat";
+    return if !defined($channel);
 
-    my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg');
-    my $mailfrom = $dcconf->{email_from} || "root";
+    my $severity = $failed ? "error" : "info";
 
-    PVE::Tools::sendmail($mailto, $subject, $text, $html, $mailfrom, "vzdump backup tool");
+    PVE::Notify::send_notification(
+	$channel,
+	$severity,
+	$subject_template,
+	$body_template,
+	$notification_props,
+	$notification_config
+    );
 };
 
 sub new {
@@ -615,7 +642,7 @@ sub new {
     }
 
     if ($errors) {
-	eval { $self->sendmail([], 0, $errors); };
+	eval { $self->send_notification([], 0, $errors); };
 	debugmsg ('err', $@) if $@;
 	die "$errors\n";
     }
@@ -1305,11 +1332,11 @@ sub exec_backup {
     my $totaltime = time() - $starttime;
 
     eval {
-	# otherwise $self->sendmail() will interpret it as multiple problems
+	# otherwise $self->send_notification() will interpret it as multiple problems
 	my $chomped_err = $err;
 	chomp($chomped_err) if $chomped_err;
 
-	$self->sendmail(
+	$self->send_notification(
 	    $tasklist,
 	    $totaltime,
 	    $chomped_err,
diff --git a/test/mail_test.pl b/test/mail_test.pl
index d0114441..0635ebb7 100755
--- a/test/mail_test.pl
+++ b/test/mail_test.pl
@@ -5,7 +5,7 @@ use warnings;
 
 use lib '..';
 
-use Test::More tests => 5;
+use Test::More tests => 3;
 use Test::MockModule;
 
 use PVE::VZDump;
@@ -29,17 +29,19 @@ sub prepare_mail_with_status {
 sub prepare_long_mail {
     open(TEST_FILE, '>', $TEST_FILE_PATH); # Removes previous content
     # 0.5 MB * 2 parts + the overview tables gives more than 1 MB mail
-    print TEST_FILE "a" x (1024*1024/2);
+    print TEST_FILE "a" x (1024*1024);
     close(TEST_FILE);
 }
 
-my ($result_text, $result_html);
+my $result_text;
+my $result_properties;
+
+my $mock_notification_module = Test::MockModule->new('PVE::Notify');
+$mock_notification_module->mock('send_notification', sub {
+    my ($channel, $severity, $title, $text, $properties) = @_;
 
-my $mock_tools_module = Test::MockModule->new('PVE::Tools');
-$mock_tools_module->mock('sendmail', sub {
-    my (undef, undef, $text, $html, undef, undef) = @_;
     $result_text = $text;
-    $result_html = $html;
+    $result_properties = $properties;
 });
 
 my $mock_cluster_module = Test::MockModule->new('PVE::Cluster');
@@ -47,7 +49,9 @@ $mock_cluster_module->mock('cfs_read_file', sub {
     my $path = shift;
 
     if ($path eq 'datacenter.cfg') {
-	return {};
+        return {};
+    } elsif ($path eq 'notifications.cfg' || $path eq 'priv/notifications.cfg') {
+        return '';
     } else {
 	die "unexpected cfs_read_file\n";
     }
@@ -62,28 +66,26 @@ my $SELF = {
 my $task = { state => 'ok', vmid => '100', };
 my $tasklist;
 sub prepare_test {
-    $result_text = $result_html = undef;
+    $result_text = undef;
     $task->{tmplog} = shift;
     $tasklist = [ $task ];
 }
 
 {
     prepare_test($TEST_FILE_WRONG_PATH);
-    PVE::VZDump::sendmail($SELF, $tasklist, 0, undef, undef, undef);
-    like($result_text, $NO_LOGFILE, "Missing logfile is detected");
+    PVE::VZDump::send_notification($SELF, $tasklist, 0, undef, undef, undef);
+    like($result_properties->{logs}, $NO_LOGFILE, "Missing logfile is detected");
 }
 {
     prepare_test($TEST_FILE_PATH);
     prepare_mail_with_status();
-    PVE::VZDump::sendmail($SELF, $tasklist, 0, undef, undef, undef);
-    unlike($result_text, $STATUS, "Status are not in text part of mails");
-    unlike($result_html, $STATUS, "Status are not in HTML part of mails");
+    PVE::VZDump::send_notification($SELF, $tasklist, 0, undef, undef, undef);
+    unlike($result_properties->{"status-text"}, $STATUS, "Status are not in text part of mails");
 }
 {
     prepare_test($TEST_FILE_PATH);
     prepare_long_mail();
-    PVE::VZDump::sendmail($SELF, $tasklist, 0, undef, undef, undef);
-    like($result_text, $LOG_TOO_LONG, "Text part of mails gets shortened");
-    like($result_html, $LOG_TOO_LONG, "HTML part of mails gets shortened");
+    PVE::VZDump::send_notification($SELF, $tasklist, 0, undef, undef, undef);
+    like($result_properties->{logs}, $LOG_TOO_LONG, "Text part of mails gets shortened");
 }
 unlink $TEST_FILE_PATH;
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 30/42] test: rename mail_test.pl to vzdump_notification_test.pl
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (28 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 29/42] vzdump: send notifications via new notification module Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 31/42] api: apt: send notification via new notification module Lukas Wagner
                   ` (12 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 test/Makefile                                      | 8 ++++----
 test/{mail_test.pl => vzdump_notification_test.pl} | 0
 2 files changed, 4 insertions(+), 4 deletions(-)
 rename test/{mail_test.pl => vzdump_notification_test.pl} (100%)

diff --git a/test/Makefile b/test/Makefile
index cccdc1c9..62d75050 100644
--- a/test/Makefile
+++ b/test/Makefile
@@ -5,7 +5,7 @@ all:
 export PERLLIB=..
 
 .PHONY: check
-check: test-replication test-balloon test-mail test-vzdump test-osd
+check: test-replication test-balloon test-vzdump-notification test-vzdump test-osd
 
 .PHONY: test-balloon
 test-balloon:
@@ -17,9 +17,9 @@ test-replication: replication1.t replication2.t replication3.t replication4.t re
 replication%.t: replication_test%.pl
 	./$<
 
-.PHONY: test-mail
-test-mail:
-	./mail_test.pl
+.PHONY: test-vzdump-notification
+test-vzdump-notification:
+	./vzdump_notification_test.pl
 
 .PHONY: test-vzdump
 test-vzdump: test-vzdump-guest-included test-vzdump-new
diff --git a/test/mail_test.pl b/test/vzdump_notification_test.pl
similarity index 100%
rename from test/mail_test.pl
rename to test/vzdump_notification_test.pl
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 31/42] api: apt: send notification via new notification module
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (29 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 30/42] test: rename mail_test.pl to vzdump_notification_test.pl Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 32/42] api: replication: send notifications " Lukas Wagner
                   ` (11 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

... instead of using sendmail directly

As of now, there is no way to configure a notification channel
for APT notifications. Thus, the implementation simply creates
an temporary channel with a sendmail endpoint sending mail
to root.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/API2/APT.pm | 73 ++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 63 insertions(+), 10 deletions(-)

diff --git a/PVE/API2/APT.pm b/PVE/API2/APT.pm
index 6694dbeb..c1b7ece5 100644
--- a/PVE/API2/APT.pm
+++ b/PVE/API2/APT.pm
@@ -19,6 +19,7 @@ use PVE::DataCenterConfig;
 use PVE::SafeSyslog;
 use PVE::INotify;
 use PVE::Exception;
+use PVE::Notify;
 use PVE::RESTHandler;
 use PVE::RPCEnvironment;
 use PVE::API2Tools;
@@ -272,6 +273,12 @@ __PACKAGE__->register_method({
 	return $pkglist;
     }});
 
+my $updates_available_subject_template = "New software packages available ({{hostname}})";
+my $updates_available_body_template = <<EOT;
+The following updates are available:
+{{table updates}}
+EOT
+
 __PACKAGE__->register_method({
     name => 'update_database',
     path => 'update',
@@ -342,27 +349,71 @@ __PACKAGE__->register_method({
 		my $mailto = $rootcfg->{email};
 
 		if ($mailto) {
-		    my $hostname = `hostname -f` || PVE::INotify::nodename();
-		    chomp $hostname;
+		    # Add ephemeral sendmail endpoint/channel for backwards compatibility
+		    # TODO: Make notification channel configurable, then the
+		    # temporary endpoint/channel should not be necessary any more.
+		    my $notification_config = PVE::Notify::read_config();
 		    my $mailfrom = $dcconf->{email_from} || "root";
-		    my $subject = "New software packages available ($hostname)";
+		    $notification_config->add_sendmail_endpoint(
+			"anonymous-apt-sendmail",
+			[$mailto],
+			$mailfrom,
+			""
+		    );
+
+		    $notification_config->add_channel("mail", ["anonymous-apt-sendmail"]);
+
+		    my $updates_table = {
+			schema => {
+			    columns => [
+				{
+				    label => "Package",
+				    id    => "package",
+				},
+				{
+				    label => "Old Version",
+				    id    => "old-version",
+				},
+				{
+				    label => "New Version",
+				    id    => "new-version",
+				}
+			    ]
+			},
+			data => []
+		    };
 
-		    my $data = "The following updates are available:\n\n";
+		    my $hostname = `hostname -f` || PVE::INotify::nodename();
+		    chomp $hostname;
 
 		    my $count = 0;
 		    foreach my $p (sort {$a->{Package} cmp $b->{Package} } @$pkglist) {
 			next if $p->{NotifyStatus} && $p->{NotifyStatus} eq $p->{Version};
 			$count++;
-			if ($p->{OldVersion}) {
-			    $data .= "$p->{Package}: $p->{OldVersion} ==> $p->{Version}\n";
-			} else {
-			    $data .= "$p->{Package}: $p->{Version} (new)\n";
-			}
+
+			push @{$updates_table->{data}}, {
+			    "package"     => $p->{Package},
+			    "old-version" => $p->{OldVersion},
+			    "new-version" => $p->{Version}
+
+			};
 		    }
 
 		    return if !$count;
 
-		    PVE::Tools::sendmail($mailto, $subject, $data, undef, $mailfrom, '');
+		    my $properties = {
+			updates  => $updates_table,
+			hostname => $hostname,
+		    };
+
+		    PVE::Notify::send_notification(
+			"mail",
+			"info",
+			$updates_available_subject_template,
+			$updates_available_body_template,
+			$properties,
+			$notification_config
+		    );
 
 		    foreach my $pi (@$pkglist) {
 			$pi->{NotifyStatus} = $pi->{Version};
@@ -378,6 +429,8 @@ __PACKAGE__->register_method({
 
     }});
 
+
+
 __PACKAGE__->register_method({
     name => 'changelog',
     path => 'changelog',
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 32/42] api: replication: send notifications via new notification module
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (30 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 31/42] api: apt: send notification via new notification module Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 33/42] ui: backup: allow to select notification channel for notifications Lukas Wagner
                   ` (10 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

There is no way yet to configure a notification channel for replication
notifications. Thus a temporary channel with a sendmail endpoint with
root as recipient is added.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/API2/Replication.pm | 75 ++++++++++++++++++++++++++++++-----------
 1 file changed, 55 insertions(+), 20 deletions(-)

diff --git a/PVE/API2/Replication.pm b/PVE/API2/Replication.pm
index d70b4607..8935c7e7 100644
--- a/PVE/API2/Replication.pm
+++ b/PVE/API2/Replication.pm
@@ -15,6 +15,7 @@ use PVE::QemuConfig;
 use PVE::QemuServer;
 use PVE::LXC::Config;
 use PVE::LXC;
+use PVE::Notify;
 
 use PVE::RESTHandler;
 
@@ -91,6 +92,24 @@ my sub _should_mail_at_failcount {
     return $i * 48 == $fail_count;
 };
 
+my $replication_error_subject_template = "Replication Job: '{{job-id}}' failed";
+my $replication_error_body_template = <<EOT;
+{{#verbatim}}
+Replication job '{{job-id}}' with target '{{job-target}}' and schedule '{{job-schedule}}' failed!
+
+Last successful sync: {{timestamp last-sync}}
+Next sync try: {{timestamp next-sync}}
+Failure count: {{failure-count}}
+
+{{#if (eq failure-count 3)}}
+Note: The system  will now reduce the frequency of error reports, as the job
+appears to be stuck.
+{{/if}}
+Error:
+{{verbatim-monospaced error}}
+{{/verbatim}}
+EOT
+
 my sub _handle_job_err {
     my ($job, $err, $mail) = @_;
 
@@ -103,33 +122,49 @@ my sub _handle_job_err {
 
     return if !_should_mail_at_failcount($fail_count);
 
-    my $schedule = $job->{schedule} // '*/15';
-
-    my $msg = "Replication job $job->{id} with target '$job->{target}' and schedule";
-    $msg .= " '$schedule' failed!\n";
-
-    $msg .= "  Last successful sync: ";
-    if (my $last_sync = $jobstate->{last_sync}) {
-	$msg .= render_timestamp($last_sync) ."\n";
-    } else {
-	$msg .= "None/Unknown\n";
-    }
     # not yet updated, so $job->next_sync here is actually the current one.
     # NOTE: Copied from PVE::ReplicationState::job_status()
     my $next_sync = $job->{next_sync} + 60 * ($fail_count <= 3 ? 5 * $fail_count : 30);
-    $msg .= "  Next sync try: " . render_timestamp($next_sync) ."\n";
-    $msg .= "  Failure count: $fail_count\n";
-
 
-    if ($fail_count == 3) {
-	$msg .= "\nNote: The system will now reduce the frequency of error reports,";
-	$msg .= " as the job appears to be stuck.\n";
-    }
+    # The replication job is run every 15 mins if no schedule is set.
+    my $schedule = $job->{schedule} // '*/15';
 
-    $msg .= "\nError:\n$err";
+    my $properties = {
+	"failure-count" => $fail_count,
+	"last-sync"     => $jobstate->{last_sync},
+	"next-sync"     => $next_sync,
+	"job-id"        => $job->{id},
+	"job-target"    => $job->{target},
+	"job-schedule"  => $schedule,
+	"error"         => $err,
+    };
 
     eval {
-	PVE::Tools::sendmail('root', "Replication Job: $job->{id} failed", $msg)
+	my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg');
+	my $mailfrom = $dcconf->{email_from} || "root";
+
+	# Add ephemeral sendmail endpoint/channel for backwards compatibility
+	# TODO: Make notification channel configurable, then the
+	# temporary endpoint/channel should not be necessary any more.
+	my $notification_config = PVE::Notify::read_config();
+	$notification_config->add_sendmail_endpoint(
+	    "anonymous-replication-sendmail",
+	    ["root"],
+	    $mailfrom,
+	    "pvescheduler"
+	);
+
+	my $channel = "mail";
+
+	$notification_config->add_channel($channel, ["anonymous-replication-sendmail"]);
+
+	PVE::Notify::error(
+	    $channel,
+	    $replication_error_subject_template,
+	    $replication_error_body_template,
+	    $properties,
+	    $notification_config
+	);
     };
     warn ": $@" if $@;
 }
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 33/42] ui: backup: allow to select notification channel for notifications
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (31 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 32/42] api: replication: send notifications " Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 34/42] ui: backup: adapt backup job details to new notification params Lukas Wagner
                   ` (9 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

This commit adds a possibility to choose between different options
for notifications for backup jobs:
    - Notify via email, in the same manner as before
    - Notify via a notification channel

If 'notify via mail' is selected, a text field where an email address
can be entered is displayed:

    Notify:         | Always notify  v |
    Notify via:     | E-Mail         v |
    Send Mail to:   | foo@example.com  |
    Compression:    | .....          v |

If 'notify via channel' is selected, a combo picker for selecting
a channel is displayed:

    Notify:         | Always notify  v |
    Notify via:     | Channel        v |
    Channel:        | chan1          v |
    Compression:    | .....          v |

The code has also been adapted to use the newly introduced
'notification-policy' parameter, which replaces the 'mailnotification'
paramter for backup jobs. Some logic which automatically migrates from
'mailnotification' has been added.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 www/manager6/Makefile                         |  4 +-
 www/manager6/dc/Backup.js                     | 77 +++++++++++++++++--
 .../form/NotificationChannelSelector.js       | 47 +++++++++++
 www/manager6/form/NotificationModeSelector.js |  8 ++
 ...ector.js => NotificationPolicySelector.js} |  1 +
 5 files changed, 129 insertions(+), 8 deletions(-)
 create mode 100644 www/manager6/form/NotificationChannelSelector.js
 create mode 100644 www/manager6/form/NotificationModeSelector.js
 rename www/manager6/form/{EmailNotificationSelector.js => NotificationPolicySelector.js} (87%)

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 2b577c8e..fea0bfdd 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -34,7 +34,6 @@ JSSRC= 							\
 	form/DayOfWeekSelector.js			\
 	form/DiskFormatSelector.js			\
 	form/DiskStorageSelector.js			\
-	form/EmailNotificationSelector.js		\
 	form/FileSelector.js				\
 	form/FirewallPolicySelector.js			\
 	form/GlobalSearchField.js			\
@@ -48,6 +47,9 @@ JSSRC= 							\
 	form/MemoryField.js				\
 	form/NetworkCardSelector.js			\
 	form/NodeSelector.js				\
+	form/NotificationChannelSelector.js		\
+	form/NotificationModeSelector.js		\
+	form/NotificationPolicySelector.js		\
 	form/PCISelector.js				\
 	form/PermPathSelector.js			\
 	form/PoolSelector.js				\
diff --git a/www/manager6/dc/Backup.js b/www/manager6/dc/Backup.js
index 03a02651..997b0393 100644
--- a/www/manager6/dc/Backup.js
+++ b/www/manager6/dc/Backup.js
@@ -35,6 +35,29 @@ Ext.define('PVE.dc.BackupEdit', {
 		}
 		delete values.node;
 	    }
+	    
+	    if (!isCreate) {
+		// 'mailnotification' is deprecated in favor of 'notification-policy'
+		// -> Migration to the new paramater happens in init, so we are
+		//    safe to remove the old parameter here.
+		Proxmox.Utils.assemble_field_data(values, { 'delete': 'mailnotification' });
+
+		// If sending notifications via mail, remove the current value of
+		// 'notification-channel'
+		if (values['notification-mode'] === "mail") {
+		    Proxmox.Utils.assemble_field_data(
+			values,
+			{ 'delete': 'notification-channel' }
+		    );
+		}
+		// and vice versa
+		if (values['notification-mode'] === "channel") {
+		    Proxmox.Utils.assemble_field_data(
+			values,
+			{ 'delete': 'mailto' }
+		    );
+		}
+	    }
 
 	    if (!values.id && isCreate) {
 		values.id = 'backup-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13);
@@ -146,6 +169,16 @@ Ext.define('PVE.dc.BackupEdit', {
 		    success: function(response, _options) {
 			let data = response.result.data;
 
+			// 'mailnotification' is deprecated. Let's automatically
+			// migrate to the compatible 'notification-policy' parameter
+			if (data.mailnotification) {
+			    if (!data["notification-policy"]) {
+				data["notification-policy"] = data.mailnotification;
+			    }
+
+			    delete data.mailnotification;
+			}
+
 			if (data.exclude) {
 			    data.vmid = data.exclude;
 			    data.selMode = 'exclude';
@@ -188,11 +221,13 @@ Ext.define('PVE.dc.BackupEdit', {
     viewModel: {
 	data: {
 	    selMode: 'include',
+	    notificationMode: 'mail',
 	},
 
 	formulas: {
 	    poolMode: (get) => get('selMode') === 'pool',
 	    disableVMSelection: (get) => get('selMode') !== 'include' && get('selMode') !== 'exclude',
+	    mailNotificationSelected: (get) => get('notificationMode') === 'mail',
 	},
     },
 
@@ -282,20 +317,48 @@ Ext.define('PVE.dc.BackupEdit', {
 				},
 			    ],
 			    column2: [
-				{
-				    xtype: 'textfield',
-				    fieldLabel: gettext('Send email to'),
-				    name: 'mailto',
-				},
 				{
 				    xtype: 'pveEmailNotificationSelector',
-				    fieldLabel: gettext('Email'),
-				    name: 'mailnotification',
+				    fieldLabel: gettext('Notify'),
+				    name: 'notification-policy',
 				    cbind: {
 					value: (get) => get('isCreate') ? 'always' : '',
 					deleteEmpty: '{!isCreate}',
 				    },
 				},
+				{
+				    xtype: 'pveNotificationModeSelector',
+				    fieldLabel: gettext('Notify via'),
+				    name: 'notification-mode',
+				    bind: {
+					value: '{notificationMode}',
+				    },
+				},
+				{
+				    xtype: 'pveNotificationChannelSelector',
+				    fieldLabel: gettext('Notification channel'),
+				    name: 'notification-channel',
+				    allowBlank: true,
+				    editable: true,
+				    autoSelect: false,
+				    bind: {
+					hidden: '{mailNotificationSelected}',
+					disabled: '{mailNotificationSelected}',
+				    },
+				    cbind: {
+					deleteEmpty: '{!isCreate}',
+				    },
+				},
+				{
+				    xtype: 'textfield',
+				    fieldLabel: gettext('Send email to'),
+				    name: 'mailto',
+				    hidden: true,
+				    bind: {
+					hidden: '{!mailNotificationSelected}',
+					disabled: '{!mailNotificationSelected}',
+				    },
+				},
 				{
 				    xtype: 'pveCompressionSelector',
 				    reference: 'compressionSelector',
diff --git a/www/manager6/form/NotificationChannelSelector.js b/www/manager6/form/NotificationChannelSelector.js
new file mode 100644
index 00000000..72dfd709
--- /dev/null
+++ b/www/manager6/form/NotificationChannelSelector.js
@@ -0,0 +1,47 @@
+Ext.define('PVE.form.NotificationChannelSelector', {
+    extend: 'Proxmox.form.ComboGrid',
+    alias: ['widget.pveNotificationChannelSelector'],
+
+    // set default value to empty array, else it inits it with
+    // null and after the store load it is an empty array,
+    // triggering dirtychange
+    value: [],
+    valueField: 'name',
+    displayField: 'name',
+    deleteEmpty: true,
+    skipEmptyText: true,
+
+    store: {
+	    fields: ['name', 'comment'],
+	    proxy: {
+		type: 'proxmox',
+		url: '/api2/json/cluster/notifications/channels',
+	    },
+	    sorters: [
+		{
+		    property: 'name',
+		    direction: 'ASC',
+		},
+	    ],
+	    autoLoad: true,
+	},
+
+    listConfig: {
+	columns: [
+	    {
+		header: gettext('Channel'),
+		dataIndex: 'name',
+		sortable: true,
+		hideable: false,
+		flex: 1,
+	    },
+	    {
+		header: gettext('Comment'),
+		dataIndex: 'comment',
+		sortable: true,
+		hideable: false,
+		flex: 2,
+	    },
+	],
+    },
+});
diff --git a/www/manager6/form/NotificationModeSelector.js b/www/manager6/form/NotificationModeSelector.js
new file mode 100644
index 00000000..7f56f10a
--- /dev/null
+++ b/www/manager6/form/NotificationModeSelector.js
@@ -0,0 +1,8 @@
+Ext.define('PVE.form.NotificationModeSelector', {
+    extend: 'Proxmox.form.KVComboBox',
+    alias: ['widget.pveNotificationModeSelector'],
+    comboItems: [
+	['channel', gettext('Channel')],
+	['mail', gettext('E-Mail')],
+    ],
+});
diff --git a/www/manager6/form/EmailNotificationSelector.js b/www/manager6/form/NotificationPolicySelector.js
similarity index 87%
rename from www/manager6/form/EmailNotificationSelector.js
rename to www/manager6/form/NotificationPolicySelector.js
index f318ea18..68087275 100644
--- a/www/manager6/form/EmailNotificationSelector.js
+++ b/www/manager6/form/NotificationPolicySelector.js
@@ -4,5 +4,6 @@ Ext.define('PVE.form.EmailNotificationSelector', {
     comboItems: [
 	['always', gettext('Notify always')],
 	['failure', gettext('On failure only')],
+	['never', gettext('Notify never')],
     ],
 });
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 34/42] ui: backup: adapt backup job details to new notification params
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (32 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 33/42] ui: backup: allow to select notification channel for notifications Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 35/42] ui: backup: allow to set notification-{channel, mode} for one-off backups Lukas Wagner
                   ` (8 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 www/manager6/dc/BackupJobDetail.js | 24 ++++++++++++++++++++----
 1 file changed, 20 insertions(+), 4 deletions(-)

diff --git a/www/manager6/dc/BackupJobDetail.js b/www/manager6/dc/BackupJobDetail.js
index c4683a47..f4a0d981 100644
--- a/www/manager6/dc/BackupJobDetail.js
+++ b/www/manager6/dc/BackupJobDetail.js
@@ -202,15 +202,31 @@ Ext.define('PVE.dc.BackupInfo', {
     column2: [
 	{
 	    xtype: 'displayfield',
-	    name: 'mailnotification',
+	    name: 'notification-policy',
 	    fieldLabel: gettext('Notification'),
 	    renderer: function(value) {
-		let mailto = this.up('pveBackupInfo')?.record?.mailto || 'root@localhost';
+		// Fall back to old value, in case this option is not migrated yet.
+		let policy = value || this.up('pveBackupInfo')?.record?.mailnotification || 'always';
+
 		let when = gettext('Always');
-		if (value === 'failure') {
+		if (policy === 'failure') {
 		    when = gettext('On failure only');
+		} else if (policy === 'never') {
+		    when = gettext('Never');
+		}
+
+		let mode = this.up('pveBackupInfo')?.record?.['notification-mode'] || 'mail';
+
+		let target = "";
+
+		if (mode === 'mail') {
+		    target = this.up('pveBackupInfo')?.record?.mailto ||
+			gettext('No mail recipient configured');
+		} else {
+		    target = this.up('pveBackupInfo')?.record?.['notification-channel'] ||
+			gettext('No channel configured');
 		}
-		return `${when} (${mailto})`;
+		return `${when} (${target})`;
 	    },
 	},
 	{
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 35/42] ui: backup: allow to set notification-{channel, mode} for one-off backups
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (33 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 34/42] ui: backup: adapt backup job details to new notification params Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 36/42] api: prepare api handler module for notification config Lukas Wagner
                   ` (7 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 www/manager6/window/Backup.js | 35 ++++++++++++++++++++++++++++++++++-
 1 file changed, 34 insertions(+), 1 deletion(-)

diff --git a/www/manager6/window/Backup.js b/www/manager6/window/Backup.js
index 4b21c746..8c8cd7d8 100644
--- a/www/manager6/window/Backup.js
+++ b/www/manager6/window/Backup.js
@@ -30,12 +30,33 @@ Ext.define('PVE.window.Backup', {
 	    name: 'mode',
 	});
 
+	let notificationChannelSelector = Ext.create('PVE.form.NotificationChannelSelector', {
+	    fieldLabel: gettext('Notification channel'),
+	    name: 'notification-channel',
+	    emptyText: Proxmox.Utils.noneText,
+	    hidden: true,
+	});
+
 	let mailtoField = Ext.create('Ext.form.field.Text', {
 	    fieldLabel: gettext('Send email to'),
 	    name: 'mailto',
 	    emptyText: Proxmox.Utils.noneText,
 	});
 
+	let notificationModeSelector = Ext.create('PVE.form.NotificationModeSelector', {
+	    fieldLabel: gettext('Notify via'),
+	    value: 'mail',
+	    name: 'notification-mode',
+	    listeners: {
+		change: function(f, v) {
+		    let mailSelected = v === 'mail';
+		    notificationChannelSelector.setHidden(mailSelected);
+		    mailtoField.setHidden(!mailSelected);
+		}
+	    }
+
+	});
+
 	const keepNames = [
 	    ['keep-last', gettext('Keep Last')],
 	    ['keep-hourly', gettext('Keep Hourly')],
@@ -107,6 +128,12 @@ Ext.define('PVE.window.Backup', {
 			success: function(response, opts) {
 			    const data = response.result.data;
 
+			    if (!initialDefaults && data['notification-mode'] !== undefined) {
+				notificationModeSelector.setValue(data['notification-mode']);
+			    }
+			    if (!initialDefaults && data['notification-channel'] !== undefined) {
+				notificationChannelSelector.setValue(data['notification-channel']);
+			    }
 			    if (!initialDefaults && data.mailto !== undefined) {
 				mailtoField.setValue(data.mailto);
 			    }
@@ -176,6 +203,8 @@ Ext.define('PVE.window.Backup', {
 	    ],
 	    column2: [
 		compressionSelector,
+		notificationModeSelector,
+		notificationChannelSelector,
 		mailtoField,
 		removeCheckbox,
 	    ],
@@ -252,10 +281,14 @@ Ext.define('PVE.window.Backup', {
 		    remove: values.remove,
 		};
 
-		if (values.mailto) {
+		if (values.mailto && values['notification-mode'] === 'mail') {
 		    params.mailto = values.mailto;
 		}
 
+		if (values['notification-channel'] && values['notification-mode'] === 'channel') {
+		    params['notification-channel'] = values['notification-channel'];
+		}
+
 		if (values.compress) {
 		    params.compress = values.compress;
 		}
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 36/42] api: prepare api handler module for notification config
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (34 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 35/42] ui: backup: allow to set notification-{channel, mode} for one-off backups Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 37/42] api: add api routes for notification channels Lukas Wagner
                   ` (6 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/API2/Cluster.pm               |   7 ++
 PVE/API2/Cluster/Makefile         |   1 +
 PVE/API2/Cluster/Notifications.pm | 134 ++++++++++++++++++++++++++++++
 3 files changed, 142 insertions(+)
 create mode 100644 PVE/API2/Cluster/Notifications.pm

diff --git a/PVE/API2/Cluster.pm b/PVE/API2/Cluster.pm
index 2e942368..c19a03c4 100644
--- a/PVE/API2/Cluster.pm
+++ b/PVE/API2/Cluster.pm
@@ -28,6 +28,7 @@ use PVE::API2::Cluster::BackupInfo;
 use PVE::API2::Cluster::Ceph;
 use PVE::API2::Cluster::Jobs;
 use PVE::API2::Cluster::MetricServer;
+use PVE::API2::Cluster::Notifications;
 use PVE::API2::ClusterConfig;
 use PVE::API2::Firewall::Cluster;
 use PVE::API2::HAConfig;
@@ -51,6 +52,11 @@ __PACKAGE__->register_method ({
     path => 'metrics',
 });
 
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Cluster::Notifications",
+    path => 'notifications',
+});
+
 __PACKAGE__->register_method ({
     subclass => "PVE::API2::ClusterConfig",
     path => 'config',
@@ -141,6 +147,7 @@ __PACKAGE__->register_method ({
 	    { name => 'jobs' },
 	    { name => 'log' },
 	    { name => 'metrics' },
+	    { name => 'notifications' },
 	    { name => 'nextid' },
 	    { name => 'options' },
 	    { name => 'replication' },
diff --git a/PVE/API2/Cluster/Makefile b/PVE/API2/Cluster/Makefile
index 8d306507..8bd65d3e 100644
--- a/PVE/API2/Cluster/Makefile
+++ b/PVE/API2/Cluster/Makefile
@@ -5,6 +5,7 @@ include ../../../defines.mk
 PERLSOURCE= 			\
 	BackupInfo.pm		\
 	MetricServer.pm		\
+	Notifications.pm		\
 	Jobs.pm			\
 	Ceph.pm
 
diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
new file mode 100644
index 00000000..7d3bf53d
--- /dev/null
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -0,0 +1,134 @@
+package PVE::API2::Cluster::Notifications;
+
+use warnings;
+use strict;
+
+use Storable qw(dclone);
+use JSON;
+
+use PVE::Tools qw(extract_param);
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::RESTHandler;
+use PVE::Notify;
+
+use base qw(PVE::RESTHandler);
+
+sub make_properties_optional {
+    my ($properties) = @_;
+    $properties = dclone($properties);
+
+    for my $key (keys %$properties) {
+	$properties->{$key}->{optional} = 1 if $key ne 'name';
+    }
+
+    return $properties;
+}
+
+sub raise_api_error {
+    my ($api_error) = @_;
+
+    my $msg = "$api_error->{message}\n";
+    my $exc = PVE::Exception->new($msg, code => $api_error->{code});
+
+    my (undef, $filename, $line) = caller;
+
+    $exc->{filename} = $filename;
+    $exc->{line} = $line;
+
+    die $exc;
+}
+
+__PACKAGE__->register_method ({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    description => 'Notifications index.',
+    permissions => { user => 'all' },
+    parameters => {
+	additionalProperties => 0,
+	properties => {},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => 'object',
+	    properties => {},
+	},
+	links => [ { rel => 'child', href => '{name}' } ],
+    },
+    code => sub {
+	my $result = [
+	    { name => 'endpoints' },
+	];
+
+	return $result;
+    }
+});
+
+
+__PACKAGE__->register_method ({
+    name => 'endpoints_index',
+    path => 'endpoints',
+    method => 'GET',
+    description => 'Notifications index.',
+    permissions => { user => 'all' },
+    parameters => {
+	additionalProperties => 0,
+	properties => {},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => 'object',
+	    properties => {},
+	},
+	links => [ { rel => 'child', href => '{name}' } ],
+    },
+    code => sub {
+	my $result = [
+	    { name => 'test' },
+	];
+
+	return $result;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'test_endpoint',
+    path => 'endpoints/test/{name}',
+    protected => 1,
+    method => 'POST',
+    description => 'Send notification',
+    permissions => {
+	# Let's assume that 'testing an endpoint' should be allowed for users
+	# who are also allowed to modify the notification configuration.
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    name => {
+		description => 'Name of the endpoint',
+		type => 'string',
+		format => 'pve-configid'
+	    },
+	},
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+	my $name = extract_param($param, 'name');
+
+	my $config = PVE::Notify::read_config();
+
+	eval {
+	    $config->test_endpoint($name);
+	};
+
+	raise_api_error($@) if ($@);
+
+	return;
+    }
+});
+
+1;
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 37/42] api: add api routes for notification channels
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (35 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 36/42] api: prepare api handler module for notification config Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 38/42] api: add api routes for sendmail endpoints Lukas Wagner
                   ` (5 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/API2/Cluster/Notifications.pm | 251 ++++++++++++++++++++++++++++++
 1 file changed, 251 insertions(+)

diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 7d3bf53d..74538554 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -59,6 +59,7 @@ __PACKAGE__->register_method ({
     code => sub {
 	my $result = [
 	    { name => 'endpoints' },
+	    { name => 'channels' },
 	];
 
 	return $result;
@@ -131,4 +132,254 @@ __PACKAGE__->register_method ({
     }
 });
 
+my $channel_properties = {
+    name => {
+	description => 'Name of the channel.',
+	type => 'string',
+	format => 'pve-configid',
+    },
+    'endpoint' => {
+	type => 'array',
+	items => {
+	    type => 'string',
+	    format => 'pve-configid',
+	},
+	description => 'List of included endpoints',
+	optional => 1,
+    },
+    'comment' => {
+	description => 'Comment',
+	type        => 'string',
+	optional    => 1,
+    },
+};
+
+my $channel_create_properties = {
+    name => {
+	description => 'Name of the channel.',
+	type => 'string',
+	format => 'pve-configid',
+    },
+    'endpoint' => {
+	type => 'string',
+	format => 'pve-configid-list',
+	description => 'List of included endpoints',
+	optional => 1,
+    },
+    'comment' => {
+	description => 'Comment',
+	type        => 'string',
+	optional    => 1,
+    },
+};
+
+# when updating, every property (except for 'name') becomes optional
+my $channel_update_properties = make_properties_optional($channel_create_properties);
+
+__PACKAGE__->register_method ({
+    name => 'get_channels',
+    path => 'channels',
+    method => 'GET',
+    description => 'Returns a list of all channels',
+    protected => 1,
+    permissions => {
+	check => ['perm', '/', ['Sys.Audit']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => 'object',
+	    properties => $channel_properties,
+	},
+	links => [ { rel => 'child', href => '{name}' } ],
+    },
+    code => sub {
+	my $config = PVE::Notify::read_config();
+
+	my $channels = eval {
+	    $config->get_channels()
+	};
+
+	raise_api_error($@) if ($@);
+	return $channels;
+
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'get_channel',
+    path => 'channels/{name}',
+    method => 'GET',
+    description => 'Return a specific channel',
+    protected => 1,
+    permissions => {
+	check => ['perm', '/', ['Sys.Audit']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    name => {
+		type => 'string',
+		format => 'pve-configid',
+	    },
+	}
+    },
+    returns => {
+	type => 'object',
+	properties => $channel_properties,
+    },
+    code => sub {
+	my ($param) = @_;
+	my $name = extract_param($param, 'name');
+
+	my $config = PVE::Notify::read_config();
+
+	my $channel = eval {
+	    $config->get_channel($name)
+	};
+
+	raise_api_error($@) if ($@);
+	return $channel;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'create_channel',
+    path => 'channels',
+    protected => 1,
+    method => 'POST',
+    description => 'Create a new channel',
+    permissions => {
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => $channel_create_properties,
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+
+	my $name = extract_param($param, 'name');
+	my $endpoint = extract_param($param, 'endpoint');
+	my $comment = extract_param($param, 'comment');
+
+	if (defined $endpoint) {
+	    $endpoint = [PVE::Tools::split_list($endpoint)];
+	}
+
+	PVE::Notify::lock_config(sub {
+	    my $config = PVE::Notify::read_config();
+
+	    $config->add_channel(
+		$name,
+		$endpoint,
+		$comment,
+	    );
+
+	    PVE::Notify::write_config($config);
+	});
+
+	raise_api_error($@) if ($@);
+	return;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'update_channel',
+    path => 'channels/{name}',
+    protected => 1,
+    method => 'PUT',
+    description => 'Update existing channel',
+    permissions => {
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    %$channel_update_properties,
+	    delete => {
+		type => 'string',
+		format => 'pve-configid-list',
+		description => 'A list of settings you want to delete.',
+		maxLength => 4096,
+		optional => 1,
+	    },
+	    digest => get_standard_option('pve-config-digest'),
+	},
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+
+	my $name = extract_param($param, 'name');
+	my $endpoint = extract_param($param, 'endpoint');
+	my $comment = extract_param($param, 'comment');
+	my $digest = extract_param($param, 'digest');
+	my $delete = extract_param($param, 'delete');
+
+	if (defined $endpoint) {
+	    $endpoint = [PVE::Tools::split_list($endpoint)];
+	}
+
+	if (defined $delete) {
+	    $delete = [PVE::Tools::split_list($delete)];
+	}
+
+	PVE::Notify::lock_config(sub {
+	    my $config = PVE::Notify::read_config();
+
+	    $config->update_channel(
+		$name,
+		$endpoint,
+		$comment,
+		$delete,
+		$digest,
+	    );
+
+	    PVE::Notify::write_config($config);
+	});
+
+	raise_api_error($@) if ($@);
+	return;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'delete_channel',
+    protected => 1,
+    path => 'channels/{name}',
+    method => 'DELETE',
+    description => 'Remove channel',
+    permissions => {
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    name => {
+		type => 'string',
+		format => 'pve-configid',
+	    },
+	}
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+	my $name = extract_param($param, 'name');
+
+	PVE::Notify::lock_config(sub {
+	    my $config = PVE::Notify::read_config();
+	    $config->delete_channel($name);
+	    PVE::Notify::write_config($config);
+	});
+
+	raise_api_error($@) if ($@);
+	return;
+    }
+});
 1;
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 38/42] api: add api routes for sendmail endpoints
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (36 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 37/42] api: add api routes for notification channels Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 39/42] api: add api routes for gotify endpoints Lukas Wagner
                   ` (4 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/API2/Cluster/Notifications.pm | 293 ++++++++++++++++++++++++++++++
 1 file changed, 293 insertions(+)

diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 74538554..53e447ad 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -87,6 +87,7 @@ __PACKAGE__->register_method ({
     },
     code => sub {
 	my $result = [
+	    { name => 'sendmail' },
 	    { name => 'test' },
 	];
 
@@ -382,4 +383,296 @@ __PACKAGE__->register_method ({
 	return;
     }
 });
+
+my $sendmail_properties = {
+    name => {
+	description => 'The name of the endpoint.',
+	type => 'string',
+	format => 'pve-configid',
+    },
+    recipient => {
+	type => 'array',
+	items => {
+	    type => 'string',
+	    format => 'email',
+	},
+	description => 'List of email recipients',
+    },
+    'from-address' => {
+	description => '`From` address for the mail',
+	type => 'string',
+	optional => 1,
+    },
+    author => {
+	description => 'Author of the mail',
+	type => 'string',
+	optional => 1,
+    },
+    'comment' => {
+	description => 'Comment',
+	type        => 'string',
+	optional    => 1,
+    },
+    filter => {
+	description => 'Filter to apply',
+	type => 'string',
+	format => 'pve-configid',
+	optional => 1,
+    },
+};
+
+my $sendmail_create_properties = {
+    name => {
+	type => 'string',
+	format => 'pve-configid',
+	description => 'The name of the endpoint.',
+    },
+    recipient => {
+	type => 'string',
+	format => 'email-list',
+	description => 'List of email recipients',
+    },
+    'from-address' => {
+	description => '`From` address for the mail',
+	type => 'string',
+	optional => 1,
+    },
+    author => {
+	description => 'Author of the mail',
+	type => 'string',
+	optional => 1,
+    },
+    'comment' => {
+	description => 'Comment',
+	type        => 'string',
+	optional    => 1,
+    },
+    filter => {
+	description => 'Filter to apply',
+	type => 'string',
+	format => 'pve-configid',
+	optional => 1,
+    },
+};
+
+# when updating, every property (except for 'name') becomes optional
+my $sendmail_update_properties = make_properties_optional($sendmail_create_properties);
+
+__PACKAGE__->register_method ({
+    name => 'get_sendmail_endpoints',
+    path => 'endpoints/sendmail',
+    method => 'GET',
+    description => 'Returns a list of all sendmail endpoints',
+    permissions => {
+	check => ['perm', '/', ['Sys.Audit']],
+    },
+    protected => 1,
+    parameters => {
+	additionalProperties => 0,
+	properties => {},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => 'object',
+	    properties => $sendmail_properties,
+	},
+	links => [ { rel => 'child', href => '{name}' } ],
+    },
+    code => sub {
+	my $config = PVE::Notify::read_config();
+	my $endpoints = eval {
+	    $config->get_sendmail_endpoints()
+	};
+
+	raise_api_error($@) if ($@);
+	return $endpoints;
+
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'get_sendmail_endpoint',
+    path => 'endpoints/sendmail/{name}',
+    method => 'GET',
+    description => 'Return a specific sendmail endpoint',
+    permissions => {
+	check => ['perm', '/', ['Sys.Audit']],
+    },
+    protected => 1,
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    name => {
+		type => 'string',
+		format => 'pve-configid',
+	    },
+	}
+    },
+    returns => {
+	type => 'object',
+	properties => $sendmail_properties,
+    },
+    code => sub {
+	my ($param) = @_;
+	my $name = extract_param($param, 'name');
+
+	my $config = PVE::Notify::read_config();
+	my $endpoint = eval {
+	    $config->get_sendmail_endpoint($name)
+	};
+
+	raise_api_error($@) if ($@);
+	return $endpoint;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'create_sendmail_endpoint',
+    path => 'endpoints/sendmail',
+    protected => 1,
+    method => 'POST',
+    description => 'Create a new sendmail endpoint',
+    permissions => {
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => $sendmail_create_properties,
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+
+	my $name = extract_param($param, 'name');
+	my $recipient = extract_param($param, 'recipient');
+	my $from_address = extract_param($param, 'from-address');
+	my $author = extract_param($param, 'author');
+	my $comment = extract_param($param, 'comment');
+	my $filter = extract_param($param, 'filter');
+
+
+	PVE::Notify::lock_config(sub {
+	    my $config = PVE::Notify::read_config();
+
+	    my $recipient_list = [PVE::Tools::split_list($recipient)];
+
+	    $config->add_sendmail_endpoint(
+		$name,
+		$recipient_list,
+		$from_address,
+		$author,
+		$comment,
+		$filter
+	    );
+
+	    PVE::Notify::write_config($config);
+	});
+
+	raise_api_error($@) if ($@);
+	return;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'update_sendmail_endpoint',
+    path => 'endpoints/sendmail/{name}',
+    protected => 1,
+    method => 'PUT',
+    description => 'Update existing sendmail endpoint',
+    permissions => {
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    %$sendmail_update_properties,
+	    delete => {
+		type => 'string',
+		format => 'pve-configid-list',
+		description => 'A list of settings you want to delete.',
+		maxLength => 4096,
+		optional => 1,
+	    },
+	    digest => get_standard_option('pve-config-digest'),
+
+	}
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+
+	my $name = extract_param($param, 'name');
+	my $recipient = extract_param($param, 'recipient');
+	my $from_address = extract_param($param, 'from-address');
+	my $author = extract_param($param, 'author');
+	my $comment = extract_param($param, 'comment');
+	my $filter = extract_param($param, 'filter');
+
+	my $delete = extract_param($param, 'delete');
+	my $digest = extract_param($param, 'digest');
+
+	if (defined $delete) {
+	    $delete = [PVE::Tools::split_list($delete)];
+	}
+
+	if (defined $recipient) {
+	    $recipient = [PVE::Tools::split_list($recipient)];
+	}
+
+	PVE::Notify::lock_config(sub {
+	    my $config = PVE::Notify::read_config();
+
+	    $config->update_sendmail_endpoint(
+		$name,
+		$recipient,
+		$from_address,
+		$author,
+		$comment,
+		$filter,
+		$delete,
+		$digest,
+	    );
+
+	    PVE::Notify::write_config($config);
+	});
+
+	raise_api_error($@) if ($@);
+	return;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'delete_sendmail_endpoint',
+    protected => 1,
+    path => 'endpoints/sendmail/{name}',
+    method => 'DELETE',
+    description => 'Remove sendmail endpoint',
+    permissions => {
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    name => {
+		type => 'string',
+		format => 'pve-configid',
+	    },
+	}
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+
+	PVE::Notify::lock_config(sub {
+	    my $config = PVE::Notify::read_config();
+	    $config->delete_sendmail_endpoint($param->{name});
+	    PVE::Notify::write_config($config);
+	});
+
+	raise_api_error($@) if ($@);
+	return;
+    }
+});
+
 1;
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 39/42] api: add api routes for gotify endpoints
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (37 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 38/42] api: add api routes for sendmail endpoints Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 40/42] api: add api routes for notification filters Lukas Wagner
                   ` (3 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/API2/Cluster/Notifications.pm | 257 ++++++++++++++++++++++++++++++
 1 file changed, 257 insertions(+)

diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 53e447ad..823e8107 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -87,6 +87,7 @@ __PACKAGE__->register_method ({
     },
     code => sub {
 	my $result = [
+	    { name => 'gotify' },
 	    { name => 'sendmail' },
 	    { name => 'test' },
 	];
@@ -675,4 +676,260 @@ __PACKAGE__->register_method ({
     }
 });
 
+my $gotify_properties = {
+    name => {
+	description => 'The name of the endpoint.',
+	type => 'string',
+	format => 'pve-configid',
+    },
+    'server' => {
+	description => 'Server URL',
+	type => 'string',
+    },
+    'comment' => {
+	description => 'Comment',
+	type        => 'string',
+	optional    => 1,
+    },
+    'filter' => {
+	description => 'Filter to apply',
+	type => 'string',
+	format => 'pve-configid',
+	optional => 1,
+    }
+};
+
+my $gotify_create_properties = {
+    name => {
+	type => 'string',
+	format => 'pve-configid',
+	description => 'The name of the endpoint.',
+    },
+    server => {
+	type => 'string',
+    },
+    token => {
+	type => 'string',
+    },
+    'comment' => {
+	description => 'Comment',
+	type        => 'string',
+	optional    => 1,
+    },
+    filter => {
+	description => 'Filter to apply',
+	type => 'string',
+	format => 'pve-configid',
+	optional => 1,
+    },
+};
+
+# when updating, every property (except for 'name') becomes optional
+my $gotify_update_properties = make_properties_optional($gotify_create_properties);
+
+__PACKAGE__->register_method ({
+    name => 'get_gotify_endpoints',
+    path => 'endpoints/gotify',
+    method => 'GET',
+    description => 'Returns a list of all gotify endpoints',
+    protected => 1,
+    permissions => {
+	check => ['perm', '/', ['Sys.Audit']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => 'object',
+	    properties => $gotify_properties,
+	},
+	links => [ { rel => 'child', href => '{name}' } ],
+    },
+    code => sub {
+	my $config = PVE::Notify::read_config();
+	my $endpoints = eval {
+	    $config->get_gotify_endpoints()
+	};
+
+	raise_api_error($@) if ($@);
+	return $endpoints;
+
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'get_gotify_endpoint',
+    path => 'endpoints/gotify/{name}',
+    method => 'GET',
+    description => 'Return a specific gotify endpoint',
+    protected => 1,
+    permissions => {
+	check => ['perm', '/', ['Sys.Audit']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    name => {
+		type => 'string',
+		format => 'pve-configid',
+	    },
+	}
+    },
+    returns => {
+	type => 'object',
+	properties => $gotify_properties,
+    },
+    code => sub {
+	my ($param) = @_;
+	my $name = extract_param($param, 'name');
+
+	my $config = PVE::Notify::read_config();
+	my $endpoint = eval {
+	    $config->get_gotify_endpoint($name)
+	};
+
+	raise_api_error($@) if ($@);
+	return $endpoint;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'create_gotify_endpoint',
+    path => 'endpoints/gotify',
+    protected => 1,
+    method => 'POST',
+    description => 'Create a new gotify endpoint',
+    permissions => {
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => $gotify_create_properties,
+    },
+    returns => { type => 'null' },
+    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 $filter = extract_param($param, 'filter');
+
+	PVE::Notify::lock_config(sub {
+	    my $config = PVE::Notify::read_config();
+
+	    $config->add_gotify_endpoint(
+		$name,
+		$server,
+		$token,
+		$comment,
+		$filter
+	    );
+
+	    PVE::Notify::write_config($config);
+	});
+
+	raise_api_error($@) if ($@);
+	return;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'update_gotify_endpoint',
+    path => 'endpoints/gotify/{name}',
+    protected => 1,
+    method => 'PUT',
+    description => 'Update existing gotify endpoint',
+    permissions => {
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    %$gotify_update_properties,
+	    delete => {
+		type => 'string',
+		format => 'pve-configid-list',
+		description => 'A list of settings you want to delete.',
+		maxLength => 4096,
+		optional => 1,
+	    },
+	    digest => get_standard_option('pve-config-digest'),
+	}
+    },
+    returns => { type => 'null' },
+    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 $filter = extract_param($param, 'filter');
+
+	my $delete = extract_param($param, 'delete');
+	my $digest = extract_param($param, 'digest');
+
+	if (defined $delete) {
+	    $delete = [PVE::Tools::split_list($delete)];
+	}
+
+	PVE::Notify::lock_config(sub {
+	    my $config = PVE::Notify::read_config();
+
+	    $config->update_gotify_endpoint(
+		$name,
+		$server,
+		$token,
+		$comment,
+		$filter,
+		$delete,
+		$digest,
+	    );
+
+	    PVE::Notify::write_config($config);
+	});
+
+	raise_api_error($@) if ($@);
+	return;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'delete_gotify_endpoint',
+    protected => 1,
+    path => 'endpoints/gotify/{name}',
+    method => 'DELETE',
+    description => 'Remove gotify endpoint',
+    permissions => {
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    name => {
+		type => 'string',
+		format => 'pve-configid',
+	    },
+	}
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+	my $name = extract_param($param, 'name');
+
+	PVE::Notify::lock_config(sub {
+	    my $config = PVE::Notify::read_config();
+	    $config->delete_gotify_endpoint($name);
+	    PVE::Notify::write_config($config);
+	});
+
+	raise_api_error($@) if ($@);
+	return;
+    }
+});
 1;
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 40/42] api: add api routes for notification filters
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (38 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 39/42] api: add api routes for gotify endpoints Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 41/42] ui: backup: disable notification mode selector for now Lukas Wagner
                   ` (2 subsequent siblings)
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/API2/Cluster/Notifications.pm | 327 ++++++++++++++++++++++++++++++
 1 file changed, 327 insertions(+)

diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 823e8107..61deb9b3 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -59,6 +59,7 @@ __PACKAGE__->register_method ({
     code => sub {
 	my $result = [
 	    { name => 'endpoints' },
+	    { name => 'filters' },
 	    { name => 'channels' },
 	];
 
@@ -932,4 +933,330 @@ __PACKAGE__->register_method ({
 	return;
     }
 });
+
+my $filter_properties = {
+    name => {
+	description => 'Name of the endpoint.',
+	type => 'string',
+	format => 'pve-configid',
+    },
+    'min-severity' => {
+	type => 'string',
+	description => 'Minimum severity to match',
+	optional => 1,
+	enum => [qw(info notice warning error)],
+    },
+    'sub-filter' => {
+	type => 'array',
+	items => {
+	    type => 'string',
+	    format => 'pve-configid',
+	},
+	description => 'List of sub-filters',
+	optional => 1,
+    },
+    mode => {
+	type => 'string',
+	description => "Choose between 'and' and 'or' for when multiple properties are specified",
+	optional => 1,
+    },
+    'match-property' => {
+	type => 'array',
+	items => {
+	    type => 'string',
+	},
+	description => 'Properties to match (exact match)',
+	optional => 1,
+    },
+    'invert-match' => {
+	type => 'boolean',
+	description => 'Invert match of the whole filter',
+	optional => 1,
+    },
+    'comment' => {
+	description => 'Comment',
+	type        => 'string',
+	optional    => 1,
+    },
+};
+
+my $filter_create_properties = {
+    name => {
+	description => 'Name of the filter.',
+	type => 'string',
+	format => 'pve-configid',
+    },
+    'min-severity' => {
+	type => 'string',
+	description => 'Minimum severity to match',
+	optional => 1,
+	enum => [qw(info notice warning error)],
+    },
+    'sub-filter' => {
+	type => 'string',
+	format => 'pve-configid-list',
+	description => 'List of sub-filters',
+	optional => 1,
+    },
+    mode => {
+	type => 'string',
+	description => "Choose between 'and' and 'or' for when multiple properties are specified",
+	optional => 1,
+	enum => [qw(and or)],
+    },
+    'match-property' => {
+	type => 'string',
+	format => 'string-list',
+	description => 'Properties to match (exact match)',
+	optional => 1,
+    },
+    'invert-match' => {
+	type => 'boolean',
+	description => 'Invert match of the whole filter',
+	optional => 1,
+    },
+    'comment' => {
+	description => 'Comment',
+	type        => 'string',
+	optional    => 1,
+    },
+};
+
+# when updating, every property (except for 'name') becomes optional
+my $filter_update_properties = make_properties_optional($filter_create_properties);
+
+__PACKAGE__->register_method ({
+    name => 'get_filters',
+    path => 'filters',
+    method => 'GET',
+    description => 'Returns a list of all filters',
+    protected => 1,
+    permissions => {
+	check => ['perm', '/', ['Sys.Audit']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {},
+    },
+    returns => {
+	type => 'array',
+	items => {
+	    type => 'object',
+	    properties => {
+		%$filter_properties,
+	    },
+	},
+	links => [ { rel => 'child', href => '{name}' } ],
+    },
+    code => sub {
+	my $config = PVE::Notify::read_config();
+	my $filters = eval {
+	    $config->get_filters()
+	};
+
+	raise_api_error($@) if ($@);
+	return $filters;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'get_fiter',
+    path => 'filters/{name}',
+    method => 'GET',
+    description => 'Return a specific filter',
+    protected => 1,
+    permissions => {
+	check => ['perm', '/', ['Sys.Audit']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    name => {
+		type => 'string',
+		format => 'pve-configid',
+	    },
+	}
+    },
+    returns => {
+	type => 'object',
+	properties => {
+	    %$filter_properties,
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+	my $name = extract_param($param, 'name');
+
+	my $config = PVE::Notify::read_config();
+
+	my $filter = eval {
+	    $config->get_filter($name)
+	};
+
+	raise_api_error($@) if ($@);
+	return $filter;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'create_filter',
+    path => 'filters',
+    protected => 1,
+    method => 'POST',
+    description => 'Create a new filter',
+    protected => 1,
+    permissions => {
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => $filter_create_properties,
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+
+	my $name = extract_param($param, 'name');
+	my $min_severity = extract_param($param, 'min-severity');
+	my $sub_filter = extract_param($param, 'sub-filter');
+	my $mode = extract_param($param, 'mode');
+	my $invert_match = extract_param($param, 'invert-match');
+	my $match_property = extract_param($param, 'match-property');
+	my $comment = extract_param($param, 'comment');
+
+	if (defined $sub_filter) {
+	    $sub_filter = [PVE::Tools::split_list($sub_filter)];
+	}
+
+	if (defined $match_property) {
+	    $match_property = [PVE::Tools::split_list($match_property)];
+	}
+
+	PVE::Notify::lock_config(sub {
+	    my $config = PVE::Notify::read_config();
+
+	    $config->add_filter(
+		$name,
+		$min_severity,
+		$sub_filter,
+		$mode,
+		$match_property,
+		$invert_match,
+		$comment,
+	    );
+
+	    PVE::Notify::write_config($config);
+	});
+
+	raise_api_error($@) if ($@);
+	return;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'update_filter',
+    path => 'filters/{name}',
+    protected => 1,
+    method => 'PUT',
+    description => 'Update existing filter',
+    permissions => {
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    %$filter_update_properties,
+	    delete => {
+		type => 'string',
+		format => 'pve-configid-list',
+		description => 'A list of settings you want to delete.',
+		maxLength => 4096,
+		optional => 1,
+	    },
+	    digest => get_standard_option('pve-config-digest'),
+	},
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+
+	my $name = extract_param($param, 'name');
+	my $min_severity = extract_param($param, 'min-severity');
+	my $sub_filter = extract_param($param, 'sub-filter');
+	my $mode = extract_param($param, 'mode');
+	my $invert_match = extract_param($param, 'invert-match');
+	my $comment = extract_param($param, 'comment');
+	my $digest = extract_param($param, 'digest');
+	my $match_property = extract_param($param, 'match-property');
+	my $delete = extract_param($param, 'delete');
+
+	if (defined $sub_filter) {
+	    $sub_filter = [PVE::Tools::split_list($sub_filter)];
+	}
+
+	if (defined $match_property) {
+	    $match_property = [PVE::Tools::split_list($match_property)];
+	}
+
+	if (defined $delete) {
+	    $delete = [PVE::Tools::split_list($delete)];
+	}
+
+	PVE::Notify::lock_config(sub {
+	    my $config = PVE::Notify::read_config();
+
+	    $config->update_filter(
+		$name,
+		$min_severity,
+		$sub_filter,
+		$mode,
+		$match_property,
+		$invert_match,
+		$comment,
+		$delete,
+		$digest,
+	    );
+
+	    PVE::Notify::write_config($config);
+	});
+
+	raise_api_error($@) if ($@);
+	return;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'delete_filter',
+    protected => 1,
+    path => 'filters/{name}',
+    method => 'DELETE',
+    description => 'Remove filter',
+    permissions => {
+	check => ['perm', '/', ['Sys.Modify']],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    name => {
+		type => 'string',
+		format => 'pve-configid',
+	    },
+	}
+    },
+    returns => { type => 'null' },
+    code => sub {
+	my ($param) = @_;
+	my $name = extract_param($param, 'name');
+
+	PVE::Notify::lock_config(sub {
+	    my $config = PVE::Notify::read_config();
+	    $config->delete_filter($name);
+	    PVE::Notify::write_config($config);
+	});
+
+	raise_api_error($@) if ($@);
+	return;
+    }
+});
+
 1;
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-manager 41/42] ui: backup: disable notification mode selector for now
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (39 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 40/42] api: add api routes for notification filters Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-ha-manager 42/42] manager: send notifications via new notification module Lukas Wagner
  2023-05-26  8:31 ` [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce " Lukas Wagner
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

I think it's best to not expose the notification channel feature
until we have at least a basic managment GUI for endpoints/channels.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 www/manager6/dc/Backup.js     | 1 +
 www/manager6/window/Backup.js | 4 ++--
 2 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/www/manager6/dc/Backup.js b/www/manager6/dc/Backup.js
index 997b0393..56e70121 100644
--- a/www/manager6/dc/Backup.js
+++ b/www/manager6/dc/Backup.js
@@ -333,6 +333,7 @@ Ext.define('PVE.dc.BackupEdit', {
 				    bind: {
 					value: '{notificationMode}',
 				    },
+				    disabled: true,
 				},
 				{
 				    xtype: 'pveNotificationChannelSelector',
diff --git a/www/manager6/window/Backup.js b/www/manager6/window/Backup.js
index 8c8cd7d8..b659c582 100644
--- a/www/manager6/window/Backup.js
+++ b/www/manager6/window/Backup.js
@@ -53,8 +53,8 @@ Ext.define('PVE.window.Backup', {
 		    notificationChannelSelector.setHidden(mailSelected);
 		    mailtoField.setHidden(!mailSelected);
 		}
-	    }
-
+	    },
+	    disabled: true,
 	});
 
 	const keepNames = [
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* [pve-devel] [PATCH v2 pve-ha-manager 42/42] manager: send notifications via new notification module
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (40 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 41/42] ui: backup: disable notification mode selector for now Lukas Wagner
@ 2023-05-24 13:56 ` Lukas Wagner
  2023-05-26  8:31 ` [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce " Lukas Wagner
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-24 13:56 UTC (permalink / raw)
  To: pve-devel

... instead of using sendmail directly

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/PVE/HA/Env.pm        |  6 ++---
 src/PVE/HA/Env/PVE2.pm   | 27 ++++++++++++++++++---
 src/PVE/HA/NodeStatus.pm | 52 ++++++++++++++++++++++++----------------
 src/PVE/HA/Sim/Env.pm    | 10 ++++++--
 4 files changed, 66 insertions(+), 29 deletions(-)

diff --git a/src/PVE/HA/Env.pm b/src/PVE/HA/Env.pm
index 16603ec..b7060a4 100644
--- a/src/PVE/HA/Env.pm
+++ b/src/PVE/HA/Env.pm
@@ -144,10 +144,10 @@ sub log {
     return $self->{plug}->log($level, @args);
 }
 
-sub sendmail {
-    my ($self, $subject, $text) = @_;
+sub send_notification {
+    my ($self, $subject, $text, $properties) = @_;
 
-    return $self->{plug}->sendmail($subject, $text);
+    return $self->{plug}->send_notification($subject, $text, $properties);
 }
 
 # acquire a cluster wide manager lock
diff --git a/src/PVE/HA/Env/PVE2.pm b/src/PVE/HA/Env/PVE2.pm
index f6ebfeb..d818812 100644
--- a/src/PVE/HA/Env/PVE2.pm
+++ b/src/PVE/HA/Env/PVE2.pm
@@ -13,6 +13,7 @@ use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file
 use PVE::DataCenterConfig;
 use PVE::INotify;
 use PVE::RPCEnvironment;
+use PVE::Notify;
 
 use PVE::HA::Tools ':exit_codes';
 use PVE::HA::Env;
@@ -219,8 +220,8 @@ sub log {
     syslog($level, $msg);
 }
 
-sub sendmail {
-    my ($self, $subject, $text) = @_;
+sub send_notification {
+    my ($self, $subject, $text, $properties) = @_;
 
     # Leave it to postfix to append the correct hostname
     my $mailfrom = 'root';
@@ -228,7 +229,27 @@ sub sendmail {
     # mail to the address configured in the datacenter
     my $mailto = 'root';
 
-    PVE::Tools::sendmail($mailto, $subject, $text, undef, $mailfrom);
+    # Add ephemeral sendmail endpoint/channel for backwards compatibility
+    # TODO: Make notification channel configurable, then the
+    # temporary endpoint/channel should not be necessary any more.
+    my $notification_config = PVE::Notify::read_config();
+    $notification_config->add_sendmail_endpoint(
+	'anonymous-ha-manager-sendmail',
+	[$mailto],
+	$mailfrom,
+	undef
+    );
+    my $channel = 'mail';
+
+    $notification_config->add_channel($channel, ['anonymous-ha-manager-sendmail']);
+
+    PVE::Notify::warning(
+	$channel,
+	$subject,
+	$text,
+	$properties,
+	$notification_config
+    );
 }
 
 my $last_lock_status_hash = {};
diff --git a/src/PVE/HA/NodeStatus.pm b/src/PVE/HA/NodeStatus.pm
index ee5be8e..b264a36 100644
--- a/src/PVE/HA/NodeStatus.pm
+++ b/src/PVE/HA/NodeStatus.pm
@@ -188,35 +188,45 @@ sub update {
    }
 }
 
-# assembles a commont text for fence emails
-my $send_fence_state_email = sub {
-    my ($self, $subject_prefix, $subject, $node) = @_;
-
-    my $haenv = $self->{haenv};
-
-    my $mail_text = <<EOF
-The node '$node' failed and needs manual intervention.
+my $body_template = <<EOT;
+{{#verbatim}}
+The node '{{node}}' failed and needs manual intervention.
 
-The PVE HA manager tries  to fence it and recover the
-configured HA resources to a healthy node if possible.
+The PVE HA manager tries  to fence it and recover the configured HA resources to
+a healthy node if possible.
 
-Current fence status:  $subject_prefix
-$subject
+Current fence status: {{subject-prefix}}
+{{subject}}
+{{/verbatim}}
 
+{{heading-2 "Overall Cluster status:"}}
+{{object status-data}}
+EOT
 
-Overall Cluster status:
------------------------
+my $subject_template = "{{subject-prefix}}: {{subject}}";
 
-EOF
-;
-    my $mail_subject = $subject_prefix . ': ' . $subject;
+# assembles a commont text for fence emails
+my $send_fence_state_email = sub {
+    my ($self, $subject_prefix, $subject, $node) = @_;
 
+    my $haenv = $self->{haenv};
     my $status = $haenv->read_manager_status();
-    my $data = { manager_status => $status, node_status => $self->{status} };
-
-    $mail_text .= to_json($data, { pretty => 1, canonical => 1});
 
-    $haenv->sendmail($mail_subject, $mail_text);
+    my $notification_properties = {
+	"status-data"    => {
+	    manager_status => $status,
+	    node_status    => $self->{status}
+	},
+	"node"           => $node,
+	"subject-prefix" => $subject_prefix,
+	"subject"        => $subject,
+    };
+
+    $haenv->send_notification(
+	$subject_template,
+	$body_template,
+	$notification_properties
+    );
 };
 
 
diff --git a/src/PVE/HA/Sim/Env.pm b/src/PVE/HA/Sim/Env.pm
index c6ea73c..d3aea8d 100644
--- a/src/PVE/HA/Sim/Env.pm
+++ b/src/PVE/HA/Sim/Env.pm
@@ -288,8 +288,14 @@ sub log {
     printf("%-5s %5d %12s: $msg\n", $level, $time, "$self->{nodename}/$self->{log_id}");
 }
 
-sub sendmail {
-    my ($self, $subject, $text) = @_;
+sub send_notification {
+    my ($self, $subject, $text, $properties) = @_;
+
+    # The template for the subject is "{{subject-prefix}}: {{subject}}"
+    # We have to perform poor-man's template rendering to pass the test cases.
+
+    $subject = $subject =~ s/\{\{subject-prefix}}/$properties->{"subject-prefix"}/r;
+    $subject = $subject =~ s/\{\{subject}}/$properties->{"subject"}/r;
 
     # only log subject, do not spam the logs
     $self->log('email', $subject);
-- 
2.30.2





^ permalink raw reply	[flat|nested] 46+ messages in thread

* Re: [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module
  2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
                   ` (41 preceding siblings ...)
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-ha-manager 42/42] manager: send notifications via new notification module Lukas Wagner
@ 2023-05-26  8:31 ` Lukas Wagner
  42 siblings, 0 replies; 46+ messages in thread
From: Lukas Wagner @ 2023-05-26  8:31 UTC (permalink / raw)
  To: pve-devel

On 5/24/23 15:56, Lukas Wagner wrote:
>    - Channels:
>      Logically, channel can be thought of as a 'group of endpoints'. Each
>      endpoint can be included in one or more channels. If one is using the
>      notification API to send a notification, a channel has to be specified.
>      The notification will then be forwarded to all endpoints included in that
>      channel.
>      Logically they decouple endpoints from notification senders - for instance,
>      a backup job configuration would need to contain references to potentially
>      multiple  endpoints, or, a alternatively, always notify via *all* endpoints.
>      The latter would potentially shift more configuration effort to filters, for
>      instance if some backup jobs should only notify via *some* endpoints.
>      I think the group/channel-based approach provides a relatively nice middle
>      ground.
> 
Having worked on UI stuff yesterday, lifting some of my "Betriebsblindheit" after working on this
for a long time, I think I want to s/channel/(notification) group/g - I think
this should make it a bit clearer to the user what this actually means.

Also I want to somehow unify the concepts of groups and endpoints from a users perspective.
Everywhere where a user can choose a group (formerly channel) in the UI (e.g. backup jobs),
the user would also be able to select a single endpoint.
Benefit: If there is only one endpoint (e.g. send email to root), the user does not
have to create a group first.

The changes needed for these two things should be pretty minor, but ultimately
warrant a v3.

I'm out of office for the next two weeks, so I'll probably have to send the v3
along with any other requested changes after that.


-- 
- Lukas




^ permalink raw reply	[flat|nested] 46+ messages in thread

* Re: [pve-devel] [PATCH v2 proxmox-perl-rs 18/42] log: set default log level to 'info', add product specific logging env var
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 18/42] log: set default log level to 'info', add product specific logging env var Lukas Wagner
@ 2023-06-05  7:27   ` Wolfgang Bumiller
  0 siblings, 0 replies; 46+ messages in thread
From: Wolfgang Bumiller @ 2023-06-05  7:27 UTC (permalink / raw)
  To: Lukas Wagner; +Cc: pve-devel

On Wed, May 24, 2023 at 03:56:25PM +0200, Lukas Wagner wrote:
> Logging behaviour can be overridden by the {PMG,PVE}_LOG environment
> variable.
> 
> This commit also disables styled output and  timestamps in log messages,
> since we usually log to the journal anyway. The log output is configured
> to match with other log messages in task logs.
> 
> Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>

applied/cherry-picked this one




^ permalink raw reply	[flat|nested] 46+ messages in thread

* Re: [pve-devel] [PATCH v2 proxmox 01/42] add `proxmox-human-byte` crate
  2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 01/42] add `proxmox-human-byte` crate Lukas Wagner
@ 2023-06-26 11:58   ` Wolfgang Bumiller
  0 siblings, 0 replies; 46+ messages in thread
From: Wolfgang Bumiller @ 2023-06-26 11:58 UTC (permalink / raw)
  To: Lukas Wagner; +Cc: pve-devel

fyi: first 2 patches are applied via the other human-byte patch
series now.




^ permalink raw reply	[flat|nested] 46+ messages in thread

end of thread, other threads:[~2023-06-26 11:58 UTC | newest]

Thread overview: 46+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2023-05-24 13:56 [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce new notification module Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 01/42] add `proxmox-human-byte` crate Lukas Wagner
2023-06-26 11:58   ` Wolfgang Bumiller
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 02/42] human-byte: move tests to their own sub-module Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 03/42] add proxmox-notify crate Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 04/42] notify: add debian packaging Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 05/42] notify: preparation for the first endpoint plugin Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 06/42] notify: preparation for the API Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 07/42] notify: api: add API for sending notifications/testing endpoints Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 08/42] notify: add notification channels Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 09/42] notify: api: add API for channels Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 10/42] notify: add sendmail plugin Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 11/42] notify: api: add API for sendmail endpoints Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 12/42] notify: add gotify endpoint Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 13/42] notify: api: add API for gotify endpoints Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 14/42] notify: add notification filter mechanism Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 15/42] notify: api: add API for filters Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 16/42] notify: add template rendering Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox 17/42] notify: add example for " Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 18/42] log: set default log level to 'info', add product specific logging env var Lukas Wagner
2023-06-05  7:27   ` Wolfgang Bumiller
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 19/42] add PVE::RS::Notify module Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 20/42] notify: add api for sending notifications/testing endpoints Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 21/42] notify: add api for notification channels Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 22/42] notify: add api for sendmail endpoints Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 23/42] notify: add api for gotify endpoints Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 proxmox-perl-rs 24/42] notify: add api for notification filters Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-cluster 25/42] cluster files: add notifications.cfg Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-guest-common 26/42] vzdump: add config options for new notification backend Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 27/42] test: fix names of .PHONY targets Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 28/42] add PVE::Notify module Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 29/42] vzdump: send notifications via new notification module Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 30/42] test: rename mail_test.pl to vzdump_notification_test.pl Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 31/42] api: apt: send notification via new notification module Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 32/42] api: replication: send notifications " Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 33/42] ui: backup: allow to select notification channel for notifications Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 34/42] ui: backup: adapt backup job details to new notification params Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 35/42] ui: backup: allow to set notification-{channel, mode} for one-off backups Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 36/42] api: prepare api handler module for notification config Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 37/42] api: add api routes for notification channels Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 38/42] api: add api routes for sendmail endpoints Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 39/42] api: add api routes for gotify endpoints Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 40/42] api: add api routes for notification filters Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-manager 41/42] ui: backup: disable notification mode selector for now Lukas Wagner
2023-05-24 13:56 ` [pve-devel] [PATCH v2 pve-ha-manager 42/42] manager: send notifications via new notification module Lukas Wagner
2023-05-26  8:31 ` [pve-devel] [PATCH v2 cluster/guest-common/manager/ha-manager/proxmox{, -perl-rs} 00/42] fix #4156: introduce " Lukas Wagner

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal