mockforge_chaos/
integrations.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Integration configuration
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct IntegrationConfig {
8    pub slack: Option<SlackConfig>,
9    pub teams: Option<TeamsConfig>,
10    pub jira: Option<JiraConfig>,
11    pub pagerduty: Option<PagerDutyConfig>,
12    pub grafana: Option<GrafanaConfig>,
13}
14
15/// Slack integration configuration
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SlackConfig {
18    pub webhook_url: String,
19    pub channel: String,
20    pub username: Option<String>,
21    pub icon_emoji: Option<String>,
22    pub mention_users: Vec<String>,
23}
24
25/// Microsoft Teams integration configuration
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct TeamsConfig {
28    pub webhook_url: String,
29    pub mention_users: Vec<String>,
30}
31
32/// Jira integration configuration
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct JiraConfig {
35    pub url: String,
36    pub username: String,
37    pub api_token: String,
38    pub project_key: String,
39    pub issue_type: String,
40    pub priority: Option<String>,
41    pub assignee: Option<String>,
42}
43
44/// PagerDuty integration configuration
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct PagerDutyConfig {
47    pub routing_key: String,
48    pub severity: Option<String>,
49    pub dedup_key_prefix: Option<String>,
50}
51
52/// Grafana integration configuration
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct GrafanaConfig {
55    pub url: String,
56    pub api_key: String,
57    pub dashboard_uid: Option<String>,
58    pub folder_uid: Option<String>,
59}
60
61/// Notification severity
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub enum NotificationSeverity {
64    Info,
65    Warning,
66    Error,
67    Critical,
68}
69
70/// Notification message
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct Notification {
73    pub title: String,
74    pub message: String,
75    pub severity: NotificationSeverity,
76    pub timestamp: chrono::DateTime<chrono::Utc>,
77    pub metadata: HashMap<String, serde_json::Value>,
78}
79
80/// Slack notifier
81pub struct SlackNotifier {
82    config: SlackConfig,
83    client: reqwest::Client,
84}
85
86impl SlackNotifier {
87    pub fn new(config: SlackConfig) -> Self {
88        Self {
89            config,
90            client: reqwest::Client::new(),
91        }
92    }
93
94    pub async fn send(&self, notification: &Notification) -> Result<()> {
95        let color = match notification.severity {
96            NotificationSeverity::Info => "#36a64f",
97            NotificationSeverity::Warning => "#ff9900",
98            NotificationSeverity::Error => "#ff0000",
99            NotificationSeverity::Critical => "#8b0000",
100        };
101
102        let mentions = if !self.config.mention_users.is_empty() {
103            format!(
104                "\n{}",
105                self.config
106                    .mention_users
107                    .iter()
108                    .map(|u| format!("<@{}>", u))
109                    .collect::<Vec<_>>()
110                    .join(" ")
111            )
112        } else {
113            String::new()
114        };
115
116        let payload = serde_json::json!({
117            "channel": self.config.channel,
118            "username": self.config.username.as_deref().unwrap_or("MockForge"),
119            "icon_emoji": self.config.icon_emoji.as_deref().unwrap_or(":robot_face:"),
120            "attachments": [{
121                "color": color,
122                "title": notification.title,
123                "text": format!("{}{}", notification.message, mentions),
124                "timestamp": notification.timestamp.timestamp(),
125                "fields": notification.metadata.iter().map(|(k, v)| {
126                    serde_json::json!({
127                        "title": k,
128                        "value": v.to_string(),
129                        "short": true
130                    })
131                }).collect::<Vec<_>>()
132            }]
133        });
134
135        self.client
136            .post(&self.config.webhook_url)
137            .json(&payload)
138            .send()
139            .await
140            .context("Failed to send Slack notification")?;
141
142        Ok(())
143    }
144}
145
146/// Microsoft Teams notifier
147pub struct TeamsNotifier {
148    config: TeamsConfig,
149    client: reqwest::Client,
150}
151
152impl TeamsNotifier {
153    pub fn new(config: TeamsConfig) -> Self {
154        Self {
155            config,
156            client: reqwest::Client::new(),
157        }
158    }
159
160    pub async fn send(&self, notification: &Notification) -> Result<()> {
161        let theme_color = match notification.severity {
162            NotificationSeverity::Info => "0078D4",
163            NotificationSeverity::Warning => "FFA500",
164            NotificationSeverity::Error => "FF0000",
165            NotificationSeverity::Critical => "8B0000",
166        };
167
168        let mentions = if !self.config.mention_users.is_empty() {
169            format!(
170                "\n\n{}",
171                self.config
172                    .mention_users
173                    .iter()
174                    .map(|u| format!("<at>{}</at>", u))
175                    .collect::<Vec<_>>()
176                    .join(" ")
177            )
178        } else {
179            String::new()
180        };
181
182        let facts: Vec<_> = notification
183            .metadata
184            .iter()
185            .map(|(k, v)| {
186                serde_json::json!({
187                    "name": k,
188                    "value": v.to_string()
189                })
190            })
191            .collect();
192
193        let payload = serde_json::json!({
194            "@type": "MessageCard",
195            "@context": "https://schema.org/extensions",
196            "themeColor": theme_color,
197            "summary": notification.title,
198            "sections": [{
199                "activityTitle": notification.title,
200                "activitySubtitle": format!("Severity: {:?}", notification.severity),
201                "text": format!("{}{}", notification.message, mentions),
202                "facts": facts
203            }]
204        });
205
206        self.client
207            .post(&self.config.webhook_url)
208            .json(&payload)
209            .send()
210            .await
211            .context("Failed to send Teams notification")?;
212
213        Ok(())
214    }
215}
216
217/// Jira ticket creator
218pub struct JiraIntegration {
219    config: JiraConfig,
220    client: reqwest::Client,
221}
222
223impl JiraIntegration {
224    pub fn new(config: JiraConfig) -> Self {
225        Self {
226            config,
227            client: reqwest::Client::new(),
228        }
229    }
230
231    pub async fn create_ticket(&self, notification: &Notification) -> Result<String> {
232        let description = format!(
233            "{}\n\n*Metadata:*\n{}",
234            notification.message,
235            notification
236                .metadata
237                .iter()
238                .map(|(k, v)| format!("* {}: {}", k, v))
239                .collect::<Vec<_>>()
240                .join("\n")
241        );
242
243        let priority = self.config.priority.as_deref().or({
244            Some(match notification.severity {
245                NotificationSeverity::Critical => "Highest",
246                NotificationSeverity::Error => "High",
247                NotificationSeverity::Warning => "Medium",
248                NotificationSeverity::Info => "Low",
249            })
250        });
251
252        let mut fields = serde_json::json!({
253            "project": {
254                "key": self.config.project_key
255            },
256            "summary": notification.title,
257            "description": description,
258            "issuetype": {
259                "name": self.config.issue_type
260            }
261        });
262
263        if let Some(priority) = priority {
264            fields["priority"] = serde_json::json!({ "name": priority });
265        }
266
267        if let Some(assignee) = &self.config.assignee {
268            fields["assignee"] = serde_json::json!({ "name": assignee });
269        }
270
271        let payload = serde_json::json!({ "fields": fields });
272
273        let url = format!("{}/rest/api/2/issue", self.config.url);
274
275        let response = self
276            .client
277            .post(&url)
278            .basic_auth(&self.config.username, Some(&self.config.api_token))
279            .json(&payload)
280            .send()
281            .await
282            .context("Failed to create Jira ticket")?;
283
284        let result: serde_json::Value = response.json().await?;
285        let ticket_key = result["key"]
286            .as_str()
287            .context("Failed to get ticket key from response")?
288            .to_string();
289
290        Ok(ticket_key)
291    }
292
293    pub async fn update_ticket(&self, ticket_key: &str, comment: &str) -> Result<()> {
294        let payload = serde_json::json!({
295            "body": comment
296        });
297
298        let url = format!("{}/rest/api/2/issue/{}/comment", self.config.url, ticket_key);
299
300        self.client
301            .post(&url)
302            .basic_auth(&self.config.username, Some(&self.config.api_token))
303            .json(&payload)
304            .send()
305            .await
306            .context("Failed to add comment to Jira ticket")?;
307
308        Ok(())
309    }
310}
311
312/// PagerDuty integration
313pub struct PagerDutyIntegration {
314    config: PagerDutyConfig,
315    client: reqwest::Client,
316}
317
318impl PagerDutyIntegration {
319    pub fn new(config: PagerDutyConfig) -> Self {
320        Self {
321            config,
322            client: reqwest::Client::new(),
323        }
324    }
325
326    pub async fn trigger_incident(&self, notification: &Notification) -> Result<String> {
327        let severity = self.config.severity.as_deref().or({
328            Some(match notification.severity {
329                NotificationSeverity::Critical => "critical",
330                NotificationSeverity::Error => "error",
331                NotificationSeverity::Warning => "warning",
332                NotificationSeverity::Info => "info",
333            })
334        });
335
336        let dedup_key = format!(
337            "{}-{}",
338            self.config.dedup_key_prefix.as_deref().unwrap_or("mockforge"),
339            notification.timestamp.timestamp()
340        );
341
342        let payload = serde_json::json!({
343            "routing_key": self.config.routing_key,
344            "event_action": "trigger",
345            "dedup_key": dedup_key,
346            "payload": {
347                "summary": notification.title,
348                "severity": severity,
349                "source": "MockForge",
350                "timestamp": notification.timestamp.to_rfc3339(),
351                "custom_details": notification.metadata
352            }
353        });
354
355        let response = self
356            .client
357            .post("https://events.pagerduty.com/v2/enqueue")
358            .json(&payload)
359            .send()
360            .await
361            .context("Failed to trigger PagerDuty incident")?;
362
363        let _result: serde_json::Value = response.json().await?;
364        Ok(dedup_key)
365    }
366
367    pub async fn resolve_incident(&self, dedup_key: &str) -> Result<()> {
368        let payload = serde_json::json!({
369            "routing_key": self.config.routing_key,
370            "event_action": "resolve",
371            "dedup_key": dedup_key
372        });
373
374        self.client
375            .post("https://events.pagerduty.com/v2/enqueue")
376            .json(&payload)
377            .send()
378            .await
379            .context("Failed to resolve PagerDuty incident")?;
380
381        Ok(())
382    }
383}
384
385/// Grafana integration
386pub struct GrafanaIntegration {
387    config: GrafanaConfig,
388    client: reqwest::Client,
389}
390
391impl GrafanaIntegration {
392    pub fn new(config: GrafanaConfig) -> Self {
393        Self {
394            config,
395            client: reqwest::Client::new(),
396        }
397    }
398
399    pub async fn create_annotation(&self, notification: &Notification) -> Result<()> {
400        let tags = vec![
401            format!("severity:{:?}", notification.severity).to_lowercase(),
402            "mockforge".to_string(),
403        ];
404
405        let payload = serde_json::json!({
406            "text": notification.message,
407            "tags": tags,
408            "time": notification.timestamp.timestamp_millis(),
409            "dashboardUID": self.config.dashboard_uid
410        });
411
412        let url = format!("{}/api/annotations", self.config.url);
413
414        self.client
415            .post(&url)
416            .header("Authorization", format!("Bearer {}", self.config.api_key))
417            .json(&payload)
418            .send()
419            .await
420            .context("Failed to create Grafana annotation")?;
421
422        Ok(())
423    }
424
425    pub async fn create_dashboard(&self, dashboard_json: serde_json::Value) -> Result<String> {
426        let payload = serde_json::json!({
427            "dashboard": dashboard_json,
428            "folderUid": self.config.folder_uid,
429            "overwrite": false
430        });
431
432        let url = format!("{}/api/dashboards/db", self.config.url);
433
434        let response = self
435            .client
436            .post(&url)
437            .header("Authorization", format!("Bearer {}", self.config.api_key))
438            .json(&payload)
439            .send()
440            .await
441            .context("Failed to create Grafana dashboard")?;
442
443        let result: serde_json::Value = response.json().await?;
444        let uid = result["uid"].as_str().context("Failed to get dashboard UID")?.to_string();
445
446        Ok(uid)
447    }
448}
449
450/// Integration manager
451pub struct IntegrationManager {
452    slack: Option<SlackNotifier>,
453    teams: Option<TeamsNotifier>,
454    jira: Option<JiraIntegration>,
455    pagerduty: Option<PagerDutyIntegration>,
456    grafana: Option<GrafanaIntegration>,
457}
458
459impl IntegrationManager {
460    pub fn new(config: IntegrationConfig) -> Self {
461        Self {
462            slack: config.slack.map(SlackNotifier::new),
463            teams: config.teams.map(TeamsNotifier::new),
464            jira: config.jira.map(JiraIntegration::new),
465            pagerduty: config.pagerduty.map(PagerDutyIntegration::new),
466            grafana: config.grafana.map(GrafanaIntegration::new),
467        }
468    }
469
470    /// Send notification to all configured channels
471    pub async fn notify(&self, notification: &Notification) -> Result<NotificationResults> {
472        let mut results = NotificationResults::default();
473
474        // Send to Slack
475        if let Some(slack) = &self.slack {
476            match slack.send(notification).await {
477                Ok(_) => results.slack_sent = true,
478                Err(e) => results.errors.push(format!("Slack: {}", e)),
479            }
480        }
481
482        // Send to Teams
483        if let Some(teams) = &self.teams {
484            match teams.send(notification).await {
485                Ok(_) => results.teams_sent = true,
486                Err(e) => results.errors.push(format!("Teams: {}", e)),
487            }
488        }
489
490        // Create Jira ticket for errors and critical
491        if let Some(jira) = &self.jira {
492            if matches!(
493                notification.severity,
494                NotificationSeverity::Error | NotificationSeverity::Critical
495            ) {
496                match jira.create_ticket(notification).await {
497                    Ok(key) => {
498                        results.jira_ticket = Some(key);
499                    }
500                    Err(e) => results.errors.push(format!("Jira: {}", e)),
501                }
502            }
503        }
504
505        // Trigger PagerDuty incident for critical
506        if let Some(pd) = &self.pagerduty {
507            if notification.severity == NotificationSeverity::Critical {
508                match pd.trigger_incident(notification).await {
509                    Ok(key) => {
510                        results.pagerduty_incident = Some(key);
511                    }
512                    Err(e) => results.errors.push(format!("PagerDuty: {}", e)),
513                }
514            }
515        }
516
517        // Create Grafana annotation
518        if let Some(grafana) = &self.grafana {
519            match grafana.create_annotation(notification).await {
520                Ok(_) => results.grafana_annotated = true,
521                Err(e) => results.errors.push(format!("Grafana: {}", e)),
522            }
523        }
524
525        Ok(results)
526    }
527}
528
529#[derive(Debug, Default, Serialize, Deserialize)]
530pub struct NotificationResults {
531    pub slack_sent: bool,
532    pub teams_sent: bool,
533    pub jira_ticket: Option<String>,
534    pub pagerduty_incident: Option<String>,
535    pub grafana_annotated: bool,
536    pub errors: Vec<String>,
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn test_notification_creation() {
545        let notification = Notification {
546            title: "Test Alert".to_string(),
547            message: "This is a test".to_string(),
548            severity: NotificationSeverity::Warning,
549            timestamp: chrono::Utc::now(),
550            metadata: HashMap::new(),
551        };
552
553        assert_eq!(notification.severity, NotificationSeverity::Warning);
554    }
555}