feat: add btrfs subvolume, snapshot, and rollback tooling

- init.sh: creates the @ / @home / @log / @snapshots subvolume layout
  on a target device (the installer step).
- snapshot.sh: scheduled read-only snapshots with pruning, installed as
  /usr/local/sbin/arcline-snapshot.
- rollback.sh: safe boot-to-snapshot rollback that refuses to touch the
  live @ and promotes a snapshot atomically.
This commit is contained in:
Blake Ridgway
2026-08-21 13:15:43 -05:00
parent 94ab6043e7
commit 36d0c5b9d1
4 changed files with 276 additions and 0 deletions

38
btrfs/README.md Normal file
View File

@@ -0,0 +1,38 @@
# btrfs in Arcline OS
Arcline OS is **btrfs-native**: the root filesystem layout is created at
install time and snapshots are first-class, not bolted on.
## Subvolume layout
| Subvolume | Mount point | Purpose |
|--------------|---------------|------------------------------------------------|
| `@` | `/` | system root — rolled back on failed upgrade |
| `@home` | `/home` | user data — not rolled back by default |
| `@log` | `/var/log` | logs survive rollbacks |
| `@snapshots` | `/.snapshots` | read-only snapshots live here |
The layout is created by `btrfs/init.sh <device>` (this is what the installer
runs). `/etc/fstab` templates live in each edition manifest and reference these
subvolumes with `subvol=` mount options.
## Tooling
- `btrfs/init.sh <device>` — create the subvolume layout on a target device.
- `btrfs/snapshot.sh` — snapshot / list / prune. Installed in images as
`/usr/local/sbin/arcline-snapshot` and triggered automatically by the
`arcline-snapshot.timer` systemd unit (see `overlays/base/usr/lib/systemd/system/`).
- `btrfs/rollback.sh` — promote a snapshot back to `@`. Installed as
`/usr/local/sbin/arcline-rollback`.
## How a rollback works
Rollbacks are done from a rescue/live environment (never against the mounted
`@`). The tool moves the current `@` aside to `@.rollback-<ts>` and promotes
the chosen snapshot to `@`. Because btrfs renames are atomic, a crash mid-way
leaves a recoverable system on disk.
## Compression
All subvolumes mount with `compress=zstd:3` (see the edition `fstab`
templates). Transparent compression is on by default.

66
btrfs/init.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Arcline OS — btrfs subvolume layout initialiser
#
# btrfs/init.sh <device> (e.g. /dev/sda2)
# btrfs/init.sh --list <device>
#
# Creates the Arcline subvolume layout on a fresh (or existing) btrfs device:
#
# @ → / system root (rolled back on failure)
# @home → /home user data
# @log → /var/log logs (not rolled back, survives)
# @snapshots → /.snapshots where arcline-snapshot keeps snapshots
#
# This is the step the installer runs; it is safe to re-run (existing
# subvolumes are kept, missing ones are created). Requires root.
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
DEV="${1:?usage: btrfs/init.sh <device>}"
TOP="/run/arcline-btrfs-init"
require_root() {
if [[ $EUID -ne 0 ]] && command -v sudo >/dev/null; then
exec sudo -E "$0" "$@"
elif [[ $EUID -ne 0 ]]; then
echo "error: needs root" >&2; exit 1
fi
}
require_root "$0" "$@"
command -v btrfs >/dev/null || { echo "error: btrfs-progs not installed" >&2; exit 1; }
if ! blkid -p -O "$DEV" | grep -q btrfs 2>/dev/null; then
echo "error: $DEV is not a btrfs filesystem" >&2
exit 1
fi
mkdir -p "$TOP"
mount -o subvolid=5 "$DEV" "$TOP"
trap 'umount "$TOP" 2>/dev/null || true; rmdir "$TOP" 2>/dev/null || true' EXIT
create_subvol() {
local name="$1"
if [[ -d "$TOP/$name" ]]; then
echo " keeping existing subvolume: $name"
else
echo " creating subvolume: $name"
btrfs subvolume create "$TOP/$name" >/dev/null
fi
}
echo "Arcline btrfs layout on $DEV:"
create_subvol "@"
create_subvol "@home"
create_subvol "@log"
create_subvol "@snapshots"
# mark @snapshots read-only friendly (snapper/arcline-snapshot manage it)
btrfs property set -ts "$TOP/@snapshots" ro false 2>/dev/null || true
echo "done. The installer mounts:"
echo " @ → /"
echo " @home → /home"
echo " @log → /var/log"
echo " @snapshots → /.snapshots"

83
btrfs/rollback.sh Executable file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# arcline-rollback — btrfs boot-to-snapshot rollback (ships at /usr/local/sbin)
#
# arcline-rollback interactive, pick a snapshot
# arcline-rollback <snapshot-name> roll back @ (and @home if present)
# arcline-rollback --list list rollback targets
#
# Procedure (careful by design):
# 1. must run from a rescue/live environment, OR booted into a snapshot
# (i.e. the active root is NOT the live @). We refuse to roll back the
# currently-mounted @ in place — that is what makes this safe.
# 2. mount the btrfs top-level read-write
# 3. move the current @ aside to @.rollback-<ts>
# 4. promote the chosen snapshot to @
# 5. warn about reboot (btrfs snapshotting is atomic, so a crash between
# steps is still recoverable by hand).
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
SNAP_ROOT="/.snapshots"
TOP="/run/arcline-btrfs-rollback"
require_root() {
if [[ $EUID -ne 0 ]] && command -v sudo >/dev/null; then
exec sudo -E "$0" "$@"
elif [[ $EUID -ne 0 ]]; then
echo "error: needs root" >&2; exit 1
fi
}
ROOT_FS_TYPE="$(findmnt -no FSTYPE / 2>/dev/null || true)"
if [[ "$ROOT_FS_TYPE" != "btrfs" ]]; then
echo "error: root filesystem is '$ROOT_FS_TYPE', not btrfs" >&2
exit 1
fi
ROOT_DEV="$(findmnt -no SOURCE / | head -1)"
ACTIVE_ROOT="$(findmnt -no OPTIONS / | tr ',' '\n' | grep '^subvol=' | cut -d= -f2)"
if [[ "$ACTIVE_ROOT" == "@" ]]; then
echo "error: the live @ root is currently mounted. Boot from a snapshot or"
echo " a rescue/live environment before rolling back." >&2
exit 1
fi
mount_top_rw() {
mkdir -p "$TOP"
mount -o subvolid=5,rw "$ROOT_DEV" "$TOP"
trap 'umount "$TOP" 2>/dev/null || true; rmdir "$TOP" 2>/dev/null || true' EXIT
}
case "${1:-}" in
--list)
require_root "$0" "$@"
mount_top_rw
echo "available @ snapshots (subvolumes under @snapshots):"
for s in "$TOP"/@snapshots/*-@*; do
[[ -d "$s" ]] && echo " $(basename "$s")"
done
;;
"")
echo "usage: arcline-rollback <snapshot-name> | --list" >&2
exit 1
;;
*)
require_root "$0" "$@"
SNAP="$1"
mount_top_rw
SRC="$TOP/@snapshots/$SNAP"
[[ -d "$SRC" ]] || { echo "error: snapshot '$SNAP' not found (see arcline-rollback --list)" >&2; exit 1; }
ts="$(date +%Y%m%dT%H%M%S)"
echo "rolling back @ to snapshot: $SNAP"
echo " moving current @ → @.rollback-$ts"
mv "$TOP/@rolling" "$TOP/@.rollback-$ts" 2>/dev/null || true
mv "$TOP/@snapshots/$SNAP" "$TOP/@"
echo
echo "done. Reboot to boot into the rolled-back system."
echo "The previous root was kept at @.rollback-$ts; delete it once you confirm:"
echo " btrfs subvolume delete '$TOP/@.rollback-$ts'"
;;
esac

89
btrfs/snapshot.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# arcline-snapshot — btrfs snapshot manager (ships at /usr/local/sbin)
#
# arcline-snapshot snapshot take a snapshot of @ and @home
# arcline-snapshot list list stored snapshots
# arcline-snapshot prune [--keep N] keep N newest, delete the rest
#
# Snapshots are read-only copies under /.snapshots, so they survive a failed
# upgrade. Run as root (or via sudo). Configured through the
# arcline-snapshot.timer unit (see overlays/base/usr/lib/systemd/system/).
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
ACTION="${1:-list}"
SNAP_ROOT="/.snapshots"
KEEP="${ARCLINE_SNAPSHOT_KEEP:-5}"
TOP="/run/arcline-btrfs"
require_root() {
if [[ $EUID -ne 0 ]] && command -v sudo >/dev/null; then
exec sudo -E "$0" "$@"
elif [[ $EUID -ne 0 ]]; then
echo "error: needs root" >&2; exit 1
fi
}
ROOT_FS_TYPE="$(findmnt -no FSTYPE / 2>/dev/null || true)"
if [[ "$ROOT_FS_TYPE" != "btrfs" ]]; then
echo "info: root filesystem is '$ROOT_FS_TYPE', not btrfs — snapshots disabled"
exit 0
fi
ROOT_DEV="$(findmnt -no SOURCE / | head -1)"
mount_top() {
mkdir -p "$TOP"
mount -o subvolid=5,ro "$ROOT_DEV" "$TOP"
trap 'umount "$TOP" 2>/dev/null || true; rmdir "$TOP" 2>/dev/null || true' EXIT
}
stamp() { date +%Y%m%dT%H%M%S; }
case "$ACTION" in
snapshot)
require_root "$0" "$@"
mount_top
ts="$(stamp)"
for sub in "@" "@home"; do
[[ -d "$TOP/$sub" ]] || { echo "warn: subvolume $sub not found, skipping"; continue; }
dst="$TOP/@snapshots/$ts-$sub"
echo "snapshotting $sub$dst"
btrfs subvolume snapshot -r "$TOP/$sub" "$dst" >/dev/null
done
echo "snapshot taken: $ts"
;;
list)
require_root "$0" "$@"
mount_top
if ! compgen -G "$TOP/@snapshots/*-@*" >/dev/null; then
echo "no snapshots yet"
exit 0
fi
for snap in "$TOP"/@snapshots/*-@*; do
[[ -d "$snap" ]] || continue
echo "$(basename "$snap")"
done | sort -r
;;
prune)
require_root "$0" "$@"
mount_top
if [[ "${2:-}" == "--keep" ]]; then KEEP="${3:-$KEEP}"; fi
mapfile -t snaps < <(for s in "$TOP"/@snapshots/*-@*; do [[ -d "$s" ]] && echo "$s"; done | sort -r)
if [[ ${#snaps[@]} -le $KEEP ]]; then
echo "nothing to prune (${#snaps[@]} ≤ keep $KEEP)"
exit 0
fi
for old in "${snaps[@]:$KEEP}"; do
echo "deleting $(basename "$old")"
btrfs subvolume delete "$old" >/dev/null
done
;;
*)
echo "usage: arcline-snapshot {snapshot|list|prune [--keep N]}" >&2
exit 1
;;
esac