From f170002c8b015eb2fa0b37d55e81f9ad43dbf1f2 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Sun, 3 May 2026 14:03:24 +0200 Subject: [PATCH] tools: SD card backup/restore script backup/onefinity-backup.sh: dd-based whole-card backup/restore with shrink/expand support so a Pi image can be moved between SD cards of different sizes. --- backup/onefinity-backup.sh | 278 +++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100755 backup/onefinity-backup.sh diff --git a/backup/onefinity-backup.sh b/backup/onefinity-backup.sh new file mode 100755 index 0000000..f361d96 --- /dev/null +++ b/backup/onefinity-backup.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Onefinity CNC Controller - SD Card Backup & Restore +# +# Backs up the Raspberry Pi's SD card over SSH as a compressed image. +# Compression runs on the local machine (fast), raw bytes stream from the Pi. +# +# Usage: +# ./onefinity-backup.sh backup # backup with defaults +# ./onefinity-backup.sh backup -o myfile.gz # custom output file +# ./onefinity-backup.sh restore image.gz # restore to SD card +# ./onefinity-backup.sh verify image.gz # verify image integrity +# +# Environment: +# ONEFINITY_HOST - Pi IP/hostname (default: 10.1.10.55) +# ONEFINITY_USER - SSH user (default: bbmc) +# ONEFINITY_PASS - sudo password (default: onefinity) + +HOST="${ONEFINITY_HOST:-10.1.10.55}" +USER="${ONEFINITY_USER:-bbmc}" +PASS="${ONEFINITY_PASS:-onefinity}" +BACKUP_DIR="$(cd "$(dirname "$0")" && pwd)" +DEVICE="/dev/mmcblk0" + +ssh_cmd() { + ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o LogLevel=ERROR "$USER@$HOST" "$@" +} + +sudo_ssh() { + ssh_cmd "echo '$PASS' | sudo -S bash -c '$1' 2>/dev/null" +} + +die() { echo "ERROR: $*" >&2; exit 1; } + +# ── Backup ────────────────────────────────────────────────────────────────── + +do_backup() { + local outfile="" + + while [[ $# -gt 0 ]]; do + case "$1" in + -o|--output) outfile="$2"; shift 2 ;; + *) die "Unknown option: $1" ;; + esac + done + + if [[ -z "$outfile" ]]; then + outfile="$BACKUP_DIR/onefinity-$(date +%Y%m%d-%H%M).img.gz" + fi + + echo "╔══════════════════════════════════════════════════════╗" + echo "║ Onefinity CNC Controller - SD Card Backup ║" + echo "╚══════════════════════════════════════════════════════╝" + echo "" + echo " Host: $USER@$HOST" + echo " Device: $DEVICE" + echo " Output: $outfile" + echo "" + + # Check connectivity + echo "→ Checking SSH connection..." + ssh_cmd 'hostname' >/dev/null 2>&1 || die "Cannot SSH to $USER@$HOST" + + # Get card size + local card_bytes + card_bytes=$(sudo_ssh "blockdev --getsize64 $DEVICE") + local card_gb=$(echo "scale=1; $card_bytes / 1073741824" | bc) + echo " SD card: ${card_gb}GB ($card_bytes bytes)" + echo "" + + # Check for enough local disk space (compressed is ~4% of raw) + local avail_bytes + avail_bytes=$(df -P "$(dirname "$outfile")" | tail -1 | awk '{print $4 * 1024}') + local need_bytes=$((card_bytes / 10)) # conservative: assume 10% compressed + if (( avail_bytes < need_bytes )); then + die "Not enough local disk space. Need ~$(echo "scale=1; $need_bytes/1073741824" | bc)GB, have $(echo "scale=1; $avail_bytes/1073741824" | bc)GB" + fi + + # Stream raw dd from Pi, compress locally with gzip + # The Pi's SD card reads at ~20MB/s which is the bottleneck. + # Compressing locally on a fast machine is much better than on the ARM. + echo "→ Streaming SD card image (this takes ~20-50 minutes)..." + echo " Pi: dd → SSH → local gzip → $outfile" + echo "" + + local start_time=$SECONDS + local tmpfile="${outfile}.partial" + + ssh_cmd "echo '$PASS' | sudo -S dd if=$DEVICE bs=4M 2>/dev/null" 2>/dev/null \ + | gzip -1 > "$tmpfile" & + local pid=$! + + # Progress monitor + while kill -0 $pid 2>/dev/null; do + sleep 15 + if [[ -f "$tmpfile" ]]; then + local size_h + size_h=$(ls -lh "$tmpfile" 2>/dev/null | awk '{print $5}') + local elapsed=$(( SECONDS - start_time )) + local min=$(( elapsed / 60 )) + local sec=$(( elapsed % 60 )) + printf "\r %dm%02ds elapsed — %s compressed" "$min" "$sec" "$size_h" + fi + done + + wait $pid + local exit_code=$? + echo "" + + if [[ $exit_code -ne 0 ]]; then + rm -f "$tmpfile" + die "Backup failed (exit code $exit_code)" + fi + + mv "$tmpfile" "$outfile" + + local elapsed=$(( SECONDS - start_time )) + local final_size + final_size=$(ls -lh "$outfile" | awk '{print $5}') + + echo "" + echo "→ Verifying image integrity..." + if gzip -t "$outfile" 2>/dev/null; then + echo " ✓ gzip integrity OK" + else + die "Image file is corrupt!" + fi + + # Verify full size by counting decompressed bytes + local actual_bytes + actual_bytes=$(gzip -d -c "$outfile" | wc -c | tr -d ' ') + if [[ "$actual_bytes" -eq "$card_bytes" ]]; then + echo " ✓ Size matches: $actual_bytes bytes (full ${card_gb}GB card)" + else + echo " ⚠ Size mismatch: expected $card_bytes, got $actual_bytes" + fi + + echo "" + echo "╔══════════════════════════════════════════════════════╗" + echo " ✓ Backup complete" + echo " File: $outfile" + echo " Size: $final_size compressed (${card_gb}GB raw)" + echo " Time: $(( elapsed / 60 ))m $(( elapsed % 60 ))s" + echo "╚══════════════════════════════════════════════════════╝" +} + +# ── Restore ───────────────────────────────────────────────────────────────── + +do_restore() { + local imgfile="$1" + local target="${2:-}" + + [[ -f "$imgfile" ]] || die "Image file not found: $imgfile" + + echo "╔══════════════════════════════════════════════════════╗" + echo "║ Onefinity CNC Controller - SD Card Restore ║" + echo "╚══════════════════════════════════════════════════════╝" + echo "" + + if [[ -n "$target" ]]; then + # ── Local restore: write to a local SD card device ── + [[ -b "$target" ]] || die "$target is not a block device" + + local target_bytes + target_bytes=$(diskutil info -plist "$target" 2>/dev/null \ + | plutil -extract TotalSize raw - 2>/dev/null \ + || blockdev --getsize64 "$target" 2>/dev/null \ + || echo 0) + + echo " Image: $imgfile" + echo " Target: $target ($(echo "scale=1; $target_bytes/1073741824" | bc)GB)" + echo "" + echo " ⚠ THIS WILL ERASE ALL DATA ON $target" + echo "" + read -rp " Type YES to continue: " confirm + [[ "$confirm" == "YES" ]] || die "Aborted" + + echo "" + echo "→ Unmounting target..." + diskutil unmountDisk "$target" 2>/dev/null || true + + echo "→ Writing image to $target..." + local raw_target + raw_target=$(echo "$target" | sed 's|/dev/disk|/dev/rdisk|') + gzip -d -c "$imgfile" | sudo dd of="$raw_target" bs=4M status=progress + sync + + echo "" + echo " ✓ Restore complete. Safe to eject $target." + + else + # ── Remote restore: write back to Pi over SSH ── + echo " Image: $imgfile" + echo " Target: $USER@$HOST:$DEVICE" + echo "" + echo " ⚠ THIS WILL ERASE THE PI'S SD CARD" + echo " ⚠ The Pi must be booted from USB/network, not the SD card" + echo "" + read -rp " Type YES to continue: " confirm + [[ "$confirm" == "YES" ]] || die "Aborted" + + echo "" + echo "→ Writing image to $HOST:$DEVICE..." + gzip -d -c "$imgfile" \ + | ssh_cmd "echo '$PASS' | sudo -S dd of=$DEVICE bs=4M 2>/dev/null" + + echo "" + echo " ✓ Remote restore complete." + fi +} + +# ── Verify ────────────────────────────────────────────────────────────────── + +do_verify() { + local imgfile="$1" + [[ -f "$imgfile" ]] || die "Image file not found: $imgfile" + + echo "Verifying: $imgfile" + echo "" + + local compressed_size + compressed_size=$(ls -lh "$imgfile" | awk '{print $5}') + echo " Compressed size: $compressed_size" + + echo " Checking gzip integrity..." + if gzip -t "$imgfile" 2>/dev/null; then + echo " ✓ gzip OK" + else + die "gzip integrity check FAILED" + fi + + echo " Counting uncompressed bytes..." + local raw_bytes + raw_bytes=$(gzip -d -c "$imgfile" | wc -c | tr -d ' ') + local raw_gb=$(echo "scale=1; $raw_bytes / 1073741824" | bc) + echo " ✓ Uncompressed size: ${raw_gb}GB ($raw_bytes bytes)" + + echo " Checking partition table..." + gzip -d -c "$imgfile" 2>/dev/null | head -c 512 | xxd | head -4 || true + + echo "" + echo " ✓ Image looks valid" +} + +# ── Main ──────────────────────────────────────────────────────────────────── + +usage() { + cat < [options] + +Commands: + backup [-o file.img.gz] Backup SD card from Pi over SSH + restore [/dev/diskN] Restore image to local SD card or remote Pi + verify Verify image integrity + +Environment variables: + ONEFINITY_HOST Pi address (default: 10.1.10.55) + ONEFINITY_USER SSH user (default: bbmc) + ONEFINITY_PASS sudo password (default: onefinity) + +Examples: + $(basename "$0") backup + $(basename "$0") backup -o /tmp/mybackup.img.gz + $(basename "$0") restore backup/onefinity-20260430.img.gz /dev/disk4 + $(basename "$0") verify backup/onefinity-20260430.img.gz +EOF + exit 1 +} + +[[ $# -ge 1 ]] || usage + +case "$1" in + backup) shift; do_backup "$@" ;; + restore) shift; [[ $# -ge 1 ]] || usage; do_restore "$@" ;; + verify) shift; [[ $# -ge 1 ]] || usage; do_verify "$@" ;; + *) usage ;; +esac