Files
onefinity-firmware/backup/onefinity-backup.sh
Henrik Muehe f170002c8b 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.
2026-05-03 14:03:24 +02:00

279 lines
10 KiB
Bash
Executable File

#!/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 <<EOF
Usage: $(basename "$0") <command> [options]
Commands:
backup [-o file.img.gz] Backup SD card from Pi over SSH
restore <image.gz> [/dev/diskN] Restore image to local SD card or remote Pi
verify <image.gz> 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