├── .github ├── CODEOWNERS └── workflows │ └── status-checks.yaml ├── .gitignore ├── .markdownlint.yaml ├── Makefile ├── README.md ├── config.sample.sh ├── test ├── find.sh ├── ssh.sh ├── test.sh └── zfs.sh └── zfs-replicate.sh /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * ahurt@anbcs.com 2 | -------------------------------------------------------------------------------- /.github/workflows/status-checks.yaml: -------------------------------------------------------------------------------- 1 | name: status checks 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | pull-requests: read 11 | 12 | jobs: 13 | misspell: 14 | name: runner / misspell 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 18 | - uses: reviewdog/action-misspell@9daa94af4357dddb6fd3775de806bc0a8e98d3e4 # ratchet:reviewdog/action-misspell@v1 19 | with: 20 | level: info 21 | reporter: github-check 22 | filter_mode: nofilter 23 | locale: "US" 24 | 25 | typos: 26 | name: runner / typos 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 30 | - uses: reviewdog/action-typos@627388e238f182b925d9acd151432f9b68f1d666 # ratchet:reviewdog/action-typos@v1 31 | with: 32 | level: info 33 | reporter: github-check 34 | filter_mode: nofilter 35 | 36 | markdownlint: 37 | name: runner / markdownlint 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 41 | - uses: reviewdog/action-markdownlint@3667398db9118d7e78f7a63d10e26ce454ba5f58 # ratchet:reviewdog/action-markdownlint@v0 42 | with: 43 | level: info 44 | reporter: github-check 45 | filter_mode: nofilter 46 | 47 | shfmt: 48 | name: runner / shfmt 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 52 | - uses: reviewdog/action-shfmt@d8f080930b9be5847b4f97e9f4122b81a82aaeac # ratchet:reviewdog/action-shfmt@v1 53 | with: 54 | level: info 55 | filter_mode: nofilter 56 | fail_on_error: true 57 | shfmt_flags: '-ci -sr -i 2' 58 | 59 | shellcheck: 60 | name: runner / shellcheck 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 64 | - name: shellcheck 65 | uses: reviewdog/action-shellcheck@5ebd09ddbe2ebb471646ce234c6c8dd18663ca7c # ratchet:reviewdog/action-shellcheck@v1 66 | with: 67 | level: info 68 | reporter: github-check 69 | filter_mode: nofilter 70 | fail_on_error: true 71 | exclude: | 72 | "./.git/*" 73 | 74 | tests: 75 | name: unit tests 76 | runs-on: ubuntu-latest 77 | steps: 78 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 79 | - run: cd test && ./test.sh 80 | env: 81 | TMPDIR: ${{ runner.temp }} 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .vimrc 4 | .DS_* 5 | .Apple* 6 | .*~ 7 | config.sh 8 | logs/* 9 | log/* 10 | *.log 11 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # MD013/line-length : Line length : https://github.com/DavidAnson/markdownlint/blob/v0.34.0/doc/md013.md 2 | MD013: 3 | line_length: 120 4 | heading_line_length: 80 5 | code_block_line_length: 120 6 | code_blocks: true 7 | tables: true 8 | headings: true 9 | strict: false 10 | stern: false 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: check pin unpin update upgrade switch switch-tags test-actions 2 | 3 | ## The paths to search for yaml files. 4 | PATHS := actions .github/workflows 5 | ## The yaml files from above paths up to 2 levels deep. 6 | FILES := $(foreach path, $(PATHS), $(wildcard $(path)/*.yaml $(path)/*/*.yaml)) 7 | 8 | ## help: Show Makefile targets. This is the default target. 9 | help: 10 | @echo "Available Targets:\n" 11 | @egrep '^## .+?:' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's,^## ,,' 12 | 13 | ## check: Check for pinned uses values. 14 | check: 15 | @ratchet check $(FILES) 16 | 17 | ## pin: Pin any unpinned uses values. 18 | pin: 19 | @ratchet pin $(FILES) 20 | 21 | ## unpin: Unpin previously pinned items and revert back to previous value. 22 | unpin: 23 | @ratchet unpin $(FILES) 24 | 25 | ## update: Update pinned uses values to the latest commit matching the unpinned reference. 26 | update: 27 | @ratchet update $(FILES) 28 | 29 | ## upgrade: Upgrade pinned uses values to the latest available upstream reference commit. 30 | upgrade: 31 | @ratchet upgrade $(FILES) 32 | 33 | ## Set container_arch to the desired architecture for testing. 34 | set-container-arch: 35 | container_arch := linux/amd64 36 | ifeq ($(shell uname -s),Darwin) 37 | ifeq ($(shell uname -m),arm64) 38 | container_arch := linux/arm64 39 | endif 40 | endif 41 | 42 | ## test: Run github action "tests" job locally. 43 | test: set-container-arch 44 | act --container-architecture="$(container_arch)" \ 45 | --job tests --rm 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zfs-replicate 2 | 3 | A POSIX shell script to automate ZFS Replication. 4 | 5 | ## Features 6 | 7 | - The script follows strict POSIX standards and should be usable on any host with a POSIX compliant shell. 8 | - Source pools and datasets are always authoritative, the script will always defer to the source. 9 | - Supports push and pull replication with local and remote datasets. 10 | - Supports multiple pool/dataset pairs to replicate. 11 | - Supports divergence detection and reconciliation of destination datasets. 12 | - Logging leverages syslog (via logger) by default, but local logging may be configured. 13 | - Includes a well documented `config.sh` file that may be used as configuration or as reference for environment 14 | variables passed to the script. 15 | - May be run on any schedule using cron or similar mechanism. 16 | - Fully source compliant and may be used by other scripts. 17 | - Test coverage of core functions via mocks in the test/test.sh script. 18 | - Includes a `--status` option for XigmaNAS that can be used to email the last log output at your preferred schedule. 19 | Simply add it as a custom script in the email settings under "System > Advanced > Email Reports" 20 | 21 | ## FreeBSD Package 22 | 23 | This script is available in the FreeBSD [package and ports tree](https://www.freshports.org/sysutils/zfs-replicate/). 24 | 25 | Special thanks to [@tschettervictor](https://www.github.com/tschettervictor) for taking over maintenance of the 26 | package, suggesting new features, and testing for the v1.0 release. 27 | This script has been published to GitHub since 2012 and largely untouched since 2017. The v1.0 release updates mark 28 | the first major changes to this script in over 7 years. 29 | 30 | ## Warning 31 | 32 | Replicating to a root dataset will rewrite the remote pool with forced replication. 33 | This script will create a true 1:1 copy of the source dataset in the destination dataset with default options. 34 | 35 | The configuration `REPLICATE_SETS="zpoolOne:zpoolTwo"` will result in `zpoolTwo` being a 1:1 copy of `zpoolOne` and may 36 | result in data loss on `zpoolTwo`. 37 | 38 | To replicate a root dataset safely to another pool consider `REPLICATE_SETS="zpoolOne:zpoolTwo/zpoolOne"` instead. 39 | 40 | This will result in a 1:1 copy of `zpoolOne` in a separate dataset of `zpoolTwo` and will not affect other datasets 41 | currently present on the destination. 42 | 43 | ## Configuration 44 | 45 | Configuration is done via an optional config file as environment variables. Most options have sane 46 | defaults to keep configuration to a minimum. The script will attempt to locate a file called `config.sh` in the same 47 | directory as the script if one is not passed via the command line. 48 | 49 | The config file is very well commented and the contents of the sample config are shown below. The only required 50 | setting without a default is the `REPLICATE_SETS` option. The script will error out on launch if required configuration 51 | is not met. 52 | 53 | ### Available Command Line Options 54 | 55 | ```text 56 | Usage: ./zfs-replicate.sh [config] [options] 57 | 58 | POSIX shell script to automate ZFS Replication 59 | 60 | Options: 61 | -c, --config configuration file 62 | -s, --status print most recent log messages to stdout 63 | -h, --help show this message 64 | ``` 65 | 66 | ### Config File and Environment Variable Reference 67 | 68 | ```sh 69 | #!/usr/bin/env sh 70 | ## zfs-replicate configuration file 71 | # shellcheck disable=SC2034 72 | 73 | ## Datasets to replicate. These must be zfs paths not mount points. 74 | ## The format general format is "source:destination". The source is always 75 | ## considered authoritative. This holds true for reconciliation attempts with 76 | ## the "ALLOW_RECONCILIATION" option described below as well. 77 | ## 78 | ## Examples replicating a local source to a remote destination (PUSH): 79 | ## - sourcePool/sourceDataset:destinationPool@host 80 | ## - sourcePool/sourceDataset:destinationPool/destinationDataset@host 81 | ## Examples replicating from a remote source to a local destination (PULL): 82 | ## - sourcePool/sourceDataset@host:destinationPool 83 | ## - sourcePool/sourceDataset@host:destinationPool/destinationDataset 84 | ## Examples replicating a local source to a local destination: 85 | ## - sourcePool/sourceDataset:destinationPool 86 | ## - sourcePool/sourceDataset:destinationPool/destinationDataset 87 | ## Multiple space separated sets may be specified. 88 | ## Pools and dataset pairs must exist on the respective servers. 89 | ## 90 | #REPLICATE_SETS="" 91 | 92 | ## Allow replication of root datasets. 93 | ## If "REPLICATE_SETS" contains root datasets and "ALLOW_ROOT_DATASETS" is 94 | ## NOT set to 1, root datasets will be skipped and a warning will be printed. 95 | ## 96 | ## 0 - disable (default) 97 | ## 1 - enable (use at your own risk) 98 | ## 99 | #ALLOW_ROOT_DATASETS=0 100 | 101 | ## Manual alteration of the source or destination datasets by removing 102 | ## snapshots often results in failure. It is expected that datasets configured 103 | ## for replication are a 1:1 copy of each other after the first script run. 104 | ## Setting this option to "1" allows the script to attempt reconciliation when 105 | ## source and destination datasets have diverged. 106 | ## 107 | ## NOTE: The source is always authoritative. Reconciliation will only 108 | ## affect the destination dataset. This script will NEVER modify the source 109 | ## as a means to prevent reconcile divergence between datasets. 110 | ## 111 | ## Setting this option to "1" will result in the following potentially 112 | ## destructive behavior for the destination dataset. 113 | ## 114 | ## - If the script is unable to find the source base snapshot 115 | ## in the destination dataset. The script will fallback to a full send. 116 | ## When combined with the "-F" option in the destination receive pipe, 117 | ## this option will force a reconciliation. ZFS will automatically remove 118 | ## snapshots in the destination that do not exist within the source. 119 | ## - If the script determines that replication snapshots exist in the 120 | ## destination dataset, and no base snapshot is present in the source. 121 | ## The script will remove ALL destination snapshots that appear to have been 122 | ## created by this script and instruct ZFS to do a full send of the source 123 | ## to the destination. 124 | ## 125 | ## These scenarios should never happen under normal circumstances. 126 | ## Setting "ALLOW_RECONCILIATION" to "1" will allow the script to push 127 | ## past failures caused by divergent source and destination datasets to 128 | ## create a 1:1 copy of the source in the destination. 129 | ## 130 | ## 0 - disable (default) 131 | ## 1 - enable (use at your own risk) 132 | ## 133 | #ALLOW_RECONCILIATION=0 134 | 135 | ## Option to recursively snapshot children of datasets contained 136 | ## in the replication set. 137 | ## 138 | ## 0 - disable (default) 139 | ## 1 - enable 140 | ## 141 | #RECURSE_CHILDREN=0 142 | 143 | ## The number of snapshots to keep for each dataset. 144 | ## Older snapshots, by creation date, will be deleted. 145 | ## A minimum of 2 snapshots must be kept for replication to work. 146 | ## This defaults to 2 if not set. 147 | ## 148 | #SNAP_KEEP=2 149 | 150 | ## Option to write logs to syslog via the "logger" tool. This option 151 | ## may be enabled or disabled independently from log file settings. 152 | ## 153 | ## 0 - disable 154 | ## 1 - enable (default) 155 | ## 156 | #SYSLOG=1 157 | 158 | ## Optional logging facility to use with syslog. The default facility 159 | ## is "user" unless changed below. Other common options include local 160 | ## facilities 0-7. 161 | ## Example: local0, local1, local2, local3, local4, local5, local6, or local7 162 | ## 163 | #SYSLOG_FACILITY="user" 164 | 165 | ## The following substitutions for current date information 166 | ## may be used in the "TAG" setting below. 167 | ## These are evaluated at runtime. 168 | ## - %DOW% = Day of Week (date "+%a") 169 | ## - %MOY% = Month of Year (date "+%m") 170 | ## - %DOM% = Day of Month (date "+%d") 171 | ## - %CYR% = Current Year (date "+%Y") 172 | ## - %NOW% = Current Unixtime (date "+%s") 173 | 174 | ## String used for snapshot names and log tags. 175 | ## Example: pool0/someplace@autorep-08242024_1724527527 176 | ## The default is "%MOY%%DOM%%CYR%_%NOW%" 177 | ## 178 | #TAG="%MOY%%DOM%%CYR%_%NOW%" 179 | 180 | ## The log file needs to start with "autorep-" in order for log cleanup 181 | ## to work using the default below is strongly suggested. Leaving this commented out 182 | ## will disable the writing of the standalone log file. The "%TAG%" substitution 183 | ## and/or other date substitutions may be used. The default is "autorep-%TAG%.log" 184 | ## When enabled logs will be placed under the "LOG_BASE" path set above. 185 | ## 186 | #LOG_FILE="autorep-%TAG%.log" 187 | 188 | ## Number of log files to keep. Note, this is only used 189 | ## if "LOG_BASE" is set to a non-empty value above. 190 | ## Older logs, by creation date, will be deleted. 191 | ## This defaults to 5 if not set. 192 | ## 193 | #LOG_KEEP=5 194 | 195 | ## Set the destination for physical log files to reside. By default 196 | ## logging is done via syslog. This setting will always be treated as a 197 | ## directory and not a file. 198 | ## 199 | #LOG_BASE="/var/log/zfs-replicate" 200 | 201 | ## Path to the system "logger" executable. 202 | ## The default uses the first "logger" executable found in $PATH. 203 | ## 204 | #LOGGER=$(which logger) 205 | 206 | ## Path to GNU "find" binary. Solaris find does not support the "-maxdepth" 207 | ## option, which is required to rotate log files. 208 | ## On solaris 11, GNU find is typically located at "/usr/bin/gfind". 209 | ## The default uses the first "find" executable in $PATH. 210 | ## This is NOT required when using syslog. 211 | ## 212 | #FIND=$(which find) 213 | 214 | ## Path to the system "ssh" binary. You may also include custom arguments 215 | ## to SSH here or in the "DEST_PIPE_WITH_HOST" option above. 216 | ## Example: SSH="ssh -l root" to login as root to target host. 217 | ## The default uses the first "ssh" executable found in $PATH. 218 | ## 219 | #SSH=$(which ssh) 220 | 221 | ## Path to the system "zfs" binary. The default uses the first "zfs" 222 | ## executable found in $PATH. 223 | ## 224 | #ZFS=$(which zfs) 225 | 226 | ## Set the pipe to the destination pool. But DO NOT INCLUDE the pipe (|) 227 | ## character in this setting. Filesystem names from the source will be 228 | ## sent to the destination. For increased transfer speed to remote hosts you 229 | ## may want to customize ssh ciphers or include mbuffer. 230 | ## The macro %HOST% string will be substituted with the value of the "@host" 231 | ## target in the replication set. 232 | ## The default WITH a "@host" option is "ssh %HOST% zfs receive -vFd" 233 | ## The default WITHOUT a "@host" option is "zfs receive -vFd". 234 | ## 235 | #DEST_PIPE_WITH_HOST="$SSH %HOST% $ZFS receive -vFd" 236 | #DEST_PIPE_WITHOUT_HOST="$ZFS receive -vFd" 237 | 238 | ## Command to check the health of a source or destination host. 239 | ## A return code of 0 is considered OK/available. 240 | ## This is only used when a replicate set contains an "@host" option. 241 | ## The macro string "%HOST%" will be substituted with the value of 242 | ## the "@host" target in the replicate set. 243 | ## The default command is "ping -c1 -q -W2 %HOST%". 244 | ## 245 | #HOST_CHECK="ping -c1 -q -W2 %HOST%" 246 | ``` 247 | 248 | ### With Environment Variables 249 | 250 | ```shell 251 | LOG_BASE="./logs" SYSLOG=0 SSH="ssh -l root" REPLICATE_SETS="srcPool/srcFS:destPool/destFS@host" ./zfs-replicate.sh 252 | ``` 253 | 254 | ## Notes 255 | 256 | If you use this script, let me know. Report issues via GitHub so they may be resolved. 257 | -------------------------------------------------------------------------------- /config.sample.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | ## zfs-replicate configuration file 3 | # shellcheck disable=SC2034 4 | 5 | ## Datasets to replicate. These must be zfs paths not mount points. 6 | ## The format general format is "source:destination". The source is always 7 | ## considered authoritative. This holds true for reconciliation attempts with 8 | ## the "ALLOW_RECONCILIATION" option described below as well. 9 | ## 10 | ## Examples replicating a local source to a remote destination (PUSH): 11 | ## - sourcePool/sourceDataset:destinationPool@host 12 | ## - sourcePool/sourceDataset:destinationPool/destinationDataset@host 13 | ## Examples replicating from a remote source to a local destination (PULL): 14 | ## - sourcePool/sourceDataset@host:destinationPool 15 | ## - sourcePool/sourceDataset@host:destinationPool/destinationDataset 16 | ## Examples replicating a local source to a local destination: 17 | ## - sourcePool/sourceDataset:destinationPool 18 | ## - sourcePool/sourceDataset:destinationPool/destinationDataset 19 | ## Multiple space separated sets may be specified. 20 | ## Pools and dataset pairs must exist on the respective servers. 21 | ## 22 | #REPLICATE_SETS="" 23 | 24 | ## Allow replication of root datasets. 25 | ## If "REPLICATE_SETS" contains root datasets and "ALLOW_ROOT_DATASETS" is 26 | ## NOT set to 1, root datasets will be skipped and a warning will be printed. 27 | ## 28 | ## 0 - disable (default) 29 | ## 1 - enable (use at your own risk) 30 | ## 31 | #ALLOW_ROOT_DATASETS=0 32 | 33 | ## Manual alteration of the source or destination datasets by removing 34 | ## snapshots often results in failure. It is expected that datasets configured 35 | ## for replication are a 1:1 copy of each other after the first script run. 36 | ## Setting this option to "1" allows the script to attempt reconciliation when 37 | ## source and destination datasets have diverged. 38 | ## 39 | ## NOTE: The source is always authoritative. Reconciliation will only 40 | ## affect the destination dataset. This script will NEVER modify the source 41 | ## as a means to prevent reconcile divergence between datasets. 42 | ## 43 | ## Setting this option to "1" will result in the following potentially 44 | ## destructive behavior for the destination dataset. 45 | ## 46 | ## - If the script is unable to find the source base snapshot 47 | ## in the destination dataset. The script will fallback to a full send. 48 | ## When combined with the "-F" option in the destination receive pipe, 49 | ## this option will force a reconciliation. ZFS will automatically remove 50 | ## snapshots in the destination that do not exist within the source. 51 | ## - If the script determines that replication snapshots exist in the 52 | ## destination dataset, and no base snapshot is present in the source. 53 | ## The script will remove ALL destination snapshots that appear to have been 54 | ## created by this script and instruct ZFS to do a full send of the source 55 | ## to the destination. 56 | ## 57 | ## These scenarios should never happen under normal circumstances. 58 | ## Setting "ALLOW_RECONCILIATION" to "1" will allow the script to push 59 | ## past failures caused by divergent source and destination datasets to 60 | ## create a 1:1 copy of the source in the destination. 61 | ## 62 | ## 0 - disable (default) 63 | ## 1 - enable (use at your own risk) 64 | ## 65 | #ALLOW_RECONCILIATION=0 66 | 67 | ## Option to recursively snapshot children of datasets contained 68 | ## in the replication set. 69 | ## 70 | ## 0 - disable (default) 71 | ## 1 - enable 72 | ## 73 | #RECURSE_CHILDREN=0 74 | 75 | ## The number of snapshots to keep for each dataset. 76 | ## Older snapshots, by creation date, will be deleted. 77 | ## A minimum of 2 snapshots must be kept for replication to work. 78 | ## This defaults to 2 if not set. 79 | ## 80 | #SNAP_KEEP=2 81 | 82 | ## Option to write logs to syslog via the "logger" tool. This option 83 | ## may be enabled or disabled independently from log file settings. 84 | ## 85 | ## 0 - disable 86 | ## 1 - enable (default) 87 | ## 88 | #SYSLOG=1 89 | 90 | ## Optional logging facility to use with syslog. The default facility 91 | ## is "user" unless changed below. Other common options include local 92 | ## facilities 0-7. 93 | ## Example: local0, local1, local2, local3, local4, local5, local6, or local7 94 | ## 95 | #SYSLOG_FACILITY="user" 96 | 97 | ## The following substitutions for current date information 98 | ## may be used in the "TAG" setting below. 99 | ## These are evaluated at runtime. 100 | ## - %DOW% = Day of Week (date "+%a") 101 | ## - %MOY% = Month of Year (date "+%m") 102 | ## - %DOM% = Day of Month (date "+%d") 103 | ## - %CYR% = Current Year (date "+%Y") 104 | ## - %NOW% = Current Unixtime (date "+%s") 105 | 106 | ## String used for snapshot names and log tags. 107 | ## Example: pool0/someplace@autorep-08242024_1724527527 108 | ## The default is "%MOY%%DOM%%CYR%_%NOW%" 109 | ## 110 | #TAG="%MOY%%DOM%%CYR%_%NOW%" 111 | 112 | ## The log file needs to start with "autorep-" in order for log cleanup 113 | ## to work using the default below is strongly suggested. Leaving this commented out 114 | ## will disable the writing of the standalone log file. The "%TAG%" substitution 115 | ## and/or other date substitutions may be used. The default is "autorep-%TAG%.log" 116 | ## When enabled logs will be placed under the "LOG_BASE" path set above. 117 | ## 118 | #LOG_FILE="autorep-%TAG%.log" 119 | 120 | ## Number of log files to keep. Note, this is only used 121 | ## if "LOG_BASE" is set to a non-empty value above. 122 | ## Older logs, by creation date, will be deleted. 123 | ## This defaults to 5 if not set. 124 | ## 125 | #LOG_KEEP=5 126 | 127 | ## Set the destination for physical log files to reside. By default 128 | ## logging is done via syslog. This setting will always be treated as a 129 | ## directory and not a file. 130 | ## 131 | #LOG_BASE="/var/log/zfs-replicate" 132 | 133 | ## Path to the system "logger" executable. 134 | ## The default uses the first "logger" executable found in $PATH. 135 | ## 136 | #LOGGER=$(which logger) 137 | 138 | ## Path to GNU "find" binary. Solaris find does not support the "-maxdepth" 139 | ## option, which is required to rotate log files. 140 | ## On solaris 11, GNU find is typically located at "/usr/bin/gfind". 141 | ## The default uses the first "find" executable in $PATH. 142 | ## This is NOT required when using syslog. 143 | ## 144 | #FIND=$(which find) 145 | 146 | ## Path to the system "ssh" binary. You may also include custom arguments 147 | ## to SSH here or in the "DEST_PIPE_WITH_HOST" option above. 148 | ## Example: SSH="ssh -l root" to login as root to target host. 149 | ## The default uses the first "ssh" executable found in $PATH. 150 | ## 151 | #SSH=$(which ssh) 152 | 153 | ## Path to the system "zfs" binary. The default uses the first "zfs" 154 | ## executable found in $PATH. 155 | ## 156 | #ZFS=$(which zfs) 157 | 158 | ## Set the pipe to the destination pool. But DO NOT INCLUDE the pipe (|) 159 | ## character in this setting. Filesystem names from the source will be 160 | ## sent to the destination. For increased transfer speed to remote hosts you 161 | ## may want to customize ssh ciphers or include mbuffer. 162 | ## The macro %HOST% string will be substituted with the value of the "@host" 163 | ## target in the replication set. 164 | ## The default WITH a "@host" option is "ssh %HOST% zfs receive -vFd" 165 | ## The default WITHOUT a "@host" option is "zfs receive -vFd". 166 | ## 167 | #DEST_PIPE_WITH_HOST="$SSH %HOST% $ZFS receive -vFd" 168 | #DEST_PIPE_WITHOUT_HOST="$ZFS receive -vFd" 169 | 170 | ## Command to check the health of a source or destination host. 171 | ## A return code of 0 is considered OK/available. 172 | ## This is only used when a replicate set contains an "@host" option. 173 | ## The macro string "%HOST%" will be substituted with the value of 174 | ## the "@host" target in the replicate set. 175 | ## The default command is "ping -c1 -q -W2 %HOST%". 176 | ## 177 | #HOST_CHECK="ping -c1 -q -W2 %HOST%" 178 | -------------------------------------------------------------------------------- /test/find.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | ## test/zfs.sh 3 | set -eu 4 | 5 | # check pipefail in a subshell and set if supported 6 | # shellcheck disable=SC3040 7 | (set -o pipefail 2> /dev/null) && set -o pipefail 8 | 9 | _fakeFIND() { 10 | path="$1" 11 | printf "%s/autorep-test1.log\n" "$path" 12 | printf "%s/autorep-test2.log\n" "$path" 13 | printf "%s/autorep-test3.log\n" "$path" 14 | return 0 15 | } 16 | 17 | _fakeFIND "$@" 18 | -------------------------------------------------------------------------------- /test/ssh.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | ## test/zfs.sh 3 | set -eu 4 | 5 | # check pipefail in a subshell and set if supported 6 | # shellcheck disable=SC3040 7 | (set -o pipefail 2> /dev/null) && set -o pipefail 8 | 9 | _fakeSSH() { 10 | host=$1 11 | shift 12 | cmd=$1 13 | shift 14 | case "$cmd" in 15 | *zfs*) 16 | ./zfs.sh "$@" 17 | ;; 18 | *) 19 | printf "ssh $host $cmd %s\n" "$*" 20 | ;; 21 | esac 22 | return 0 23 | } 24 | 25 | _fakeSSH "$@" 26 | -------------------------------------------------------------------------------- /test/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # shellcheck disable=SC2030,SC2031,SC2034 3 | ## ^^ tests are intentionally run in subshells 4 | ## variables that appear unused here are used by main script 5 | 6 | ## test.sh contains zfs-replicate test cases 7 | set -eu ## fail on errors and undefined variables 8 | 9 | # check pipefail in a subshell and set if supported 10 | # shellcheck disable=SC3040 11 | (set -o pipefail 2> /dev/null) && set -o pipefail 12 | 13 | ## set self identification values 14 | SCRIPT_PATH="${0%/*}" 15 | 16 | ## check line against match and exit on failure 17 | _fail() { 18 | line=$1 19 | match=$2 20 | ## hack to match blank lines 21 | if [ "$match" = "null" ]; then 22 | if [ -n "$line" ]; then 23 | printf "FAILED '%s' != ''\n" "$line" && exit 1 24 | fi 25 | return 0 26 | fi 27 | case "$line" in 28 | *"$match"*) ;; 29 | *) printf "FAILED '%s' != '*%s*'\n" "$line" "$match" && exit 1 ;; 30 | esac 31 | return 0 32 | } 33 | 34 | _testZFSReplicate() { 35 | ## wrapper for easy matching 36 | ECHO="echo" 37 | ## disable syslog for tests 38 | SYSLOG=0 39 | 40 | ## test loadConfig without error 41 | ( 42 | FIND="fakeFIND" 43 | ZFS="fakeZFS" 44 | SSH="fakeSSH" 45 | REPLICATE_SETS="fakeSource:fakeDest" 46 | # shellcheck source=/dev/null 47 | . ../zfs-replicate.sh 48 | printf "_testZFSReplicate/loadConfigWithoutError\n" 49 | lines=$(loadConfig 2>&1) 50 | _fail "$lines" "null" ## we expect no output here 51 | ) 52 | 53 | ## test loadConfig with missing values 54 | ( 55 | FIND="fakeFIND" 56 | ZFS="fakeZFS" 57 | SSH="fakeSSH" 58 | # shellcheck source=/dev/null 59 | . ../zfs-replicate.sh 60 | printf "_testZFSReplicate/loadConfigWithError\n" 61 | ! lines=$(loadConfig 2>&1) && true ## prevent tests from exiting 62 | _fail "$lines" "missing required setting REPLICATE_SETS" 63 | ) 64 | 65 | ## test config override of script defaults 66 | ( 67 | ## generic default values 68 | FIND="fakeFIND" 69 | ZFS="fakeZFS" 70 | SSH="fakeSSH" 71 | REPLICATE_SETS="fakeSource:fakeDest" 72 | # shellcheck source=/dev/null 73 | . ../zfs-replicate.sh 74 | printf "_testZFSReplicate/loadConfigOverrideDefaults\n" 75 | _fail "fakeSSH %HOST% /sbin/zfs receive -vFd" "$DEST_PIPE_WITH_HOST" 76 | _fail "fakeZFS receive -vFd" "$DEST_PIPE_WITHOUT_HOST" 77 | ## generate config 78 | config="$(mktemp)" 79 | printf "ZFS=\"myZFS\"\n" >> "$config" 80 | ## set SSH via environment 81 | SSH="mySSH" 82 | loadConfig "$config" 2>&1 && rm -f "$config" 83 | ## values should match config and environment 84 | _fail "mySSH %HOST% myZFS receive -vFd" "$DEST_PIPE_WITH_HOST" 85 | _fail "myZFS receive -vFd" "$DEST_PIPE_WITHOUT_HOST" 86 | ) 87 | 88 | ## test loadConfig with options 89 | ( 90 | FIND="${SCRIPT_PATH}/find.sh" 91 | ZFS="fakeZFS" 92 | SSH="fakeSSH" 93 | LOG_BASE="$(mktemp -d)" 94 | # shellcheck source=/dev/null 95 | . ../zfs-replicate.sh 96 | ## test --help and -h 97 | printf "_testZFSReplicate/loadConfigWithHelp\n" 98 | ! lines=$(loadConfig "--help" 2>&1) && true ## prevent tests from exiting 99 | _fail "$lines" "Usage: test.sh" 100 | ! lines=$(loadConfig "-h" 2>&1) && true ## prevent tests from exiting 101 | _fail "$lines" "Usage: test.sh" 102 | ## test --status and -s 103 | printf "_testZFSReplicate/loadConfigWithStatus\n" 104 | ## generate fake log files with staggered creation time 105 | for idx in $(seq 1 3); do 106 | printf "testing log %d\n" "$idx" > "${LOG_BASE}/autorep-test${idx}.log" && sleep 1 107 | done 108 | ## check status command 109 | ! lines=$(loadConfig "--status" 2>&1) && true ## prevent tests from exiting 110 | _fail "$lines" "testing log 3" 111 | ! lines=$(loadConfig "-s" 2>&1) && true ## prevent tests from exiting 112 | _fail "$lines" "testing log 3" 113 | ## cleanup 114 | rm -rvf "${LOG_BASE}" 115 | ) 116 | 117 | ## test snapCreate with different set combinations 118 | ( 119 | ## configure test parameters 120 | FIND="fakeFIND" 121 | ZFS="${SCRIPT_PATH}/zfs.sh" 122 | SSH="${SCRIPT_PATH}/ssh.sh" 123 | HOST_CHECK="${ECHO} %HOST%" 124 | REPLICATE_SETS="srcPool0/srcFS0:dstPool0/dstFS0" 125 | REPLICATE_SETS="${REPLICATE_SETS} srcPool1/srcFS1/subFS1:dstPool1/dstFS1@dstHost1" 126 | REPLICATE_SETS="${REPLICATE_SETS} srcPool2/srcFS2:dstPool2/dstFS2@dstHost2" 127 | REPLICATE_SETS="${REPLICATE_SETS} srcPool3/srcFS3@srcHost3:dstPool3/dstFS3" 128 | REPLICATE_SETS="${REPLICATE_SETS} srcPool4/srcFS4@srcHost4:dstPool4/dstFS4@dstHost4" 129 | # shellcheck source=/dev/null 130 | . ../zfs-replicate.sh && loadConfig 131 | printf "_testZFSReplicate/snapCreateWithoutErrors\n" 132 | idx=0 133 | snapCreate 2>&1 | while IFS= read -r line; do 134 | match="" 135 | printf "%d %s\n" "$idx" "$line" 136 | case $idx in 137 | 0) 138 | match="creating lockfile ${TMPDIR}/.replicate.snapshot.lock" 139 | ;; 140 | 1) 141 | match="cmd=${ZFS} list -H -o name srcPool0/srcFS0" 142 | ;; 143 | 3) 144 | match="cmd=${ZFS} list -H -o name dstPool0/dstFS0" 145 | ;; 146 | 5) 147 | match="cmd=${ZFS} list -Hr -o name -s creation -t snapshot -d 1 srcPool0/srcFS0" 148 | ;; 149 | 6) 150 | match="cmd=${ZFS} list -Hr -o name -s creation -t snapshot dstPool0/dstFS0" 151 | ;; 152 | 8) 153 | match="cmd=${ZFS} destroy srcPool0/srcFS0@autorep-test1" 154 | ;; 155 | 9) 156 | match="cmd=${ZFS} snapshot srcPool0/srcFS0@autorep-" 157 | ;; 158 | 10) 159 | match="creating lockfile ${TMPDIR}/.replicate.send.lock" 160 | ;; 161 | 11) 162 | match="cmd=${ZFS} send -Rs -I srcPool0/srcFS0@autorep-test3 srcPool0/srcFS0@autorep-${TAG} |" 163 | match="$match ${DEST_PIPE_WITHOUT_HOST} dstPool0/dstFS0" 164 | ;; 165 | 12) 166 | match="receive -vFd dstPool0/dstFS0" 167 | ;; 168 | 13) 169 | match="deleting lockfile ${TMPDIR}/.replicate.send.lock" 170 | ;; 171 | 14) 172 | match="cmd=${ECHO} dstHost1" 173 | ;; 174 | 15) 175 | match="cmd=${ZFS} list -H -o name srcPool1/srcFS1/subFS1" 176 | ;; 177 | 17) 178 | match="cmd=${SSH} dstHost1 ${ZFS} list -H -o name dstPool1/dstFS1" 179 | ;; 180 | 19) 181 | match="cmd=${ZFS} list -Hr -o name -s creation -t snapshot -d 1 srcPool1/srcFS1/subFS1" 182 | ;; 183 | 20) 184 | match="cmd=${SSH} dstHost1 ${ZFS} list -Hr -o name -s creation -t snapshot dstPool1/dstFS1" 185 | ;; 186 | 22) 187 | match="cmd=${ZFS} destroy srcPool1/srcFS1/subFS1@autorep-test1" 188 | ;; 189 | 23) 190 | match="cmd=${ZFS} snapshot srcPool1/srcFS1/subFS1@autorep-${TAG}" 191 | ;; 192 | 24) 193 | match="creating lockfile ${TMPDIR}/.replicate.send.lock" 194 | ;; 195 | 25) 196 | match="cmd=${ZFS} send -Rs -I srcPool1/srcFS1/subFS1@autorep-test3 srcPool1/srcFS1/subFS1@autorep-${TAG} |" 197 | match="$match ${SSH} dstHost1 ${ZFS} receive -vFd dstPool1/dstFS1" 198 | ;; 199 | 27) 200 | match="deleting lockfile ${TMPDIR}/.replicate.send.lock" 201 | ;; 202 | 28) 203 | match="cmd=${ECHO} dstHost2" 204 | ;; 205 | 29) 206 | match="cmd=${ZFS} list -H -o name srcPool2/srcFS2" 207 | ;; 208 | 31) 209 | match="cmd=${SSH} dstHost2 ${ZFS} list -H -o name dstPool2/dstFS2" 210 | ;; 211 | 33) 212 | match="cmd=${ZFS} list -Hr -o name -s creation -t snapshot -d 1 srcPool2/srcFS2" 213 | ;; 214 | 34) 215 | match="cmd=${SSH} dstHost2 ${ZFS} list -Hr -o name -s creation -t snapshot dstPool2/dstFS2" 216 | ;; 217 | 36) 218 | match="cmd=${ZFS} destroy srcPool2/srcFS2@autorep-test1" 219 | ;; 220 | 37) 221 | match="cmd=${ZFS} snapshot srcPool2/srcFS2@autorep-${TAG}" 222 | ;; 223 | 38) 224 | match="creating lockfile ${TMPDIR}/.replicate.send.lock" 225 | ;; 226 | 39) 227 | match="cmd=${ZFS} send -Rs -I srcPool2/srcFS2@autorep-test3 srcPool2/srcFS2@autorep-${TAG} |" 228 | match="$match ${SSH} dstHost2 ${ZFS} receive -vFd dstPool2/dstFS2" 229 | ;; 230 | 41) 231 | match="deleting lockfile ${TMPDIR}/.replicate.send.lock" 232 | ;; 233 | 42) 234 | match="cmd=${ECHO} srcHost3" 235 | ;; 236 | 43) 237 | match=" cmd=${SSH} srcHost3 ${ZFS} list -H -o name srcPool3/srcFS3" 238 | ;; 239 | 45) 240 | match="cmd=${ZFS} list -H -o name dstPool3/dstFS3" 241 | ;; 242 | 47) 243 | match="cmd=${SSH} srcHost3 ${ZFS} list -Hr -o name -s creation -t snapshot -d 1 srcPool3/srcFS3" 244 | ;; 245 | 48) 246 | match="cmd=${ZFS} list -Hr -o name -s creation -t snapshot dstPool3/dstFS3" 247 | ;; 248 | 50) 249 | match="cmd=${SSH} srcHost3 ${ZFS} destroy srcPool3/srcFS3@autorep-test1" 250 | ;; 251 | 51) 252 | match="cmd=${SSH} srcHost3 ${ZFS} snapshot srcPool3/srcFS3@autorep-${TAG}" 253 | ;; 254 | 52) 255 | match="creating lockfile ${TMPDIR}/.replicate.send.lock" 256 | ;; 257 | 53) 258 | match="cmd=${SSH} srcHost3 ${ZFS} send -Rs -I srcPool3/srcFS3@autorep-test3 srcPool3/srcFS3@autorep-${TAG} |" 259 | match="$match ${ZFS} receive -vFd dstPool3/dstFS3" 260 | ;; 261 | 55) 262 | match="deleting lockfile ${TMPDIR}/.replicate.send.lock" 263 | ;; 264 | 56) 265 | match="cmd=${ECHO} srcHost4" 266 | ;; 267 | 57) 268 | match="cmd=${ECHO} dstHost4" 269 | ;; 270 | 58) 271 | match="cmd=${SSH} srcHost4 ${ZFS} list -H -o name srcPool4/srcFS4" 272 | ;; 273 | 60) 274 | match="cmd=${SSH} dstHost4 ${ZFS} list -H -o name dstPool4/dstFS4" 275 | ;; 276 | 62) 277 | match="cmd=${SSH} srcHost4 ${ZFS} list -Hr -o name -s creation -t snapshot -d 1 srcPool4/srcFS4" 278 | ;; 279 | 63) 280 | match="cmd=${SSH} dstHost4 ${ZFS} list -Hr -o name -s creation -t snapshot dstPool4/dstFS4" 281 | ;; 282 | 65) 283 | match="cmd=${SSH} srcHost4 ${ZFS} destroy srcPool4/srcFS4@autorep-test1" 284 | ;; 285 | 66) 286 | match="cmd=${SSH} srcHost4 ${ZFS} snapshot srcPool4/srcFS4@autorep-${TAG}" 287 | ;; 288 | 67) 289 | match="creating lockfile ${TMPDIR}/.replicate.send.lock" 290 | ;; 291 | 68) 292 | match="cmd=${SSH} srcHost4 ${ZFS} send -Rs -I srcPool4/srcFS4@autorep-test3 srcPool4/srcFS4@autorep-${TAG} |" 293 | match="$match ${SSH} dstHost4 ${ZFS} receive -vFd dstPool4/dstFS4" 294 | ;; 295 | 70) 296 | match="deleting lockfile ${TMPDIR}/.replicate.send.lock" 297 | ;; 298 | 71) 299 | match="deleting lockfile ${TMPDIR}/.replicate.snapshot.lock" 300 | ;; 301 | esac 302 | _fail "$line" "$match" 303 | idx=$((idx + 1)) 304 | done 305 | ) 306 | 307 | ## test snapCreate with host check errors 308 | ( 309 | ## configure test parameters 310 | FIND="fakeFIND" 311 | ZFS="${SCRIPT_PATH}/zfs.sh" 312 | SSH="${SCRIPT_PATH}/ssh.sh" 313 | HOST_CHECK="false" 314 | REPLICATE_SETS="srcPool0/srcFS0:dstPool0/dstFS0" 315 | REPLICATE_SETS="${REPLICATE_SETS} srcPool1/srcFS1/subFS1:dstPool1/dstFS1@dstHost1" 316 | REPLICATE_SETS="${REPLICATE_SETS} srcPool2/srcFS2:dstPool2/dstFS2@dstHost2" 317 | REPLICATE_SETS="${REPLICATE_SETS} srcPool3/srcFS3@srcHost3:dstPool3/dstFS3" 318 | REPLICATE_SETS="${REPLICATE_SETS} srcPool4/srcFS4@srcHost4:dstPool4/dstFS4@dstHost4" 319 | # shellcheck source=/dev/null 320 | . ../zfs-replicate.sh && loadConfig 321 | printf "_testZFSReplicate/snapCreateWithHostCheckErrors\n" 322 | idx=0 323 | snapCreate 2>&1 | while IFS= read -r line; do 324 | match="" 325 | printf "%d %s\n" "$idx" "$line" 326 | case $idx in 327 | 0) 328 | match="creating lockfile ${TMPDIR}/.replicate.snapshot.lock" 329 | ;; 330 | 15) 331 | match="source or destination host check failed" 332 | ;; 333 | 17) 334 | match="source or destination host check failed" 335 | ;; 336 | 19) 337 | match="source or destination host check failed" 338 | ;; 339 | 21) 340 | match="source or destination host check failed" 341 | ;; 342 | 22) 343 | match="deleting lockfile ${TMPDIR}/.replicate.snapshot.lock" 344 | ;; 345 | esac 346 | _fail "$line" "$match" 347 | idx=$((idx + 1)) 348 | done 349 | ) 350 | 351 | ## test snapCreate with dataset check errors 352 | ( 353 | ## configure test parameters 354 | FIND="fakeFIND" 355 | ZFS="${SCRIPT_PATH}/zfs.sh" 356 | SSH="${SCRIPT_PATH}/ssh.sh" 357 | HOST_CHECK="${ECHO} %HOST%" 358 | REPLICATE_SETS="failPool0/srcFS0:dstPool0/dstFS0" 359 | REPLICATE_SETS="${REPLICATE_SETS} srcPool1/srcFS1:failPool1/dstFS1@dstHost1" 360 | REPLICATE_SETS="${REPLICATE_SETS} failPool2/srcFS2@srcHost2:dstPool2/dstFS2" 361 | # shellcheck source=/dev/null 362 | . ../zfs-replicate.sh && loadConfig 363 | printf "_testZFSReplicate/snapCreateWithDatasetCheckErrors\n" 364 | idx=0 365 | snapCreate 2>&1 | while IFS= read -r line; do 366 | match="" 367 | printf "%d %s\n" "$idx" "$line" 368 | case $idx in 369 | 0) 370 | match="creating lockfile ${TMPDIR}/.replicate.snapshot.lock" 371 | ;; 372 | 1) 373 | match="cmd=${ZFS} list -H -o name failPool0/srcFS0" 374 | ;; 375 | 2) 376 | match="dataset does not exist" 377 | ;; 378 | 3) 379 | match="source or destination dataset check failed" 380 | ;; 381 | 5) 382 | match="cmd=${ZFS} list -H -o name srcPool1/srcFS1" 383 | ;; 384 | 6) 385 | match="srcPool1/srcFS1" 386 | ;; 387 | 7) 388 | match="cmd=${SSH} dstHost1 ${ZFS} list -H -o name failPool1/dstFS1" 389 | ;; 390 | 8) 391 | match="dataset does not exist" 392 | ;; 393 | 9) 394 | match="source or destination dataset check failed" 395 | ;; 396 | 11) 397 | match="cmd=${SSH} srcHost2 ${ZFS} list -H -o name failPool2/srcFS2" 398 | ;; 399 | 12) 400 | match="dataset does not exist" 401 | ;; 402 | 13) 403 | match="source or destination dataset check failed" 404 | ;; 405 | 14) 406 | match="deleting lockfile ${TMPDIR}/.replicate.snapshot.lock" 407 | ;; 408 | esac 409 | _fail "$line" "$match" 410 | idx=$((idx + 1)) 411 | done 412 | ) 413 | 414 | ## test exitClean code=0 and extra message 415 | ( 416 | FIND="fakeFIND" 417 | ZFS="fakeZFS" 418 | SSH="fakeSSH" 419 | REPLICATE_SETS="fakeSource:fakeDest" 420 | ## source script functions 421 | # shellcheck source=/dev/null 422 | . ../zfs-replicate.sh && loadConfig 423 | printf "_testZFSReplicate/exitCleanSuccess\n" 424 | lines=$(exitClean 0 "test message" 2>&1) 425 | match="success total sets 0 skipped 0: test message" ## counts are modified in snapCreate 426 | _fail "$lines" "$match" 427 | ) 428 | 429 | ## test exitClean code=99 with error message 430 | ( 431 | FIND="fakeFIND" 432 | ZFS="fakeZFS" 433 | SSH="fakeSSH" 434 | REPLICATE_SETS="fakeSource:fakeDest" 435 | ## source script functions 436 | # shellcheck source=/dev/null 437 | . ../zfs-replicate.sh && loadConfig 438 | printf "_testZFSReplicate/exitCleanError\n" 439 | ! lines=$(exitClean 99 "error message" 2>&1) && true ## prevent tests from exiting 440 | match="operation exited unexpectedly: code=99 msg=error message" 441 | _fail "$lines" "$match" 442 | ) 443 | 444 | ## yay, tests completed! 445 | printf "Tests Complete: No Error!\n" 446 | return 0 447 | } 448 | 449 | _testZFSReplicate 450 | -------------------------------------------------------------------------------- /test/zfs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | ## test/zfs.sh 3 | set -eu 4 | 5 | # check pipefail in a subshell and set if supported 6 | # shellcheck disable=SC3040 7 | (set -o pipefail 2> /dev/null) && set -o pipefail 8 | 9 | _fakeZFS() { 10 | cmd=$1 11 | shift 12 | showSnaps=0 13 | 14 | ## check arguments 15 | for arg in "$@"; do 16 | case "$arg" in 17 | -H) 18 | ## nothing for now 19 | ;; 20 | -o) 21 | ## nothing for now 22 | ;; 23 | -t) 24 | ## assume snapshots for tests 25 | showSnaps=1 26 | ;; 27 | esac 28 | ## cheap way to get the last arg 29 | target=$arg 30 | done 31 | 32 | case "$cmd" in 33 | list) 34 | if [ $showSnaps -eq 1 ]; then 35 | printf "%s@autorep-test1\n" "$target" 36 | printf "%s@autorep-test2\n" "$target" 37 | printf "%s@autorep-test3\n" "$target" 38 | return 0 39 | fi 40 | ## allow selective failures in tests 41 | if [ "$(expr "$target" : 'fail')" -gt 0 ]; then 42 | printf "cannot open '%s': dataset does not exist\n" "$target" 43 | return 1 44 | fi 45 | ## just print target 46 | printf "%s\n" "$target" 47 | ;; 48 | receive) 49 | sleep 2 ## simulate transfer wait 50 | printf "%s %s\n" "$cmd" "$*" 51 | ;; 52 | destroy | snapshot | send) ;; 53 | *) 54 | printf "%s %s\n" "$cmd" "$*" 55 | ;; 56 | esac 57 | return 0 58 | } 59 | 60 | _fakeZFS "$@" 61 | -------------------------------------------------------------------------------- /zfs-replicate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | ## zfs-replicate.sh 3 | set -eu ## fail on errors and undefined variables 4 | 5 | # check pipefail in a subshell and set if supported 6 | # shellcheck disable=SC3040 7 | (set -o pipefail 2> /dev/null) && set -o pipefail 8 | 9 | ## set self identification values 10 | readonly SCRIPT="${0##*/}" 11 | readonly SCRIPT_PATH="${0%/*}" 12 | 13 | ## set date substitutions for macros 14 | __DOW=$(date "+%a") 15 | readonly __DOW 16 | __DOM=$(date "+%d") 17 | readonly __DOM 18 | __MOY=$(date "+%m") 19 | readonly __MOY 20 | __CYR=$(date "+%Y") 21 | readonly __CYR 22 | __NOW=$(date "+%s") 23 | readonly __NOW 24 | 25 | ## init configuration with values from environment or set defaults 26 | REPLICATE_SETS=${REPLICATE_SETS:-""} ## default empty 27 | ALLOW_ROOT_DATASETS="${ALLOW_ROOT_DATASETS:-0}" 28 | ALLOW_RECONCILIATION="${ALLOW_RECONCILIATION:-0}" 29 | RECURSE_CHILDREN="${RECURSE_CHILDREN:-0}" 30 | SNAP_KEEP="${SNAP_KEEP:-2}" 31 | SYSLOG="${SYSLOG:-1}" 32 | SYSLOG_FACILITY="${SYSLOG_FACILITY:-"user"}" 33 | TAG="${TAG:-"%MOY%%DOM%%CYR%_%NOW%"}" 34 | LOG_FILE="${LOG_FILE:-"autorep-%TAG%.log"}" 35 | LOG_KEEP="${LOG_KEEP:-5}" 36 | LOG_BASE=${LOG_BASE:-""} ## default empty 37 | LOGGER="${LOGGER:-$(which logger || true)}" 38 | FIND="${FIND:-$(which find || true)}" 39 | SSH="${SSH:-$(which ssh || true)}" 40 | ZFS="${ZFS:-$(which zfs || true)}" 41 | HOST_CHECK="${HOST_CHECK:-"ping -c1 -q -W2 %HOST%"}" 42 | ## we default these after config is loaded 43 | DEST_PIPE_WITH_HOST= 44 | DEST_PIPE_WITHOUT_HOST= 45 | ## temp path used for lock files 46 | TMPDIR="${TMPDIR:-"/tmp"}" 47 | ## init values used in snapCreate and exitClean 48 | __PAIR_COUNT=0 49 | __SKIP_COUNT=0 50 | 51 | ## output log files in decreasing age order 52 | sortLogs() { 53 | ## check if file logging is enabled 54 | if [ -z "$LOG_BASE" ] || [ ! -d "$LOG_BASE" ]; then 55 | return 0 56 | fi 57 | ## find existing logs 58 | logs=$($FIND "$LOG_BASE" -maxdepth 1 -type f -name 'autorep-*') 59 | ## get file change time via stat (platform specific) 60 | if [ "$(uname -s)" = "Linux" ] || [ "$(uname -s)" = "SunOS" ]; then 61 | fstat='stat -c %Z' 62 | else 63 | fstat='stat -f %c' 64 | fi 65 | ## output logs in descending age order 66 | for log in $logs; do 67 | printf "%s\t%s\n" "$($fstat "$log")" "$log" 68 | done | sort -rn | cut -f2 69 | } 70 | 71 | ## check log count and delete old logs 72 | pruneLogs() { 73 | logs=$(sortLogs) 74 | logCount=0 75 | if [ -n "$logs" ]; then 76 | logCount=$(printf "%s" "$logs" | wc -l) 77 | fi 78 | if [ "$logCount" -gt "$LOG_KEEP" ]; then 79 | prune="$(printf "%s\n" "$logs" | sed -n "$((LOG_KEEP + 1)),\$p")" 80 | printf "pruning %d logs\n" "$((logCount - LOG_KEEP + 1))" 1>&2 81 | printf "%s\n" "$prune" | xargs rm -vf 82 | fi 83 | } 84 | 85 | ## delete lock files 86 | clearLock() { 87 | lockFile=$1 88 | if [ -f "$lockFile" ]; then 89 | printf "deleting lockfile %s\n" "$lockFile" 1>&2 90 | rm "$lockFile" 91 | fi 92 | } 93 | 94 | ## exit and cleanup 95 | exitClean() { 96 | exitCode=${1:-0} 97 | extraMsg=${2:-""} 98 | status="success" 99 | ## set status to warning if we skipped any datasets 100 | if [ "$__SKIP_COUNT" -gt 0 ]; then 101 | status="WARNING" 102 | fi 103 | logMsg=$(printf "%s total sets %d skipped %d" "$status" "$__PAIR_COUNT" "$__SKIP_COUNT") 104 | ## build and print error message 105 | if [ "$exitCode" -ne 0 ]; then 106 | status="ERROR" 107 | logMsg=$(printf "%s: operation exited unexpectedly: code=%d" "$status" "$exitCode") 108 | if [ -n "$extraMsg" ]; then 109 | logMsg=$(printf "%s msg=%s" "$logMsg" "$extraMsg") 110 | fi 111 | fi 112 | ## append extra message if available 113 | if [ "$exitCode" -eq 0 ] && [ -n "$extraMsg" ]; then 114 | logMsg=$(printf "%s: %s" "$logMsg" "$extraMsg") 115 | fi 116 | ## cleanup old logs and clear locks 117 | pruneLogs 118 | clearLock "${TMPDIR}/.replicate.snapshot.lock" 119 | clearLock "${TMPDIR}/.replicate.send.lock" 120 | ## print log message and exit 121 | printf "%s\n" "$logMsg" 1>&2 122 | exit "$exitCode" 123 | } 124 | 125 | ## lockfile creation and maintenance 126 | checkLock() { 127 | lockFile=$1 128 | ## check our lockfile status 129 | if [ -f "$lockFile" ]; then 130 | ## see if this pid is still running 131 | if ps -p "$(cat "$lockFile")" > /dev/null 2>&1; then 132 | ## looks like it's still running 133 | printf "ERROR: script is already running as: %d\n" "$(cat "$lockFile")" 1>&2 134 | else 135 | ## stale lock file? 136 | printf "ERROR: stale lockfile %s\n" "$lockFile" 1>&2 137 | fi 138 | ## cleanup and exit 139 | exitClean 128 "confirm script is not running and delete lockfile $lockFile" 140 | fi 141 | ## well no lockfile..let's make a new one 142 | printf "creating lockfile %s\n" "$lockFile" 1>&2 143 | printf "%d\n" "$$" > "$lockFile" 144 | } 145 | 146 | ## check remote host status 147 | checkHost() { 148 | ## do we have a host check defined 149 | if [ -z "$HOST_CHECK" ]; then 150 | return 0 151 | fi 152 | host=$1 153 | if [ -z "$host" ]; then 154 | return 0 155 | fi 156 | cmd=$(printf "%s\n" "$HOST_CHECK" | sed "s/%HOST%/$host/g") 157 | printf "checking host cmd=%s\n" "$cmd" 2>&1 158 | ## run the check 159 | if ! $cmd > /dev/null 2>&1; then 160 | return 1 161 | fi 162 | return 0 163 | } 164 | 165 | ## ensure dataset exists 166 | checkDataset() { 167 | set=$1 168 | host=$2 169 | cmd="" 170 | ## build command 171 | if [ -n "$host" ]; then 172 | cmd="$SSH $host " 173 | fi 174 | cmd="$cmd$ZFS list -H -o name $set" 175 | printf "checking dataset cmd=%s\n" "$cmd" 1>&2 176 | ## execute command 177 | if ! $cmd; then 178 | return 1 179 | fi 180 | return 0 181 | } 182 | 183 | ## small wrapper around zfs destroy 184 | snapDestroy() { 185 | snap=$1 186 | host=$2 187 | cmd="" 188 | ## build command 189 | if [ -n "$host" ]; then 190 | cmd="$SSH $host " 191 | fi 192 | cmd="$cmd$ZFS destroy" 193 | if [ "$RECURSE_CHILDREN" -eq 1 ]; then 194 | cmd="$cmd -r" 195 | fi 196 | cmd="$cmd $snap" 197 | printf "destroying snapshot cmd=%s\n" "$cmd" 1>&2 198 | ## ignore error from destroy and count on logging to alert the end-user 199 | ## destroying recursive snapshots can lead to "snapshot not found" errors 200 | $cmd || true 201 | } 202 | 203 | ## main replication function 204 | snapSend() { 205 | base=$1 206 | snap=$2 207 | src=$3 208 | srcHost=$4 209 | dst=$5 210 | dstHost=$6 211 | ## check our send lockfile 212 | checkLock "${TMPDIR}/.replicate.send.lock" 213 | ## begin building send command 214 | cmd="" 215 | if [ -n "$srcHost" ]; then 216 | cmd="$SSH $srcHost " 217 | fi 218 | cmd="$cmd$ZFS send -Rs" 219 | ## if first snap name is not empty generate an incremental 220 | if [ -n "$base" ]; then 221 | cmd="$cmd -I $base" 222 | fi 223 | cmd="$cmd ${src}@${snap}" 224 | ## set destination pipe based on destination host 225 | pipe="$DEST_PIPE_WITHOUT_HOST" 226 | if [ -n "$dstHost" ]; then 227 | pipe=$(printf "%s\n" "$DEST_PIPE_WITH_HOST" | sed "s/%HOST%/$dstHost/g") 228 | fi 229 | pipe="$pipe $dst" 230 | printf "sending snapshot cmd=%s | %s\n" "$cmd" "$pipe" 1>&2 231 | ## execute send and check return 232 | if ! $cmd | $pipe; then 233 | snapDestroy "${src}@${name}" "$srcHost" 234 | exitClean 128 "failed to send snapshot: ${src}@${name}" 235 | fi 236 | ## clear lockfile 237 | clearLock "${TMPDIR}/.replicate.send.lock" 238 | } 239 | 240 | ## list replication snapshots 241 | snapList() { 242 | set=$1 243 | host=$2 244 | depth=$3 245 | cmd="" 246 | ## build send command 247 | if [ -n "$host" ]; then 248 | cmd="$SSH $host " 249 | fi 250 | cmd="$cmd$ZFS list -Hr -o name -s creation -t snapshot" 251 | if [ "$depth" -gt 0 ]; then 252 | cmd="$cmd -d $depth" 253 | fi 254 | cmd="$cmd $set" 255 | printf "listing snapshots cmd=%s\n" "$cmd" 1>&2 256 | ## get snapshots from host 257 | if ! snaps=$($cmd); then 258 | exitClean 128 "failed to list snapshots for dataset: $set" 259 | fi 260 | ## filter snaps matching our pattern 261 | printf "%s\n" "$snaps" | grep "@autorep-" || true 262 | } 263 | 264 | ## create and manage source snapshots 265 | snapCreate() { 266 | ## make sure we aren't ever creating simultaneous snapshots 267 | checkLock "${TMPDIR}/.replicate.snapshot.lock" 268 | ## set our snap name 269 | name="autorep-${TAG}" 270 | ## generate snapshot list and cleanup old snapshots 271 | for pair in $REPLICATE_SETS; do 272 | __PAIR_COUNT=$((__PAIR_COUNT + 1)) 273 | ## split dataset into source and destination parts and trim any trailing space 274 | src=$(printf "%s\n" "$pair" | cut -f1 -d: | sed 's/[[:space:]]*$//') 275 | dst=$(printf "%s\n" "$pair" | cut -f2 -d: | sed 's/[[:space:]]*$//') 276 | ## check for root dataset destination 277 | if [ "$ALLOW_ROOT_DATASETS" -ne 1 ]; then 278 | if [ "$dst" = "$(basename "$dst")" ] || [ "$dst" = "$(basename "$dst")/" ]; then 279 | temps="replicating root datasets can lead to data loss - set ALLOW_ROOT_DATASETS=1 to override" 280 | printf "WARNING: skipping replication set '%s' - %s\n" "$pair" "$temps" 1>&2 281 | __SKIP_COUNT=$((__SKIP_COUNT + 1)) 282 | continue 283 | fi 284 | fi 285 | ## init source and destination host in each loop iteration 286 | srcHost="" 287 | dstHost="" 288 | ## look for source host option 289 | if [ "${src#*"@"}" != "$src" ]; then 290 | srcHost=$(printf "%s\n" "$src" | cut -f2 -d@) 291 | src=$(printf "%s\n" "$src" | cut -f1 -d@) 292 | fi 293 | ## look for destination host option 294 | if [ "${dst#*"@"}" != "$dst" ]; then 295 | dstHost=$(printf "%s\n" "$dst" | cut -f2 -d@) 296 | dst=$(printf "%s\n" "$dst" | cut -f1 -d@) 297 | fi 298 | ## check source and destination hosts 299 | if ! checkHost "$srcHost" || ! checkHost "$dstHost"; then 300 | printf "WARNING: skipping replication set '%s' - source or destination host check failed\n" "$pair" 1>&2 301 | __SKIP_COUNT=$((__SKIP_COUNT + 1)) 302 | continue 303 | fi 304 | ## check source and destination datasets 305 | if ! checkDataset "$src" "$srcHost" || ! checkDataset "$dst" "$dstHost"; then 306 | printf "WARNING: skipping replication set '%s' - source or destination dataset check failed\n" "$pair" 1>&2 307 | __SKIP_COUNT=$((__SKIP_COUNT + 1)) 308 | continue 309 | fi 310 | ## get source and destination snapshots 311 | srcSnaps=$(snapList "$src" "$srcHost" 1) 312 | dstSnaps=$(snapList "$dst" "$dstHost" 0) 313 | for snap in $srcSnaps; do 314 | ## while we are here...check for our current snap name 315 | if [ "$snap" = "${src}@${name}" ]; then 316 | ## looks like it's here...we better kill it 317 | printf "destroying duplicate snapshot: %s@%s\n" "$src" "$name" 1>&2 318 | snapDestroy "${src}@${name}" "$srcHost" 319 | fi 320 | done 321 | ## get source and destination snap count 322 | srcSnapCount=0 323 | dstSnapCount=0 324 | if [ -n "$srcSnaps" ]; then 325 | srcSnapCount=$(printf "%s\n" "$srcSnaps" | wc -l) 326 | fi 327 | if [ -n "$dstSnaps" ]; then 328 | dstSnapCount=$(printf "%s\n" "$dstSnaps" | wc -l) 329 | fi 330 | ## set our base snap for incremental generation if src contains a sufficient 331 | ## number of snapshots and the base source snapshot exists in destination dataset 332 | base="" 333 | if [ "$srcSnapCount" -ge 1 ] && [ "$dstSnapCount" -ge 1 ]; then 334 | ## get most recent source snapshot 335 | ss=$(printf "%s\n" "$srcSnaps" | tail -n 1) 336 | ## get source snapshot name 337 | sn=$(printf "%s\n" "$ss" | cut -f2 -d@) 338 | ## loop over destinations snaps and look for a match 339 | for ds in $dstSnaps; do 340 | dn=$(printf "%s\n" "$ds" | cut -f2 -d@) 341 | if [ "$dn" = "$sn" ]; then 342 | base="$ss" 343 | break 344 | fi 345 | done 346 | ## no matching base, are we allowed to fallback? 347 | if [ -z "$base" ] && [ "$ALLOW_RECONCILIATION" -ne 1 ]; then 348 | temps=$(printf "source snapshot '%s' not in destination dataset: %s" "$ss" "$dst") 349 | temps=$(printf "%s - set 'ALLOW_RECONCILIATION=1' to fallback to a full send" "$temps") 350 | printf "WARNING: skipping replication set '%s' - %s\n" "$pair" "$temps" 1>&2 351 | __SKIP_COUNT=$((__SKIP_COUNT + 1)) 352 | continue 353 | fi 354 | fi 355 | ## without a base snapshot, the destination must be clean 356 | if [ -z "$base" ] && [ "$dstSnapCount" -gt 0 ]; then 357 | ## allowed to prune remote dataset? 358 | if [ "$ALLOW_RECONCILIATION" -ne 1 ]; then 359 | temps="destination contains snapshots not in source - set 'ALLOW_RECONCILIATION=1' to prune snapshots" 360 | printf "WARNING: skipping replication set '%s' - %s\n" "$pair" "$temps" 1>&2 361 | __SKIP_COUNT=$((__SKIP_COUNT + 1)) 362 | continue 363 | fi 364 | ## prune destination snapshots 365 | printf "pruning destination snapshots: %s\n" "$dstSnaps" 1>&2 366 | for snap in $dstSnaps; do 367 | snapDestroy "$snap" "$dstHost" 368 | done 369 | fi 370 | ## cleanup old snapshots 371 | if [ "$srcSnapCount" -ge "$SNAP_KEEP" ]; then 372 | ## snaps are sorted above by creation in ascending order 373 | printf "%s\n" "$srcSnaps" | sed -n "1,$((srcSnapCount - SNAP_KEEP))p" | while read -r snap; do 374 | printf "found old snapshot %s\n" "$snap" 1>&2 375 | snapDestroy "$snap" "$srcHost" 376 | done 377 | fi 378 | ## build snapshot create command 379 | cmd="" 380 | if [ -n "$srcHost" ]; then 381 | cmd="$SSH $srcHost " 382 | fi 383 | cmd="$cmd$ZFS snapshot" 384 | ## check if we are supposed to be recursive 385 | if [ "$RECURSE_CHILDREN" -eq 1 ]; then 386 | cmd="$cmd -r" 387 | fi 388 | cmd="$cmd ${src}@${name}" 389 | ## come on already...take that snapshot 390 | printf "creating snapshot cmd=%s\n" "$cmd" 1>&2 391 | if ! $cmd; then 392 | snapDestroy "${src}@${name}" "$srcHost" 393 | exitClean 128 "failed to create snapshot: ${src}@${name}" 394 | fi 395 | ## send snapshot to destination 396 | snapSend "$base" "$name" "$src" "$srcHost" "$dst" "$dstHost" 397 | done 398 | ## clear snapshot lockfile 399 | clearLock "${TMPDIR}/.replicate.snapshot.lock" 400 | } 401 | 402 | ## handle logging to file or syslog 403 | writeLog() { 404 | line=$1 405 | logf="/dev/null" 406 | ## if a log base and file has been configured set them 407 | if [ -n "$LOG_BASE" ] && [ -n "$LOG_FILE" ]; then 408 | logf="${LOG_BASE}/${LOG_FILE}" 409 | fi 410 | ## always print to stdout and copy to logfile if set 411 | printf "%s %s[%d]: %s\n" "$(date '+%b %d %T')" "$SCRIPT" "$$" "$line" | tee -a "$logf" 1>&2 412 | ## if syslog has been enabled write to syslog via logger 413 | if [ "$SYSLOG" -eq 1 ] && [ -n "$LOGGER" ]; then 414 | $LOGGER -p "${SYSLOG_FACILITY}.info" -t "$SCRIPT" "$line" 415 | fi 416 | } 417 | 418 | ## read from stdin till script exit 419 | captureOutput() { 420 | while IFS= read -r line; do 421 | writeLog "$line" 422 | done 423 | } 424 | 425 | ## perform macro substitution for tags 426 | subTags() { 427 | m=$1 428 | ## do the substitutions 429 | m=$(printf "%s\n" "$m" | sed "s/%DOW%/${__DOW}/g") 430 | m=$(printf "%s\n" "$m" | sed "s/%DOM%/${__DOM}/g") 431 | m=$(printf "%s\n" "$m" | sed "s/%MOY%/${__MOY}/g") 432 | m=$(printf "%s\n" "$m" | sed "s/%CYR%/${__CYR}/g") 433 | m=$(printf "%s\n" "$m" | sed "s/%NOW%/${__NOW}/g") 434 | m=$(printf "%s\n" "$m" | sed "s/%TAG%/${TAG}/g") 435 | printf "%s\n" "$m" 436 | } 437 | 438 | ## show last replication status 439 | showStatus() { 440 | log=$(sortLogs | head -n 1) 441 | if [ -n "$log" ]; then 442 | printf "%s" "$(cat "${log}")" && exit 0 443 | fi 444 | ## not found, log error and exit 445 | writeLog "ERROR: unable to find most recent log file, cannot print status" && exit 1 446 | } 447 | 448 | ## show usage and exit 449 | showHelp() { 450 | printf "Usage: %s [config] [options]\n\n" "${SCRIPT}" 451 | printf "POSIX shell script to automate ZFS Replication\n\n" 452 | printf "Options:\n" 453 | printf " -c, --config configuration file\n" 454 | printf " -s, --status print most recent log messages to stdout\n" 455 | printf " -h, --help show this message\n" 456 | exit 0 457 | } 458 | 459 | ## read config file if present, process flags, validate, and lock config variables 460 | loadConfig() { 461 | configFile="" 462 | status=0 463 | help=0 464 | ## sub macros for logging 465 | TAG="$(subTags "$TAG")" 466 | LOG_FILE="$(subTags "$LOG_FILE")" 467 | ## check for config file as first argument for backwards compatibility 468 | if [ $# -gt 0 ] && [ -f "$1" ]; then 469 | configFile="$1" 470 | shift 471 | fi 472 | ## process command-line options 473 | while [ $# -gt 0 ]; do 474 | if [ "$1" = "-c" ] || [ "$1" = "--config" ]; then 475 | shift 476 | configFile="$1" 477 | shift 478 | continue 479 | fi 480 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 481 | help=1 482 | shift 483 | continue 484 | fi 485 | if [ "$1" = "-s" ] || [ "$1" = "--status" ]; then 486 | status=1 487 | shift 488 | continue 489 | fi 490 | ## unknown option 491 | writeLog "ERROR: illegal option ${1}" && exit 1 492 | done 493 | ## someone ask for help? 494 | if [ "$help" -eq 1 ]; then 495 | showHelp 496 | fi 497 | ## attempt to load configuration 498 | if [ -f "$configFile" ]; then 499 | # shellcheck disable=SC1090 500 | . "$configFile" 501 | elif configFile="${SCRIPT_PATH}/config.sh" && [ -f "$configFile" ]; then 502 | # shellcheck disable=SC1090 503 | . "$configFile" 504 | fi 505 | ## perform final substitution 506 | TAG="$(subTags "$TAG")" 507 | LOG_FILE="$(subTags "$LOG_FILE")" 508 | ## lock configuration 509 | readonly REPLICATE_SETS 510 | readonly ALLOW_ROOT_DATASETS 511 | readonly ALLOW_RECONCILIATION 512 | readonly RECURSE_CHILDREN 513 | readonly SNAP_KEEP 514 | readonly SYSLOG 515 | readonly SYSLOG_FACILITY 516 | readonly TAG 517 | readonly LOG_FILE 518 | readonly LOG_KEEP 519 | readonly LOG_BASE 520 | readonly LOGGER 521 | readonly FIND 522 | readonly SSH 523 | readonly ZFS 524 | readonly HOST_CHECK 525 | readonly TMPDIR 526 | ## set pipes after configuration to ensure proper $SSH and $ZFS subs 527 | readonly DEST_PIPE_WITH_HOST="${DEST_PIPE_WITH_HOST:-"$SSH %HOST% $ZFS receive -vFd"}" 528 | readonly DEST_PIPE_WITHOUT_HOST="${DEST_PIPE_WITHOUT_HOST:-"$ZFS receive -vFd"}" 529 | ## check configuration 530 | if [ -n "$LOG_BASE" ] && [ ! -d "$LOG_BASE" ]; then 531 | mkdir -p "$LOG_BASE" 532 | fi 533 | ## we have all we need for status 534 | if [ "$status" -eq 1 ]; then 535 | showStatus 536 | fi 537 | ## continue validating config 538 | if [ "$SYSLOG" -eq 1 ] && [ -z "$LOGGER" ]; then 539 | writeLog "ERROR: unable to locate system logger binary and SYSLOG is enabled" && exit 1 540 | fi 541 | if [ -z "$REPLICATE_SETS" ]; then 542 | writeLog "ERROR: missing required setting REPLICATE_SETS" && exit 1 543 | fi 544 | if [ "$SNAP_KEEP" -lt 2 ]; then 545 | writeLog "ERROR: a minimum of 2 snapshots are required for incremental sending" && exit 1 546 | fi 547 | if [ -z "$FIND" ]; then 548 | writeLog "ERROR: unable to locate system find binary" && exit 1 549 | fi 550 | if [ -z "$SSH" ]; then 551 | writeLog "ERROR: unable to locate system ssh binary" && exit 1 552 | fi 553 | if [ -z "$ZFS" ]; then 554 | writeLog "ERROR: unable to locate system zfs binary" && exit 1 555 | fi 556 | } 557 | 558 | ## main function, not much here 559 | main() { 560 | ## do snapshots and send 561 | snapCreate 562 | ## that's it, sending is called from doSnap 563 | exitClean 0 564 | } 565 | 566 | ## process config and start main if we weren't sourced 567 | if [ "$(expr "$SCRIPT" : 'zfs-replicate')" -gt 0 ]; then 568 | loadConfig "$@" && main 2>&1 | captureOutput 569 | fi 570 | --------------------------------------------------------------------------------