/ 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 "$@"