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        let should_route = match self.config.routing_strategy {
167            CanaryRoutingStrategy::ConsistentHash => {
168                self.consistent_hash_route(user_id, ip_address)
169            }
170            CanaryRoutingStrategy::Random => self.random_route(),
171            CanaryRoutingStrategy::RoundRobin => self.round_robin_route(),
172        };
173
174        should_route
175    }
176
177    /// Check if request matches team identification criteria
178    fn matches_team_criteria(
179        &self,
180        user_agent: Option<&str>,
181        ip_address: Option<&str>,
182        headers: &HashMap<String, String>,
183    ) -> bool {
184        // Check user agent
185        if let Some(user_agents) = &self.config.team_identifiers.user_agents {
186            if let Some(ua) = user_agent {
187                let matches = user_agents.iter().any(|pattern| {
188                    if pattern == "*" {
189                        true
190                    } else {
191                        // Simple substring match (could be enhanced with regex)
192                        ua.contains(pattern)
193                    }
194                });
195                if !matches {
196                    return false;
197                }
198            } else if !user_agents.contains(&"*".to_string()) {
199                return false;
200            }
201        }
202
203        // Check IP ranges
204        if let Some(ip_ranges) = &self.config.team_identifiers.ip_ranges {
205            if let Some(ip) = ip_address {
206                let matches = ip_ranges.iter().any(|range| {
207                    if range == "*" {
208                        true
209                    } else {
210                        // Simple prefix match (could be enhanced with CIDR parsing)
211                        ip.starts_with(range) || range == ip
212                    }
213                });
214                if !matches {
215                    return false;
216                }
217            } else if !ip_ranges.contains(&"*".to_string()) {
218                return false;
219            }
220        }
221
222        // Check headers
223        if let Some(header_rules) = &self.config.team_identifiers.headers {
224            for (header_name, expected_value) in header_rules {
225                if let Some(actual_value) = headers.get(header_name) {
226                    if actual_value != expected_value && expected_value != "*" {
227                        return false;
228                    }
229                } else if expected_value != "*" {
230                    return false;
231                }
232            }
233        }
234
235        true
236    }
237
238    /// Consistent hash routing
239    fn consistent_hash_route(&self, user_id: Option<&str>, ip_address: Option<&str>) -> bool {
240        // Use user_id if available, otherwise fall back to IP
241        let hash_input = user_id.unwrap_or_else(|| ip_address.unwrap_or("default"));
242
243        // Simple hash function
244        let mut hasher = DefaultHasher::new();
245        hash_input.hash(&mut hasher);
246        let hash = hasher.finish();
247
248        // Convert to percentage (0.0 to 1.0)
249        let percentage = (hash % 10000) as f64 / 10000.0;
250
251        percentage < self.config.traffic_percentage
252    }
253
254    /// Random routing
255    fn random_route(&self) -> bool {
256        use rand::Rng;
257        let mut rng = rand::thread_rng();
258        let random_value: f64 = rng.gen();
259        random_value < self.config.traffic_percentage
260    }
261
262    /// Round-robin routing
263    fn round_robin_route(&self) -> bool {
264        let counter = self.round_robin_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
265        let cycle_size = (1.0 / self.config.traffic_percentage) as u64;
266        (counter % cycle_size) == 0
267    }
268
269    /// Get current configuration
270    pub fn config(&self) -> &DeceptiveCanaryConfig {
271        &self.config
272    }
273
274    /// Update configuration
275    pub fn update_config(&mut self, config: DeceptiveCanaryConfig) {
276        self.config = config;
277    }
278
279    /// Get routing statistics
280    pub fn stats(&self) -> Option<&CanaryStats> {
281        self.config.stats.as_ref()
282    }
283
284    /// Update statistics (thread-safe)
285    pub fn record_request(&self, routed: bool, opted_out: bool, matched: bool) {
286        if let Some(stats) = &self.config.stats {
287            // Note: This is a simplified implementation
288            // In production, you'd want to use atomic counters or a proper stats collector
289            // For now, stats are stored in config which is not thread-safe for updates
290            // This would need to be refactored to use Arc<RwLock<CanaryStats>> for thread-safety
291        }
292    }
293}
294
295impl Default for DeceptiveCanaryRouter {
296    fn default() -> Self {
297        Self::new(DeceptiveCanaryConfig::default())
298    }
299}