Skip to main content

mockforge_core/
deceptive_canary.rs

1//! Deceptive Canary Mode
2//!
3//! Routes a small percentage of team traffic to "deceptive deploys" by default, with opt-out.
4//! Great for dogfooding realism in cloud deployments.
5
6use serde::{Deserialize, Serialize};
7use std::collections::hash_map::DefaultHasher;
8use std::collections::HashMap;
9use std::hash::{Hash, Hasher};
10
11/// Deceptive canary configuration
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14pub struct DeceptiveCanaryConfig {
15    /// Enable deceptive canary mode
16    pub enabled: bool,
17    /// Traffic percentage to route to deceptive deploy (0.0 to 1.0)
18    pub traffic_percentage: f64,
19    /// Team/user identification criteria
20    pub team_identifiers: TeamIdentifiers,
21    /// Opt-out header name (e.g., "X-Opt-Out-Canary")
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub opt_out_header: Option<String>,
24    /// Opt-out query parameter name (e.g., "no-canary")
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub opt_out_query_param: Option<String>,
27    /// Deceptive deploy URL to route to
28    pub deceptive_deploy_url: String,
29    /// Routing strategy for selecting which requests to route
30    pub routing_strategy: CanaryRoutingStrategy,
31    /// Statistics tracking
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub stats: Option<CanaryStats>,
34}
35
36impl Default for DeceptiveCanaryConfig {
37    fn default() -> Self {
38        Self {
39            enabled: false,
40            traffic_percentage: 0.05, // 5% by default
41            team_identifiers: TeamIdentifiers::default(),
42            opt_out_header: Some("X-Opt-Out-Canary".to_string()),
43            opt_out_query_param: Some("no-canary".to_string()),
44            deceptive_deploy_url: String::new(),
45            routing_strategy: CanaryRoutingStrategy::ConsistentHash,
46            stats: Some(CanaryStats::default()),
47        }
48    }
49}
50
51/// Team/user identification criteria
52#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54pub struct TeamIdentifiers {
55    /// User agent patterns (regex patterns, "*" matches all)
56    #[serde(default)]
57    pub user_agents: Option<Vec<String>>,
58    /// IP address ranges (CIDR notation or specific IPs)
59    #[serde(default)]
60    pub ip_ranges: Option<Vec<String>>,
61    /// Header matching rules (header name -> value pattern)
62    #[serde(default)]
63    pub headers: Option<HashMap<String, String>>,
64    /// Team names/IDs to match
65    #[serde(default)]
66    pub teams: Option<Vec<String>>,
67}
68
69/// Canary routing strategy
70#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72#[serde(rename_all = "snake_case")]
73pub enum CanaryRoutingStrategy {
74    /// Consistent hashing on user ID for consistent routing
75    ConsistentHash,
76    /// Random selection per request
77    Random,
78    /// Round-robin distribution
79    RoundRobin,
80}
81
82/// Statistics for canary routing
83#[derive(Debug, Clone, Serialize, Deserialize, Default)]
84#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
85pub struct CanaryStats {
86    /// Total requests processed
87    pub total_requests: u64,
88    /// Requests routed to canary
89    pub canary_requests: u64,
90    /// Requests that opted out
91    pub opted_out_requests: u64,
92    /// Requests that matched team criteria
93    pub matched_requests: u64,
94}
95
96impl CanaryStats {
97    /// Get canary routing percentage
98    pub fn canary_percentage(&self) -> f64 {
99        if self.matched_requests == 0 {
100            return 0.0;
101        }
102        (self.canary_requests as f64 / self.matched_requests as f64) * 100.0
103    }
104}
105
106/// Deceptive canary router
107///
108/// Handles routing logic for deceptive canary mode.
109pub struct DeceptiveCanaryRouter {
110    config: DeceptiveCanaryConfig,
111    round_robin_counter: std::sync::Arc<std::sync::atomic::AtomicU64>,
112    // Thread-safe atomic counters for statistics tracking
113    total_requests: std::sync::atomic::AtomicU64,
114    canary_requests: std::sync::atomic::AtomicU64,
115    opted_out_requests: std::sync::atomic::AtomicU64,
116    matched_requests: std::sync::atomic::AtomicU64,
117}
118
119impl DeceptiveCanaryRouter {
120    /// Create a new deceptive canary router
121    pub fn new(config: DeceptiveCanaryConfig) -> Self {
122        Self {
123            config,
124            round_robin_counter: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
125            total_requests: std::sync::atomic::AtomicU64::new(0),
126            canary_requests: std::sync::atomic::AtomicU64::new(0),
127            opted_out_requests: std::sync::atomic::AtomicU64::new(0),
128            matched_requests: std::sync::atomic::AtomicU64::new(0),
129        }
130    }
131
132    /// Check if a request should be routed to deceptive deploy
133    ///
134    /// # Arguments
135    /// * `user_agent` - User agent string from request
136    /// * `ip_address` - Client IP address
137    /// * `headers` - Request headers
138    /// * `query_params` - Query parameters
139    /// * `user_id` - Optional user ID for consistent hashing
140    ///
141    /// # Returns
142    /// True if request should be routed to deceptive deploy
143    pub fn should_route_to_canary(
144        &self,
145        user_agent: Option<&str>,
146        ip_address: Option<&str>,
147        headers: &HashMap<String, String>,
148        query_params: &HashMap<String, String>,
149        user_id: Option<&str>,
150    ) -> bool {
151        // Check if canary is enabled
152        if !self.config.enabled {
153            return false;
154        }
155
156        // Check opt-out mechanisms
157        if let Some(opt_out_header) = &self.config.opt_out_header {
158            if headers.get(opt_out_header).is_some() {
159                return false;
160            }
161        }
162
163        if let Some(opt_out_param) = &self.config.opt_out_query_param {
164            if query_params.get(opt_out_param).is_some() {
165                return false;
166            }
167        }
168
169        // Check if request matches team criteria
170        if !self.matches_team_criteria(user_agent, ip_address, headers) {
171            return false;
172        }
173
174        // Apply routing strategy
175
176        match self.config.routing_strategy {
177            CanaryRoutingStrategy::ConsistentHash => {
178                self.consistent_hash_route(user_id, ip_address)
179            }
180            CanaryRoutingStrategy::Random => self.random_route(),
181            CanaryRoutingStrategy::RoundRobin => self.round_robin_route(),
182        }
183    }
184
185    /// Check if request matches team identification criteria
186    fn matches_team_criteria(
187        &self,
188        user_agent: Option<&str>,
189        ip_address: Option<&str>,
190        headers: &HashMap<String, String>,
191    ) -> bool {
192        // Check user agent
193        if let Some(user_agents) = &self.config.team_identifiers.user_agents {
194            if let Some(ua) = user_agent {
195                let matches = user_agents.iter().any(|pattern| {
196                    if pattern == "*" {
197                        true
198                    } else {
199                        // Simple substring match (could be enhanced with regex)
200                        ua.contains(pattern)
201                    }
202                });
203                if !matches {
204                    return false;
205                }
206            } else if !user_agents.contains(&"*".to_string()) {
207                return false;
208            }
209        }
210
211        // Check IP ranges
212        if let Some(ip_ranges) = &self.config.team_identifiers.ip_ranges {
213            if let Some(ip) = ip_address {
214                let matches = ip_ranges.iter().any(|range| {
215                    if range == "*" {
216                        true
217                    } else {
218                        // Simple prefix match (could be enhanced with CIDR parsing)
219                        ip.starts_with(range) || range == ip
220                    }
221                });
222                if !matches {
223                    return false;
224                }
225            } else if !ip_ranges.contains(&"*".to_string()) {
226                return false;
227            }
228        }
229
230        // Check headers
231        if let Some(header_rules) = &self.config.team_identifiers.headers {
232            for (header_name, expected_value) in header_rules {
233                if let Some(actual_value) = headers.get(header_name) {
234                    if actual_value != expected_value && expected_value != "*" {
235                        return false;
236                    }
237                } else if expected_value != "*" {
238                    return false;
239                }
240            }
241        }
242
243        true
244    }
245
246    /// Consistent hash routing
247    fn consistent_hash_route(&self, user_id: Option<&str>, ip_address: Option<&str>) -> bool {
248        // Use user_id if available, otherwise fall back to IP
249        let hash_input = user_id.unwrap_or_else(|| ip_address.unwrap_or("default"));
250
251        // Simple hash function
252        let mut hasher = DefaultHasher::new();
253        hash_input.hash(&mut hasher);
254        let hash = hasher.finish();
255
256        // Convert to percentage (0.0 to 1.0)
257        let percentage = (hash % 10000) as f64 / 10000.0;
258
259        percentage < self.config.traffic_percentage
260    }
261
262    /// Random routing
263    fn random_route(&self) -> bool {
264        use rand::Rng;
265        let mut rng = rand::thread_rng();
266        let random_value: f64 = rng.gen();
267        random_value < self.config.traffic_percentage
268    }
269
270    /// Round-robin routing
271    fn round_robin_route(&self) -> bool {
272        let counter = self.round_robin_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
273        let cycle_size = (1.0 / self.config.traffic_percentage) as u64;
274        counter.is_multiple_of(cycle_size)
275    }
276
277    /// Get current configuration
278    pub fn config(&self) -> &DeceptiveCanaryConfig {
279        &self.config
280    }
281
282    /// Update configuration
283    pub fn update_config(&mut self, config: DeceptiveCanaryConfig) {
284        self.config = config;
285    }
286
287    /// Get routing statistics as a snapshot.
288    pub fn stats(&self) -> CanaryStats {
289        CanaryStats {
290            total_requests: self.total_requests.load(std::sync::atomic::Ordering::Relaxed),
291            canary_requests: self.canary_requests.load(std::sync::atomic::Ordering::Relaxed),
292            opted_out_requests: self.opted_out_requests.load(std::sync::atomic::Ordering::Relaxed),
293            matched_requests: self.matched_requests.load(std::sync::atomic::Ordering::Relaxed),
294        }
295    }
296
297    /// Record a request in the thread-safe atomic counters.
298    pub fn record_request(&self, routed: bool, opted_out: bool, matched: bool) {
299        self.total_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
300        if routed {
301            self.canary_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
302        }
303        if opted_out {
304            self.opted_out_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
305        }
306        if matched {
307            self.matched_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
308        }
309    }
310}
311
312impl Default for DeceptiveCanaryRouter {
313    fn default() -> Self {
314        Self::new(DeceptiveCanaryConfig::default())
315    }
316}