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}
113
114impl DeceptiveCanaryRouter {
115    /// Create a new deceptive canary router
116    pub fn new(config: DeceptiveCanaryConfig) -> Self {
117        Self {
118            config,
119            round_robin_counter: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
120        }
121    }
122
123    /// Check if a request should be routed to deceptive deploy
124    ///
125    /// # Arguments
126    /// * `user_agent` - User agent string from request
127    /// * `ip_address` - Client IP address
128    /// * `headers` - Request headers
129    /// * `query_params` - Query parameters
130    /// * `user_id` - Optional user ID for consistent hashing
131    ///
132    /// # Returns
133    /// True if request should be routed to deceptive deploy
134    pub fn should_route_to_canary(
135        &self,
136        user_agent: Option<&str>,
137        ip_address: Option<&str>,
138        headers: &HashMap<String, String>,
139        query_params: &HashMap<String, String>,
140        user_id: Option<&str>,
141    ) -> bool {
142        // Check if canary is enabled
143        if !self.config.enabled {
144            return false;
145        }
146
147        // Check opt-out mechanisms
148        if let Some(opt_out_header) = &self.config.opt_out_header {
149            if headers.get(opt_out_header).is_some() {
150                return false;
151            }
152        }
153
154        if let Some(opt_out_param) = &self.config.opt_out_query_param {
155            if query_params.get(opt_out_param).is_some() {
156                return false;
157            }
158        }
159
160        // Check if request matches team criteria
161        if !self.matches_team_criteria(user_agent, ip_address, headers) {
162            return false;
163        }
164
165        // Apply routing strategy
166
167        match self.config.routing_strategy {
168            CanaryRoutingStrategy::ConsistentHash => {
169                self.consistent_hash_route(user_id, ip_address)
170            }
171            CanaryRoutingStrategy::Random => self.random_route(),
172            CanaryRoutingStrategy::RoundRobin => self.round_robin_route(),
173        }
174    }
175
176    /// Check if request matches team identification criteria
177    fn matches_team_criteria(
178        &self,
179        user_agent: Option<&str>,
180        ip_address: Option<&str>,
181        headers: &HashMap<String, String>,
182    ) -> bool {
183        // Check user agent
184        if let Some(user_agents) = &self.config.team_identifiers.user_agents {
185            if let Some(ua) = user_agent {
186                let matches = user_agents.iter().any(|pattern| {
187                    if pattern == "*" {
188                        true
189                    } else {
190                        // Simple substring match (could be enhanced with regex)
191                        ua.contains(pattern)
192                    }
193                });
194                if !matches {
195                    return false;
196                }
197            } else if !user_agents.contains(&"*".to_string()) {
198                return false;
199            }
200        }
201
202        // Check IP ranges
203        if let Some(ip_ranges) = &self.config.team_identifiers.ip_ranges {
204            if let Some(ip) = ip_address {
205                let matches = ip_ranges.iter().any(|range| {
206                    if range == "*" {
207                        true
208                    } else {
209                        // Simple prefix match (could be enhanced with CIDR parsing)
210                        ip.starts_with(range) || range == ip
211                    }
212                });
213                if !matches {
214                    return false;
215                }
216            } else if !ip_ranges.contains(&"*".to_string()) {
217                return false;
218            }
219        }
220
221        // Check headers
222        if let Some(header_rules) = &self.config.team_identifiers.headers {
223            for (header_name, expected_value) in header_rules {
224                if let Some(actual_value) = headers.get(header_name) {
225                    if actual_value != expected_value && expected_value != "*" {
226                        return false;
227                    }
228                } else if expected_value != "*" {
229                    return false;
230                }
231            }
232        }
233
234        true
235    }
236
237    /// Consistent hash routing
238    fn consistent_hash_route(&self, user_id: Option<&str>, ip_address: Option<&str>) -> bool {
239        // Use user_id if available, otherwise fall back to IP
240        let hash_input = user_id.unwrap_or_else(|| ip_address.unwrap_or("default"));
241
242        // Simple hash function
243        let mut hasher = DefaultHasher::new();
244        hash_input.hash(&mut hasher);
245        let hash = hasher.finish();
246
247        // Convert to percentage (0.0 to 1.0)
248        let percentage = (hash % 10000) as f64 / 10000.0;
249
250        percentage < self.config.traffic_percentage
251    }
252
253    /// Random routing
254    fn random_route(&self) -> bool {
255        use rand::Rng;
256        let mut rng = rand::thread_rng();
257        let random_value: f64 = rng.gen();
258        random_value < self.config.traffic_percentage
259    }
260
261    /// Round-robin routing
262    fn round_robin_route(&self) -> bool {
263        let counter = self.round_robin_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
264        let cycle_size = (1.0 / self.config.traffic_percentage) as u64;
265        counter.is_multiple_of(cycle_size)
266    }
267
268    /// Get current configuration
269    pub fn config(&self) -> &DeceptiveCanaryConfig {
270        &self.config
271    }
272
273    /// Update configuration
274    pub fn update_config(&mut self, config: DeceptiveCanaryConfig) {
275        self.config = config;
276    }
277
278    /// Get routing statistics
279    pub fn stats(&self) -> Option<&CanaryStats> {
280        self.config.stats.as_ref()
281    }
282
283    /// Update statistics (thread-safe)
284    pub fn record_request(&self, routed: bool, opted_out: bool, matched: bool) {
285        if let Some(stats) = &self.config.stats {
286            // Note: This is a simplified implementation
287            // In production, you'd want to use atomic counters or a proper stats collector
288            // For now, stats are stored in config which is not thread-safe for updates
289            // This would need to be refactored to use Arc<RwLock<CanaryStats>> for thread-safety
290        }
291    }
292}
293
294impl Default for DeceptiveCanaryRouter {
295    fn default() -> Self {
296        Self::new(DeceptiveCanaryConfig::default())
297    }
298}