Skip to main content

mockforge_k8s_operator/
webhook.rs

1//! Admission webhook for validating and mutating ChaosOrchestration resources
2
3use crate::crd::{ChaosOrchestration, ChaosOrchestrationSpec};
4use cron::Schedule;
5// Note: k8s-openapi doesn't include admission API types, so we define them manually
6// These match the Kubernetes admission webhook API v1
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::str::FromStr;
10use tracing::info;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AdmissionReview {
14    pub request: Option<AdmissionRequest>,
15    pub response: Option<AdmissionResponse>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct AdmissionRequest {
20    pub uid: String,
21    pub operation: String,
22    pub object: Option<serde_json::Value>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct AdmissionResponse {
27    pub uid: String,
28    pub allowed: bool,
29    pub status: Option<k8s_openapi::apimachinery::pkg::apis::meta::v1::Status>,
30}
31
32/// Webhook handler
33pub struct WebhookHandler;
34
35impl WebhookHandler {
36    /// Create a new webhook handler
37    pub fn new() -> Self {
38        Self
39    }
40
41    /// Handle admission review request
42    pub async fn handle_admission_review(
43        &self,
44        review: AdmissionReview,
45    ) -> Result<AdmissionReview, String> {
46        let request =
47            review.request.ok_or_else(|| "Missing request in AdmissionReview".to_string())?;
48
49        let response = match request.operation.as_str() {
50            "CREATE" | "UPDATE" => self.validate_orchestration(&request).await,
51            "DELETE" => self.validate_delete(&request).await,
52            _ => AdmissionResponse {
53                uid: request.uid.clone(),
54                allowed: true,
55                ..Default::default()
56            },
57        };
58
59        Ok(AdmissionReview {
60            request: Some(request),
61            response: Some(response),
62        })
63    }
64
65    /// Validate orchestration on create/update
66    async fn validate_orchestration(&self, request: &AdmissionRequest) -> AdmissionResponse {
67        let object = match &request.object {
68            Some(obj) => obj,
69            None => {
70                return AdmissionResponse {
71                    uid: request.uid.clone(),
72                    allowed: false,
73                    status: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Status {
74                        message: Some("Missing object in request".to_string()),
75                        ..Default::default()
76                    }),
77                    ..Default::default()
78                };
79            }
80        };
81
82        // Parse the ChaosOrchestration
83        let orchestration: ChaosOrchestration = match serde_json::from_value(object.clone()) {
84            Ok(orch) => orch,
85            Err(e) => {
86                return AdmissionResponse {
87                    uid: request.uid.clone(),
88                    allowed: false,
89                    status: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Status {
90                        message: Some(format!("Failed to parse ChaosOrchestration: {}", e)),
91                        ..Default::default()
92                    }),
93                    ..Default::default()
94                };
95            }
96        };
97
98        // Validate the spec
99        if let Err(e) = self.validate_spec(&orchestration.spec) {
100            return AdmissionResponse {
101                uid: request.uid.clone(),
102                allowed: false,
103                status: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Status {
104                    message: Some(format!("Validation failed: {}", e)),
105                    ..Default::default()
106                }),
107                ..Default::default()
108            };
109        }
110
111        info!("Validated ChaosOrchestration: {}", orchestration.spec.name);
112
113        AdmissionResponse {
114            uid: request.uid.clone(),
115            allowed: true,
116            ..Default::default()
117        }
118    }
119
120    /// Validate delete operation
121    async fn validate_delete(&self, request: &AdmissionRequest) -> AdmissionResponse {
122        // Allow deletion, but could add checks for running orchestrations
123        AdmissionResponse {
124            uid: request.uid.clone(),
125            allowed: true,
126            ..Default::default()
127        }
128    }
129
130    /// Validate orchestration spec
131    fn validate_spec(&self, spec: &ChaosOrchestrationSpec) -> Result<(), String> {
132        // Validate name
133        if spec.name.is_empty() {
134            return Err("Orchestration name cannot be empty".to_string());
135        }
136
137        // Validate steps
138        if spec.steps.is_empty() {
139            return Err("Orchestration must have at least one step".to_string());
140        }
141
142        // Validate each step
143        for (idx, step) in spec.steps.iter().enumerate() {
144            if step.name.is_empty() {
145                return Err(format!("Step {} must have a name", idx));
146            }
147
148            if step.scenario.is_empty() {
149                return Err(format!("Step {} must specify a scenario", idx));
150            }
151        }
152
153        // Validate schedule if present
154        if let Some(schedule) = &spec.schedule {
155            if !self.is_valid_cron(schedule) {
156                return Err(format!("Invalid cron schedule: {}", schedule));
157            }
158        }
159
160        // Validate target services
161        for service in &spec.target_services {
162            if service.name.is_empty() {
163                return Err("Target service name cannot be empty".to_string());
164            }
165        }
166
167        Ok(())
168    }
169
170    /// Check if cron expression is valid
171    ///
172    /// Validates the cron expression using the `cron` crate which supports
173    /// standard cron syntax with seconds (6 or 7 fields).
174    fn is_valid_cron(&self, schedule: &str) -> bool {
175        if schedule.is_empty() {
176            return false;
177        }
178
179        // Try to parse as a cron expression
180        // The cron crate expects 6 or 7 fields: sec min hour day-of-month month day-of-week [year]
181        // Kubernetes uses 5-field cron (min hour day month weekday), so we prepend "0" for seconds
182        let cron_expr = if schedule.split_whitespace().count() == 5 {
183            format!("0 {}", schedule)
184        } else {
185            schedule.to_string()
186        };
187
188        Schedule::from_str(&cron_expr).is_ok()
189    }
190
191    /// Mutate orchestration (set defaults)
192    pub fn mutate_orchestration(&self, spec: &mut ChaosOrchestrationSpec) {
193        // Set default values if not specified
194        for step in &mut spec.steps {
195            if step.duration_seconds.is_none() {
196                step.duration_seconds = Some(60); // Default 60 seconds
197            }
198        }
199    }
200}
201
202impl Default for WebhookHandler {
203    fn default() -> Self {
204        Self::new()
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_validate_empty_name() {
214        let handler = WebhookHandler::new();
215        let spec = ChaosOrchestrationSpec {
216            name: "".to_string(),
217            description: None,
218            schedule: None,
219            steps: vec![],
220            variables: HashMap::new(),
221            hooks: vec![],
222            assertions: vec![],
223            enable_reporting: true,
224            target_services: vec![],
225        };
226
227        assert!(handler.validate_spec(&spec).is_err());
228    }
229
230    #[test]
231    fn test_validate_no_steps() {
232        let handler = WebhookHandler::new();
233        let spec = ChaosOrchestrationSpec {
234            name: "test".to_string(),
235            description: None,
236            schedule: None,
237            steps: vec![],
238            variables: HashMap::new(),
239            hooks: vec![],
240            assertions: vec![],
241            enable_reporting: true,
242            target_services: vec![],
243        };
244
245        assert!(handler.validate_spec(&spec).is_err());
246    }
247
248    #[test]
249    fn test_valid_cron_expressions() {
250        let handler = WebhookHandler::new();
251
252        // Standard 5-field K8s cron expressions
253        assert!(handler.is_valid_cron("0 * * * *")); // Every hour
254        assert!(handler.is_valid_cron("*/15 * * * *")); // Every 15 minutes
255        assert!(handler.is_valid_cron("0 0 * * *")); // Daily at midnight
256        assert!(handler.is_valid_cron("0 0 * * SUN")); // Weekly on Sunday (cron crate uses SUN/1-7, not 0)
257        assert!(handler.is_valid_cron("0 0 1 * *")); // Monthly on the 1st
258
259        // 6-field cron expressions (with seconds)
260        assert!(handler.is_valid_cron("0 0 * * * *")); // Every hour at :00
261        assert!(handler.is_valid_cron("30 */5 * * * *")); // Every 5 minutes at :30 sec
262    }
263
264    #[test]
265    fn test_invalid_cron_expressions() {
266        let handler = WebhookHandler::new();
267
268        assert!(!handler.is_valid_cron("")); // Empty
269        assert!(!handler.is_valid_cron("invalid")); // Invalid text
270        assert!(!handler.is_valid_cron("60 * * * *")); // Invalid minute (>59)
271        assert!(!handler.is_valid_cron("* 25 * * *")); // Invalid hour (>23)
272        assert!(!handler.is_valid_cron("* * 32 * *")); // Invalid day (>31)
273    }
274}