/ be-btrfs.sh
be-btrfs.sh
1 #!/bin/bash 2 # be-btrfs — Boot Environment manager for btrfs 3 # Designed for Armbian/Debian/Ubuntu on ARM SBCs with U-Boot 4 # Requires: btrfs-progs >= 6.1, util-linux (findmnt) 5 # Compatible with: bash 5.2+ (Ubuntu Noble / Debian Trixie) 6 # 7 # SPDX-License-Identifier: GPL-3.0-or-later 8 9 set -euo pipefail 10 11 readonly PROG="${0##*/}" 12 readonly VERSION="0.4.0" 13 14 # --- Defaults (overridden by config) --- 15 16 BE_PREFIX="@be-" 17 SNAP_PREFIX="@snap-" 18 META_DIR=".be-meta" 19 TOPLEVEL_MOUNT="/run/be-toplevel" 20 21 PRUNE_RULES=( 22 "@be-*:5:30d" 23 "@snap-apt-*:10:7d" 24 "@snap-*:20:30d" 25 ) 26 27 # --- Config loading --- 28 29 _load_config() { 30 local f 31 for f in /etc/be-btrfs.conf ~/.config/be-btrfs.conf; do 32 # shellcheck source=/dev/null 33 if [[ -f "$f" ]]; then . "$f"; fi 34 done 35 } 36 37 # --- Output --- 38 39 if [[ -t 1 ]]; then 40 _B='\033[1m' _G='\033[32m' _Y='\033[33m' _R='\033[31m' _0='\033[0m' 41 else 42 _B='' _G='' _Y='' _R='' _0='' 43 fi 44 45 if command -v logger &>/dev/null; then 46 _log() { logger -t be-btrfs "$*"; } 47 else 48 _log() { :; } 49 fi 50 51 die() { printf "${_R}error:${_0} %s\n" "$*" >&2; _log "ERROR: $*"; exit 1; } 52 warn() { printf "${_Y}warning:${_0} %s\n" "$*" >&2; } 53 info() { printf "${_G}::${_0} %s\n" "$*"; _log "$*"; } 54 55 # --- Common checks --- 56 57 need_root() { 58 [[ $EUID -eq 0 ]] || die "root required (use sudo)" 59 } 60 61 need_btrfs() { 62 local fstype 63 fstype=$(findmnt -n -o FSTYPE /) 64 [[ "$fstype" == "btrfs" ]] || die "root filesystem is not btrfs (found: $fstype)" 65 } 66 67 # Verify that root is mounted as the default subvolume, 68 # otherwise set-default will have no effect on reboot. 69 check_mount_opts() { 70 # Check fstab, not runtime mount options — 71 # the kernel always shows subvol=/subvolid= in mount output, even when 72 # not specified in fstab (i.e. mounted by default subvolume). 73 local fstab_opts 74 fstab_opts=$(findmnt -n -o OPTIONS --fstab /) || return 0 75 76 if [[ "$fstab_opts" =~ subvol=([^,]+) ]]; then 77 local sv="${BASH_REMATCH[1]}" 78 [[ "$sv" == "/" ]] && return 0 79 die "fstab has subvol=${sv} for / 80 Boot environments require mounting by default subvolume. 81 Remove the subvol= option from /etc/fstab and reboot." 82 fi 83 84 if [[ "$fstab_opts" =~ subvolid=([0-9]+) ]]; then 85 local sid="${BASH_REMATCH[1]}" 86 [[ "$sid" == "0" || "$sid" == "5" ]] && return 0 87 die "fstab has subvolid=${sid} for / (not default). 88 Remove the subvolid= option from /etc/fstab and reboot." 89 fi 90 } 91 92 # --- Toplevel (subvolid=5) management --- 93 94 _tl="" 95 _tl_owned=false 96 97 mount_toplevel() { 98 if [[ -n "$_tl" ]]; then 99 return 100 fi 101 if mountpoint -q "$TOPLEVEL_MOUNT" 2>/dev/null; then 102 _tl="$TOPLEVEL_MOUNT" 103 return 104 fi 105 local dev 106 dev=$(root_dev) 107 mkdir -p "$TOPLEVEL_MOUNT" 108 mount -o subvolid=5 "$dev" "$TOPLEVEL_MOUNT" 109 _tl="$TOPLEVEL_MOUNT" 110 _tl_owned=true 111 } 112 113 cleanup() { 114 if $_tl_owned && mountpoint -q "$TOPLEVEL_MOUNT" 2>/dev/null; then 115 umount "$TOPLEVEL_MOUNT" 2>/dev/null || true 116 rmdir "$TOPLEVEL_MOUNT" 2>/dev/null || true 117 fi 118 } 119 trap cleanup EXIT 120 121 root_dev() { findmnt -n -o SOURCE / | sed 's/\[.*//'; } 122 123 default_id() { btrfs subvolume get-default / | awk '{print $2}'; } 124 125 # Path of current root on toplevel (e.g. "@" or "@be-upgrade"). 126 current_root_path() { 127 btrfs subvolume show / 2>/dev/null | awk '/^\tName:/ {print $2}' 128 } 129 130 # Full path to current root on mounted toplevel. 131 _current_root_dir() { 132 local crp 133 crp=$(current_root_path) 134 if [[ -n "$crp" ]]; then 135 echo "$_tl/$crp" 136 else 137 echo "$_tl" 138 fi 139 } 140 141 subvol_id_of() { 142 btrfs subvolume show "$1" 2>/dev/null | awk '/Subvolume ID:/ {print $3}' 143 } 144 145 timestamp() { date -u +%Y%m%dT%H%M%SZ; } 146 147 # --- Metadata --- 148 149 meta_set() { 150 local name="$1" desc="$2" 151 mkdir -p "$_tl/$META_DIR" 152 printf '%s\n' "$desc" > "$_tl/$META_DIR/${name}.desc" 153 } 154 155 meta_get() { 156 local f="$_tl/$META_DIR/${1}.desc" 157 [[ -f "$f" ]] && cat "$f" || true 158 } 159 160 # --- Common subvolume primitives --- 161 162 # Create a read-only snapshot. 163 # Usage: _make_snapshot <src_path> <svname> [desc] 164 _make_snapshot() { 165 local src="$1" svname="$2" desc="${3:-}" 166 [[ -d "$_tl/$svname" ]] && die "snapshot '${svname}' already exists" 167 btrfs subvolume snapshot -r "$src" "$_tl/$svname" >/dev/null 168 [[ -n "$desc" ]] && meta_set "$svname" "$desc" 169 } 170 171 # Check fstab inside a clone for hardcoded subvol=/subvolid= on /. 172 # Such entries would prevent the clone from booting correctly. 173 _check_clone_fstab() { 174 local path="$1" 175 local fstab="$path/etc/fstab" 176 [[ -f "$fstab" ]] || return 0 177 local line 178 while IFS= read -r line; do 179 # Skip comments and non-root entries 180 [[ "$line" =~ ^[[:space:]]*# ]] && continue 181 [[ "$line" =~ [[:space:]]/[[:space:]] ]] || continue 182 if [[ "$line" =~ subvol=([^,[:space:]]+) ]]; then 183 local sv="${BASH_REMATCH[1]}" 184 [[ "$sv" == "/" ]] && continue 185 warn "clone's /etc/fstab has subvol=${sv} for / — this will prevent correct boot." 186 warn "Edit $fstab and remove the subvol= option, or use 'be-btrfs shell' to fix it." 187 fi 188 if [[ "$line" =~ subvolid=([0-9]+) ]]; then 189 local sid="${BASH_REMATCH[1]}" 190 [[ "$sid" == "0" || "$sid" == "5" ]] && continue 191 warn "clone's /etc/fstab has subvolid=${sid} for / — this will prevent correct boot." 192 warn "Edit $fstab and remove the subvolid= option, or use 'be-btrfs shell' to fix it." 193 fi 194 done < "$fstab" 195 } 196 197 # Create a writable clone (BE). 198 # Usage: _make_clone <src_path> <bename> [desc] 199 _make_clone() { 200 local src="$1" bename="$2" desc="${3:-}" 201 [[ -d "$_tl/$bename" ]] && die "boot environment '${bename#${BE_PREFIX}}' already exists" 202 btrfs subvolume snapshot "$src" "$_tl/$bename" >/dev/null 203 meta_set "$bename" "$desc" 204 _check_clone_fstab "$_tl/$bename" 205 } 206 207 # Find actual mountpoint of a subvolume by its name. 208 # Returns empty string if not mounted. 209 _find_mountpoint() { 210 local svname="$1" 211 findmnt -ln -o TARGET,SOURCE -t btrfs \ 212 | while read -r tgt src; do 213 [[ "$src" == *"[/${svname}]" ]] && echo "$tgt" && break 214 done 215 } 216 217 # Iterator over btrfs subvolume list: calls callback with args (id, svname). 218 # Usage: _iter_subvols <path> <callback> [--sort=gen] 219 _iter_subvols() { 220 local path="$1" callback="$2" 221 shift 2 222 while IFS= read -r line; do 223 local id svname 224 id=$(awk '{print $2}' <<< "$line") 225 svname=$(awk '{print $NF}' <<< "$line") 226 "$callback" "$id" "$svname" 227 done < <(btrfs subvolume list "$@" "$path") 228 } 229 230 # Interactive BE selection menu. 231 # Usage: _choose_be <path> [did] 232 # Prints selected BE name (without prefix) to stdout. 233 _choose_be() { 234 local path="$1" did="${2:-}" 235 local -a names=() ids=() 236 237 _chooser_cb() { 238 local id="$1" svname="$2" 239 [[ "$svname" == ${BE_PREFIX}* ]] || return 0 240 local display="${svname#${BE_PREFIX}}" 241 local mark="" 242 [[ -n "$did" && "$id" == "$did" ]] && mark=" (active)" 243 local desc 244 desc=$(meta_get "$svname") 245 [[ -n "$desc" ]] && desc=" — $desc" 246 names+=("$display") 247 ids+=("$id") 248 printf " %d) %s%s%s\n" "${#names[@]}" "$display" "$mark" "$desc" 249 } 250 251 _iter_subvols "$path" _chooser_cb 252 253 [[ ${#names[@]} -gt 0 ]] || die "no boot environments found" 254 255 echo 256 read -rp "Choose (1-${#names[@]}): " choice 257 [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#names[@]} )) \ 258 || die "invalid choice" 259 260 local idx=$((choice - 1)) 261 printf '%s\n' "${names[$idx]}" 262 } 263 264 # --- BE mount for chroot --- 265 266 _be_resolv_backup="" 267 268 _be_mount() { 269 local bename="$1" mnt="$2" 270 local dev 271 dev=$(root_dev) 272 mkdir -p "$mnt" 273 mount -o "subvol=${bename}" "$dev" "$mnt" 274 mount --bind /proc "$mnt/proc" 275 mount --bind /sys "$mnt/sys" 276 mount --bind /dev "$mnt/dev" 277 mount --bind /dev/pts "$mnt/dev/pts" 278 # Chroot needs real DNS servers, not the stub 127.0.0.53. 279 # resolv.conf in the clone may be a symlink to systemd-resolved — 280 # save the original, replace with upstream DNS file. 281 _be_resolv_backup="" 282 if [[ -L "$mnt/etc/resolv.conf" ]]; then 283 _be_resolv_backup=$(readlink "$mnt/etc/resolv.conf") 284 rm -f "$mnt/etc/resolv.conf" 285 fi 286 if [[ -f /run/systemd/resolve/resolv.conf ]]; then 287 cp /run/systemd/resolve/resolv.conf "$mnt/etc/resolv.conf" 2>/dev/null || true 288 elif [[ -f /etc/resolv.conf ]]; then 289 cp --dereference /etc/resolv.conf "$mnt/etc/resolv.conf" 2>/dev/null || true 290 fi 291 } 292 293 _be_umount() { 294 local mnt="$1" 295 # Restore original resolv.conf (symlink to systemd-resolved) 296 if [[ -n "${_be_resolv_backup:-}" ]]; then 297 rm -f "$mnt/etc/resolv.conf" 298 ln -s "$_be_resolv_backup" "$mnt/etc/resolv.conf" 299 fi 300 for p in dev/pts dev sys proc; do 301 umount -l "$mnt/$p" 2>/dev/null || true 302 done 303 umount -l "$mnt" 2>/dev/null || true 304 rmdir "$mnt" 2>/dev/null || true 305 } 306 307 # Run a command in a chroot BE with guaranteed cleanup. 308 # Usage: _with_mounted_be <bename> <command...> 309 # Returns the command's exit code. 310 _with_mounted_be() { 311 local bename="$1"; shift 312 local name="${bename#${BE_PREFIX}}" 313 local mnt="/run/be/${name}" 314 _be_mount "$bename" "$mnt" 315 316 local rc=0 317 # Trap ensures cleanup on Ctrl+C, set -e, or any abort 318 trap '_be_umount "'"$mnt"'"; trap - INT TERM' INT TERM 319 "$@" "$mnt" || rc=$? 320 trap - INT TERM 321 _be_umount "$mnt" 322 return $rc 323 } 324 325 # Resolve source path for clone/create -e. 326 # Supports: @snap-*, @be-*, snapper#N, timeshift/<date>, toplevel path 327 _resolve_source() { 328 local source="$1" 329 330 if [[ "$source" == snapper#* ]]; then 331 echo "$_tl/.snapshots/${source#snapper#}/snapshot" 332 elif [[ "$source" == timeshift/* ]]; then 333 echo "$_tl/timeshift-btrfs/snapshots/${source#timeshift/}/@" 334 elif [[ -d "$_tl/${SNAP_PREFIX}${source}" ]]; then 335 echo "$_tl/${SNAP_PREFIX}${source}" 336 elif [[ -d "$_tl/${BE_PREFIX}${source}" ]]; then 337 echo "$_tl/${BE_PREFIX}${source}" 338 elif [[ -d "$_tl/${source}" ]]; then 339 echo "$_tl/${source}" 340 else 341 return 1 342 fi 343 } 344 345 # --- Commands --- 346 347 # beadm create [-a] [-d description] [-e source] name 348 # beadm create name@snapshot 349 cmd_create() { 350 need_root; need_btrfs; mount_toplevel 351 352 local do_activate=false 353 local desc="" 354 local source="" 355 local OPTIND=1 356 357 while getopts ":ad:e:" opt; do 358 case "$opt" in 359 a) do_activate=true ;; 360 d) desc="$OPTARG" ;; 361 e) source="$OPTARG" ;; 362 :) die "option -$OPTARG requires an argument" ;; 363 *) die "unknown option: -$OPTARG" ;; 364 esac 365 done 366 shift $((OPTIND - 1)) 367 368 local name="${1:?specify BE name}" 369 370 # create name@snapshot — create a snapshot (like beadm create BE1@now) 371 if [[ "$name" == *@* ]]; then 372 local be_part="${name%%@*}" 373 local snap_part="${name#*@}" 374 [[ -n "$snap_part" ]] || die "specify snapshot name after @" 375 376 local src 377 if [[ -z "$be_part" ]]; then 378 src=$(_current_root_dir) 379 else 380 local bename="${BE_PREFIX}${be_part}" 381 [[ -d "$_tl/$bename" ]] || die "boot environment '$be_part' not found" 382 src="$_tl/$bename" 383 fi 384 385 local svname="${SNAP_PREFIX}${be_part:+${be_part}-}${snap_part}" 386 _make_snapshot "$src" "$svname" "$desc" 387 info "Snapshot created: ${be_part:+${be_part}-}${snap_part}" 388 return 389 fi 390 391 # create [-e source] name — create a BE (clone) 392 local bename="${BE_PREFIX}${name}" 393 394 local sp="" 395 if [[ -n "$source" ]]; then 396 sp=$(_resolve_source "$source") || die "source '$source' not found" 397 else 398 sp=$(_current_root_dir) 399 fi 400 401 [[ -z "$desc" ]] && desc="clone of ${source:-$(current_root_path)}" 402 _make_clone "$sp" "$bename" "$desc" 403 info "Boot environment created: $name" 404 405 if $do_activate; then 406 cmd_activate "$name" 407 fi 408 } 409 410 # beadm destroy [-fF] name | name@snapshot 411 cmd_destroy() { 412 need_root; mount_toplevel 413 414 local force_umount=false 415 local no_confirm=false 416 local OPTIND=1 417 418 while getopts ":fF" opt; do 419 case "$opt" in 420 f) force_umount=true ;; 421 F) no_confirm=true ;; 422 *) die "unknown option: -$OPTARG" ;; 423 esac 424 done 425 shift $((OPTIND - 1)) 426 427 local name="${1:?specify BE name or snapshot (name@snap)}" 428 429 # destroy name@snapshot — maps name to @snap-name-snapshot 430 # (matches how create name@snap creates snapshots) 431 if [[ "$name" == *@* ]]; then 432 local be_part="${name%%@*}" 433 local snap_part="${name#*@}" 434 local snap_name="${be_part:+${be_part}-}${snap_part}" 435 local svname="${SNAP_PREFIX}${snap_name}" 436 [[ -d "$_tl/$svname" ]] || die "snapshot '$snap_name' not found" 437 438 if ! $no_confirm; then 439 read -rp "Delete snapshot '$snap_name'? [y/N] " yn 440 [[ "$yn" =~ ^[Yy]$ ]] || return 0 441 fi 442 443 btrfs subvolume delete "$_tl/$svname" >/dev/null 444 rm -f "$_tl/$META_DIR/${svname}.desc" 445 info "Deleted snapshot: $snap_name" 446 return 447 fi 448 449 # destroy BE 450 local bename="${BE_PREFIX}${name}" 451 local bepath="$_tl/$bename" 452 453 [[ -d "$bepath" ]] || die "'$name' not found" 454 455 local sid did 456 sid=$(subvol_id_of "$bepath") 457 did=$(default_id) 458 [[ "$sid" != "$did" ]] || die "cannot delete the active boot environment" 459 460 if ! $no_confirm; then 461 read -rp "Delete boot environment '$name'? [y/N] " yn 462 [[ "$yn" =~ ^[Yy]$ ]] || return 0 463 fi 464 465 # Check if mounted (find actual mountpoint) 466 local mounted_at 467 mounted_at=$(_find_mountpoint "$bename") || true 468 469 if [[ -n "$mounted_at" ]]; then 470 if $force_umount; then 471 warn "Force unmounting '$name' ($mounted_at)..." 472 umount -l "$mounted_at" 2>/dev/null || true 473 else 474 die "'$name' is mounted at $mounted_at. Use -f to force unmount." 475 fi 476 fi 477 478 btrfs subvolume delete "$bepath" >/dev/null 479 rm -f "$_tl/$META_DIR/${bename}.desc" 480 info "Deleted: $name" 481 } 482 483 # beadm list [-a | -ds] [-H] [name] 484 cmd_list() { 485 need_btrfs 486 mount_toplevel 487 488 local show_all=false show_datasets=false show_snaps=false 489 local machine=false 490 local OPTIND=1 491 492 while getopts ":adsH" opt; do 493 case "$opt" in 494 a) show_all=true ;; 495 d) show_datasets=true ;; 496 s) show_snaps=true ;; 497 H) machine=true ;; 498 *) die "unknown option: -$OPTARG" ;; 499 esac 500 done 501 shift $((OPTIND - 1)) 502 503 local filter="${1:-}" 504 local did crp 505 did=$(default_id) 506 crp=$(current_root_path) 507 508 if ! $machine; then 509 printf "${_B}%-28s %-5s %-12s %-8s %-20s %s${_0}\n" \ 510 "BE" "Flags" "Mountpoint" "Space" "Created" "Description" 511 fi 512 513 _list_be_cb() { 514 local id="$1" svname="$2" 515 [[ "$svname" == ${BE_PREFIX}* ]] || return 0 516 517 local short="${svname#${BE_PREFIX}}" 518 [[ -z "$filter" || "$short" == "$filter" ]] || return 0 519 520 # Flags: N=active now, R=active on reboot 521 local flags="-" 522 local is_now=false is_reboot=false 523 [[ "$svname" == "$crp" ]] && is_now=true 524 [[ "$id" == "$did" ]] && is_reboot=true 525 if $is_now && $is_reboot; then flags="NR" 526 elif $is_now; then flags="N" 527 elif $is_reboot; then flags="R" 528 fi 529 530 # Mountpoint — find by SOURCE (any mountpoint) 531 local mnt="-" 532 if $is_now; then 533 mnt="/" 534 else 535 local found_mnt 536 found_mnt=$(_find_mountpoint "$svname") || true 537 [[ -n "$found_mnt" ]] && mnt="$found_mnt" 538 fi 539 540 # Space 541 local space 542 space=$(btrfs subvolume show "$_tl/$svname" 2>/dev/null \ 543 | awk '/Exclusive:/ {print $2 $3}') || space="?" 544 [[ "$space" == "?" ]] && space="-" 545 546 # Created 547 local created 548 created=$(btrfs subvolume show "$_tl/$svname" 2>/dev/null \ 549 | awk '/Creation time:/ {print $3, $4}') || created="?" 550 551 # Description 552 local desc 553 desc=$(meta_get "$svname") 554 555 if $machine; then 556 printf '%s;%s;%s;%s;%s;%s\n' \ 557 "$short" "$flags" "$mnt" "$space" "$created" "$desc" 558 else 559 printf "%-28s %-5s %-12s %-8s %-20s %s\n" \ 560 "$short" "$flags" "$mnt" "$space" "$created" "$desc" 561 fi 562 } 563 564 _iter_subvols "$_tl" _list_be_cb 565 566 # Nested subvolumes (datasets) — show with -d or -a 567 if $show_datasets || $show_all; then 568 local root_path 569 root_path=$(current_root_path) 570 local has_nested=false 571 572 _list_datasets_cb() { 573 local _id="$1" svname="$2" 574 [[ "$svname" == "${root_path}/"* ]] || return 0 575 if ! $has_nested; then 576 has_nested=true 577 if ! $machine; then 578 echo 579 printf "${_B}Nested subvolumes (shared):${_0}\n" 580 fi 581 fi 582 local relative="${svname#${root_path}/}" 583 if $machine; then 584 printf 'dataset:%s;-;-;-;-;\n' "$relative" 585 else 586 printf " %-26s (shared)\n" "$relative" 587 fi 588 } 589 590 _iter_subvols "$_tl" _list_datasets_cb 591 fi 592 593 # Snapshots (@snap-*) — show with -s or -a 594 if $show_snaps || $show_all; then 595 if ! $machine; then 596 echo 597 printf "${_B}Snapshots:${_0}\n" 598 fi 599 600 _list_snaps_cb() { 601 local _id="$1" svname="$2" 602 [[ "$svname" == ${SNAP_PREFIX}* ]] || return 0 603 local short="${svname#${SNAP_PREFIX}}" 604 local created 605 created=$(btrfs subvolume show "$_tl/$svname" 2>/dev/null \ 606 | awk '/Creation time:/ {print $3, $4}') || created="?" 607 local desc 608 desc=$(meta_get "$svname") 609 if $machine; then 610 printf '@%s;-;-;-;%s;%s\n' "$short" "$created" "$desc" 611 else 612 printf " @%-25s %-20s %s\n" "$short" "$created" "$desc" 613 fi 614 } 615 616 _iter_subvols "$_tl" _list_snaps_cb 617 fi 618 619 # snapper snapshots — show with -a 620 if $show_all && [[ -d "$_tl/.snapshots" ]]; then 621 if ! $machine; then 622 echo 623 printf "${_B}snapper:${_0}\n" 624 fi 625 for d in "$_tl/.snapshots"/*/; do 626 [[ -d "${d}snapshot" ]] || continue 627 local num="${d%/}"; num="${num##*/}" 628 local sd="" 629 [[ -f "${d}info.xml" ]] && \ 630 sd=$(grep -oP '<description>\K[^<]+' "${d}info.xml" 2>/dev/null || true) 631 if $machine; then 632 printf 'snapper#%s;-;-;-;-;%s\n' "$num" "$sd" 633 else 634 printf " snapper#%-19s %s\n" "$num" "$sd" 635 fi 636 done 637 fi 638 639 # timeshift snapshots — show with -a 640 if $show_all && [[ -d "$_tl/timeshift-btrfs/snapshots" ]]; then 641 if ! $machine; then 642 echo 643 printf "${_B}timeshift:${_0}\n" 644 fi 645 for d in "$_tl/timeshift-btrfs/snapshots"/*/; do 646 [[ -d "${d}@" ]] || continue 647 local tn="${d%/}"; tn="${tn##*/}" 648 if $machine; then 649 printf 'timeshift/%s;-;-;-;-;\n' "$tn" 650 else 651 printf " timeshift/%-17s\n" "$tn" 652 fi 653 done 654 fi 655 } 656 657 # beadm activate name 658 cmd_activate() { 659 need_root; need_btrfs; check_mount_opts; mount_toplevel 660 661 local name="$1" 662 local bename="${BE_PREFIX}${name}" 663 local bepath="$_tl/$bename" 664 665 [[ -d "$bepath" ]] || die "boot environment '$name' not found" 666 667 local sid 668 sid=$(subvol_id_of "$bepath") 669 [[ -n "$sid" ]] || die "could not determine subvolid for '$name'" 670 671 # Save previous default for rollback 672 mkdir -p "$_tl/$META_DIR" 673 default_id > "$_tl/$META_DIR/previous-default" 674 675 btrfs subvolume set-default "$sid" "$_tl" 676 info "Activated: $name (subvolid=$sid)" 677 info "Reboot to apply." 678 } 679 680 # Interactive BE selection. 681 cmd_activate_interactive() { 682 need_root; need_btrfs; check_mount_opts; mount_toplevel 683 684 local did 685 did=$(default_id) 686 local chosen 687 chosen=$(_choose_be "$_tl" "$did") 688 cmd_activate "$chosen" 689 } 690 691 # beadm mount name mountpoint 692 cmd_mount() { 693 need_root; need_btrfs; mount_toplevel 694 695 local name="${1:?specify BE name}" 696 local mnt="${2:?specify mountpoint}" 697 local bename="${BE_PREFIX}${name}" 698 699 [[ -d "$_tl/$bename" ]] || die "boot environment '$name' not found" 700 [[ -d "$mnt" ]] || die "'$mnt' does not exist" 701 mountpoint -q "$mnt" && die "'$mnt' is already mounted" 702 703 local dev 704 dev=$(root_dev) 705 mount -o "subvol=${bename}" "$dev" "$mnt" 706 info "Mounted: $name -> $mnt" 707 } 708 709 # beadm unmount [-f] name 710 cmd_unmount() { 711 need_root 712 713 local force=false 714 local OPTIND=1 715 716 while getopts ":f" opt; do 717 case "$opt" in 718 f) force=true ;; 719 *) die "unknown option: -$OPTARG" ;; 720 esac 721 done 722 shift $((OPTIND - 1)) 723 724 local name="${1:?specify BE name}" 725 local bename="${BE_PREFIX}${name}" 726 727 # Find mountpoint by subvol name in SOURCE 728 local mnt 729 mnt=$(_find_mountpoint "$bename") || true 730 731 if [[ -z "$mnt" ]]; then 732 die "'$name' is not mounted" 733 fi 734 735 if $force; then 736 umount -l "$mnt" 2>/dev/null || die "failed to unmount '$mnt'" 737 else 738 umount "$mnt" 2>/dev/null || die "failed to unmount '$mnt' (use -f to force)" 739 fi 740 info "Unmounted: $name" 741 } 742 743 # beadm rename old new 744 cmd_rename() { 745 need_root; need_btrfs; mount_toplevel 746 747 local old="${1:?specify current BE name}" 748 local new="${2:?specify new BE name}" 749 750 local old_bename="${BE_PREFIX}${old}" 751 local new_bename="${BE_PREFIX}${new}" 752 753 [[ -d "$_tl/$old_bename" ]] || die "boot environment '$old' not found" 754 [[ ! -d "$_tl/$new_bename" ]] || die "boot environment '$new' already exists" 755 756 mv "$_tl/$old_bename" "$_tl/$new_bename" 757 758 # Move metadata 759 [[ -f "$_tl/$META_DIR/${old_bename}.desc" ]] && \ 760 mv "$_tl/$META_DIR/${old_bename}.desc" "$_tl/$META_DIR/${new_bename}.desc" 761 762 info "Renamed: $old -> $new" 763 } 764 765 # --- Compatibility: snapshot and clone --- 766 767 # snapshot [name] [description] 768 cmd_snapshot() { 769 need_root; need_btrfs; mount_toplevel 770 771 local name="${1:-$(timestamp)}" 772 local desc="${2:-manual snapshot}" 773 local svname="${SNAP_PREFIX}${name}" 774 775 _make_snapshot "$(_current_root_dir)" "$svname" "$desc" 776 info "Snapshot created: $name" 777 } 778 779 # clone <source> [name] [description] 780 cmd_clone() { 781 need_root; need_btrfs; mount_toplevel 782 783 local source="${1:?specify source}" 784 local name="${2:-$(timestamp)}" 785 local desc="${3:-clone of $source}" 786 local bename="${BE_PREFIX}${name}" 787 788 local sp 789 sp=$(_resolve_source "$source") || die "source '$source' not found" 790 791 _make_clone "$sp" "$bename" "$desc" 792 info "Boot environment created: $name" 793 794 # Warn about shared nested subvolumes when cloning from external source 795 local crp 796 crp=$(current_root_path) 797 if [[ -n "$crp" ]]; then 798 local has_nested=false 799 while IFS= read -r line; do 800 local svname 801 svname=$(awk '{print $NF}' <<< "$line") 802 [[ "$svname" == "${crp}/"* ]] || continue 803 if ! $has_nested; then 804 has_nested=true 805 warn "Current root has nested subvolumes (shared, not cloned):" 806 fi 807 warn " ${svname#${crp}/}" 808 done < <(btrfs subvolume list "$_tl") 809 $has_nested && warn "These are shared across all BEs and were NOT included in the clone." 810 fi 811 } 812 813 # --- Extensions --- 814 815 cmd_check() { 816 local ok=true 817 818 local fstype 819 fstype=$(findmnt -n -o FSTYPE /) 820 if [[ "$fstype" == "btrfs" ]]; then 821 printf " root filesystem: ${_G}btrfs${_0}\n" 822 else 823 printf " root filesystem: ${_R}%s${_0}\n" "$fstype" 824 ok=false 825 fi 826 827 local fstab_opts 828 fstab_opts=$(findmnt -n -o OPTIONS --fstab / 2>/dev/null) || fstab_opts="" 829 if [[ "$fstab_opts" =~ subvol=([^,]+) ]] && [[ "${BASH_REMATCH[1]}" != "/" ]]; then 830 printf " fstab: ${_R}subvol=%s${_0} — must be removed\n" "${BASH_REMATCH[1]}" 831 ok=false 832 elif [[ "$fstab_opts" =~ subvolid=([0-9]+) ]] && [[ "${BASH_REMATCH[1]}" != "0" && "${BASH_REMATCH[1]}" != "5" ]]; then 833 printf " fstab: ${_R}subvolid=%s${_0} — must be removed\n" "${BASH_REMATCH[1]}" 834 ok=false 835 else 836 printf " fstab: ${_G}default subvolume${_0}\n" 837 fi 838 839 if command -v btrfs &>/dev/null; then 840 printf " btrfs-progs: ${_G}%s${_0}\n" "$(btrfs --version 2>&1 | awk '{print $2}')" 841 else 842 printf " btrfs-progs: ${_R}not found${_0}\n" 843 ok=false 844 fi 845 846 $ok || { echo; die "system is not ready"; } 847 printf "\n${_G}System is ready.${_0}\n" 848 } 849 850 cmd_status() { 851 need_btrfs 852 local did crp 853 did=$(default_id) 854 crp=$(current_root_path) 855 info "Current root: ${crp:-<toplevel>}" 856 info "Default subvolume: $(btrfs subvolume list / \ 857 | awk -v id="$did" '$2==id {print $NF}')" 858 } 859 860 # shell <name> — chroot into BE (mount + chroot + unmount) 861 cmd_shell() { 862 need_root; need_btrfs; mount_toplevel 863 864 local name="$1" 865 local bename="${BE_PREFIX}${name}" 866 [[ -d "$_tl/$bename" ]] || die "boot environment '$name' not found" 867 868 _shell_inner() { 869 local mnt="$1" 870 info "Entering '$name'. Type 'exit' to leave." 871 chroot "$mnt" /bin/bash || true 872 info "Leaving '$name'." 873 } 874 875 _with_mounted_be "$bename" _shell_inner 876 } 877 878 # upgrade [-d desc] [name] 879 cmd_upgrade() { 880 need_root; need_btrfs; check_mount_opts; mount_toplevel 881 882 local desc="" 883 local OPTIND=1 884 885 while getopts ":d:" opt; do 886 case "$opt" in 887 d) desc="$OPTARG" ;; 888 :) die "option -$OPTARG requires an argument" ;; 889 *) die "unknown option: -$OPTARG" ;; 890 esac 891 done 892 shift $((OPTIND - 1)) 893 894 local name="${1:-upgrade-$(timestamp)}" 895 local bename="${BE_PREFIX}${name}" 896 [[ -z "$desc" ]] && desc="upgrade $(date -u +%Y-%m-%d)" 897 898 # 1. Snapshot current system (safety net) 899 info "Snapshotting current system..." 900 cmd_snapshot "pre-${name}" "before upgrade" 901 902 # 2. Clone current root filesystem 903 _make_clone "$(_current_root_dir)" "$bename" "$desc" 904 info "Clone created: $name" 905 906 # 3. Upgrade in chroot with guaranteed cleanup 907 _upgrade_inner() { 908 local mnt="$1" 909 info "Starting upgrade..." 910 chroot "$mnt" sh -c 'apt-get update && apt-get -y dist-upgrade' 911 } 912 913 if _with_mounted_be "$bename" _upgrade_inner; then 914 info "Upgrade completed successfully." 915 cmd_activate "$name" 916 else 917 warn "Upgrade failed. BE not activated." 918 read -rp "Delete failed clone? [y/N] " yn 919 [[ "$yn" =~ ^[Yy]$ ]] && cmd_destroy -F "$name" 920 return 1 921 fi 922 } 923 924 # Convert min_age (7d, 4w, 2m, 24h) to seconds. 925 _age_to_seconds() { 926 local spec="$1" 927 [[ "$spec" == "0" ]] && echo 0 && return 928 local num="${spec%[hdwm]}" 929 local unit="${spec##*[0-9]}" 930 case "$unit" in 931 h) echo $(( num * 3600 )) ;; 932 d) echo $(( num * 86400 )) ;; 933 w) echo $(( num * 604800 )) ;; 934 m) echo $(( num * 2592000 )) ;; # 30 days 935 *) die "unknown age suffix: '$unit' (in '$spec')" ;; 936 esac 937 } 938 939 # Get creation time of a subvolume in epoch seconds. 940 _subvol_ctime() { 941 local path="$1" 942 local ctime 943 ctime=$(btrfs subvolume show "$path" 2>/dev/null \ 944 | awk '/Creation time:/ {print $3, $4}') || return 1 945 date -d "$ctime" +%s 2>/dev/null || return 1 946 } 947 948 # prune — cleanup by PRUNE_RULES. 949 # No arguments: apply all rules from config. 950 # With argument N: legacy mode — keep N newest BEs. 951 cmd_prune() { 952 need_root; mount_toplevel 953 954 # Legacy mode: prune N 955 if [[ "${1:-}" =~ ^[0-9]+$ ]]; then 956 _prune_legacy "$1" 957 return 958 fi 959 960 local did 961 did=$(default_id) 962 local now 963 now=$(date +%s) 964 local deleted=0 965 966 # Collect all subvolumes (sorted by generation, oldest first) 967 local -a all_svnames=() all_ids=() 968 969 # Cannot use _iter_subvols here — callback runs in same shell but 970 # arrays modified inside process substitution loops are lost. 971 while IFS= read -r line; do 972 local id svname 973 id=$(awk '{print $2}' <<< "$line") 974 svname=$(awk '{print $NF}' <<< "$line") 975 all_svnames+=("$svname") 976 all_ids+=("$id") 977 done < <(btrfs subvolume list "$_tl" --sort=gen 2>/dev/null \ 978 || btrfs subvolume list "$_tl") 979 980 # Set of already processed subvolumes (first matching rule wins) 981 local -A processed=() 982 983 for rule in "${PRUNE_RULES[@]}"; do 984 local pattern min_keep min_age_spec 985 IFS=: read -r pattern min_keep min_age_spec <<< "$rule" 986 987 local min_age_sec 988 min_age_sec=$(_age_to_seconds "$min_age_spec") 989 990 # Collect candidates for this rule 991 local -a candidates=() 992 for (( i=0; i<${#all_svnames[@]}; i++ )); do 993 local sv="${all_svnames[$i]}" 994 local sid="${all_ids[$i]}" 995 996 # Already processed by another rule 997 [[ -z "${processed[$sv]:-}" ]] || continue 998 999 # Glob match 1000 # shellcheck disable=SC2254 1001 case "$sv" in $pattern) ;; *) continue ;; esac 1002 1003 # Never delete the active BE 1004 [[ "$sid" != "$did" ]] || continue 1005 1006 processed[$sv]=1 1007 candidates+=("$sv") 1008 done 1009 1010 local total=${#candidates[@]} 1011 (( total > min_keep )) || continue 1012 1013 local to_delete=$(( total - min_keep )) 1014 local rule_deleted=0 1015 1016 # Delete from the beginning (oldest first, by generation) 1017 for sv in "${candidates[@]}"; do 1018 (( rule_deleted < to_delete )) || break 1019 1020 # Check min_age 1021 if (( min_age_sec > 0 )); then 1022 local ctime 1023 ctime=$(_subvol_ctime "$_tl/$sv") || continue 1024 local age=$(( now - ctime )) 1025 (( age >= min_age_sec )) || continue 1026 fi 1027 1028 btrfs subvolume delete "$_tl/$sv" >/dev/null 1029 rm -f "$_tl/$META_DIR/${sv}.desc" 1030 info " deleted: $sv" 1031 (( rule_deleted++ )) || true 1032 (( deleted++ )) || true 1033 done 1034 done 1035 1036 if (( deleted == 0 )); then 1037 info "Nothing to delete." 1038 else 1039 info "Deleted: $deleted item(s)." 1040 fi 1041 } 1042 1043 # Legacy: prune N — keep N newest BEs (@be-*). 1044 _prune_legacy() { 1045 local keep="$1" 1046 local did 1047 did=$(default_id) 1048 1049 local -a candidates=() 1050 while IFS= read -r line; do 1051 local svname id 1052 svname=$(awk '{print $NF}' <<< "$line") 1053 id=$(awk '{print $2}' <<< "$line") 1054 [[ "$svname" == ${BE_PREFIX}* ]] || continue 1055 [[ "$id" != "$did" ]] || continue 1056 candidates+=("$svname") 1057 done < <(btrfs subvolume list "$_tl" --sort=gen 2>/dev/null \ 1058 || btrfs subvolume list "$_tl") 1059 1060 local total=${#candidates[@]} 1061 if (( total <= keep )); then 1062 info "Nothing to delete ($total item(s), limit $keep)." 1063 return 1064 fi 1065 1066 local n=$((total - keep)) 1067 info "Deleting $n old BE(s) (keeping $keep)..." 1068 for (( i=0; i<n; i++ )); do 1069 local sv="${candidates[$i]}" 1070 btrfs subvolume delete "$_tl/$sv" >/dev/null 1071 rm -f "$_tl/$META_DIR/${sv}.desc" 1072 info " deleted: ${sv#${BE_PREFIX}}" 1073 done 1074 } 1075 1076 # rescue <mountpoint> 1077 cmd_rescue() { 1078 local mnt="${1:?specify btrfs volume mountpoint}" 1079 mountpoint -q "$mnt" || die "'$mnt' is not a mountpoint" 1080 [[ "$(findmnt -n -o FSTYPE "$mnt")" == "btrfs" ]] || die "'$mnt' is not btrfs" 1081 1082 local chosen 1083 chosen=$(_choose_be "$mnt") 1084 1085 # Find subvolid for chosen BE 1086 local bename="${BE_PREFIX}${chosen}" 1087 local sid 1088 sid=$(btrfs subvolume show "$mnt/$bename" 2>/dev/null \ 1089 | awk '/Subvolume ID:/ {print $3}') 1090 [[ -n "$sid" ]] || die "could not determine subvolid for '$chosen'" 1091 1092 btrfs subvolume set-default "$sid" "$mnt" 1093 info "Activated: $chosen (subvolid=$sid)" 1094 info "Reboot from primary media." 1095 } 1096 1097 # --- APT hook --- 1098 1099 cmd_apt_hook_install() { 1100 need_root 1101 cat > /etc/apt/apt.conf.d/80-be-snapshot << 'HOOK' 1102 DPkg::Pre-Invoke { "/usr/local/sbin/be-btrfs apt-pre-hook 2>/dev/null || true"; }; 1103 HOOK 1104 info "APT hook installed." 1105 } 1106 1107 cmd_apt_pre_hook() { 1108 need_btrfs 1109 mount_toplevel 1110 local name="apt-$(timestamp)" 1111 local desc 1112 desc="apt: $(tr '\0' ' ' < /proc/$PPID/cmdline 2>/dev/null | head -c 200 || echo '?')" 1113 cmd_snapshot "$name" "$desc" 2>/dev/null || true 1114 } 1115 1116 # --- Entry point --- 1117 1118 usage() { 1119 cat <<EOF 1120 ${PROG} v${VERSION} — Boot Environment Manager for btrfs 1121 1122 Usage: ${PROG} <command> [options] [arguments] 1123 1124 Commands: 1125 create [-a] [-d desc] [-e source] name 1126 Create BE (clone of current or specified source) 1127 create name@snapshot Create a snapshot of a BE 1128 destroy [-fF] name Delete BE (-f: force unmount, -F: no confirmation) 1129 destroy [-F] name@snapshot Delete snapshot 1130 list [-a|-ds] [-H] [name] List BEs (-H: machine-parseable) 1131 mount name mountpoint Mount a BE 1132 unmount [-f] name Unmount a BE 1133 rename old new Rename a BE 1134 activate [name] Activate BE (no name = interactive) 1135 1136 Additional commands: 1137 snapshot [name] [description] Snapshot current system (read-only) 1138 clone <source> [name] Clone from external snapshot (writable BE) 1139 shell <name> Chroot into BE (mount + shell + unmount) 1140 upgrade [-d desc] [name] Clone + apt dist-upgrade + activate 1141 prune Cleanup by rules from config 1142 prune N Keep N newest BEs (legacy) 1143 rescue <mountpoint> Activate BE from rescue media 1144 check Check system compatibility 1145 status Current state 1146 1147 apt-hook-install Install APT hook 1148 1149 Sources for clone / create -e: 1150 <name> own snapshot (@snap-name) or BE 1151 snapper#<N> snapper snapshot 1152 timeshift/<date> timeshift snapshot 1153 EOF 1154 } 1155 1156 main() { 1157 _load_config 1158 1159 local cmd="${1:-help}" 1160 shift || true 1161 1162 case "$cmd" in 1163 create) cmd_create "$@" ;; 1164 destroy|rm) cmd_destroy "$@" ;; 1165 list|ls) cmd_list "$@" ;; 1166 activate) if [[ $# -ge 1 ]]; then cmd_activate "$1" 1167 else cmd_activate_interactive; fi ;; 1168 mount) cmd_mount "$@" ;; 1169 unmount|umount) cmd_unmount "$@" ;; 1170 rename) cmd_rename "$@" ;; 1171 snapshot|snap) cmd_snapshot "${1:-}" "${2:-}" ;; 1172 clone) cmd_clone "$@" ;; 1173 shell|sh) cmd_shell "${1:?specify BE name}" ;; 1174 upgrade) cmd_upgrade "$@" ;; 1175 prune) cmd_prune "${1:-}" ;; 1176 rescue) cmd_rescue "${1:?specify mountpoint}" ;; 1177 check) cmd_check ;; 1178 status) cmd_status ;; 1179 apt-hook-install) cmd_apt_hook_install ;; 1180 apt-pre-hook) cmd_apt_pre_hook ;; 1181 help|-h|--help) usage ;; 1182 version|-V|--version) echo "${PROG} v${VERSION}" ;; 1183 *) die "unknown command: $cmd" ;; 1184 esac 1185 } 1186 1187 main "$@"