1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct Trigger {
30 #[serde(default = "default_api_version")]
32 pub api_version: String,
33
34 #[serde(default = "default_trigger_kind")]
36 pub kind: String,
37
38 pub metadata: TriggerMetadata,
40
41 pub spec: TriggerSpec,
43}
44
45fn default_api_version() -> String {
46 "aof.dev/v1".to_string()
47}
48
49fn default_trigger_kind() -> String {
50 "Trigger".to_string()
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct TriggerMetadata {
56 pub name: String,
58
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub namespace: Option<String>,
62
63 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
65 pub labels: HashMap<String, String>,
66
67 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
69 pub annotations: HashMap<String, String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct TriggerSpec {
76 #[serde(rename = "type")]
78 pub trigger_type: StandaloneTriggerType,
79
80 #[serde(default)]
82 pub config: StandaloneTriggerConfig,
83
84 #[serde(default = "default_enabled")]
86 pub enabled: bool,
87
88 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
91 pub commands: HashMap<String, CommandBinding>,
92
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub default_agent: Option<String>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub struct CommandBinding {
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub agent: Option<String>,
105
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub fleet: Option<String>,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub flow: Option<String>,
113
114 #[serde(default, skip_serializing_if = "String::is_empty")]
116 pub description: String,
117}
118
119fn default_enabled() -> bool {
120 true
121}
122
123#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
125pub enum StandaloneTriggerType {
126 Slack,
128 Telegram,
130 Discord,
132 WhatsApp,
134 HTTP,
136 Schedule,
138 PagerDuty,
140 GitHub,
142 Jira,
144 Manual,
146}
147
148impl std::fmt::Display for StandaloneTriggerType {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 match self {
151 Self::Slack => write!(f, "slack"),
152 Self::Telegram => write!(f, "telegram"),
153 Self::Discord => write!(f, "discord"),
154 Self::WhatsApp => write!(f, "whatsapp"),
155 Self::HTTP => write!(f, "http"),
156 Self::Schedule => write!(f, "schedule"),
157 Self::PagerDuty => write!(f, "pagerduty"),
158 Self::GitHub => write!(f, "github"),
159 Self::Jira => write!(f, "jira"),
160 Self::Manual => write!(f, "manual"),
161 }
162 }
163}
164
165#[derive(Debug, Clone, Default, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub struct StandaloneTriggerConfig {
169 #[serde(skip_serializing_if = "Option::is_none")]
175 pub bot_token: Option<String>,
176
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub signing_secret: Option<String>,
180
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub app_secret: Option<String>,
184
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub events: Vec<String>,
188
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub channels: Vec<String>,
192
193 #[serde(default, skip_serializing_if = "Vec::is_empty")]
195 pub chat_ids: Vec<i64>,
196
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
199 pub guild_ids: Vec<String>,
200
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub users: Vec<String>,
204
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub patterns: Vec<String>,
208
209 #[serde(skip_serializing_if = "Option::is_none")]
215 pub path: Option<String>,
216
217 #[serde(default, skip_serializing_if = "Vec::is_empty")]
219 pub methods: Vec<String>,
220
221 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
223 pub required_headers: HashMap<String, String>,
224
225 #[serde(skip_serializing_if = "Option::is_none")]
227 pub webhook_secret: Option<String>,
228
229 #[serde(skip_serializing_if = "Option::is_none")]
235 pub cron: Option<String>,
236
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub timezone: Option<String>,
240
241 #[serde(skip_serializing_if = "Option::is_none")]
247 pub api_key: Option<String>,
248
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub routing_key: Option<String>,
252
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 pub service_ids: Vec<String>,
256
257 #[serde(default, skip_serializing_if = "Vec::is_empty")]
263 pub github_events: Vec<String>,
264
265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
267 pub repositories: Vec<String>,
268
269 #[serde(skip_serializing_if = "Option::is_none")]
275 pub business_account_id: Option<String>,
276
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub phone_number_id: Option<String>,
280
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub verify_token: Option<String>,
284
285 #[serde(skip_serializing_if = "Option::is_none")]
291 pub port: Option<u16>,
292
293 #[serde(skip_serializing_if = "Option::is_none")]
295 pub host: Option<String>,
296
297 #[serde(flatten, skip_serializing_if = "HashMap::is_empty")]
299 pub extra: HashMap<String, serde_json::Value>,
300}
301
302impl Trigger {
303 pub fn name(&self) -> &str {
305 &self.metadata.name
306 }
307
308 pub fn trigger_type(&self) -> StandaloneTriggerType {
310 self.spec.trigger_type
311 }
312
313 pub fn validate(&self) -> Result<(), String> {
316 if self.metadata.name.is_empty() {
318 return Err("Trigger name is required".to_string());
319 }
320
321 match self.spec.trigger_type {
324 StandaloneTriggerType::Slack => {
325 if self.spec.config.bot_token.is_none() {
326 return Err("Slack trigger requires bot_token".to_string());
327 }
328 }
329 StandaloneTriggerType::Telegram => {
330 if self.spec.config.bot_token.is_none() {
331 return Err("Telegram trigger requires bot_token".to_string());
332 }
333 }
334 StandaloneTriggerType::Discord => {
335 if self.spec.config.bot_token.is_none() {
336 return Err("Discord trigger requires bot_token".to_string());
337 }
338 }
339 StandaloneTriggerType::Schedule => {
340 if self.spec.config.cron.is_none() {
341 return Err("Schedule trigger requires cron expression".to_string());
342 }
343 }
344 StandaloneTriggerType::PagerDuty => {
345 if self.spec.config.api_key.is_none() && self.spec.config.routing_key.is_none() {
346 return Err("PagerDuty trigger requires api_key or routing_key".to_string());
347 }
348 }
349 StandaloneTriggerType::WhatsApp => {
350 if self.spec.config.bot_token.is_none() {
351 return Err("WhatsApp trigger requires bot_token (access token)".to_string());
352 }
353 }
354 _ => {}
355 }
356
357 Ok(())
358 }
359
360 pub fn expand_env_vars(&mut self) {
362 let config = &mut self.spec.config;
363
364 if let Some(ref token) = config.bot_token {
365 config.bot_token = Some(expand_env_var(token));
366 }
367 if let Some(ref secret) = config.signing_secret {
368 config.signing_secret = Some(expand_env_var(secret));
369 }
370 if let Some(ref secret) = config.app_secret {
371 config.app_secret = Some(expand_env_var(secret));
372 }
373 if let Some(ref secret) = config.webhook_secret {
374 config.webhook_secret = Some(expand_env_var(secret));
375 }
376 if let Some(ref key) = config.api_key {
377 config.api_key = Some(expand_env_var(key));
378 }
379 if let Some(ref key) = config.routing_key {
380 config.routing_key = Some(expand_env_var(key));
381 }
382 if let Some(ref token) = config.verify_token {
383 config.verify_token = Some(expand_env_var(token));
384 }
385 }
386
387 pub fn matches(&self, platform: &str, channel: Option<&str>, user: Option<&str>, text: Option<&str>) -> bool {
389 let trigger_platform = self.spec.trigger_type.to_string().to_lowercase();
391 if trigger_platform != platform.to_lowercase() {
392 return false;
393 }
394
395 let config = &self.spec.config;
396
397 if !config.channels.is_empty() {
399 if let Some(ch) = channel {
400 if !config.channels.iter().any(|c| c == ch) {
401 return false;
402 }
403 } else {
404 return false;
405 }
406 }
407
408 if !config.users.is_empty() {
410 if let Some(u) = user {
411 if !config.users.iter().any(|allowed| allowed == u) {
412 return false;
413 }
414 } else {
415 return false;
416 }
417 }
418
419 if !config.patterns.is_empty() {
421 if let Some(t) = text {
422 let matches_pattern = config.patterns.iter().any(|p| {
423 if let Ok(re) = regex::Regex::new(p) {
424 re.is_match(t)
425 } else {
426 t.contains(p)
427 }
428 });
429 if !matches_pattern {
430 return false;
431 }
432 } else {
433 return false;
434 }
435 }
436
437 true
438 }
439
440 pub fn match_score(&self, platform: &str, channel: Option<&str>, user: Option<&str>, text: Option<&str>) -> u32 {
443 if !self.matches(platform, channel, user, text) {
444 return 0;
445 }
446
447 let config = &self.spec.config;
448 let mut score = 10; if !config.channels.is_empty() && channel.is_some() {
452 score += 100;
453 }
454
455 if !config.users.is_empty() && user.is_some() {
457 score += 80;
458 }
459
460 if !config.patterns.is_empty() && text.is_some() {
462 score += 60;
463 }
464
465 score
466 }
467}
468
469fn expand_env_var(value: &str) -> String {
471 let mut result = value.to_string();
472 let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
473
474 for cap in re.captures_iter(value) {
475 let var_name = &cap[1];
476 if let Ok(var_value) = std::env::var(var_name) {
477 result = result.replace(&cap[0], &var_value);
478 }
479 }
480
481 result
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn test_parse_slack_trigger() {
490 let yaml = r#"
491apiVersion: aof.dev/v1
492kind: Trigger
493metadata:
494 name: slack-prod-channel
495 labels:
496 environment: production
497spec:
498 type: Slack
499 config:
500 bot_token: ${SLACK_BOT_TOKEN}
501 signing_secret: ${SLACK_SIGNING_SECRET}
502 channels:
503 - production
504 - prod-alerts
505 events:
506 - app_mention
507 - message
508"#;
509
510 let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
511 assert_eq!(trigger.metadata.name, "slack-prod-channel");
512 assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::Slack);
513 assert_eq!(trigger.spec.config.channels.len(), 2);
514 assert!(trigger.validate().is_ok());
515 }
516
517 #[test]
518 fn test_parse_telegram_trigger() {
519 let yaml = r#"
520apiVersion: aof.dev/v1
521kind: Trigger
522metadata:
523 name: telegram-oncall
524spec:
525 type: Telegram
526 config:
527 bot_token: ${TELEGRAM_BOT_TOKEN}
528 chat_ids:
529 - -1001234567890
530 users:
531 - "123456789"
532"#;
533
534 let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
535 assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::Telegram);
536 assert_eq!(trigger.spec.config.chat_ids.len(), 1);
537 assert!(trigger.validate().is_ok());
538 }
539
540 #[test]
541 fn test_parse_schedule_trigger() {
542 let yaml = r#"
543apiVersion: aof.dev/v1
544kind: Trigger
545metadata:
546 name: daily-report
547spec:
548 type: Schedule
549 config:
550 cron: "0 9 * * *"
551 timezone: "America/New_York"
552"#;
553
554 let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
555 assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::Schedule);
556 assert_eq!(trigger.spec.config.cron, Some("0 9 * * *".to_string()));
557 assert!(trigger.validate().is_ok());
558 }
559
560 #[test]
561 fn test_parse_http_trigger() {
562 let yaml = r#"
563apiVersion: aof.dev/v1
564kind: Trigger
565metadata:
566 name: webhook-endpoint
567spec:
568 type: HTTP
569 config:
570 path: /webhook/github
571 methods:
572 - POST
573 webhook_secret: ${WEBHOOK_SECRET}
574"#;
575
576 let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
577 assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::HTTP);
578 assert_eq!(trigger.spec.config.path, Some("/webhook/github".to_string()));
579 assert!(trigger.validate().is_ok());
580 }
581
582 #[test]
583 fn test_parse_pagerduty_trigger() {
584 let yaml = r#"
585apiVersion: aof.dev/v1
586kind: Trigger
587metadata:
588 name: pagerduty-incidents
589spec:
590 type: PagerDuty
591 config:
592 api_key: ${PAGERDUTY_API_KEY}
593 service_ids:
594 - P123ABC
595 - P456DEF
596"#;
597
598 let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
599 assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::PagerDuty);
600 assert!(trigger.validate().is_ok());
601 }
602
603 #[test]
604 fn test_validation_errors() {
605 let yaml = r#"
607apiVersion: aof.dev/v1
608kind: Trigger
609metadata:
610 name: ""
611spec:
612 type: Slack
613 config:
614 bot_token: token
615"#;
616 let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
617 assert!(trigger.validate().is_err());
618
619 let yaml2 = r#"
621apiVersion: aof.dev/v1
622kind: Trigger
623metadata:
624 name: test
625spec:
626 type: Slack
627 config: {}
628"#;
629 let trigger2: Trigger = serde_yaml::from_str(yaml2).unwrap();
630 assert!(trigger2.validate().is_err());
631
632 let yaml3 = r#"
634apiVersion: aof.dev/v1
635kind: Trigger
636metadata:
637 name: test
638spec:
639 type: Schedule
640 config: {}
641"#;
642 let trigger3: Trigger = serde_yaml::from_str(yaml3).unwrap();
643 assert!(trigger3.validate().is_err());
644 }
645
646 #[test]
647 fn test_matches() {
648 let yaml = r#"
649apiVersion: aof.dev/v1
650kind: Trigger
651metadata:
652 name: test
653spec:
654 type: Slack
655 config:
656 bot_token: token
657 channels:
658 - production
659 patterns:
660 - kubectl
661"#;
662
663 let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
664
665 assert!(trigger.matches("slack", Some("production"), None, Some("kubectl get pods")));
667
668 assert!(!trigger.matches("telegram", Some("production"), None, Some("kubectl get pods")));
670
671 assert!(!trigger.matches("slack", Some("staging"), None, Some("kubectl get pods")));
673
674 assert!(!trigger.matches("slack", Some("production"), None, Some("hello world")));
676 }
677
678 #[test]
679 fn test_match_score() {
680 let yaml1 = r#"
682apiVersion: aof.dev/v1
683kind: Trigger
684metadata:
685 name: specific
686spec:
687 type: Slack
688 config:
689 bot_token: token
690 channels: [production]
691 patterns: [kubectl]
692"#;
693
694 let yaml2 = r#"
696apiVersion: aof.dev/v1
697kind: Trigger
698metadata:
699 name: catchall
700spec:
701 type: Slack
702 config:
703 bot_token: token
704"#;
705
706 let specific: Trigger = serde_yaml::from_str(yaml1).unwrap();
707 let catchall: Trigger = serde_yaml::from_str(yaml2).unwrap();
708
709 let score1 = specific.match_score("slack", Some("production"), None, Some("kubectl get pods"));
710 let score2 = catchall.match_score("slack", Some("production"), None, Some("kubectl get pods"));
711
712 assert!(score1 > score2);
714 }
715
716 #[test]
717 fn test_expand_env_var() {
718 std::env::set_var("TEST_TOKEN", "secret123");
719 let result = expand_env_var("Bearer ${TEST_TOKEN}");
720 assert_eq!(result, "Bearer secret123");
721 std::env::remove_var("TEST_TOKEN");
722 }
723}