feat(nixos): prune system+HM gens after 14d, keep ≥3 past (#128)
Some checks failed
Check / eval (push) Has been cancelled
Some checks failed
Check / eval (push) Has been cancelled
Weekly nomarchy-gen-prune deletes only generations that are both older than 14 days and beyond the three most recent past gens (current always kept), for system and Home Manager profiles. Stock nix.gc no longer uses --delete-older-than. checks.gen-prune covers selection self-test and unit wiring; doctor points at the new tool when disk is tight.
This commit is contained in:
228
pkgs/nomarchy-gen-prune/default.nix
Normal file
228
pkgs/nomarchy-gen-prune/default.nix
Normal file
@@ -0,0 +1,228 @@
|
||||
{ lib
|
||||
, writeShellScriptBin
|
||||
, coreutils
|
||||
, gnugrep
|
||||
, gawk
|
||||
, nix
|
||||
, findutils
|
||||
}:
|
||||
|
||||
# #128 — prune system + Home Manager generations:
|
||||
# drop only if older than 14 days AND beyond the 3 most recent *past*
|
||||
# gens (current + ≥3 past always kept). Store reclaim is a plain
|
||||
# nix-collect-garbage after (no --delete-older-than — that would ignore
|
||||
# the floor).
|
||||
writeShellScriptBin "nomarchy-gen-prune" ''
|
||||
set -euo pipefail
|
||||
PATH=${lib.makeBinPath [ coreutils gnugrep gawk nix findutils ]}:$PATH
|
||||
|
||||
MAX_AGE_DAYS=''${NOMARCHY_GEN_PRUNE_MAX_AGE_DAYS:-14}
|
||||
KEEP_PAST=''${NOMARCHY_GEN_PRUNE_KEEP_PAST:-3}
|
||||
DRY=0
|
||||
SELFTEST=0
|
||||
|
||||
usage() {
|
||||
echo "usage: nomarchy-gen-prune [--dry-run] [--self-test]" >&2
|
||||
echo " Prune NixOS system + Home Manager profile generations:" >&2
|
||||
echo " delete only if older than ''${MAX_AGE_DAYS}d and beyond the" >&2
|
||||
echo " ''${KEEP_PAST} most recent past gens (current always kept)." >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY=1 ;;
|
||||
--self-test) SELFTEST=1 ;;
|
||||
-h|--help) usage ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { echo "nomarchy-gen-prune: $*" >&2; }
|
||||
|
||||
# Pure selection: stdin lines "NUM EPOCH [current]"
|
||||
# stdout: generation numbers to delete (one per line).
|
||||
# Always protect current + up to KEEP_PAST gens with number < current
|
||||
# (highest first). Others delete only if EPOCH < now - MAX_AGE_DAYS.
|
||||
select_deletions() {
|
||||
local now keep_past max_age
|
||||
now=$(date +%s)
|
||||
keep_past=$KEEP_PAST
|
||||
max_age=$MAX_AGE_DAYS
|
||||
awk -v now="$now" -v keep_past="$keep_past" -v max_age="$max_age" '
|
||||
NF < 2 { next }
|
||||
{
|
||||
num = $1 + 0
|
||||
ts = $2 + 0
|
||||
cur = (NF >= 3 && $3 == "current")
|
||||
nums[n] = num; tss[num] = ts; if (cur) current = num; n++
|
||||
}
|
||||
END {
|
||||
if (n == 0) exit 0
|
||||
if (current == 0) {
|
||||
# No marker: treat highest generation number as current.
|
||||
current = nums[0]
|
||||
for (i = 1; i < n; i++) if (nums[i] > current) current = nums[i]
|
||||
}
|
||||
# Past gens: number < current, sort desc.
|
||||
pn = 0
|
||||
for (i = 0; i < n; i++) {
|
||||
if (nums[i] < current) { past[pn++] = nums[i] }
|
||||
}
|
||||
# bubble-sort past desc (small n)
|
||||
for (i = 0; i < pn; i++)
|
||||
for (j = i + 1; j < pn; j++)
|
||||
if (past[j] > past[i]) { t = past[i]; past[i] = past[j]; past[j] = t }
|
||||
protect[current] = 1
|
||||
for (i = 0; i < pn && i < keep_past; i++) protect[past[i]] = 1
|
||||
cutoff = now - (max_age * 86400)
|
||||
for (i = 0; i < n; i++) {
|
||||
g = nums[i]
|
||||
if (protect[g]) continue
|
||||
if (tss[g] < cutoff) print g
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
if [ "$SELFTEST" = 1 ]; then
|
||||
# Fixture: now-fixed via epoch math in the data itself.
|
||||
# current=10; past 9,8,7 protected; 6 is >14d old → delete; 5 is young → keep.
|
||||
export KEEP_PAST=3 MAX_AGE_DAYS=14
|
||||
now=$(date +%s)
|
||||
old=$((now - 20 * 86400))
|
||||
young=$((now - 2 * 86400))
|
||||
got=$(printf '%s\n' \
|
||||
"10 $young current" \
|
||||
"9 $old" \
|
||||
"8 $old" \
|
||||
"7 $old" \
|
||||
"6 $old" \
|
||||
"5 $young" \
|
||||
| select_deletions)
|
||||
echo "$got" | grep -qx 6 || { echo "self-test: expected only 6, got: [$got]" >&2; exit 1; }
|
||||
# Rarely rebuilt: only old gens → delete none below floor (keep 10+9+8+7)
|
||||
got=$(printf '%s\n' \
|
||||
"10 $old current" \
|
||||
"9 $old" \
|
||||
"8 $old" \
|
||||
"7 $old" \
|
||||
"6 $old" \
|
||||
| select_deletions)
|
||||
echo "$got" | grep -qx 6 || { echo "self-test: floor failed, got: [$got]" >&2; exit 1; }
|
||||
# Only 3 gens total, all old → delete nothing
|
||||
got=$(printf '%s\n' \
|
||||
"3 $old current" \
|
||||
"2 $old" \
|
||||
"1 $old" \
|
||||
| select_deletions)
|
||||
[ -z "$got" ] || { echo "self-test: expected empty, got: [$got]" >&2; exit 1; }
|
||||
log "self-test ok"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Enumerate "NUM EPOCH [current]" for a profile symlink base.
|
||||
# profile is the live link, e.g. /nix/var/nix/profiles/system
|
||||
list_gens() {
|
||||
local profile=$1
|
||||
local dir base cur_base cur_num name num ts
|
||||
[ -e "$profile" ] || [ -L "$profile" ] || return 0
|
||||
dir=$(dirname -- "$profile")
|
||||
base=$(basename -- "$profile")
|
||||
cur_base=$(basename -- "$(readlink "$profile" 2>/dev/null || true)")
|
||||
cur_num=
|
||||
case "$cur_base" in
|
||||
"$base"-*-link)
|
||||
cur_num=''${cur_base#"$base"-}
|
||||
cur_num=''${cur_num%-link}
|
||||
;;
|
||||
esac
|
||||
# shellcheck disable=SC2035
|
||||
for link in "$dir"/"$base"-*-link; do
|
||||
[ -e "$link" ] || [ -L "$link" ] || continue
|
||||
name=$(basename -- "$link")
|
||||
case "$name" in
|
||||
"$base"-*-link) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
num=''${name#"$base"-}
|
||||
num=''${num%-link}
|
||||
case "$num" in *[!0-9]*|"") continue ;; esac
|
||||
ts=$(stat -c %Y -- "$link" 2>/dev/null || echo 0)
|
||||
if [ -n "$cur_num" ] && [ "$num" = "$cur_num" ]; then
|
||||
printf '%s %s current\n' "$num" "$ts"
|
||||
else
|
||||
printf '%s %s\n' "$num" "$ts"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
prune_profile() {
|
||||
local profile=$1 label=$2
|
||||
local list dels
|
||||
if [ ! -e "$profile" ] && [ ! -L "$profile" ]; then
|
||||
log "skip $label (no profile at $profile)"
|
||||
return 0
|
||||
fi
|
||||
list=$(list_gens "$profile" || true)
|
||||
if [ -z "$list" ]; then
|
||||
log "skip $label (no generation links)"
|
||||
return 0
|
||||
fi
|
||||
dels=$(printf '%s\n' "$list" | select_deletions)
|
||||
if [ -z "$dels" ]; then
|
||||
log "$label: nothing to prune"
|
||||
return 0
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
set -- $dels
|
||||
log "$label: delete generations $* (keep current + $KEEP_PAST past; age>$MAX_AGE_DAYS d)"
|
||||
if [ "$DRY" = 1 ]; then
|
||||
log "$label: dry-run — not deleting"
|
||||
return 0
|
||||
fi
|
||||
# nix-env needs a writeable profile; system requires root.
|
||||
nix-env -p "$profile" --delete-generations "$@" \
|
||||
|| log "$label: nix-env --delete-generations failed (need root for system?)"
|
||||
}
|
||||
|
||||
prune_profile /nix/var/nix/profiles/system "system"
|
||||
|
||||
# Home Manager: per-user under /nix/var/nix/profiles and XDG state.
|
||||
if [ -d /nix/var/nix/profiles/per-user ]; then
|
||||
for u in /nix/var/nix/profiles/per-user/*; do
|
||||
[ -d "$u" ] || continue
|
||||
prune_profile "$u/home-manager" "home-manager($(basename "$u"))"
|
||||
done
|
||||
fi
|
||||
# Standalone / modern HM state dir for real users' homes.
|
||||
if [ -d /home ]; then
|
||||
for h in /home/*; do
|
||||
[ -d "$h" ] || continue
|
||||
prune_profile "$h/.local/state/nix/profiles/home-manager" \
|
||||
"home-manager-xdg($(basename "$h"))"
|
||||
done
|
||||
fi
|
||||
# root's XDG state if present
|
||||
prune_profile /root/.local/state/nix/profiles/home-manager "home-manager-xdg(root)"
|
||||
|
||||
if [ "$DRY" = 1 ]; then
|
||||
log "dry-run done (no store GC)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Reclaim store paths freed by gen deletion — no --delete-older-than
|
||||
# (would re-introduce floor-free age GC on any remaining profiles).
|
||||
log "nix-collect-garbage (dead store paths only)"
|
||||
nix-collect-garbage || true
|
||||
|
||||
# Refresh systemd-boot entries so removed system gens disappear from
|
||||
# the menu without waiting for the next rebuild.
|
||||
if [ -x /run/current-system/bin/switch-to-configuration ]; then
|
||||
log "refreshing bootloader entries"
|
||||
/run/current-system/bin/switch-to-configuration boot >/dev/null 2>&1 \
|
||||
|| log "bootloader refresh skipped (non-fatal)"
|
||||
fi
|
||||
|
||||
log "done"
|
||||
''
|
||||
Reference in New Issue
Block a user