← back to Council 0809 Builds
disk-forecaster/forecast-disk.sh
89 lines
#!/usr/bin/env bash
# Kamatera Disk Runway Forecaster (Council idea #1, 2026-08-09)
# -------------------------------------------------------------
# Forecasting, not thresholding: samples du per top directory, appends to a
# rolling log, fits a linear growth rate over the trailing window, and
# extrapolates DAYS-UNTIL-FULL. Fires a George email + CNCP card when any
# tracked path's runway drops below THRESHOLD_DAYS.
#
# Born from TK-10236: prod Postgres ENOSPC-crashed on a 98%-full disk. A
# threshold canary only screams once you're already near the wall; this
# predicts the wall ~2 weeks out so the prune happens on a weekday, calmly.
#
# Designed to run on Kamatera via cron/launchd (weekly). Safe to dry-run
# anywhere: pass a ROOT to scan and it just prints the forecast (no email).
#
# Usage:
# ROOT=/root TOP=20 THRESHOLD_DAYS=14 ./forecast-disk.sh # real
# ROOT="$HOME/Projects" DRY=1 ./forecast-disk.sh # dry-run
set -euo pipefail
ROOT="${ROOT:-/root}"
TOP="${TOP:-20}"
THRESHOLD_DAYS="${THRESHOLD_DAYS:-14}"
LOG="${LOG:-$HOME/disk-growth-log.tsv}"
DRY="${DRY:-0}"
NOW="$(date +%s)"
# --- capacity of the filesystem holding ROOT (KB) ---
avail_kb="$(df -Pk "$ROOT" | awk 'NR==2{print $4}')"
size_kb="$(df -Pk "$ROOT" | awk 'NR==2{print $2}')"
# --- sample: du -sk per top-N child dir, append to the rolling log ---
touch "$LOG"
du -sk "$ROOT"/*/ 2>/dev/null | sort -rn | head -n "$TOP" | while IFS=$'\t' read -r kb path; do
printf '%s\t%s\t%s\n' "$NOW" "$kb" "$path" >> "$LOG"
done
# --- forecast per path from the trailing samples in the log ---
# linear rate = (latest - earliest)/(t_latest - t_earliest) KB/sec over >=2 pts
# days_until_full = free_kb / (rate_kb_per_sec * 86400), attributed to fastest grower
alerts="$(awk -v now="$NOW" -v avail="$avail_kb" -v thr="$THRESHOLD_DAYS" '
{ t=$1; kb=$2; p=$3;
if (!(p in firstT) || t<firstT[p]) { firstT[p]=t; firstKB[p]=kb }
if (!(p in lastT) || t>lastT[p]) { lastT[p]=t; lastKB[p]=kb }
}
END {
for (p in lastT) {
dt = lastT[p]-firstT[p];
if (dt <= 0) continue; # need >=2 samples apart
rate = (lastKB[p]-firstKB[p])/dt; # KB/sec
if (rate <= 0) continue; # flat/shrinking = no runway risk
days = avail / (rate*86400);
if (days < thr) printf "%s\t%.1f\t%.2f\n", p, days, rate*86400/1024/1024;
}
}' "$LOG" | sort -t$'\t' -k2 -n)"
pct_used="$(df -Pk "$ROOT" | awk 'NR==2{gsub("%","",$5); print $5}')"
echo "[$(date -u +%FT%TZ)] ROOT=$ROOT used=${pct_used}% avail=$((avail_kb/1024/1024))G threshold=${THRESHOLD_DAYS}d"
if [ -z "$alerts" ]; then
echo "OK: no tracked path forecast to fill within ${THRESHOLD_DAYS} days (or <2 samples yet)."
exit 0
fi
echo "RUNWAY ALERT — paths forecast to fill within ${THRESHOLD_DAYS} days:"
echo "$alerts" | awk -F'\t' '{printf " %-40s %5.1f days (+%.2f GB/day)\n",$1,$2,$3}'
if [ "$DRY" = "1" ]; then
echo "(dry-run: email/CNCP suppressed)"
exit 0
fi
# --- fire George email + CNCP card (only on real run) ---
body="Kamatera disk runway forecast — paths projected to fill within ${THRESHOLD_DAYS} days:
$(echo "$alerts" | awk -F'\t' '{printf " %s : %.1f days (+%.2f GB/day)\n",$1,$2,$3}')
Filesystem holding ${ROOT}: ${pct_used}% used, $((avail_kb/1024/1024))G free.
Prune before the wall — TK-10236 was an ENOSPC crash. Suggested: homesonspec/var/snapshots cache."
curl -s -X POST http://127.0.0.1:9850/api/send \
-H 'Content-Type: application/json' \
-u "admin:${GEORGE_BASIC_AUTH_PASS:-}" \
-d "$(python3 -c 'import json,os,sys; print(json.dumps({"to":"steve@designerwallcoverings.com","subject":"[disk-forecaster] Kamatera runway < '"$THRESHOLD_DAYS"'d","text":sys.stdin.read()}))' <<<"$body")" \
>/dev/null 2>&1 || echo "(warn: George send failed)"
curl -s -X POST http://127.0.0.1:3333/api/parking-lot \
-H 'Content-Type: application/json' \
-d "{\"project\":\"kamatera-ops\",\"title\":\"Disk runway < ${THRESHOLD_DAYS}d\",\"detail\":$(python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))' <<<"$body")}" \
>/dev/null 2>&1 || true
echo "alerted."