diff --git a/.github/workflows/status-checks.yaml b/.github/workflows/status-checks.yaml index c7bdf40..4c7c3b3 100644 --- a/.github/workflows/status-checks.yaml +++ b/.github/workflows/status-checks.yaml @@ -54,7 +54,7 @@ jobs: level: info filter_mode: nofilter fail_on_error: true - shfmt_flags: '-ln bash -ci -sr -i 2' + shfmt_flags: '-ci -sr -i 2' shellcheck: name: runner / shellcheck @@ -76,6 +76,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: bash ./test.sh + - run: cd test && ./test.sh env: TMPDIR: ${{ runner.temp }} diff --git a/README.md b/README.md index acc5b7e..0fff434 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # zfs-replicate -A Bash script to automate ZFS Replication. +A POSIX shell script to automate ZFS Replication. ## Features +- The script follows strict POSIX standards and should be usable on any host with a POSIX compliant shell. - Source pools and datasets are always authoritative, the script will always defer to the source. - Supports push and pull replication with local and remote datasets. - Supports multiple pool/dataset pairs to replicate. @@ -12,8 +13,8 @@ A Bash script to automate ZFS Replication. - Includes a well documented `config.sh` file that may be used as configuration or as reference for environment variables passed to the script. - May be run on any schedule using cron or similar mechanism. -- May be sourced and/or leveraged by/in other Bash scripts. -- Test coverage of core functions via mocks in the test.sh script. +- Fully source compliant and may be used by other scripts. +- Test coverage of core functions via mocks in the test/test.sh script. - Includes a `--status` option for XigmaNAS that can be used to email the last log output at your preferred schedule. Simply add it as a custom script in the email settings under "System > Advanced > Email Reports" @@ -54,18 +55,18 @@ is not met. ```text Usage: ./zfs-replicate.sh [options] [config] -Bash script to automate ZFS Replication +POSIX shell script to automate ZFS Replication Options: - -c, --config bash configuration file + -c, --config configuration file -s, --status print most recent log messages to stdout -h, --help show this message ``` ### Config File and Environment Variable Reference -```bash -#!/usr/bin/env bash +```sh +#!/usr/bin/env sh ## zfs-replicate configuration file # shellcheck disable=SC2034 @@ -210,11 +211,6 @@ Options: ## #FIND=$(which find) -## Path to the system "zfs" binary. The default uses the first "zfs" -## executable found in $PATH. -## -#ZFS=$(which zfs) - ## Path to the system "ssh" binary. You may also include custom arguments ## to SSH here or in the "DEST_PIPE_WITH_HOST" option above. ## Example: SSH="ssh -l root" to login as root to target host. @@ -222,6 +218,11 @@ Options: ## #SSH=$(which ssh) +## Path to the system "zfs" binary. The default uses the first "zfs" +## executable found in $PATH. +## +#ZFS=$(which zfs) + ## Set the pipe to the destination pool. But DO NOT INCLUDE the pipe (|) ## character in this setting. Filesystem names from the source will be ## sent to the destination. For increased transfer speed to remote hosts you diff --git a/config.sample.sh b/config.sample.sh index afadb6d..da579d7 100644 --- a/config.sample.sh +++ b/config.sample.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh ## zfs-replicate configuration file # shellcheck disable=SC2034 @@ -143,11 +143,6 @@ ## #FIND=$(which find) -## Path to the system "zfs" binary. The default uses the first "zfs" -## executable found in $PATH. -## -#ZFS=$(which zfs) - ## Path to the system "ssh" binary. You may also include custom arguments ## to SSH here or in the "DEST_PIPE_WITH_HOST" option above. ## Example: SSH="ssh -l root" to login as root to target host. @@ -155,6 +150,11 @@ ## #SSH=$(which ssh) +## Path to the system "zfs" binary. The default uses the first "zfs" +## executable found in $PATH. +## +#ZFS=$(which zfs) + ## Set the pipe to the destination pool. But DO NOT INCLUDE the pipe (|) ## character in this setting. Filesystem names from the source will be ## sent to the destination. For increased transfer speed to remote hosts you diff --git a/test.sh b/test.sh deleted file mode 100755 index a479daa..0000000 --- a/test.sh +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2317 -## test.sh contains zfs-replicate test cases -set -e -o pipefail - -_fakeLogger() { - return 0 -} - -_fakeFind() { - return 0 -} - -_fakeSSH() { - return 0 -} - -_fakeZFS() { - local cmd=$1 - local args=("$@") - local target="${args[-1]}" - - case "$cmd" in - list) - printf "%s@autorep-test1\n" "${target}" - printf "%s@autorep-test2\n" "${target}" - printf "%s@autorep-test3\n" "${target}" - ;; - esac - return 0 -} - -_fakeCheck() { - return 0 -} - -_fail() { - local line=$1 match=$2 - printf "test failed: '%s' != '*%s*'\n" "$line" "$match" - exit 1 -} - -_testSimpleSetNoConfig() { - ## define test conditions - export FIND=_fakeFind - export ZFS=_fakeZFS - export DEST_PIPE_WITHOUT_HOST="echo receive -vFd" - export SYSLOG=0 - export REPLICATE_SETS="srcPool/srcFS:dstPool/dstFS" - - ## set output - local configOut snapOut exitOut line idx match - - ## source script and run test - # shellcheck source=/dev/null - . zfs-replicate.sh || true - - ## test loadConfig - printf "_testSimpleSetNoConfig/loadConfig\n" - mapfile -t configOut < <(loadConfig) - line="${configOut[0]}" - printf "%d %s\n" 0 "$line" - match="loading configuration from defaults" - [[ ! "$line" == *"$match"* ]] && _fail "$line" "$match" - - ## init test environment - loadConfig > /dev/null 2>&1 - - ## test snapCreate - printf "_testSimpleSetNoConfig/snapCreate\n" - mapfile -t snapOut < <(snapCreate) - for idx in "${!snapOut[@]}"; do - line="${snapOut[idx]}" - printf "%d %s\n" "$idx" "$line" - case $idx in - 0) - match="creating lockfile ${TMPDIR}/.replicate.snapshot.lock" - [[ ! "$line" == *"$match"* ]] && _fail "$line" "$match" - ;; - 1) - match="cmd=_fakeZFS list -H -o name srcPool/srcFS" - [[ ! "$line" == *"$match"* ]] && _fail "$line" "$match" - ;; - 5) - match="cmd=_fakeZFS list -H -o name dstPool/dstFS" - [[ ! "$line" == *"$match"* ]] && _fail "$line" "$match" - ;; - 10) - match="cmd=_fakeZFS destroy srcPool/srcFS@autorep-test1" - [[ ! "$line" == *"$match"* ]] && _fail "$line" "$match" - ;; - 12) - match="cmd=_fakeZFS destroy srcPool/srcFS@autorep-test2" - [[ ! "$line" == *"$match"* ]] && _fail "$line" "$match" - ;; - 13) - match="cmd=_fakeZFS snapshot srcPool/srcFS@autorep-" - [[ ! "$line" == *"$match"* ]] && _fail "$line" "$match" - ;; - 15) - match="cmd=_fakeZFS send -Rs -I srcPool/srcFS@autorep-test3 srcPool/srcFS@autorep-${TAG} | " - match+="echo receive -vFd dstPool/dstFS" - [[ ! "$line" == *"$match"* ]] && _fail "$line" "$match" - ;; - 16) - match="receive -vFd dstPool/dstFS" - [[ ! "$line" == *"$match"* ]] && _fail "$line" "$match" - ;; - 17) - match="deleting lockfile ${TMPDIR}/.replicate.send.lock" - [[ ! "$line" == *"$match"* ]] && _fail "$line" "$match" - ;; - 18) - match="deleting lockfile ${TMPDIR}/.replicate.snapshot.lock" - [[ ! "$line" == *"$match"* ]] && _fail "$line" "$match" - ;; - esac - done - - ## test exitClean - printf "_testSimpleSetNoConfig/exitClean\n" - mapfile -t exitOut < <(exitClean 0 "test message") - for idx in "${!exitOut[@]}"; do - line="${exitOut[idx]}" - printf "%d %s\n" "$idx" "$line" - case $idx in - 10) - match="success total sets 0 skipped 0: test message" - [[ ! "$line" == *"$match"* ]] && _fail "$line" "$match" - ;; - esac - done - - ## yay, tests completed! - return 0 -} - -_testSimpleSetNoConfig diff --git a/test/find.sh b/test/find.sh new file mode 100755 index 0000000..d10f69d --- /dev/null +++ b/test/find.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +## test/zfs.sh +set -eu + +# check pipefail in a subshell and set if supported +# shellcheck disable=SC3040 +(set -o pipefail 2> /dev/null) && set -o pipefail + +_fakeFIND() { + printf "find %s\n" "$*" + return 0 +} + +_fakeZFS "$@" diff --git a/test/ssh.sh b/test/ssh.sh new file mode 100755 index 0000000..fe22902 --- /dev/null +++ b/test/ssh.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env sh +## test/zfs.sh +set -eu + +# check pipefail in a subshell and set if supported +# shellcheck disable=SC3040 +(set -o pipefail 2> /dev/null) && set -o pipefail + +_fakeSSH() { + host=$1 + shift + cmd=$1 + shift + case "$cmd" in + *zfs*) + ./zfs.sh "$@" + ;; + *) + printf "ssh $host $cmd %s\n" "$*" + ;; + esac + return 0 +} + +_fakeSSH "$@" diff --git a/test/test.sh b/test/test.sh new file mode 100755 index 0000000..e6510ae --- /dev/null +++ b/test/test.sh @@ -0,0 +1,390 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2030,SC2031,SC2034 +## ^^ tests are intentionally run in subshells +## variables that appear unused here are used by main script + +## test.sh contains zfs-replicate test cases +set -eu ## fail on errors and undefined variables + +# check pipefail in a subshell and set if supported +# shellcheck disable=SC3040 +(set -o pipefail 2> /dev/null) && set -o pipefail + +## set self identification values +SCRIPT_PATH="${0%/*}" + +## check line against match and exit on failure +_fail() { + line=$1 + match=$2 + ## hack to match blank lines + if [ "$match" = "null" ]; then + if [ -n "$line" ]; then + printf "FAILED '%s' != ''\n" "$line" && exit 1 + fi + return 0 + fi + case "$line" in + *"$match"*) ;; + *) printf "FAILED '%s' != '*%s*'\n" "$line" "$match" && exit 1 ;; + esac + return 0 +} + +_testZFSReplicate() { + ## wrapper for easy matching + ECHO="echo" + ## disable syslog for tests + SYSLOG=0 + + ## test loadConfig without error + ( + FIND="fakeFIND" + ZFS="fakeZFS" + SSH="fakeSSH" + REPLICATE_SETS="fakeSource:fakeDest" + # shellcheck source=/dev/null + . ../zfs-replicate.sh + printf "_testZFSReplicate/loadConfigWithoutError\n" + line=$(loadConfig) + _fail "$line" "null" ## we expect no output here + ) + + ## test loadConfig with missing values + ( + FIND="fakeFIND" + ZFS="fakeZFS" + SSH="fakeSSH" + # shellcheck source=/dev/null + . ../zfs-replicate.sh + printf "_testZFSReplicate/loadConfigWithError\n" + ! line=$(loadConfig) && true ## prevent tests from exiting + _fail "$line" "missing required setting REPLICATE_SETS" + ) + + ## test config override of script defaults + ( + ## likely default values at script load time + FIND="/usr/bin/find" + ZFS="/sbin/zfs" + SSH="/usr/sbin/ssh" + REPLICATE_SETS="fakeSource:fakeDest" + # shellcheck source=/dev/null + . ../zfs-replicate.sh + printf "_testZFSReplicate/loadConfigOverrideDefaults\n" + _fail "/usr/sbin/ssh %HOST% /sbin/zfs receive -vFd" "$DEST_PIPE_WITH_HOST" + _fail "/sbin/zfs receive -vFd" "$DEST_PIPE_WITHOUT_HOST" + ## generate config + config="$(mktemp)" + printf "ZFS=\"myZFS\"\n" >> "$config" + ## set SSH via environment + SSH="mySSH" + loadConfig "$config" && rm -f "$config" + ## values should match config and environment + _fail "mySSH %HOST% myZFS receive -vFd" "$DEST_PIPE_WITH_HOST" + _fail "myZFS receive -vFd" "$DEST_PIPE_WITHOUT_HOST" + ) + + ## test snapCreate with different set combinations + ( + ## configure test parameters + FIND="${SCRIPT_PATH}/find.sh" + ZFS="${SCRIPT_PATH}/zfs.sh" + SSH="${SCRIPT_PATH}/ssh.sh" + HOST_CHECK="${ECHO} %HOST%" + REPLICATE_SETS="srcPool0/srcFS0:dstPool0/dstFS0" + REPLICATE_SETS="${REPLICATE_SETS} srcPool1/srcFS1/subFS1:dstPool1/dstFS1@dstHost1" + REPLICATE_SETS="${REPLICATE_SETS} srcPool2/srcFS2:dstPool2/dstFS2@dstHost2" + REPLICATE_SETS="${REPLICATE_SETS} srcPool3/srcFS3@srcHost3:dstPool3/dstFS3" + REPLICATE_SETS="${REPLICATE_SETS} srcPool4/srcFS4@srcHost4:dstPool4/dstFS4@dstHost4" + # shellcheck source=/dev/null + . ../zfs-replicate.sh && loadConfig + printf "_testZFSReplicate/snapCreateWithoutErrors\n" + idx=0 + snapCreate | while IFS= read -r line; do + match="" + printf "%d %s\n" "$idx" "$line" + case $idx in + 0) + match="creating lockfile ${TMPDIR}/.replicate.snapshot.lock" + ;; + 1) + match="cmd=${ZFS} list -H -o name srcPool0/srcFS0" + ;; + 3) + match="cmd=${ZFS} list -H -o name dstPool0/dstFS0" + ;; + 6) + match="cmd=${ZFS} destroy srcPool0/srcFS0@autorep-test1" + ;; + 7) + match="cmd=${ZFS} snapshot srcPool0/srcFS0@autorep-" + ;; + 8) + match="creating lockfile ${TMPDIR}/.replicate.send.lock" + ;; + 9) + match="cmd=${ZFS} send -Rs -I srcPool0/srcFS0@autorep-test3 srcPool0/srcFS0@autorep-${TAG} |" + match="$match ${DEST_PIPE_WITHOUT_HOST} dstPool0/dstFS0" + ;; + 10) + match="receive -vFd dstPool0/dstFS0" + ;; + 11) + match="deleting lockfile ${TMPDIR}/.replicate.send.lock" + ;; + 12) + match="cmd=${ECHO} dstHost1" + ;; + 13) + match="cmd=${ZFS} list -H -o name srcPool1/srcFS1/subFS1" + ;; + 15) + match="cmd=${SSH} dstHost1 ${ZFS} list -H -o name dstPool1/dstFS1" + ;; + 18) + match="cmd=${ZFS} destroy srcPool1/srcFS1/subFS1@autorep-test1" + ;; + 19) + match="cmd=${ZFS} snapshot srcPool1/srcFS1/subFS1@autorep-${TAG}" + ;; + 20) + match="creating lockfile ${TMPDIR}/.replicate.send.lock" + ;; + 21) + match="cmd=${ZFS} send -Rs -I srcPool1/srcFS1/subFS1@autorep-test3 srcPool1/srcFS1/subFS1@autorep-${TAG} |" + match="$match ${SSH} dstHost1 ${ZFS} receive -vFd dstPool1/dstFS1" + ;; + 23) + match="deleting lockfile ${TMPDIR}/.replicate.send.lock" + ;; + 24) + match="cmd=${ECHO} dstHost2" + ;; + 25) + match="cmd=${ZFS} list -H -o name srcPool2/srcFS2" + ;; + 27) + match="cmd=${SSH} dstHost2 ${ZFS} list -H -o name dstPool2/dstFS2" + ;; + 30) + match="cmd=${ZFS} destroy srcPool2/srcFS2@autorep-test1" + ;; + 31) + match="cmd=${ZFS} snapshot srcPool2/srcFS2@autorep-${TAG}" + ;; + 32) + match="creating lockfile ${TMPDIR}/.replicate.send.lock" + ;; + 33) + match="cmd=${ZFS} send -Rs -I srcPool2/srcFS2@autorep-test3 srcPool2/srcFS2@autorep-${TAG} |" + match="$match ${SSH} dstHost2 ${ZFS} receive -vFd dstPool2/dstFS2" + ;; + 35) + match="deleting lockfile ${TMPDIR}/.replicate.send.lock" + ;; + 36) + match="cmd=${ECHO} srcHost3" + ;; + 37) + match=" cmd=${SSH} srcHost3 ${ZFS} list -H -o name srcPool3/srcFS3" + ;; + 39) + match="cmd=${ZFS} list -H -o name dstPool3/dstFS3" + ;; + 42) + match="cmd=${SSH} srcHost3 ${ZFS} destroy srcPool3/srcFS3@autorep-test1" + ;; + 43) + match="cmd=${SSH} srcHost3 ${ZFS} snapshot srcPool3/srcFS3@autorep-${TAG}" + ;; + 44) + match="creating lockfile ${TMPDIR}/.replicate.send.lock" + ;; + 45) + match="cmd=${SSH} srcHost3 ${ZFS} send -Rs -I srcPool3/srcFS3@autorep-test3 srcPool3/srcFS3@autorep-${TAG} |" + match="$match ${ZFS} receive -vFd dstPool3/dstFS3" + ;; + 47) + match="deleting lockfile ${TMPDIR}/.replicate.send.lock" + ;; + 48) + match="cmd=${ECHO} srcHost4" + ;; + 49) + match="cmd=${ECHO} dstHost4" + ;; + 50) + match="cmd=${SSH} srcHost4 ${ZFS} list -H -o name srcPool4/srcFS4" + ;; + 52) + match="cmd=${SSH} dstHost4 ${ZFS} list -H -o name dstPool4/dstFS4" + ;; + 55) + match="cmd=${SSH} srcHost4 ${ZFS} destroy srcPool4/srcFS4@autorep-test1" + ;; + 56) + match="cmd=${SSH} srcHost4 ${ZFS} snapshot srcPool4/srcFS4@autorep-${TAG}" + ;; + 57) + match="creating lockfile ${TMPDIR}/.replicate.send.lock" + ;; + 58) + match="cmd=${SSH} srcHost4 ${ZFS} send -Rs -I srcPool4/srcFS4@autorep-test3 srcPool4/srcFS4@autorep-${TAG} |" + match="$match ${SSH} dstHost4 ${ZFS} receive -vFd dstPool4/dstFS4" + ;; + 60) + match="deleting lockfile ${TMPDIR}/.replicate.send.lock" + ;; + 61) + match="deleting lockfile ${TMPDIR}/.replicate.snapshot.lock" + ;; + esac + _fail "$line" "$match" + idx=$((idx + 1)) + done + ) + + ## test snapCreate with host check errors + ( + ## configure test parameters + FIND="${SCRIPT_PATH}/find.sh" + ZFS="${SCRIPT_PATH}/zfs.sh" + SSH="${SCRIPT_PATH}/ssh.sh" + HOST_CHECK="false" + REPLICATE_SETS="srcPool0/srcFS0:dstPool0/dstFS0" + REPLICATE_SETS="${REPLICATE_SETS} srcPool1/srcFS1/subFS1:dstPool1/dstFS1@dstHost1" + REPLICATE_SETS="${REPLICATE_SETS} srcPool2/srcFS2:dstPool2/dstFS2@dstHost2" + REPLICATE_SETS="${REPLICATE_SETS} srcPool3/srcFS3@srcHost3:dstPool3/dstFS3" + REPLICATE_SETS="${REPLICATE_SETS} srcPool4/srcFS4@srcHost4:dstPool4/dstFS4@dstHost4" + # shellcheck source=/dev/null + . ../zfs-replicate.sh && loadConfig + printf "_testZFSReplicate/snapCreateWithHostCheckErrors\n" + idx=0 + snapCreate | while IFS= read -r line; do + match="" + printf "%d %s\n" "$idx" "$line" + case $idx in + 0) + match="creating lockfile ${TMPDIR}/.replicate.snapshot.lock" + ;; + 13) + match="source or destination host check failed" + ;; + 15) + match="source or destination host check failed" + ;; + 17) + match="source or destination host check failed" + ;; + 19) + match="source or destination host check failed" + ;; + 20) + match="deleting lockfile ${TMPDIR}/.replicate.snapshot.lock" + ;; + esac + _fail "$line" "$match" + idx=$((idx + 1)) + done + ) + + ## test snapCreate with dataset check errors + ( + ## configure test parameters + FIND="${SCRIPT_PATH}/find.sh" + ZFS="${SCRIPT_PATH}/zfs.sh" + SSH="${SCRIPT_PATH}/ssh.sh" + HOST_CHECK="${ECHO} %HOST%" + REPLICATE_SETS="failPool0/srcFS0:dstPool0/dstFS0" + REPLICATE_SETS="${REPLICATE_SETS} srcPool1/srcFS1:failPool1/dstFS1@dstHost1" + REPLICATE_SETS="${REPLICATE_SETS} failPool2/srcFS2@srcHost2:dstPool2/dstFS2" + # shellcheck source=/dev/null + . ../zfs-replicate.sh && loadConfig + printf "_testZFSReplicate/snapCreateWithDatasetCheckErrors\n" + idx=0 + snapCreate | while IFS= read -r line; do + match="" + printf "%d %s\n" "$idx" "$line" + case $idx in + 0) + match="creating lockfile ${TMPDIR}/.replicate.snapshot.lock" + ;; + 1) + match="cmd=${ZFS} list -H -o name failPool0/srcFS0" + ;; + 2) + match="dataset does not exist" + ;; + 3) + match="source or destination dataset check failed" + ;; + 5) + match="cmd=${ZFS} list -H -o name srcPool1/srcFS1" + ;; + 6) + match="srcPool1/srcFS1" + ;; + 7) + match="cmd=${SSH} dstHost1 ${ZFS} list -H -o name failPool1/dstFS1" + ;; + 8) + match="dataset does not exist" + ;; + 9) + match="source or destination dataset check failed" + ;; + 11) + match="cmd=${SSH} srcHost2 ${ZFS} list -H -o name failPool2/srcFS2" + ;; + 12) + match="dataset does not exist" + ;; + 13) + match="source or destination dataset check failed" + ;; + 14) + match="deleting lockfile ${TMPDIR}/.replicate.snapshot.lock" + ;; + esac + _fail "$line" "$match" + idx=$((idx + 1)) + done + ) + + ## test exitClean code=0 and extra message + ( + FIND="fakeFIND" + ZFS="fakeZFS" + SSH="fakeSSH" + REPLICATE_SETS="fakeSource:fakeDest" + ## source script functions + # shellcheck source=/dev/null + . ../zfs-replicate.sh && loadConfig + printf "_testZFSReplicate/exitCleanSuccess\n" + line=$(exitClean 0 "test message") + match="success total sets 0 skipped 0: test message" ## counts are modified in snapCreate + _fail "$line" "$match" + ) + + ## test exitClean code=99 with error message + ( + FIND="fakeFIND" + ZFS="fakeZFS" + SSH="fakeSSH" + REPLICATE_SETS="fakeSource:fakeDest" + ## source script functions + # shellcheck source=/dev/null + . ../zfs-replicate.sh && loadConfig + printf "_testZFSReplicate/exitCleanError\n" + ! line=$(exitClean 99 "error message") && true ## prevent tests from exiting + match="operation exited unexpectedly: code=99 msg=error message" + _fail "$line" "$match" + ) + + ## yay, tests completed! + printf "Tests Complete: No Error!\n" + return 0 +} + +_testZFSReplicate diff --git a/test/zfs.sh b/test/zfs.sh new file mode 100755 index 0000000..b2c6c4b --- /dev/null +++ b/test/zfs.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env sh +## test/zfs.sh +set -eu + +# check pipefail in a subshell and set if supported +# shellcheck disable=SC3040 +(set -o pipefail 2> /dev/null) && set -o pipefail + +_fakeZFS() { + cmd=$1 + shift + showSnaps=0 + + ## check arguments + for arg in "$@"; do + case "$arg" in + -H) + ## nothing for now + ;; + -o) + ## nothing for now + ;; + -t) + ## assume snapshots for tests + showSnaps=1 + ;; + esac + ## cheap way to get the last arg + target=$arg + done + + case "$cmd" in + list) + if [ $showSnaps -eq 1 ]; then + printf "%s@autorep-test1\n" "$target" + printf "%s@autorep-test2\n" "$target" + printf "%s@autorep-test3\n" "$target" + return 0 + fi + ## allow selective failures in tests + if [ "$(expr "$target" : 'fail')" -gt 0 ]; then + printf "cannot open '%s': dataset does not exist\n" "$target" + return 1 + fi + ## just print target + printf "%s\n" "$target" + ;; + receive) + sleep 2 ## simulate transfer wait + printf "%s %s\n" "$cmd" "$*" + ;; + destroy | snapshot | send) ;; + *) + printf "%s %s\n" "$cmd" "$*" + ;; + esac + return 0 +} + +_fakeZFS "$@" diff --git a/zfs-replicate.sh b/zfs-replicate.sh index 26ae894..d4741ba 100755 --- a/zfs-replicate.sh +++ b/zfs-replicate.sh @@ -1,53 +1,90 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh ## zfs-replicate.sh -set -e -o pipefail +set -eu ## fail on errors and undefined variables -############################################ -##### warning gremlins live below here ##### -############################################ +# check pipefail in a subshell and set if supported +# shellcheck disable=SC3040 +(set -o pipefail 2> /dev/null) && set -o pipefail + +## set self identification values +readonly SCRIPT="${0##*/}" +readonly SCRIPT_PATH="${0%/*}" + +## set date substitutions for macros +__DOW=$(date "+%a") +readonly __DOW +__DOM=$(date "+%d") +readonly __DOM +__MOY=$(date "+%m") +readonly __MOY +__CYR=$(date "+%Y") +readonly __CYR +__NOW=$(date "+%s") +readonly __NOW + +## init configuration with values from environment or set defaults +REPLICATE_SETS=${REPLICATE_SETS:-""} ## default empty +ALLOW_ROOT_DATASETS="${ALLOW_ROOT_DATASETS:-0}" +ALLOW_RECONCILIATION="${ALLOW_RECONCILIATION:-0}" +RECURSE_CHILDREN="${RECURSE_CHILDREN:-0}" +SNAP_KEEP="${SNAP_KEEP:-2}" +SYSLOG="${SYSLOG:-1}" +SYSLOG_FACILITY="${SYSLOG_FACILITY:-"user"}" +TAG="${TAG:-"%MOY%%DOM%%CYR%_%NOW%"}" +LOG_FILE="${LOG_FILE:-"autorep-%TAG%.log"}" +LOG_KEEP="${LOG_KEEP:-5}" +LOG_BASE=${LOG_BASE:-""} ## default empty +LOGGER="${LOGGER:-$(which logger || true)}" +FIND="${FIND:-$(which find || true)}" +SSH="${SSH:-$(which ssh || true)}" +ZFS="${ZFS:-$(which zfs || true)}" +HOST_CHECK="${HOST_CHECK:-"ping -c1 -q -W2 %HOST%"}" +## we default these after config is loaded +DEST_PIPE_WITH_HOST= +DEST_PIPE_WITHOUT_HOST= +## temp path used for lock files +TMPDIR="${TMPDIR:-"/tmp"}" +## init values used in snapCreate and exitClean +__PAIR_COUNT=0 +__SKIP_COUNT=0 ## output log files in decreasing age order sortLogs() { ## check if file logging is enabled - if [[ -z "$LOG_BASE" ]] || [[ ! -d "$LOG_BASE" ]]; then - return + if [ -z "$LOG_BASE" ] || [ ! -d "$LOG_BASE" ]; then + return 0 fi ## find existing logs - local logs=() - for log in $("$FIND" "$LOG_BASE" -maxdepth 1 -type f -name autorep-\*); do - ## get file change time via stat (platform specific) - local fstat - case "$(uname -s)" in - Linux | SunOS) - fstat=$(stat -c %Z "$log") - ;; - *) - fstat=$(stat -f %c "$log") - ;; - esac - ## append logs to array with creation time - logs+=("${fstat}\t${log}") - done + logs=$($FIND "$LOG_BASE" -maxdepth 1 -type f -name 'autorep-*') + ## get file change time via stat (platform specific) + if [ "$(uname -s)" = "Linux" ] || [ "$(uname -s)" = "SunOS" ]; then + fstat='stat -c %Z' + else + fstat='stat -f %c' + fi ## output logs in descending age order - for log in $(printf "%b\n" "${logs[@]}" | sort -rn | cut -f2); do - printf "%s\n" "$log" - done + for log in $logs; do + printf "%s\t%s\n" "$($fstat "$log")" "$log" + done | sort -rn | cut -f2 } ## check log count and delete old logs pruneLogs() { - local logs - mapfile -t logs < <(sortLogs) - ## check count and delete old logs - if [[ "${#logs[@]}" -gt "$LOG_KEEP" ]]; then - printf "pruning logs %s\n" "${logs[*]:${LOG_KEEP}}" - rm -rf "${logs[@]:${LOG_KEEP}}" + logs=$(sortLogs) + logCount=0 + if [ -n "$logs" ]; then + logCount=$(printf "%s" "$logs" | wc -l) + fi + if [ "$logCount" -gt "$LOG_KEEP" ]; then + prune="$(printf "%s\n" "$logs" | sed -n "$((LOG_KEEP + 1)),\$p")" + printf "pruning %d logs\n" "$((logCount - LOG_KEEP + 1))" + printf "%s\n" "$prune" | xargs rm -vf fi } ## delete lock files clearLock() { - local lockFile=$1 + lockFile=$1 if [ -f "$lockFile" ]; then printf "deleting lockfile %s\n" "$lockFile" rm "$lockFile" @@ -56,28 +93,30 @@ clearLock() { ## exit and cleanup exitClean() { - local exitCode=${1:-0} extraMsg=$2 logMsg status="success" + exitCode=${1:-0} + extraMsg=${2:-""} + status="success" ## set status to warning if we skipped any datasets - if [[ $__SKIP_COUNT -gt 0 ]]; then + if [ "$__SKIP_COUNT" -gt 0 ]; then status="WARNING" fi - printf -v logMsg "%s total sets %d skipped %d" "$status" "$__PAIR_COUNT" "$__SKIP_COUNT" + logMsg=$(printf "%s total sets %d skipped %d" "$status" "$__PAIR_COUNT" "$__SKIP_COUNT") ## build and print error message - if [[ $exitCode -ne 0 ]]; then + if [ "$exitCode" -ne 0 ]; then status="ERROR" - printf -v logMsg "%s: operation exited unexpectedly: code=%d" "$status" "$exitCode" - if [[ -n "$extraMsg" ]]; then - printf -v logMsg "%s msg=%s" "$logMsg" "$extraMsg" + logMsg=$(printf "%s: operation exited unexpectedly: code=%d" "$status" "$exitCode") + if [ -n "$extraMsg" ]; then + logMsg=$(printf "%s msg=%s" "$logMsg" "$extraMsg") fi fi ## append extra message if available - if [[ $exitCode -eq 0 ]] && [[ -n "$extraMsg" ]]; then - printf -v logMsg "%s: %s" "$logMsg" "$extraMsg" + if [ "$exitCode" -eq 0 ] && [ -n "$extraMsg" ]; then + logMsg=$(printf "%s: %s" "$logMsg" "$extraMsg") fi ## cleanup old logs and clear locks pruneLogs - clearLock "${TMPDIR}"/.replicate.snapshot.lock - clearLock "${TMPDIR}"/.replicate.send.lock + clearLock "${TMPDIR}/.replicate.snapshot.lock" + clearLock "${TMPDIR}/.replicate.send.lock" ## print log message and exit printf "%s\n" "$logMsg" exit "$exitCode" @@ -85,103 +124,112 @@ exitClean() { ## lockfile creation and maintenance checkLock() { - local lockFile=$1 + lockFile=$1 ## check our lockfile status - if [[ -f "$lockFile" ]]; then + if [ -f "$lockFile" ]; then ## see if this pid is still running - local ps - if ps=$(pgrep -lx -F "$lockFile"); then + if ps -p "$(cat "$lockFile")" > /dev/null 2>&1; then ## looks like it's still running - printf "ERROR: script is already running as: %s\n" "$ps" + printf "ERROR: script is already running as: %d\n" "$(cat "$lockFile")" else ## stale lock file? printf "ERROR: stale lockfile %s\n" "$lockFile" fi ## cleanup and exit exitClean 128 "confirm script is not running and delete lockfile $lockFile" - else - ## well no lockfile..let's make a new one - printf "creating lockfile %s\n" "$lockFile" - printf "%d\n" "$$" > "$lockFile" fi + ## well no lockfile..let's make a new one + printf "creating lockfile %s\n" "$lockFile" + printf "%d\n" "$$" > "$lockFile" } ## check remote host status checkHost() { ## do we have a host check defined - if [[ -z "$HOST_CHECK" ]]; then - return + if [ -z "$HOST_CHECK" ]; then + return 0 + fi + host=$1 + if [ -z "$host" ]; then + return 0 fi - local host=$1 cmd=() - ## substitute host - read -r -a cmd <<< "${HOST_CHECK//%HOST%/$host}" - printf "checking host cmd=%s\n" "${cmd[*]}" + cmd=$(printf "%s\n" "$HOST_CHECK" | sed "s/%HOST%/$host/g") + printf "checking host cmd=%s\n" "$cmd" ## run the check - if ! "${cmd[@]}" > /dev/null 2>&1; then - exitClean 128 "host check failed" + if ! $cmd > /dev/null 2>&1; then + return 1 fi + return 0 } ## ensure dataset exists checkDataset() { - local set=$1 host=$2 cmd=() + set=$1 + host=$2 + cmd="" ## build command - if [[ -n "$host" ]]; then - read -r -a cmd <<< "$SSH" - cmd+=("$host") + if [ -n "$host" ]; then + cmd="$SSH $host " fi - cmd+=("$ZFS" "list" "-H" "-o" "name" "$set") - printf "checking dataset cmd=%s\n" "${cmd[*]}" + cmd="$cmd$ZFS list -H -o name $set" + printf "checking dataset cmd=%s\n" "$cmd" ## execute command - if ! "${cmd[@]}"; then - exitClean 128 "failed to list dataset: ${set}" + if ! $cmd; then + return 1 fi + return 0 } ## small wrapper around zfs destroy snapDestroy() { - local snap=$1 host=$2 cmd=() + snap=$1 + host=$2 + cmd="" ## build command - if [[ -n "$host" ]]; then - read -r -a cmd <<< "$SSH" - cmd+=("$host") + if [ -n "$host" ]; then + cmd="$SSH $host " fi - cmd+=("$ZFS" "destroy") - if [[ $RECURSE_CHILDREN -eq 1 ]]; then - cmd+=("-r") + cmd="$cmd$ZFS destroy" + if [ "$RECURSE_CHILDREN" -eq 1 ]; then + cmd="$cmd -r" fi - cmd+=("$snap") - printf "destroying snapshot cmd=%s\n" "${cmd[*]}" + cmd="$cmd $snap" + printf "destroying snapshot cmd=%s\n" "$cmd" ## ignore error from destroy and count on logging to alert the end-user ## destroying recursive snapshots can lead to "snapshot not found" errors - "${cmd[@]}" || true + $cmd || true } ## main replication function snapSend() { - local base=$1 snap=$2 src=$3 srcHost=$4 dst=$5 dstHost=$6 cmd=() pipe=() + base=$1 + snap=$2 + src=$3 + srcHost=$4 + dst=$5 + dstHost=$6 ## check our send lockfile checkLock "${TMPDIR}/.replicate.send.lock" ## begin building send command - if [[ -n "$srcHost" ]]; then - read -r -a cmd <<< "$SSH" - cmd+=("$srcHost") + cmd="" + if [ -n "$srcHost" ]; then + cmd="$SSH $srcHost " fi - cmd+=("$ZFS" "send" "-Rs") + cmd="$cmd$ZFS send -Rs" ## if first snap name is not empty generate an incremental if [ -n "$base" ]; then - cmd+=("-I" "$base") + cmd="$cmd -I $base" fi - cmd+=("${src}@${snap}") + cmd="$cmd ${src}@${snap}" ## set destination pipe based on destination host - read -r -a pipe <<< "$DEST_PIPE_WITHOUT_HOST" - if [[ -n "$dstHost" ]]; then - read -r -a pipe <<< "${DEST_PIPE_WITH_HOST//%HOST%/$dstHost}" + pipe="$DEST_PIPE_WITHOUT_HOST" + if [ -n "$dstHost" ]; then + pipe=$(printf "%s\n" "$DEST_PIPE_WITH_HOST" | sed "s/%HOST%/$dstHost/g") fi - pipe+=("$dst") - printf "sending snapshot cmd=%s | %s\n" "${cmd[*]}" "${pipe[*]}" + pipe="$pipe $dst" + printf "sending snapshot cmd=%s | %s\n" "$cmd" "$pipe" ## execute send and check return - if ! "${cmd[@]}" | "${pipe[@]}"; then + if ! $cmd | $pipe; then snapDestroy "${src}@${name}" "$srcHost" exitClean 128 "failed to send snapshot: ${src}@${name}" fi @@ -191,27 +239,25 @@ snapSend() { ## list replication snapshots snapList() { - local set=$1 host=$2 depth=${3:-0} cmd=() snaps snap + set=$1 + host=$2 + depth=$3 + cmd="" ## build send command - if [[ -n "$host" ]]; then - read -r -a cmd <<< "$SSH" - cmd+=("$host") + if [ -n "$host" ]; then + cmd="$SSH $host " fi - cmd+=("$ZFS" "list" "-Hr" "-o" "name" "-s" "creation" "-t" "snapshot") - if [[ $depth -gt 0 ]]; then - cmd+=("-d" "$depth") + cmd="$cmd$ZFS list -Hr -o name -s creation -t snapshot" + if [ "$depth" -gt 0 ]; then + cmd="$cmd -d $depth" fi - cmd+=("$set") + cmd="$cmd $set" ## get snapshots from host - if ! snaps="$("${cmd[@]}")"; then - exitClean 128 "failed to list snapshots for dataset: ${set}" + if ! snaps=$($cmd); then + exitClean 128 "failed to list snapshots for dataset: $set" fi ## filter snaps matching our pattern - for snap in $snaps; do - if [[ "$snap" == *"@autorep-"* ]]; then - printf "%s\n" "$snap" - fi - done + printf "%s\n" "$snaps" | grep "@autorep-" || true } ## create and manage source snapshots @@ -219,154 +265,157 @@ snapCreate() { ## make sure we aren't ever creating simultaneous snapshots checkLock "${TMPDIR}/.replicate.snapshot.lock" ## set our snap name - local name="autorep-${TAG}" temps="" tempa=() src dst pair + name="autorep-${TAG}" ## generate snapshot list and cleanup old snapshots - __PAIR_COUNT=0 __SKIP_COUNT=0 ## these are used in exitClean for pair in $REPLICATE_SETS; do - ((__PAIR_COUNT++)) || true + __PAIR_COUNT=$((__PAIR_COUNT + 1)) ## split dataset into source and destination parts and trim any trailing space - read -r -a tempa <<< "${pair//:/ }" - src="${tempa[0]}" - src="${src%"${src##*[![:space:]]}"}" - dst="${tempa[1]}" - dst="${dst%"${dst##*[![:space:]]}"}" + src=$(printf "%s\n" "$pair" | cut -f1 -d: | sed 's/[[:space:]]*$//') + dst=$(printf "%s\n" "$pair" | cut -f2 -d: | sed 's/[[:space:]]*$//') ## check for root dataset destination - if [[ "$ALLOW_ROOT_DATASETS" -ne 1 ]]; then - if [[ "$dst" == "$(basename "$dst")" ]] || [[ "$dst" == "$(basename "$dst")/" ]]; then - temps="replicating root datasets can lead to data loss - set 'ALLOW_ROOT_DATASETS=1' to disable warning" + if [ "$ALLOW_ROOT_DATASETS" -ne 1 ]; then + if [ "$dst" = "$(basename "$dst")" ] || [ "$dst" = "$(basename "$dst")/" ]; then + temps="replicating root datasets can lead to data loss - set ALLOW_ROOT_DATASETS=1 to override" printf "WARNING: skipping replication set '%s' - %s\n" "$pair" "$temps" - ((__SKIP_COUNT++)) || true + __SKIP_COUNT=$((__SKIP_COUNT + 1)) continue fi fi - ## look for host options on source and destination - local srcHost dstHost - if [[ "$src" == *@* ]]; then - ## split and trim trailing spaces - read -r -a tempa <<< "${src//@/ }" - src="${tempa[0]}" - src="${src%"${src##*[![:space:]]}"}" - srcHost="${tempa[1]}" - srcHost="${srcHost%"${srcHost##*[![:space:]]}"}" - checkHost "$srcHost" ## we only check the host once per set + ## init source and destination host in each loop iteration + srcHost="" + dstHost="" + ## look for source host option + if [ "${src#*"@"}" != "$src" ]; then + srcHost=$(printf "%s\n" "$src" | cut -f2 -d@) + src=$(printf "%s\n" "$src" | cut -f1 -d@) fi - if [[ "$dst" == *@* ]]; then - ## split and trim trailing spaces - read -r -a tempa <<< "${dst//@/ }" - dst="${tempa[0]}" - dst="${dst%"${dst##*[![:space:]]}"}" - dstHost="${tempa[1]}" - dstHost="${dstHost%"${dstHost##*[![:space:]]}"}" - checkHost "$dstHost" ## we only check the host once per set + ## look for destination host option + if [ "${dst#*"@"}" != "$dst" ]; then + dstHost=$(printf "%s\n" "$dst" | cut -f2 -d@) + dst=$(printf "%s\n" "$dst" | cut -f1 -d@) + fi + ## check source and destination hosts + if ! checkHost "$srcHost" || ! checkHost "$dstHost"; then + printf "WARNING: skipping replication set '%s' - source or destination host check failed\n" "$pair" + __SKIP_COUNT=$((__SKIP_COUNT + 1)) + continue + fi + ## check source and destination datasets + if ! checkDataset "$src" "$srcHost" || ! checkDataset "$dst" "$dstHost"; then + printf "WARNING: skipping replication set '%s' - source or destination dataset check failed\n" "$pair" + __SKIP_COUNT=$((__SKIP_COUNT + 1)) + continue fi - ## ensure datasets exist - checkDataset "$src" "$srcHost" - checkDataset "$dst" "$dstHost" ## get source and destination snapshots - local srcSnaps dstSnaps - mapfile -t srcSnaps < <(snapList "$src" "$srcHost" 1) - mapfile -t dstSnaps < <(snapList "$dst" "$dstHost" 0) - for snap in "${srcSnaps[@]}"; do + srcSnaps=$(snapList "$src" "$srcHost" 1) + dstSnaps=$(snapList "$dst" "$dstHost" 0) + for snap in $srcSnaps; do ## while we are here...check for our current snap name - if [[ "$snap" == "${src}@${name}" ]]; then + if [ "$snap" = "${src}@${name}" ]; then ## looks like it's here...we better kill it printf "destroying duplicate snapshot: %s@%s\n" "$src" "$name" snapDestroy "${src}@${name}" "$srcHost" fi done + ## get source and destination snap count + srcSnapCount=0 + dstSnapCount=0 + if [ -n "$srcSnaps" ]; then + srcSnapCount=$(printf "%s\n" "$srcSnaps" | wc -l) + fi + if [ -n "$dstSnaps" ]; then + dstSnapCount=$(printf "%s\n" "$dstSnaps" | wc -l) + fi ## set our base snap for incremental generation if src contains a sufficient - ## number of snapshots and the base source snapshot exists in destination data set. - local base - if [[ ${#srcSnaps[@]} -ge 1 ]]; then - ## set source snap base candidate - ss="${srcSnaps[-1]}" - ## split snap into fs and snap name - read -r -a tempa <<< "${ss//@/ }" - sn="${tempa[1]}" - sn="${sn%"${sn##*[![:space:]]}"}" - ## loop over base snaps and check for a match - for snap in "${dstSnaps[@]}"; do - read -r -a tempa <<< "${snap//@/ }" - dn="${tempa[1]}" - dn="${dn%"${dn##*[![:space:]]}"}" - if [[ "$dn" == "$sn" ]]; then + ## number of snapshots and the base source snapshot exists in destination dataset + base="" + if [ "$srcSnapCount" -ge 1 ] && [ "$dstSnapCount" -ge 1 ]; then + ## get most recent source snapshot + ss=$(printf "%s\n" "$srcSnaps" | tail -n 1) + ## get source snapshot name + sn=$(printf "%s\n" "$ss" | cut -f2 -d@) + ## loop over destinations snaps and look for a match + for ds in $dstSnaps; do + dn=$(printf "%s\n" "$ds" | cut -f2 -d@) + if [ "$dn" = "$sn" ]; then base="$ss" + break fi done ## no matching base, are we allowed to fallback? - if [[ -z "$base" ]] && [[ ${#dstSnaps[@]} -ge 1 ]] && [[ $ALLOW_RECONCILIATION -ne 1 ]]; then - printf -v temps "source snapshot '%s' not in destination dataset: %s" "${srcSnaps[-1]}" "$dst" - printf -v temps "%s - set 'ALLOW_RECONCILIATION=1' to fallback to a full send" "$temps" + if [ -z "$base" ] && [ "$ALLOW_RECONCILIATION" -ne 1 ]; then + temps=$(printf "source snapshot '%s' not in destination dataset: %s" "$ss" "$dst") + temps=$(printf "%s - set 'ALLOW_RECONCILIATION=1' to fallback to a full send" "$temps") printf "WARNING: skipping replication set '%s' - %s\n" "$pair" "$temps" - ((__SKIP_COUNT++)) || true + __SKIP_COUNT=$((__SKIP_COUNT + 1)) continue fi fi ## without a base snapshot, the destination must be clean - if [[ -z "$base" ]] && [[ ${#dstSnaps[@]} -gt 0 ]]; then + if [ -z "$base" ] && [ "$dstSnapCount" -gt 0 ]; then ## allowed to prune remote dataset? - if [[ $ALLOW_RECONCILIATION -ne 1 ]]; then + if [ "$ALLOW_RECONCILIATION" -ne 1 ]; then temps="destination contains snapshots not in source - set 'ALLOW_RECONCILIATION=1' to prune snapshots" printf "WARNING: skipping replication set '%s' - %s\n" "$pair" "$temps" - ((__SKIP_COUNT++)) || true + __SKIP_COUNT=$((__SKIP_COUNT + 1)) continue fi ## prune destination snapshots - printf "pruning destination snapshots: %s\n" "${dstSnaps[*]}" - for snap in "${dstSnaps[@]}"; do + printf "pruning destination snapshots: %s\n" "$dstSnaps" + for snap in $dstSnaps; do snapDestroy "$snap" "$dstHost" done fi ## cleanup old snapshots - local idx - for idx in "${!srcSnaps[@]}"; do - if [[ ${#srcSnaps[@]} -ge $SNAP_KEEP ]]; then - ## snaps are sorted above by creation in ascending order - printf "found old snapshot %s\n" "${srcSnaps[idx]}" - snapDestroy "${srcSnaps[idx]}" "$srcHost" - unset 'srcSnaps[idx]' - fi - done - ## come on already...make that snapshot - if [[ -n "$srcHost" ]]; then - read -r -a cmd <<< "$SSH" - cmd+=("$srcHost") + if [ "$srcSnapCount" -ge "$SNAP_KEEP" ]; then + ## snaps are sorted above by creation in ascending order + printf "%s\n" "$srcSnaps" | sed -n "1,$((srcSnapCount - SNAP_KEEP))p" | while read -r snap; do + printf "found old snapshot %s\n" "$snap" + snapDestroy "$snap" "$srcHost" + done fi - cmd+=("$ZFS" "snapshot") + ## build snapshot create command + cmd="" + if [ -n "$srcHost" ]; then + cmd="$SSH $srcHost " + fi + cmd="$cmd$ZFS snapshot" ## check if we are supposed to be recursive - if [[ $RECURSE_CHILDREN -eq 1 ]]; then - cmd+=("-r") + if [ "$RECURSE_CHILDREN" -eq 1 ]; then + cmd="$cmd -r" fi - cmd+=("$src@$name") - printf "taking snapshot cmd=%s\n" "${cmd[*]}" - if ! "${cmd[@]}"; then + cmd="$cmd ${src}@${name}" + ## come on already...take that snapshot + printf "creating snapshot cmd=%s\n" "$cmd" + if ! $cmd; then + snapDestroy "${src}@${name}" "$srcHost" exitClean 128 "failed to create snapshot: ${src}@${name}" fi ## send snapshot to destination snapSend "$base" "$name" "$src" "$srcHost" "$dst" "$dstHost" done - ## clear our lockfile + ## clear snapshot lockfile clearLock "${TMPDIR}/.replicate.snapshot.lock" } ## handle logging to file or syslog writeLog() { - local line=$1 logf="/dev/null" + line=$1 + logf="/dev/null" ## if a log base and file has been configured set them - if [[ -n "$LOG_BASE" ]] && [[ -n "$LOG_FILE" ]]; then + if [ -n "$LOG_BASE" ] && [ -n "$LOG_FILE" ]; then logf="${LOG_BASE}/${LOG_FILE}" fi ## always print to stdout and copy to logfile if set printf "%s %s[%d]: %s\n" "$(date '+%b %d %T')" "$SCRIPT" "$$" "$line" | tee -a "$logf" ## if syslog has been enabled write to syslog via logger - if [[ -n "$SYSLOG" ]] && [[ "$SYSLOG" -eq 1 ]] && [[ -n "$LOGGER" ]]; then + if [ "$SYSLOG" -eq 1 ] && [ -n "$LOGGER" ]; then $LOGGER -p "${SYSLOG_FACILITY}.info" -t "$SCRIPT" "$line" fi } ## read from stdin till script exit captureOutput() { - local line while IFS= read -r line; do writeLog "$line" done @@ -374,139 +423,129 @@ captureOutput() { ## perform macro substitution for tags subTags() { - local m=$1 + m=$1 ## do the substitutions - m=${m//%DOW%/${DATE_MACROS[DOW]}} - m=${m//%DOM%/${DATE_MACROS[DOM]}} - m=${m//%MOY%/${DATE_MACROS[MOY]}} - m=${m//%CYR%/${DATE_MACROS[CYR]}} - m=${m//%NOW%/${DATE_MACROS[NOW]}} - m=${m//%TAG%/$TAG} + m=$(printf "%s\n" "$m" | sed "s/%DOW%/${__DOW}/g") + m=$(printf "%s\n" "$m" | sed "s/%DOM%/${__DOM}/g") + m=$(printf "%s\n" "$m" | sed "s/%MOY%/${__MOY}/g") + m=$(printf "%s\n" "$m" | sed "s/%CYR%/${__CYR}/g") + m=$(printf "%s\n" "$m" | sed "s/%NOW%/${__NOW}/g") + m=$(printf "%s\n" "$m" | sed "s/%TAG%/${TAG}/g") printf "%s\n" "$m" } -## dump latest log to stdout and exit +## show last replication status showStatus() { - local logs - mapfile -t logs < <(sortLogs) - if [[ -n "${logs[0]}" ]]; then - printf "Last output from %s:\n%s\n" "$SCRIPT" "$(cat "${logs[0]}")" - else - printf "Unable to find most recent log file, cannot print status." + log=$(sortLogs | head -n 1) + if [ -n "$log" ]; then + printf "%s" "$(cat "${log}")" && exit 0 fi - exit 0 + ## not found, log error and exit + writeLog "ERROR: unable to find most recent log file, cannot print status" && exit 1 } ## show usage and exit showHelp() { - printf "Usage: %s [options] [config]\n\n" "${BASH_SOURCE[0]}" - printf "Bash script to automate ZFS Replication\n\n" + printf "Usage: %s [options] [config]\n\n" "${SCRIPT}" + printf "POSIX shell script to automate ZFS Replication\n\n" printf "Options:\n" - printf " -c, --config bash configuration file\n" + printf " -c, --config configuration file\n" printf " -s, --status print most recent log messages to stdout\n" printf " -h, --help show this message\n" exit 0 } -## load configuration defaults, parse flags, config, and environment -## captureOutput is not running yet, so use writeLog directly in loadConfig +## read config file if present, process flags, validate, and lock config variables loadConfig() { - ## set SCRIPT used by writeLog and showStatus - SCRIPT="$(basename "${BASH_SOURCE[0]}")" - readonly SCRIPT - ## local variables only used in loadConfig - local status=0 configFile opt OPTARG OPTIND line - ## read command line flags - while getopts ":shc:-:" opt; do - if [[ "$opt" == "-" ]]; then - opt="${OPTARG%%=*}" # extract long option name - opt="${opt#"${opt%%[![:space:]]*}"}" # remove leading whitespace characters - opt="${opt%"${opt##*[![:space:]]}"}" # remove trailing whitespace characters - OPTARG="${OPTARG#"$opt"}" # extract long option argument (may be empty) - OPTARG="${OPTARG#=}" # if long option argument, remove assigning `=` + configFile="" + status=0 + ## sub macros for logging + TAG="$(subTags "$TAG")" + LOG_FILE="$(subTags "$LOG_FILE")" + ## process command-line options + while [ $# -gt 0 ]; do + if [ "$1" = "-c" ] || [ "$1" = "--config" ]; then + shift + configFile="$1" + shift + continue + fi + if [ "$1" = "-s" ] || [ "$1" = "--status" ]; then + status=1 + shift + continue + fi + ## unknown option - check for config file for backwards compatibility + if [ -z "$configFile" ] && [ -f "$1" ]; then + configFile="$1" + shift + continue fi - case "$opt" in - c | config) - configFile="${OPTARG}" - ;; - s | status) - status=1 - ;; - h | help) - showHelp - ;; - \?) # bad short option - writeLog "ERROR: illegal option -${OPTARG}" && exit 1 - ;; - *) # bad long option - writeLog "ERROR: illegal option --${opt}" && exit 1 - ;; - esac + ## nothing left, error out + writeLog "ERROR: illegal option ${1}" && exit 1 done - # remove parsed options and args from $@ list - shift $((OPTIND - 1)) - ## allow config file to be passed as argument without a flag for backwards compat - [[ -z "$configFile" ]] && configFile=$1 ## attempt to load configuration - if [[ -f "$configFile" ]]; then - writeLog "sourcing config file $configFile" + if [ -f "$configFile" ]; then # shellcheck disable=SC1090 - source "$configFile" - elif configFile="$(dirname "${BASH_SOURCE[0]}")/config.sh" && [[ -f "$configFile" ]]; then - writeLog "sourcing config file $configFile" + . "$configFile" + elif configFile="${SCRIPT_PATH}/config.sh" && [ -f "$configFile" ]; then # shellcheck disable=SC1090 - source "$configFile" - else - writeLog "loading configuration from defaults and environmental settings." - fi - declare -A DATE_MACROS=( - ["DOW"]=$(date "+%a") ["DOM"]=$(date "+%d") ["MOY"]=$(date "+%m") - ["CYR"]=$(date "+%Y") ["NOW"]=$(date "+%s") - ) - readonly DATE_MACROS - readonly TMPDIR=${TMPDIR:-"/tmp"} - readonly REPLICATE_SETS ## no default value - readonly ALLOW_ROOT_DATASETS=${ALLOW_ROOT_DATASETS:-0} - readonly ALLOW_RECONCILIATION=${ALLOW_RECONCILIATION:-0} - readonly RECURSE_CHILDREN=${RECURSE_CHILDREN:-0} - readonly SNAP_KEEP=${SNAP_KEEP:-2} - readonly SYSLOG=${SYSLOG:-1} - readonly SYSLOG_FACILITY=${SYSLOG_FACILITY:-"user"} - TAG=${TAG:-"%MOY%%DOM%%CYR%_%NOW%"} - TAG=$(subTags "$TAG") + . "$configFile" + fi + ## perform final substitution + TAG="$(subTags "$TAG")" + LOG_FILE="$(subTags "$LOG_FILE")" + ## lock configuration + readonly REPLICATE_SETS + readonly ALLOW_ROOT_DATASETS + readonly ALLOW_RECONCILIATION + readonly RECURSE_CHILDREN + readonly SNAP_KEEP + readonly SYSLOG + readonly SYSLOG_FACILITY readonly TAG - LOG_FILE=${LOG_FILE:-"autorep-%TAG%.log"} - LOG_FILE=$(subTags "$LOG_FILE") readonly LOG_FILE - readonly LOG_KEEP=${LOG_KEEP:-5} - readonly LOG_BASE ## no default value - readonly LOGGER=${LOGGER:-$(which logger)} - readonly FIND=${FIND:-$(which find)} - readonly ZFS=${ZFS:-$(which zfs)} - readonly SSH=${SSH:-$(which ssh)} - readonly DEST_PIPE_WITH_HOST=${DEST_PIPE_WITH_HOST:-"$SSH %HOST% $ZFS receive -vFd"} - readonly DEST_PIPE_WITHOUT_HOST=${DEST_PIPE_WITHOUT_HOST:-"$ZFS receive -vFd"} - readonly HOST_CHECK=${HOST_CHECK:-"ping -c1 -q -W2 %HOST%"} + readonly LOG_KEEP + readonly LOG_BASE + readonly LOGGER + readonly FIND + readonly SSH + readonly ZFS + readonly HOST_CHECK + readonly TMPDIR + ## set pipes after configuration to ensure proper $SSH and $ZFS subs + readonly DEST_PIPE_WITH_HOST="${DEST_PIPE_WITH_HOST:-"$SSH %HOST% $ZFS receive -vFd"}" + readonly DEST_PIPE_WITHOUT_HOST="${DEST_PIPE_WITHOUT_HOST:-"$ZFS receive -vFd"}" ## check configuration - if [[ -n "$LOG_BASE" ]] && [[ ! -d "$LOG_BASE" ]]; then + if [ -n "$LOG_BASE" ] && [ ! -d "$LOG_BASE" ]; then mkdir -p "$LOG_BASE" fi - if [[ -z "$REPLICATE_SETS" ]]; then - writeLog "ERROR: missing required setting REPLICATE_SETS" && exit 1 + ## we have all we need for status + if [ "$status" -eq 1 ]; then + showStatus fi - if [[ -z "$ZFS" ]]; then - writeLog "ERROR: unable to locate system zfs binary" && exit 1 + ## continue validating config + if [ "$SYSLOG" -eq 1 ] && [ -z "$LOGGER" ]; then + writeLog "ERROR: unable to locate system logger binary and SYSLOG is enabled" && exit 1 + fi + if [ -z "$REPLICATE_SETS" ]; then + writeLog "ERROR: missing required setting REPLICATE_SETS" && exit 1 fi - if [[ $SNAP_KEEP -lt 2 ]]; then + if [ "$SNAP_KEEP" -lt 2 ]; then writeLog "ERROR: a minimum of 2 snapshots are required for incremental sending" && exit 1 fi - ## show status if toggled - if [[ $status -eq 1 ]]; then - showStatus + if [ -z "$FIND" ]; then + writeLog "ERROR: unable to locate system find binary" && exit 1 + fi + if [ -z "$SSH" ]; then + writeLog "ERROR: unable to locate system ssh binary" && exit 1 + fi + if [ -z "$ZFS" ]; then + writeLog "ERROR: unable to locate system zfs binary" && exit 1 fi } -## it all starts here... +## main function, not much here main() { ## do snapshots and send snapCreate @@ -514,5 +553,8 @@ main() { exitClean 0 } -## start main if we weren't sourced -[[ "$0" == "${BASH_SOURCE[0]}" ]] && loadConfig "$@" && main 2>&1 | captureOutput +## process config and start main if we weren't sourced +if [ "$SCRIPT" != "sh" ] && [ "$SCRIPT" != "dash" ] && [ "$SCRIPT" != "-bash" ] && + [ "$(expr "$SCRIPT" : 'zfs-replicate')" -gt 0 ]; then + loadConfig "$@" && main 2>&1 | captureOutput +fi