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}