1use serde::{Deserialize, Serialize};
70use std::collections::BTreeMap;
71
72pub const SCENARIO_SCHEMA_VERSION: u32 = 1;
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct Scenario {
82 #[serde(default = "default_schema_version")]
84 pub schema_version: u32,
85
86 pub id: String,
88
89 #[serde(default)]
91 pub description: String,
92
93 #[serde(default)]
95 pub lab: LabSection,
96
97 #[serde(default)]
99 pub chaos: ChaosSection,
100
101 #[serde(default)]
103 pub network: NetworkSection,
104
105 #[serde(default)]
107 pub faults: Vec<FaultEvent>,
108
109 #[serde(default)]
111 pub participants: Vec<Participant>,
112
113 #[serde(default = "default_oracles")]
115 pub oracles: Vec<String>,
116
117 #[serde(default)]
119 pub cancellation: Option<CancellationSection>,
120
121 #[serde(default)]
123 pub include: Vec<IncludeRef>,
124
125 #[serde(default)]
127 pub metadata: BTreeMap<String, String>,
128}
129
130fn default_schema_version() -> u32 {
131 SCENARIO_SCHEMA_VERSION
132}
133
134fn default_oracles() -> Vec<String> {
135 vec!["all".to_string()]
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct LabSection {
145 #[serde(default = "default_seed")]
147 pub seed: u64,
148
149 pub entropy_seed: Option<u64>,
151
152 #[serde(default = "default_worker_count")]
154 pub worker_count: usize,
155
156 #[serde(default = "default_trace_capacity")]
158 pub trace_capacity: usize,
159
160 #[serde(default = "default_max_steps")]
162 pub max_steps: Option<u64>,
163
164 #[serde(default = "default_true")]
166 pub panic_on_obligation_leak: bool,
167
168 #[serde(default = "default_true")]
170 pub panic_on_futurelock: bool,
171
172 #[serde(default = "default_futurelock_max_idle")]
174 pub futurelock_max_idle_steps: u64,
175
176 #[serde(default)]
178 pub replay_recording: bool,
179}
180
181impl Default for LabSection {
182 fn default() -> Self {
183 Self {
184 seed: 42,
185 entropy_seed: None,
186 worker_count: 1,
187 trace_capacity: 4096,
188 max_steps: Some(100_000),
189 panic_on_obligation_leak: true,
190 panic_on_futurelock: true,
191 futurelock_max_idle_steps: 10_000,
192 replay_recording: false,
193 }
194 }
195}
196
197fn default_seed() -> u64 {
198 42
199}
200fn default_worker_count() -> usize {
201 1
202}
203fn default_trace_capacity() -> usize {
204 4096
205}
206#[allow(clippy::unnecessary_wraps)]
207fn default_max_steps() -> Option<u64> {
208 Some(100_000)
209}
210fn default_true() -> bool {
211 true
212}
213fn default_futurelock_max_idle() -> u64 {
214 10_000
215}
216
217#[derive(Debug, Clone, Default, Serialize, Deserialize)]
223#[serde(tag = "preset", rename_all = "snake_case")]
224pub enum ChaosSection {
225 #[default]
227 Off,
228 Light,
230 Heavy,
232 Custom {
234 #[serde(default)]
236 cancel_probability: f64,
237 #[serde(default)]
239 delay_probability: f64,
240 #[serde(default)]
242 delay_min_ms: u64,
243 #[serde(default = "default_delay_max_ms")]
245 delay_max_ms: u64,
246 #[serde(default)]
248 io_error_probability: f64,
249 #[serde(default)]
251 wakeup_storm_probability: f64,
252 #[serde(default)]
254 budget_exhaustion_probability: f64,
255 },
256}
257
258fn default_delay_max_ms() -> u64 {
259 10
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct NetworkSection {
269 #[serde(default = "default_network_preset")]
271 pub preset: NetworkPreset,
272
273 #[serde(default)]
275 pub links: BTreeMap<String, LinkConditions>,
276}
277
278impl Default for NetworkSection {
279 fn default() -> Self {
280 Self {
281 preset: NetworkPreset::Ideal,
282 links: BTreeMap::new(),
283 }
284 }
285}
286
287fn default_network_preset() -> NetworkPreset {
288 NetworkPreset::Ideal
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum NetworkPreset {
295 Ideal,
297 Local,
299 Lan,
301 Wan,
303 Satellite,
305 Congested,
307 Lossy,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct LinkConditions {
314 #[serde(default)]
316 pub latency: Option<LatencySpec>,
317 #[serde(default)]
319 pub packet_loss: Option<f64>,
320 #[serde(default)]
322 pub packet_corrupt: Option<f64>,
323 #[serde(default)]
325 pub packet_duplicate: Option<f64>,
326 #[serde(default)]
328 pub packet_reorder: Option<f64>,
329 #[serde(default)]
331 pub bandwidth: Option<u64>,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
336#[serde(tag = "model", rename_all = "snake_case")]
337pub enum LatencySpec {
338 Fixed {
340 ms: u64,
342 },
343 Uniform {
345 min_ms: u64,
347 max_ms: u64,
349 },
350 Normal {
352 mean_ms: u64,
354 stddev_ms: u64,
356 },
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct FaultEvent {
366 pub at_ms: u64,
368
369 pub action: FaultAction,
371
372 #[serde(default)]
374 pub args: BTreeMap<String, serde_json::Value>,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
379#[serde(rename_all = "snake_case")]
380pub enum FaultAction {
381 Partition,
383 Heal,
385 HostCrash,
387 HostRestart,
389 ClockSkew,
391 ClockReset,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct Participant {
402 pub name: String,
404
405 #[serde(default)]
407 pub role: String,
408
409 #[serde(default)]
411 pub properties: BTreeMap<String, serde_json::Value>,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct CancellationSection {
421 pub strategy: CancellationStrategy,
423
424 #[serde(default)]
426 pub count: Option<usize>,
427
428 #[serde(default)]
430 pub probability: Option<f64>,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
435#[serde(rename_all = "snake_case")]
436pub enum CancellationStrategy {
437 Never,
439 AllPoints,
441 RandomSample,
443 FirstN,
445 LastN,
447 EveryNth,
449 Probabilistic,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct IncludeRef {
460 pub path: String,
462}
463
464#[derive(Debug, Clone)]
470pub struct ValidationError {
471 pub field: String,
473 pub message: String,
475}
476
477impl std::fmt::Display for ValidationError {
478 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
479 write!(f, "{}: {}", self.field, self.message)
480 }
481}
482
483impl std::error::Error for ValidationError {}
484
485impl Scenario {
486 #[must_use]
490 pub fn validate(&self) -> Vec<ValidationError> {
491 let mut errors = Vec::new();
492 self.validate_header(&mut errors);
493 self.validate_chaos(&mut errors);
494 self.validate_network(&mut errors);
495 self.validate_faults(&mut errors);
496 self.validate_participants(&mut errors);
497 self.validate_cancellation(&mut errors);
498 errors
499 }
500
501 fn validate_header(&self, errors: &mut Vec<ValidationError>) {
502 if self.schema_version != SCENARIO_SCHEMA_VERSION {
503 errors.push(ValidationError {
504 field: "schema_version".into(),
505 message: format!(
506 "unsupported version {}, expected {SCENARIO_SCHEMA_VERSION}",
507 self.schema_version
508 ),
509 });
510 }
511 if self.id.is_empty() {
512 errors.push(ValidationError {
513 field: "id".into(),
514 message: "scenario id must not be empty".into(),
515 });
516 }
517 if self.lab.worker_count == 0 {
518 errors.push(ValidationError {
519 field: "lab.worker_count".into(),
520 message: "worker_count must be >= 1".into(),
521 });
522 }
523 if self.lab.trace_capacity == 0 {
524 errors.push(ValidationError {
525 field: "lab.trace_capacity".into(),
526 message: "trace_capacity must be > 0".into(),
527 });
528 }
529 }
530
531 fn validate_chaos(&self, errors: &mut Vec<ValidationError>) {
532 if let ChaosSection::Custom {
533 cancel_probability,
534 delay_probability,
535 io_error_probability,
536 wakeup_storm_probability,
537 budget_exhaustion_probability,
538 ..
539 } = &self.chaos
540 {
541 for (name, val) in [
542 ("chaos.cancel_probability", cancel_probability),
543 ("chaos.delay_probability", delay_probability),
544 ("chaos.io_error_probability", io_error_probability),
545 ("chaos.wakeup_storm_probability", wakeup_storm_probability),
546 (
547 "chaos.budget_exhaustion_probability",
548 budget_exhaustion_probability,
549 ),
550 ] {
551 if !(0.0..=1.0).contains(val) {
552 errors.push(ValidationError {
553 field: name.into(),
554 message: format!("probability must be in [0.0, 1.0], got {val}"),
555 });
556 }
557 }
558 }
559 }
560
561 fn validate_network(&self, errors: &mut Vec<ValidationError>) {
562 for key in self.network.links.keys() {
563 if !key.contains("->") {
564 errors.push(ValidationError {
565 field: format!("network.links.{key}"),
566 message: "link key must be in format \"from->to\"".into(),
567 });
568 }
569 }
570 }
571
572 fn validate_faults(&self, errors: &mut Vec<ValidationError>) {
573 for window in self.faults.windows(2) {
574 if window[1].at_ms < window[0].at_ms {
575 errors.push(ValidationError {
576 field: "faults".into(),
577 message: format!(
578 "fault events must be ordered by at_ms: {} comes before {}",
579 window[0].at_ms, window[1].at_ms
580 ),
581 });
582 break;
583 }
584 }
585 }
586
587 fn validate_participants(&self, errors: &mut Vec<ValidationError>) {
588 let mut seen_names = std::collections::HashSet::new();
589 for p in &self.participants {
590 if !seen_names.insert(&p.name) {
591 errors.push(ValidationError {
592 field: format!("participants.{}", p.name),
593 message: "duplicate participant name".into(),
594 });
595 }
596 }
597 }
598
599 fn validate_cancellation(&self, errors: &mut Vec<ValidationError>) {
600 let Some(ref cancel) = self.cancellation else {
601 return;
602 };
603 match cancel.strategy {
604 CancellationStrategy::RandomSample
605 | CancellationStrategy::FirstN
606 | CancellationStrategy::LastN
607 | CancellationStrategy::EveryNth => {
608 if cancel.count.is_none() {
609 errors.push(ValidationError {
610 field: "cancellation.count".into(),
611 message: format!(
612 "strategy {:?} requires a count parameter",
613 cancel.strategy
614 ),
615 });
616 }
617 }
618 CancellationStrategy::Probabilistic => {
619 if cancel.probability.is_none() {
620 errors.push(ValidationError {
621 field: "cancellation.probability".into(),
622 message: "strategy probabilistic requires a probability parameter".into(),
623 });
624 }
625 }
626 CancellationStrategy::Never | CancellationStrategy::AllPoints => {}
627 }
628 }
629
630 #[must_use]
632 pub fn to_lab_config(&self) -> super::config::LabConfig {
633 let mut config = super::config::LabConfig::new(self.lab.seed)
634 .worker_count(self.lab.worker_count)
635 .trace_capacity(self.lab.trace_capacity)
636 .panic_on_leak(self.lab.panic_on_obligation_leak)
637 .panic_on_futurelock(self.lab.panic_on_futurelock)
638 .futurelock_max_idle_steps(self.lab.futurelock_max_idle_steps);
639
640 if let Some(entropy) = self.lab.entropy_seed {
641 config = config.entropy_seed(entropy);
642 }
643
644 if let Some(max) = self.lab.max_steps {
645 config = config.max_steps(max);
646 } else {
647 config = config.no_step_limit();
648 }
649
650 config = match &self.chaos {
652 ChaosSection::Off => config,
653 ChaosSection::Light => config.with_light_chaos(),
654 ChaosSection::Heavy => config.with_heavy_chaos(),
655 ChaosSection::Custom {
656 cancel_probability,
657 delay_probability,
658 delay_min_ms,
659 delay_max_ms,
660 io_error_probability,
661 wakeup_storm_probability,
662 budget_exhaustion_probability,
663 } => {
664 use std::time::Duration;
665 let chaos = super::chaos::ChaosConfig::new(self.lab.seed)
666 .with_cancel_probability(*cancel_probability)
667 .with_delay_probability(*delay_probability)
668 .with_delay_range(
669 Duration::from_millis(*delay_min_ms)..Duration::from_millis(*delay_max_ms),
670 )
671 .with_io_error_probability(*io_error_probability)
672 .with_wakeup_storm_probability(*wakeup_storm_probability)
673 .with_budget_exhaust_probability(*budget_exhaustion_probability);
674 config.with_chaos(chaos)
675 }
676 };
677
678 if self.lab.replay_recording {
679 config = config.with_default_replay_recording();
680 }
681
682 config
683 }
684
685 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
691 serde_json::from_str(json)
692 }
693
694 pub fn to_json(&self) -> Result<String, serde_json::Error> {
700 serde_json::to_string_pretty(self)
701 }
702}
703
704#[cfg(test)]
709mod tests {
710 use super::*;
711
712 fn minimal_json() -> &'static str {
713 r#"{
714 "id": "test-scenario",
715 "description": "minimal test"
716 }"#
717 }
718
719 #[test]
720 fn parse_minimal_scenario() {
721 let s: Scenario = serde_json::from_str(minimal_json()).unwrap();
722 assert_eq!(s.id, "test-scenario");
723 assert_eq!(s.schema_version, 1);
724 assert_eq!(s.lab.seed, 42);
725 assert_eq!(s.lab.worker_count, 1);
726 assert!(s.faults.is_empty());
727 assert!(s.participants.is_empty());
728 assert_eq!(s.oracles, vec!["all"]);
729 }
730
731 #[test]
732 fn validate_minimal_scenario() {
733 let s: Scenario = serde_json::from_str(minimal_json()).unwrap();
734 let errors = s.validate();
735 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
736 }
737
738 #[test]
739 fn validate_empty_id_rejected() {
740 let json = r#"{"id": "", "description": "bad"}"#;
741 let s: Scenario = serde_json::from_str(json).unwrap();
742 let errors = s.validate();
743 assert!(errors.iter().any(|e| e.field == "id"));
744 }
745
746 #[test]
747 fn validate_bad_schema_version() {
748 let json = r#"{"schema_version": 99, "id": "x"}"#;
749 let s: Scenario = serde_json::from_str(json).unwrap();
750 let errors = s.validate();
751 assert!(errors.iter().any(|e| e.field == "schema_version"));
752 }
753
754 #[test]
755 fn parse_chaos_preset_light() {
756 let json = r#"{"id": "x", "chaos": {"preset": "light"}}"#;
757 let s: Scenario = serde_json::from_str(json).unwrap();
758 assert!(matches!(s.chaos, ChaosSection::Light));
759 }
760
761 #[test]
762 fn parse_chaos_custom() {
763 let json = r#"{
764 "id": "x",
765 "chaos": {
766 "preset": "custom",
767 "cancel_probability": 0.05,
768 "delay_probability": 0.3,
769 "io_error_probability": 0.1
770 }
771 }"#;
772 let s: Scenario = serde_json::from_str(json).unwrap();
773 match s.chaos {
774 ChaosSection::Custom {
775 cancel_probability,
776 delay_probability,
777 io_error_probability,
778 ..
779 } => {
780 assert!((cancel_probability - 0.05).abs() < f64::EPSILON);
781 assert!((delay_probability - 0.3).abs() < f64::EPSILON);
782 assert!((io_error_probability - 0.1).abs() < f64::EPSILON);
783 }
784 other => panic!("expected Custom, got {other:?}"),
785 }
786 }
787
788 #[test]
789 fn validate_chaos_bad_probability() {
790 let json = r#"{
791 "id": "x",
792 "chaos": {"preset": "custom", "cancel_probability": 1.5}
793 }"#;
794 let s: Scenario = serde_json::from_str(json).unwrap();
795 let errors = s.validate();
796 assert!(errors.iter().any(|e| e.field == "chaos.cancel_probability"));
797 }
798
799 #[test]
800 fn parse_network_preset_wan() {
801 let json = r#"{"id": "x", "network": {"preset": "wan"}}"#;
802 let s: Scenario = serde_json::from_str(json).unwrap();
803 assert_eq!(s.network.preset, NetworkPreset::Wan);
804 }
805
806 #[test]
807 fn parse_network_link_override() {
808 let json = r#"{
809 "id": "x",
810 "network": {
811 "preset": "lan",
812 "links": {
813 "alice->bob": { "packet_loss": 0.5 }
814 }
815 }
816 }"#;
817 let s: Scenario = serde_json::from_str(json).unwrap();
818 let link = s.network.links.get("alice->bob").unwrap();
819 assert!((link.packet_loss.unwrap() - 0.5).abs() < f64::EPSILON);
820 }
821
822 #[test]
823 fn validate_bad_link_key() {
824 let json = r#"{
825 "id": "x",
826 "network": {"links": {"alice_bob": {}}}
827 }"#;
828 let s: Scenario = serde_json::from_str(json).unwrap();
829 let errors = s.validate();
830 assert!(errors.iter().any(|e| e.field.contains("network.links")));
831 }
832
833 #[test]
834 fn parse_fault_events() {
835 let json = r#"{
836 "id": "x",
837 "faults": [
838 {"at_ms": 100, "action": "partition", "args": {"from": "a", "to": "b"}},
839 {"at_ms": 500, "action": "heal", "args": {"from": "a", "to": "b"}}
840 ]
841 }"#;
842 let s: Scenario = serde_json::from_str(json).unwrap();
843 assert_eq!(s.faults.len(), 2);
844 assert_eq!(s.faults[0].at_ms, 100);
845 assert!(matches!(s.faults[0].action, FaultAction::Partition));
846 assert_eq!(s.faults[1].at_ms, 500);
847 assert!(matches!(s.faults[1].action, FaultAction::Heal));
848 }
849
850 #[test]
851 fn validate_unordered_faults() {
852 let json = r#"{
853 "id": "x",
854 "faults": [
855 {"at_ms": 500, "action": "partition"},
856 {"at_ms": 100, "action": "heal"}
857 ]
858 }"#;
859 let s: Scenario = serde_json::from_str(json).unwrap();
860 let errors = s.validate();
861 assert!(errors.iter().any(|e| e.field == "faults"));
862 }
863
864 #[test]
865 fn parse_participants() {
866 let json = r#"{
867 "id": "x",
868 "participants": [
869 {"name": "alice", "role": "sender"},
870 {"name": "bob", "role": "receiver"}
871 ]
872 }"#;
873 let s: Scenario = serde_json::from_str(json).unwrap();
874 assert_eq!(s.participants.len(), 2);
875 assert_eq!(s.participants[0].name, "alice");
876 assert_eq!(s.participants[1].role, "receiver");
877 }
878
879 #[test]
880 fn validate_duplicate_participant() {
881 let json = r#"{
882 "id": "x",
883 "participants": [
884 {"name": "alice"},
885 {"name": "alice"}
886 ]
887 }"#;
888 let s: Scenario = serde_json::from_str(json).unwrap();
889 let errors = s.validate();
890 assert!(errors.iter().any(|e| e.message.contains("duplicate")));
891 }
892
893 #[test]
894 fn parse_cancellation_strategy() {
895 let json = r#"{
896 "id": "x",
897 "cancellation": {
898 "strategy": "random_sample",
899 "count": 100
900 }
901 }"#;
902 let s: Scenario = serde_json::from_str(json).unwrap();
903 let cancel = s.cancellation.as_ref().unwrap();
904 assert!(matches!(
905 cancel.strategy,
906 CancellationStrategy::RandomSample
907 ));
908 assert_eq!(cancel.count, Some(100));
909 }
910
911 #[test]
912 fn validate_missing_count() {
913 let json = r#"{
914 "id": "x",
915 "cancellation": {"strategy": "random_sample"}
916 }"#;
917 let s: Scenario = serde_json::from_str(json).unwrap();
918 let errors = s.validate();
919 assert!(errors.iter().any(|e| e.field == "cancellation.count"));
920 }
921
922 #[test]
923 fn to_lab_config_defaults() {
924 let s: Scenario = serde_json::from_str(minimal_json()).unwrap();
925 let config = s.to_lab_config();
926 assert_eq!(config.seed, 42);
927 assert_eq!(config.worker_count, 1);
928 assert_eq!(config.trace_capacity, 4096);
929 assert!(config.panic_on_obligation_leak);
930 }
931
932 #[test]
933 fn to_lab_config_chaos_light() {
934 let json = r#"{"id": "x", "chaos": {"preset": "light"}}"#;
935 let s: Scenario = serde_json::from_str(json).unwrap();
936 let config = s.to_lab_config();
937 assert!(config.has_chaos());
938 }
939
940 #[test]
941 fn to_lab_config_custom_seed() {
942 let json = r#"{"id": "x", "lab": {"seed": 12345, "worker_count": 4}}"#;
943 let s: Scenario = serde_json::from_str(json).unwrap();
944 let config = s.to_lab_config();
945 assert_eq!(config.seed, 12345);
946 assert_eq!(config.worker_count, 4);
947 }
948
949 #[test]
950 fn json_roundtrip() {
951 let json = r#"{
952 "id": "roundtrip-test",
953 "description": "full roundtrip",
954 "lab": {"seed": 99, "worker_count": 2},
955 "chaos": {"preset": "heavy"},
956 "network": {"preset": "wan"},
957 "participants": [{"name": "alice", "role": "sender"}],
958 "faults": [{"at_ms": 100, "action": "partition"}]
959 }"#;
960 let s1: Scenario = serde_json::from_str(json).unwrap();
961 let serialized = s1.to_json().unwrap();
962 let s2: Scenario = Scenario::from_json(&serialized).unwrap();
963 assert_eq!(s1.id, s2.id);
964 assert_eq!(s1.lab.seed, s2.lab.seed);
965 assert_eq!(s1.participants.len(), s2.participants.len());
966 assert_eq!(s1.faults.len(), s2.faults.len());
967 }
968
969 #[test]
970 fn parse_metadata() {
971 let json = r#"{
972 "id": "x",
973 "metadata": {"git_sha": "abc123", "author": "bot"}
974 }"#;
975 let s: Scenario = serde_json::from_str(json).unwrap();
976 assert_eq!(s.metadata.get("git_sha").unwrap(), "abc123");
977 }
978
979 #[test]
980 fn parse_latency_models() {
981 let json = r#"{
982 "id": "x",
983 "network": {
984 "preset": "ideal",
985 "links": {
986 "a->b": {"latency": {"model": "fixed", "ms": 5}},
987 "b->c": {"latency": {"model": "uniform", "min_ms": 1, "max_ms": 10}},
988 "c->d": {"latency": {"model": "normal", "mean_ms": 50, "stddev_ms": 10}}
989 }
990 }
991 }"#;
992 let s: Scenario = serde_json::from_str(json).unwrap();
993 assert_eq!(s.network.links.len(), 3);
994 let ab = s.network.links.get("a->b").unwrap();
995 assert!(matches!(ab.latency, Some(LatencySpec::Fixed { ms: 5 })));
996 }
997
998 #[test]
999 fn parse_include() {
1000 let json = r#"{
1001 "id": "x",
1002 "include": [{"path": "base.yaml"}]
1003 }"#;
1004 let s: Scenario = serde_json::from_str(json).unwrap();
1005 assert_eq!(s.include.len(), 1);
1006 assert_eq!(s.include[0].path, "base.yaml");
1007 }
1008}