diff --git a/migrations/1784809452.sh b/migrations/1784809452.sh new file mode 100644 index 00000000..e32da36b --- /dev/null +++ b/migrations/1784809452.sh @@ -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 diff --git a/test/shell.d/snapper-timeline-leak-test.sh b/test/shell.d/snapper-timeline-leak-test.sh new file mode 100644 index 00000000..a5adb84f --- /dev/null +++ b/test/shell.d/snapper-timeline-leak-test.sh @@ -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"