From 2ce4afdfdd5b1266a600f998520f2535ce489cd2 Mon Sep 17 00:00:00 2001 From: Tero Date: Tue, 1 Sep 2026 22:28:25 +0300 Subject: [PATCH] first commit --- README.md | 122 +++++++++++++++++++++++++++++++++++++++ zfs-move-over-ssh.sh | 134 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 README.md create mode 100644 zfs-move-over-ssh.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cc893a --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# ZFS Move Over SSH + +`zfs-move-over-ssh.sh` snapshots every ZFS filesystem at and beneath a source +dataset, then sends each snapshot to a remote ZFS server over SSH. + +For a source tree such as: + +```text +tank/data +tank/data/documents +tank/data/media +``` + +and destination root `backup/data`, the script sends the filesystems to: + +```text +backup/data +backup/data/documents +backup/data/media +``` + +Each local snapshot is named `move-YYYYMMDD-HHMMSS`. + +## Requirements + +- Run the script as an account that can use `zfs` on the source server. +- The source host needs Bash, ZFS utilities, and SSH. +- The destination host needs ZFS utilities and the destination pool must exist. +- The SSH account on the destination must be able to execute `sudo -n zfs` + without a password prompt. +- SSH connectivity and host-key verification must already work. + +The script is intended for one-shot full snapshot transfers. It does not use +incremental sends or resume tokens. + +## Usage + +```bash +bash zfs-move-over-ssh.sh [options] SOURCE_ROOT REMOTE_HOST DESTINATION_ROOT +``` + +Example: + +```bash +bash zfs-move-over-ssh.sh tank/data root@new-server backup/data +``` + +Use a non-standard SSH key or port when needed: + +```bash +bash zfs-move-over-ssh.sh \ + --identity-file ~/.ssh/zfs-migration \ + --ssh-port 2222 \ + tank/data admin@new-server backup/data +``` + +Options: + +| Option | Description | +| --- | --- | +| `-n`, `--dry-run` | Print planned snapshot, send, and receive commands without running them. | +| `-i`, `--identity-file FILE` | Use the specified SSH private key. | +| `-p`, `--ssh-port PORT` | Connect to the specified SSH port. | +| `--destroy-source-snapshots` | Remove each local migration snapshot only after its receive succeeds. | +| `-h`, `--help` | Show command help. | + +## Recommended Procedure + +1. Confirm the source datasets: + + ```bash + zfs list -r tank/data + ``` + +2. Confirm remote privileged ZFS access: + + ```bash + ssh admin@new-server 'sudo -n zfs list' + ``` + +3. Preview the operation. Review every destination name in the output: + + ```bash + bash zfs-move-over-ssh.sh --dry-run tank/data admin@new-server backup/data + ``` + +4. Run the migration: + + ```bash + bash zfs-move-over-ssh.sh tank/data admin@new-server backup/data + ``` + +5. Verify the received datasets and data before retiring the source: + + ```bash + ssh admin@new-server 'sudo zfs list -r backup/data' + ``` + +## Safety and Recovery + +- The script does **not** destroy source filesystems or source data. +- By default, the generated local snapshots remain on the source. This allows a + failed run to be inspected or sent again. Remove them manually only after + verification, or use `--destroy-source-snapshots` once the workflow is trusted. +- The remote command is `zfs receive -uF`. `-u` leaves received filesystems + unmounted. `-F` can roll back the destination filesystem and destroy newer + snapshots or changes that conflict with the incoming snapshot. Do not point + the script at a destination containing data you need unless you have reviewed + the dry-run output and have a current backup. +- The script processes one filesystem at a time. If it stops partway through, + completed destination filesystems remain received. Re-running with the same + source can create another timestamped snapshot; inspect both source and + destination state before retrying. +- A successful transfer is not a destructive move. Verify applications and + data on the destination, then perform any source cleanup separately. + +## Notes + +The destination root itself does not have to exist before the transfer, but +its parent pool or filesystem must exist and the remote account must have ZFS +permission to create the target datasets. Dataset properties are included in +the stream through `zfs send -p`. \ No newline at end of file diff --git a/zfs-move-over-ssh.sh b/zfs-move-over-ssh.sh new file mode 100644 index 0000000..5c15436 --- /dev/null +++ b/zfs-move-over-ssh.sh @@ -0,0 +1,134 @@ +#!/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_ROOT REMOTE_HOST DESTINATION_ROOT + +Create one snapshot for every filesystem under SOURCE_ROOT and send each one +over SSH. Filesystems retain their relative paths below DESTINATION_ROOT. + +Arguments: + SOURCE_ROOT Source ZFS filesystem, for example: tank/data + 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. + +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=() + +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_ROOT="$1" +REMOTE_HOST="$2" +DESTINATION_ROOT="${3%/}" +SNAPSHOT_NAME="move-$(date +%Y%m%d-%H%M%S)" + +command -v zfs >/dev/null || die "zfs command was not found" +command -v ssh >/dev/null || die "ssh command was not found" +zfs list -H -o name -t filesystem "$SOURCE_ROOT" >/dev/null || die "source filesystem does not exist: $SOURCE_ROOT" + +if ! $DRY_RUN; then + ssh "${SSH_OPTIONS[@]}" "$REMOTE_HOST" 'sudo -n zfs list -H -o name -t filesystem >/dev/null' \ + || die "cannot run 'sudo -n zfs' on $REMOTE_HOST" +fi + +map_destination() { + local filesystem="$1" + local relative_path="${filesystem#"$SOURCE_ROOT"}" + 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_ROOT") +((${#FILESYSTEMS[@]} > 0)) || die "no filesystems found below $SOURCE_ROOT" + +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' \ No newline at end of file