Last active
March 28, 2026 20:50
-
-
Save fennectech/3d3db929ae276eb522d79d315b7bc4c4 to your computer and use it in GitHub Desktop.
LTO-Backup.sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| #Copyright 2026 fennectech | |
| # | |
| # Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: | |
| # | |
| # 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. | |
| # | |
| # 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. | |
| # | |
| # 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. | |
| echo "THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." | |
| # | |
| # btrfs-to-lto-simple.sh | |
| # | |
| set -euo pipefail | |
| TAPE_DEVICE="/dev/nst0" | |
| BLOCK_SIZE_KB=1024 | |
| MBUFFER_RAM="64G" | |
| MBUFFER_BLOCK="4M" | |
| # Usable tape capacity limit in 1 MiB blocks. | |
| # | |
| # This must be set BELOW the physical capacity of the tape for the | |
| # data being backed up. dd must always exit cleanly (exit 0) by | |
| # hitting this count before the tape runs out. If dd instead hits | |
| # the physical end of tape, the kernel returns ENOSPC: dd has already | |
| # consumed the in-flight block from the pipe but failed to write it, | |
| # so that block is silently lost and the resulting multi-tape stream | |
| # is corrupt and unrestorable. | |
| # | |
| # LTO-8 native capacity is ~12 TiB; with compression actual capacity | |
| # varies with data compressibility. Set this conservatively based on | |
| # the worst-case (least compressible) data you expect to back up. | |
| # | |
| # Observed capacity for this data: ~1,454,345 MiB (~1.4 TiB). | |
| # Setting ~50 GiB (~51,200 MiB) below that as a safe margin. | |
| # | |
| # If you ever see "No space left on device" in the output, lower this | |
| # value further — do NOT continue with a tape change, as data will | |
| # have been lost. | |
| TAPE_BLOCKS=1474560 | |
| [[ $EUID -ne 0 ]] && { echo "Error: must be run as root."; exit 1; } | |
| for cmd in btrfs mbuffer mt findmnt dd; do | |
| command -v "$cmd" &>/dev/null || { echo "Error: $cmd not found."; exit 1; } | |
| done | |
| [[ ! -c "$TAPE_DEVICE" ]] && { echo "Error: tape device $TAPE_DEVICE not found."; exit 1; } | |
| # --- Select filesystem --- | |
| echo "" | |
| echo "Scanning for BTRFS filesystems with snapper snapshots..." | |
| echo "" | |
| mapfile -t VOLUMES < <( | |
| findmnt -t btrfs -lno TARGET 2>/dev/null | sort -u | while read -r mnt; do | |
| compgen -G "$mnt/.snapshots/*/snapshot" &>/dev/null && echo "$mnt" || true | |
| done | |
| ) | |
| [[ ${#VOLUMES[@]} -eq 0 ]] && { echo "Error: no BTRFS filesystems with snapper snapshots found."; exit 1; } | |
| for i in "${!VOLUMES[@]}"; do | |
| printf " [%2d] %s\n" $(( i+1 )) "${VOLUMES[$i]}" | |
| done | |
| echo "" | |
| read -rp "Select filesystem: " CHOICE | |
| [[ "$CHOICE" =~ ^[0-9]+$ ]] && (( CHOICE >= 1 && CHOICE <= ${#VOLUMES[@]} )) \ | |
| || { echo "Invalid selection."; exit 1; } | |
| VOLUME="${VOLUMES[$(( CHOICE-1 ))]}" | |
| # --- Select snapshot --- | |
| echo "" | |
| echo "Snapshots on $VOLUME:" | |
| echo "" | |
| mapfile -t SNAPSHOTS < <(compgen -G "$VOLUME/.snapshots/*/snapshot" | sort -V) | |
| [[ ${#SNAPSHOTS[@]} -eq 0 ]] && { echo "Error: no snapshots found."; exit 1; } | |
| for i in "${!SNAPSHOTS[@]}"; do | |
| SNAP="${SNAPSHOTS[$i]}" | |
| NUM=$(echo "$SNAP" | grep -oP '(?<=\.snapshots/)\d+') | |
| INFOXML="${SNAP%/snapshot}/info.xml" | |
| DATE="?" | |
| [[ -f "$INFOXML" ]] && DATE=$(grep -oP '(?<=<date>)[^<]+' "$INFOXML" 2>/dev/null | head -1 | cut -c1-19 || echo "?") | |
| [[ "$DATE" == "?" ]] && DATE=$(stat -c '%y' "$SNAP" 2>/dev/null | cut -d. -f1 || echo "?") | |
| printf " [%2d] #%-4s %s\n" $(( i+1 )) "$NUM" "$DATE" | |
| done | |
| echo "" | |
| read -rp "Select snapshot: " CHOICE | |
| [[ "$CHOICE" =~ ^[0-9]+$ ]] && (( CHOICE >= 1 && CHOICE <= ${#SNAPSHOTS[@]} )) \ | |
| || { echo "Invalid selection."; exit 1; } | |
| SOURCE="${SNAPSHOTS[$(( CHOICE-1 ))]}" | |
| echo "" | |
| echo " Source: $SOURCE" | |
| echo " Device: $TAPE_DEVICE" | |
| echo "" | |
| read -rp "Confirm backup? (y/N): " CONFIRM | |
| [[ "$CONFIRM" =~ ^[Yy]$ ]] || { echo "Cancelled."; exit 0; } | |
| # --- Backup --- | |
| MBUF_LOG="/tmp/mbuffer_$$.log" | |
| DD_ERR="/tmp/dd_err_$$" | |
| TAIL_PID_FILE="/tmp/tail_pid_$$" | |
| cleanup() { | |
| if [[ -f "$TAIL_PID_FILE" ]]; then | |
| kill "$(cat "$TAIL_PID_FILE")" 2>/dev/null || true | |
| rm -f "$TAIL_PID_FILE" | |
| fi | |
| rm -f "$MBUF_LOG" "$DD_ERR" | |
| } | |
| trap cleanup EXIT | |
| echo "" | |
| echo "Starting backup..." | |
| echo "" | |
| # Pipe btrfs send → mbuffer → { tape writing loop } | |
| # | |
| # Variable block mode (setblk 0) is set on each tape before writing. | |
| # In this mode each write() syscall becomes its own tape record of | |
| # exactly that size, so the final record is naturally the exact length | |
| # of the remaining data — no padding required and no trailing zeros | |
| # for btrfs receive to choke on during restore. | |
| # | |
| # Each dd writes at most TAPE_BLOCKS x 1 MiB, then exits cleanly with | |
| # status 0, leaving the remainder of the stream in the pipe for the | |
| # next tape. TAPE_BLOCKS MUST be set low enough that dd always hits | |
| # this count before the physical end of tape — see comment above. | |
| # | |
| # iflag=fullblock causes dd to accumulate full 1 MiB input blocks | |
| # before writing, keeping the tape streaming at full speed. The final | |
| # block is whatever the stream has left and is written at its natural | |
| # size — variable block mode accepts it without complaint. | |
| # | |
| # mbuffer absorbs btrfs send output into RAM so dd can start the next | |
| # tape without stalling the source. | |
| btrfs send --compressed-data "$SOURCE" 2>/dev/null \ | |
| | mbuffer -m "$MBUFFER_RAM" -s "$MBUFFER_BLOCK" 2>"$MBUF_LOG" \ | |
| | { | |
| TAPE_NUM=1 | |
| echo "Insert tape #1 and press ENTER..." >&2 | |
| read -r < /dev/tty | |
| tail -f "$MBUF_LOG" >&2 & | |
| TAIL_PID=$! | |
| echo "$TAIL_PID" > "$TAIL_PID_FILE" | |
| mt -f "$TAPE_DEVICE" rewind | |
| mt -f "$TAPE_DEVICE" setblk 0 | |
| while true; do | |
| echo "Writing to tape #${TAPE_NUM} (up to ${TAPE_BLOCKS} x 1 MiB)..." >&2 | |
| set +e | |
| dd of="$TAPE_DEVICE" bs="${BLOCK_SIZE_KB}k" iflag=fullblock \ | |
| count="$TAPE_BLOCKS" 2>"$DD_ERR" | |
| DD_EXIT=$? | |
| set -e | |
| cat "$DD_ERR" >&2 | |
| # Any dd failure is fatal. In particular, ENOSPC means the | |
| # physical tape filled before TAPE_BLOCKS was reached: dd had | |
| # already pulled the in-flight block out of the pipe before the | |
| # failed write, so that block is gone and the stream is corrupt. | |
| # Lower TAPE_BLOCKS and start the backup over from scratch. | |
| if [[ $DD_EXIT -ne 0 ]]; then | |
| if grep -q "No space left on device" "$DD_ERR"; then | |
| echo "" >&2 | |
| echo "Error: physical end of tape reached before block limit." >&2 | |
| echo " The in-flight block was lost — this backup is corrupt." >&2 | |
| echo " Lower TAPE_BLOCKS (currently ${TAPE_BLOCKS}) and retry." >&2 | |
| else | |
| echo "Error: dd failed (exit $DD_EXIT). Aborting." >&2 | |
| fi | |
| exit 1 | |
| fi | |
| # If dd wrote fewer blocks than the limit the stream is exhausted | |
| # and we are done. | |
| FULL_RECORDS=$(grep 'records out' "$DD_ERR" | grep -oP '^\d+' | tail -1 || echo 0) | |
| if (( FULL_RECORDS < TAPE_BLOCKS )); then | |
| break | |
| fi | |
| echo "Tape #${TAPE_NUM} full." >&2 | |
| mt -f "$TAPE_DEVICE" weof 1 2>/dev/null || true | |
| mt -f "$TAPE_DEVICE" rewind | |
| mt -f "$TAPE_DEVICE" eject 2>/dev/null || mt -f "$TAPE_DEVICE" offline 2>/dev/null || true | |
| (( TAPE_NUM++ )) | |
| echo "Insert tape #${TAPE_NUM} and press ENTER..." >&2 | |
| read -r < /dev/tty | |
| mt -f "$TAPE_DEVICE" rewind | |
| mt -f "$TAPE_DEVICE" setblk 0 | |
| done | |
| mt -f "$TAPE_DEVICE" weof 1 2>/dev/null || true | |
| mt -f "$TAPE_DEVICE" eject | |
| echo "" >&2 | |
| echo "Backup complete ($TAPE_NUM tape(s) used)." >&2 | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment