use std::collections::HashMap;
use std::sync::LazyLock;
use crate::prelude::*;
use crate::utils;
use axum::extract;
use matrix_sdk::ruma::RoomId;
static TEMPLATES: LazyLock<minijinja::Environment<'static>> = LazyLock::new(|| {
let mut templates = minijinja::Environment::new();
templates.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
minijinja_contrib::add_to_environment(&mut templates);
templates.add_test("firing", is_firing);
templates.add_test("resolved", is_resolved);
#[cfg(feature = "mod-rcb")]
templates.add_function("get_alert", get_alert);
templates
.add_template_owned("alertmanager".to_string(), include_str!("../templates/alertmanager"))
.unwrap();
templates
});
#[cfg(feature = "mod-rcb")]
fn get_alert(module: &str, template: Option<&str>) -> Result<String, minijinja::Error> {
crate::rcb::get_alert(module, template)
.map_err(|e| minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, e))
}
#[derive(Debug, serde::Deserialize)]
struct SomethingWithStatus {
status: Status,
}
fn is_firing(alert: minijinja::value::ViaDeserialize<SomethingWithStatus>) -> bool {
alert.status == Status::Firing
}
fn is_resolved(alert: minijinja::value::ViaDeserialize<SomethingWithStatus>) -> bool {
alert.status == Status::Resolved
}
#[derive(Debug, Deserialize)]
#[serde(default)]
#[doc(hidden)]
pub struct Config {
enabled: bool,
max_age: u64,
template_plain: Option<String>,
template_html: Option<String>,
despatch: HashMap<String, Vec<OwnedRoomOrAliasId>>,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
max_age: 48 * 60 * 60, template_plain: Default::default(),
template_html: Default::default(),
despatch: Default::default(),
}
}
}
#[doc(hidden)]
pub fn load(bot: Rdzobot) {
if !bot.config().module.alertmanager.enabled {
return;
}
bot.merge_web_router(
axum::Router::new()
.route("/api/alertmanager/{secret}", axum::routing::post(on_alertmanager_notification)),
);
}
async fn on_alertmanager_notification(
extract::State(bot): extract::State<Rdzobot>,
extract::Path(secret): extract::Path<String>,
extract::Json(notification): extract::Json<Notification>,
) -> axum::http::StatusCode {
let mut ret = axum::http::StatusCode::NOT_FOUND;
for (despatch, rooms) in bot.config().module.alertmanager.despatch.iter() {
if bot.get_secret("alertmanager-despatch", despatch).is_some_and(|s| s == secret) {
ret = axum::http::StatusCode::OK;
for room_or_alias_id in rooms {
let Some(room) = utils::resolve_room_alias_with_default(
bot.client.clone(),
Some(room_or_alias_id.clone()),
None,
)
.await
.unwrap() else {
tracing::warn!(
"failed to resolve {room_or_alias_id}, not sending notification"
);
continue;
};
let mut template_plain = TEMPLATES.get_template("alertmanager").unwrap();
let mut template_html = Some(TEMPLATES.get_template("alertmanager").unwrap());
if let Some(t) = &bot.config().module.alertmanager.template_plain {
template_plain = TEMPLATES.template_from_str(t).unwrap();
template_html = bot
.config()
.module
.alertmanager
.template_html
.as_ref()
.map(|t| TEMPLATES.template_from_str(t).unwrap());
} else {
match &bot.config().module.alertmanager.template_html {
None => {}
Some(t) if t.is_empty() => {
template_html = None;
}
_ => {
tracing::warn!("missing module.alertmanager.template_plain=");
continue;
}
}
}
let body_plain = template_plain
.render(RenderContext {
data: ¬ification,
room: room.room_id(),
despatch: despatch.clone(),
HTML: false,
})
.unwrap();
let content = if let Some(t) = template_html {
let body_html = t
.render(RenderContext {
data: ¬ification,
room: room.room_id(),
despatch: despatch.clone(),
HTML: true,
})
.unwrap();
RoomMessageEventContent::notice_html(body_plain, body_html)
} else {
RoomMessageEventContent::notice_plain(body_plain)
};
match room.send(content).await {
Ok(_) => {}
Err(e) => {
tracing::error!("failed to send notice: {e}");
}
}
}
}
}
ret
}
#[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[allow(dead_code, missing_docs)]
pub enum Status {
#[serde(rename = "resolved")]
Resolved,
#[serde(rename = "firing")]
Firing,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
#[allow(dead_code, missing_docs)]
pub struct Alert {
status: Status,
labels: HashMap<String, String>,
annotations: HashMap<String, String>,
#[serde(alias = "startsAt")]
starts_at: chrono::DateTime<chrono::FixedOffset>,
#[serde(alias = "endsAt")]
ends_at: chrono::DateTime<chrono::FixedOffset>,
#[serde(alias = "generatorURL")]
generator_url: String,
fingerprint: String,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
#[serde(tag = "version")]
#[allow(dead_code, missing_docs)]
pub enum Notification {
#[serde(rename = "4")]
V4 {
#[serde(alias = "groupKey")]
group_key: String,
#[serde(alias = "truncatedAlerts")]
truncated_alerts: i64,
status: Status,
receiver: String,
#[serde(alias = "groupLabels")]
group_labels: HashMap<String, String>,
#[serde(alias = "commonLabels")]
common_labels: HashMap<String, String>,
#[serde(alias = "commonAnnotations")]
common_annotations: HashMap<String, String>,
#[serde(alias = "externalURL")]
external_url: String,
alerts: Vec<Alert>,
},
}
#[derive(serde::Serialize)]
#[allow(non_snake_case)]
struct RenderContext<'a> {
data: &'a Notification,
room: &'a RoomId,
despatch: String,
HTML: bool,
}
#[cfg(test)]
#[rustfmt::skip]
mod tests {
use super::*;
#[test]
#[should_panic]
fn parse_toml_multiline_table() {
toml::from_str::<toml::Table>("
[a]
b = {
c = '1',
d = '2',
}
").unwrap();
}
fn firing() -> Notification {
serde_json::from_str(r#"
{
"receiver": "webhook",
"status": "firing",
"truncatedAlerts": 0,
"alerts": [
{
"status": "firing",
"labels": {
"alertname": "Test 1",
"dc": "eu-west-1",
"instance": "localhost:9090",
"job": "prometheus24"
},
"annotations": {
"description": "some description"
},
"startsAt": "2018-08-03T09:52:26.739266876+02:00",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://example.com:9090/graph?g0.expr=go_memstats_alloc_bytes+%3E+0\u0026g0.tab=1",
"fingerprint": "example1"
},
{
"status": "firing",
"labels": {
"alertname": "Test 2",
"dc": "eu-west-1",
"instance": "localhost:9090",
"job": "prometheus24"
},
"annotations": {
"description": "some description"
},
"startsAt": "2018-08-03T09:52:26.739266876+02:00",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://example.com:9090/graph?g0.expr=go_memstats_alloc_bytes+%3E+0\u0026g0.tab=1",
"fingerprint": "example1"
},
{
"status": "resolved",
"labels": {
"alertname": "Test 3",
"dc": "eu-west-1",
"instance": "localhost:9090",
"job": "prometheus24"
},
"annotations": {
"description": "some description"
},
"startsAt": "2018-08-03T09:52:26.739266876+02:00",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://example.com:9090/graph?g0.expr=go_memstats_alloc_bytes+%3E+0\u0026g0.tab=1",
"fingerprint": "example1"
}
],
"groupLabels": {
"alertname": "Test",
"job": "prometheus24"
},
"commonLabels": {
"alertname": "Test",
"dc": "eu-west-1",
"instance": "localhost:9090",
"job": "prometheus24"
},
"commonAnnotations": {
"description": "some description"
},
"externalURL": "http://example.com:9093",
"version": "4",
"groupKey": "{}:{alertname=\"Test\", job=\"prometheus24\"}"
}
"#).unwrap()
}
fn resolved() -> Notification {
serde_json::from_str(r#"
{
"receiver": "webhook",
"status": "resolved",
"truncatedAlerts": 0,
"alerts": [
{
"status": "resolved",
"labels": {
"alertname": "Test 1",
"dc": "eu-west-1",
"instance": "localhost:9090",
"job": "prometheus24"
},
"annotations": {
"description": "some description"
},
"startsAt": "2018-08-03T09:52:26.739266876+02:00",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://example.com:9090/graph?g0.expr=go_memstats_alloc_bytes+%3E+0\u0026g0.tab=1",
"fingerprint": "example1"
},
{
"status": "resolved",
"labels": {
"alertname": "Test 2",
"dc": "eu-west-1",
"instance": "localhost:9090",
"job": "prometheus24"
},
"annotations": {
"description": "some description"
},
"startsAt": "2018-08-03T09:52:26.739266876+02:00",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://example.com:9090/graph?g0.expr=go_memstats_alloc_bytes+%3E+0\u0026g0.tab=1",
"fingerprint": "example1"
},
{
"status": "resolved",
"labels": {
"alertname": "Test 3",
"dc": "eu-west-1",
"instance": "localhost:9090",
"job": "prometheus24"
},
"annotations": {
"description": "some description"
},
"startsAt": "2018-08-03T09:52:26.739266876+02:00",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://example.com:9090/graph?g0.expr=go_memstats_alloc_bytes+%3E+0\u0026g0.tab=1",
"fingerprint": "example1"
}
],
"groupLabels": {
"alertname": "Test",
"job": "prometheus24"
},
"commonLabels": {
"alertname": "Test",
"dc": "eu-west-1",
"instance": "localhost:9090",
"job": "prometheus24"
},
"commonAnnotations": {
"description": "some description"
},
"externalURL": "http://example.com:9093",
"version": "4",
"groupKey": "{}:{alertname=\"Test\", job=\"prometheus24\"}"
}
"#).unwrap()
}
const TEMPLATE_ALERTRCB_PLAIN: &str = "
{%- extends 'alertmanager' %}
{% block footer %}
{%- if get_alert is defined %}
{{- '\\n\\n' + get_alert('rcb') }}
{%- endif %}
{%- endblock %}
";
const TEMPLATE_ALERTRCB_HTML: &str = "
{%- extends 'alertmanager' %}
{% block footer %}
{%- if get_alert is defined -%}
<em>{{ get_alert('rcb') }}</em>
{%- endif %}
{%- endblock %}
";
const TEMPLATE_FMDUDA_PLAIN: &str = "
{%- extends 'alertmanager' %}
{% block header %}
{%- if data.status == 'firing' -%}
R{{ 'rr-r' * (data.alerts | select('firing') | length) }}rwa mać!
{%- else -%}
Brrrylanty, brrylanty!
{%- endif %}
{%- endblock %}
";
const TEMPLATE_FMDUDA_HTML: &str = "
{%- extends 'alertmanager' %}
{% block header %}
{%- if data.status == 'firing' -%}
<font color='red'>R{{ 'rr-r' * (data.alerts | select('firing') | length) }}rwa mać!</font>
{%- else -%}
<font color='green'>Brrrylanty, brrylanty!</font>
{%- endif %}
{%- endblock %}
";
fn get_test_context(data: &Notification, html: bool) -> RenderContext {
RenderContext {
data: data,
despatch: "example".to_string(),
room: matrix_sdk::ruma::room_id!("!example:domain.example"),
HTML: html,
}
}
#[test]
fn test_render_default_firing_plain() {
pyo3::prepare_freethreaded_python();
println!("{}", TEMPLATES.get_template("alertmanager").unwrap()
.render(get_test_context(&firing(), false)).unwrap());
}
#[test]
fn test_render_default_firing_html() {
pyo3::prepare_freethreaded_python();
println!("{}", TEMPLATES.get_template("alertmanager").unwrap()
.render(get_test_context(&firing(), true)).unwrap());
}
#[test]
fn test_render_default_resolved_plain() {
pyo3::prepare_freethreaded_python();
println!("{}", TEMPLATES.get_template("alertmanager").unwrap()
.render(get_test_context(&resolved(), false)).unwrap());
}
#[test]
fn test_render_default_resolved_html() {
pyo3::prepare_freethreaded_python();
println!("{}", TEMPLATES.get_template("alertmanager").unwrap()
.render(get_test_context(&resolved(), true)).unwrap());
}
#[test]
fn test_render_alertrcb_firing_plain() {
pyo3::prepare_freethreaded_python();
println!("{}", TEMPLATES.template_from_str(TEMPLATE_ALERTRCB_PLAIN).unwrap()
.render(get_test_context(&firing(), false)).unwrap());
}
#[test]
fn test_render_alertrcb_firing_html() {
pyo3::prepare_freethreaded_python();
println!("{}", TEMPLATES.template_from_str(TEMPLATE_ALERTRCB_HTML).unwrap()
.render(get_test_context(&firing(), true)).unwrap());
}
#[test]
fn test_render_alertrcb_resolved_plain() {
pyo3::prepare_freethreaded_python();
println!("{}", TEMPLATES.template_from_str(TEMPLATE_ALERTRCB_PLAIN).unwrap()
.render(get_test_context(&resolved(), false)).unwrap());
}
#[test]
fn test_render_alertrcb_resolved_html() {
pyo3::prepare_freethreaded_python();
println!("{}", TEMPLATES.template_from_str(TEMPLATE_ALERTRCB_HTML).unwrap()
.render(get_test_context(&resolved(), true)).unwrap());
}
#[test]
fn test_render_fmduda_firing_plain() {
pyo3::prepare_freethreaded_python();
println!("{}", TEMPLATES.template_from_str(TEMPLATE_FMDUDA_PLAIN).unwrap()
.render(get_test_context(&firing(), false)).unwrap());
}
#[test]
fn test_render_fmduda_firing_html() {
pyo3::prepare_freethreaded_python();
println!("{}", TEMPLATES.template_from_str(TEMPLATE_FMDUDA_HTML).unwrap()
.render(get_test_context(&firing(), true)).unwrap());
}
#[test]
fn test_render_fmduda_resolved_plain() {
pyo3::prepare_freethreaded_python();
println!("{}", TEMPLATES.template_from_str(TEMPLATE_FMDUDA_PLAIN).unwrap()
.render(get_test_context(&resolved(), false)).unwrap());
}
#[test]
fn test_render_fmduda_resolved_html() {
pyo3::prepare_freethreaded_python();
println!("{}", TEMPLATES.template_from_str(TEMPLATE_FMDUDA_HTML).unwrap()
.render(get_test_context(&resolved(), true)).unwrap());
}
}