mirror of
https://github.com/bashclub/bashclub-zfs-push-pull.git
synced 2024-12-24 13:40:13 +01:00
3bb83e8029
Turn backup-zfs into a two-step process: 1. Send snapshots to a server over SSH 2. Receive those snapshots over SSH Supports a disconnected backup environment where the backup storage host and the system being backed up don't have to be online at the same time. Use case: Send snapshots from my home server to my work server while I'm asleep, then read them over the LAN from my work server to my external drive while I'm at work.
246 lines
6.5 KiB
Bash
Executable File
246 lines
6.5 KiB
Bash
Executable File
#!/bin/bash
|
|
# backup-zfs: use zfs send/recv to push/pull snapshots
|
|
prog="$(basename "$0")"
|
|
|
|
usage() {
|
|
cat >&2 <<-EOF
|
|
usage: $prog [-hvq] [-t tag] [-k keep] [-d dateopts] src dest
|
|
use zfs send/recv to push/pull snapshots
|
|
|
|
src the source fs, specified as [host:]pool/path/to/fs
|
|
dest the destination fs parent, specified as [host:]pool/path/to/fs
|
|
(the final path component of src will be appended to dest)
|
|
|
|
-h help
|
|
-v verbose mode
|
|
-q quiet mode
|
|
-t tag tag to use for naming snapshots (default: backup-zfs)
|
|
-k keep number of snapshots to keep on src (default: 5)
|
|
-d dateopts options for date(1) - used to name the snapshots (default: +%F_%T)
|
|
-s store mode - output snaps from local fs to ssh server
|
|
-r read mode - read snaps from ssh server to local fs
|
|
EOF
|
|
exit $1
|
|
}
|
|
|
|
# log to syslog; if verbose or on a tty, also to stdout
|
|
# usage: log msg
|
|
log() {
|
|
logger -t "$prog" -- "$@"
|
|
if ! $quiet && [[ -t 1 ]] || $verbose ; then
|
|
echo "$@" >&2
|
|
fi
|
|
}
|
|
|
|
# exit with a code & message
|
|
# usage: die $exitcode msg
|
|
die() {
|
|
code="$1"
|
|
shift
|
|
if [[ $code -ne 0 ]] ; then
|
|
verbose=true log "FATAL: $@"
|
|
else
|
|
log "$@"
|
|
fi
|
|
exit $code
|
|
}
|
|
|
|
# run zfs(1) command either locally or via ssh
|
|
# usage: ZFS "$host" command args...
|
|
ZFS() {
|
|
host="$1"
|
|
shift
|
|
if [[ -n $host ]] ; then
|
|
log "remote ($host): zfs $@"
|
|
ssh "$host" sudo zfs "$@"
|
|
else
|
|
log "local: zfs $@"
|
|
sudo zfs "$@"
|
|
fi
|
|
}
|
|
|
|
###
|
|
### defaults
|
|
###
|
|
tag="$prog"
|
|
dateopts="+%F_%T"
|
|
keep=5
|
|
verbose=false
|
|
quiet=false
|
|
tossh=false
|
|
fromssh=false
|
|
|
|
###
|
|
### parse options
|
|
###
|
|
while getopts "hvqk:t:d:sr" opt ; do
|
|
case $opt in
|
|
h) usage 0 ;;
|
|
v)
|
|
verbose=true
|
|
send_opts="-v"
|
|
recv_opts="-v"
|
|
;;
|
|
q) quiet=true ;;
|
|
k) keep=$OPTARG ;;
|
|
t) tag=$OPTARG ;;
|
|
d) dateopts=$OPTARG ;;
|
|
s) tossh=true ;;
|
|
r) fromssh=true ;;
|
|
*) usage 1 ;;
|
|
esac
|
|
done
|
|
shift $((OPTIND-1))
|
|
date="$(date $dateopts)"
|
|
$tossh && $fromssh && die 1 "-s and -r are mutually exclusive"
|
|
|
|
###
|
|
### parse src & dest host/fs info
|
|
###
|
|
# fail if there's ever >1 colon
|
|
if [[ $1 =~ :.*: || $2 =~ :.*: ]] ; then
|
|
die 1 "invalid fsspec: '$1' or '$2'"
|
|
fi
|
|
|
|
# fail if src or dest isn't specified
|
|
if [[ -z $1 || -z $2 ]] ; then
|
|
usage 1
|
|
fi
|
|
src="$1"
|
|
dest="$2"
|
|
|
|
###
|
|
### ssh mode - output snaps from local fs to ssh or read snaps from ssh to local fs
|
|
if $tossh ; then
|
|
log "sending from local zfs filesystem to SSH server"
|
|
|
|
# make sure src exists
|
|
if [[ $src =~ : ]] ; then
|
|
die 1 "$src must be a local zfs filesystem"
|
|
elif [[ $(ZFS "" list -H -o name "$src" 2>/dev/null) != $src ]] ; then
|
|
die 1 "$src must be a local zfs filesystem"
|
|
fi
|
|
|
|
# split dest to components
|
|
if [[ $dest =~ : ]] ; then
|
|
desthost="${dest%:*}"
|
|
destpath="${dest#*:}"
|
|
else
|
|
die 1 "$dest must be ssh host:path"
|
|
fi
|
|
|
|
# get the last src component
|
|
srcbase="${src##*/}"
|
|
|
|
###
|
|
### create new snapshot on src
|
|
###
|
|
cur="$src@${tag}_$date"
|
|
ZFS "$srchost" snapshot -r "$cur" || die $? "zfs snapshot failed"
|
|
|
|
###
|
|
### get newest snapshot on dest - it must exist on src
|
|
###
|
|
#last="$(ZFS "$desthost" list -d 1 -t snapshot -H -S creation -o name $destfs/$srcbase | head -n1 | cut -f2 -d@)"
|
|
last="$(ssh "$desthost" cat "$destpath/.last")"
|
|
|
|
###
|
|
### send
|
|
###
|
|
# refuse to send without a valid .last maker
|
|
if [[ -z $last ]] ; then
|
|
die 1 "ssh path contains no .last file"
|
|
# special case: tagged snapshots exist on dest, but src has rotated through all
|
|
elif ! ZFS "$srchost" list $src@$last &>/dev/null ; then
|
|
die 1 "no incremental path from from $src to $dest"
|
|
# normal case: send incremental
|
|
else
|
|
log "sending incremental snapshot from $src to $dest (${last#${tag}_}..${cur#*@${tag}_})"
|
|
#ZFS "$srchost" send $send_opts -R -I "$last" "$cur" | ZFS "$desthost" receive $recv_opts -Fue "$destfs" || die $? "zfs incremental send failed"
|
|
ZFS "$srchost" send $send_opts -R -I "$last" "$cur" | ssh "$desthost" "cat > \"$destpath/${tag}_$date.zfssnap\"" || die $? "zfs incremental send failed"
|
|
fi
|
|
|
|
exit
|
|
elif $fromssh ; then
|
|
log "receving from SSH server to local zfs filesystem"
|
|
|
|
# make sure dest exists
|
|
if [[ $dest =~ : ]] ; then
|
|
die 1 "$dest must be a local zfs filesystem"
|
|
elif [[ $(ZFS "" list -H -o name "$dest" 2>/dev/null) != $dest ]] ; then
|
|
die 1 "$dest must be a local zfs filesystem"
|
|
fi
|
|
|
|
# split src into components
|
|
if [[ $src =~ : ]] ; then
|
|
srchost="${src%:*}"
|
|
srcpath="${src#*:}"
|
|
else
|
|
die 1 "$src must be ssh host:path"
|
|
fi
|
|
|
|
###
|
|
### receive
|
|
###
|
|
log "receiving incremental snapshot from $src to $dest"
|
|
#ZFS "$srchost" send $send_opts -R -I "$last" "$cur" | ZFS "$desthost" receive $recv_opts -Fue "$destfs" || die $? "zfs incremental send failed"
|
|
for file in $(ssh "$srchost" "find \"$srcpath\" -name \"*.zfssnap\"") ; do
|
|
ssh "$srchost" "cat \"$file\"" | ZFS "$desthost" receive $recv_opts -Fue "$dest" && ssh "$srchost" "rm \"$file\""
|
|
done
|
|
|
|
exit
|
|
fi
|
|
die 1 "neither -s nor -r was specified"
|
|
|
|
# discard anything before a colon to get the fs
|
|
srcfs="${src#*:}"
|
|
destfs="${dest#*:}"
|
|
|
|
# iff there is a colon, discard everything after it to get the host
|
|
[[ $src =~ : ]] && srchost="${src%:*}"
|
|
[[ $dest =~ : ]] && desthost="${dest%:*}"
|
|
|
|
# get the last src component
|
|
srcbase="${srcfs##*/}"
|
|
|
|
# ensure the destination fs exists before proceeding
|
|
if [[ $(ZFS "$desthost" list -H -o name "$destfs" 2>/dev/null) != $destfs ]] ; then
|
|
die 1 "destination fs '$destfs' doesn't exist"
|
|
fi
|
|
|
|
###
|
|
### create new snapshot on src
|
|
###
|
|
cur="$srcfs@${tag}_$date"
|
|
ZFS "$srchost" snapshot -r "$cur" || die $? "zfs snapshot failed"
|
|
|
|
###
|
|
### get newest snapshot on dest - it must exist on src
|
|
###
|
|
last="$(ZFS "$desthost" list -d 1 -t snapshot -H -S creation -o name $destfs/$srcbase | head -n1 | cut -f2 -d@)"
|
|
|
|
###
|
|
### send & receive
|
|
###
|
|
# 1st time: send full snapshot
|
|
if [[ -z $last ]] ; then
|
|
log "sending full recursive snapshot from $src to $dest"
|
|
ZFS "$srchost" send $send_opts -R "$cur" | ZFS "$desthost" receive $recv_opts -Fue "$destfs" || die $? "zfs full send failed"
|
|
# special case: tagged snapshots exist on dest, but src has rotated through all
|
|
elif ! ZFS "$srchost" list $srcfs@$last &>/dev/null ; then
|
|
die 1 "no incremental path from from $src to $dest"
|
|
# normal case: send incremental
|
|
else
|
|
log "sending incremental snapshot from $src to $dest (${last#${tag}_}..${cur#*@${tag}_})"
|
|
ZFS "$srchost" send $send_opts -R -I "$last" "$cur" | ZFS "$desthost" receive $recv_opts -Fue "$destfs" || die $? "zfs incremental send failed"
|
|
fi
|
|
|
|
###
|
|
### clean up old snapshots
|
|
###
|
|
for snap in $(ZFS "$srchost" list -d 1 -t snapshot -H -S creation -o name $srcfs \
|
|
| grep -F "@${tag}_" | cut -f2 -d@ | tail -n+$((keep+1)) ) ;
|
|
do
|
|
ZFS "$srchost" destroy -r $srcfs@$snap
|
|
done
|