Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ targets = []
db-path = "~/.cargo/advisory-db"
db-urls = ["https://github.com/rustsec/advisory-db"]

# smartstring (transitive dep of rhai, used for script jobs) was archived
# upstream on 2026-05-03 with no safe upgrade available (RUSTSEC-2026-0249).
# rhai 1.26.0 still depends on it, so the only fix is replacing rhai.
# Re-evaluate before 2026-12-31.
ignore = [
{ id = "RUSTSEC-2026-0249", reason = "smartstring is a transitive dep of rhai (script jobs); crate archived 2026-05-03 with no safe upgrade — tracked for replacement, re-evaluate 2026-12-31" },
]

[licenses]
allow = [
"MIT",
Expand Down
2 changes: 1 addition & 1 deletion src/bin/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
);
kronforce::executor::notifications::send_notification(
&db_notif, &subject, &body, None,
&db_notif, &subject, &body, None, None,
)
.await;
}
Expand Down
178 changes: 170 additions & 8 deletions src/executor/notifications.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::collections::HashMap;

use crate::db::Db;
use crate::db::models::{EventSeverity, ExecutionStatus, JobNotificationConfig};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -25,17 +27,35 @@ pub struct SmsConfig {
pub from_number: Option<String>,
}

/// Webhook notification configuration (Slack, Teams, PagerDuty, generic).
/// Webhook notification configuration (Slack, Teams, PagerDuty, generic, custom template).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookConfig {
pub enabled: bool,
pub url: String,
/// Webhook format: "slack", "teams", "pagerduty", "discord", or "generic" (default).
/// Webhook format: "slack", "teams", "pagerduty", "discord", "custom", "passthrough", or "generic" (default).
#[serde(default = "default_generic")]
pub format: String,
/// Optional custom headers (e.g., Authorization).
#[serde(default)]
pub headers: std::collections::HashMap<String, String>,
pub headers: HashMap<String, String>,
/// Custom JSON template with `{{placeholder}}` variables. Used when format is "custom".
/// Available placeholders: {{subject}}, {{body}}, {{job_name}}, {{status}},
/// {{execution_id}}, {{stdout}}, {{stderr}}, {{timestamp}}.
#[serde(default)]
pub template: Option<String>,
}

/// Context passed to webhook template rendering. Contains all available placeholder values.
#[derive(Debug, Clone, Default)]
pub struct WebhookContext {
pub subject: String,
pub body: String,
pub job_name: Option<String>,
pub status: Option<String>,
pub execution_id: Option<String>,
pub stdout: Option<String>,
pub stderr: Option<String>,
pub timestamp: String,
}

fn default_generic() -> String {
Expand Down Expand Up @@ -107,12 +127,13 @@ pub fn load_system_alerts(db: &Db) -> SystemAlerts {
.unwrap_or_default()
}

/// Sends a notification via all enabled channels (email, SMS) to the given or global recipients.
/// Sends a notification via all enabled channels (email, SMS, webhook) to the given or global recipients.
pub async fn send_notification(
db: &Db,
subject: &str,
body: &str,
recipient_override: Option<&NotificationRecipients>,
webhook_context: Option<WebhookContext>,
) {
let recipients = match recipient_override {
Some(r) if !r.emails.is_empty() || !r.phones.is_empty() => r.clone(),
Expand Down Expand Up @@ -185,8 +206,14 @@ pub async fn send_notification(
let subj = subject.to_string();
let bod = body.to_string();
let db_clone = db.clone();
let ctx = webhook_context.unwrap_or_else(|| WebhookContext {
subject: subj.clone(),
body: bod.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
..Default::default()
});
tokio::spawn(async move {
match send_webhook(&webhook_config, &subj, &bod).await {
match send_webhook(&webhook_config, &subj, &bod, Some(&ctx)).await {
Ok(_) => {
let _ = db_clone.log_event(
"notification.sent",
Expand Down Expand Up @@ -281,7 +308,22 @@ pub async fn notify_execution_complete(
emails: r.emails.clone(),
phones: r.phones.clone(),
});
send_notification(db, &subject, &body, recipients.as_ref()).await;
let context = WebhookContext {
subject: subject.clone(),
body: body.clone(),
job_name: Some(job_name.to_string()),
status: Some(match exec_status {
ExecutionStatus::Succeeded => "succeeded".to_string(),
ExecutionStatus::Failed => "failed".to_string(),
ExecutionStatus::TimedOut => "timed_out".to_string(),
_ => "completed".to_string(),
}),
execution_id: Some(exec_id_short.to_string()),
stdout: Some(stdout.to_string()),
stderr: Some(stderr.to_string()),
timestamp: chrono::Utc::now().to_rfc3339(),
};
send_notification(db, &subject, &body, recipients.as_ref(), Some(context)).await;
}

/// Sends an email to one or more recipients via SMTP.
Expand Down Expand Up @@ -362,10 +404,41 @@ pub async fn send_sms(config: &SmsConfig, to: &[String], body: &str) -> Result<(
Ok(())
}

/// Sends a notification via a webhook (Slack, Teams, PagerDuty, Discord, or generic JSON POST).
pub async fn send_webhook(config: &WebhookConfig, subject: &str, body: &str) -> Result<(), String> {
/// Renders a template string by replacing `{{placeholder}}` markers with values from the context.
fn render_template(template: &str, ctx: &WebhookContext) -> String {
let mut result = template.to_string();
result = result.replace("{{subject}}", &ctx.subject);
result = result.replace("{{body}}", &ctx.body);
result = result.replace("{{job_name}}", ctx.job_name.as_deref().unwrap_or(""));
result = result.replace("{{status}}", ctx.status.as_deref().unwrap_or(""));
result = result.replace(
"{{execution_id}}",
ctx.execution_id.as_deref().unwrap_or(""),
);
result = result.replace("{{stdout}}", ctx.stdout.as_deref().unwrap_or(""));
result = result.replace("{{stderr}}", ctx.stderr.as_deref().unwrap_or(""));
result = result.replace("{{timestamp}}", &ctx.timestamp);
result
}

/// Sends a notification via a webhook (Slack, Teams, PagerDuty, Discord, custom template, or generic JSON POST).
pub async fn send_webhook(
config: &WebhookConfig,
subject: &str,
body: &str,
context: Option<&WebhookContext>,
) -> Result<(), String> {
let client = reqwest::Client::new();

// Build a default context if none provided
let default_ctx = WebhookContext {
subject: subject.to_string(),
body: body.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
..Default::default()
};
let ctx = context.unwrap_or(&default_ctx);

let payload = match config.format.as_str() {
"slack" => serde_json::json!({
"text": format!("*{}*\n{}", subject, body),
Expand Down Expand Up @@ -404,6 +477,33 @@ pub async fn send_webhook(config: &WebhookConfig, subject: &str, body: &str) ->
}]
})
}
"custom" => {
let template = config
.template
.as_deref()
.unwrap_or(r#"{"text": "{{subject}}\n{{body}}"}"#);
let rendered = render_template(template, ctx);
serde_json::from_str(&rendered).map_err(|e| {
format!("custom template produced invalid JSON: {e}\nRendered: {rendered}")
})?
}
"passthrough" => {
// In passthrough mode, stdout IS the payload. If stdout is valid JSON, send it raw.
// Falls back to generic format if stdout is empty or not valid JSON.
let raw = ctx.stdout.as_deref().unwrap_or("").trim();
if raw.is_empty() {
serde_json::json!({
"subject": subject,
"body": body,
"source": "kronforce",
"timestamp": chrono::Utc::now().to_rfc3339(),
})
} else {
serde_json::from_str(raw).map_err(|e| {
format!("passthrough mode requires stdout to be valid JSON: {e}")
})?
}
}
_ => serde_json::json!({
"subject": subject,
"body": body,
Expand Down Expand Up @@ -482,6 +582,7 @@ pub async fn send_test(db: &Db) -> Result<String, String> {
&webhook_config,
"[Kronforce] Test Notification",
"This is a test notification from Kronforce.",
None,
)
.await
{
Expand All @@ -494,3 +595,64 @@ pub async fn send_test(db: &Db) -> Result<String, String> {

Ok(results.join("; "))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_render_template_all_placeholders() {
let ctx = WebhookContext {
subject: "Job 'deploy' succeeded".to_string(),
body: "All good".to_string(),
job_name: Some("deploy".to_string()),
status: Some("succeeded".to_string()),
execution_id: Some("abc123".to_string()),
stdout: Some("deployed v2.0".to_string()),
stderr: Some("".to_string()),
timestamp: "2026-06-08T12:00:00Z".to_string(),
};
let template = r#"{"title":"{{subject}}","output":"{{stdout}}","job":"{{job_name}}","status":"{{status}}","exec":"{{execution_id}}","ts":"{{timestamp}}"}"#;
let rendered = render_template(template, &ctx);
let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(parsed["title"], "Job 'deploy' succeeded");
assert_eq!(parsed["output"], "deployed v2.0");
assert_eq!(parsed["job"], "deploy");
assert_eq!(parsed["status"], "succeeded");
assert_eq!(parsed["exec"], "abc123");
assert_eq!(parsed["ts"], "2026-06-08T12:00:00Z");
}

#[test]
fn test_render_template_missing_optional_fields() {
let ctx = WebhookContext {
subject: "test".to_string(),
body: "hello".to_string(),
job_name: None,
status: None,
execution_id: None,
stdout: None,
stderr: None,
timestamp: "now".to_string(),
};
let template = r#"{"msg":"{{subject}} {{job_name}}"}"#;
let rendered = render_template(template, &ctx);
let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(parsed["msg"], "test ");
}

#[test]
fn test_render_template_discord_preset() {
let ctx = WebhookContext {
subject: "Build done".to_string(),
body: "Success".to_string(),
timestamp: "2026-01-01T00:00:00Z".to_string(),
..Default::default()
};
let template = r#"{"embeds":[{"title":"{{subject}}","description":"{{body}}","timestamp":"{{timestamp}}"}]}"#;
let rendered = render_template(template, &ctx);
let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(parsed["embeds"][0]["title"], "Build done");
assert_eq!(parsed["embeds"][0]["description"], "Success");
}
}
40 changes: 40 additions & 0 deletions web/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@ async function loadNotificationSettings() {
document.getElementById('notif-webhook-url').value = webhook.url || '';
document.getElementById('notif-webhook-format').value = webhook.format || 'slack';
document.getElementById('notif-webhook-headers').value = webhook.headers && Object.keys(webhook.headers).length ? JSON.stringify(webhook.headers) : '';
document.getElementById('notif-webhook-template').value = webhook.template || '';
toggleWebhookTemplate();

const recipients = settings.notification_recipients ? JSON.parse(settings.notification_recipients) : {};
document.getElementById('notif-emails').value = (recipients.emails || []).join('\n');
Expand Down Expand Up @@ -269,11 +271,13 @@ async function saveNotificationSettings() {
});
let webhookHeaders = {};
try { const h = document.getElementById('notif-webhook-headers').value.trim(); if (h) webhookHeaders = JSON.parse(h); } catch(e) { /* ignore invalid JSON */ }
const webhookTemplate = document.getElementById('notif-webhook-template').value.trim();
settings.notification_webhook = JSON.stringify({
enabled: document.getElementById('notif-webhook-enabled').checked,
url: document.getElementById('notif-webhook-url').value.trim(),
format: document.getElementById('notif-webhook-format').value,
headers: webhookHeaders,
template: webhookTemplate || null,
});
settings.notification_recipients = JSON.stringify({
emails: document.getElementById('notif-emails').value.split('\n').map(s => s.trim()).filter(Boolean),
Expand Down Expand Up @@ -307,3 +311,39 @@ async function testNotification() {
}
}

function toggleWebhookTemplate() {
const format = document.getElementById('notif-webhook-format').value;
const wrap = document.getElementById('notif-webhook-template-wrap');
if (wrap) wrap.style.display = format === 'custom' ? '' : 'none';
}

const WEBHOOK_TEMPLATE_PRESETS = {
discord: JSON.stringify({
embeds: [{
title: "{{subject}}",
description: "{{body}}",
color: 3066993,
footer: { text: "Kronforce" },
timestamp: "{{timestamp}}"
}]
}, null, 2),
slack: JSON.stringify({
text: "*{{subject}}*\n{{body}}"
}, null, 2),
signal: JSON.stringify({
jsonrpc: "2.0",
method: "send",
params: {
recipient: ["+1234567890"],
message: "{{subject}}\n{{body}}"
},
id: 1
}, null, 2),
};

function loadTemplatePreset(name) {
const el = document.getElementById('notif-webhook-template');
if (el && WEBHOOK_TEMPLATE_PRESETS[name]) {
el.value = WEBHOOK_TEMPLATE_PRESETS[name];
}
}
Loading