#!/usr/bin/env bash # Snapshot ZFS filesystems and stream each snapshot to a remote ZFS host. set -Eeuo pipefail usage() { cat <<'EOF' Usage: zfs-move-over-ssh.sh [options] SOURCE_POOL REMOTE_HOST DESTINATION_ROOT Create one snapshot for every filesystem in SOURCE_POOL and send each one over SSH. Filesystems retain their relative paths below DESTINATION_ROOT. Arguments: SOURCE_POOL Source ZFS pool, for example: tank REMOTE_HOST SSH host, optionally user@host DESTINATION_ROOT Existing or new destination ZFS filesystem, for example: backup/data Options: -n, --dry-run Print the ZFS and SSH commands without running them. -i, --identity-file FILE SSH private key to use. -p, --ssh-port PORT SSH port to use. --destroy-source-snapshots Destroy each local migration snapshot after its successful receive. -h, --help Show this help. SSH must use key or agent authentication; password prompts are disabled. The remote SSH account must be able to run `sudo -n zfs receive` without a password prompt. This script does not destroy source filesystems or data. EOF } die() { printf 'Error: %s\n' "$*" >&2 exit 1 } DRY_RUN=false DESTROY_SOURCE_SNAPSHOTS=false SSH_OPTIONS=(-o BatchMode=yes) while (($#)); do case "$1" in -n|--dry-run) DRY_RUN=true ;; -i|--identity-file) (($# >= 2)) || die "$1 requires a file path" SSH_OPTIONS+=(-i "$2") shift ;; -p|--ssh-port) (($# >= 2)) || die "$1 requires a port" SSH_OPTIONS+=(-p "$2") shift ;; --destroy-source-snapshots) DESTROY_SOURCE_SNAPSHOTS=true ;; -h|--help) usage exit 0 ;; --) shift break ;; -*) die "unknown option: $1" ;; *) break ;; esac shift done (($# == 3)) || { usage >&2 exit 2 } SOURCE_POOL="$1" REMOTE_HOST="$2" DESTINATION_ROOT="${3%/}" SNAPSHOT_NAME="move-$(date +%Y%m%d-%H%M%S)" [[ "$REMOTE_HOST" != *@ ]] || die "REMOTE_HOST is missing a hostname; use user@server-name or server-name" command -v zfs >/dev/null || die "zfs command was not found" command -v zpool >/dev/null || die "zpool command was not found" command -v ssh >/dev/null || die "ssh command was not found" zpool list -H -o name "$SOURCE_POOL" >/dev/null || die "source pool does not exist: $SOURCE_POOL" if ! $DRY_RUN; then ssh "${SSH_OPTIONS[@]}" "$REMOTE_HOST" true \ || die "cannot authenticate to $REMOTE_HOST with SSH keys or an SSH agent" ssh "${SSH_OPTIONS[@]}" "$REMOTE_HOST" 'sudo -n zfs list -H -o name -t filesystem >/dev/null' \ || die "$REMOTE_HOST accepts SSH, but cannot run 'sudo -n zfs'; configure passwordless sudo for zfs" fi map_destination() { local filesystem="$1" local relative_path="${filesystem#"$SOURCE_POOL"}" printf '%s%s\n' "$DESTINATION_ROOT" "$relative_path" } run() { if $DRY_RUN; then printf '+' >&2 printf ' %q' "$@" >&2 printf '\n' >&2 else "$@" fi } mapfile -t FILESYSTEMS < <(zfs list -H -o name -t filesystem -r "$SOURCE_POOL") ((${#FILESYSTEMS[@]} > 0)) || die "no filesystems found in $SOURCE_POOL" printf 'Migration snapshot: %s\n' "$SNAPSHOT_NAME" for filesystem in "${FILESYSTEMS[@]}"; do destination="$(map_destination "$filesystem")" snapshot="${filesystem}@${SNAPSHOT_NAME}" printf 'Transferring %s to %s:%s\n' "$filesystem" "$REMOTE_HOST" "$destination" run zfs snapshot "$snapshot" if $DRY_RUN; then printf '+ zfs send -p %q | ssh' "$snapshot" >&2 printf ' %q' "${SSH_OPTIONS[@]}" "$REMOTE_HOST" "sudo -n zfs receive -uF '$destination'" >&2 printf '\n' >&2 else zfs send -p "$snapshot" | ssh "${SSH_OPTIONS[@]}" "$REMOTE_HOST" "sudo -n zfs receive -uF '$destination'" fi if $DESTROY_SOURCE_SNAPSHOTS; then run zfs destroy "$snapshot" fi done printf 'Completed. Local source filesystems were not destroyed.\n'