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 = "Vec::is_empty")]
308 pub disk_roots: Vec<String>,
309 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
311 pub details: BTreeMap<String, String>,
312}
313
314impl BypassRecord {
315 #[must_use]
319 pub fn new(
320 worker_id: impl Into<String>,
321 host: impl Into<String>,
322 user: impl Into<String>,
323 failure_class: BypassFailureClass,
324 first_failure_unix_ms: u64,
325 ) -> Self {
326 let backoff = BypassBackoff::initial();
327 Self {
328 schema_version: bypass_record_schema_version().to_string(),
329 worker_id: worker_id.into(),
330 host: host.into(),
331 user: user.into(),
332 failure_class,
333 reason_code: failure_class.incident_reason_code(),
334 state: BypassState::TemporaryBypass,
335 first_failure_unix_ms,
336 last_failure_unix_ms: first_failure_unix_ms,
337 next_probe_unix_ms: first_failure_unix_ms.saturating_add(backoff.current_ms),
338 backoff,
339 consecutive_failures: 1,
340 consecutive_passes: 0,
341 last_diagnostic: String::new(),
342 auto_rejoin: AutoRejoinCriteria::default(),
343 local_fallback_allowed: true,
344 disk_roots: Vec::new(),
345 details: BTreeMap::new(),
346 }
347 }
348
349 #[must_use]
351 pub fn with_diagnostic(mut self, diagnostic: impl Into<String>) -> Self {
352 self.last_diagnostic = truncate_diagnostic(diagnostic.into());
353 self
354 }
355
356 #[must_use]
358 pub fn with_local_fallback_allowed(mut self, allowed: bool) -> Self {
359 self.local_fallback_allowed = allowed;
360 self
361 }
362
363 #[must_use]
365 pub fn with_auto_rejoin(mut self, criteria: AutoRejoinCriteria) -> Self {
366 self.auto_rejoin = criteria;
367 self
368 }
369
370 #[must_use]
372 pub fn with_detail(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
373 self.details.insert(key.into(), value.into());
374 self
375 }
376
377 pub fn record_failure(&mut self, now_unix_ms: u64, diagnostic: impl Into<String>) {
384 self.last_failure_unix_ms = now_unix_ms;
385 self.consecutive_failures = self.consecutive_failures.saturating_add(1);
386 self.consecutive_passes = 0;
387 self.backoff = self.backoff.advanced();
388 self.next_probe_unix_ms = now_unix_ms.saturating_add(self.backoff.current_ms);
389 self.state = BypassState::TemporaryBypass;
390 let diagnostic = diagnostic.into();
391 if !diagnostic.is_empty() {
392 self.last_diagnostic = truncate_diagnostic(diagnostic);
393 }
394 }
395
396 pub fn record_probe_pass(&mut self, now_unix_ms: u64) -> bool {
403 self.consecutive_passes = self.consecutive_passes.saturating_add(1);
404 self.consecutive_failures = 0;
405 self.next_probe_unix_ms = now_unix_ms.saturating_add(self.backoff.current_ms);
406 let met = self.consecutive_passes >= self.auto_rejoin.required_consecutive_passes;
407 if met && self.auto_rejoin.canary_required {
408 self.state = BypassState::RecoveredPendingCanary;
409 }
410 met
411 }
412
413 #[must_use]
415 pub const fn probe_due(&self, now_unix_ms: u64) -> bool {
416 now_unix_ms >= self.next_probe_unix_ms
417 }
418}
419
420#[must_use]
423pub fn truncate_diagnostic(mut s: String) -> String {
424 if s.chars().count() <= MAX_DIAGNOSTIC_CHARS {
425 return s;
426 }
427 let cut = s
428 .char_indices()
429 .nth(MAX_DIAGNOSTIC_CHARS)
430 .map_or(s.len(), |(i, _)| i);
431 s.truncate(cut);
432 s.push('…');
433 s
434}
435
436#[must_use]
438pub fn bypass_record_schema_version() -> &'static str {
439 current_version(SchemaComponent::WorkerBypassRecord)
440}
441
442#[must_use]
444pub fn bypass_record_schema() -> Schema {
445 SchemaSettings::draft07()
446 .into_generator()
447 .into_root_schema_for::<BypassRecord>()
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
457struct BypassRecordFile {
458 schema_version: String,
460 records: Vec<BypassRecord>,
462}
463
464#[must_use]
467pub fn default_bypass_record_path() -> PathBuf {
468 let base = std::env::var_os("RCH_STATE_HOME")
469 .map(PathBuf::from)
470 .filter(|p| !p.as_os_str().is_empty())
471 .or_else(|| {
472 std::env::var_os("XDG_STATE_HOME")
473 .map(|d| PathBuf::from(d).join("rch"))
474 .filter(|p| p.as_os_str().len() > "rch".len())
475 })
476 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state/rch")))
477 .unwrap_or_else(|| PathBuf::from("/tmp/rch"));
478 base.join("bypass_records.json")
479}
480
481#[derive(Debug, Clone)]
484pub struct BypassRecordStore {
485 path: PathBuf,
486 records: BTreeMap<String, BypassRecord>,
487}
488
489impl BypassRecordStore {
490 #[must_use]
492 pub fn with_path(path: impl Into<PathBuf>) -> Self {
493 Self {
494 path: path.into(),
495 records: BTreeMap::new(),
496 }
497 }
498
499 #[must_use]
503 pub fn load(path: impl Into<PathBuf>) -> Self {
504 let path = path.into();
505 let mut records = BTreeMap::new();
506 if let Ok(bytes) = fs::read(&path)
507 && let Ok(file) = serde_json::from_slice::<BypassRecordFile>(&bytes)
508 {
509 for record in file.records {
510 records.insert(record.worker_id.clone(), record);
512 }
513 }
514 Self { path, records }
515 }
516
517 #[must_use]
519 pub fn path(&self) -> &Path {
520 &self.path
521 }
522
523 #[must_use]
525 pub fn get(&self, worker_id: &str) -> Option<&BypassRecord> {
526 self.records.get(worker_id)
527 }
528
529 #[must_use]
531 pub fn contains(&self, worker_id: &str) -> bool {
532 self.records.contains_key(worker_id)
533 }
534
535 #[must_use]
537 pub fn all(&self) -> Vec<&BypassRecord> {
538 self.records.values().collect()
539 }
540
541 #[must_use]
543 pub fn len(&self) -> usize {
544 self.records.len()
545 }
546
547 #[must_use]
549 pub fn is_empty(&self) -> bool {
550 self.records.is_empty()
551 }
552
553 pub fn upsert(&mut self, record: BypassRecord) -> std::io::Result<()> {
555 self.records.insert(record.worker_id.clone(), record);
556 self.persist()
557 }
558
559 pub fn remove(&mut self, worker_id: &str) -> std::io::Result<Option<BypassRecord>> {
562 let removed = self.records.remove(worker_id);
563 if removed.is_some() {
564 self.persist()?;
565 }
566 Ok(removed)
567 }
568
569 fn persist(&self) -> std::io::Result<()> {
572 if let Some(parent) = self.path.parent()
573 && !parent.as_os_str().is_empty()
574 {
575 fs::create_dir_all(parent)?;
576 }
577 let doc = BypassRecordFile {
578 schema_version: bypass_record_schema_version().to_string(),
579 records: self.records.values().cloned().collect(),
580 };
581 let body = serde_json::to_vec_pretty(&doc)
582 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
583
584 let parent = self
585 .path
586 .parent()
587 .filter(|p| !p.as_os_str().is_empty())
588 .unwrap_or_else(|| Path::new("."));
589 let tmp = parent.join(format!(".bypass_records.{}.tmp", std::process::id()));
590 {
591 let mut tmp_file = OpenOptions::new()
592 .create(true)
593 .write(true)
594 .truncate(true)
595 .open(&tmp)?;
596 tmp_file.write_all(&body)?;
597 tmp_file.flush()?;
598 tmp_file.sync_all()?;
599 }
600 fs::rename(&tmp, &self.path)?;
601 #[cfg(unix)]
602 fs::File::open(parent)?.sync_all()?;
603 Ok(())
604 }
605}
606
607#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
623pub struct AdminDisableRecord {
624 pub worker_id: String,
626 #[serde(default, skip_serializing_if = "Option::is_none")]
628 pub reason: Option<String>,
629 pub disabled_unix_ms: u64,
631}
632
633#[derive(Debug, Serialize, Deserialize)]
635struct AdminDisableFile {
636 schema_version: u32,
637 records: Vec<AdminDisableRecord>,
638}
639
640const ADMIN_DISABLE_SCHEMA_VERSION: u32 = 1;
641
642#[must_use]
645pub fn default_admin_disable_path() -> PathBuf {
646 default_bypass_record_path().with_file_name("admin_disables.json")
647}
648
649#[derive(Debug)]
655pub struct AdminDisableStore {
656 path: PathBuf,
657 records: BTreeMap<String, AdminDisableRecord>,
658}
659
660impl AdminDisableStore {
661 #[must_use]
663 pub fn with_path(path: impl Into<PathBuf>) -> Self {
664 Self {
665 path: path.into(),
666 records: BTreeMap::new(),
667 }
668 }
669
670 #[must_use]
672 pub fn load(path: impl Into<PathBuf>) -> Self {
673 let path = path.into();
674 let mut records = BTreeMap::new();
675 if let Ok(bytes) = fs::read(&path)
676 && let Ok(file) = serde_json::from_slice::<AdminDisableFile>(&bytes)
677 {
678 for record in file.records {
679 records.insert(record.worker_id.clone(), record);
680 }
681 }
682 Self { path, records }
683 }
684
685 #[must_use]
687 pub fn path(&self) -> &Path {
688 &self.path
689 }
690
691 #[must_use]
693 pub fn get(&self, worker_id: &str) -> Option<&AdminDisableRecord> {
694 self.records.get(worker_id)
695 }
696
697 #[must_use]
699 pub fn all(&self) -> Vec<&AdminDisableRecord> {
700 self.records.values().collect()
701 }
702
703 pub fn upsert(&mut self, record: AdminDisableRecord) -> std::io::Result<()> {
705 self.records.insert(record.worker_id.clone(), record);
706 self.persist()
707 }
708
709 pub fn remove(&mut self, worker_id: &str) -> std::io::Result<Option<AdminDisableRecord>> {
712 let removed = self.records.remove(worker_id);
713 if removed.is_some() {
714 self.persist()?;
715 }
716 Ok(removed)
717 }
718
719 fn persist(&self) -> std::io::Result<()> {
721 if let Some(parent) = self.path.parent()
722 && !parent.as_os_str().is_empty()
723 {
724 fs::create_dir_all(parent)?;
725 }
726 let doc = AdminDisableFile {
727 schema_version: ADMIN_DISABLE_SCHEMA_VERSION,
728 records: self.records.values().cloned().collect(),
729 };
730 let body = serde_json::to_vec_pretty(&doc)
731 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
732
733 let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
734 let tmp = parent.join(format!(".admin_disables.{}.tmp", std::process::id()));
735 {
736 let mut tmp_file = OpenOptions::new()
737 .create(true)
738 .write(true)
739 .truncate(true)
740 .open(&tmp)?;
741 tmp_file.write_all(&body)?;
742 tmp_file.flush()?;
743 }
744 match fs::rename(&tmp, &self.path) {
745 Ok(()) => Ok(()),
746 Err(e) => {
747 let _ = fs::remove_file(&tmp);
748 Err(e)
749 }
750 }
751 }
752}
753
754#[derive(Debug, Clone, PartialEq, Eq)]
762pub struct DisabledWorkerSnapshot {
763 pub worker_id: String,
765 pub host: String,
767 pub user: String,
769 pub disabled_reason: Option<String>,
771 pub disabled_at_unix_ms: Option<u64>,
773}
774
775#[derive(Debug, Clone, PartialEq, Eq)]
777pub enum DisabledMigration {
778 Migrate(Box<BypassRecord>),
781 KeepDisabled {
783 reason: String,
785 },
786}
787
788#[must_use]
796pub fn classify_disable_reason(reason: &str) -> Option<BypassFailureClass> {
797 let r = reason.to_ascii_lowercase();
798 if r.trim().is_empty() {
799 return None;
800 }
801 const TABLE: &[(&[&str], BypassFailureClass)] = &[
803 (
804 &["no space", "enospc", "disk full", "inode", "disk pressure"],
805 BypassFailureClass::DiskInodePressure,
806 ),
807 (
808 &["toolchain", "rustup", "nightly", "missing target", "rustc"],
809 BypassFailureClass::RuntimeToolchain,
810 ),
811 (
812 &[
813 "rch-wkr",
814 "worker binary",
815 "wrong user",
816 "wrong path",
817 "no such file",
818 ],
819 BypassFailureClass::WorkerBinary,
820 ),
821 (&["circuit"], BypassFailureClass::CircuitBreaker),
822 (
823 &["telemetry", "stale", "heartbeat"],
824 BypassFailureClass::StaleTelemetry,
825 ),
826 (
827 &["rsync", "vanished", "path sync", "source sync"],
828 BypassFailureClass::PathSync,
829 ),
830 (&["artifact"], BypassFailureClass::ArtifactRetrieval),
831 (
832 &["os/arch", "architecture", "arch mismatch", "triple"],
833 BypassFailureClass::OsArchMismatch,
834 ),
835 (
836 &[
837 "ssh",
838 "connection refused",
839 "connection reset",
840 "connection failed",
841 "connection timed out",
842 "unreachable",
843 "timed out",
844 "auth",
845 "permission denied",
846 ],
847 BypassFailureClass::Ssh,
848 ),
849 ];
850 for (needles, class) in TABLE {
851 if needles.iter().any(|n| r.contains(n)) {
852 return Some(*class);
853 }
854 }
855 None
856}
857
858#[must_use]
864pub fn migrate_disabled_worker(
865 snapshot: &DisabledWorkerSnapshot,
866 now_unix_ms: u64,
867) -> DisabledMigration {
868 let Some(reason) = snapshot.disabled_reason.as_deref() else {
869 return DisabledMigration::KeepDisabled {
870 reason: "no disable reason recorded — treat as deliberate operator disable".to_string(),
871 };
872 };
873 let Some(class) = classify_disable_reason(reason) else {
874 return DisabledMigration::KeepDisabled {
875 reason: format!("disable reason does not look transient: {reason:?}"),
876 };
877 };
878
879 let first_failure = snapshot.disabled_at_unix_ms.unwrap_or(now_unix_ms);
880 let mut record = BypassRecord::new(
881 &snapshot.worker_id,
882 &snapshot.host,
883 &snapshot.user,
884 class,
885 first_failure,
886 )
887 .with_diagnostic(format!("migrated from admin-disabled: {reason}"))
888 .with_detail("migrated_from", "admin_disabled");
889 record.next_probe_unix_ms = now_unix_ms;
892 DisabledMigration::Migrate(Box::new(record))
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 const TS: u64 = 1_700_000_000_000;
900
901 fn record(class: BypassFailureClass) -> BypassRecord {
902 BypassRecord::new("css", "203.0.113.20", "ubuntu", class, TS)
903 }
904
905 #[test]
906 fn failure_class_wire_form_is_stable_snake_case() {
907 assert_eq!(BypassFailureClass::Ssh.as_str(), "ssh");
908 assert_eq!(
909 BypassFailureClass::DiskInodePressure.as_str(),
910 "disk_inode_pressure"
911 );
912 for c in BypassFailureClass::ALL {
914 let json = serde_json::to_string(c).unwrap();
915 assert_eq!(json, format!("\"{}\"", c.as_str()));
916 assert_eq!(BypassFailureClass::from_str_opt(c.as_str()), Some(*c));
917 }
918 assert_eq!(BypassFailureClass::from_str_opt("nonsense"), None);
919 }
920
921 #[test]
922 fn failure_class_maps_to_an_incident_reason_for_every_variant() {
923 for c in BypassFailureClass::ALL {
925 let code = c.incident_reason_code();
926 assert_eq!(
928 IncidentReasonCode::from_code_str(code.code()),
929 Some(code),
930 "reason code for {c:?} not in registry"
931 );
932 }
933 assert_eq!(
934 BypassFailureClass::Ssh.incident_reason_code(),
935 IncidentReasonCode::HardPreflight
936 );
937 assert_eq!(
938 BypassFailureClass::CircuitBreaker.incident_reason_code(),
939 IncidentReasonCode::CircuitOpen
940 );
941 assert_eq!(
942 BypassFailureClass::DiskInodePressure.incident_reason_code(),
943 IncidentReasonCode::DiskFull
944 );
945 }
946
947 #[test]
948 fn new_record_is_consistent_and_schema_stamped() {
949 let r = record(BypassFailureClass::Ssh);
950 assert_eq!(r.schema_version, bypass_record_schema_version());
951 assert_eq!(r.state, BypassState::TemporaryBypass);
952 assert_eq!(r.reason_code, IncidentReasonCode::HardPreflight);
953 assert_eq!(r.consecutive_failures, 1);
954 assert_eq!(r.consecutive_passes, 0);
955 assert_eq!(r.first_failure_unix_ms, TS);
956 assert_eq!(r.last_failure_unix_ms, TS);
957 assert_eq!(r.next_probe_unix_ms, TS + BypassBackoff::DEFAULT_INITIAL_MS);
958 assert!(r.local_fallback_allowed);
959 }
960
961 #[test]
962 fn record_serializes_required_fields_and_roundtrips() {
963 let r = record(BypassFailureClass::CircuitBreaker)
964 .with_diagnostic("circuit open after 5 failures")
965 .with_local_fallback_allowed(false)
966 .with_detail("attempts", "5");
967 let v = serde_json::to_value(&r).unwrap();
968 for key in [
970 "worker_id",
971 "host",
972 "user",
973 "failure_class",
974 "reason_code",
975 "state",
976 "first_failure_unix_ms",
977 "last_failure_unix_ms",
978 "next_probe_unix_ms",
979 "backoff",
980 "consecutive_failures",
981 "consecutive_passes",
982 "last_diagnostic",
983 "auto_rejoin",
984 "local_fallback_allowed",
985 ] {
986 assert!(v.get(key).is_some(), "missing record field {key}");
987 }
988 assert_eq!(v["failure_class"], "circuit_breaker");
989 assert_eq!(v["reason_code"], "RCH-I009");
990 assert_eq!(v["local_fallback_allowed"], false);
991 let back: BypassRecord = serde_json::from_value(v).unwrap();
992 assert_eq!(back, r);
993 }
994
995 #[test]
996 fn record_never_carries_raw_command_or_secret_fields() {
997 let v = serde_json::to_value(record(BypassFailureClass::Ssh)).unwrap();
998 let obj = v.as_object().unwrap();
999 for forbidden in ["command", "raw_command", "secret", "password", "token"] {
1000 assert!(
1001 !obj.contains_key(forbidden),
1002 "must not expose `{forbidden}`"
1003 );
1004 }
1005 }
1006
1007 #[test]
1008 fn diagnostic_is_truncated() {
1009 let long = "x".repeat(MAX_DIAGNOSTIC_CHARS + 50);
1010 let r = record(BypassFailureClass::Ssh).with_diagnostic(long);
1011 assert!(r.last_diagnostic.chars().count() <= MAX_DIAGNOSTIC_CHARS + 1);
1012 assert!(r.last_diagnostic.ends_with('…'));
1013 let short = record(BypassFailureClass::Ssh).with_diagnostic("brief");
1015 assert_eq!(short.last_diagnostic, "brief");
1016 }
1017
1018 #[test]
1019 fn backoff_doubles_up_to_ceiling() {
1020 let mut b = BypassBackoff::initial();
1021 assert_eq!(b.current_ms, BypassBackoff::DEFAULT_INITIAL_MS);
1022 let mut last = b.current_ms;
1023 for _ in 0..20 {
1024 b = b.advanced();
1025 assert!(b.current_ms >= last);
1026 assert!(b.current_ms <= BypassBackoff::DEFAULT_MAX_MS);
1027 last = b.current_ms;
1028 }
1029 assert_eq!(b.current_ms, BypassBackoff::DEFAULT_MAX_MS);
1031 assert_eq!(b.attempts, 20);
1032 }
1033
1034 #[test]
1035 fn record_failure_advances_backoff_and_resets_passes() {
1036 let mut r = record(BypassFailureClass::Ssh);
1037 r.consecutive_passes = 1;
1038 let before = r.backoff.current_ms;
1039 r.record_failure(TS + 1000, "ssh timed out");
1040 assert_eq!(r.consecutive_failures, 2);
1041 assert_eq!(r.consecutive_passes, 0);
1042 assert!(r.backoff.current_ms > before);
1043 assert_eq!(r.next_probe_unix_ms, TS + 1000 + r.backoff.current_ms);
1044 assert_eq!(r.last_diagnostic, "ssh timed out");
1045 assert_eq!(r.state, BypassState::TemporaryBypass);
1046 }
1047
1048 #[test]
1049 fn probe_pass_advances_to_canary_when_criteria_met() {
1050 let mut r = record(BypassFailureClass::Ssh);
1051 assert!(!r.record_probe_pass(TS + 1));
1053 assert_eq!(r.state, BypassState::TemporaryBypass);
1054 assert!(r.record_probe_pass(TS + 2));
1055 assert_eq!(r.state, BypassState::RecoveredPendingCanary);
1056 assert_eq!(r.consecutive_passes, 2);
1057 assert_eq!(r.consecutive_failures, 0);
1058 }
1059
1060 #[test]
1061 fn probe_due_respects_schedule() {
1062 let r = record(BypassFailureClass::Ssh);
1063 assert!(!r.probe_due(r.next_probe_unix_ms - 1));
1064 assert!(r.probe_due(r.next_probe_unix_ms));
1065 assert!(r.probe_due(r.next_probe_unix_ms + 1));
1066 }
1067
1068 #[test]
1069 fn schema_export_names_the_record_and_fields() {
1070 let text = serde_json::to_string(&bypass_record_schema()).unwrap();
1071 assert!(text.contains("BypassRecord"));
1072 for f in [
1073 "failure_class",
1074 "reason_code",
1075 "next_probe_unix_ms",
1076 "auto_rejoin",
1077 "local_fallback_allowed",
1078 ] {
1079 assert!(text.contains(f), "schema omits {f}");
1080 }
1081 }
1082
1083 #[test]
1086 fn store_upsert_get_remove_and_persist() {
1087 let dir = tempfile::tempdir().unwrap();
1088 let path = dir.path().join("bypass_records.json");
1089 let mut store = BypassRecordStore::load(&path);
1090 assert!(store.is_empty());
1091
1092 store.upsert(record(BypassFailureClass::Ssh)).unwrap();
1093 store
1094 .upsert(BypassRecord::new(
1095 "bil",
1096 "203.0.113.21",
1097 "ubuntu",
1098 BypassFailureClass::DiskInodePressure,
1099 TS,
1100 ))
1101 .unwrap();
1102 assert_eq!(store.len(), 2);
1103 assert!(store.contains("css"));
1104 assert_eq!(
1105 store.get("css").unwrap().failure_class,
1106 BypassFailureClass::Ssh
1107 );
1108 let ids: Vec<&str> = store.all().iter().map(|r| r.worker_id.as_str()).collect();
1110 assert_eq!(ids, vec!["bil", "css"]);
1111
1112 let removed = store.remove("css").unwrap();
1113 assert_eq!(removed.unwrap().worker_id, "css");
1114 assert!(!store.contains("css"));
1115 assert!(store.remove("nope").unwrap().is_none());
1116 }
1117
1118 #[test]
1119 fn store_survives_reload_across_instances() {
1120 let dir = tempfile::tempdir().unwrap();
1121 let path = dir.path().join("bypass_records.json");
1122 {
1123 let mut store = BypassRecordStore::load(&path);
1124 store
1125 .upsert(record(BypassFailureClass::CircuitBreaker).with_diagnostic("circuit churn"))
1126 .unwrap();
1127 }
1128 let reopened = BypassRecordStore::load(&path);
1129 assert_eq!(reopened.len(), 1);
1130 let r = reopened.get("css").unwrap();
1131 assert_eq!(r.failure_class, BypassFailureClass::CircuitBreaker);
1132 assert_eq!(r.last_diagnostic, "circuit churn");
1133 }
1134
1135 #[test]
1136 fn store_tolerates_corrupt_or_missing_file() {
1137 let dir = tempfile::tempdir().unwrap();
1139 let missing = dir.path().join("nope.json");
1140 assert!(BypassRecordStore::load(&missing).is_empty());
1141
1142 let corrupt = dir.path().join("corrupt.json");
1144 fs::write(&corrupt, b"{ not valid json").unwrap();
1145 assert!(BypassRecordStore::load(&corrupt).is_empty());
1146 }
1147
1148 #[test]
1149 fn default_path_ends_with_bypass_records_json() {
1150 assert!(default_bypass_record_path().ends_with("bypass_records.json"));
1151 }
1152
1153 #[test]
1156 fn classify_disable_reason_recognizes_transient_classes() {
1157 assert_eq!(
1158 classify_disable_reason("ssh: connection refused"),
1159 Some(BypassFailureClass::Ssh)
1160 );
1161 assert_eq!(
1162 classify_disable_reason("No space left on device"),
1163 Some(BypassFailureClass::DiskInodePressure)
1164 );
1165 assert_eq!(
1166 classify_disable_reason("circuit breaker tripped"),
1167 Some(BypassFailureClass::CircuitBreaker)
1168 );
1169 assert_eq!(
1170 classify_disable_reason("missing rustup toolchain nightly"),
1171 Some(BypassFailureClass::RuntimeToolchain)
1172 );
1173 assert_eq!(classify_disable_reason("decommissioned by ops"), None);
1175 assert_eq!(classify_disable_reason(""), None);
1176 assert_eq!(classify_disable_reason(" "), None);
1177 }
1178
1179 #[test]
1180 fn migrate_transient_disable_produces_bypass_record() {
1181 let snap = DisabledWorkerSnapshot {
1182 worker_id: "css".to_string(),
1183 host: "203.0.113.20".to_string(),
1184 user: "ubuntu".to_string(),
1185 disabled_reason: Some("ssh connection timed out during probe".to_string()),
1186 disabled_at_unix_ms: Some(TS),
1187 };
1188 match migrate_disabled_worker(&snap, TS + 100_000) {
1189 DisabledMigration::Migrate(record) => {
1190 assert_eq!(record.worker_id, "css");
1191 assert_eq!(record.failure_class, BypassFailureClass::Ssh);
1192 assert_eq!(record.first_failure_unix_ms, TS);
1193 assert_eq!(record.next_probe_unix_ms, TS + 100_000);
1195 assert_eq!(
1196 record.details.get("migrated_from").map(String::as_str),
1197 Some("admin_disabled")
1198 );
1199 assert!(
1200 record
1201 .last_diagnostic
1202 .contains("migrated from admin-disabled")
1203 );
1204 }
1205 other => panic!("expected migration, got {other:?}"),
1206 }
1207 }
1208
1209 #[test]
1210 fn migrate_deliberate_or_unknown_disable_keeps_disabled() {
1211 let no_reason = DisabledWorkerSnapshot {
1213 worker_id: "css".to_string(),
1214 host: "h".to_string(),
1215 user: "u".to_string(),
1216 disabled_reason: None,
1217 disabled_at_unix_ms: Some(TS),
1218 };
1219 assert!(matches!(
1220 migrate_disabled_worker(&no_reason, TS),
1221 DisabledMigration::KeepDisabled { .. }
1222 ));
1223
1224 let deliberate = DisabledWorkerSnapshot {
1226 disabled_reason: Some("retired hardware".to_string()),
1227 ..no_reason
1228 };
1229 assert!(matches!(
1230 migrate_disabled_worker(&deliberate, TS),
1231 DisabledMigration::KeepDisabled { .. }
1232 ));
1233 }
1234
1235 #[test]
1236 fn admin_disable_store_roundtrips_through_disk() {
1237 let dir = tempfile::tempdir().unwrap();
1238 let path = dir.path().join("admin_disables.json");
1239 let mut store = AdminDisableStore::with_path(&path);
1240 store
1241 .upsert(AdminDisableRecord {
1242 worker_id: "ovh-b".to_string(),
1243 reason: Some("cpu-capability-fault (SIGILL)".to_string()),
1244 disabled_unix_ms: 1_700_000_000_000,
1245 })
1246 .unwrap();
1247
1248 let reloaded = AdminDisableStore::load(&path);
1249 let record = reloaded.get("ovh-b").expect("record survives reload");
1250 assert_eq!(
1251 record.reason.as_deref(),
1252 Some("cpu-capability-fault (SIGILL)")
1253 );
1254 assert_eq!(record.disabled_unix_ms, 1_700_000_000_000);
1255 assert_eq!(reloaded.all().len(), 1);
1256 }
1257
1258 #[test]
1259 fn admin_disable_store_remove_persists_and_reports() {
1260 let dir = tempfile::tempdir().unwrap();
1261 let path = dir.path().join("admin_disables.json");
1262 let mut store = AdminDisableStore::with_path(&path);
1263 store
1264 .upsert(AdminDisableRecord {
1265 worker_id: "w1".to_string(),
1266 reason: None,
1267 disabled_unix_ms: 1,
1268 })
1269 .unwrap();
1270 assert!(store.remove("w1").unwrap().is_some());
1271 assert!(store.remove("w1").unwrap().is_none());
1272 assert!(AdminDisableStore::load(&path).get("w1").is_none());
1273 }
1274
1275 #[test]
1276 fn admin_disable_store_tolerates_missing_and_corrupt_files() {
1277 let dir = tempfile::tempdir().unwrap();
1278 let missing = AdminDisableStore::load(dir.path().join("nope.json"));
1279 assert!(missing.all().is_empty());
1280
1281 let corrupt_path = dir.path().join("corrupt.json");
1282 std::fs::write(&corrupt_path, b"{ not json").unwrap();
1283 let corrupt = AdminDisableStore::load(&corrupt_path);
1284 assert!(corrupt.all().is_empty());
1285 }
1286
1287 #[test]
1288 fn admin_disable_default_path_is_beside_bypass_records() {
1289 let path = default_admin_disable_path();
1290 assert!(path.ends_with("admin_disables.json"));
1291 assert_eq!(path.parent(), default_bypass_record_path().parent());
1292 }
1293}