Skip to main content

rch_common/e2e/
fault_injection.rs

1//! Reusable fault-injection fixtures for RCH remediation scenarios
2//! (bd-session-history-remediation-ocv9i.16.2).
3//!
4//! Each [`FaultFixture`] sets up the filesystem / mock state needed to drive a
5//! single remediation failure class through the real code paths, **without any
6//! real destructive operation**: everything lives under a private
7//! [`tempfile::TempDir`], so cleanup (on `Drop`) is bounded to that root and can
8//! never touch files outside it. Setup and teardown emit structured `tracing`
9//! events on `target: "rch::e2e::fault"` so a test or E2E script can prove the
10//! fixture was installed and torn down.
11//!
12//! Fixtures are usable from unit tests, integration tests, and E2E scripts, and
13//! need no real Contabo/VMI worker — runtime-behavioral faults (worker
14//! unreachable, daemon socket refusal) are expressed as mock SSH config / dead
15//! socket paths that the production code treats identically to the real fault.
16//!
17//! Each scenario maps to a stable [`IncidentReasonCode`] so fixtures and the
18//! incident vocabulary stay in lockstep.
19
20use std::path::{Path, PathBuf};
21
22use crate::incident::IncidentReasonCode;
23use crate::types::{WorkerConfig, WorkerId};
24
25/// A fault class this module can inject.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum FaultScenario {
28    /// Daemon Unix socket is absent — connections are refused.
29    DaemonSocketRefused,
30    /// A stale socket file exists but no live listener is behind it.
31    StaleSocket,
32    /// A worker host is unreachable over SSH.
33    WorkerUnreachable,
34    /// The deployed `rch-wkr` is the wrong architecture / at the wrong path.
35    WrongWorkerBinaryPath,
36    /// `rustup` is absent from the resolved PATH.
37    MissingRustup,
38    /// The wasm target is not installed in the toolchain.
39    MissingWasmTarget,
40    /// Disk byte capacity is exhausted.
41    DiskBytesExhausted,
42    /// Disk inode capacity is exhausted.
43    DiskInodesExhausted,
44    /// Worker telemetry has gone stale / is missing.
45    TelemetryGap,
46    /// The active project root is excluded from offload.
47    ActiveProjectExclusion,
48    /// A source file vanishes mid-transfer (rsync vanished-file race).
49    RsyncVanishedFile,
50    /// An expected artifact is missing under a rewritten target dir.
51    ArtifactMissingRewrittenTarget,
52    /// A queued job's owning process is gone (orphan).
53    QueuedJobOrphan,
54    /// The source changes while a proof intent is queued.
55    SourceChangedWhileProofQueued,
56}
57
58impl FaultScenario {
59    /// Stable snake_case identifier (for logs and scenario selection).
60    #[must_use]
61    pub const fn as_str(self) -> &'static str {
62        match self {
63            Self::DaemonSocketRefused => "daemon_socket_refused",
64            Self::StaleSocket => "stale_socket",
65            Self::WorkerUnreachable => "worker_unreachable",
66            Self::WrongWorkerBinaryPath => "wrong_worker_binary_path",
67            Self::MissingRustup => "missing_rustup",
68            Self::MissingWasmTarget => "missing_wasm_target",
69            Self::DiskBytesExhausted => "disk_bytes_exhausted",
70            Self::DiskInodesExhausted => "disk_inodes_exhausted",
71            Self::TelemetryGap => "telemetry_gap",
72            Self::ActiveProjectExclusion => "active_project_exclusion",
73            Self::RsyncVanishedFile => "rsync_vanished_file",
74            Self::ArtifactMissingRewrittenTarget => "artifact_missing_rewritten_target",
75            Self::QueuedJobOrphan => "queued_job_orphan",
76            Self::SourceChangedWhileProofQueued => "source_changed_while_proof_queued",
77        }
78    }
79
80    /// The incident reason this scenario reproduces (links fixtures to the
81    /// stable reason-code registry).
82    #[must_use]
83    pub const fn reason_code(self) -> IncidentReasonCode {
84        match self {
85            Self::DaemonSocketRefused | Self::StaleSocket => {
86                IncidentReasonCode::DaemonSocketRefused
87            }
88            Self::WorkerUnreachable => IncidentReasonCode::NoAdmissibleWorkers,
89            Self::WrongWorkerBinaryPath => IncidentReasonCode::WrongUserPathWorkerBinary,
90            Self::MissingRustup | Self::MissingWasmTarget => {
91                IncidentReasonCode::MissingRuntimeToolchainTarget
92            }
93            Self::DiskBytesExhausted | Self::DiskInodesExhausted => IncidentReasonCode::DiskFull,
94            Self::TelemetryGap => IncidentReasonCode::TelemetryStale,
95            Self::ActiveProjectExclusion => IncidentReasonCode::ActiveProjectExclusion,
96            Self::RsyncVanishedFile => IncidentReasonCode::RsyncVanishedFile,
97            Self::ArtifactMissingRewrittenTarget => IncidentReasonCode::ArtifactMiss,
98            Self::QueuedJobOrphan => IncidentReasonCode::QueueAmbiguity,
99            Self::SourceChangedWhileProofQueued => IncidentReasonCode::ProofRefusal,
100        }
101    }
102}
103
104/// Simulated `statvfs`-style disk capacity, so a disk-pressure check can be
105/// exercised without actually filling a real filesystem.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct SimulatedDiskStats {
108    pub total_bytes: u64,
109    pub available_bytes: u64,
110    pub total_inodes: u64,
111    pub available_inodes: u64,
112}
113
114impl SimulatedDiskStats {
115    const BIG_BYTES: u64 = 1 << 40; // 1 TiB
116    const BIG_INODES: u64 = 10_000_000;
117
118    /// Plenty of headroom (the no-fault baseline).
119    #[must_use]
120    pub fn healthy() -> Self {
121        Self {
122            total_bytes: Self::BIG_BYTES,
123            available_bytes: Self::BIG_BYTES / 2,
124            total_inodes: Self::BIG_INODES,
125            available_inodes: Self::BIG_INODES / 2,
126        }
127    }
128
129    /// Byte capacity exhausted (inodes fine).
130    #[must_use]
131    pub fn bytes_exhausted() -> Self {
132        Self {
133            available_bytes: 0,
134            ..Self::healthy()
135        }
136    }
137
138    /// Inode capacity exhausted (bytes fine).
139    #[must_use]
140    pub fn inodes_exhausted() -> Self {
141        Self {
142            available_inodes: 0,
143            ..Self::healthy()
144        }
145    }
146
147    #[must_use]
148    pub fn is_bytes_exhausted(&self) -> bool {
149        self.available_bytes == 0
150    }
151
152    #[must_use]
153    pub fn is_inodes_exhausted(&self) -> bool {
154        self.available_inodes == 0
155    }
156}
157
158/// Mach-O 64-bit magic — a deployed darwin binary on a linux worker path is the
159/// canonical "wrong arch" fault. A fixture writes this header so a sanity check
160/// can detect the mismatch.
161pub const MACH_O_64_MAGIC: [u8; 4] = [0xCF, 0xFA, 0xED, 0xFE];
162/// ELF magic — the *correct* header a linux worker binary should carry.
163pub const ELF_MAGIC: [u8; 4] = [0x7F, b'E', b'L', b'F'];
164
165/// A single installed fault scenario. Owns a private temp root; all state lives
166/// under it and is removed on `Drop`.
167pub struct FaultFixture {
168    scenario: FaultScenario,
169    dir: tempfile::TempDir,
170    disk_stats: Option<SimulatedDiskStats>,
171    worker: Option<WorkerConfig>,
172}
173
174impl FaultFixture {
175    /// Install `scenario`. Returns an error only on a genuine temp-dir / I/O
176    /// failure (never on the simulated fault itself).
177    pub fn inject(scenario: FaultScenario) -> std::io::Result<Self> {
178        let dir = tempfile::tempdir()?;
179        let root = dir.path().to_path_buf();
180        let mut disk_stats = None;
181        let mut worker = None;
182
183        match scenario {
184            FaultScenario::DaemonSocketRefused => {
185                // Intentionally do NOT create the socket: a connect() refuses.
186            }
187            FaultScenario::StaleSocket => {
188                // A regular file at the socket path: exists, but not a listener.
189                std::fs::write(root.join("rch.sock"), b"stale")?;
190            }
191            FaultScenario::WorkerUnreachable => {
192                worker = Some(unreachable_worker());
193            }
194            FaultScenario::WrongWorkerBinaryPath => {
195                // Write a darwin (Mach-O) binary where a linux ELF is expected.
196                std::fs::create_dir_all(root.join("bin"))?;
197                std::fs::write(root.join("bin/rch-wkr"), MACH_O_64_MAGIC)?;
198            }
199            FaultScenario::MissingRustup => {
200                // A PATH dir with no `rustup` in it.
201                std::fs::create_dir_all(root.join("bin"))?;
202            }
203            FaultScenario::MissingWasmTarget => {
204                // A toolchain lib dir with no wasm32 target subdir.
205                std::fs::create_dir_all(root.join("toolchain/lib/rustlib"))?;
206                std::fs::create_dir_all(
207                    root.join("toolchain/lib/rustlib/x86_64-unknown-linux-gnu"),
208                )?;
209            }
210            FaultScenario::DiskBytesExhausted => {
211                disk_stats = Some(SimulatedDiskStats::bytes_exhausted());
212            }
213            FaultScenario::DiskInodesExhausted => {
214                disk_stats = Some(SimulatedDiskStats::inodes_exhausted());
215            }
216            FaultScenario::TelemetryGap => {
217                // No state on disk; the gap is expressed via telemetry_signals().
218            }
219            FaultScenario::ActiveProjectExclusion => {
220                // A project root that the exclusion policy will mark excluded.
221                std::fs::create_dir_all(root.join("project"))?;
222                std::fs::write(root.join("project/.rch-exclude"), b"excluded")?;
223            }
224            FaultScenario::RsyncVanishedFile => {
225                std::fs::create_dir_all(root.join("src"))?;
226                std::fs::write(root.join("src/vanishing.rs"), b"// will vanish\n")?;
227            }
228            FaultScenario::ArtifactMissingRewrittenTarget => {
229                // The rewritten target dir exists but the expected artifact is
230                // absent.
231                std::fs::create_dir_all(root.join(".rch-target-rewritten/debug"))?;
232            }
233            FaultScenario::QueuedJobOrphan => {
234                // A job record whose owning pid does not exist.
235                std::fs::write(root.join("job.record"), b"job_id=orphan-1 pid=0\n")?;
236            }
237            FaultScenario::SourceChangedWhileProofQueued => {
238                std::fs::create_dir_all(root.join("src"))?;
239                std::fs::write(root.join("src/lib.rs"), b"pub fn v() -> u32 { 1 }\n")?;
240            }
241        }
242
243        tracing::info!(
244            target: "rch::e2e::fault",
245            scenario = scenario.as_str(),
246            reason_code = scenario.reason_code().code(),
247            root = %root.display(),
248            "fault.fixture.setup",
249        );
250
251        Ok(Self {
252            scenario,
253            dir,
254            disk_stats,
255            worker,
256        })
257    }
258
259    /// The scenario this fixture injects.
260    #[must_use]
261    pub fn scenario(&self) -> FaultScenario {
262        self.scenario
263    }
264
265    /// The private temp root. All fixture state lives under here.
266    #[must_use]
267    pub fn root(&self) -> &Path {
268        self.dir.path()
269    }
270
271    /// Path to the (absent or stale) daemon socket for socket-fault scenarios.
272    #[must_use]
273    pub fn socket_path(&self) -> PathBuf {
274        self.root().join("rch.sock")
275    }
276
277    /// Path to the deployed worker binary (Mach-O wrong-arch fixture).
278    #[must_use]
279    pub fn worker_binary_path(&self) -> PathBuf {
280        self.root().join("bin/rch-wkr")
281    }
282
283    /// PATH directory to point a toolchain lookup at (missing-rustup fixture).
284    #[must_use]
285    pub fn path_dir(&self) -> PathBuf {
286        self.root().join("bin")
287    }
288
289    /// The simulated disk stats for disk-exhaustion scenarios.
290    #[must_use]
291    pub fn disk_stats(&self) -> Option<SimulatedDiskStats> {
292        self.disk_stats
293    }
294
295    /// A mock-unreachable worker config (worker-unreachable scenario).
296    #[must_use]
297    pub fn worker(&self) -> Option<&WorkerConfig> {
298        self.worker.as_ref()
299    }
300
301    /// Telemetry signals describing the gap (telemetry-gap scenario).
302    #[must_use]
303    pub fn telemetry_signals(&self) -> crate::telemetry_explain::TelemetrySignals {
304        crate::telemetry_explain::TelemetrySignals {
305            ever_received_sample: false,
306            poller_behind: false,
307            ..crate::telemetry_explain::TelemetrySignals::default()
308        }
309    }
310
311    /// The source file used by vanished-file / proof-changed scenarios.
312    #[must_use]
313    pub fn source_file(&self) -> PathBuf {
314        match self.scenario {
315            FaultScenario::RsyncVanishedFile => self.root().join("src/vanishing.rs"),
316            _ => self.root().join("src/lib.rs"),
317        }
318    }
319
320    /// Trigger the vanished-file race: remove the source file (bounded to the
321    /// temp root) to simulate rsync observing a file disappear mid-transfer.
322    pub fn vanish_source(&self) -> std::io::Result<()> {
323        let path = self.source_file();
324        debug_assert!(
325            path.starts_with(self.root()),
326            "vanish must stay in temp root"
327        );
328        std::fs::remove_file(&path)?;
329        tracing::info!(
330            target: "rch::e2e::fault",
331            scenario = self.scenario.as_str(),
332            path = %path.display(),
333            "fault.fixture.source_vanished",
334        );
335        Ok(())
336    }
337
338    /// Mutate the source after a proof intent would have been queued (changes
339    /// content so a proof re-validation sees drift).
340    pub fn mutate_source(&self) -> std::io::Result<()> {
341        let path = self.source_file();
342        debug_assert!(
343            path.starts_with(self.root()),
344            "mutate must stay in temp root"
345        );
346        std::fs::write(&path, b"pub fn v() -> u32 { 2 }\n")?;
347        tracing::info!(
348            target: "rch::e2e::fault",
349            scenario = self.scenario.as_str(),
350            path = %path.display(),
351            "fault.fixture.source_mutated",
352        );
353        Ok(())
354    }
355}
356
357impl Drop for FaultFixture {
358    fn drop(&mut self) {
359        // The TempDir removes the (bounded) root; we just journal teardown.
360        tracing::info!(
361            target: "rch::e2e::fault",
362            scenario = self.scenario.as_str(),
363            root = %self.dir.path().display(),
364            "fault.fixture.teardown",
365        );
366    }
367}
368
369/// A worker config that mock SSH treats as unreachable (RFC 5737 TEST-NET host
370/// that never resolves to a real machine).
371fn unreachable_worker() -> WorkerConfig {
372    WorkerConfig {
373        id: WorkerId("unreachable".to_string()),
374        host: "192.0.2.1".to_string(),
375        user: "rch".to_string(),
376        ..WorkerConfig::default()
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    const ALL: &[FaultScenario] = &[
385        FaultScenario::DaemonSocketRefused,
386        FaultScenario::StaleSocket,
387        FaultScenario::WorkerUnreachable,
388        FaultScenario::WrongWorkerBinaryPath,
389        FaultScenario::MissingRustup,
390        FaultScenario::MissingWasmTarget,
391        FaultScenario::DiskBytesExhausted,
392        FaultScenario::DiskInodesExhausted,
393        FaultScenario::TelemetryGap,
394        FaultScenario::ActiveProjectExclusion,
395        FaultScenario::RsyncVanishedFile,
396        FaultScenario::ArtifactMissingRewrittenTarget,
397        FaultScenario::QueuedJobOrphan,
398        FaultScenario::SourceChangedWhileProofQueued,
399    ];
400
401    #[test]
402    fn every_scenario_injects_and_cleans_up_within_temp_root() {
403        for &scenario in ALL {
404            let root: PathBuf;
405            {
406                let fx = FaultFixture::inject(scenario).expect("inject");
407                root = fx.root().to_path_buf();
408                assert!(root.exists(), "{}: root exists", scenario.as_str());
409                // Everything the fixture creates is under its own root.
410                assert!(root.starts_with(std::env::temp_dir()));
411            }
412            // Dropped -> bounded cleanup removed the whole root, nothing else.
413            assert!(
414                !root.exists(),
415                "{}: root cleaned on drop",
416                scenario.as_str()
417            );
418        }
419    }
420
421    #[test]
422    fn scenario_ids_and_reason_codes_are_distinct_and_mapped() {
423        let mut ids: Vec<&str> = ALL.iter().map(|s| s.as_str()).collect();
424        ids.sort_unstable();
425        let n = ids.len();
426        ids.dedup();
427        assert_eq!(n, ids.len(), "scenario ids must be unique");
428        // Every scenario maps to a real incident reason code.
429        for &s in ALL {
430            assert!(!s.reason_code().code().is_empty());
431        }
432    }
433
434    #[test]
435    fn daemon_socket_refused_has_no_socket() {
436        let fx = FaultFixture::inject(FaultScenario::DaemonSocketRefused).unwrap();
437        assert!(
438            !fx.socket_path().exists(),
439            "socket must be absent (refused)"
440        );
441    }
442
443    #[test]
444    fn stale_socket_is_a_file_not_a_listener() {
445        let fx = FaultFixture::inject(FaultScenario::StaleSocket).unwrap();
446        assert!(fx.socket_path().is_file());
447    }
448
449    #[test]
450    fn wrong_worker_binary_is_mach_o_not_elf() {
451        let fx = FaultFixture::inject(FaultScenario::WrongWorkerBinaryPath).unwrap();
452        let bytes = std::fs::read(fx.worker_binary_path()).unwrap();
453        assert_eq!(&bytes[..4], &MACH_O_64_MAGIC);
454        assert_ne!(&bytes[..4], &ELF_MAGIC);
455    }
456
457    #[test]
458    fn missing_rustup_path_dir_has_no_rustup() {
459        let fx = FaultFixture::inject(FaultScenario::MissingRustup).unwrap();
460        assert!(fx.path_dir().is_dir());
461        assert!(!fx.path_dir().join("rustup").exists());
462    }
463
464    #[test]
465    fn disk_exhaustion_stats_are_distinct() {
466        let bytes = FaultFixture::inject(FaultScenario::DiskBytesExhausted).unwrap();
467        let s = bytes.disk_stats().unwrap();
468        assert!(s.is_bytes_exhausted() && !s.is_inodes_exhausted());
469
470        let inodes = FaultFixture::inject(FaultScenario::DiskInodesExhausted).unwrap();
471        let s = inodes.disk_stats().unwrap();
472        assert!(s.is_inodes_exhausted() && !s.is_bytes_exhausted());
473
474        assert!(!SimulatedDiskStats::healthy().is_bytes_exhausted());
475    }
476
477    #[test]
478    fn worker_unreachable_provides_test_net_worker() {
479        let fx = FaultFixture::inject(FaultScenario::WorkerUnreachable).unwrap();
480        let w = fx.worker().expect("worker present");
481        assert_eq!(w.host, "192.0.2.1");
482    }
483
484    #[test]
485    fn telemetry_gap_signals_never_received() {
486        let fx = FaultFixture::inject(FaultScenario::TelemetryGap).unwrap();
487        let signals = fx.telemetry_signals();
488        assert!(!signals.ever_received_sample);
489    }
490
491    #[test]
492    fn rsync_vanished_file_removes_only_its_own_source() {
493        let fx = FaultFixture::inject(FaultScenario::RsyncVanishedFile).unwrap();
494        let src = fx.source_file();
495        assert!(src.exists());
496        fx.vanish_source().unwrap();
497        assert!(!src.exists());
498        // The temp root itself survives the vanish (only the file went).
499        assert!(fx.root().exists());
500    }
501
502    #[test]
503    fn source_changed_while_proof_queued_mutates_content() {
504        let fx = FaultFixture::inject(FaultScenario::SourceChangedWhileProofQueued).unwrap();
505        let before = std::fs::read_to_string(fx.source_file()).unwrap();
506        fx.mutate_source().unwrap();
507        let after = std::fs::read_to_string(fx.source_file()).unwrap();
508        assert_ne!(before, after);
509    }
510
511    #[test]
512    fn artifact_missing_rewritten_target_has_dir_but_no_artifact() {
513        let fx = FaultFixture::inject(FaultScenario::ArtifactMissingRewrittenTarget).unwrap();
514        assert!(fx.root().join(".rch-target-rewritten/debug").is_dir());
515        assert!(
516            !fx.root()
517                .join(".rch-target-rewritten/debug/libfixture.rlib")
518                .exists()
519        );
520    }
521}