use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FaultConfig {
#[serde(default)]
pub fs: FsFaultConfig,
#[serde(default)]
pub clock: ClockFaultConfig,
#[serde(default)]
pub alloc: AllocFaultConfig,
#[serde(default)]
pub process: ProcessFaultConfig,
#[serde(default)]
pub network: NetworkFaultConfig,
#[serde(default)]
pub threading: ThreadSchedulingConfig,
}
impl FaultConfig {
pub fn none() -> Self {
Self {
fs: FsFaultConfig::default(),
clock: ClockFaultConfig::default(),
alloc: AllocFaultConfig::default(),
process: ProcessFaultConfig::default(),
network: NetworkFaultConfig::default(),
threading: ThreadSchedulingConfig::default(),
}
}
pub fn merge(base: &FaultConfig, overlay: &FaultConfig) -> FaultConfig {
FaultConfig {
fs: FsFaultConfig {
rules: [base.fs.rules.clone(), overlay.fs.rules.clone()].concat(),
},
clock: if overlay.clock != ClockFaultConfig::default() {
overlay.clock.clone()
} else {
base.clock.clone()
},
alloc: if overlay.alloc != AllocFaultConfig::default() {
overlay.alloc.clone()
} else {
base.alloc.clone()
},
process: ProcessFaultConfig {
rules: [base.process.rules.clone(), overlay.process.rules.clone()].concat(),
},
network: NetworkFaultConfig {
rules: [base.network.rules.clone(), overlay.network.rules.clone()].concat(),
},
threading: if overlay.threading != ThreadSchedulingConfig::default() {
overlay.threading.clone()
} else {
base.threading.clone()
},
}
}
pub fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
for (i, rule) in self.fs.rules.iter().enumerate() {
if rule.probability < 0.0 || rule.probability > 1.0 {
errors.push(ConfigError {
path: format!("fs.rules[{i}].probability"),
message: format!(
"probability must be in [0.0, 1.0], got {}",
rule.probability
),
});
}
if rule.path.is_empty() {
errors.push(ConfigError {
path: format!("fs.rules[{i}].path"),
message: "path pattern must not be empty".into(),
});
}
}
if self.alloc.fail_probability < 0.0 || self.alloc.fail_probability > 1.0 {
errors.push(ConfigError {
path: "alloc.fail_probability".into(),
message: format!("must be in [0.0, 1.0], got {}", self.alloc.fail_probability),
});
}
if self.clock.jitter_us < 0 {
errors.push(ConfigError {
path: "clock.jitter_us".into(),
message: format!("jitter must be non-negative, got {}", self.clock.jitter_us),
});
}
for (i, rule) in self.process.rules.iter().enumerate() {
if rule.command.is_empty() {
errors.push(ConfigError {
path: format!("process.rules[{i}].command"),
message: "command pattern must not be empty".into(),
});
}
}
if self.threading.preempt_probability < 0.0 || self.threading.preempt_probability > 1.0 {
errors.push(ConfigError {
path: "threading.preempt_probability".into(),
message: format!(
"must be in [0.0, 1.0], got {}",
self.threading.preempt_probability
),
});
}
errors
}
pub fn has_faults(&self) -> bool {
!self.fs.rules.is_empty()
|| self.clock != ClockFaultConfig::default()
|| self.alloc != AllocFaultConfig::default()
|| !self.process.rules.is_empty()
|| !self.network.rules.is_empty()
|| self.threading.is_enabled()
}
}
impl Default for FaultConfig {
fn default() -> Self {
Self::none()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct FsFaultConfig {
#[serde(default)]
pub rules: Vec<FsFaultRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FsFaultRule {
pub path: String,
#[serde(default)]
pub op: FsOp,
#[serde(default)]
pub error: FsError,
#[serde(default)]
pub after_bytes: u64,
#[serde(default = "default_probability")]
pub probability: f64,
}
fn default_probability() -> f64 {
1.0
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FsOp {
Read,
Write,
Open,
Fsync,
Rename,
Delete,
#[default]
Any,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FsError {
Enospc,
#[default]
Eio,
Eacces,
TornWrite,
Corrupt,
DelayedFsync,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ClockFaultConfig {
#[serde(default)]
pub drift_us_per_sec: i64,
#[serde(default)]
pub jitter_us: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AllocFaultConfig {
#[serde(default)]
pub fail_probability: f64,
#[serde(default)]
pub hard_limit_bytes: u64,
#[serde(default)]
pub min_fail_size: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ProcessFaultConfig {
#[serde(default)]
pub rules: Vec<ProcessFaultRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProcessFaultRule {
pub command: String,
#[serde(default)]
pub fault: ProcessFault,
#[serde(default)]
pub after_us: u64,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProcessFault {
Hang,
Sigkill,
CorruptStdout,
#[default]
ExitError,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct NetworkFaultConfig {
#[serde(default)]
pub rules: Vec<NetworkFaultRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NetworkFaultRule {
pub target: String,
#[serde(default)]
pub fault: NetworkFault,
#[serde(default = "default_probability")]
pub probability: f64,
#[serde(default)]
pub limit_bytes_per_sec: u64,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NetworkFault {
#[default]
ConnRefused,
ConnTimeout,
PacketDrop,
PacketDelay,
PacketCorrupt,
BandwidthLimit,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ThreadSchedulingConfig {
#[serde(default)]
pub strategy: SchedulingStrategy,
#[serde(default)]
pub preempt_probability: f64,
}
impl Default for ThreadSchedulingConfig {
fn default() -> Self {
Self {
strategy: SchedulingStrategy::default(),
preempt_probability: 0.0,
}
}
}
impl ThreadSchedulingConfig {
pub fn is_enabled(&self) -> bool {
self.preempt_probability > 0.0
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SchedulingStrategy {
#[default]
Random,
RoundRobin,
Adversarial,
}
#[derive(Debug, Clone)]
pub struct ConfigError {
pub path: String,
pub message: String,
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.path, self.message)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_has_no_faults() {
let cfg = FaultConfig::none();
assert!(!cfg.has_faults());
assert!(cfg.validate().is_empty());
}
#[test]
fn test_config_with_fs_fault() {
let cfg = FaultConfig {
fs: FsFaultConfig {
rules: vec![FsFaultRule {
path: "*.wal".into(),
op: FsOp::Write,
error: FsError::Enospc,
after_bytes: 4096,
probability: 0.05,
}],
},
..FaultConfig::none()
};
assert!(cfg.has_faults());
assert!(cfg.validate().is_empty());
}
#[test]
fn test_validate_bad_probability() {
let cfg = FaultConfig {
fs: FsFaultConfig {
rules: vec![FsFaultRule {
path: "*.log".into(),
op: FsOp::Write,
error: FsError::Eio,
after_bytes: 0,
probability: 1.5, }],
},
..FaultConfig::none()
};
let errors = cfg.validate();
assert_eq!(errors.len(), 1);
assert!(errors[0].path.contains("probability"));
}
#[test]
fn test_validate_empty_path() {
let cfg = FaultConfig {
fs: FsFaultConfig {
rules: vec![FsFaultRule {
path: "".into(), op: FsOp::Read,
error: FsError::Eio,
after_bytes: 0,
probability: 0.5,
}],
},
..FaultConfig::none()
};
let errors = cfg.validate();
assert_eq!(errors.len(), 1);
assert!(errors[0].path.contains("path"));
}
#[test]
fn test_merge_concatenates_rules() {
let base = FaultConfig {
fs: FsFaultConfig {
rules: vec![FsFaultRule {
path: "*.wal".into(),
op: FsOp::Write,
error: FsError::Enospc,
after_bytes: 4096,
probability: 0.05,
}],
},
..FaultConfig::none()
};
let overlay = FaultConfig {
fs: FsFaultConfig {
rules: vec![FsFaultRule {
path: "*.log".into(),
op: FsOp::Read,
error: FsError::Eio,
after_bytes: 0,
probability: 0.01,
}],
},
..FaultConfig::none()
};
let merged = FaultConfig::merge(&base, &overlay);
assert_eq!(merged.fs.rules.len(), 2);
assert_eq!(merged.fs.rules[0].path, "*.wal");
assert_eq!(merged.fs.rules[1].path, "*.log");
}
#[test]
fn test_merge_overlay_clock_takes_precedence() {
let base = FaultConfig {
clock: ClockFaultConfig {
drift_us_per_sec: 100,
jitter_us: 50,
},
..FaultConfig::none()
};
let overlay = FaultConfig {
clock: ClockFaultConfig {
drift_us_per_sec: 200,
jitter_us: 10,
},
..FaultConfig::none()
};
let merged = FaultConfig::merge(&base, &overlay);
assert_eq!(merged.clock.drift_us_per_sec, 200);
assert_eq!(merged.clock.jitter_us, 10);
}
#[test]
fn test_serde_roundtrip() {
let cfg = FaultConfig {
fs: FsFaultConfig {
rules: vec![FsFaultRule {
path: "/data/*.wal".into(),
op: FsOp::Write,
error: FsError::TornWrite,
after_bytes: 1024,
probability: 0.02,
}],
},
clock: ClockFaultConfig {
drift_us_per_sec: 200,
jitter_us: 50,
},
alloc: AllocFaultConfig {
fail_probability: 0.001,
hard_limit_bytes: 67_108_864,
min_fail_size: 0,
},
process: ProcessFaultConfig {
rules: vec![ProcessFaultRule {
command: "gcc".into(),
fault: ProcessFault::Hang,
after_us: 5_000_000,
}],
},
network: NetworkFaultConfig {
rules: vec![NetworkFaultRule {
target: "127.0.0.1:5432".into(),
fault: NetworkFault::ConnTimeout,
probability: 0.1,
limit_bytes_per_sec: 0,
}],
},
threading: ThreadSchedulingConfig {
strategy: SchedulingStrategy::Random,
preempt_probability: 0.1,
},
};
let json = serde_json::to_string_pretty(&cfg).unwrap();
let parsed: FaultConfig = serde_json::from_str(&json).unwrap();
assert_eq!(cfg, parsed);
}
#[test]
fn test_threading_config_default_disabled() {
let cfg = FaultConfig::none();
assert!(!cfg.threading.is_enabled());
assert_eq!(cfg.threading.strategy, SchedulingStrategy::Random);
assert_eq!(cfg.threading.preempt_probability, 0.0);
}
#[test]
fn test_threading_config_enabled() {
let cfg = FaultConfig {
threading: ThreadSchedulingConfig {
strategy: SchedulingStrategy::Adversarial,
preempt_probability: 0.5,
},
..FaultConfig::none()
};
assert!(cfg.threading.is_enabled());
assert!(cfg.has_faults());
}
#[test]
fn test_validate_bad_preempt_probability() {
let cfg = FaultConfig {
threading: ThreadSchedulingConfig {
strategy: SchedulingStrategy::Random,
preempt_probability: 1.5,
},
..FaultConfig::none()
};
let errors = cfg.validate();
assert_eq!(errors.len(), 1);
assert!(errors[0].path.contains("threading"));
}
#[test]
fn test_merge_threading_overlay() {
let base = FaultConfig::none();
let overlay = FaultConfig {
threading: ThreadSchedulingConfig {
strategy: SchedulingStrategy::RoundRobin,
preempt_probability: 0.3,
},
..FaultConfig::none()
};
let merged = FaultConfig::merge(&base, &overlay);
assert_eq!(merged.threading.strategy, SchedulingStrategy::RoundRobin);
assert_eq!(merged.threading.preempt_probability, 0.3);
}
#[test]
fn test_serde_threading_roundtrip_json() {
let json = r#"{
"threading": {
"strategy": "adversarial",
"preempt_probability": 0.25
}
}"#;
let cfg: FaultConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.threading.strategy, SchedulingStrategy::Adversarial);
assert!((cfg.threading.preempt_probability - 0.25).abs() < f64::EPSILON);
}
}