Remove Snapper timeline snapshots leaked by earlier defaults

Installs from before the Snapper setup was normalized ran hourly
timeline snapshots. Newer configs stopped creating them but never
deleted the existing ones, and number cleanup skips snapshots marked
Cleanup=timeline, so they sit there forever: one machine installed from
the 2026-05-11 ISO had accumulated 592 of them, silently pinning 219 GB
of disk. The limine-snapper-sync limit-mismatch warning that would have
surfaced this is disabled by default since the notifier migration.

Delete leaked timeline snapshots in batches of 20 (a single mass delete
can die on a DBus timeout partway through), and only when
TIMELINE_CREATE="no" so anyone who deliberately re-enabled timeline
snapshotting keeps their setup untouched.

The drain is best effort: a batch that fails is skipped rather than
aborting the migration run and everything queued behind it, since the
next run re-lists whatever is left.
This commit is contained in:
Martin Bastien
2026-07-23 09:10:16 -04:00
parent 8ef4eb24a2
commit d548c064ee
2 changed files with 147 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
echo "Remove Snapper timeline snapshots leaked by earlier defaults"
SNAPPER_CONFIG_PATH="${OMARCHY_SNAPPER_CONFIG_PATH:-/etc/snapper/configs/root}"
as_root() {
if (( EUID == 0 )); then
"$@"
else
sudo "$@"
fi
}
command -v snapper >/dev/null || exit 0
[[ -f $SNAPPER_CONFIG_PATH ]] || exit 0
# Only clean up when timeline snapshotting is off, as Omarchy configures it.
# Anyone who deliberately turned it back on keeps their snapshots.
grep -qFx 'TIMELINE_CREATE="no"' "$SNAPPER_CONFIG_PATH" || exit 0
# Earlier installs ran hourly timeline snapshots. Later configs stopped
# creating them but never deleted the existing ones, and number cleanup
# skips snapshots marked Cleanup=timeline, so they pile up forever:
# hundreds of snapshots pinning 100+ GB of extents on long-running machines.
leaked=$(as_root snapper -c root --csvout list --columns number,cleanup 2>/dev/null | awk -F, '$2 == "timeline" { print $1 }' || true)
[[ -n $leaked ]] || exit 0
echo "Deleting $(wc -w <<<"$leaked") leaked timeline snapshots (disk space is reclaimed in the background)"
# Delete in small batches; one big delete can die on a DBus timeout partway.
# A failed batch must not take the rest of the migration run down with it, so
# the drain is best effort: whatever survives is picked up by the next run.
batch=()
for number in $leaked; do
batch+=("$number")
if (( ${#batch[@]} == 20 )); then
as_root snapper -c root delete "${batch[@]}" || true
batch=()
fi
done
if (( ${#batch[@]} > 0 )); then
as_root snapper -c root delete "${batch[@]}" || true
fi
+104
View File
@@ -0,0 +1,104 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
leak_migration=$(grep -rl 'timeline snapshots leaked by earlier defaults' "$ROOT/migrations" | head -n 1 || true)
[[ -n $leak_migration ]] || fail "Snapper timeline leak migration exists"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
fake_bin="$test_tmp/bin"
mkdir -p "$fake_bin"
cat >"$fake_bin/sudo" <<'STUB'
#!/bin/bash
exec "$@"
STUB
chmod +x "$fake_bin/sudo"
cat >"$fake_bin/snapper" <<'STUB'
#!/bin/bash
printf 'snapper %s\n' "$*" >>"$TEST_LOG"
if [[ "$*" == *"--csvout list"* ]]; then
echo "number,cleanup"
for i in $(seq 1 45); do
echo "$i,timeline"
done
echo "100,number"
echo "101,"
fi
STUB
chmod +x "$fake_bin/snapper"
snapper_config="$test_tmp/root"
printf '%s\n' 'TIMELINE_CREATE="no"' 'NUMBER_CLEANUP="yes"' >"$snapper_config"
TEST_LOG="$test_tmp/calls.log" \
PATH="$fake_bin:$PATH" \
OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \
bash -euo pipefail "$leak_migration" >/dev/null
deletes=$(grep -c '^snapper -c root delete ' "$test_tmp/calls.log" || true)
[[ $deletes -eq 3 ]] || fail "leak migration deletes snapshots in batches" "expected 3 delete calls, got $deletes"
first_batch=$(grep -m1 '^snapper -c root delete ' "$test_tmp/calls.log")
[[ $first_batch == "snapper -c root delete $(seq -s ' ' 1 20)" ]] || fail "leak migration caps delete batches at 20 snapshots" "$first_batch"
last_batch=$(grep '^snapper -c root delete ' "$test_tmp/calls.log" | tail -n 1)
[[ $last_batch == "snapper -c root delete $(seq -s ' ' 41 45)" ]] || fail "leak migration deletes the final partial batch" "$last_batch"
! grep -E '^snapper -c root delete .*\b(100|101)\b' "$test_tmp/calls.log" || fail "leak migration only deletes timeline snapshots"
pass "leak migration removes leaked timeline snapshots in batches and keeps the rest"
# omarchy-migrate runs under set -e, so a batch that dies on a DBus timeout
# would otherwise abort the run and skip every migration queued behind it.
: >"$test_tmp/calls.log"
printf '%s\n' 'TIMELINE_CREATE="no"' 'NUMBER_CLEANUP="yes"' >"$snapper_config"
cat >"$fake_bin/snapper" <<'STUB'
#!/bin/bash
printf 'snapper %s\n' "$*" >>"$TEST_LOG"
if [[ "$*" == *"--csvout list"* ]]; then
echo "number,cleanup"
for i in $(seq 1 45); do
echo "$i,timeline"
done
exit 0
fi
echo "failure: dbus timeout" >&2
exit 1
STUB
TEST_LOG="$test_tmp/calls.log" \
PATH="$fake_bin:$PATH" \
OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \
bash -euo pipefail "$leak_migration" >/dev/null 2>&1 ||
fail "leak migration survives a failed delete batch"
deletes=$(grep -c '^snapper -c root delete ' "$test_tmp/calls.log" || true)
[[ $deletes -eq 3 ]] || fail "leak migration keeps draining after a failed batch" "expected 3 delete calls, got $deletes"
pass "leak migration tolerates a batch that fails partway"
: >"$test_tmp/calls.log"
printf '%s\n' 'TIMELINE_CREATE="yes"' >"$snapper_config"
TEST_LOG="$test_tmp/calls.log" \
PATH="$fake_bin:$PATH" \
OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \
bash -euo pipefail "$leak_migration" >/dev/null
[[ ! -s $test_tmp/calls.log ]] || fail "leak migration leaves deliberate timeline setups alone"
pass "leak migration skips systems where timeline snapshots are intentional"
: >"$test_tmp/calls.log"
TEST_LOG="$test_tmp/calls.log" \
PATH="$fake_bin:$PATH" \
OMARCHY_SNAPPER_CONFIG_PATH="$test_tmp/missing" \
bash -euo pipefail "$leak_migration" >/dev/null
[[ ! -s $test_tmp/calls.log ]] || fail "leak migration skips systems without a Snapper root config"
pass "leak migration is a no-op without Snapper configured"