diff --git a/btrfs/README.md b/btrfs/README.md new file mode 100644 index 0000000..c9f8a08 --- /dev/null +++ b/btrfs/README.md @@ -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 ` (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 ` — 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-` 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. diff --git a/btrfs/init.sh b/btrfs/init.sh new file mode 100755 index 0000000..e133874 --- /dev/null +++ b/btrfs/init.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Arcline OS — btrfs subvolume layout initialiser +# +# btrfs/init.sh (e.g. /dev/sda2) +# btrfs/init.sh --list +# +# 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 }" +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" diff --git a/btrfs/rollback.sh b/btrfs/rollback.sh new file mode 100755 index 0000000..cce4398 --- /dev/null +++ b/btrfs/rollback.sh @@ -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 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- +# 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 | --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 diff --git a/btrfs/snapshot.sh b/btrfs/snapshot.sh new file mode 100755 index 0000000..3423e03 --- /dev/null +++ b/btrfs/snapshot.sh @@ -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