clnrm_core/determinism/
mod.rs

1//! Determinism engine for reproducible test execution
2//!
3//! Provides infrastructure for deterministic test execution with:
4//! - Fixed random seeds for reproducible random number generation
5//! - Frozen clock timestamps for deterministic time operations
6//! - SHA-256 digest generation for trace verification
7//!
8//! # Examples
9//!
10//! ```no_run
11//! use clnrm_core::determinism::{DeterminismEngine, DeterminismConfig};
12//!
13//! let config = DeterminismConfig {
14//!     seed: Some(42),
15//!     freeze_clock: Some("2025-01-01T00:00:00Z".to_string()),
16//! };
17//!
18//! let engine = DeterminismEngine::new(config).unwrap();
19//! let timestamp = engine.get_timestamp();
20//! let random_value = engine.next_u64();
21//! ```
22
23pub mod digest;
24pub mod ports;
25pub mod rng;
26pub mod time;
27pub mod volumes;
28
29use crate::config::DeterminismConfig;
30use crate::error::{CleanroomError, Result};
31use chrono::{DateTime, Utc};
32use rand::RngCore;
33use std::sync::{Arc, Mutex};
34
35/// Determinism engine for reproducible test execution
36///
37/// This engine provides:
38/// - Seeded random number generation
39/// - Frozen clock timestamps
40/// - Deterministic port allocation
41/// - Hash-based volume naming
42/// - Digest generation for trace verification
43pub struct DeterminismEngine {
44    /// Configuration for determinism features
45    config: DeterminismConfig,
46    /// Seeded random number generator (thread-safe)
47    rng: Option<Arc<Mutex<Box<dyn RngCore + Send>>>>,
48    /// Frozen timestamp
49    frozen_time: Option<DateTime<Utc>>,
50    /// Port allocator for deterministic port assignment
51    port_allocator: Option<ports::PortAllocator>,
52}
53
54impl std::fmt::Debug for DeterminismEngine {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct("DeterminismEngine")
57            .field("config", &self.config)
58            .field("has_rng", &self.rng.is_some())
59            .field("frozen_time", &self.frozen_time)
60            .field("has_port_allocator", &self.port_allocator.is_some())
61            .finish()
62    }
63}
64
65impl DeterminismEngine {
66    /// Create new determinism engine from configuration
67    ///
68    /// # Arguments
69    /// * `config` - Determinism configuration with optional seed and freeze_clock
70    ///
71    /// # Returns
72    /// * `Result<Self>` - Initialized engine or error
73    ///
74    /// # Errors
75    /// * Returns error if freeze_clock is not valid RFC3339 format
76    pub fn new(config: DeterminismConfig) -> Result<Self> {
77        // Validate and parse freeze_clock if present
78        let frozen_time = if let Some(ref timestamp_str) = config.freeze_clock {
79            Some(Self::parse_timestamp(timestamp_str)?)
80        } else {
81            None
82        };
83
84        // Initialize RNG if seed is present
85        let rng = config
86            .seed
87            .map(|seed| Arc::new(Mutex::new(rng::create_seeded_rng(seed))));
88
89        // Initialize port allocator if deterministic ports enabled
90        let port_allocator = if config.has_deterministic_ports() {
91            Some(ports::PortAllocator::new())
92        } else {
93            None
94        };
95
96        Ok(Self {
97            config,
98            rng,
99            frozen_time,
100            port_allocator,
101        })
102    }
103
104    /// Parse RFC3339 timestamp string
105    fn parse_timestamp(timestamp_str: &str) -> Result<DateTime<Utc>> {
106        DateTime::parse_from_rfc3339(timestamp_str)
107            .map(|dt| dt.with_timezone(&Utc))
108            .map_err(|e| {
109                CleanroomError::deterministic_error(format!(
110                    "Invalid freeze_clock timestamp '{}': {}. Expected RFC3339 format (e.g., 2025-01-01T00:00:00Z)",
111                    timestamp_str, e
112                ))
113            })
114    }
115
116    /// Get current timestamp (frozen or actual)
117    ///
118    /// If freeze_clock is configured, returns the frozen timestamp.
119    /// Otherwise, returns the current system time.
120    pub fn get_timestamp(&self) -> DateTime<Utc> {
121        self.frozen_time.unwrap_or_else(Utc::now)
122    }
123
124    /// Get timestamp as RFC3339 string
125    pub fn get_timestamp_rfc3339(&self) -> String {
126        self.get_timestamp().to_rfc3339()
127    }
128
129    /// Generate next random u64 value
130    ///
131    /// If seed is configured, uses seeded RNG for deterministic values.
132    /// Otherwise, uses system randomness.
133    ///
134    /// # Returns
135    /// * Random u64 value
136    ///
137    /// # Errors
138    /// * Returns error if RNG mutex is poisoned (indicates panic in another thread)
139    pub fn next_u64(&self) -> Result<u64> {
140        if let Some(ref rng_mutex) = self.rng {
141            let mut rng = rng_mutex.lock().map_err(|e| {
142                CleanroomError::internal_error(format!(
143                    "Failed to acquire RNG lock - mutex poisoned by panic in another thread: {}",
144                    e
145                ))
146            })?;
147            Ok(rng.next_u64())
148        } else {
149            Ok(rand::random())
150        }
151    }
152
153    /// Generate next random u32 value
154    ///
155    /// # Errors
156    /// * Returns error if RNG mutex is poisoned (indicates panic in another thread)
157    pub fn next_u32(&self) -> Result<u32> {
158        if let Some(ref rng_mutex) = self.rng {
159            let mut rng = rng_mutex.lock().map_err(|e| {
160                CleanroomError::internal_error(format!(
161                    "Failed to acquire RNG lock - mutex poisoned by panic in another thread: {}",
162                    e
163                ))
164            })?;
165            Ok(rng.next_u32())
166        } else {
167            Ok(rand::random())
168        }
169    }
170
171    /// Fill buffer with random bytes
172    ///
173    /// # Errors
174    /// * Returns error if RNG mutex is poisoned (indicates panic in another thread)
175    pub fn fill_bytes(&self, dest: &mut [u8]) -> Result<()> {
176        if let Some(ref rng_mutex) = self.rng {
177            let mut rng = rng_mutex.lock().map_err(|e| {
178                CleanroomError::internal_error(format!(
179                    "Failed to acquire RNG lock - mutex poisoned by panic in another thread: {}",
180                    e
181                ))
182            })?;
183            rng.fill_bytes(dest);
184            Ok(())
185        } else {
186            rand::thread_rng().fill_bytes(dest);
187            Ok(())
188        }
189    }
190
191    /// Check if determinism is enabled
192    pub fn is_deterministic(&self) -> bool {
193        self.config.seed.is_some() || self.config.freeze_clock.is_some()
194    }
195
196    /// Check if seed is configured
197    pub fn has_seed(&self) -> bool {
198        self.config.seed.is_some()
199    }
200
201    /// Check if clock is frozen
202    pub fn has_frozen_clock(&self) -> bool {
203        self.config.freeze_clock.is_some()
204    }
205
206    /// Get the seed value if configured
207    pub fn get_seed(&self) -> Option<u64> {
208        self.config.seed
209    }
210
211    /// Get the frozen clock timestamp string if configured
212    pub fn get_frozen_clock(&self) -> Option<&str> {
213        self.config.freeze_clock.as_deref()
214    }
215
216    /// Get reference to configuration
217    pub fn config(&self) -> &DeterminismConfig {
218        &self.config
219    }
220
221    /// Allocate next deterministic port
222    ///
223    /// # Returns
224    /// * `Result<u16>` - Next port from the deterministic pool
225    ///
226    /// # Errors
227    /// * Returns error if deterministic ports not enabled
228    /// * Returns error if no ports available
229    pub fn allocate_port(&self) -> Result<u16> {
230        self.port_allocator
231            .as_ref()
232            .ok_or_else(|| {
233                CleanroomError::deterministic_error(
234                    "Deterministic ports not enabled. Set determinism.deterministic_ports = true in config"
235                )
236            })?
237            .allocate()
238    }
239
240    /// Release port back to the pool
241    ///
242    /// # Arguments
243    /// * `port` - Port to release
244    ///
245    /// # Errors
246    /// * Returns error if deterministic ports not enabled
247    /// * Returns error if port was not allocated
248    pub fn release_port(&self, port: u16) -> Result<()> {
249        self.port_allocator
250            .as_ref()
251            .ok_or_else(|| CleanroomError::deterministic_error("Deterministic ports not enabled"))?
252            .release(port)
253    }
254
255    /// Get list of allocated ports
256    ///
257    /// # Returns
258    /// * `Result<Vec<u16>>` - List of currently allocated ports
259    ///
260    /// # Errors
261    /// * Returns error if deterministic ports not enabled
262    pub fn allocated_ports(&self) -> Result<Vec<u16>> {
263        self.port_allocator
264            .as_ref()
265            .ok_or_else(|| CleanroomError::deterministic_error("Deterministic ports not enabled"))?
266            .allocated_ports()
267    }
268
269    /// Generate deterministic volume name
270    ///
271    /// Uses hash-based naming with test name and seed
272    ///
273    /// # Arguments
274    /// * `test_name` - Name of the test
275    ///
276    /// # Returns
277    /// * String - Deterministic volume name
278    pub fn generate_volume_name(&self, test_name: &str) -> String {
279        volumes::generate_volume_name(test_name, self.config.seed)
280    }
281
282    /// Generate deterministic container name
283    ///
284    /// # Arguments
285    /// * `test_name` - Name of the test
286    /// * `step_name` - Name of the step
287    ///
288    /// # Returns
289    /// * String - Deterministic container name
290    pub fn generate_container_name(&self, test_name: &str, step_name: &str) -> String {
291        volumes::generate_container_name(test_name, step_name, self.config.seed)
292    }
293
294    /// Generate deterministic network name
295    ///
296    /// # Arguments
297    /// * `test_name` - Name of the test
298    ///
299    /// # Returns
300    /// * String - Deterministic network name
301    pub fn generate_network_name(&self, test_name: &str) -> String {
302        volumes::generate_network_name(test_name, self.config.seed)
303    }
304
305    /// Get port pool as environment variable string
306    ///
307    /// Returns comma-separated list of ports for CLEANROOM_ALLOWED_PORTS
308    ///
309    /// # Returns
310    /// * `Result<String>` - Comma-separated port list
311    ///
312    /// # Errors
313    /// * Returns error if deterministic ports not enabled
314    pub fn get_port_pool_env(&self) -> Result<String> {
315        if self.port_allocator.is_some() {
316            Ok(ports::PortAllocator::default_ports_string())
317        } else {
318            Err(CleanroomError::deterministic_error(
319                "Deterministic ports not enabled",
320            ))
321        }
322    }
323}
324
325// Implement Clone for DeterminismEngine
326// Note: RNG state is not cloned; instead, each clone gets a fresh RNG with the same seed
327impl Clone for DeterminismEngine {
328    fn clone(&self) -> Self {
329        // SAFETY: This cannot fail because:
330        // 1. If config.freeze_clock exists, it was already validated in the original new() call
331        // 2. We're cloning the exact same config that was previously validated
332        // 3. The only error condition is invalid RFC3339 format, which we've already verified
333        Self {
334            config: self.config.clone(),
335            rng: self
336                .config
337                .seed
338                .map(|seed| Arc::new(Mutex::new(rng::create_seeded_rng(seed)))),
339            frozen_time: self.frozen_time,
340            port_allocator: self.port_allocator.clone(),
341        }
342    }
343}