Skip to content

Commit d350a5a

Browse files
committed
refactor(webhook): simplify config to type/url format
Change WebhookConfig from enum (Url/Discord variants) to struct with type and url fields. The type defaults to "discord" and url supports $ENV_VAR expansion from runner's env_file.
1 parent c77b5ab commit d350a5a

File tree

3 files changed

+138
-27
lines changed

3 files changed

+138
-27
lines changed

CLAUDE.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ struct Job {
4141
log_max_size: u64, // Max log size before rotation (default: 10M)
4242
env_file: Option<String>, // Path to .env file (relative to job dir)
4343
env: Option<HashMap<String, String>>, // Inline env vars
44+
webhook: Vec<WebhookConfig>, // Failure notifications (inherited from runner + job-level)
45+
}
46+
47+
struct WebhookConfig {
48+
webhook_type: String, // Currently only "discord" (default)
49+
url: String, // Webhook URL (supports $ENV_VAR expansion)
4450
}
4551

4652
struct RetryConfig {
@@ -70,6 +76,10 @@ runner: # Optional: global settings
7076
env_file: .env # Optional: load env vars from file
7177
env: # Optional: inline env vars
7278
KEY: value
79+
webhook: # Optional: failure notifications (inherited by all jobs)
80+
- url: $DISCORD_WEBHOOK_URL # URL or $ENV_VAR (loaded from runner.env_file)
81+
- type: discord # Optional: defaults to "discord"
82+
url: https://discord.com/api/webhooks/...
7383

7484
jobs:
7585
<job-id>: # Key = ID (used for directories)
@@ -151,6 +161,23 @@ jobs:
151161

152162
**Size format**: `10M` (megabytes), `1G` (gigabytes), `512K` (kilobytes), or bytes
153163

164+
## Webhooks
165+
166+
Webhooks send notifications on job failure. Configure at runner level (inherited by all jobs) or job level.
167+
168+
```yaml
169+
runner:
170+
env_file: .env # Load DISCORD_WEBHOOK_URL from here
171+
webhook:
172+
- url: $DISCORD_WEBHOOK_URL # Expanded from env_file
173+
```
174+
175+
**Format**: `{ type?: "discord", url: string }` where `type` defaults to "discord".
176+
177+
**Payload**: JSON with `text`, `job_id`, `job_name`, `error`, `stderr`, `attempts`.
178+
179+
**Inheritance**: Job webhooks extend runner webhooks (both are notified).
180+
154181
## Environment Variables
155182

156183
Environment variables can be set at runner (global) or job level, via inline definitions or `.env` files.

src/actor/job/executor.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,9 @@ pub async fn execute_job(job: &Job, sot_path: &PathBuf, runner: &RunnerConfig) -
109109
attempts: max_attempts,
110110
};
111111

112+
let runner_env = load_runner_env_vars(sot_path, runner);
112113
for webhook in &job.webhook {
113-
send_webhook(&webhook.to_url(), &payload).await;
114+
send_webhook(&webhook.to_url(runner_env.as_ref()), &payload).await;
114115
}
115116
}
116117

@@ -178,6 +179,37 @@ async fn run_command(
178179
}
179180
}
180181

182+
/// Load runner-level env vars for webhook URL expansion.
183+
/// Returns None on error (webhook will fall back to process env).
184+
fn load_runner_env_vars(
185+
sot_path: &PathBuf,
186+
runner: &RunnerConfig,
187+
) -> Option<HashMap<String, String>> {
188+
let mut env_vars = HashMap::new();
189+
190+
// Load runner.env_file
191+
if let Some(env_file_path) = &runner.env_file {
192+
let expanded = env::expand_string(env_file_path);
193+
let full_path = sot_path.join(&expanded);
194+
match env::load_env_from_path(&full_path) {
195+
Ok(vars) => env_vars.extend(vars),
196+
Err(e) => {
197+
warn!(target: "rollcron::webhook", error = %e, "Failed to load runner env_file");
198+
return None;
199+
}
200+
}
201+
}
202+
203+
// Merge runner.env
204+
if let Some(runner_env) = &runner.env {
205+
for (k, v) in runner_env {
206+
env_vars.insert(k.clone(), env::expand_string(v));
207+
}
208+
}
209+
210+
Some(env_vars)
211+
}
212+
181213
fn merge_env_vars(
182214
job: &Job,
183215
work_dir: &PathBuf,

src/config.rs

Lines changed: 78 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -31,31 +31,48 @@ pub enum TimezoneConfig {
3131
Named(Tz),
3232
}
3333

34-
/// Webhook configuration - either a URL or Discord id/token pair
34+
/// Webhook configuration for failure notifications
3535
#[derive(Debug, Clone, PartialEq, Deserialize)]
36-
#[serde(untagged)]
37-
pub enum WebhookConfig {
38-
Url { url: String },
39-
Discord { id: String, token: String },
36+
pub struct WebhookConfig {
37+
/// Webhook type (currently only "discord" supported)
38+
#[serde(rename = "type", default = "default_webhook_type")]
39+
pub webhook_type: String,
40+
/// Webhook URL (supports $ENV_VAR expansion)
41+
pub url: String,
42+
}
43+
44+
fn default_webhook_type() -> String {
45+
"discord".to_string()
4046
}
4147

4248
impl WebhookConfig {
43-
/// Convert to URL string, expanding environment variables
44-
pub fn to_url(&self) -> String {
45-
match self {
46-
WebhookConfig::Url { url } => shellexpand::full(url)
47-
.map(|s| s.into_owned())
48-
.unwrap_or_else(|_| url.clone()),
49-
WebhookConfig::Discord { id, token } => {
50-
let id = shellexpand::full(id)
51-
.map(|s| s.into_owned())
52-
.unwrap_or_else(|_| id.clone());
53-
let token = shellexpand::full(token)
54-
.map(|s| s.into_owned())
55-
.unwrap_or_else(|_| token.clone());
56-
format!("https://discord.com/api/webhooks/{}/{}", id, token)
57-
}
49+
/// Convert to URL string, expanding environment variables.
50+
/// If env_vars is provided, uses those for $VAR expansion.
51+
/// Falls back to process environment for undefined variables.
52+
pub fn to_url(&self, env_vars: Option<&std::collections::HashMap<String, String>>) -> String {
53+
expand_with_env(&self.url, env_vars)
54+
}
55+
}
56+
57+
/// Expand shell-like variables in a string.
58+
/// Uses provided env_vars first, then falls back to process environment.
59+
fn expand_with_env(
60+
s: &str,
61+
env_vars: Option<&std::collections::HashMap<String, String>>,
62+
) -> String {
63+
match env_vars {
64+
Some(vars) => {
65+
let home_dir = || dirs::home_dir().map(|p| p.to_string_lossy().into_owned());
66+
shellexpand::full_with_context_no_errors(s, home_dir, |var| {
67+
vars.get(var)
68+
.map(|v| std::borrow::Cow::Borrowed(v.as_str()))
69+
.or_else(|| std::env::var(var).ok().map(std::borrow::Cow::Owned))
70+
})
71+
.into_owned()
5872
}
73+
None => shellexpand::full(s)
74+
.map(|s| s.into_owned())
75+
.unwrap_or_else(|_| s.to_string()),
5976
}
6077
}
6178

@@ -900,7 +917,7 @@ jobs:
900917
let (_, jobs) = parse_config(yaml).unwrap();
901918
// Job inherits runner webhook
902919
assert_eq!(jobs[0].webhook.len(), 1);
903-
assert_eq!(jobs[0].webhook[0].to_url(), "https://hooks.slack.com/test");
920+
assert_eq!(jobs[0].webhook[0].to_url(None), "https://hooks.slack.com/test");
904921
}
905922

906923
#[test]
@@ -944,13 +961,13 @@ jobs:
944961
cron: "* * * * *"
945962
run: echo test
946963
webhook:
947-
- id: "123456"
948-
token: "abcdef"
964+
- url: https://discord.com/api/webhooks/123456/abcdef
949965
"#;
950966
let (_, jobs) = parse_config(yaml).unwrap();
951967
assert_eq!(jobs[0].webhook.len(), 1);
968+
assert_eq!(jobs[0].webhook[0].webhook_type, "discord");
952969
assert_eq!(
953-
jobs[0].webhook[0].to_url(),
970+
jobs[0].webhook[0].to_url(None),
954971
"https://discord.com/api/webhooks/123456/abcdef"
955972
);
956973
}
@@ -966,11 +983,46 @@ jobs:
966983
webhook:
967984
- url: https://hooks.slack.com/first
968985
- url: https://hooks.slack.com/second
969-
- id: discord_id
970-
token: discord_token
986+
- type: discord
987+
url: https://discord.com/api/webhooks/id/token
971988
"#;
972989
let (_, jobs) = parse_config(yaml).unwrap();
973990
assert_eq!(jobs[0].webhook.len(), 3);
974991
}
975992

993+
#[test]
994+
fn parse_webhook_with_explicit_type() {
995+
let yaml = r#"
996+
jobs:
997+
test:
998+
schedule:
999+
cron: "* * * * *"
1000+
run: echo test
1001+
webhook:
1002+
- type: discord
1003+
url: https://discord.com/api/webhooks/test
1004+
"#;
1005+
let (_, jobs) = parse_config(yaml).unwrap();
1006+
assert_eq!(jobs[0].webhook.len(), 1);
1007+
assert_eq!(jobs[0].webhook[0].webhook_type, "discord");
1008+
}
1009+
1010+
#[test]
1011+
fn webhook_env_var_expansion() {
1012+
let mut env_vars = HashMap::new();
1013+
env_vars.insert(
1014+
"DISCORD_WEBHOOK".to_string(),
1015+
"https://discord.com/api/webhooks/from_env".to_string(),
1016+
);
1017+
1018+
let webhook = WebhookConfig {
1019+
webhook_type: "discord".to_string(),
1020+
url: "$DISCORD_WEBHOOK".to_string(),
1021+
};
1022+
1023+
assert_eq!(
1024+
webhook.to_url(Some(&env_vars)),
1025+
"https://discord.com/api/webhooks/from_env"
1026+
);
1027+
}
9761028
}

0 commit comments

Comments
 (0)