Skip to content

Commit 6f919a3

Browse files
committed
feat(webhook): add Discord notifications for config parse errors
- Extract webhook logic into dedicated src/webhook.rs module - Fix Discord payload format (use embeds instead of text field) - Add runner-level webhook config for config error notifications - Send orange-colored notification when rollcron.yaml parsing fails
1 parent 3789c9e commit 6f919a3

File tree

7 files changed

+212
-46
lines changed

7 files changed

+212
-46
lines changed

CLAUDE.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ src/
1717
│ └── job/ # Job Actor - single job control
1818
│ ├── mod.rs # Actor definition, state machine
1919
│ ├── tick.rs # cron evaluation, jitter
20-
│ └── executor.rs # command execution, retry, timeout, logging
20+
│ └── executor.rs # command execution, retry, timeout
2121
├── config.rs # YAML config parsing, Job struct
2222
├── git.rs # Git operations (clone, pull, archive)
2323
├── env.rs # Environment variable handling
24-
└── logging.rs # Logging setup
24+
├── logging.rs # Logging setup
25+
└── webhook.rs # Discord webhook notifications
2526
```
2627

2728
## Key Types
@@ -163,7 +164,9 @@ jobs:
163164

164165
## Webhooks
165166

166-
Webhooks send notifications on job failure. Configure at runner level (inherited by all jobs) or job level.
167+
Webhooks send Discord notifications for:
168+
- **Job failures** (after all retries exhausted)
169+
- **Config parse errors** (runner-level webhooks only)
167170

168171
```yaml
169172
runner:
@@ -174,9 +177,11 @@ runner:
174177

175178
**Format**: `{ type?: "discord", url: string }` where `type` defaults to "discord".
176179

177-
**Payload**: JSON with `text`, `job_id`, `job_name`, `error`, `stderr`, `attempts`.
180+
**Payloads**:
181+
- Job failure: Discord embed (red) with Job, Attempts, Error, Stderr fields
182+
- Config error: Discord embed (orange) with Error field
178183

179-
**Inheritance**: Job webhooks extend runner webhooks (both are notified).
184+
**Inheritance**: Job webhooks extend runner webhooks (both are notified on job failure).
180185

181186
## Environment Variables
182187

src/actor/job/executor.rs

Lines changed: 8 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
use rand::Rng;
2-
use serde::Serialize;
32
use std::collections::HashMap;
43
use std::fs::{self, File, OpenOptions};
54
use std::io::Write;
@@ -12,21 +11,11 @@ use tracing::{debug, error, info, warn};
1211
use crate::config::{Job, RetryConfig, RunnerConfig};
1312
use crate::env;
1413
use crate::git;
14+
use crate::webhook::{self, JobFailure};
1515

1616
/// Default jitter ratio when not explicitly configured (25% of base delay)
1717
const AUTO_JITTER_RATIO: u32 = 25;
1818

19-
/// Webhook notification payload
20-
#[derive(Debug, Serialize)]
21-
pub struct WebhookPayload {
22-
pub text: String,
23-
pub job_id: String,
24-
pub job_name: String,
25-
pub error: String,
26-
pub stderr: String,
27-
pub attempts: u32,
28-
}
29-
3019
pub async fn execute_job(job: &Job, sot_path: &PathBuf, runner: &RunnerConfig) -> bool {
3120
let job_dir = git::get_job_dir(sot_path, &job.id);
3221
let work_dir = resolve_work_dir(sot_path, &job.id, &job.working_dir);
@@ -100,18 +89,17 @@ pub async fn execute_job(job: &Job, sot_path: &PathBuf, runner: &RunnerConfig) -
10089
None => ("unknown error".to_string(), String::new()),
10190
};
10291

103-
let payload = WebhookPayload {
104-
text: format!("[rollcron] Job '{}' failed", job.name),
105-
job_id: job.id.clone(),
106-
job_name: job.name.clone(),
92+
let failure = JobFailure {
93+
job_id: &job.id,
94+
job_name: &job.name,
10795
error,
10896
stderr,
10997
attempts: max_attempts,
11098
};
11199

112100
let runner_env = load_runner_env_vars(sot_path, runner);
113-
for webhook in &job.webhook {
114-
let url = webhook.to_url(runner_env.as_ref());
101+
for wh in &job.webhook {
102+
let url = wh.to_url(runner_env.as_ref());
115103
if url.contains('$') {
116104
warn!(
117105
target: "rollcron::webhook",
@@ -130,7 +118,7 @@ pub async fn execute_job(job: &Job, sot_path: &PathBuf, runner: &RunnerConfig) -
130118
);
131119
continue;
132120
}
133-
send_webhook(&url, &payload).await;
121+
webhook::send_job_failure(&url, &failure).await;
134122
}
135123
}
136124

@@ -376,23 +364,6 @@ fn create_log_file(job_dir: &PathBuf, log_path: &str, max_size: u64) -> Option<F
376364
}
377365
}
378366

379-
// === Webhook ===
380-
381-
async fn send_webhook(url: &str, payload: &WebhookPayload) {
382-
let client = reqwest::Client::new();
383-
match client.post(url).json(payload).send().await {
384-
Ok(resp) if resp.status().is_success() => {
385-
info!(target: "rollcron::webhook", url = %url, "Notification sent");
386-
}
387-
Ok(resp) => {
388-
error!(target: "rollcron::webhook", url = %url, status = %resp.status(), "Failed to send notification");
389-
}
390-
Err(e) => {
391-
error!(target: "rollcron::webhook", url = %url, error = %e, "Failed to send notification");
392-
}
393-
}
394-
}
395-
396367
#[cfg(test)]
397368
mod tests {
398369
use super::*;
@@ -427,6 +398,7 @@ mod tests {
427398
timezone: TimezoneConfig::Utc,
428399
env_file: None,
429400
env: None,
401+
webhook: vec![],
430402
}
431403
}
432404

src/actor/runner/git_poll.rs

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
1-
use super::ConfigUpdate;
2-
use crate::config;
3-
use crate::git;
1+
use super::{ConfigUpdate, GetRunnerConfig};
2+
use crate::config::{self, RunnerConfig};
3+
use crate::{env, git, webhook};
4+
use std::collections::HashMap;
45
use std::path::PathBuf;
56
use std::time::Duration;
67
use tokio::time::interval;
7-
use tracing::{error, info};
8+
use tracing::{error, info, warn};
89
use xtra::prelude::*;
910
use xtra::refcount::Weak;
1011

1112
const CONFIG_FILE: &str = "rollcron.yaml";
1213

1314
pub async fn run<A>(source: String, pull_interval: Duration, addr: Address<A, Weak>)
1415
where
15-
A: Handler<ConfigUpdate>,
16+
A: Handler<ConfigUpdate> + Handler<GetRunnerConfig, Return = RunnerConfig>,
1617
{
1718
let mut ticker = interval(pull_interval);
1819

@@ -48,11 +49,64 @@ where
4849
}
4950
Err(e) => {
5051
error!(target: "rollcron::runner", error = %e, "Failed to reload config");
52+
notify_config_error(&addr, &sot_path, &e.to_string()).await;
5153
}
5254
}
5355
}
5456
}
5557

58+
async fn notify_config_error<A>(addr: &Address<A, Weak>, sot_path: &PathBuf, error: &str)
59+
where
60+
A: Handler<GetRunnerConfig, Return = RunnerConfig>,
61+
{
62+
let runner = match addr.send(GetRunnerConfig).await {
63+
Ok(r) => r,
64+
Err(_) => return, // Runner stopped
65+
};
66+
67+
if runner.webhook.is_empty() {
68+
return;
69+
}
70+
71+
let runner_env = load_runner_env(sot_path, &runner);
72+
for wh in &runner.webhook {
73+
let url = wh.to_url(runner_env.as_ref());
74+
if url.contains('$') {
75+
warn!(target: "rollcron::webhook", url = %url, "Webhook URL contains unexpanded variable, skipping");
76+
continue;
77+
}
78+
if !url.starts_with("http://") && !url.starts_with("https://") {
79+
warn!(target: "rollcron::webhook", url = %url, "Webhook URL must start with http:// or https://, skipping");
80+
continue;
81+
}
82+
webhook::send_config_error(&url, error).await;
83+
}
84+
}
85+
86+
fn load_runner_env(sot_path: &PathBuf, runner: &RunnerConfig) -> Option<HashMap<String, String>> {
87+
let mut env_vars = HashMap::new();
88+
89+
if let Some(env_file_path) = &runner.env_file {
90+
let expanded = env::expand_string(env_file_path);
91+
let full_path = sot_path.join(&expanded);
92+
match env::load_env_from_path(&full_path) {
93+
Ok(vars) => env_vars.extend(vars),
94+
Err(e) => {
95+
warn!(target: "rollcron::webhook", error = %e, "Failed to load runner env_file");
96+
return None;
97+
}
98+
}
99+
}
100+
101+
if let Some(runner_env) = &runner.env {
102+
for (k, v) in runner_env {
103+
env_vars.insert(k.clone(), env::expand_string(v));
104+
}
105+
}
106+
107+
Some(env_vars)
108+
}
109+
56110
fn load_config(sot_path: &PathBuf) -> anyhow::Result<(config::RunnerConfig, Vec<config::Job>)> {
57111
let config_path = sot_path.join(CONFIG_FILE);
58112
let content = std::fs::read_to_string(&config_path)

src/actor/runner/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,17 @@ impl Handler<GetJobActors> for RunnerActor {
199199
}
200200
}
201201

202+
/// Get runner config for webhook notifications
203+
pub struct GetRunnerConfig;
204+
205+
impl Handler<GetRunnerConfig> for RunnerActor {
206+
type Return = RunnerConfig;
207+
208+
async fn handle(&mut self, _msg: GetRunnerConfig, _ctx: &mut Context<Self>) -> Self::Return {
209+
self.runner_config.clone()
210+
}
211+
}
212+
202213
/// Respawn a job actor that died unexpectedly
203214
pub struct RespawnJob {
204215
pub job_id: String,

src/config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ pub struct RunnerConfig {
8181
pub timezone: TimezoneConfig,
8282
pub env_file: Option<String>,
8383
pub env: Option<HashMap<String, String>>,
84+
pub webhook: Vec<WebhookConfig>,
8485
}
8586

8687
#[derive(Debug, Deserialize, Default)]
@@ -205,6 +206,7 @@ pub fn parse_config(content: &str) -> Result<(RunnerConfig, Vec<Job>)> {
205206
timezone: timezone.clone(),
206207
env_file: config.runner.env_file,
207208
env: config.runner.env,
209+
webhook: runner_webhook.clone(),
208210
};
209211

210212
let jobs = config

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod config;
33
mod env;
44
mod git;
55
mod logging;
6+
mod webhook;
67

78
use actor::runner::{GracefulShutdown, Initialize, RunnerActor};
89
use anyhow::{Context, Result};

src/webhook.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//! Discord webhook notifications.
2+
3+
use serde::Serialize;
4+
use tracing::{error, info};
5+
6+
/// Information about a failed job.
7+
pub struct JobFailure<'a> {
8+
pub job_id: &'a str,
9+
pub job_name: &'a str,
10+
pub error: String,
11+
pub stderr: String,
12+
pub attempts: u32,
13+
}
14+
15+
/// Send a Discord notification for a job failure.
16+
pub async fn send_job_failure(url: &str, failure: &JobFailure<'_>) {
17+
let payload = build_job_failure_payload(failure);
18+
send_discord(url, &payload).await;
19+
}
20+
21+
/// Send a Discord notification for a config parse error.
22+
pub async fn send_config_error(url: &str, error: &str) {
23+
let payload = build_config_error_payload(error);
24+
send_discord(url, &payload).await;
25+
}
26+
27+
async fn send_discord(url: &str, payload: &DiscordPayload) {
28+
let client = reqwest::Client::new();
29+
match client.post(url).json(payload).send().await {
30+
Ok(resp) if resp.status().is_success() => {
31+
info!(target: "rollcron::webhook", url = %url, "Notification sent");
32+
}
33+
Ok(resp) => {
34+
error!(target: "rollcron::webhook", url = %url, status = %resp.status(), "Failed to send notification");
35+
}
36+
Err(e) => {
37+
error!(target: "rollcron::webhook", url = %url, error = %e, "Failed to send notification");
38+
}
39+
}
40+
}
41+
42+
// === Internal ===
43+
44+
#[derive(Serialize)]
45+
struct DiscordPayload {
46+
embeds: Vec<DiscordEmbed>,
47+
}
48+
49+
#[derive(Serialize)]
50+
struct DiscordEmbed {
51+
title: String,
52+
color: u32,
53+
fields: Vec<DiscordField>,
54+
}
55+
56+
#[derive(Serialize)]
57+
struct DiscordField {
58+
name: &'static str,
59+
value: String,
60+
inline: bool,
61+
}
62+
63+
fn build_job_failure_payload(failure: &JobFailure<'_>) -> DiscordPayload {
64+
let mut fields = vec![
65+
DiscordField {
66+
name: "Job",
67+
value: format!("`{}`", failure.job_id),
68+
inline: true,
69+
},
70+
DiscordField {
71+
name: "Attempts",
72+
value: failure.attempts.to_string(),
73+
inline: true,
74+
},
75+
DiscordField {
76+
name: "Error",
77+
value: failure.error.clone(),
78+
inline: false,
79+
},
80+
];
81+
82+
if !failure.stderr.is_empty() {
83+
let truncated = truncate(&failure.stderr, 1000);
84+
fields.push(DiscordField {
85+
name: "Stderr",
86+
value: format!("```\n{}\n```", truncated),
87+
inline: false,
88+
});
89+
}
90+
91+
DiscordPayload {
92+
embeds: vec![DiscordEmbed {
93+
title: format!("[rollcron] Job '{}' failed", failure.job_name),
94+
color: 0xED4245, // Discord red
95+
fields,
96+
}],
97+
}
98+
}
99+
100+
fn build_config_error_payload(err: &str) -> DiscordPayload {
101+
let truncated = truncate(err, 1000);
102+
DiscordPayload {
103+
embeds: vec![DiscordEmbed {
104+
title: "[rollcron] Config parse error".to_string(),
105+
color: 0xFFA500, // Orange
106+
fields: vec![DiscordField {
107+
name: "Error",
108+
value: format!("```\n{}\n```", truncated),
109+
inline: false,
110+
}],
111+
}],
112+
}
113+
}
114+
115+
fn truncate(s: &str, max_len: usize) -> &str {
116+
if s.len() <= max_len {
117+
s
118+
} else {
119+
&s[..max_len]
120+
}
121+
}

0 commit comments

Comments
 (0)