#![allow(dead_code)]
use serde::Serialize;
pub const DEFAULT_MIN_FREE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
pub const DEFAULT_MIN_FREE_INODES: u64 = 50_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FsKind {
Disk,
Tmpfs,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RootRole {
FilesystemRoot,
Tmp,
CargoHome,
TargetRoot,
SyncRoot,
Override,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MountStats {
pub path: String,
pub fs_kind: FsKind,
pub free_bytes: u64,
pub free_inodes: u64,
}
#[derive(Debug, Clone)]
pub struct RootCandidate {
pub role: RootRole,
pub stats: MountStats,
pub is_override: bool,
}
impl RootCandidate {
pub fn new(role: RootRole, stats: MountStats, is_override: bool) -> Self {
Self {
role,
stats,
is_override,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RootSafetyThresholds {
pub min_free_bytes: u64,
pub min_free_inodes: u64,
}
impl Default for RootSafetyThresholds {
fn default() -> Self {
Self {
min_free_bytes: DEFAULT_MIN_FREE_BYTES,
min_free_inodes: DEFAULT_MIN_FREE_INODES,
}
}
}
impl RootSafetyThresholds {
#[must_use]
pub fn from_remediation(rem: &rch_common::remediation_config::RemediationConfig) -> Self {
Self {
min_free_bytes: rem.build_root.min_free_bytes,
min_free_inodes: rem.build_root.min_free_inodes,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RejectReason {
InsufficientBytes,
InsufficientInodes,
RamBackedAvoided,
}
#[derive(Debug, Clone, Serialize)]
pub struct RejectedRoot {
pub path: String,
pub role: RootRole,
pub fs_kind: FsKind,
pub reason: RejectReason,
pub detail: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ChosenRoot {
pub path: String,
pub role: RootRole,
pub fs_kind: FsKind,
pub free_bytes: u64,
pub free_inodes: u64,
pub rationale: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct BuildRootDecision {
pub chosen: Option<ChosenRoot>,
pub rejected: Vec<RejectedRoot>,
pub safe: bool,
pub explanation: String,
}
fn threshold_rejection(
stats: &MountStats,
thresholds: RootSafetyThresholds,
) -> Option<(RejectReason, String)> {
if stats.free_bytes < thresholds.min_free_bytes {
return Some((
RejectReason::InsufficientBytes,
format!(
"{} free < {} required",
stats.free_bytes, thresholds.min_free_bytes
),
));
}
if stats.free_inodes < thresholds.min_free_inodes {
return Some((
RejectReason::InsufficientInodes,
format!(
"{} free inodes < {} required",
stats.free_inodes, thresholds.min_free_inodes
),
));
}
None
}
fn candidate_rank(c: &RootCandidate) -> (u8, u8, u64) {
let override_rank = u8::from(c.is_override);
let fs_rank = match c.stats.fs_kind {
FsKind::Disk => 2,
FsKind::Unknown => 1,
FsKind::Tmpfs => 0,
};
(override_rank, fs_rank, c.stats.free_bytes)
}
pub fn evaluate_build_roots(
candidates: &[RootCandidate],
thresholds: RootSafetyThresholds,
) -> BuildRootDecision {
let mut rejected = Vec::new();
let mut threshold_safe: Vec<&RootCandidate> = Vec::new();
for c in candidates {
match threshold_rejection(&c.stats, thresholds) {
Some((reason, detail)) => rejected.push(RejectedRoot {
path: c.stats.path.clone(),
role: c.role,
fs_kind: c.stats.fs_kind,
reason,
detail,
}),
None => threshold_safe.push(c),
}
}
let has_safe_disk = threshold_safe
.iter()
.any(|c| c.stats.fs_kind == FsKind::Disk);
let mut eligible: Vec<&RootCandidate> = Vec::new();
for c in threshold_safe {
if c.stats.fs_kind == FsKind::Tmpfs && has_safe_disk && !c.is_override {
rejected.push(RejectedRoot {
path: c.stats.path.clone(),
role: c.role,
fs_kind: c.stats.fs_kind,
reason: RejectReason::RamBackedAvoided,
detail: "RAM-backed root skipped; a disk-backed root is available".to_string(),
});
} else {
eligible.push(c);
}
}
eligible.sort_by_key(|c| std::cmp::Reverse(candidate_rank(c)));
match eligible.first() {
Some(best) => {
let kind_note = match best.stats.fs_kind {
FsKind::Disk => "disk-backed",
FsKind::Tmpfs => "RAM-backed (no disk-backed root available)",
FsKind::Unknown => "unknown-backing",
};
let override_note = if best.is_override {
" (worker override)"
} else {
""
};
let rationale = format!(
"selected {} {root}{override_note}: {kind_note}, {} bytes / {} inodes free",
best.role_str(),
best.stats.free_bytes,
best.stats.free_inodes,
root = best.stats.path,
);
let explanation = format!("{rationale}; {} candidate(s) rejected", rejected.len());
BuildRootDecision {
chosen: Some(ChosenRoot {
path: best.stats.path.clone(),
role: best.role,
fs_kind: best.stats.fs_kind,
free_bytes: best.stats.free_bytes,
free_inodes: best.stats.free_inodes,
rationale,
}),
rejected,
safe: true,
explanation,
}
}
None => BuildRootDecision {
chosen: None,
rejected,
safe: false,
explanation: "no safe build root: every candidate is below the free-bytes/inode \
threshold or RAM-backed with no disk-backed alternative"
.to_string(),
},
}
}
impl RootCandidate {
fn role_str(&self) -> &'static str {
match self.role {
RootRole::FilesystemRoot => "/",
RootRole::Tmp => "/tmp",
RootRole::CargoHome => "cargo-home",
RootRole::TargetRoot => "target-root",
RootRole::SyncRoot => "sync-root",
RootRole::Override => "override",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const GIB: u64 = 1024 * 1024 * 1024;
fn stats(path: &str, fs_kind: FsKind, free_gib: u64, free_inodes: u64) -> MountStats {
MountStats {
path: path.to_string(),
fs_kind,
free_bytes: free_gib * GIB,
free_inodes,
}
}
fn cand(role: RootRole, stats: MountStats) -> RootCandidate {
RootCandidate::new(role, stats, false)
}
#[test]
fn drift_guard_build_root() {
let from_cfg = RootSafetyThresholds::from_remediation(
&rch_common::remediation_config::RemediationConfig::default(),
);
assert_eq!(from_cfg, RootSafetyThresholds::default());
}
#[test]
fn tmpfs_pressure_prefers_disk_backed_root() {
let candidates = vec![
cand(RootRole::Tmp, stats("/tmp", FsKind::Tmpfs, 30, 1_000_000)),
cand(
RootRole::TargetRoot,
stats("/data/tmp", FsKind::Disk, 50, 2_000_000),
),
];
let decision = evaluate_build_roots(&candidates, RootSafetyThresholds::default());
assert!(decision.safe);
let chosen = decision.chosen.unwrap();
assert_eq!(chosen.path, "/data/tmp");
assert_eq!(chosen.fs_kind, FsKind::Disk);
assert!(
decision
.rejected
.iter()
.any(|r| r.path == "/tmp" && r.reason == RejectReason::RamBackedAvoided)
);
}
#[test]
fn falls_back_to_tmpfs_when_no_disk_root_available() {
let candidates = vec![cand(
RootRole::Tmp,
stats("/tmp", FsKind::Tmpfs, 20, 800_000),
)];
let decision = evaluate_build_roots(&candidates, RootSafetyThresholds::default());
assert!(decision.safe);
let chosen = decision.chosen.unwrap();
assert_eq!(chosen.fs_kind, FsKind::Tmpfs);
assert!(chosen.rationale.contains("RAM-backed"));
assert!(decision.rejected.is_empty());
}
#[test]
fn inode_exhaustion_rejects_root() {
let candidates = vec![cand(
RootRole::TargetRoot,
stats("/data", FsKind::Disk, 500, 10),
)];
let decision = evaluate_build_roots(&candidates, RootSafetyThresholds::default());
assert!(!decision.safe);
assert!(decision.chosen.is_none());
assert_eq!(
decision.rejected[0].reason,
RejectReason::InsufficientInodes
);
}
#[test]
fn insufficient_bytes_rejects_root() {
let candidates = vec![cand(
RootRole::FilesystemRoot,
stats("/", FsKind::Disk, 0, 5_000_000),
)];
let decision = evaluate_build_roots(&candidates, RootSafetyThresholds::default());
assert!(!decision.safe);
assert_eq!(decision.rejected[0].reason, RejectReason::InsufficientBytes);
}
#[test]
fn worker_override_is_chosen_when_safe_even_over_bigger_disk() {
let candidates = vec![
cand(
RootRole::TargetRoot,
stats("/data/big", FsKind::Disk, 500, 9_000_000),
),
RootCandidate::new(
RootRole::Override,
stats("/mnt/builds", FsKind::Disk, 10, 1_000_000),
true,
),
];
let decision = evaluate_build_roots(&candidates, RootSafetyThresholds::default());
assert!(decision.safe);
assert_eq!(decision.chosen.unwrap().path, "/mnt/builds");
}
#[test]
fn override_on_tmpfs_is_kept_not_ram_avoided() {
let candidates = vec![
cand(
RootRole::TargetRoot,
stats("/data", FsKind::Disk, 50, 2_000_000),
),
RootCandidate::new(
RootRole::Override,
stats("/dev/shm/builds", FsKind::Tmpfs, 40, 1_000_000),
true,
),
];
let decision = evaluate_build_roots(&candidates, RootSafetyThresholds::default());
assert!(decision.safe);
assert_eq!(decision.chosen.unwrap().path, "/dev/shm/builds");
assert!(
!decision
.rejected
.iter()
.any(|r| r.reason == RejectReason::RamBackedAvoided)
);
}
#[test]
fn missing_inode_counts_do_not_constrain() {
let candidates = vec![cand(
RootRole::Tmp,
MountStats {
path: "/tmp".to_string(),
fs_kind: FsKind::Tmpfs,
free_bytes: 20 * GIB,
free_inodes: u64::MAX,
},
)];
let decision = evaluate_build_roots(&candidates, RootSafetyThresholds::default());
assert!(decision.safe);
}
#[test]
fn picks_most_free_bytes_among_equal_disk_roots() {
let candidates = vec![
cand(
RootRole::TargetRoot,
stats("/a", FsKind::Disk, 20, 2_000_000),
),
cand(RootRole::SyncRoot, stats("/b", FsKind::Disk, 80, 2_000_000)),
cand(
RootRole::FilesystemRoot,
stats("/", FsKind::Disk, 50, 2_000_000),
),
];
let decision = evaluate_build_roots(&candidates, RootSafetyThresholds::default());
assert_eq!(decision.chosen.unwrap().path, "/b");
}
#[test]
fn empty_candidate_pool_is_unsafe() {
let decision = evaluate_build_roots(&[], RootSafetyThresholds::default());
assert!(!decision.safe);
assert!(decision.chosen.is_none());
}
#[test]
fn decision_serializes_for_status_surfaces() {
let candidates = vec![cand(
RootRole::TargetRoot,
stats("/data", FsKind::Disk, 50, 2_000_000),
)];
let decision = evaluate_build_roots(&candidates, RootSafetyThresholds::default());
let json = serde_json::to_string(&decision).expect("serialize");
assert!(json.contains("\"safe\":true"));
assert!(json.contains("/data"));
}
}