1use std::collections::BTreeMap;
42use std::fs::{self, OpenOptions};
43use std::io::Write;
44use std::path::{Path, PathBuf};
45
46use schemars::generate::SchemaSettings;
47use schemars::{JsonSchema, Schema};
48use serde::{Deserialize, Serialize};
49
50use crate::incident::IncidentReasonCode;
51use crate::schema_versions::{SchemaComponent, current_version};
52
53pub const MAX_DIAGNOSTIC_CHARS: usize = 512;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
69#[serde(rename_all = "snake_case")]
70pub enum BypassFailureClass {
71 Ssh,
73 WorkerBinary,
75 RuntimeToolchain,
77 DiskInodePressure,
79 StaleTelemetry,
81 PathSync,
83 ArtifactRetrieval,
85 CircuitBreaker,
87 OsArchMismatch,
89}
90
91impl BypassFailureClass {
92 pub const ALL: &'static [BypassFailureClass] = &[
95 Self::Ssh,
96 Self::WorkerBinary,
97 Self::RuntimeToolchain,
98 Self::DiskInodePressure,
99 Self::StaleTelemetry,
100 Self::PathSync,
101 Self::ArtifactRetrieval,
102 Self::CircuitBreaker,
103 Self::OsArchMismatch,
104 ];
105
106 #[must_use]
108 pub const fn as_str(self) -> &'static str {
109 match self {
110 Self::Ssh => "ssh",
111 Self::WorkerBinary => "worker_binary",
112 Self::RuntimeToolchain => "runtime_toolchain",
113 Self::DiskInodePressure => "disk_inode_pressure",
114 Self::StaleTelemetry => "stale_telemetry",
115 Self::PathSync => "path_sync",
116 Self::ArtifactRetrieval => "artifact_retrieval",
117 Self::CircuitBreaker => "circuit_breaker",
118 Self::OsArchMismatch => "os_arch_mismatch",
119 }
120 }
121
122 #[must_use]
124 pub const fn label(self) -> &'static str {
125 match self {
126 Self::Ssh => "SSH connection failure",
127 Self::WorkerBinary => "worker binary missing/wrong path or user",
128 Self::RuntimeToolchain => "missing runtime/toolchain/target",
129 Self::DiskInodePressure => "disk/inode pressure",
130 Self::StaleTelemetry => "telemetry stale/age unknown",
131 Self::PathSync => "path/source sync failure",
132 Self::ArtifactRetrieval => "artifact retrieval failure",
133 Self::CircuitBreaker => "circuit breaker open",
134 Self::OsArchMismatch => "OS/arch mismatch",
135 }
136 }
137
138 #[must_use]
146 pub const fn incident_reason_code(self) -> IncidentReasonCode {
147 match self {
148 Self::Ssh => IncidentReasonCode::HardPreflight,
149 Self::WorkerBinary => IncidentReasonCode::WrongUserPathWorkerBinary,
150 Self::RuntimeToolchain => IncidentReasonCode::MissingRuntimeToolchainTarget,
151 Self::DiskInodePressure => IncidentReasonCode::DiskFull,
152 Self::StaleTelemetry => IncidentReasonCode::TelemetryStale,
153 Self::PathSync => IncidentReasonCode::RsyncVanishedFile,
154 Self::ArtifactRetrieval => IncidentReasonCode::ArtifactMiss,
155 Self::CircuitBreaker => IncidentReasonCode::CircuitOpen,
156 Self::OsArchMismatch => IncidentReasonCode::OsArchMismatch,
157 }
158 }
159
160 #[must_use]
162 pub fn from_str_opt(s: &str) -> Option<Self> {
163 Self::ALL.iter().copied().find(|c| c.as_str() == s)
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
172#[serde(rename_all = "snake_case")]
173pub enum BypassState {
174 TemporaryBypass,
176 RecoveredPendingCanary,
178}
179
180impl BypassState {
181 #[must_use]
183 pub const fn label(self) -> &'static str {
184 match self {
185 Self::TemporaryBypass => "temporary bypass",
186 Self::RecoveredPendingCanary => "recovered, pending canary",
187 }
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct BypassBackoff {
198 pub current_ms: u64,
200 pub attempts: u32,
202 pub max_ms: u64,
204}
205
206impl BypassBackoff {
207 pub const DEFAULT_INITIAL_MS: u64 = 30_000;
209 pub const DEFAULT_MAX_MS: u64 = 900_000;
211
212 #[must_use]
214 pub const fn initial() -> Self {
215 Self {
216 current_ms: Self::DEFAULT_INITIAL_MS,
217 attempts: 0,
218 max_ms: Self::DEFAULT_MAX_MS,
219 }
220 }
221
222 #[must_use]
225 pub const fn advanced(self) -> Self {
226 let doubled = self.current_ms.saturating_mul(2);
227 let current_ms = if doubled > self.max_ms {
228 self.max_ms
229 } else {
230 doubled
231 };
232 Self {
233 current_ms,
234 attempts: self.attempts.saturating_add(1),
235 max_ms: self.max_ms,
236 }
237 }
238}
239
240impl Default for BypassBackoff {
241 fn default() -> Self {
242 Self::initial()
243 }
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
248pub struct AutoRejoinCriteria {
249 pub required_consecutive_passes: u32,
251 pub canary_required: bool,
253}
254
255impl Default for AutoRejoinCriteria {
256 fn default() -> Self {
257 Self {
258 required_consecutive_passes: 2,
259 canary_required: true,
260 }
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
270pub struct BypassRecord {
271 pub schema_version: String,
273 pub worker_id: String,
275 pub host: String,
277 pub user: String,
279 pub failure_class: BypassFailureClass,
281 #[schemars(with = "String")]
284 pub reason_code: IncidentReasonCode,
285 pub state: BypassState,
287 pub first_failure_unix_ms: u64,
289 pub last_failure_unix_ms: u64,
291 pub next_probe_unix_ms: u64,
293 pub backoff: BypassBackoff,
295 pub consecutive_failures: u32,
297 pub consecutive_passes: u32,
299 pub last_diagnostic: String,
301 pub auto_rejoin: AutoRejoinCriteria,
303 pub local_fallback_allowed: bool,
305 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
307 pub details: BTreeMap<String, String>,
308}
309
310impl BypassRecord {
311 #[must_use]
315 pub fn new(
316 worker_id: impl Into<String>,
317 host: impl Into<String>,
318 user: impl Into<String>,
319 failure_class: BypassFailureClass,
320 first_failure_unix_ms: u64,
321 ) -> Self {
322 let backoff = BypassBackoff::initial();
323 Self {
324 schema_version: bypass_record_schema_version().to_string(),
325 worker_id: worker_id.into(),
326 host: host.into(),
327 user: user.into(),
328 failure_class,
329 reason_code: failure_class.incident_reason_code(),
330 state: BypassState::TemporaryBypass,
331 first_failure_unix_ms,
332 last_failure_unix_ms: first_failure_unix_ms,
333 next_probe_unix_ms: first_failure_unix_ms.saturating_add(backoff.current_ms),
334 backoff,
335 consecutive_failures: 1,
336 consecutive_passes: 0,
337 last_diagnostic: String::new(),
338 auto_rejoin: AutoRejoinCriteria::default(),
339 local_fallback_allowed: true,
340 details: BTreeMap::new(),
341 }
342 }
343
344 #[must_use]
346 pub fn with_diagnostic(mut self, diagnostic: impl Into<String>) -> Self {
347 self.last_diagnostic = truncate_diagnostic(diagnostic.into());
348 self
349 }
350
351 #[must_use]
353 pub fn with_local_fallback_allowed(mut self, allowed: bool) -> Self {
354 self.local_fallback_allowed = allowed;
355 self
356 }
357
358 #[must_use]
360 pub fn with_auto_rejoin(mut self, criteria: AutoRejoinCriteria) -> Self {
361 self.auto_rejoin = criteria;
362 self
363 }
364
365 #[must_use]
367 pub fn with_detail(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
368 self.details.insert(key.into(), value.into());
369 self
370 }
371
372 pub fn record_failure(&mut self, now_unix_ms: u64, diagnostic: impl Into<String>) {
379 self.last_failure_unix_ms = now_unix_ms;
380 self.consecutive_failures = self.consecutive_failures.saturating_add(1);
381 self.consecutive_passes = 0;
382 self.backoff = self.backoff.advanced();
383 self.next_probe_unix_ms = now_unix_ms.saturating_add(self.backoff.current_ms);
384 self.state = BypassState::TemporaryBypass;
385 let diagnostic = diagnostic.into();
386 if !diagnostic.is_empty() {
387 self.last_diagnostic = truncate_diagnostic(diagnostic);
388 }
389 }
390
391 pub fn record_probe_pass(&mut self, now_unix_ms: u64) -> bool {
398 self.consecutive_passes = self.consecutive_passes.saturating_add(1);
399 self.consecutive_failures = 0;
400 self.next_probe_unix_ms = now_unix_ms.saturating_add(self.backoff.current_ms);
401 let met = self.consecutive_passes >= self.auto_rejoin.required_consecutive_passes;
402 if met && self.auto_rejoin.canary_required {
403 self.state = BypassState::RecoveredPendingCanary;
404 }
405 met
406 }
407
408 #[must_use]
410 pub const fn probe_due(&self, now_unix_ms: u64) -> bool {
411 now_unix_ms >= self.next_probe_unix_ms
412 }
413}
414
415#[must_use]
418pub fn truncate_diagnostic(mut s: String) -> String {
419 if s.chars().count() <= MAX_DIAGNOSTIC_CHARS {
420 return s;
421 }
422 let cut = s
423 .char_indices()
424 .nth(MAX_DIAGNOSTIC_CHARS)
425 .map_or(s.len(), |(i, _)| i);
426 s.truncate(cut);
427 s.push('…');
428 s
429}
430
431#[must_use]
433pub fn bypass_record_schema_version() -> &'static str {
434 current_version(SchemaComponent::WorkerBypassRecord)
435}
436
437#[must_use]
439pub fn bypass_record_schema() -> Schema {
440 SchemaSettings::draft07()
441 .into_generator()
442 .into_root_schema_for::<BypassRecord>()
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
452struct BypassRecordFile {
453 schema_version: String,
455 records: Vec<BypassRecord>,
457}
458
459#[must_use]
462pub fn default_bypass_record_path() -> PathBuf {
463 let base = std::env::var_os("RCH_STATE_HOME")
464 .map(PathBuf::from)
465 .filter(|p| !p.as_os_str().is_empty())
466 .or_else(|| {
467 std::env::var_os("XDG_STATE_HOME")
468 .map(|d| PathBuf::from(d).join("rch"))
469 .filter(|p| p.as_os_str().len() > "rch".len())
470 })
471 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state/rch")))
472 .unwrap_or_else(|| PathBuf::from("/tmp/rch"));
473 base.join("bypass_records.json")
474}
475
476#[derive(Debug, Clone)]
479pub struct BypassRecordStore {
480 path: PathBuf,
481 records: BTreeMap<String, BypassRecord>,
482}
483
484impl BypassRecordStore {
485 #[must_use]
487 pub fn with_path(path: impl Into<PathBuf>) -> Self {
488 Self {
489 path: path.into(),
490 records: BTreeMap::new(),
491 }
492 }
493
494 #[must_use]
498 pub fn load(path: impl Into<PathBuf>) -> Self {
499 let path = path.into();
500 let mut records = BTreeMap::new();
501 if let Ok(bytes) = fs::read(&path)
502 && let Ok(file) = serde_json::from_slice::<BypassRecordFile>(&bytes)
503 {
504 for record in file.records {
505 records.insert(record.worker_id.clone(), record);
507 }
508 }
509 Self { path, records }
510 }
511
512 #[must_use]
514 pub fn path(&self) -> &Path {
515 &self.path
516 }
517
518 #[must_use]
520 pub fn get(&self, worker_id: &str) -> Option<&BypassRecord> {
521 self.records.get(worker_id)
522 }
523
524 #[must_use]
526 pub fn contains(&self, worker_id: &str) -> bool {
527 self.records.contains_key(worker_id)
528 }
529
530 #[must_use]
532 pub fn all(&self) -> Vec<&BypassRecord> {
533 self.records.values().collect()
534 }
535
536 #[must_use]
538 pub fn len(&self) -> usize {
539 self.records.len()
540 }
541
542 #[must_use]
544 pub fn is_empty(&self) -> bool {
545 self.records.is_empty()
546 }
547
548 pub fn upsert(&mut self, record: BypassRecord) -> std::io::Result<()> {
550 self.records.insert(record.worker_id.clone(), record);
551 self.persist()
552 }
553
554 pub fn remove(&mut self, worker_id: &str) -> std::io::Result<Option<BypassRecord>> {
557 let removed = self.records.remove(worker_id);
558 if removed.is_some() {
559 self.persist()?;
560 }
561 Ok(removed)
562 }
563
564 fn persist(&self) -> std::io::Result<()> {
567 if let Some(parent) = self.path.parent()
568 && !parent.as_os_str().is_empty()
569 {
570 fs::create_dir_all(parent)?;
571 }
572 let doc = BypassRecordFile {
573 schema_version: bypass_record_schema_version().to_string(),
574 records: self.records.values().cloned().collect(),
575 };
576 let body = serde_json::to_vec_pretty(&doc)
577 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
578
579 let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
580 let tmp = parent.join(format!(".bypass_records.{}.tmp", std::process::id()));
581 {
582 let mut tmp_file = OpenOptions::new()
583 .create(true)
584 .write(true)
585 .truncate(true)
586 .open(&tmp)?;
587 tmp_file.write_all(&body)?;
588 tmp_file.flush()?;
589 }
590 match fs::rename(&tmp, &self.path) {
591 Ok(()) => Ok(()),
592 Err(e) => {
593 let _ = fs::remove_file(&tmp);
594 Err(e)
595 }
596 }
597 }
598}
599
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
616pub struct AdminDisableRecord {
617 pub worker_id: String,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub reason: Option<String>,
622 pub disabled_unix_ms: u64,
624}
625
626#[derive(Debug, Serialize, Deserialize)]
628struct AdminDisableFile {
629 schema_version: u32,
630 records: Vec<AdminDisableRecord>,
631}
632
633const ADMIN_DISABLE_SCHEMA_VERSION: u32 = 1;
634
635#[must_use]
638pub fn default_admin_disable_path() -> PathBuf {
639 default_bypass_record_path().with_file_name("admin_disables.json")
640}
641
642#[derive(Debug)]
648pub struct AdminDisableStore {
649 path: PathBuf,
650 records: BTreeMap<String, AdminDisableRecord>,
651}
652
653impl AdminDisableStore {
654 #[must_use]
656 pub fn with_path(path: impl Into<PathBuf>) -> Self {
657 Self {
658 path: path.into(),
659 records: BTreeMap::new(),
660 }
661 }
662
663 #[must_use]
665 pub fn load(path: impl Into<PathBuf>) -> Self {
666 let path = path.into();
667 let mut records = BTreeMap::new();
668 if let Ok(bytes) = fs::read(&path)
669 && let Ok(file) = serde_json::from_slice::<AdminDisableFile>(&bytes)
670 {
671 for record in file.records {
672 records.insert(record.worker_id.clone(), record);
673 }
674 }
675 Self { path, records }
676 }
677
678 #[must_use]
680 pub fn path(&self) -> &Path {
681 &self.path
682 }
683
684 #[must_use]
686 pub fn get(&self, worker_id: &str) -> Option<&AdminDisableRecord> {
687 self.records.get(worker_id)
688 }
689
690 #[must_use]
692 pub fn all(&self) -> Vec<&AdminDisableRecord> {
693 self.records.values().collect()
694 }
695
696 pub fn upsert(&mut self, record: AdminDisableRecord) -> std::io::Result<()> {
698 self.records.insert(record.worker_id.clone(), record);
699 self.persist()
700 }
701
702 pub fn remove(&mut self, worker_id: &str) -> std::io::Result<Option<AdminDisableRecord>> {
705 let removed = self.records.remove(worker_id);
706 if removed.is_some() {
707 self.persist()?;
708 }
709 Ok(removed)
710 }
711
712 fn persist(&self) -> std::io::Result<()> {
714 if let Some(parent) = self.path.parent()
715 && !parent.as_os_str().is_empty()
716 {
717 fs::create_dir_all(parent)?;
718 }
719 let doc = AdminDisableFile {
720 schema_version: ADMIN_DISABLE_SCHEMA_VERSION,
721 records: self.records.values().cloned().collect(),
722 };
723 let body = serde_json::to_vec_pretty(&doc)
724 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
725
726 let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
727 let tmp = parent.join(format!(".admin_disables.{}.tmp", std::process::id()));
728 {
729 let mut tmp_file = OpenOptions::new()
730 .create(true)
731 .write(true)
732 .truncate(true)
733 .open(&tmp)?;
734 tmp_file.write_all(&body)?;
735 tmp_file.flush()?;
736 }
737 match fs::rename(&tmp, &self.path) {
738 Ok(()) => Ok(()),
739 Err(e) => {
740 let _ = fs::remove_file(&tmp);
741 Err(e)
742 }
743 }
744 }
745}
746
747#[derive(Debug, Clone, PartialEq, Eq)]
755pub struct DisabledWorkerSnapshot {
756 pub worker_id: String,
758 pub host: String,
760 pub user: String,
762 pub disabled_reason: Option<String>,
764 pub disabled_at_unix_ms: Option<u64>,
766}
767
768#[derive(Debug, Clone, PartialEq, Eq)]
770pub enum DisabledMigration {
771 Migrate(Box<BypassRecord>),
774 KeepDisabled {
776 reason: String,
778 },
779}
780
781#[must_use]
789pub fn classify_disable_reason(reason: &str) -> Option<BypassFailureClass> {
790 let r = reason.to_ascii_lowercase();
791 if r.trim().is_empty() {
792 return None;
793 }
794 const TABLE: &[(&[&str], BypassFailureClass)] = &[
796 (
797 &["no space", "enospc", "disk full", "inode", "disk pressure"],
798 BypassFailureClass::DiskInodePressure,
799 ),
800 (
801 &["toolchain", "rustup", "nightly", "missing target", "rustc"],
802 BypassFailureClass::RuntimeToolchain,
803 ),
804 (
805 &[
806 "rch-wkr",
807 "worker binary",
808 "wrong user",
809 "wrong path",
810 "no such file",
811 ],
812 BypassFailureClass::WorkerBinary,
813 ),
814 (&["circuit"], BypassFailureClass::CircuitBreaker),
815 (
816 &["telemetry", "stale", "heartbeat"],
817 BypassFailureClass::StaleTelemetry,
818 ),
819 (
820 &["rsync", "vanished", "path sync", "source sync"],
821 BypassFailureClass::PathSync,
822 ),
823 (&["artifact"], BypassFailureClass::ArtifactRetrieval),
824 (
825 &["os/arch", "architecture", "arch mismatch", "triple"],
826 BypassFailureClass::OsArchMismatch,
827 ),
828 (
829 &[
830 "ssh",
831 "connection refused",
832 "connection reset",
833 "connection failed",
834 "connection timed out",
835 "unreachable",
836 "timed out",
837 "auth",
838 "permission denied",
839 ],
840 BypassFailureClass::Ssh,
841 ),
842 ];
843 for (needles, class) in TABLE {
844 if needles.iter().any(|n| r.contains(n)) {
845 return Some(*class);
846 }
847 }
848 None
849}
850
851#[must_use]
857pub fn migrate_disabled_worker(
858 snapshot: &DisabledWorkerSnapshot,
859 now_unix_ms: u64,
860) -> DisabledMigration {
861 let Some(reason) = snapshot.disabled_reason.as_deref() else {
862 return DisabledMigration::KeepDisabled {
863 reason: "no disable reason recorded — treat as deliberate operator disable".to_string(),
864 };
865 };
866 let Some(class) = classify_disable_reason(reason) else {
867 return DisabledMigration::KeepDisabled {
868 reason: format!("disable reason does not look transient: {reason:?}"),
869 };
870 };
871
872 let first_failure = snapshot.disabled_at_unix_ms.unwrap_or(now_unix_ms);
873 let mut record = BypassRecord::new(
874 &snapshot.worker_id,
875 &snapshot.host,
876 &snapshot.user,
877 class,
878 first_failure,
879 )
880 .with_diagnostic(format!("migrated from admin-disabled: {reason}"))
881 .with_detail("migrated_from", "admin_disabled");
882 record.next_probe_unix_ms = now_unix_ms;
885 DisabledMigration::Migrate(Box::new(record))
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891
892 const TS: u64 = 1_700_000_000_000;
893
894 fn record(class: BypassFailureClass) -> BypassRecord {
895 BypassRecord::new("css", "203.0.113.20", "ubuntu", class, TS)
896 }
897
898 #[test]
899 fn failure_class_wire_form_is_stable_snake_case() {
900 assert_eq!(BypassFailureClass::Ssh.as_str(), "ssh");
901 assert_eq!(
902 BypassFailureClass::DiskInodePressure.as_str(),
903 "disk_inode_pressure"
904 );
905 for c in BypassFailureClass::ALL {
907 let json = serde_json::to_string(c).unwrap();
908 assert_eq!(json, format!("\"{}\"", c.as_str()));
909 assert_eq!(BypassFailureClass::from_str_opt(c.as_str()), Some(*c));
910 }
911 assert_eq!(BypassFailureClass::from_str_opt("nonsense"), None);
912 }
913
914 #[test]
915 fn failure_class_maps_to_an_incident_reason_for_every_variant() {
916 for c in BypassFailureClass::ALL {
918 let code = c.incident_reason_code();
919 assert_eq!(
921 IncidentReasonCode::from_code_str(code.code()),
922 Some(code),
923 "reason code for {c:?} not in registry"
924 );
925 }
926 assert_eq!(
927 BypassFailureClass::Ssh.incident_reason_code(),
928 IncidentReasonCode::HardPreflight
929 );
930 assert_eq!(
931 BypassFailureClass::CircuitBreaker.incident_reason_code(),
932 IncidentReasonCode::CircuitOpen
933 );
934 assert_eq!(
935 BypassFailureClass::DiskInodePressure.incident_reason_code(),
936 IncidentReasonCode::DiskFull
937 );
938 }
939
940 #[test]
941 fn new_record_is_consistent_and_schema_stamped() {
942 let r = record(BypassFailureClass::Ssh);
943 assert_eq!(r.schema_version, bypass_record_schema_version());
944 assert_eq!(r.state, BypassState::TemporaryBypass);
945 assert_eq!(r.reason_code, IncidentReasonCode::HardPreflight);
946 assert_eq!(r.consecutive_failures, 1);
947 assert_eq!(r.consecutive_passes, 0);
948 assert_eq!(r.first_failure_unix_ms, TS);
949 assert_eq!(r.last_failure_unix_ms, TS);
950 assert_eq!(r.next_probe_unix_ms, TS + BypassBackoff::DEFAULT_INITIAL_MS);
951 assert!(r.local_fallback_allowed);
952 }
953
954 #[test]
955 fn record_serializes_required_fields_and_roundtrips() {
956 let r = record(BypassFailureClass::CircuitBreaker)
957 .with_diagnostic("circuit open after 5 failures")
958 .with_local_fallback_allowed(false)
959 .with_detail("attempts", "5");
960 let v = serde_json::to_value(&r).unwrap();
961 for key in [
963 "worker_id",
964 "host",
965 "user",
966 "failure_class",
967 "reason_code",
968 "state",
969 "first_failure_unix_ms",
970 "last_failure_unix_ms",
971 "next_probe_unix_ms",
972 "backoff",
973 "consecutive_failures",
974 "consecutive_passes",
975 "last_diagnostic",
976 "auto_rejoin",
977 "local_fallback_allowed",
978 ] {
979 assert!(v.get(key).is_some(), "missing record field {key}");
980 }
981 assert_eq!(v["failure_class"], "circuit_breaker");
982 assert_eq!(v["reason_code"], "RCH-I009");
983 assert_eq!(v["local_fallback_allowed"], false);
984 let back: BypassRecord = serde_json::from_value(v).unwrap();
985 assert_eq!(back, r);
986 }
987
988 #[test]
989 fn record_never_carries_raw_command_or_secret_fields() {
990 let v = serde_json::to_value(record(BypassFailureClass::Ssh)).unwrap();
991 let obj = v.as_object().unwrap();
992 for forbidden in ["command", "raw_command", "secret", "password", "token"] {
993 assert!(
994 !obj.contains_key(forbidden),
995 "must not expose `{forbidden}`"
996 );
997 }
998 }
999
1000 #[test]
1001 fn diagnostic_is_truncated() {
1002 let long = "x".repeat(MAX_DIAGNOSTIC_CHARS + 50);
1003 let r = record(BypassFailureClass::Ssh).with_diagnostic(long);
1004 assert!(r.last_diagnostic.chars().count() <= MAX_DIAGNOSTIC_CHARS + 1);
1005 assert!(r.last_diagnostic.ends_with('…'));
1006 let short = record(BypassFailureClass::Ssh).with_diagnostic("brief");
1008 assert_eq!(short.last_diagnostic, "brief");
1009 }
1010
1011 #[test]
1012 fn backoff_doubles_up_to_ceiling() {
1013 let mut b = BypassBackoff::initial();
1014 assert_eq!(b.current_ms, BypassBackoff::DEFAULT_INITIAL_MS);
1015 let mut last = b.current_ms;
1016 for _ in 0..20 {
1017 b = b.advanced();
1018 assert!(b.current_ms >= last);
1019 assert!(b.current_ms <= BypassBackoff::DEFAULT_MAX_MS);
1020 last = b.current_ms;
1021 }
1022 assert_eq!(b.current_ms, BypassBackoff::DEFAULT_MAX_MS);
1024 assert_eq!(b.attempts, 20);
1025 }
1026
1027 #[test]
1028 fn record_failure_advances_backoff_and_resets_passes() {
1029 let mut r = record(BypassFailureClass::Ssh);
1030 r.consecutive_passes = 1;
1031 let before = r.backoff.current_ms;
1032 r.record_failure(TS + 1000, "ssh timed out");
1033 assert_eq!(r.consecutive_failures, 2);
1034 assert_eq!(r.consecutive_passes, 0);
1035 assert!(r.backoff.current_ms > before);
1036 assert_eq!(r.next_probe_unix_ms, TS + 1000 + r.backoff.current_ms);
1037 assert_eq!(r.last_diagnostic, "ssh timed out");
1038 assert_eq!(r.state, BypassState::TemporaryBypass);
1039 }
1040
1041 #[test]
1042 fn probe_pass_advances_to_canary_when_criteria_met() {
1043 let mut r = record(BypassFailureClass::Ssh);
1044 assert!(!r.record_probe_pass(TS + 1));
1046 assert_eq!(r.state, BypassState::TemporaryBypass);
1047 assert!(r.record_probe_pass(TS + 2));
1048 assert_eq!(r.state, BypassState::RecoveredPendingCanary);
1049 assert_eq!(r.consecutive_passes, 2);
1050 assert_eq!(r.consecutive_failures, 0);
1051 }
1052
1053 #[test]
1054 fn probe_due_respects_schedule() {
1055 let r = record(BypassFailureClass::Ssh);
1056 assert!(!r.probe_due(r.next_probe_unix_ms - 1));
1057 assert!(r.probe_due(r.next_probe_unix_ms));
1058 assert!(r.probe_due(r.next_probe_unix_ms + 1));
1059 }
1060
1061 #[test]
1062 fn schema_export_names_the_record_and_fields() {
1063 let text = serde_json::to_string(&bypass_record_schema()).unwrap();
1064 assert!(text.contains("BypassRecord"));
1065 for f in [
1066 "failure_class",
1067 "reason_code",
1068 "next_probe_unix_ms",
1069 "auto_rejoin",
1070 "local_fallback_allowed",
1071 ] {
1072 assert!(text.contains(f), "schema omits {f}");
1073 }
1074 }
1075
1076 #[test]
1079 fn store_upsert_get_remove_and_persist() {
1080 let dir = tempfile::tempdir().unwrap();
1081 let path = dir.path().join("bypass_records.json");
1082 let mut store = BypassRecordStore::load(&path);
1083 assert!(store.is_empty());
1084
1085 store.upsert(record(BypassFailureClass::Ssh)).unwrap();
1086 store
1087 .upsert(BypassRecord::new(
1088 "bil",
1089 "203.0.113.21",
1090 "ubuntu",
1091 BypassFailureClass::DiskInodePressure,
1092 TS,
1093 ))
1094 .unwrap();
1095 assert_eq!(store.len(), 2);
1096 assert!(store.contains("css"));
1097 assert_eq!(
1098 store.get("css").unwrap().failure_class,
1099 BypassFailureClass::Ssh
1100 );
1101 let ids: Vec<&str> = store.all().iter().map(|r| r.worker_id.as_str()).collect();
1103 assert_eq!(ids, vec!["bil", "css"]);
1104
1105 let removed = store.remove("css").unwrap();
1106 assert_eq!(removed.unwrap().worker_id, "css");
1107 assert!(!store.contains("css"));
1108 assert!(store.remove("nope").unwrap().is_none());
1109 }
1110
1111 #[test]
1112 fn store_survives_reload_across_instances() {
1113 let dir = tempfile::tempdir().unwrap();
1114 let path = dir.path().join("bypass_records.json");
1115 {
1116 let mut store = BypassRecordStore::load(&path);
1117 store
1118 .upsert(record(BypassFailureClass::CircuitBreaker).with_diagnostic("circuit churn"))
1119 .unwrap();
1120 }
1121 let reopened = BypassRecordStore::load(&path);
1122 assert_eq!(reopened.len(), 1);
1123 let r = reopened.get("css").unwrap();
1124 assert_eq!(r.failure_class, BypassFailureClass::CircuitBreaker);
1125 assert_eq!(r.last_diagnostic, "circuit churn");
1126 }
1127
1128 #[test]
1129 fn store_tolerates_corrupt_or_missing_file() {
1130 let dir = tempfile::tempdir().unwrap();
1132 let missing = dir.path().join("nope.json");
1133 assert!(BypassRecordStore::load(&missing).is_empty());
1134
1135 let corrupt = dir.path().join("corrupt.json");
1137 fs::write(&corrupt, b"{ not valid json").unwrap();
1138 assert!(BypassRecordStore::load(&corrupt).is_empty());
1139 }
1140
1141 #[test]
1142 fn default_path_ends_with_bypass_records_json() {
1143 assert!(default_bypass_record_path().ends_with("bypass_records.json"));
1144 }
1145
1146 #[test]
1149 fn classify_disable_reason_recognizes_transient_classes() {
1150 assert_eq!(
1151 classify_disable_reason("ssh: connection refused"),
1152 Some(BypassFailureClass::Ssh)
1153 );
1154 assert_eq!(
1155 classify_disable_reason("No space left on device"),
1156 Some(BypassFailureClass::DiskInodePressure)
1157 );
1158 assert_eq!(
1159 classify_disable_reason("circuit breaker tripped"),
1160 Some(BypassFailureClass::CircuitBreaker)
1161 );
1162 assert_eq!(
1163 classify_disable_reason("missing rustup toolchain nightly"),
1164 Some(BypassFailureClass::RuntimeToolchain)
1165 );
1166 assert_eq!(classify_disable_reason("decommissioned by ops"), None);
1168 assert_eq!(classify_disable_reason(""), None);
1169 assert_eq!(classify_disable_reason(" "), None);
1170 }
1171
1172 #[test]
1173 fn migrate_transient_disable_produces_bypass_record() {
1174 let snap = DisabledWorkerSnapshot {
1175 worker_id: "css".to_string(),
1176 host: "203.0.113.20".to_string(),
1177 user: "ubuntu".to_string(),
1178 disabled_reason: Some("ssh connection timed out during probe".to_string()),
1179 disabled_at_unix_ms: Some(TS),
1180 };
1181 match migrate_disabled_worker(&snap, TS + 100_000) {
1182 DisabledMigration::Migrate(record) => {
1183 assert_eq!(record.worker_id, "css");
1184 assert_eq!(record.failure_class, BypassFailureClass::Ssh);
1185 assert_eq!(record.first_failure_unix_ms, TS);
1186 assert_eq!(record.next_probe_unix_ms, TS + 100_000);
1188 assert_eq!(
1189 record.details.get("migrated_from").map(String::as_str),
1190 Some("admin_disabled")
1191 );
1192 assert!(
1193 record
1194 .last_diagnostic
1195 .contains("migrated from admin-disabled")
1196 );
1197 }
1198 other => panic!("expected migration, got {other:?}"),
1199 }
1200 }
1201
1202 #[test]
1203 fn migrate_deliberate_or_unknown_disable_keeps_disabled() {
1204 let no_reason = DisabledWorkerSnapshot {
1206 worker_id: "css".to_string(),
1207 host: "h".to_string(),
1208 user: "u".to_string(),
1209 disabled_reason: None,
1210 disabled_at_unix_ms: Some(TS),
1211 };
1212 assert!(matches!(
1213 migrate_disabled_worker(&no_reason, TS),
1214 DisabledMigration::KeepDisabled { .. }
1215 ));
1216
1217 let deliberate = DisabledWorkerSnapshot {
1219 disabled_reason: Some("retired hardware".to_string()),
1220 ..no_reason
1221 };
1222 assert!(matches!(
1223 migrate_disabled_worker(&deliberate, TS),
1224 DisabledMigration::KeepDisabled { .. }
1225 ));
1226 }
1227
1228 #[test]
1229 fn admin_disable_store_roundtrips_through_disk() {
1230 let dir = tempfile::tempdir().unwrap();
1231 let path = dir.path().join("admin_disables.json");
1232 let mut store = AdminDisableStore::with_path(&path);
1233 store
1234 .upsert(AdminDisableRecord {
1235 worker_id: "ovh-b".to_string(),
1236 reason: Some("cpu-capability-fault (SIGILL)".to_string()),
1237 disabled_unix_ms: 1_700_000_000_000,
1238 })
1239 .unwrap();
1240
1241 let reloaded = AdminDisableStore::load(&path);
1242 let record = reloaded.get("ovh-b").expect("record survives reload");
1243 assert_eq!(
1244 record.reason.as_deref(),
1245 Some("cpu-capability-fault (SIGILL)")
1246 );
1247 assert_eq!(record.disabled_unix_ms, 1_700_000_000_000);
1248 assert_eq!(reloaded.all().len(), 1);
1249 }
1250
1251 #[test]
1252 fn admin_disable_store_remove_persists_and_reports() {
1253 let dir = tempfile::tempdir().unwrap();
1254 let path = dir.path().join("admin_disables.json");
1255 let mut store = AdminDisableStore::with_path(&path);
1256 store
1257 .upsert(AdminDisableRecord {
1258 worker_id: "w1".to_string(),
1259 reason: None,
1260 disabled_unix_ms: 1,
1261 })
1262 .unwrap();
1263 assert!(store.remove("w1").unwrap().is_some());
1264 assert!(store.remove("w1").unwrap().is_none());
1265 assert!(AdminDisableStore::load(&path).get("w1").is_none());
1266 }
1267
1268 #[test]
1269 fn admin_disable_store_tolerates_missing_and_corrupt_files() {
1270 let dir = tempfile::tempdir().unwrap();
1271 let missing = AdminDisableStore::load(dir.path().join("nope.json"));
1272 assert!(missing.all().is_empty());
1273
1274 let corrupt_path = dir.path().join("corrupt.json");
1275 std::fs::write(&corrupt_path, b"{ not json").unwrap();
1276 let corrupt = AdminDisableStore::load(&corrupt_path);
1277 assert!(corrupt.all().is_empty());
1278 }
1279
1280 #[test]
1281 fn admin_disable_default_path_is_beside_bypass_records() {
1282 let path = default_admin_disable_path();
1283 assert!(path.ends_with("admin_disables.json"));
1284 assert_eq!(path.parent(), default_bypass_record_path().parent());
1285 }
1286}