diff --git a/bashclub-zfs b/bashclub-zfs new file mode 100644 index 0000000..78ad736 --- /dev/null +++ b/bashclub-zfs @@ -0,0 +1,278 @@ +#!/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) + -p ssh port + -h help + -v verbose mode + -C SSH Compression + -R ZFS Re-Init + -I Send intermediate Snapshots + -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 + -g gpg-id gpg recipient key id (store mode only) + 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 $sshcompress -p $port "$host" zfs "$@" + else + log "local: zfs $@" + zfs "$@" + fi +} + +### +### defaults +### +tag="$prog" +dateopts="+%F_%T" +keep=5 +verbose=false +quiet=false +tossh=false +fromssh=false +port=22 +sshcompress="" +reinit="" +intermediate="-i " +### +### parse options +### +while getopts "hvqk:p:t:d:srCIRg:" opt ; do + case $opt in + h) usage 0 ;; + v) + verbose=true + send_opts="-v" + recv_opts="-v" + ;; + q) quiet=true ;; + k) keep=$OPTARG ;; + p) port=$OPTARG ;; + t) tag=$OPTARG ;; + d) dateopts=$OPTARG ;; + s) tossh=true ;; + r) fromssh=true ;; + C) sshcompress="-C " ;; + R) reinit="-R " ;; + I) intermediate="-I " ;; + g) gpgid="$OPTARG" ;; + *) usage 1 ;; + esac +done +shift $((OPTIND-1)) +date="$(date $dateopts)" +$tossh && $fromssh && die 1 "-s and -r are mutually exclusive" +if ! $tossh && [[ -n $gpgid ]] ; then + die 1 "-g can only be used with -s" +fi + +### +### 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 + ### + snap="${tag}_$date" + cur="$src@$snap" + 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" zfslast)" + + ### + ### 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 $([[ -n $gpgid ]] && echo "encrypted ")incremental snapshot from $src to $dest (${last#${tag}_}..${cur#*@${tag}_})" + #ZFS "$srchost" send $send_opts -R $intermediate "$last" "$cur" | ZFS "$desthost" receive $recv_opts -Fue "$destfs" || die $? "zfs incremental send failed" + if [[ -n $gpgid ]] ; then + ZFS "$srchost" send $send_opts -R $intermediate "$last" "$cur" \ + | gpg --trust-model always --encrypt --recipient "$gpgid" \ + | ssh "$desthost" zfswrite "${tag}_$date.zfssnap.gpg" \ + || die $? "zfs incremental send failed" + ssh "$desthost" zfslast "$snap" + else + ZFS "$srchost" send $send_opts -R $intermediate "$last" "$cur" \ + | ssh "$desthost" zfswrite "${tag}_$date.zfssnap" \ + || die $? "zfs incremental send failed" + ssh "$desthost" zfslast "$snap" + fi + 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 $intermediate "$last" "$cur" | ZFS "$desthost" receive $recv_opts -Fue "$destfs" || die $? "zfs incremental send failed" + for file in $(ssh "$srchost" zfsfind | sort) ; do + log "receiving $file from $srchost" + if [[ $file =~ \.gpg$ ]] ; then + ssh "$srchost" zfsget "$file" | gpg | ZFS "$desthost" receive $recv_opts -Fue "$dest" \ + && ssh "$srchost" rm "$file" + else + ssh "$srchost" zfsget "$file" | ZFS "$desthost" receive $recv_opts -Fue "$dest" \ + && ssh "$srchost" rm "$file" + fi + done + + exit +fi + +# 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 $reinit "$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 $intermediate "$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