use crate::core::shell_escape::sh_squote;
use crate::core::types::{DiskBudget, Resource};
use crate::core::types::{
DEFAULT_CRITICAL_FREE_GB, DEFAULT_HIGH_WATERMARK_PCT, DEFAULT_SCHEDULE, DEFAULT_TARGET_FREE_PCT,
};
mod detect;
mod reaper;
mod units;
#[cfg(test)]
mod tests;
const STALE_AFTER_MISSED_RUNS: u64 = 3;
pub fn budget_of(resource: &Resource) -> Result<DiskBudget, String> {
let path = resource
.path
.as_deref()
.ok_or_else(|| "disk_budget requires `path` (the filesystem to budget)".to_string())?;
DiskBudget::new(
path,
resource
.budget_high_watermark_pct
.unwrap_or(DEFAULT_HIGH_WATERMARK_PCT),
resource
.budget_target_free_pct
.unwrap_or(DEFAULT_TARGET_FREE_PCT),
resource
.budget_critical_free_gb
.unwrap_or(DEFAULT_CRITICAL_FREE_GB),
resource
.budget_schedule
.as_deref()
.unwrap_or(DEFAULT_SCHEDULE),
resource.budget_reclaim.clone(),
)
}
fn slug(path: &str) -> String {
let s: String = path
.trim_matches('/')
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
if s.is_empty() {
"root".to_string()
} else {
s
}
}
fn script_path(path: &str) -> String {
format!("/usr/local/sbin/forjar-disk-budget-{}.sh", slug(path))
}
fn service_name(path: &str) -> String {
format!("forjar-disk-budget-{}", slug(path))
}
fn service_path(path: &str) -> String {
format!("/etc/systemd/system/{}.service", service_name(path))
}
fn timer_path(path: &str) -> String {
format!("/etc/systemd/system/{}.timer", service_name(path))
}
fn status_json(path: &str) -> String {
format!("/run/forjar-disk-budget-{}.json", slug(path))
}
fn reject(msg: &str) -> String {
format!("echo {} >&2; exit 1", sh_squote(&format!("ERROR: {msg}")))
}
fn stale_secs(schedule: &str) -> u64 {
let period = match schedule {
"minutely" => 60,
"hourly" => 3600,
"daily" => 86400,
"weekly" => 604_800,
_ => 3600,
};
period * STALE_AFTER_MISSED_RUNS
}
pub fn check_script(resource: &Resource) -> String {
let budget = match budget_of(resource) {
Ok(b) => b,
Err(e) => return reject(&e),
};
let p = sh_squote(&budget.path);
let svc = service_path(&budget.path);
let scr = script_path(&budget.path);
let high = budget.high_watermark_pct;
format!(
"set -u\n\
if [ ! -f {scr} ] || [ ! -f {svc} ]; then echo 'absent'; exit 0; fi\n\
set -- $(df -P -k {p} 2>/dev/null | awk 'NR==2{{gsub(/%/,\"\",$5); print $5, $4}}')\n\
USED=\"${{1:-0}}\"; FREEGB=$((${{2:-0}} / 1024 / 1024))\n\
if [ \"$USED\" -lt {high} ]; then echo 'present'; else echo 'over-budget'; fi\n\
echo \"used_pct=$USED free_gb=$FREEGB\" >&2\n"
)
}
pub fn reaper_script(resource: &Resource) -> Result<String, String> {
let budget = budget_of(resource)?;
Ok(reaper::script(
&budget,
&status_json(&budget.path),
&service_name(&budget.path),
))
}
pub fn apply_script(resource: &Resource) -> String {
let budget = match budget_of(resource) {
Ok(b) => b,
Err(e) => return reject(&e),
};
if resource.state.as_deref() == Some("absent") {
return remove_script(&budget);
}
let scr = script_path(&budget.path);
let scr_q = sh_squote(&scr);
let name = service_name(&budget.path);
let body = reaper::script(&budget, &status_json(&budget.path), &name);
let svc = units::service_unit(&scr, &budget.path);
let tmr = units::timer_unit(&budget.schedule, &budget.path);
format!(
"set -eu\n\
# -- reaper script --\n\
mkdir -p /usr/local/sbin\n\
NEW_SCRIPT=$(cat <<'FORJAR_REAPER_EOF'\n\
{body}\n\
FORJAR_REAPER_EOF\n\
)\n\
if [ ! -f {scr_q} ] || [ \"$NEW_SCRIPT\" != \"$(cat {scr_q} 2>/dev/null)\" ]; then\n\
\x20 printf '%s\\n' \"$NEW_SCRIPT\" >{scr_q}\n\
fi\n\
chmod 0755 {scr_q}\n\
# -- units --\n\
{svc_install}{tmr_install}\n\
if [ \"$SVC_CHANGED\" = \"1\" ] || [ \"$TMR_CHANGED\" = \"1\" ]; then\n\
\x20 systemctl daemon-reload\n\
fi\n\
systemctl enable {name}.timer >/dev/null 2>&1 || true\n\
# Restart (not start) so a unit-content change actually takes effect.\n\
systemctl restart {name}.timer\n\
# Run one pass now so `apply` converges the budget instead of merely\n\
# scheduling it. A missed budget surfaces here, at apply time.\n\
#\n\
# #334: SAY WHICH MODE RAN. The reaper previews unless granted the\n\
# opt-in, and this grant is constant script text — never read from the\n\
# operator's environment — because `canonical_generated_script` hashes\n\
# this string into `hash_desired_state`. An env-dependent apply script\n\
# would make a machine's desired state depend on whoever ran forjar.\n\
echo 'forjar: running one disk-budget reclaim pass in EXECUTE mode (this deletes)'\n\
FORJAR_BUDGET_EXECUTE=1 {scr_q}\n",
svc_install = units::install_unit(&service_path(&budget.path), &svc, "SVC_CHANGED"),
tmr_install = units::install_unit(&timer_path(&budget.path), &tmr, "TMR_CHANGED"),
)
}
fn remove_script(budget: &DiskBudget) -> String {
let name = service_name(&budget.path);
let scr = sh_squote(&script_path(&budget.path));
let svc = sh_squote(&service_path(&budget.path));
let tmr = sh_squote(&timer_path(&budget.path));
format!(
"set -u\n\
systemctl disable --now {name}.timer >/dev/null 2>&1 || true\n\
rm -f {scr} {svc} {tmr}\n\
systemctl daemon-reload || true\n"
)
}
pub fn state_query_script(resource: &Resource) -> String {
let budget = match budget_of(resource) {
Ok(b) => b,
Err(e) => return reject(&e),
};
let p = sh_squote(&budget.path);
let status = sh_squote(&status_json(&budget.path));
let name = service_name(&budget.path);
let high = budget.high_watermark_pct;
let crit = budget.critical_free_gb;
let stale_min = stale_secs(&budget.schedule) / 60;
let scr = script_path(&budget.path);
let svc = service_path(&budget.path);
let tmr = timer_path(&budget.path);
format!(
"set -u\n\
set -- $(df -P -k {p} 2>/dev/null | awk 'NR==2{{gsub(/%/,\"\",$5); print $5, $4}}')\n\
USED=\"${{1:-0}}\"; FREEGB=$((${{2:-0}} / 1024 / 1024))\n\
if [ \"$FREEGB\" -lt {crit} ]; then echo 'disk_budget_tier=critical'\n\
elif [ \"$USED\" -ge {high} ]; then echo 'disk_budget_tier=pressure'\n\
else echo 'disk_budget_tier=ok'; fi\n\
echo \"disk_budget_installed=$([ -f {scr} ] && echo yes || echo no)\"\n\
# Hash the DEPLOYED reaper. Without this, the state hash is computed\n\
# only from runtime classes, so regenerating the script (a forjar\n\
# upgrade, an edited reclaim rule) is invisible: `apply` reports\n\
# \"unchanged\" and the machine keeps running the OLD reaper forever.\n\
# That is the same silent-desync this resource exists to eliminate.\n\
echo \"disk_budget_script_sha=$( (sha256sum {scr} 2>/dev/null || echo missing) | awk '{{print $1}}')\"\n\
echo \"disk_budget_unit_sha=$( (sha256sum {svc} 2>/dev/null || echo missing) | awk '{{print $1}}')\"\n\
echo \"disk_budget_timer_sha=$( (sha256sum {tmr} 2>/dev/null || echo missing) | awk '{{print $1}}')\"\n\
# `systemctl is-active`/`is-failed` PRINT a state and still exit non-zero\n\
# for most states, so `$(... || echo unknown)` captures BOTH and emits a\n\
# stray second line into the drift-hashed output. Take the first line\n\
# and default only when it is genuinely empty.\n\
TMR_STATE=\"$(systemctl is-active {name}.timer 2>/dev/null | head -1)\"\n\
UNIT_STATE=\"$(systemctl is-failed {name}.service 2>/dev/null | head -1)\"\n\
echo \"disk_budget_timer=${{TMR_STATE:-unknown}}\"\n\
echo \"disk_budget_unit=${{UNIT_STATE:-unknown}}\"\n\
HE=\"$(sed -n 's/.*\"health\":\"\\([a-z][a-z]*\\)\".*/\\1/p' {status} 2>/dev/null | head -1)\"\n\
RB=\"$(sed -n 's/.*\"reclaimed_bytes\":\\([0-9][0-9]*\\).*/\\1/p' {status} 2>/dev/null | head -1)\"\n\
AGED=\"$(find {status} -mmin +{stale_min} 2>/dev/null)\"\n\
if [ ! -f {status} ]; then echo 'disk_budget_heartbeat=missing'\n\
elif [ -n \"$AGED\" ]; then echo 'disk_budget_heartbeat=stale'\n\
else echo 'disk_budget_heartbeat=fresh'; fi\n\
echo \"disk_budget_health=${{HE:-unknown}}\"\n\
# raw, volatile values -> stderr only (never drift-hashed)\n\
echo \"disk_budget_used_pct=$USED disk_budget_free_gb=$FREEGB disk_budget_last_reclaimed=${{RB:-0}}\" >&2\n"
)
}