use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Trigger {
#[serde(default = "default_api_version")]
pub api_version: String,
#[serde(default = "default_trigger_kind")]
pub kind: String,
pub metadata: TriggerMetadata,
pub spec: TriggerSpec,
}
fn default_api_version() -> String {
"aof.dev/v1".to_string()
}
fn default_trigger_kind() -> String {
"Trigger".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerMetadata {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub annotations: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TriggerSpec {
#[serde(rename = "type")]
pub trigger_type: StandaloneTriggerType,
#[serde(default)]
pub config: StandaloneTriggerConfig,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub commands: HashMap<String, CommandBinding>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_agent: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct CommandBinding {
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fleet: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum StandaloneTriggerType {
Slack,
Telegram,
Discord,
WhatsApp,
HTTP,
Schedule,
PagerDuty,
GitHub,
Jira,
Manual,
}
impl std::fmt::Display for StandaloneTriggerType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Slack => write!(f, "slack"),
Self::Telegram => write!(f, "telegram"),
Self::Discord => write!(f, "discord"),
Self::WhatsApp => write!(f, "whatsapp"),
Self::HTTP => write!(f, "http"),
Self::Schedule => write!(f, "schedule"),
Self::PagerDuty => write!(f, "pagerduty"),
Self::GitHub => write!(f, "github"),
Self::Jira => write!(f, "jira"),
Self::Manual => write!(f, "manual"),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct StandaloneTriggerConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub bot_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signing_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_secret: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub chat_ids: Vec<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guild_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub users: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub patterns: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub methods: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub required_headers: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub webhook_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cron: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub routing_key: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub service_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub github_events: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repositories: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub business_account_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_number_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verify_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(flatten, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, serde_json::Value>,
}
impl Trigger {
pub fn name(&self) -> &str {
&self.metadata.name
}
pub fn trigger_type(&self) -> StandaloneTriggerType {
self.spec.trigger_type
}
pub fn validate(&self) -> Result<(), String> {
if self.metadata.name.is_empty() {
return Err("Trigger name is required".to_string());
}
match self.spec.trigger_type {
StandaloneTriggerType::Slack => {
if self.spec.config.bot_token.is_none() {
return Err("Slack trigger requires bot_token".to_string());
}
}
StandaloneTriggerType::Telegram => {
if self.spec.config.bot_token.is_none() {
return Err("Telegram trigger requires bot_token".to_string());
}
}
StandaloneTriggerType::Discord => {
if self.spec.config.bot_token.is_none() {
return Err("Discord trigger requires bot_token".to_string());
}
}
StandaloneTriggerType::Schedule => {
if self.spec.config.cron.is_none() {
return Err("Schedule trigger requires cron expression".to_string());
}
}
StandaloneTriggerType::PagerDuty => {
if self.spec.config.api_key.is_none() && self.spec.config.routing_key.is_none() {
return Err("PagerDuty trigger requires api_key or routing_key".to_string());
}
}
StandaloneTriggerType::WhatsApp => {
if self.spec.config.bot_token.is_none() {
return Err("WhatsApp trigger requires bot_token (access token)".to_string());
}
}
_ => {}
}
Ok(())
}
pub fn expand_env_vars(&mut self) {
let config = &mut self.spec.config;
if let Some(ref token) = config.bot_token {
config.bot_token = Some(expand_env_var(token));
}
if let Some(ref secret) = config.signing_secret {
config.signing_secret = Some(expand_env_var(secret));
}
if let Some(ref secret) = config.app_secret {
config.app_secret = Some(expand_env_var(secret));
}
if let Some(ref secret) = config.webhook_secret {
config.webhook_secret = Some(expand_env_var(secret));
}
if let Some(ref key) = config.api_key {
config.api_key = Some(expand_env_var(key));
}
if let Some(ref key) = config.routing_key {
config.routing_key = Some(expand_env_var(key));
}
if let Some(ref token) = config.verify_token {
config.verify_token = Some(expand_env_var(token));
}
}
pub fn matches(&self, platform: &str, channel: Option<&str>, user: Option<&str>, text: Option<&str>) -> bool {
let trigger_platform = self.spec.trigger_type.to_string().to_lowercase();
if trigger_platform != platform.to_lowercase() {
return false;
}
let config = &self.spec.config;
if !config.channels.is_empty() {
if let Some(ch) = channel {
if !config.channels.iter().any(|c| c == ch) {
return false;
}
} else {
return false;
}
}
if !config.users.is_empty() {
if let Some(u) = user {
if !config.users.iter().any(|allowed| allowed == u) {
return false;
}
} else {
return false;
}
}
if !config.patterns.is_empty() {
if let Some(t) = text {
let matches_pattern = config.patterns.iter().any(|p| {
if let Ok(re) = regex::Regex::new(p) {
re.is_match(t)
} else {
t.contains(p)
}
});
if !matches_pattern {
return false;
}
} else {
return false;
}
}
true
}
pub fn match_score(&self, platform: &str, channel: Option<&str>, user: Option<&str>, text: Option<&str>) -> u32 {
if !self.matches(platform, channel, user, text) {
return 0;
}
let config = &self.spec.config;
let mut score = 10;
if !config.channels.is_empty() && channel.is_some() {
score += 100;
}
if !config.users.is_empty() && user.is_some() {
score += 80;
}
if !config.patterns.is_empty() && text.is_some() {
score += 60;
}
score
}
}
fn expand_env_var(value: &str) -> String {
let mut result = value.to_string();
let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
for cap in re.captures_iter(value) {
let var_name = &cap[1];
if let Ok(var_value) = std::env::var(var_name) {
result = result.replace(&cap[0], &var_value);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_slack_trigger() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Trigger
metadata:
name: slack-prod-channel
labels:
environment: production
spec:
type: Slack
config:
bot_token: ${SLACK_BOT_TOKEN}
signing_secret: ${SLACK_SIGNING_SECRET}
channels:
- production
- prod-alerts
events:
- app_mention
- message
"#;
let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
assert_eq!(trigger.metadata.name, "slack-prod-channel");
assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::Slack);
assert_eq!(trigger.spec.config.channels.len(), 2);
assert!(trigger.validate().is_ok());
}
#[test]
fn test_parse_telegram_trigger() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Trigger
metadata:
name: telegram-oncall
spec:
type: Telegram
config:
bot_token: ${TELEGRAM_BOT_TOKEN}
chat_ids:
- -1001234567890
users:
- "123456789"
"#;
let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::Telegram);
assert_eq!(trigger.spec.config.chat_ids.len(), 1);
assert!(trigger.validate().is_ok());
}
#[test]
fn test_parse_schedule_trigger() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Trigger
metadata:
name: daily-report
spec:
type: Schedule
config:
cron: "0 9 * * *"
timezone: "America/New_York"
"#;
let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::Schedule);
assert_eq!(trigger.spec.config.cron, Some("0 9 * * *".to_string()));
assert!(trigger.validate().is_ok());
}
#[test]
fn test_parse_http_trigger() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Trigger
metadata:
name: webhook-endpoint
spec:
type: HTTP
config:
path: /webhook/github
methods:
- POST
webhook_secret: ${WEBHOOK_SECRET}
"#;
let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::HTTP);
assert_eq!(trigger.spec.config.path, Some("/webhook/github".to_string()));
assert!(trigger.validate().is_ok());
}
#[test]
fn test_parse_pagerduty_trigger() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Trigger
metadata:
name: pagerduty-incidents
spec:
type: PagerDuty
config:
api_key: ${PAGERDUTY_API_KEY}
service_ids:
- P123ABC
- P456DEF
"#;
let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::PagerDuty);
assert!(trigger.validate().is_ok());
}
#[test]
fn test_validation_errors() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Trigger
metadata:
name: ""
spec:
type: Slack
config:
bot_token: token
"#;
let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
assert!(trigger.validate().is_err());
let yaml2 = r#"
apiVersion: aof.dev/v1
kind: Trigger
metadata:
name: test
spec:
type: Slack
config: {}
"#;
let trigger2: Trigger = serde_yaml::from_str(yaml2).unwrap();
assert!(trigger2.validate().is_err());
let yaml3 = r#"
apiVersion: aof.dev/v1
kind: Trigger
metadata:
name: test
spec:
type: Schedule
config: {}
"#;
let trigger3: Trigger = serde_yaml::from_str(yaml3).unwrap();
assert!(trigger3.validate().is_err());
}
#[test]
fn test_matches() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: Trigger
metadata:
name: test
spec:
type: Slack
config:
bot_token: token
channels:
- production
patterns:
- kubectl
"#;
let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
assert!(trigger.matches("slack", Some("production"), None, Some("kubectl get pods")));
assert!(!trigger.matches("telegram", Some("production"), None, Some("kubectl get pods")));
assert!(!trigger.matches("slack", Some("staging"), None, Some("kubectl get pods")));
assert!(!trigger.matches("slack", Some("production"), None, Some("hello world")));
}
#[test]
fn test_match_score() {
let yaml1 = r#"
apiVersion: aof.dev/v1
kind: Trigger
metadata:
name: specific
spec:
type: Slack
config:
bot_token: token
channels: [production]
patterns: [kubectl]
"#;
let yaml2 = r#"
apiVersion: aof.dev/v1
kind: Trigger
metadata:
name: catchall
spec:
type: Slack
config:
bot_token: token
"#;
let specific: Trigger = serde_yaml::from_str(yaml1).unwrap();
let catchall: Trigger = serde_yaml::from_str(yaml2).unwrap();
let score1 = specific.match_score("slack", Some("production"), None, Some("kubectl get pods"));
let score2 = catchall.match_score("slack", Some("production"), None, Some("kubectl get pods"));
assert!(score1 > score2);
}
#[test]
fn test_expand_env_var() {
std::env::set_var("TEST_TOKEN", "secret123");
let result = expand_env_var("Bearer ${TEST_TOKEN}");
assert_eq!(result, "Bearer secret123");
std::env::remove_var("TEST_TOKEN");
}
}