Skip to content

Commit 00e8f45

Browse files
authored
overlays/sbin: btrfs profile and snapshot tools (#105)
On-device tooling to manage the per-profile btrfs roots and snapshots, plus a shared library they source. flipper-btrfs.sh shared helpers: root/btrfs preamble, subvolid=5 top-level mount, confirm, reserved-subvol guard, field / parent-uuid / table helpers create-profile materialize a writable bootable profile from a snapshot or base (-m moves, preserving parent_uuid); writes the BLS entry via flipper-bls.sh rename-profile rename a profile and reissue its BLS entry delete-profile delete a profile + its BLS entry/staging (tiered confirm for read-only and _stock golden bases) list-profiles list roots, _stock bases and .old leftovers create-snapshot read-only restore point under @snapshots delete-snapshot delete a restore point list-snapshots list restore points send-snapshot zstd-compressed btrfs send (full or incremental) receive-snapshot receive a stream into @snapshots btrfs-show-space per-subvolume space usage btrfs-maintenance check / fix / dedup / balance
1 parent bd59b72 commit 00e8f45

13 files changed

Lines changed: 810 additions & 4 deletions

overlays/usr/lib/flipper-bls.sh

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# flipper-bls.sh - shared library for Flipper One BLS boot-entry generation.
55
#
66
# SOURCED, never executed. Defines the helpers used by the kernel-install plugin
7-
# (90-loaderentry.install) and the btrfs snapshot tooling (create_profile/rename_profile).
7+
# (90-loaderentry.install) and the btrfs snapshot tooling (create-profile/rename-profile).
88
# Functions read globals lazily (at call time), so callers set what they need first.
99
# The kernel/initrd staging and devicetree(dir) handling are derived from systemd's
1010
# 90-loaderentry.install (LGPL-2.1-or-later).
@@ -86,7 +86,7 @@ remove_entries() { # $1 = subvol $2 = version
8686

8787
# Tear down a whole root: remove EVERY entry that selects SUBVOL (all versions) and each one's
8888
# /boot/<token>/ staging tree. The staging dir is derived from the entry's own 'linux' line, so it
89-
# works no matter how the token was computed. For delete_profile/rename_profile (whole-profile ops).
89+
# works no matter how the token was computed. For delete-profile/rename-profile (whole-profile ops).
9090
# Uses $ENTRIES. Prints what it removes to stderr.
9191
remove_root_entries() { # $1 = subvol
9292
[ -n "$1" ] || return 0
@@ -303,8 +303,8 @@ emit_entry() {
303303
# flipper_write_entry <subvol> <mounted-path> [<origin-hint>]: write a BLS entry for an EXISTING
304304
# writable top-level subvol (a restored snapshot/clone). Token = <NN>-flipperos-<name>, NN from
305305
# clone_slot (origin base + depth; the origin hint is the dangling-parent fallback). Reflinks
306-
# the shared /boot kernel into the root's own /boot/<token>/ dir. Sourced by create_profile /
307-
# rename_profile. Returns non-zero (no exit) on a recoverable problem.
306+
# the shared /boot kernel into the root's own /boot/<token>/ dir. Sourced by create-profile /
307+
# rename-profile. Returns non-zero (no exit) on a recoverable problem.
308308
flipper_write_entry() {
309309
_name="$1"; _snap="$2"; _origin="${3:-}"
310310
{ [ -n "$_name" ] && [ -d "$_snap" ]; } || { echo "flipper-bls: usage: flipper_write_entry <name> <mounted-path>" >&2; return 1; }

overlays/usr/lib/flipper-btrfs.sh

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/bin/sh
2+
# SPDX-License-Identifier: LGPL-2.1-or-later
3+
#
4+
# flipper-btrfs.sh - shared helpers for the Flipper One btrfs profile/snapshot tooling.
5+
#
6+
# SOURCED, never executed. Defines the preamble (root/btrfs checks), the top-level
7+
# (subvolid=5) mount, and the small helpers duplicated across create-profile,
8+
# create-snapshot, delete-profile, delete-snapshot, list-profiles, list-snapshots,
9+
# btrfs-maintenance, receive-snapshot, rename-profile, send-snapshot and btrfs-show-space.
10+
# Functions read globals lazily, so callers set what they need first.
11+
12+
die() { echo "Error: $*" >&2; exit 1; }
13+
14+
need_root() { [ "$(id -u)" -eq 0 ] || die "Must run as root (use sudo)"; }
15+
need_btrfs() { command -v btrfs >/dev/null 2>&1 || die "Btrfs-progs not installed"; }
16+
need_cmd() { command -v "$1" >/dev/null 2>&1 || die "Command not installed: $1${2:+ ($2)}"; }
17+
18+
# Ask on stderr, read stdin; abort unless yes. $1 may span lines.
19+
confirm() {
20+
printf '%s [y/N] ' "$1" >&2
21+
read -r _a || die "Aborted"
22+
case "$_a" in y|Y|yes|YES) return 0 ;; *) die "Aborted" ;; esac
23+
}
24+
25+
# Reserved subvolumes the tools must never write/delete/rename.
26+
is_reserved_subvol() {
27+
case "$1" in
28+
@|@home|@root|@snapshots|@var-log|@var-cache|boot) return 0 ;;
29+
*) return 1 ;;
30+
esac
31+
}
32+
33+
# Serialize our writers: an exclusive advisory lock (flock) held on FD 9 for the script's
34+
# lifetime, released on any exit (including crash/kill). The holder records "PID (tool)" in the
35+
# file so a blocked waiter can say who it is waiting on. Read-only tools do not take it, and it
36+
# does NOT guard against concurrent native `btrfs` commands, which honor no lock.
37+
LOCK_FILE=/run/flipper-btrfs.lock
38+
set_lock() {
39+
exec 9<>"$LOCK_FILE" || die "Cannot open lock $LOCK_FILE" # <> = don't truncate the holder line
40+
if ! flock -w 0 9 2>/dev/null; then
41+
_h=$(cat "$LOCK_FILE" 2>/dev/null); [ -n "$_h" ] || _h="PID unknown"
42+
echo "Another flipper-btrfs operation is in progress ($_h); waiting for it to finish..." >&2
43+
flock 9 || die "Cannot acquire lock $LOCK_FILE"
44+
fi
45+
printf 'PID %s (%s)\n' "$$" "${0##*/}" >"$LOCK_FILE" || true
46+
}
47+
48+
# Mount the btrfs top level (subvolid=5) at a temp dir and arrange teardown on EXIT.
49+
# Sets ROOTDEV and TOP. Extra temp files to remove: assign them to TOP_TMPFILES first.
50+
mount_top() {
51+
ROOTDEV=$(findmnt -no SOURCE / | sed 's/\[.*//')
52+
[ -n "$ROOTDEV" ] || die "Cannot determine root device"
53+
TOP=$(mktemp -d)
54+
trap 'top_cleanup' EXIT
55+
_err=$(mount -o subvolid=5 "$ROOTDEV" "$TOP" 2>&1) || die "Mount failed: $_err"
56+
}
57+
top_cleanup() {
58+
[ -n "${TOP:-}" ] && { umount "$TOP" 2>/dev/null || true; rmdir "$TOP" 2>/dev/null || true; }
59+
[ -n "${TOP_TMPFILES:-}" ] && rm -f $TOP_TMPFILES
60+
return 0
61+
}
62+
63+
# One field from `btrfs subvolume show` output. $1 = output text, $2 = key label.
64+
get() { printf '%s\n' "$1" | awk -v k="$2" -F':[[:space:]]+' 'index($0,k){print $2; exit}'; }
65+
66+
subvol_id() { get "$(btrfs subvolume show "$1" 2>/dev/null)" "Subvolume ID"; }
67+
booted_id() { subvol_id /; }
68+
69+
# True if the subvolume at $1 is read-only.
70+
is_ro() {
71+
case "$(btrfs subvolume show "$1" 2>/dev/null | awk -F':[[:space:]]+' '/Flags/{print $2}')" in
72+
*readonly*) return 0 ;; *) return 1 ;;
73+
esac
74+
}
75+
76+
human() { numfmt --to=iec-i --suffix=B --format='%.1f' "${1:-0}" 2>/dev/null || printf '%s' "${1:-0}"; }
77+
78+
# uuid -> "id <tab> path" for every subvolume, so parents resolve by UUID. $1 = top, $2 = out file.
79+
build_uuid_map() {
80+
btrfs subvolume list -qu "$1" 2>/dev/null | awk '
81+
{
82+
id=""; u=""; p=""
83+
for (i = 1; i <= NF; i++) {
84+
if ($i == "ID") id = $(i+1)
85+
else if ($i == "uuid") u = $(i+1)
86+
else if ($i == "path") { p = $(i+1); for (j = i+2; j <= NF; j++) p = p" "$j; break }
87+
}
88+
if (u != "" && u != "-") print u"\t"id"\t"p
89+
}' > "$2"
90+
}
91+
92+
# Resolve a parent UUID to "name (id)" via a map from build_uuid_map. $1 = map file, $2 = uuid.
93+
parent_of() {
94+
case "$2" in ''|'-') printf '%s' "-"; return ;; esac
95+
awk -F'\t' -v u="$2" '
96+
$1==u { n=$3; sub(/.*\//,"",n); printf "%s (%s)", n, $2; f=1 }
97+
END { if (!f) printf "-" }' "$1"
98+
}
99+
100+
# Print a TSV file with every column padded to its max width; last field left unpadded.
101+
align_table() {
102+
awk -F'\t' '
103+
{ line[NR]=$0; nf=split($0,a,"\t"); for (i=1;i<=nf;i++) if (length(a[i])>w[i]) w[i]=length(a[i]) }
104+
END {
105+
for (r=1; r<=NR; r++) {
106+
n=split(line[r], a, "\t"); out=""
107+
for (i=1; i<=n; i++) out = (i<n) ? out sprintf("%-*s ", w[i], a[i]) : out a[i]
108+
print out
109+
}
110+
}' "$1"
111+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/bin/sh
2+
# btrfs-maintenance <check|fix|dedup|balance|all>
3+
# check - read-only scrub (verify checksums, no writes)
4+
# fix - scrub with repair (only where a good copy exists: DUP metadata yes,
5+
# single-profile data cannot self-heal)
6+
# dedup - offline dedup via duperemove across ALL subvolumes (separate package)
7+
# balance - light filtered balance to reclaim partly-empty chunks
8+
# all - check + dedup + balance
9+
# Offline 'btrfs check --repair' needs an UNMOUNTED fs (boot elsewhere).
10+
#
11+
# Example:
12+
# btrfs-maintenance all # scrub + dedup + balance across all profile/snapshot subvols
13+
set -eu
14+
. /usr/lib/flipper-btrfs.sh 2>/dev/null || { echo "Error: /usr/lib/flipper-btrfs.sh not found" >&2; exit 1; }
15+
need_root
16+
need_btrfs
17+
MNT=/
18+
19+
usage() { sed -n '2,9p' "$0"; exit 1; }
20+
21+
# Dedup across every profile/snapshot: duperemove must run on the btrfs TOP LEVEL (subvolid=5),
22+
# where all subvolumes appear as subdirectories. Run on / and it only sees the booted root.
23+
# Read-only subvols (@snapshots/*, *_stock) are skipped by duperemove (can't dedup a RO target).
24+
do_dedup() {
25+
need_cmd duperemove
26+
mount_top
27+
duperemove -dhr "$TOP" || die "Duperemove failed (rc=$?)"
28+
}
29+
30+
[ $# -ge 1 ] || usage
31+
case "$1" in
32+
check) btrfs scrub start -B -r "$MNT" ;;
33+
fix) set_lock; btrfs scrub start -B "$MNT" ;;
34+
dedup) set_lock; do_dedup ;;
35+
balance) set_lock; btrfs balance start -dusage=50 -musage=50 "$MNT" ;;
36+
all) set_lock
37+
btrfs scrub start -B -r "$MNT"
38+
do_dedup
39+
btrfs balance start -dusage=50 -musage=50 "$MNT" ;;
40+
*) usage ;;
41+
esac
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/bin/sh
2+
# btrfs-show-space [-q|--quick] - btrfs usage + per-root-subvolume space.
3+
# (-q/--quick skips the compsize REFERENCED column, one less extent-walk per subvol.)
4+
# Lists profile roots and _stock bases, every snapshot under @snapshots, and any other
5+
# top-level @* (e.g. @.old_*). Shared @home/@var-* are not listed.
6+
# UNIQUE - data only this subvolume holds; freed if you delete IT ALONE,
7+
# others kept (btrfs fi du, exact, uncompressed)
8+
# REFERENCED - real on-disk size of all data it references, compressed
9+
# (compsize, exact). Counts extents SHARED with other roots, so it
10+
# is NOT additive across rows.
11+
# TOTAL - apparent/uncompressed size (btrfs fi du, exact)
12+
# The currently booted subvolume is marked <- booted. Columns auto-size to fit.
13+
#
14+
# Example:
15+
# btrfs-show-space --quick # usage + per-subvol space (skips the slow REFERENCED column)
16+
set -eu
17+
. /usr/lib/flipper-btrfs.sh 2>/dev/null || { echo "Error: /usr/lib/flipper-btrfs.sh not found" >&2; exit 1; }
18+
need_root
19+
need_btrfs
20+
21+
quick=0
22+
case "${1:-}" in -q|--quick) quick=1; shift ;; esac
23+
have_cs=0
24+
if [ "$quick" = 0 ] && command -v compsize >/dev/null 2>&1; then have_cs=1; fi
25+
26+
echo "== filesystem =="
27+
btrfs filesystem usage -T /
28+
echo
29+
30+
rows=$(mktemp)
31+
list=$(mktemp)
32+
TOP_TMPFILES="$rows $list"
33+
mount_top
34+
cur_id=$(booted_id)
35+
36+
row() { # $1=display name $2=fs path -> append a TSV row
37+
line=$(btrfs filesystem du -s --raw "$2" 2>/dev/null | awk 'NR==2{print $1, $2}')
38+
tot=$(printf '%s' "$line" | awk '{print $1}'); case "$tot" in ''|*[!0-9]*) tot=0 ;; esac
39+
exc=$(printf '%s' "$line" | awk '{print $2}'); case "$exc" in ''|*[!0-9]*) exc=0 ;; esac
40+
if [ "$have_cs" = 1 ]; then
41+
ref=$(compsize -b "$2" 2>/dev/null | awk '/^TOTAL/{print $3; exit}')
42+
case "$ref" in ''|*[!0-9]*) ref=0 ;; esac
43+
ref_h=$(human "$ref")
44+
else
45+
ref_h="-"
46+
fi
47+
id=$(subvol_id "$2")
48+
mark=""; [ -n "$cur_id" ] && [ "$id" = "$cur_id" ] && mark="<- booted"
49+
printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$(human "$exc")" "$ref_h" "$(human "$tot")" "$mark" >> "$rows"
50+
}
51+
52+
# collect subvols (cheap), then measure with progress: each row walks extents (slow on flash)
53+
for s in "$TOP"/@snapshots/*; do
54+
[ -e "$s" ] || continue
55+
printf '%s\t%s\n' "@snapshots/${s##*/}" "$s" >> "$list"
56+
done
57+
for d in "$TOP"/@*; do
58+
[ -e "$d" ] || continue
59+
n=${d##*/}
60+
is_reserved_subvol "$n" && continue
61+
btrfs subvolume show "$d" >/dev/null 2>&1 || continue
62+
printf '%s\t%s\n' "$n" "$d" >> "$list"
63+
done
64+
total=$(wc -l < "$list" 2>/dev/null | tr -d ' '); [ -n "$total" ] || total=0
65+
66+
printf 'NAME\tUNIQUE\tREFERENCED\tTOTAL\t\n' > "$rows"
67+
if [ "$total" -gt 0 ]; then
68+
if [ "$have_cs" = 1 ]; then note="du + compsize"; else note="du only"; fi
69+
printf 'Measuring %s subvolume(s) (%s), please wait...\n' "$total" "$note" >&2
70+
fi
71+
i=0; tab=$(printf '\t')
72+
while IFS="$tab" read -r nm pth; do
73+
i=$((i + 1))
74+
[ -t 2 ] && printf '\r [%d/%d] %s\033[K' "$i" "$total" "$nm" >&2 || true
75+
row "$nm" "$pth"
76+
done < "$list"
77+
{ [ -t 2 ] && [ "$total" -gt 0 ] && printf '\r\033[K' >&2; } || true
78+
79+
echo "== root subvolumes & snapshots =="
80+
# align: NAME auto-width; numeric columns right-justified; marker appended
81+
awk -F'\t' '
82+
{ r[NR]=$0; if (length($1) > w) w = length($1) }
83+
END {
84+
for (i = 1; i <= NR; i++) {
85+
split(r[i], f, "\t")
86+
printf "%-*s %11s %11s %11s", w, f[1], f[2], f[3], f[4]
87+
if (f[5] != "") printf " %s", f[5]
88+
printf "\n"
89+
}
90+
}' "$rows"
91+
92+
echo
93+
echo "UNIQUE = freed if you delete that subvolume alone (uncompressed)."
94+
echo "REFERENCED = real on-disk size, compressed; counts shared extents, so NOT additive."
95+
if [ "$have_cs" = 0 ]; then
96+
if [ "$quick" = 1 ]; then echo "REFERENCED '-': skipped for speed (--quick); run without it for compressed sizes."
97+
else echo "REFERENCED shows '-': install 'compsize' (apt install compsize)."; fi
98+
fi
99+
echo "TOTAL = apparent (uncompressed)."

0 commit comments

Comments
 (0)