diff --git a/CHANGELIST b/CHANGELIST index 49fcd50..de00f66 100644 --- a/CHANGELIST +++ b/CHANGELIST @@ -1,8 +1,42 @@ +2.3.0 [overall] documentation updates, small fixes (@thecatontheflat, @mjeanson, @jiawen, @EchterAgo, @jan-krieg, @dlangille, @rightaditya, @MynaITLabs, @ossimoi, @alexgarel, @TopherIsSwell, @jimsalterjrs, @phreaker0) + [sanoid] implemented adding of taken snapshots to the cache file and a new parameter for setting an custom cache expire time (@phreaker0) + [sanoid] ignore duplicate template keys (@phreaker0) + [packaging] fix debian packaging with debian 12 and ubuntu 24.04 (@phreaker0) + [syncoid] fix typo preventing resumed transfer with --sendoptions (@Deltik) + [sanoid] remove iszfsbusy check to boost performance (@sdettmer) + [sanoid] write cache files in an atomic way to prevent race conditions (@phreaker0) + [sanoid] improve performance (especially for monitor commands) by caching the dataset list (@phreaker0) + [syncoid] add zstdmt compress options (@0xFelix) + [syncoid] added missing status information about what is done and provide more details (@phreaker0) + [syncoid] rename ssh control socket to avoid problem with length limits and conflicts (@phreaker0) + [syncoid] support relative paths (@phreaker0) + [syncoid] regather snapshots on --delete-target-snapshots flag (@Adam Fulton) + [sanoid] allow monitor commands to be run without root by using only the cache file (@Pajkastare) + [syncoid] add --include-snaps and --exclude-snaps options (@mr-vinn, @phreaker0) + [syncoid] escape property key and value pair in case of property preservation (@phreaker0) + [syncoid] prevent destroying of root dataset which leads to infinite loop because it can't be destroyed (@phreaker0) + [syncoid] modify zfs-get argument order for portability (@Rantherhin) + [sanoid] trim config values (@phreaker0) + +2.2.0 [overall] documentation updates, small fixes (@azmodude, @deviantintegral, @jimsalterjrs, @alexhaydock, @cbreak-black, @kd8bny, @JavaScriptDude, @veeableful, @rsheasby, @Topslakr, @mavhc, @adam-stamand, @joelishness, @jsoref, @dodexahedron, @phreaker0) + [syncoid] implemented flag for preserving properties without the zfs -p flag (@phreaker0) + [syncoid] implemented target snapshot deletion (@mat813) + [syncoid] support bookmarks which are taken in the same second (@delxg, @phreaker0) + [syncoid] exit with an error if the specified src dataset doesn't exist (@phreaker0) + [syncoid] rollback is now done implicitly instead of explicit (@jimsalterjrs, @phreaker0) + [syncoid] append a rand int to the socket name to prevent collisions with parallel invocations (@Gryd3) + [syncoid] implemented support for ssh_config(5) files (@endreszabo) + [syncoid] snapshot hold/unhold support (@rbike) + [sanoid] handle duplicate key definitions gracefully (@phreaker0) + [syncoid] implemented removal of conflicting snapshots with force-delete option (@phreaker0) + [sanoid] implemented pre pruning script hook (@phreaker0) + [syncoid] implemented direct connection support (bypass ssh) for the actual data transfer (@phreaker0) + 2.1.0 [overall] documentation updates, small fixes (@HavardLine, @croadfeldt, @jimsalterjrs, @jim-perkins, @kr4z33, @phreaker0) [syncoid] do not require user to be specified for syncoid (@aerusso) [syncoid] implemented option for keeping sync snaps (@phreaker0) - [syncoid] use sudo if neccessary for checking pool capabilities regarding resumeable send (@phreaker0) - [syncoid] catch another case were the resume state isn't availabe anymore (@phreaker0) + [syncoid] use sudo if necessary for checking pool capabilities regarding resumable send (@phreaker0) + [syncoid] catch another case were the resume state isn't available anymore (@phreaker0) [syncoid] check for an invalid argument combination (@phreaker0) [syncoid] fix iszfsbusy check for similar dataset names (@phreaker0) [syncoid] append timezone offset to the syncoid snapshot name to fix DST collisions (@phreaker0) @@ -29,7 +63,7 @@ 2.0.2 [overall] documentation updates, new dependencies, small fixes, more warnings (@benyanke, @matveevandrey, @RulerOf, @klemens-u, @johnramsden, @danielewood, @g-a-c, @hartzell, @fryfrog, @phreaker0) [sanoid] changed and simplified DST handling (@shodanshok) [syncoid] reset partially resume state automatically (@phreaker0) - [syncoid] handle some zfs erros automatically by parsing the stderr outputs (@phreaker0) + [syncoid] handle some zfs errors automatically by parsing the stderr outputs (@phreaker0) [syncoid] fixed ordering of snapshots with the same creation timestamp (@phreaker0) [syncoid] don't use hardcoded paths (@phreaker0) [syncoid] fix for special setup with listsnapshots=on (@phreaker0) @@ -84,7 +118,7 @@ [sanoid] implemented monitor-capacity flag for checking zpool capacity limits (@phreaker0) [syncoid] Added support for ZStandard compression.(@danielewood) [syncoid] implemented support for excluding datasets from replication with regular expressions (@phreaker0) - [syncoid] correctly parse zfs column output, fixes resumeable send with datasets containing spaces (@phreaker0) + [syncoid] correctly parse zfs column output, fixes resumable send with datasets containing spaces (@phreaker0) [syncoid] added option for using extra identification in the snapshot name for replication to multiple targets (@phreaker0) [syncoid] added option for skipping the parent dataset in recursive replication (@phreaker0) [syncoid] typos (@UnlawfulMonad, @jsavikko, @phreaker0) @@ -118,12 +152,12 @@ replicating to target/parent/child2. This could still use some cleanup TBH; syncoid SHOULD exit 3 if any of these errors happen (to assist detection of errors in scripting) but now would exit 0. -1.4.12 Sanoid now strips trailing whitespace in template definitions in sanoid.conf, per Github #61 +1.4.12 Sanoid now strips trailing whitespace in template definitions in sanoid.conf, per GitHub #61 1.4.11 enhanced Syncoid to use zfs `guid` property rather than `creation` property to ensure snapshots on source and target actually match. This immediately prevents conflicts due to timezone differences on source and target, and also paves the way in the future for Syncoid to find matching snapshots even after `zfs rename` on source - or target. Thank you Github user @mailinglists35 for the idea! + or target. Thank you GitHub user @mailinglists35 for the idea! 1.4.10 added --compress=pigz-fast and --compress=pigz-slow. On a Xeon E3-1231v3, pigz-fast is equivalent compression to --compress=gzip but with compressed throughput of 75.2 MiB/s instead of 18.1 MiB/s. pigz-slow is around 5% @@ -241,4 +275,4 @@ 1.0.1 ported slightly modified iszfsbusy sub from syncoid to sanoid (to keep from thinning snapshots during replications) -1.0.0 initial commit to Github +1.0.0 initial commit to GitHub diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..18c9147 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..15b33b7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1 @@ +Any and all contributions made to this project must be compatible with the project's own GPLv3 license. diff --git a/INSTALL.md b/INSTALL.md index d401179..879d257 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -6,7 +6,7 @@ - [Installation](#installation) - [Debian/Ubuntu](#debianubuntu) - - [CentOS](#centos) + - [RHEL/CentOS/AlmaLinux](#RHEL/CentOS/AlmaLinux) - [FreeBSD](#freebsd) - [Alpine Linux / busybox](#alpine-Linux-or-busybox-based-distributions) - [OmniOS](#OmniOS) @@ -23,49 +23,61 @@ Install prerequisite software: ```bash -apt install debhelper libcapture-tiny-perl libconfig-inifiles-perl pv lzop mbuffer build-essential +apt install debhelper libcapture-tiny-perl libconfig-inifiles-perl pv lzop mbuffer build-essential git ``` -Clone this repo, build the debian package and install it (alternatively you can skip the package and do it manually like described below for CentOS): +Clone this repo under /tmp (to make sure the apt user has access to the unpacked clone), build the debian package and install it (alternatively you can skip the package and do it manually like described below for CentOS): ```bash -# Download the repo as root to avoid changing permissions later -sudo git clone https://github.com/jimsalterjrs/sanoid.git +cd /tmp +git clone https://github.com/jimsalterjrs/sanoid.git cd sanoid # checkout latest stable release or stay on master for bleeding edge stuff (but expect bugs!) git checkout $(git tag | grep "^v" | tail -n 1) ln -s packages/debian . dpkg-buildpackage -uc -us -apt install ../sanoid_*_all.deb +sudo apt install ../sanoid_*_all.deb ``` Enable sanoid timer: ```bash # enable and start the sanoid timer -sudo systemctl enable sanoid.timer -sudo systemctl start sanoid.timer +sudo systemctl enable --now sanoid.timer ``` -## CentOS +## RHEL/CentOS/AlmaLinux Install prerequisite software: ```bash -# Install and enable epel if we don't already have it, and git too +# Install and enable EPEL if we don't already have it, and git too: +# (Note that on RHEL we cannot enable EPEL with the epel-release +# package, so you should follow the instructions on the main EPEL site.) sudo yum install -y epel-release git +# On CentOS, we also need to enable the PowerTools repo: +sudo yum config-manager --set-enabled powertools +# For Centos 8 you need to enable the PowerTools repo to make all the needed Perl modules available (Recommended) +sudo dnf config-manager --set-enabled powertools +# On RHEL, instead of PowerTools, we need to enable the CodeReady Builder repo: +sudo subscription-manager repos --enable=codeready-builder-for-rhel-8-x86_64-rpms +# For Rocky Linux 9 or AlmaLinux 9 you need the CodeReady Builder repo, and it is labelled `crb` +sudo dnf config-manager --set-enabled crb # Install the packages that Sanoid depends on: -sudo yum install -y perl-Config-IniFiles perl-Data-Dumper perl-Capture-Tiny lzop mbuffer mhash pv -# if the perl dependencies can't be found in the configured repositories you can install them from CPAN manually: +sudo yum install -y perl-Config-IniFiles perl-Data-Dumper perl-Capture-Tiny perl-Getopt-Long lzop mbuffer mhash pv +# The repositories above should contain all the relevant Perl modules, but if you +# still cannot find them then you can install them from CPAN manually: sudo dnf install perl-CPAN perl-CPAN -cpan # answer the questions and past the following lines +cpan # answer the questions and paste the following lines: # install Capture::Tiny # install Config::IniFiles +# install Getopt::Long ``` Clone this repo, then put the executables and config files into the appropriate directories: ```bash +cd /tmp # Download the repo as root to avoid changing permissions later sudo git clone https://github.com/jimsalterjrs/sanoid.git cd sanoid @@ -143,8 +155,7 @@ sudo systemctl daemon-reload # Enable sanoid-prune.service to allow it to be triggered by sanoid.service sudo systemctl enable sanoid-prune.service # Enable and start the Sanoid timer -sudo systemctl enable sanoid.timer -sudo systemctl start sanoid.timer +sudo systemctl enable --now sanoid.timer ``` Now, proceed to configure [**Sanoid**](#configuration) @@ -154,7 +165,7 @@ Now, proceed to configure [**Sanoid**](#configuration) Install prerequisite software: ```bash -pkg install p5-Config-Inifiles p5-Capture-Tiny pv mbuffer lzop +pkg install p5-Config-Inifiles p5-Capture-Tiny pv mbuffer lzop sanoid ``` **Additional notes:** @@ -163,7 +174,7 @@ pkg install p5-Config-Inifiles p5-Capture-Tiny pv mbuffer lzop * Simplest path workaround is symlinks, eg `ln -s /usr/local/bin/lzop /usr/bin/lzop` or similar, as appropriate to create links in **/usr/bin** to wherever the utilities actually are on your system. -* See note about mbuffer and other things in FREEBSD.readme +* See note about tcsh unpleasantness and other things in FREEBSD.readme ## Alpine Linux or busybox based distributions @@ -253,11 +264,57 @@ Further steps (not OmniOS specific): - set up SSH connections between two remote hosts - create a cron job that runs sanoid --cron --quiet periodically +======= +## MacOS + +Install prerequisite software: + +``` +perl -MCPAN -e install Config::IniFiles +``` + +The crontab can be used as on a normal unix. To use launchd instead, this example config file can be use can be used. Modify it for your needs. In particular, adjust the sanoid path. +It will start sanoid once per hour, at minute 51. Missed invocations due to standby will be merged into a single invocation at the next wakeup. + +```bash +cat << "EOF" | sudo tee /Library/LaunchDaemons/net.openoid.Sanoid.plist + + + + + Label + net.openoid.Sanoid + ProgramArguments + + /usr/local/sanoid/sanoid + --cron + + EnvironmentVariables + + TZ + UTC + PATH + /usr/local/zfs/bin:$PATH:/usr/local/bin + + StartCalendarInterval + + + Minute + 51 + + + + +EOF + +sudo launchctl load /Library/LaunchDaemons/net.openoid.Sanoid.plist +``` + ## Other OSes -**Sanoid** depends on the Perl module Config::IniFiles and will not operate without it. Config::IniFiles may be installed from CPAN, though the project strongly recommends using your distribution's repositories instead. +**Sanoid** depends on the Perl modules Config::IniFiles and Capture::Tiny and will not operate without them. These modules may be installed from CPAN, though the project strongly recommends using your distribution's repositories instead. -**Syncoid** depends on ssh, pv, gzip, lzop, and mbuffer. It can run with reduced functionality in the absence of any or all of the above. SSH is only required for remote synchronization. On newer FreeBSD and Ubuntu Xenial chacha20-poly1305@openssh.com, on other distributions arcfour crypto is the default for SSH transport since v1.4.6. Syncoid runs will fail if one of them is not available on either end of the transport. +**Syncoid** depends on ssh, pv, gzip, lzop, and mbuffer as well as sharing sanoid's dependency on Capture::Tiny. Capture::Tiny is mandatory, but syncoid can function with reduced functionality without any or all of the command-line dependencies. SSH is only required for remote synchronization. On newer FreeBSD and Ubuntu Xenial chacha20-poly1305@openssh.com, on other distributions arcfour crypto is the default for SSH transport since v1.4.6. Syncoid runs will fail if one of them is not available on either end of the transport. ### General outline for installation @@ -288,3 +345,12 @@ Adapt the timer interval to the lowest configured snapshot interval. Take a look at the files `sanoid.defaults.conf` and `sanoid.conf` for all possible configuration options. Also have a look at the README.md for a simpler suggestion for `sanoid.conf`. + +## Syncoid +If you are pushing or pulling from a remote host, create a user with privileges to `ssh` as well as `sudo`. To ensure that `zfs send/receive` can execute, adjust the privileges of the user to execute `sudo` **without** a password for only the `zfs` binary (run `which zfs` to find the path of the `zfs` binary). Modify `/etc/sudoers` by running `# visudo`. Add the following line for your user. + +``` +... + ALL=NOPASSWD: +... +``` diff --git a/README.md b/README.md index 21432ac..aff3b68 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,26 @@ -

sanoid logo

+ + + + +
+

+ sanoid logo +

+ +

Sanoid is provided to you completely free and libre, now and in perpetuity, via the GPL v3.0 license. If you find the project useful, please consider either a recurring or one-time donation at Patreon or PayPal—your contributions will support both this project and the Practical ZFS forum. +

+
-Sanoid is a policy-driven snapshot management tool for ZFS filesystems. When combined with the Linux KVM hypervisor, you can use it to make your systems functionally immortal. +Sanoid is a policy-driven snapshot management tool for ZFS filesystems. When combined with the Linux KVM hypervisor, you can use it to make your systems functionally immortal via automated snapshot management and over-the-air replication.

sanoid rollback demo
(Real time demo: rolling back a full-scale cryptomalware infection in seconds!)

-More prosaically, you can use Sanoid to create, automatically thin, and monitor snapshots and pool health from a single eminently human-readable TOML config file at /etc/sanoid/sanoid.conf. (Sanoid also requires a "defaults" file located at /etc/sanoid/sanoid.defaults.conf, which is not user-editable.) A typical Sanoid system would have a single cron job but see INSTALL.md fore more details: +More prosaically, you can use Sanoid to create, automatically thin, and monitor snapshots and pool health from a single eminently human-readable TOML config file at /etc/sanoid/sanoid.conf. (Sanoid also requires a "defaults" file located at /etc/sanoid/sanoid.defaults.conf, which is not user-editable.) A typical Sanoid system would have a single cron job but see INSTALL.md for more details: ``` * * * * * TZ=UTC /usr/local/bin/sanoid --cron ``` -`Note`: Using UTC as timezone is recommend to prevent problems with daylight saving times +`Note`: Using UTC as timezone is recommended to prevent problems with daylight saving times And its /etc/sanoid/sanoid.conf might look something like this: @@ -69,10 +80,6 @@ For more full details on sanoid.conf settings see [Wiki page](https://github.com This will process your sanoid.conf file, it will NOT create snapshots, but it will purge expired ones. -+ --force-prune - - Purges expired snapshots even if a send/recv is in progress - + --monitor-snapshots This option is designed to be run by a Nagios monitoring system. It reports on the health of your snapshots. @@ -89,13 +96,17 @@ For more full details on sanoid.conf settings see [Wiki page](https://github.com This clears out sanoid's zfs snapshot listing cache. This is normally not needed. ++ --cache-ttl=SECONDS + + Set custom cache expire time in seconds (default: 20 minutes). + + --version This prints the version number, and exits. + --quiet - Supress non-error output. + Suppress non-error output. + --verbose @@ -103,7 +114,7 @@ For more full details on sanoid.conf settings see [Wiki page](https://github.com + --debug - This prints out quite alot of additional information during a sanoid run, and is normally not needed. + This prints out quite a lot of additional information during a sanoid run, and is normally not needed. + --readonly @@ -115,7 +126,9 @@ For more full details on sanoid.conf settings see [Wiki page](https://github.com ### Sanoid script hooks -There are three script types which can optionally be executed at various stages in the lifecycle of a snapshot: +There are three script types which can optionally be executed at various stages in the lifecycle of a snapshot. + +**Note** that snapshots related script are triggered only if you have `autosnap = yes` and pruning scripts are triggered only if you have `autoprune = yes`. #### `pre_snapshot_script` @@ -125,7 +138,7 @@ Will be executed before the snapshot(s) of a single dataset are taken. The follo | ----------------- | ----------- | | `SANOID_SCRIPT` | The type of script being executed, one of `pre`, `post`, or `prune`. Allows for one script to be used for multiple tasks | | `SANOID_TARGET` | **DEPRECATED** The dataset about to be snapshot (only the first dataset will be provided) | -| `SANOID_TARGETS` | Comma separated list of all datasets to be snapshoted (currently only a single dataset, multiple datasets will be possible later with atomic groups) | +| `SANOID_TARGETS` | Comma separated list of all datasets to be snapshotted (currently only a single dataset, multiple datasets will be possible later with atomic groups) | | `SANOID_SNAPNAME` | **DEPRECATED** The name of the snapshot that will be taken (only the first name will be provided, does not include the dataset name) | | `SANOID_SNAPNAMES` | Comma separated list of all snapshot names that will be taken (does not include the dataset name) | | `SANOID_TYPES` | Comma separated list of all snapshot types to be taken (yearly, monthly, weekly, daily, hourly, frequently) | @@ -232,7 +245,7 @@ syncoid root@remotehost:data/images/vm backup/images/vm Which would pull-replicate the filesystem from the remote host to the local system over an SSH tunnel. Syncoid supports recursive replication (replication of a dataset and all its child datasets) and uses mbuffer buffering, lzop compression, and pv progress bars if the utilities are available on the systems used. -If ZFS supports resumeable send/receive streams on both the source and target those will be enabled as default. +If ZFS supports resumable send/receive streams on both the source and target those will be enabled as default. As of 1.4.18, syncoid also automatically supports and enables resume of interrupted replication when both source and target support this feature. @@ -274,7 +287,7 @@ As of 1.4.18, syncoid also automatically supports and enables resume of interrup + --identifier= - Adds the given identifier to the snapshot name after "syncoid_" prefix and before the hostname. This enables the use case of reliable replication to multiple targets from the same host. The following chars are allowed: a-z, A-Z, 0-9, _, -, : and . . + Adds the given identifier to the snapshot and hold name after "syncoid_" prefix and before the hostname. This enables the use case of reliable replication to multiple targets from the same host. The following chars are allowed: a-z, A-Z, 0-9, _, -, : and . . + -r --recursive @@ -286,7 +299,7 @@ As of 1.4.18, syncoid also automatically supports and enables resume of interrup + --compress - Currently accepted options: gzip, pigz-fast, pigz-slow, zstd-fast, zstd-slow, lz4, xz, lzo (default) & none. If the selected compression method is unavailable on the source and destination, no compression will be used. + Compression method to use for network transfer. Currently accepted options: gzip, pigz-fast, pigz-slow, zstd-fast, zstd-slow, lz4, xz, lzo (default) & none. If the selected compression method is unavailable on the source and destination, no compression will be used. + --source-bwlimit @@ -294,7 +307,7 @@ As of 1.4.18, syncoid also automatically supports and enables resume of interrup + --target-bwlimit - This is the bandwidth limit in bytes (kbytes, mbytesm etc) per second imposed upon the target. This is mainly used if the source does not have mbuffer installed, but bandwidth limits are desired. + This is the bandwidth limit in bytes (kbytes, mbytes, etc) per second imposed upon the target. This is mainly used if the source does not have mbuffer installed, but bandwidth limits are desired. + --no-command-checks @@ -316,10 +329,23 @@ As of 1.4.18, syncoid also automatically supports and enables resume of interrup This argument tells syncoid to create a zfs bookmark for the newest snapshot after it got replicated successfully. The bookmark name will be equal to the snapshot name. Only works in combination with the --no-sync-snap option. This can be very useful for irregular replication where the last matching snapshot on the source was already deleted but the bookmark remains so a replication is still possible. ++ --use-hold + + This argument tells syncoid to add a hold to the newest snapshot on the source and target after replication succeeds and to remove the hold after the next successful replication. Setting a hold prevents the snapshots from being destroyed. The hold name includes the identifier if set. This allows for separate holds in case of replication to multiple targets. + + --preserve-recordsize This argument tells syncoid to set the recordsize on the target before writing any data to it matching the one set on the replication src. This only applies to initial sends. ++ --preserve-properties + + This argument tells syncoid to get all locally set dataset properties from the source and apply all supported ones on the target before writing any data. It's similar to the '-p' flag for zfs send but also works for encrypted datasets in non raw sends. This only applies to initial sends. + ++ --delete-target-snapshots + + With this argument snapshots which are missing on the source will be destroyed on the target. Use this if you only want to handle snapshots on the source. + Note that snapshot deletion is only done after a successful synchronization. If no new snapshots are found, no synchronization is done and no deletion either. + + --no-clone-rollback Do not rollback clones on target @@ -330,19 +356,33 @@ As of 1.4.18, syncoid also automatically supports and enables resume of interrup + --exclude=REGEX - The given regular expression will be matched against all datasets which would be synced by this run and excludes them. This argument can be specified multiple times. + __DEPRECATION NOTICE:__ `--exclude` has been deprecated and will be removed in a future release. Please use `--exclude-datasets` instead. + + The given regular expression will be matched against all datasets which would be synced by this run and excludes them. This argument can be specified multiple times. The provided regex pattern is matched against the dataset name only; this option does not affect which snapshots are synchronized. If both `--exclude` and `--exclude-datasets` are provided, then `--exclude` is ignored. + ++ --exclude-datasets=REGEX + + The given regular expression will be matched against all datasets which would be synced by this run and excludes them. This argument can be specified multiple times. The provided regex pattern is matched against the dataset name only; this option does not affect which snapshots are synchronized. + ++ --exclude-snaps=REGEX + + Exclude specific snapshots that match the given regular expression. The provided regex pattern is matched against the snapshot name only. Can be specified multiple times. If a snapshot matches both the exclude-snaps and include-snaps patterns, then it will be excluded. + ++ --include-snaps=REGEX + + Only include snapshots that match the given regular expression. The provided regex pattern is matched against the snapshot name only. Can be specified multiple times. If a snapshot matches both the exclude-snaps and include-snaps patterns, then it will be excluded. + --no-resume - This argument tells syncoid to not use resumeable zfs send/receive streams. + This argument tells syncoid to not use resumable zfs send/receive streams. + --force-delete - Remove target datasets recursively (WARNING: this will also affect child datasets with matching snapshots/bookmarks), if there are no matching snapshots/bookmarks. + Remove target datasets recursively (WARNING: this will also affect child datasets with matching snapshots/bookmarks), if there are no matching snapshots/bookmarks. Also removes conflicting snapshots if the replication would fail because of a snapshot which has the same name between source and target but different contents. + --no-clone-handling - This argument tells syncoid to not recreate clones on the targe on initial sync and doing a normal replication instead. + This argument tells syncoid to not recreate clones on the target on initial sync, and do a normal replication instead. + --dumpsnaps @@ -368,13 +408,18 @@ As of 1.4.18, syncoid also automatically supports and enables resume of interrup Use specified identity file as per ssh -i. ++ --insecure-direct-connection=IP:PORT[,IP:PORT,[TIMEOUT,[mbuffer]]] + + WARNING: This is an insecure option as the data is not encrypted while being sent over the network. Only use if you trust the complete network path. + Use a direct tcp connection (with socat and busybox nc/mbuffer) for the actual zfs send/recv stream. All control commands are still executed via the ssh connection. The first address pair is used for connecting to the target host from the source host and the second pair is for listening on the target host. If the later isn't provided the same as the former is used. This can be used for saturating high throughput connection like >= 10GBe network which isn't easy with the overhead off ssh. It can also be useful for encrypted datasets to lower the cpu usage needed for replication but be aware that metadata is NOT ENCRYPTED in this case. The default timeout is 60 seconds and can be overridden by providing it as third argument. By default busybox nc is used for the listeing tcp socket, if mbuffer is preferred specify its name as fourth argument but be aware that mbuffer listens on all interfaces and uses an optionally provided ip address for access restriction (This option can't be used for relaying between two remote hosts) + + --quiet - Supress non-error output. + Suppress non-error output. + --debug - This prints out quite alot of additional information during a sanoid run, and is normally not needed. + This prints out quite a lot of additional information during a syncoid run, and is normally not needed. + --help diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d1bd71c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +The Sanoid project directly supports both the code in the main branch, and the last two releases found here on GitHub. + +Community support is available for all versions, with the understanding that in some cases "upgrade to a newer version" may be the support offered. +If you've installed Sanoid from your distribution's repositories, we're happy to offer community support with the same caveat! + +## Reporting a Vulnerability + +If you believe you've found a serious security vulnerability in Sanoid, please create an Issue here on GitHub. If you prefer a private contact channel to disclose +particularly sensitive or private details, you may request one in the GitHub Issue you create. diff --git a/VERSION b/VERSION index 7ec1d6d..276cbf9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.0 +2.3.0 diff --git a/findoid b/findoid index 98ad581..adda5cc 100755 --- a/findoid +++ b/findoid @@ -4,7 +4,7 @@ # from http://www.gnu.org/licenses/gpl-3.0.html on 2014-11-17. A copy should also be available in this # project's Git repository at https://github.com/jimsalterjrs/sanoid/blob/master/LICENSE. -$::VERSION = '2.1.0'; +$::VERSION = '2.3.0'; use strict; use warnings; @@ -25,6 +25,9 @@ if ($args{'path'} eq '') { } } +# resolve given path to a canonical one +$args{'path'} = Cwd::realpath($args{'path'}); + my $dataset = getdataset($args{'path'}); my %versions = getversions($args{'path'}, $dataset); diff --git a/packages/debian/changelog b/packages/debian/changelog index b394acb..ba369a1 100644 --- a/packages/debian/changelog +++ b/packages/debian/changelog @@ -1,10 +1,52 @@ +sanoid (2.3.0) unstable; urgency=medium + + [overall] documentation updates, small fixes (@thecatontheflat, @mjeanson, @jiawen, @EchterAgo, @jan-krieg, @dlangille, @rightaditya, @MynaITLabs, @ossimoi, @alexgarel, @TopherIsSwell, @jimsalterjrs, @phreaker0) + [sanoid] implemented adding of taken snapshots to the cache file and a new parameter for setting an custom cache expire time (@phreaker0) + [sanoid] ignore duplicate template keys (@phreaker0) + [packaging] fix debian packaging with debian 12 and ubuntu 24.04 (@phreaker0) + [syncoid] fix typo preventing resumed transfer with --sendoptions (@Deltik) + [sanoid] remove iszfsbusy check to boost performance (@sdettmer) + [sanoid] write cache files in an atomic way to prevent race conditions (@phreaker0) + [sanoid] improve performance (especially for monitor commands) by caching the dataset list (@phreaker0) + [syncoid] add zstdmt compress options (@0xFelix) + [syncoid] added missing status information about what is done and provide more details (@phreaker0) + [syncoid] rename ssh control socket to avoid problem with length limits and conflicts (@phreaker0) + [syncoid] support relative paths (@phreaker0) + [syncoid] regather snapshots on --delete-target-snapshots flag (@Adam Fulton) + [sanoid] allow monitor commands to be run without root by using only the cache file (@Pajkastare) + [syncoid] add --include-snaps and --exclude-snaps options (@mr-vinn, @phreaker0) + [syncoid] escape property key and value pair in case of property preservation (@phreaker0) + [syncoid] prevent destroying of root dataset which leads to infinite loop because it can't be destroyed (@phreaker0) + [syncoid] modify zfs-get argument order for portability (@Rantherhin) + [sanoid] trim config values (@phreaker0) + + -- Jim Salter Tue, 05 Jun 2025 22:47:00 +0200 + +sanoid (2.2.0) unstable; urgency=medium + + [overall] documentation updates, small fixes (@azmodude, @deviantintegral, @jimsalterjrs, @alexhaydock, @cbreak-black, @kd8bny, @JavaScriptDude, @veeableful, @rsheasby, @Topslakr, @mavhc, @adam-stamand, @joelishness, @jsoref, @dodexahedron, @phreaker0) + [syncoid] implemented flag for preserving properties without the zfs -p flag (@phreaker0) + [syncoid] implemented target snapshot deletion (@mat813) + [syncoid] support bookmarks which are taken in the same second (@delxg, @phreaker0) + [syncoid] exit with an error if the specified src dataset doesn't exist (@phreaker0) + [syncoid] rollback is now done implicitly instead of explicit (@jimsalterjrs, @phreaker0) + [syncoid] append a rand int to the socket name to prevent collisions with parallel invocations (@Gryd3) + [syncoid] implemented support for ssh_config(5) files (@endreszabo) + [syncoid] snapshot hold/unhold support (@rbike) + [sanoid] handle duplicate key definitions gracefully (@phreaker0) + [syncoid] implemented removal of conflicting snapshots with force-delete option (@phreaker0) + [sanoid] implemented pre pruning script hook (@phreaker0) + [syncoid] implemented direct connection support (bypass ssh) for the actual data transfer (@phreaker0) + + -- Jim Salter Tue, 18 Jul 2023 10:04:00 +0200 + sanoid (2.1.0) unstable; urgency=medium [overall] documentation updates, small fixes (@HavardLine, @croadfeldt, @jimsalterjrs, @jim-perkins, @kr4z33, @phreaker0) [syncoid] do not require user to be specified for syncoid (@aerusso) [syncoid] implemented option for keeping sync snaps (@phreaker0) - [syncoid] use sudo if neccessary for checking pool capabilities regarding resumeable send (@phreaker0) - [syncoid] catch another case were the resume state isn't availabe anymore (@phreaker0) + [syncoid] use sudo if necessary for checking pool capabilities regarding resumable send (@phreaker0) + [syncoid] catch another case were the resume state isn't available anymore (@phreaker0) [syncoid] check for an invalid argument combination (@phreaker0) [syncoid] fix iszfsbusy check for similar dataset names (@phreaker0) [syncoid] append timezone offset to the syncoid snapshot name to fix DST collisions (@phreaker0) @@ -39,7 +81,7 @@ sanoid (2.0.2) unstable; urgency=medium [overall] documentation updates, new dependencies, small fixes, more warnings (@benyanke, @matveevandrey, @RulerOf, @klemens-u, @johnramsden, @danielewood, @g-a-c, @hartzell, @fryfrog, @phreaker0) [syncoid] changed and simplified DST handling (@shodanshok) [syncoid] reset partially resume state automatically (@phreaker0) - [syncoid] handle some zfs erros automatically by parsing the stderr outputs (@phreaker0) + [syncoid] handle some zfs errors automatically by parsing the stderr outputs (@phreaker0) [syncoid] fixed ordering of snapshots with the same creation timestamp (@phreaker0) [syncoid] don't use hardcoded paths (@phreaker0) [syncoid] fix for special setup with listsnapshots=on (@phreaker0) @@ -102,7 +144,7 @@ sanoid (2.0.0) unstable; urgency=medium [sanoid] implemented monitor-capacity flag for checking zpool capacity limits (@phreaker0) [syncoid] Added support for ZStandard compression.(@danielewood) [syncoid] implemented support for excluding datasets from replication with regular expressions (@phreaker0) - [syncoid] correctly parse zfs column output, fixes resumeable send with datasets containing spaces (@phreaker0) + [syncoid] correctly parse zfs column output, fixes resumable send with datasets containing spaces (@phreaker0) [syncoid] added option for using extra identification in the snapshot name for replication to multiple targets (@phreaker0) [syncoid] added option for skipping the parent dataset in recursive replication (@phreaker0) [syncoid] typos (@UnlawfulMonad, @jsavikko, @phreaker0) diff --git a/packages/debian/control b/packages/debian/control index d154147..1dfe087 100644 --- a/packages/debian/control +++ b/packages/debian/control @@ -12,7 +12,7 @@ Package: sanoid Architecture: all Depends: libcapture-tiny-perl, libconfig-inifiles-perl, - zfsutils-linux | zfs, + zfsutils-linux | zfs | openzfs-zfsutils, ${misc:Depends}, ${perl:Depends} Recommends: gzip, diff --git a/packages/debian/postinst b/packages/debian/postinst index 0d6142f..646a461 100755 --- a/packages/debian/postinst +++ b/packages/debian/postinst @@ -2,3 +2,5 @@ # remove old cache file [ -f /var/cache/sanoidsnapshots.txt ] && rm /var/cache/sanoidsnapshots.txt || true +[ -f /var/cache/sanoid/snapshots.txt ] && rm /var/cache/sanoid/snapshots.txt || true +[ -f /var/cache/sanoid/datasets.txt ] && rm /var/cache/sanoid/datasets.txt || true diff --git a/packages/debian/rules b/packages/debian/rules index 51e52af..05a3754 100755 --- a/packages/debian/rules +++ b/packages/debian/rules @@ -12,10 +12,6 @@ override_dh_auto_install: install -d $(DESTDIR)/etc/sanoid install -m 664 sanoid.defaults.conf $(DESTDIR)/etc/sanoid - install -d $(DESTDIR)/lib/systemd/system - install -m 664 debian/sanoid-prune.service debian/sanoid.timer \ - $(DESTDIR)/lib/systemd/system - install -d $(DESTDIR)/usr/sbin install -m 775 \ findoid sanoid sleepymutex syncoid \ @@ -25,6 +21,8 @@ override_dh_auto_install: install -m 664 sanoid.conf \ $(DESTDIR)/usr/share/doc/sanoid/sanoid.conf.example + dh_installsystemd --name sanoid-prune + override_dh_installinit: dh_installinit --noscripts diff --git a/packages/debian/sanoid-prune.service b/packages/debian/sanoid.sanoid-prune.service similarity index 100% rename from packages/debian/sanoid-prune.service rename to packages/debian/sanoid.sanoid-prune.service diff --git a/packages/rhel/sanoid.spec b/packages/rhel/sanoid.spec index b4452e8..ce48247 100644 --- a/packages/rhel/sanoid.spec +++ b/packages/rhel/sanoid.spec @@ -1,4 +1,4 @@ -%global version 2.1.0 +%global version 2.3.0 %global git_tag v%{version} # Enable with systemctl "enable sanoid.timer" @@ -111,13 +111,17 @@ echo "* * * * * root %{_sbindir}/sanoid --cron" > %{buildroot}%{_docdir}/%{name} %endif %changelog -* Wed Nov 24 2020 Christoph Klaffl - 2.1.0 +* Tue Jun 05 2025 Christoph Klaffl - 2.3.0 +- Bump to 2.3.0 +* Tue Jul 18 2023 Christoph Klaffl - 2.2.0 +- Bump to 2.2.0 +* Tue Nov 24 2020 Christoph Klaffl - 2.1.0 - Bump to 2.1.0 * Wed Oct 02 2019 Christoph Klaffl - 2.0.3 - Bump to 2.0.3 * Wed Sep 25 2019 Christoph Klaffl - 2.0.2 - Bump to 2.0.2 -* Wed Dec 04 2018 Christoph Klaffl - 2.0.0 +* Tue Dec 04 2018 Christoph Klaffl - 2.0.0 - Bump to 2.0.0 * Sat Apr 28 2018 Dominic Robinson - 1.4.18-1 - Bump to 1.4.18 diff --git a/sanoid b/sanoid index 13ea085..8e0d186 100755 --- a/sanoid +++ b/sanoid @@ -4,7 +4,7 @@ # from http://www.gnu.org/licenses/gpl-3.0.html on 2014-11-17. A copy should also be available in this # project's Git repository at https://github.com/jimsalterjrs/sanoid/blob/master/LICENSE. -$::VERSION = '2.1.0'; +$::VERSION = '2.3.0'; my $MINIMUM_DEFAULTS_VERSION = 2; use strict; @@ -12,6 +12,7 @@ use warnings; use Config::IniFiles; # read samba-style conf file use Data::Dumper; # debugging - print contents of hash use File::Path 'make_path'; +use File::Copy; use Getopt::Long qw(:config auto_version auto_help); use Pod::Usage; # pod2usage use Time::Local; # to parse dates in reverse @@ -26,11 +27,11 @@ GetOptions(\%args, "verbose", "debug", "cron", "readonly", "quiet", "configdir=s", "cache-dir=s", "run-dir=s", "monitor-health", "force-update", "monitor-snapshots", "take-snapshots", "prune-snapshots", "force-prune", - "monitor-capacity" + "monitor-capacity", "cache-ttl=i" ) or pod2usage(2); # If only config directory (or nothing) has been specified, default to --cron --verbose -if (keys %args < 2) { +if (keys %args < 4) { $args{'cron'} = 1; $args{'verbose'} = 1; } @@ -46,26 +47,82 @@ my $zpool = 'zpool'; my $conf_file = "$args{'configdir'}/sanoid.conf"; my $default_conf_file = "$args{'configdir'}/sanoid.defaults.conf"; -# parse config file -my %config = init($conf_file,$default_conf_file); - my $cache_dir = $args{'cache-dir'}; my $run_dir = $args{'run-dir'}; make_path($cache_dir); make_path($run_dir); -# if we call getsnaps(%config,1) it will forcibly update the cache, TTL or no TTL -my $forcecacheupdate = 0; +my $cacheTTL = 1200; # 20 minutes + +if ($args{'force-prune'}) { + warn "WARN: --force-prune argument is deprecated and its behavior is now standard"; +} + +if ($args{'cache-ttl'}) { + if ($args{'cache-ttl'} < 0) { + die "ERROR: cache-ttl needs to be positive!\n"; + } + $cacheTTL = $args{'cache-ttl'}; +} + +# Allow a much older snapshot cache file than default if _only_ "--monitor-*" action commands are given +# (ignore "--verbose", "--configdir" etc) +if ( + ( + $args{'monitor-snapshots'} + || $args{'monitor-health'} + || $args{'monitor-capacity'} + ) && ! ( + $args{'cron'} + || $args{'force-update'} + || $args{'take-snapshots'} + || $args{'prune-snapshots'} + || $args{'cache-ttl'} + ) +) { + # The command combination above must not assert true for any command that takes or prunes snapshots + $cacheTTL = 18000; # 5 hours + if ($args{'debug'}) { print "DEBUG: command combo means that the cache file (provided it exists) will be allowed to be older than default.\n"; } +} + +# snapshot cache my $cache = "$cache_dir/snapshots.txt"; -my $cacheTTL = 900; # 15 minutes -my %snaps = getsnaps( \%config, $cacheTTL, $forcecacheupdate ); + +# configured dataset cache +my $cachedatasetspath = "$cache_dir/datasets.txt"; +my @cachedatasets; + +# parse config file +my %config = init($conf_file,$default_conf_file); + my %pruned; my %capacitycache; +my %taken; -my %snapsbytype = getsnapsbytype( \%config, \%snaps ); +my %snaps; +my %snapsbytype; +my %snapsbypath; -my %snapsbypath = getsnapsbypath( \%config, \%snaps ); +# get snapshot list only if needed +if ($args{'monitor-snapshots'} + || $args{'monitor-health'} + || $args{'cron'} + || $args{'take-snapshots'} + || $args{'prune-snapshots'} + || $args{'force-update'} + || $args{'debug'} +) { + my $forcecacheupdate = 0; + if ($args{'force-update'}) { + $forcecacheupdate = 1; + } + + %snaps = getsnaps( \%config, $cacheTTL, $forcecacheupdate); + + %snapsbytype = getsnapsbytype( \%config, \%snaps ); + %snapsbypath = getsnapsbypath( \%config, \%snaps ); +} # let's make it a little easier to be consistent passing these hashes in the same order to each sub my @params = ( \%config, \%snaps, \%snapsbytype, \%snapsbypath ); @@ -74,7 +131,6 @@ if ($args{'debug'}) { $args{'verbose'}=1; blabber (@params); } if ($args{'monitor-snapshots'}) { monitor_snapshots(@params); } if ($args{'monitor-health'}) { monitor_health(@params); } if ($args{'monitor-capacity'}) { monitor_capacity(@params); } -if ($args{'force-update'}) { my $snaps = getsnaps( \%config, $cacheTTL, 1 ); } if ($args{'cron'}) { if ($args{'quiet'}) { $args{'verbose'} = 0; } @@ -130,7 +186,7 @@ sub monitor_snapshots { my ($config, $snaps, $snapsbytype, $snapsbypath) = @_; my %datestamp = get_date(); - my $errorlevel = 0; + my $errlevel = 0; my $msg; my @msgs; my @paths; @@ -169,7 +225,7 @@ sub monitor_snapshots { my $dispcrit = displaytime($crit); if ( $elapsed > $crit || $elapsed == -1) { if ($crit > 0) { - if (! $config{$section}{'monitor_dont_crit'}) { $errorlevel = 2; } + if (! $config{$section}{'monitor_dont_crit'}) { $errlevel = 2; } if ($elapsed == -1) { push @msgs, "CRIT: $path has no $type snapshots at all!"; } else { @@ -178,7 +234,7 @@ sub monitor_snapshots { } } elsif ($elapsed > $warn) { if ($warn > 0) { - if (! $config{$section}{'monitor_dont_warn'} && ($errorlevel < 2) ) { $errorlevel = 1; } + if (! $config{$section}{'monitor_dont_warn'} && ($errlevel < 2) ) { $errlevel = 1; } push @msgs, "WARN: $path newest $type snapshot is $dispelapsed old (should be < $dispwarn)"; } } else { @@ -196,7 +252,7 @@ sub monitor_snapshots { if ($msg eq '') { $msg = "OK: all monitored datasets \($paths\) have fresh snapshots"; } print "$msg\n"; - exit $errorlevel; + exit $errlevel; } @@ -265,7 +321,6 @@ sub prune_snapshots { my ($config, $snaps, $snapsbytype, $snapsbypath) = @_; my %datestamp = get_date(); - my $forcecacheupdate = 0; foreach my $section (keys %config) { if ($section =~ /^template/) { next; } @@ -319,29 +374,43 @@ sub prune_snapshots { if (checklock('sanoid_pruning')) { writelock('sanoid_pruning'); foreach my $snap( @prunesnaps ){ - if ($args{'verbose'}) { print "INFO: pruning $snap ... \n"; } - if (!$args{'force-prune'} && iszfsbusy($path)) { - if ($args{'verbose'}) { print "INFO: deferring pruning of $snap - $path is currently in zfs send or receive.\n"; } - } else { - if (! $args{'readonly'}) { - if (system($zfs, "destroy", $snap) == 0) { - $pruned{$snap} = 1; - my $dataset = (split '@', $snap)[0]; - my $snapname = (split '@', $snap)[1]; - if ($config{$dataset}{'pruning_script'}) { - $ENV{'SANOID_TARGET'} = $dataset; - $ENV{'SANOID_SNAPNAME'} = $snapname; - $ENV{'SANOID_SCRIPT'} = 'prune'; - if ($args{'verbose'}) { print "executing pruning_script '".$config{$dataset}{'pruning_script'}."' on dataset '$dataset'\n"; } - my $ret = runscript('pruning_script',$dataset); + my $dataset = (split '@', $snap)[0]; + my $snapname = (split '@', $snap)[1]; - delete $ENV{'SANOID_TARGET'}; - delete $ENV{'SANOID_SNAPNAME'}; - delete $ENV{'SANOID_SCRIPT'}; - } - } else { - warn "could not remove $snap : $?"; + if (! $args{'readonly'} && $config{$dataset}{'pre_pruning_script'}) { + $ENV{'SANOID_TARGET'} = $dataset; + $ENV{'SANOID_SNAPNAME'} = $snapname; + if ($args{'verbose'}) { print "executing pre_pruning_script '".$config{$dataset}{'pre_pruning_script'}."' on dataset '$dataset'\n"; } + my $ret = runscript('pre_pruning_script', $dataset); + + delete $ENV{'SANOID_TARGET'}; + delete $ENV{'SANOID_SNAPNAME'}; + + if ($ret != 0) { + # warning was already thrown by runscript function + # skip pruning if pre snapshot script returns non zero exit code + next; + } + } + + if ($args{'verbose'}) { print "INFO: pruning $snap ... \n"; } + + if (! $args{'readonly'}) { + if (system($zfs, "destroy", $snap) == 0) { + $pruned{$snap} = 1; + if ($config{$dataset}{'pruning_script'}) { + $ENV{'SANOID_TARGET'} = $dataset; + $ENV{'SANOID_SNAPNAME'} = $snapname; + $ENV{'SANOID_SCRIPT'} = 'prune'; + if ($args{'verbose'}) { print "executing pruning_script '".$config{$dataset}{'pruning_script'}."' on dataset '$dataset'\n"; } + my $ret = runscript('pruning_script',$dataset); + + delete $ENV{'SANOID_TARGET'}; + delete $ENV{'SANOID_SNAPNAME'}; + delete $ENV{'SANOID_SCRIPT'}; } + } else { + warn "could not remove $snap : $?"; } } } @@ -533,6 +602,7 @@ sub take_snapshots { } if (%newsnapsgroup) { + $forcecacheupdate = 0; while ((my $path, my $snapData) = each(%newsnapsgroup)) { my $recursiveFlag = $snapData->{recursive}; my $dstHandling = $snapData->{handleDst}; @@ -603,9 +673,17 @@ sub take_snapshots { } }; + if ($exit == 0) { + $taken{$snap} = { + 'time' => time(), + 'recursive' => $recursiveFlag + }; + } + $exit == 0 or do { if ($dstHandling) { if ($stderr =~ /already exists/) { + $forcecacheupdate = 1; $exit = 0; $snap =~ s/_([a-z]+)$/dst_$1/g; if ($args{'verbose'}) { print "taking dst snapshot $snap$extraMessage\n"; } @@ -655,8 +733,8 @@ sub take_snapshots { } } } - $forcecacheupdate = 1; - %snaps = getsnaps(%config,$cacheTTL,$forcecacheupdate); + addcachedsnapshots(); + %snaps = getsnaps(\%config,$cacheTTL,$forcecacheupdate); } } @@ -799,7 +877,7 @@ sub getsnaps { if (checklock('sanoid_cacheupdate')) { writelock('sanoid_cacheupdate'); if ($args{'verbose'}) { - if ($args{'force-update'}) { + if ($forcecacheupdate) { print "INFO: cache forcibly expired - updating from zfs list.\n"; } else { print "INFO: cache expired - updating from zfs list.\n"; @@ -809,9 +887,10 @@ sub getsnaps { @rawsnaps = ; close FH; - open FH, "> $cache" or die 'Could not write to $cache!\n'; + open FH, "> $cache.tmp" or die "Could not write to $cache.tmp!\n"; print FH @rawsnaps; close FH; + rename("$cache.tmp", "$cache") or die "Could not rename to $cache!\n"; removelock('sanoid_cacheupdate'); } else { if ($args{'verbose'}) { print "INFO: deferring cache update - valid cache update lock held by another sanoid process.\n"; } @@ -874,6 +953,20 @@ sub init { die "FATAL: you're using sanoid.defaults.conf v$defaults_version, this version of sanoid requires a minimum sanoid.defaults.conf v$MINIMUM_DEFAULTS_VERSION"; } + my @updatedatasets; + + # load dataset cache if valid + if (!$args{'force-update'} && -f $cachedatasetspath) { + my ($dev, $ino, $mode, $nlink, $uid, $gid, $rdev, $size, $atime, $mtime, $ctime, $blksize, $blocks) = stat($cachedatasetspath); + + if ((time() - $mtime) <= $cacheTTL) { + if ($args{'debug'}) { print "DEBUG: dataset cache not expired (" . (time() - $mtime) . " seconds old with TTL of $cacheTTL): pulling dataset list from cache.\n"; } + open FH, "< $cachedatasetspath"; + @cachedatasets = ; + close FH; + } + } + foreach my $section (keys %ini) { # first up - die with honor if unknown parameters are set in any modules or templates by the user. @@ -881,6 +974,15 @@ sub init { if (! defined ($defaults{'template_default'}{$key})) { die "FATAL ERROR: I don't understand the setting $key you've set in \[$section\] in $conf_file.\n"; } + + # in case of duplicate lines we will end up with an array of all values + my $value = $ini{$section}{$key}; + if (ref($value) eq 'ARRAY') { + warn "duplicate key '$key' in section '$section', using the value from the first occurence and ignoring the others.\n"; + $ini{$section}{$key} = $value->[0]; + } + # trim + $ini{$section}{$key} =~ s/^\s+|\s+$//g; } if ($section =~ /^template_/) { next; } # don't process templates directly @@ -889,7 +991,7 @@ sub init { # for sections directly when they've already been defined recursively, without starting them over from scratch. if (! defined ($config{$section}{'initialized'})) { if ($args{'debug'}) { print "DEBUG: initializing \$config\{$section\} with default values from $default_conf_file.\n"; } - # set default values from %defaults, which can then be overriden by template + # set default values from %defaults, which can then be overridden by template # and/or local settings within the module. foreach my $key (keys %{$defaults{'template_default'}}) { if (! ($key =~ /template|recursive|children_only/)) { @@ -925,6 +1027,12 @@ sub init { } if ($args{'debug'}) { print "DEBUG: overriding $key on $section with value from user-defined template $template.\n"; } $config{$section}{$key} = $ini{$template}{$key}; + + my $value = $config{$section}{$key}; + if (ref($value) eq 'ARRAY') { + # handle duplicates silently (warning was already printed above) + $config{$section}{$key} = $value->[0]; + } } } } @@ -954,6 +1062,10 @@ sub init { $config{$section}{'path'} = $section; } + if (! @cachedatasets) { + push (@updatedatasets, "$config{$section}{'path'}\n"); + } + # how 'bout some recursion? =) if ($config{$section}{'zfs_recursion'} && $config{$section}{'zfs_recursion'} == 1 && $config{$section}{'autosnap'} == 1) { warn "ignored autosnap configuration for '$section' because it's part of a zfs recursion.\n"; @@ -971,7 +1083,9 @@ sub init { @datasets = getchilddatasets($config{$section}{'path'}); DATASETS: foreach my $dataset(@datasets) { - chomp $dataset; + if (! @cachedatasets) { + push (@updatedatasets, "$dataset\n"); + } if ($zfsRecursive) { # don't try to take the snapshot ourself, recursive zfs snapshot will take care of that @@ -1002,9 +1116,27 @@ sub init { $config{$dataset}{'initialized'} = 1; } } + } - - + # update dataset cache if it was unused + if (! @cachedatasets) { + if (checklock('sanoid_cachedatasetupdate')) { + writelock('sanoid_cachedatasetupdate'); + if ($args{'verbose'}) { + if ($args{'force-update'}) { + print "INFO: dataset cache forcibly expired - updating from zfs list.\n"; + } else { + print "INFO: dataset cache expired - updating from zfs list.\n"; + } + } + open FH, "> $cachedatasetspath.tmp" or die "Could not write to $cachedatasetspath.tmp!\n"; + print FH @updatedatasets; + close FH; + rename("$cachedatasetspath.tmp", "$cachedatasetspath") or die "Could not rename to $cachedatasetspath!\n"; + removelock('sanoid_cachedatasetupdate'); + } else { + if ($args{'verbose'}) { print "INFO: deferring dataset cache update - valid cache update lock held by another sanoid process.\n"; } + } } return %config; @@ -1137,7 +1269,7 @@ sub check_zpool() { } } - # Tony: Debuging + # Tony: Debugging # print "Size: $size \t Used: $used \t Avai: $avail \t Cap: $cap \t Health: $health\n"; close(STAT); @@ -1239,7 +1371,7 @@ sub check_zpool() { ## no display for verbose level 1 next if ($verbose==1); ## don't display working devices for verbose level 2 - if ($verbose==2 && ($state eq "OK" || $sta eq "ONLINE" || $sta eq "AVAIL" || $sta eq "INUSE")) { + if ($verbose==2 && ($state eq "OK" || $sta eq "ONLINE" || $sta eq "AVAIL")) { # check for io/checksum errors my @vdeverr = (); @@ -1521,30 +1653,6 @@ sub writelock { close FH; } -sub iszfsbusy { - # check to see if ZFS filesystem passed in as argument currently has a zfs send or zfs receive process referencing it. - # return true if busy (currently being sent or received), return false if not. - - my $fs = shift; - # if (args{'debug'}) { print "DEBUG: checking to see if $fs on is already in zfs receive using $pscmd -Ao args= ...\n"; } - - open PL, "$pscmd -Ao args= |"; - my @processes = ; - close PL; - - foreach my $process (@processes) { - # if ($args{'debug'}) { print "DEBUG: checking process $process...\n"; } - if ($process =~ /zfs *(send|receive|recv).*$fs/) { - # there's already a zfs send/receive process for our target filesystem - return true - # if ($args{'debug'}) { print "DEBUG: process $process matches target $fs!\n"; } - return 1; - } - } - - # no zfs receive processes for our target filesystem found - return false - return 0; -} - #######################################################################################################################3 #######################################################################################################################3 #######################################################################################################################3 @@ -1554,10 +1662,34 @@ sub getchilddatasets { my $fs = shift; my $mysudocmd = ''; + # use dataset cache if available + if (@cachedatasets) { + my $foundparent = 0; + my @cachechildren = (); + foreach my $dataset (@cachedatasets) { + chomp $dataset; + my $ret = rindex $dataset, "${fs}/", 0; + if ($ret == 0) { + push (@cachechildren, $dataset); + } else { + if ($dataset eq $fs) { + $foundparent = 1; + } + } + } + + # sanity check + if ($foundparent) { + return @cachechildren; + } + + # fallback if cache misses items for whatever reason + } + my $getchildrencmd = "$mysudocmd $zfs list -o name -t filesystem,volume -Hr $fs |"; if ($args{'debug'}) { print "DEBUG: getting list of child datasets on $fs using $getchildrencmd...\n"; } open FH, $getchildrencmd; - my @children = ; + chomp( my @children = ); close FH; # parent dataset is the first element @@ -1600,7 +1732,7 @@ sub removecachedsnapshots { my @rawsnaps = ; close FH; - open FH, "> $cache" or die 'Could not write to $cache!\n'; + open FH, "> $cache.tmp" or die "Could not write to $cache.tmp!\n"; foreach my $snapline ( @rawsnaps ) { my @columns = split("\t", $snapline); my $snap = $columns[0]; @@ -1608,8 +1740,14 @@ sub removecachedsnapshots { } close FH; + # preserve mtime of cache for expire check + my ($dev, $ino, $mode, $nlink, $uid, $gid, $rdev, $size, $atime, $mtime, $ctime, $blksize, $blocks) = stat($cache); + utime($atime, $mtime, "$cache.tmp"); + + rename("$cache.tmp", "$cache") or die "Could not rename to $cache!\n"; + removelock('sanoid_cacheupdate'); - %snaps = getsnaps(\%config,$cacheTTL,$forcecacheupdate); + %snaps = getsnaps(\%config,$cacheTTL,0); # clear hash undef %pruned; @@ -1619,6 +1757,62 @@ sub removecachedsnapshots { #######################################################################################################################3 #######################################################################################################################3 +sub addcachedsnapshots { + if (not %taken) { + return; + } + + my $unlocked = checklock('sanoid_cacheupdate'); + + # wait until we can get a lock to do our cache changes + while (not $unlocked) { + if ($args{'verbose'}) { print "INFO: waiting for cache update lock held by another sanoid process.\n"; } + sleep(10); + $unlocked = checklock('sanoid_cacheupdate'); + } + + writelock('sanoid_cacheupdate'); + + if ($args{'verbose'}) { + print "INFO: adding taken snapshots to cache.\n"; + } + + copy($cache, "$cache.tmp") or die "Could not copy to $cache.tmp!\n"; + + open my $fh, ">> $cache.tmp" or die "Could not write to $cache.tmp!\n"; + while((my $snap, my $details) = each(%taken)) { + my @parts = split("@", $snap, 2); + + my $suffix = $parts[1] . "\tcreation\t" . $details->{time} . "\t-"; + my $dataset = $parts[0]; + + print $fh "${dataset}\@${suffix}\n"; + + if ($details->{recursive}) { + my @datasets = getchilddatasets($dataset); + + foreach my $dataset(@datasets) { + print "${dataset}\@${suffix}\n"; + print $fh "${dataset}\@${suffix}\n"; + } + } + } + + close $fh; + + # preserve mtime of cache for expire check + my ($dev, $ino, $mode, $nlink, $uid, $gid, $rdev, $size, $atime, $mtime, $ctime, $blksize, $blocks) = stat($cache); + utime($atime, $mtime, "$cache.tmp"); + + rename("$cache.tmp", "$cache") or die "Could not rename to $cache!\n"; + + removelock('sanoid_cacheupdate'); +} + +#######################################################################################################################3 +#######################################################################################################################3 +#######################################################################################################################3 + sub runscript { my $key=shift; my $dataset=shift; @@ -1716,7 +1910,7 @@ Options: --monitor-snapshots Reports on snapshot "health", in a Nagios compatible format --take-snapshots Creates snapshots as specified in sanoid.conf --prune-snapshots Purges expired snapshots as specified in sanoid.conf - --force-prune Purges expired snapshots even if a send/recv is in progress + --cache-ttl=SECONDS Set custom cache expire time in seconds (default: 20 minutes) --help Prints this helptext --version Prints the version number diff --git a/sanoid.conf b/sanoid.conf index 8504b93..04e281f 100644 --- a/sanoid.conf +++ b/sanoid.conf @@ -31,6 +31,13 @@ # you can also handle datasets recursively in an atomic way without the possibility to override settings for child datasets. [zpoolname/parent2] use_template = production + # there are two options for recursive: zfs or yes + # * zfs - taken a zfs snapshot with the '-r' flag; zfs will recursively take a snapshot of the whole + # dataset tree which is consistent. Newly-added child datasets will not immediately get snapshots, + # and must instead slowly catch up to policy over time. Slightly lower storage load. + # + # * yes - the snapshots will be taken one-at-time through the sanoid code; not necessarily consistent. + # newly added child datasets will be immediately brought into policy. Slightly higher storage load. recursive = zfs @@ -102,6 +109,8 @@ pre_snapshot_script = /path/to/script.sh ### run script after snapshot post_snapshot_script = /path/to/script.sh + ### run script before pruning snapshot + pre_pruning_script = /path/to/script.sh ### run script after pruning snapshot pruning_script = /path/to/script.sh ### don't take an inconsistent snapshot (skip if pre script fails) diff --git a/sanoid.defaults.conf b/sanoid.defaults.conf index 2eb6c55..0e46699 100644 --- a/sanoid.defaults.conf +++ b/sanoid.defaults.conf @@ -22,6 +22,7 @@ skip_children = # See "Sanoid script hooks" in README.md for information about scripts. pre_snapshot_script = post_snapshot_script = +pre_pruning_script = pruning_script = script_timeout = 5 no_inconsistent_snapshot = diff --git a/syncoid b/syncoid index 3f112de..680afbe 100755 --- a/syncoid +++ b/syncoid @@ -4,7 +4,7 @@ # from http://www.gnu.org/licenses/gpl-3.0.html on 2014-11-17. A copy should also be available in this # project's Git repository at https://github.com/jimsalterjrs/sanoid/blob/master/LICENSE. -$::VERSION = '2.1.0'; +$::VERSION = '2.3.0'; use strict; use warnings; @@ -20,21 +20,33 @@ my $pvoptions = "-p -t -e -r -b"; # Blank defaults to use ssh client's default # TODO: Merge into a single "sshflags" option? -my %args = ('sshkey' => '', 'sshport' => '', 'sshcipher' => '', 'sshoption' => [], 'target-bwlimit' => '', 'source-bwlimit' => ''); +my %args = ('sshconfig' => '', 'sshkey' => '', 'sshport' => '', 'sshcipher' => '', 'sshoption' => [], 'target-bwlimit' => '', 'source-bwlimit' => ''); GetOptions(\%args, "no-command-checks", "monitor-version", "compress=s", "dumpsnaps", "recursive|r", "sendoptions=s", "recvoptions=s", - "source-bwlimit=s", "target-bwlimit=s", "sshkey=s", "sshport=i", "sshcipher|c=s", "sshoption|o=s@", + "source-bwlimit=s", "target-bwlimit=s", "sshconfig=s", "sshkey=s", "sshport=i", "sshcipher|c=s", "sshoption|o=s@", "debug", "quiet", "no-stream", "no-sync-snap", "no-resume", "exclude=s@", "skip-parent", "identifier=s", - "no-clone-handling", "no-privilege-elevation", "force-delete", "no-clone-rollback", "no-rollback", - "create-bookmark", "pv-options=s" => \$pvoptions, "keep-sync-snap", "preserve-recordsize", - "mbuffer-size=s" => \$mbuffer_size) or pod2usage(2); + "no-clone-handling", "no-privilege-elevation", "force-delete", "no-rollback", "create-bookmark", "use-hold", + "pv-options=s" => \$pvoptions, "keep-sync-snap", "preserve-recordsize", "mbuffer-size=s" => \$mbuffer_size, + "delete-target-snapshots", "insecure-direct-connection=s", "preserve-properties", + "include-snaps=s@", "exclude-snaps=s@", "exclude-datasets=s@") + or pod2usage(2); my %compressargs = %{compressargset($args{'compress'} || 'default')}; # Can't be done with GetOptions arg, as default still needs to be set +if (defined($args{'exclude'})) { + writelog('WARN', 'The --exclude option is deprecated, please use --exclude-datasets instead'); + + # If both --exclude and --exclude-datasets are provided, then ignore + # --exclude + if (!defined($args{'exclude-datasets'})) { + $args{'exclude-datasets'} = $args{'exclude'}; + } +} + my @sendoptions = (); if (length $args{'sendoptions'}) { @sendoptions = parsespecialoptions($args{'sendoptions'}); if (! defined($sendoptions[0])) { - warn "invalid send options!"; + writelog('WARN', "invalid send options!"); pod2usage(2); exit 127; } @@ -42,7 +54,7 @@ if (length $args{'sendoptions'}) { if (defined $args{'recursive'}) { foreach my $option(@sendoptions) { if ($option->{option} eq 'R') { - warn "invalid argument combination, zfs send -R and --recursive aren't compatible!"; + writelog('WARN', "invalid argument combination, zfs send -R and --recursive aren't compatible!"); pod2usage(2); exit 127; } @@ -54,7 +66,7 @@ my @recvoptions = (); if (length $args{'recvoptions'}) { @recvoptions = parsespecialoptions($args{'recvoptions'}); if (! defined($recvoptions[0])) { - warn "invalid receive options!"; + writelog('WARN', "invalid receive options!"); pod2usage(2); exit 127; } @@ -63,7 +75,7 @@ if (length $args{'recvoptions'}) { # TODO Expand to accept multiple sources? if (scalar(@ARGV) != 2) { - print("Source or target not found!\n"); + writelog('WARN', "Source or target not found!"); pod2usage(2); exit 127; } else { @@ -96,8 +108,9 @@ my $pscmd = 'ps'; my $pvcmd = 'pv'; my $mbuffercmd = 'mbuffer'; +my $socatcmd = 'socat'; my $sudocmd = 'sudo'; -my $mbufferoptions = "-q -s 128k -m $mbuffer_size 2>/dev/null"; +my $mbufferoptions = "-q -s 128k -m $mbuffer_size"; # currently using POSIX compatible command to check for program existence because we aren't depending on perl # being present on remote machines. my $checkcmd = 'command -v'; @@ -108,6 +121,9 @@ if (length $args{'sshcipher'}) { if (length $args{'sshport'}) { $args{'sshport'} = "-p $args{'sshport'}"; } +if (length $args{'sshconfig'}) { + $args{'sshconfig'} = "-F $args{'sshconfig'}"; +} if (length $args{'sshkey'}) { $args{'sshkey'} = "-i $args{'sshkey'}"; } @@ -117,7 +133,7 @@ my $identifier = ""; if (length $args{'identifier'}) { if ($args{'identifier'} !~ /^[a-zA-Z0-9-_:.]+$/) { # invalid extra identifier - print("CRITICAL: extra identifier contains invalid chars!\n"); + writelog('WARN', "extra identifier contains invalid chars!"); pod2usage(2); exit 127; } @@ -125,14 +141,54 @@ if (length $args{'identifier'}) { } # figure out if source and/or target are remote. -$sshcmd = "$sshcmd $args{'sshcipher'} $sshoptions $args{'sshport'} $args{'sshkey'}"; -if ($debug) { print "DEBUG: SSHCMD: $sshcmd\n"; } +$sshcmd = "$sshcmd $args{'sshconfig'} $args{'sshcipher'} $sshoptions $args{'sshport'} $args{'sshkey'}"; +writelog('DEBUG', "SSHCMD: $sshcmd"); my ($sourcehost,$sourcefs,$sourceisroot) = getssh($rawsourcefs); my ($targethost,$targetfs,$targetisroot) = getssh($rawtargetfs); my $sourcesudocmd = $sourceisroot ? '' : $sudocmd; my $targetsudocmd = $targetisroot ? '' : $sudocmd; +if (!defined $sourcehost) { $sourcehost = ''; } +if (!defined $targethost) { $targethost = ''; } + +# handle insecure direct connection arguments +my $directconnect = ""; +my $directlisten = ""; +my $directtimeout = 60; +my $directmbuffer = 0; + +if (length $args{'insecure-direct-connection'}) { + if ($sourcehost ne '' && $targethost ne '') { + print("CRITICAL: relaying between remote hosts is not supported with insecure direct connection!\n"); + pod2usage(2); + exit 127; + } + + my @parts = split(',', $args{'insecure-direct-connection'}); + if (scalar @parts > 4) { + print("CRITICAL: invalid insecure-direct-connection argument!\n"); + pod2usage(2); + exit 127; + } elsif (scalar @parts >= 2) { + $directconnect = $parts[0]; + $directlisten = $parts[1]; + } else { + $directconnect = $args{'insecure-direct-connection'}; + $directlisten = $args{'insecure-direct-connection'}; + } + + if (scalar @parts >= 3) { + $directtimeout = $parts[2]; + } + + if (scalar @parts == 4) { + if ($parts[3] eq "mbuffer") { + $directmbuffer = 1; + } + } +} + # figure out whether compression, mbuffering, pv # are available on source, target, local machines. # warn user of anything missing, then continue with sync. @@ -141,6 +197,8 @@ my %avail = checkcommands(); my %snaps; my $exitcode = 0; +my $replicationCount = 0; + ## break here to call replication individually so that we ## ## can loop across children separately, for recursive ## ## replication ## @@ -148,11 +206,11 @@ my $exitcode = 0; if (!defined $args{'recursive'}) { syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef); } else { - if ($debug) { print "DEBUG: recursive sync of $sourcefs.\n"; } + writelog('DEBUG', "recursive sync of $sourcefs."); my @datasets = getchilddatasets($sourcehost, $sourcefs, $sourceisroot); if (!@datasets) { - warn "CRITICAL ERROR: no datasets found"; + writelog('CRITICAL', "no datasets found"); @datasets = (); $exitcode = 2; } @@ -191,7 +249,6 @@ if (!defined $args{'recursive'}) { chomp $dataset; my $childsourcefs = $sourcefs . $dataset; my $childtargetfs = $targetfs . $dataset; - # print "syncdataset($sourcehost, $childsourcefs, $targethost, $childtargetfs); \n"; syncdataset($sourcehost, $childsourcefs, $targethost, $childtargetfs, $origin); } @@ -238,7 +295,7 @@ sub getchilddatasets { } my $getchildrencmd = "$rhost $mysudocmd $zfscmd list -o name,origin -t filesystem,volume -Hr $fsescaped |"; - if ($debug) { print "DEBUG: getting list of child datasets on $fs using $getchildrencmd...\n"; } + writelog('DEBUG', "getting list of child datasets on $fs using $getchildrencmd..."); if (! open FH, $getchildrencmd) { die "ERROR: list command failed!\n"; } @@ -257,11 +314,11 @@ sub getchilddatasets { my ($dataset, $origin) = /^([^\t]+)\t([^\t]+)/; - if (defined $args{'exclude'}) { - my $excludes = $args{'exclude'}; + if (defined $args{'exclude-datasets'}) { + my $excludes = $args{'exclude-datasets'}; foreach (@$excludes) { if ($dataset =~ /$_/) { - if ($debug) { print "DEBUG: excluded $dataset because of $_\n"; } + writelog('DEBUG', "excluded $dataset because of $_"); next DATASETS; } } @@ -294,19 +351,19 @@ sub syncdataset { $forcedrecv = ""; } - if ($debug) { print "DEBUG: syncing source $sourcefs to target $targetfs.\n"; } + writelog('DEBUG', "syncing source $sourcefs to target $targetfs."); my ($sync, $error) = getzfsvalue($sourcehost,$sourcefs,$sourceisroot,'syncoid:sync'); if (!defined $sync) { # zfs already printed the corresponding error - if ($error =~ /\bdataset does not exist\b/) { - if (!$quiet) { print "WARN Skipping dataset (dataset no longer exists): $sourcefs...\n"; } + if ($error =~ /\bdataset does not exist\b/ && $replicationCount > 0) { + writelog('WARN', "Skipping dataset (dataset no longer exists): $sourcefs..."); return 0; } else { # print the error out and set exit code - print "ERROR: $error\n"; + writelog('CRITICAL', "$error"); if ($exitcode < 2) { $exitcode = 2 } } @@ -317,20 +374,20 @@ sub syncdataset { # empty is handled the same as unset (aka: '-') # definitely sync this dataset - if a host is called 'true' or '-', then you're special } elsif ($sync eq 'false') { - if (!$quiet) { print "INFO: Skipping dataset (syncoid:sync=false): $sourcefs...\n"; } + writelog('INFO', "Skipping dataset (syncoid:sync=false): $sourcefs..."); return 0; } else { my $hostid = hostname(); my @hosts = split(/,/,$sync); if (!(grep $hostid eq $_, @hosts)) { - if (!$quiet) { print "INFO: Skipping dataset (syncoid:sync doesn't include $hostid): $sourcefs...\n"; } + writelog('INFO', "Skipping dataset (syncoid:sync doesn't include $hostid): $sourcefs..."); return 0; } } # make sure target is not currently in receive. if (iszfsbusy($targethost,$targetfs,$targetisroot)) { - warn "Cannot sync now: $targetfs is already target of a zfs receive process.\n"; + writelog('WARN', "Cannot sync now: $targetfs is already target of a zfs receive process."); if ($exitcode < 1) { $exitcode = 1; } return 0; } @@ -338,23 +395,21 @@ sub syncdataset { # does the target filesystem exist yet? my $targetexists = targetexists($targethost,$targetfs,$targetisroot); - my $receiveextraargs = ""; my $receivetoken; - if ($resume) { - # save state of interrupted receive stream - $receiveextraargs = "-s"; + if ($resume) { if ($targetexists) { # check remote dataset for receive resume token (interrupted receive) $receivetoken = getreceivetoken($targethost,$targetfs,$targetisroot); - if ($debug && defined($receivetoken)) { - print "DEBUG: got receive resume token: $receivetoken: \n"; + if (defined($receivetoken)) { + writelog('DEBUG', "got receive resume token: $receivetoken: "); } } } my $newsyncsnap; + my $matchingsnap; # skip snapshot checking/creation in case of resumed receive if (!defined($receivetoken)) { @@ -369,9 +424,8 @@ sub syncdataset { } if (defined $args{'dumpsnaps'}) { - print "merged snapshot list of $targetfs: \n"; + writelog('INFO', "merged snapshot list of $targetfs: "); dumphash(\%snaps); - print "\n\n\n"; } if (!defined $args{'no-sync-snap'} && !defined $skipsnapshot) { @@ -381,11 +435,21 @@ sub syncdataset { # we already whined about the error return 0; } + # Don't send the sync snap if it's filtered out by --exclude-snaps or + # --include-snaps + if (!snapisincluded($newsyncsnap)) { + $newsyncsnap = getnewestsnapshot($sourcehost,$sourcefs,$sourceisroot); + if ($newsyncsnap eq 0) { + writelog('WARN', "CRITICAL: no snapshots exist on source $sourcefs, and you asked for --no-sync-snap."); + if ($exitcode < 1) { $exitcode = 1; } + return 0; + } + } } else { # we don't want sync snapshots created, so use the newest snapshot we can find. $newsyncsnap = getnewestsnapshot($sourcehost,$sourcefs,$sourceisroot); if ($newsyncsnap eq 0) { - warn "CRITICAL: no snapshots exist on source $sourcefs, and you asked for --no-sync-snap.\n"; + writelog('WARN', "CRITICAL: no snapshots exist on source $sourcefs, and you asked for --no-sync-snap."); if ($exitcode < 1) { $exitcode = 1; } return 0; } @@ -402,19 +466,14 @@ sub syncdataset { # with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly. #my $originaltargetreadonly; - my $sendoptions = getoptionsline(\@sendoptions, ('D','L','P','R','c','e','h','p','v','w')); - my $recvoptions = getoptionsline(\@recvoptions, ('h','o','x','u','v')); - # sync 'em up. if (! $targetexists) { # do an initial sync from the oldest source snapshot # THEN do an -I to the newest - if ($debug) { - if (!defined ($args{'no-stream'}) ) { - print "DEBUG: target $targetfs does not exist. Finding oldest available snapshot on source $sourcefs ...\n"; - } else { - print "DEBUG: target $targetfs does not exist, and --no-stream selected. Finding newest available snapshot on source $sourcefs ...\n"; - } + if (!defined ($args{'no-stream'}) ) { + writelog('DEBUG', "target $targetfs does not exist. Finding oldest available snapshot on source $sourcefs ..."); + } else { + writelog('DEBUG', "target $targetfs does not exist, and --no-stream selected. Finding newest available snapshot on source $sourcefs ..."); } my $oldestsnap = getoldestsnapshot(\%snaps); if (! $oldestsnap) { @@ -424,7 +483,7 @@ sub syncdataset { } # getoldestsnapshot() returned false, so use new sync snapshot - if ($debug) { print "DEBUG: getoldestsnapshot() returned false, so using $newsyncsnap.\n"; } + writelog('DEBUG', "getoldestsnapshot() returned false, so using $newsyncsnap."); $oldestsnap = $newsyncsnap; } @@ -436,63 +495,23 @@ sub syncdataset { $oldestsnap = $newsyncsnap; } } - my $oldestsnapescaped = escapeshellparam($oldestsnap); - if (defined $args{'preserve-recordsize'}) { - my $type = getzfsvalue($sourcehost,$sourcefs,$sourceisroot,'type'); - if ($type eq "filesystem") { - my $recordsize = getzfsvalue($sourcehost,$sourcefs,$sourceisroot,'recordsize'); - $recvoptions .= "-o recordsize=$recordsize" - } - } - - my $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions $sourcefsescaped\@$oldestsnapescaped"; - my $recvcmd = "$targetsudocmd $zfscmd receive $recvoptions $receiveextraargs $forcedrecv $targetfsescaped"; - - my $pvsize; + my $ret; if (defined $origin) { - my $originescaped = escapeshellparam($origin); - $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions -i $originescaped $sourcefsescaped\@$oldestsnapescaped"; - my $streamargBackup = $args{'streamarg'}; - $args{'streamarg'} = "-i"; - $pvsize = getsendsize($sourcehost,$origin,"$sourcefs\@$oldestsnap",$sourceisroot); - $args{'streamarg'} = $streamargBackup; - } else { - $pvsize = getsendsize($sourcehost,"$sourcefs\@$oldestsnap",0,$sourceisroot); - } - - my $disp_pvsize = readablebytes($pvsize); - if ($pvsize == 0) { $disp_pvsize = 'UNKNOWN'; } - my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot); - if (!$quiet) { - if (defined $origin) { - print "INFO: Clone is recreated on target $targetfs based on $origin\n"; - } - if (!defined ($args{'no-stream'}) ) { - print "INFO: Sending oldest full snapshot $sourcefs\@$oldestsnap (~ $disp_pvsize) to new target filesystem:\n"; - } else { - print "INFO: --no-stream selected; sending newest full snapshot $sourcefs\@$oldestsnap (~ $disp_pvsize) to new target filesystem:\n"; - } - } - if ($debug) { print "DEBUG: $synccmd\n"; } - - # make sure target is (still) not currently in receive. - if (iszfsbusy($targethost,$targetfs,$targetisroot)) { - warn "Cannot sync now: $targetfs is already target of a zfs receive process.\n"; - if ($exitcode < 1) { $exitcode = 1; } - return 0; - } - system($synccmd) == 0 or do { - if (defined $origin) { - print "INFO: clone creation failed, trying ordinary replication as fallback\n"; + ($ret, $stdout) = syncclone($sourcehost, $sourcefs, $origin, $targethost, $targetfs, $oldestsnap); + if ($ret) { + writelog('INFO', "clone creation failed, trying ordinary replication as fallback"); syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef, 1); return 0; } + } else { + ($ret, $stdout) = syncfull($sourcehost, $sourcefs, $targethost, $targetfs, $oldestsnap); + } - warn "CRITICAL ERROR: $synccmd failed: $?"; + if ($ret) { if ($exitcode < 2) { $exitcode = 2; } return 0; - }; + } # now do an -I to the new sync snapshot, assuming there were any snapshots # other than the new sync snapshot to begin with, of course - and that we @@ -506,33 +525,13 @@ sub syncdataset { # $originaltargetreadonly = getzfsvalue($targethost,$targetfs,$targetisroot,'readonly'); # setzfsvalue($targethost,$targetfs,$targetisroot,'readonly','on'); - $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions $args{'streamarg'} $sourcefsescaped\@$oldestsnapescaped $sourcefsescaped\@$newsyncsnapescaped"; - $pvsize = getsendsize($sourcehost,"$sourcefs\@$oldestsnap","$sourcefs\@$newsyncsnap",$sourceisroot); - $disp_pvsize = readablebytes($pvsize); - if ($pvsize == 0) { $disp_pvsize = "UNKNOWN"; } - $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot); + (my $ret, $stdout) = syncincremental($sourcehost, $sourcefs, $targethost, $targetfs, $oldestsnap, $newsyncsnap, 0); - # make sure target is (still) not currently in receive. - if (iszfsbusy($targethost,$targetfs,$targetisroot)) { - warn "Cannot sync now: $targetfs is already target of a zfs receive process.\n"; + if ($ret != 0) { if ($exitcode < 1) { $exitcode = 1; } return 0; } - if (!$quiet) { print "INFO: Updating new target filesystem with incremental $sourcefs\@$oldestsnap ... $newsyncsnap (~ $disp_pvsize):\n"; } - if ($debug) { print "DEBUG: $synccmd\n"; } - - if ($oldestsnap ne $newsyncsnap) { - my $ret = system($synccmd); - if ($ret != 0) { - warn "CRITICAL ERROR: $synccmd failed: $?"; - if ($exitcode < 1) { $exitcode = 1; } - return 0; - } - } else { - if (!$quiet) { print "INFO: no incremental sync needed; $oldestsnap is already the newest available snapshot.\n"; } - } - # restore original readonly value to target after sync complete # dyking this functionality out for the time being due to buggy mount/unmount behavior # with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly. @@ -543,41 +542,18 @@ sub syncdataset { # and because this will ony resume the receive to the next # snapshot, do a normal sync after that if (defined($receivetoken)) { - $sendoptions = getoptionsline(\@sendoptions, ('P','e','v','w')); - my $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions -t $receivetoken"; - my $recvcmd = "$targetsudocmd $zfscmd receive $recvoptions $receiveextraargs $forcedrecv $targetfsescaped 2>&1"; - my $pvsize = getsendsize($sourcehost,"","",$sourceisroot,$receivetoken); - my $disp_pvsize = readablebytes($pvsize); - if ($pvsize == 0) { $disp_pvsize = "UNKNOWN"; } - my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot); - - if (!$quiet) { print "Resuming interrupted zfs send/receive from $sourcefs to $targetfs (~ $disp_pvsize remaining):\n"; } - if ($debug) { print "DEBUG: $synccmd\n"; } - - if ($pvsize == 0) { - # we need to capture the error of zfs send, this will render pv useless but in this case - # it doesn't matter because we don't know the estimated send size (probably because - # the initial snapshot used for resumed send doesn't exist anymore) - ($stdout, $exit) = tee_stderr { - system("$synccmd") - }; - } else { - ($stdout, $exit) = tee_stdout { - system("$synccmd") - }; - } + ($exit, $stdout) = syncresume($sourcehost, $sourcefs, $targethost, $targetfs, $receivetoken); $exit == 0 or do { if ( $stdout =~ /\Qused in the initial send no longer exists\E/ || $stdout =~ /incremental source [0-9xa-f]+ no longer exists/ ) { - if (!$quiet) { print "WARN: resetting partially receive state because the snapshot source no longer exists\n"; } + writelog('WARN', "resetting partially receive state because the snapshot source no longer exists"); resetreceivestate($targethost,$targetfs,$targetisroot); # do an normal sync cycle return syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, $origin); } else { - warn "CRITICAL ERROR: $synccmd failed: $?"; if ($exitcode < 2) { $exitcode = 2; } return 0; } @@ -602,7 +578,7 @@ sub syncdataset { my $bookmark = 0; my $bookmarkcreation = 0; - my $matchingsnap = getmatchingsnapshot($sourcefs, $targetfs, \%snaps); + $matchingsnap = getmatchingsnapshot($sourcefs, $targetfs, \%snaps); if (! $matchingsnap) { # no matching snapshots, check for bookmarks as fallback my %bookmarks = getbookmarks($sourcehost,$sourcefs,$sourceisroot); @@ -621,8 +597,9 @@ sub syncdataset { } if (! $bookmark) { - if ($args{'force-delete'}) { - if (!$quiet) { print "Removing $targetfs because no matching snapshots were found\n"; } + # force delete is not possible for the root dataset + if ($args{'force-delete'} && index($targetfs, '/') != -1) { + writelog('INFO', "Removing $targetfs because no matching snapshots were found"); my $rcommand = ''; my $mysudocmd = ''; @@ -638,7 +615,7 @@ sub syncdataset { my $ret = system("$rcommand $prunecmd"); if ($ret != 0) { - warn "WARNING: $rcommand $prunecmd failed: $?"; + writelog('WARN', "$rcommand $prunecmd failed: $?"); } else { # redo sync and skip snapshot creation (already taken) return syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef, 1); @@ -648,19 +625,27 @@ sub syncdataset { # if we got this far, we failed to find a matching snapshot/bookmark. if ($exitcode < 2) { $exitcode = 2; } - print "\n"; - print "CRITICAL ERROR: Target $targetfs exists but has no snapshots matching with $sourcefs!\n"; - print " Replication to target would require destroying existing\n"; - print " target. Cowardly refusing to destroy your existing target.\n\n"; + my $msg = <<~"EOT"; + + Target $targetfs exists but has no snapshots matching with $sourcefs! + Replication to target would require destroying existing + target. Cowardly refusing to destroy your existing target. + + EOT + + writelog('CRITICAL', $msg); # experience tells me we need a mollyguard for people who try to # zfs create targetpool/targetsnap ; syncoid sourcepool/sourcesnap targetpool/targetsnap ... if ( $targetsize < (64*1024*1024) ) { - print " NOTE: Target $targetfs dataset is < 64MB used - did you mistakenly run\n"; - print " \`zfs create $args{'target'}\` on the target? ZFS initial\n"; - print " replication must be to a NON EXISTENT DATASET, which will\n"; - print " then be CREATED BY the initial replication process.\n\n"; + $msg = <<~"EOT"; + NOTE: Target $targetfs dataset is < 64MB used - did you mistakenly run + `zfs create $args{'target'}` on the target? ZFS initial + replication must be to a NON EXISTENT DATASET, which will + then be CREATED BY the initial replication process. + + EOT } # return false now in case more child datasets need replication. @@ -670,32 +655,17 @@ sub syncdataset { # make sure target is (still) not currently in receive. if (iszfsbusy($targethost,$targetfs,$targetisroot)) { - warn "Cannot sync now: $targetfs is already target of a zfs receive process.\n"; + writelog('WARN', "Cannot sync now: $targetfs is already target of a zfs receive process."); if ($exitcode < 1) { $exitcode = 1; } return 0; } if ($matchingsnap eq $newsyncsnap) { # barf some text but don't touch the filesystem - if (!$quiet) { print "INFO: no snapshots on source newer than $newsyncsnap on target. Nothing to do, not syncing.\n"; } + writelog('INFO', "no snapshots on source newer than $newsyncsnap on target. Nothing to do, not syncing."); return 0; } else { my $matchingsnapescaped = escapeshellparam($matchingsnap); - # rollback target to matchingsnap - if (!defined $args{'no-rollback'}) { - my $rollbacktype = "-R"; - if (defined $args{'no-clone-rollback'}) { - $rollbacktype = "-r"; - } - if ($debug) { print "DEBUG: rolling back target to $targetfs\@$matchingsnap...\n"; } - if ($targethost ne '') { - if ($debug) { print "$sshcmd $targethost $targetsudocmd $zfscmd rollback $rollbacktype $targetfsescaped\@$matchingsnapescaped\n"; } - system ("$sshcmd $targethost " . escapeshellparam("$targetsudocmd $zfscmd rollback $rollbacktype $targetfsescaped\@$matchingsnapescaped")); - } else { - if ($debug) { print "$targetsudocmd $zfscmd rollback $rollbacktype $targetfsescaped\@$matchingsnapescaped\n"; } - system ("$targetsudocmd $zfscmd rollback $rollbacktype $targetfsescaped\@$matchingsnapescaped"); - } - } my $nextsnapshot = 0; @@ -714,35 +684,19 @@ sub syncdataset { } } - # bookmark stream size can't be determined - my $pvsize = 0; - my $disp_pvsize = "UNKNOWN"; - - $sendoptions = getoptionsline(\@sendoptions, ('L','c','e','w')); if ($nextsnapshot) { - my $nextsnapshotescaped = escapeshellparam($nextsnapshot); - my $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions -i $sourcefsescaped#$bookmarkescaped $sourcefsescaped\@$nextsnapshotescaped"; - my $recvcmd = "$targetsudocmd $zfscmd receive $recvoptions $receiveextraargs $forcedrecv $targetfsescaped 2>&1"; - my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot); - - if (!$quiet) { print "Sending incremental $sourcefs#$bookmarkescaped ... $nextsnapshot (~ $disp_pvsize):\n"; } - if ($debug) { print "DEBUG: $synccmd\n"; } - - ($stdout, $exit) = tee_stdout { - system("$synccmd") - }; + ($exit, $stdout) = syncbookmark($sourcehost, $sourcefs, $targethost, $targetfs, $bookmark, $nextsnapshot); $exit == 0 or do { if (!$resume && $stdout =~ /\Qcontains partially-complete state\E/) { - if (!$quiet) { print "WARN: resetting partially receive state\n"; } + writelog('WARN', "resetting partially receive state"); resetreceivestate($targethost,$targetfs,$targetisroot); - system("$synccmd") == 0 or do { - warn "CRITICAL ERROR: $synccmd failed: $?"; + (my $ret) = syncbookmark($sourcehost, $sourcefs, $targethost, $targetfs, $bookmark, $nextsnapshot); + $ret == 0 or do { if ($exitcode < 2) { $exitcode = 2; } return 0; } } else { - warn "CRITICAL ERROR: $synccmd failed: $?"; if ($exitcode < 2) { $exitcode = 2; } return 0; } @@ -751,28 +705,18 @@ sub syncdataset { $matchingsnap = $nextsnapshot; $matchingsnapescaped = escapeshellparam($matchingsnap); } else { - my $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions -i $sourcefsescaped#$bookmarkescaped $sourcefsescaped\@$newsyncsnapescaped"; - my $recvcmd = "$targetsudocmd $zfscmd receive $recvoptions $receiveextraargs $forcedrecv $targetfsescaped 2>&1"; - my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot); - - if (!$quiet) { print "Sending incremental $sourcefs#$bookmarkescaped ... $newsyncsnap (~ $disp_pvsize):\n"; } - if ($debug) { print "DEBUG: $synccmd\n"; } - - ($stdout, $exit) = tee_stdout { - system("$synccmd") - }; + ($exit, $stdout) = syncbookmark($sourcehost, $sourcefs, $targethost, $targetfs, $bookmark, $newsyncsnap); $exit == 0 or do { if (!$resume && $stdout =~ /\Qcontains partially-complete state\E/) { - if (!$quiet) { print "WARN: resetting partially receive state\n"; } + writelog('WARN', "resetting partially receive state"); resetreceivestate($targethost,$targetfs,$targetisroot); - system("$synccmd") == 0 or do { - warn "CRITICAL ERROR: $synccmd failed: $?"; + (my $ret) = syncbookmark($sourcehost, $sourcefs, $targethost, $targetfs, $bookmark, $newsyncsnap); + $ret == 0 or do { if ($exitcode < 2) { $exitcode = 2; } return 0; } } else { - warn "CRITICAL ERROR: $synccmd failed: $?"; if ($exitcode < 2) { $exitcode = 2; } return 0; } @@ -788,33 +732,48 @@ sub syncdataset { return 0; } - $sendoptions = getoptionsline(\@sendoptions, ('D','L','P','R','c','e','h','p','v','w')); - my $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions $args{'streamarg'} $sourcefsescaped\@$matchingsnapescaped $sourcefsescaped\@$newsyncsnapescaped"; - my $recvcmd = "$targetsudocmd $zfscmd receive $recvoptions $receiveextraargs $forcedrecv $targetfsescaped 2>&1"; - my $pvsize = getsendsize($sourcehost,"$sourcefs\@$matchingsnap","$sourcefs\@$newsyncsnap",$sourceisroot); - my $disp_pvsize = readablebytes($pvsize); - if ($pvsize == 0) { $disp_pvsize = "UNKNOWN"; } - my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot); - - if (!$quiet) { print "Sending incremental $sourcefs\@$matchingsnap ... $newsyncsnap (~ $disp_pvsize):\n"; } - if ($debug) { print "DEBUG: $synccmd\n"; } - - ($stdout, $exit) = tee_stdout { - system("$synccmd") - }; + ($exit, $stdout) = syncincremental($sourcehost, $sourcefs, $targethost, $targetfs, $matchingsnap, $newsyncsnap, defined($args{'no-stream'})); $exit == 0 or do { # FreeBSD reports "dataset is busy" instead of "contains partially-complete state" if (!$resume && ($stdout =~ /\Qcontains partially-complete state\E/ || $stdout =~ /\Qdataset is busy\E/)) { - if (!$quiet) { print "WARN: resetting partially receive state\n"; } + writelog('WARN', "resetting partially receive state"); resetreceivestate($targethost,$targetfs,$targetisroot); - system("$synccmd") == 0 or do { - warn "CRITICAL ERROR: $synccmd failed: $?"; + (my $ret) = syncincremental($sourcehost, $sourcefs, $targethost, $targetfs, $matchingsnap, $newsyncsnap, defined($args{'no-stream'})); + $ret == 0 or do { if ($exitcode < 2) { $exitcode = 2; } return 0; } + } elsif ($args{'force-delete'} && $stdout =~ /\Qdestination already exists\E/) { + (my $existing) = $stdout =~ m/^cannot restore to ([^:]*): destination already exists$/g; + if ($existing eq "") { + if ($exitcode < 2) { $exitcode = 2; } + return 0; + } + + writelog('WARN', "removing existing destination: $existing"); + my $rcommand = ''; + my $mysudocmd = ''; + my $existingescaped = escapeshellparam($existing); + + if ($targethost ne '') { $rcommand = "$sshcmd $targethost"; } + if (!$targetisroot) { $mysudocmd = $sudocmd; } + + my $prunecmd = "$mysudocmd $zfscmd destroy $existingescaped; "; + if ($targethost ne '') { + $prunecmd = escapeshellparam($prunecmd); + } + + my $ret = system("$rcommand $prunecmd"); + if ($ret != 0) { + warn "CRITICAL ERROR: $rcommand $prunecmd failed: $?"; + if ($exitcode < 2) { $exitcode = 2; } + return 0; + } else { + # redo sync and skip snapshot creation (already taken) + return syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef, 1); + } } else { - warn "CRITICAL ERROR: $synccmd failed: $?"; if ($exitcode < 2) { $exitcode = 2; } return 0; } @@ -828,30 +787,56 @@ sub syncdataset { } } + $replicationCount++; + + # if "--use-hold" parameter is used set hold on newsync snapshot and remove hold on matching snapshot both on source and target + # hold name: "syncoid" + identifier + hostname -> in case of replication to multiple targets separate holds can be set for each target by assinging different identifiers to each target. Only if all targets have been replicated all syncoid holds are removed from the matching snapshot and it can be removed + if (defined $args{'use-hold'}) { + my $holdcmd; + my $holdreleasecmd; + my $hostid = hostname(); + my $matchingsnapescaped = escapeshellparam($matchingsnap); + my $holdname = "syncoid\_$identifier$hostid"; + if ($sourcehost ne '') { + $holdcmd = "$sshcmd $sourcehost " . escapeshellparam("$sourcesudocmd $zfscmd hold $holdname $sourcefsescaped\@$newsyncsnapescaped"); + $holdreleasecmd = "$sshcmd $sourcehost " . escapeshellparam("$sourcesudocmd $zfscmd release $holdname $sourcefsescaped\@$matchingsnapescaped"); + } else { + $holdcmd = "$sourcesudocmd $zfscmd hold $holdname $sourcefsescaped\@$newsyncsnapescaped"; + $holdreleasecmd = "$sourcesudocmd $zfscmd release $holdname $sourcefsescaped\@$matchingsnapescaped"; + } + writelog('DEBUG', "Set new hold on source: $holdcmd"); + system($holdcmd) == 0 or warn "WARNING: $holdcmd failed: $?"; + # Do hold release only if matchingsnap exists + if ($matchingsnap) { + writelog('DEBUG', "Release old hold on source: $holdreleasecmd"); + system($holdreleasecmd) == 0 or warn "WARNING: $holdreleasecmd failed: $?"; + } + if ($targethost ne '') { + $holdcmd = "$sshcmd $targethost " . escapeshellparam("$targetsudocmd $zfscmd hold $holdname $targetfsescaped\@$newsyncsnapescaped"); + $holdreleasecmd = "$sshcmd $targethost " . escapeshellparam("$targetsudocmd $zfscmd release $holdname $targetfsescaped\@$matchingsnapescaped"); + } else { + $holdcmd = "$targetsudocmd $zfscmd hold $holdname $targetfsescaped\@$newsyncsnapescaped"; $holdreleasecmd = "$targetsudocmd $zfscmd release $holdname $targetfsescaped\@$matchingsnapescaped"; + } + writelog('DEBUG', "Set new hold on target: $holdcmd"); + system($holdcmd) == 0 or warn "WARNING: $holdcmd failed: $?"; + # Do hold release only if matchingsnap exists + if ($matchingsnap) { + writelog('DEBUG', "Release old hold on target: $holdreleasecmd"); + system($holdreleasecmd) == 0 or warn "WARNING: $holdreleasecmd failed: $?"; + } + } if (defined $args{'no-sync-snap'}) { if (defined $args{'create-bookmark'}) { - my $bookmarkcmd; - if ($sourcehost ne '') { - $bookmarkcmd = "$sshcmd $sourcehost " . escapeshellparam("$sourcesudocmd $zfscmd bookmark $sourcefsescaped\@$newsyncsnapescaped $sourcefsescaped\#$newsyncsnapescaped"); - } else { - $bookmarkcmd = "$sourcesudocmd $zfscmd bookmark $sourcefsescaped\@$newsyncsnapescaped $sourcefsescaped\#$newsyncsnapescaped"; - } - if ($debug) { print "DEBUG: $bookmarkcmd\n"; } - system($bookmarkcmd) == 0 or do { - # fallback: assume nameing conflict and try again with guid based suffix + my $ret = createbookmark($sourcehost, $sourcefs, $newsyncsnap, $newsyncsnap); + $ret == 0 or do { + # fallback: assume naming conflict and try again with guid based suffix my $guid = $snaps{'source'}{$newsyncsnap}{'guid'}; $guid = substr($guid, 0, 6); - if (!$quiet) { print "INFO: bookmark creation failed, retrying with guid based suffix ($guid)...\n"; } + writelog('INFO', "bookmark creation failed, retrying with guid based suffix ($guid)..."); - if ($sourcehost ne '') { - $bookmarkcmd = "$sshcmd $sourcehost " . escapeshellparam("$sourcesudocmd $zfscmd bookmark $sourcefsescaped\@$newsyncsnapescaped $sourcefsescaped\#$newsyncsnapescaped$guid"); - } else { - $bookmarkcmd = "$sourcesudocmd $zfscmd bookmark $sourcefsescaped\@$newsyncsnapescaped $sourcefsescaped\#$newsyncsnapescaped$guid"; - } - if ($debug) { print "DEBUG: $bookmarkcmd\n"; } - system($bookmarkcmd) == 0 or do { - warn "CRITICAL ERROR: $bookmarkcmd failed: $?"; + my $ret = createbookmark($sourcehost, $sourcefs, $newsyncsnap, "$newsyncsnap$guid"); + $ret == 0 or do { if ($exitcode < 2) { $exitcode = 2; } return 0; } @@ -865,8 +850,268 @@ sub syncdataset { } } + if (defined $args{'delete-target-snapshots'}) { + # Find the snapshots that exist on the target, filter with + # those that exist on the source. Remaining are the snapshots + # that are only on the target. Then sort to remove the oldest + # snapshots first. + + # regather snapshots on source and target + %snaps = getsnaps('source',$sourcehost,$sourcefs,$sourceisroot); + + if ($targetexists) { + my %targetsnaps = getsnaps('target',$targethost,$targetfs,$targetisroot); + my %sourcesnaps = %snaps; + %snaps = (%sourcesnaps, %targetsnaps); + } + + my @to_delete = sort { $snaps{'target'}{$a}{'creation'}<=>$snaps{'target'}{$b}{'creation'} } grep {!exists $snaps{'source'}{$_}} keys %{ $snaps{'target'} }; + while (@to_delete) { + # Create batch of snapshots to remove + my $snaps = join ',', splice(@to_delete, 0, 50); + my $command; + if ($targethost ne '') { + $command = "$sshcmd $targethost " . escapeshellparam("$targetsudocmd $zfscmd destroy $targetfsescaped\@$snaps"); + } else { + $command = "$targetsudocmd $zfscmd destroy $targetfsescaped\@$snaps"; + } + writelog('DEBUG', "$command"); + my ($stdout, $stderr, $result) = capture { system $command; }; + if ($result != 0 && !$quiet) { + warn "$command failed: $stderr"; + } + } + } + } # end syncdataset() +# Return codes: +# 0 - ZFS send/receive completed without errors +# 1 - ZFS target is currently in receive +# 2 - Critical error encountered when running the ZFS send/receive command +sub runsynccmd { + my ($sourcehost, $sourcefs, $sendsource, $targethost, $targetfs, $pvsize) = @_; + + my $sourcefsescaped = escapeshellparam($sourcefs); + my $targetfsescaped = escapeshellparam($targetfs); + + my $disp_pvsize = $pvsize == 0 ? 'UNKNOWN' : readablebytes($pvsize); + my $sendoptions; + if ($sendsource =~ /^-t /) { + $sendoptions = getoptionsline(\@sendoptions, ('P','V','e','v')); + } elsif ($sendsource =~ /#/) { + $sendoptions = getoptionsline(\@sendoptions, ('L','V','c','e','w')); + } else { + $sendoptions = getoptionsline(\@sendoptions, ('L','P','V','R','X','b','c','e','h','p','s','v','w')); + } + + my $recvoptions = getoptionsline(\@recvoptions, ('h','o','x','u','v')); + + # save state of interrupted receive stream + if ($resume) { $recvoptions .= ' -s'; } + # if no rollbacks are allowed, disable forced receive + if (!defined $args{'no-rollback'}) { $recvoptions .= ' -F'; } + + if (defined $args{'preserve-properties'}) { + my %properties = getlocalzfsvalues($sourcehost,$sourcefs,$sourceisroot); + + foreach my $key (keys %properties) { + my $value = $properties{$key}; + writelog('DEBUG', "will set $key to $value ..."); + my $pair = escapeshellparam("$key=$value"); + $recvoptions .= " -o $pair"; + } + } elsif (defined $args{'preserve-recordsize'}) { + my $type = getzfsvalue($sourcehost,$sourcefs,$sourceisroot,'type'); + if ($type eq "filesystem") { + my $recordsize = getzfsvalue($sourcehost,$sourcefs,$sourceisroot,'recordsize'); + $recvoptions .= " -o recordsize=$recordsize" + } + } + + my $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions $sendsource"; + my $recvcmd = "$targetsudocmd $zfscmd receive $recvoptions $targetfsescaped 2>&1"; + + my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot); + writelog('DEBUG', "sync size: ~$disp_pvsize"); + writelog('DEBUG', "$synccmd"); + + # make sure target is (still) not currently in receive. + if (iszfsbusy($targethost,$targetfs,$targetisroot)) { + my $targetname = buildnicename($targethost, $targetfs); + writelog('WARN', "Cannot sync now: $targetname is already target of a zfs receive process."); + return (1, ''); + } + + my $stdout; + my $ret; + if ($pvsize == 0) { + ($stdout, $ret) = tee_stderr { + system("$synccmd"); + }; + } else { + ($stdout, $ret) = tee_stdout { + system("$synccmd"); + }; + } + + if ($ret != 0) { + writelog('CRITICAL', "$synccmd failed: $?"); + return (2, $stdout); + } else { + return 0; + } +} # end runsendcmd() + +sub syncfull { + my ($sourcehost, $sourcefs, $targethost, $targetfs, $snapname) = @_; + + my $sourcefsescaped = escapeshellparam($sourcefs); + my $snapescaped = escapeshellparam($snapname); + my $sendsource = "$sourcefsescaped\@$snapescaped"; + my $pvsize = getsendsize($sourcehost,"$sourcefs\@$snapname",0,$sourceisroot); + + my $srcname = buildnicename($sourcehost, $sourcefs, $snapname); + my $targetname = buildnicename($targethost, $targetfs); + my $disp_pvsize = $pvsize == 0 ? 'UNKNOWN' : readablebytes($pvsize); + + if (!defined ($args{'no-stream'}) ) { + writelog('INFO', "Sending oldest full snapshot $srcname to new target filesystem $targetname (~ $disp_pvsize):"); + } else { + writelog('INFO', "--no-stream selected; sending newest full snapshot $srcname to new target filesystem $targetname: (~ $disp_pvsize)"); + } + + return runsynccmd($sourcehost, $sourcefs, $sendsource, $targethost, $targetfs, $pvsize); +} # end syncfull() + +sub syncincremental { + my ($sourcehost, $sourcefs, $targethost, $targetfs, $fromsnap, $tosnap, $skipintermediate) = @_; + + my $streamarg = '-I'; + + if ($skipintermediate) { + $streamarg = '-i'; + } + + # If this is an -I sync but we're filtering snaps, then we should do a series + # of -i syncs instead. + if (!$skipintermediate) { + if (defined($args{'exclude-snaps'}) || defined($args{'include-snaps'})) { + writelog('INFO', '--no-stream is omitted but snaps are filtered. Simulating -I with filtered snaps'); + + # Get the snap names between $fromsnap and $tosnap + my @intsnaps = (); + my $inrange = 0; + foreach my $testsnap (sort { $snaps{'source'}{$a}{'creation'}<=>$snaps{'source'}{$b}{'creation'} } keys %{ $snaps{'source'} }) { + if ($testsnap eq $fromsnap) { $inrange = 1; } + + if ($inrange) { push(@intsnaps, $testsnap); } + + if ($testsnap eq $tosnap) { last; } + } + + # If we created a new sync snap, it won't be in @intsnaps yet + if ($intsnaps[-1] ne $tosnap) { + # Make sure that the sync snap isn't filtered out by --include-snaps or --exclude-snaps + if (snapisincluded($tosnap)) { + push(@intsnaps, $tosnap); + } + } + + foreach my $i (0..(scalar(@intsnaps) - 2)) { + my $snapa = $intsnaps[$i]; + my $snapb = $intsnaps[$i + 1]; + (my $ret, my $stdout) = syncincremental($sourcehost, $sourcefs, $targethost, $targetfs, $snapa, $snapb, 1); + + if ($ret != 0) { + return ($ret, $stdout); + } + } + + # Return after finishing the -i syncs so that we don't try to do another -I + return 0; + } + } + + my $sourcefsescaped = escapeshellparam($sourcefs); + my $fromsnapescaped = escapeshellparam($fromsnap); + my $tosnapescaped = escapeshellparam($tosnap); + my $sendsource = "$streamarg $sourcefsescaped\@$fromsnapescaped $sourcefsescaped\@$tosnapescaped"; + my $pvsize = getsendsize($sourcehost,"$sourcefs\@$fromsnap","$sourcefs\@$tosnap",$sourceisroot); + + my $srcname = buildnicename($sourcehost, $sourcefs, $fromsnap); + my $targetname = buildnicename($targethost, $targetfs); + my $disp_pvsize = $pvsize == 0 ? 'UNKNOWN' : readablebytes($pvsize); + + writelog('INFO', "Sending incremental $srcname ... $tosnap to $targetname (~ $disp_pvsize):"); + + return runsynccmd($sourcehost, $sourcefs, $sendsource, $targethost, $targetfs, $pvsize); +} # end syncincremental() + +sub syncclone { + my ($sourcehost, $sourcefs, $origin, $targethost, $targetfs, $tosnap) = @_; + + my $sourcefsescaped = escapeshellparam($sourcefs); + my $originescaped = escapeshellparam($origin); + my $tosnapescaped = escapeshellparam($tosnap); + my $sendsource = "-i $originescaped $sourcefsescaped\@$tosnapescaped"; + my $pvsize = getsendsize($sourcehost,$origin,"$sourcefs\@$tosnap",$sourceisroot); + + my $srcname = buildnicename($sourcehost, $origin); + my $targetname = buildnicename($targethost, $targetfs); + my $disp_pvsize = $pvsize == 0 ? 'UNKNOWN' : readablebytes($pvsize); + + writelog('INFO', "Clone is recreated on target $targetname based on $srcname (~ $disp_pvsize):"); + + return runsynccmd($sourcehost, $sourcefs, $sendsource, $targethost, $targetfs, $pvsize); +} # end syncclone() + +sub syncresume { + my ($sourcehost, $sourcefs, $targethost, $targetfs, $receivetoken) = @_; + + my $sendsource = "-t $receivetoken"; + my $pvsize = getsendsize($sourcehost,"","",$sourceisroot,$receivetoken); + + my $srcname = buildnicename($sourcehost, $sourcefs); + my $targetname = buildnicename($targethost, $targetfs); + my $disp_pvsize = $pvsize == 0 ? 'UNKNOWN' : readablebytes($pvsize); + + writelog('INFO', "Resuming interrupted zfs send/receive from $srcname to $targetname (~ $disp_pvsize remaining):"); + + return runsynccmd($sourcehost, $sourcefs, $sendsource, $targethost, $targetfs, $pvsize); +} # end syncresume() + +sub syncbookmark { + my ($sourcehost, $sourcefs, $targethost, $targetfs, $bookmark, $tosnap) = @_; + + my $sourcefsescaped = escapeshellparam($sourcefs); + my $bookmarkescaped = escapeshellparam($bookmark); + my $tosnapescaped = escapeshellparam($tosnap); + my $sendsource = "-i $sourcefsescaped#$bookmarkescaped $sourcefsescaped\@$tosnapescaped"; + + my $srcname = buildnicename($sourcehost, $sourcefs, '', $bookmark); + my $targetname = buildnicename($targethost, $targetfs); + + writelog('INFO', "Sending incremental $srcname ... $tosnap to $targetname:"); + + return runsynccmd($sourcehost, $sourcefs, $sendsource, $targethost, $targetfs, 0); +} # end syncbookmark + +sub createbookmark { + my ($sourcehost, $sourcefs, $snapname, $bookmark) = @_; + + my $sourcefsescaped = escapeshellparam($sourcefs); + my $bookmarkescaped = escapeshellparam($bookmark); + my $snapnameescaped = escapeshellparam($snapname); + my $cmd = "$sourcesudocmd $zfscmd bookmark $sourcefsescaped\@$snapname $sourcefsescaped\#$bookmark"; + if ($sourcehost ne '') { + $cmd = "$sshcmd $sourcehost " . escapeshellparam($cmd); + } + + writelog('DEBUG', "$cmd"); + return system($cmd); +} # end createbookmark() + sub compressargset { my ($value) = @_; my $DEFAULT_COMPRESSION = 'lzo'; @@ -901,12 +1146,24 @@ sub compressargset { decomrawcmd => 'zstd', decomargs => '-dc', }, + 'zstdmt-fast' => { + rawcmd => 'zstdmt', + args => '-3', + decomrawcmd => 'zstdmt', + decomargs => '-dc', + }, 'zstd-slow' => { rawcmd => 'zstd', args => '-19', decomrawcmd => 'zstd', decomargs => '-dc', }, + 'zstdmt-slow' => { + rawcmd => 'zstdmt', + args => '-19', + decomrawcmd => 'zstdmt', + decomargs => '-dc', + }, 'xz' => { rawcmd => 'xz', args => '', @@ -929,8 +1186,8 @@ sub compressargset { if ($value eq 'default') { $value = $DEFAULT_COMPRESSION; - } elsif (!(grep $value eq $_, ('gzip', 'pigz-fast', 'pigz-slow', 'zstd-fast', 'zstd-slow', 'lz4', 'xz', 'lzo', 'default', 'none'))) { - warn "Unrecognised compression value $value, defaulting to $DEFAULT_COMPRESSION"; + } elsif (!(grep $value eq $_, ('gzip', 'pigz-fast', 'pigz-slow', 'zstd-fast', 'zstdmt-fast', 'zstd-slow', 'zstdmt-slow', 'lz4', 'xz', 'lzo', 'default', 'none'))) { + writelog('WARN', "Unrecognised compression value $value, defaulting to $DEFAULT_COMPRESSION"); $value = $DEFAULT_COMPRESSION; } @@ -951,7 +1208,7 @@ sub checkcommands { # if --nocommandchecks then assume everything's available and return if ($args{'nocommandchecks'}) { - if ($debug) { print "DEBUG: not checking for command availability due to --nocommandchecks switch.\n"; } + writelog('DEBUG', "not checking for command availability due to --nocommandchecks switch."); $avail{'compress'} = 1; $avail{'localpv'} = 1; $avail{'localmbuffer'} = 1; @@ -962,22 +1219,19 @@ sub checkcommands { return %avail; } - if (!defined $sourcehost) { $sourcehost = ''; } - if (!defined $targethost) { $targethost = ''; } - if ($sourcehost ne '') { $sourcessh = "$sshcmd $sourcehost"; } else { $sourcessh = ''; } if ($targethost ne '') { $targetssh = "$sshcmd $targethost"; } else { $targetssh = ''; } # if raw compress command is null, we must have specified no compression. otherwise, # make sure that compression is available everywhere we need it if ($compressargs{'compress'} eq 'none') { - if ($debug) { print "DEBUG: compression forced off from command line arguments.\n"; } + writelog('DEBUG', "compression forced off from command line arguments."); } else { - if ($debug) { print "DEBUG: checking availability of $compressargs{'rawcmd'} on source...\n"; } + writelog('DEBUG', "checking availability of $compressargs{'rawcmd'} on source..."); $avail{'sourcecompress'} = `$sourcessh $checkcmd $compressargs{'rawcmd'} 2>/dev/null`; - if ($debug) { print "DEBUG: checking availability of $compressargs{'rawcmd'} on target...\n"; } + writelog('DEBUG', "checking availability of $compressargs{'rawcmd'} on target..."); $avail{'targetcompress'} = `$targetssh $checkcmd $compressargs{'rawcmd'} 2>/dev/null`; - if ($debug) { print "DEBUG: checking availability of $compressargs{'rawcmd'} on local machine...\n"; } + writelog('DEBUG', "checking availability of $compressargs{'rawcmd'} on local machine..."); $avail{'localcompress'} = `$checkcmd $compressargs{'rawcmd'} 2>/dev/null`; } @@ -1006,13 +1260,13 @@ sub checkcommands { if ($avail{'sourcecompress'} eq '') { if ($compressargs{'rawcmd'} ne '') { - print "WARN: $compressargs{'rawcmd'} not available on source $s- sync will continue without compression.\n"; + writelog('WARN', "$compressargs{'rawcmd'} not available on source $s- sync will continue without compression."); } $avail{'compress'} = 0; } if ($avail{'targetcompress'} eq '') { if ($compressargs{'rawcmd'} ne '') { - print "WARN: $compressargs{'rawcmd'} not available on target $t - sync will continue without compression.\n"; + writelog('WARN', "$compressargs{'rawcmd'} not available on target $t - sync will continue without compression."); } $avail{'compress'} = 0; } @@ -1025,24 +1279,43 @@ sub checkcommands { # corner case - if source AND target are BOTH remote, we have to check for local compress too if ($sourcehost ne '' && $targethost ne '' && $avail{'localcompress'} eq '') { if ($compressargs{'rawcmd'} ne '') { - print "WARN: $compressargs{'rawcmd'} not available on local machine - sync will continue without compression.\n"; + writelog('WARN', "$compressargs{'rawcmd'} not available on local machine - sync will continue without compression."); } $avail{'compress'} = 0; } - if ($debug) { print "DEBUG: checking availability of $mbuffercmd on source...\n"; } + if (length $args{'insecure-direct-connection'}) { + writelog('DEBUG', "checking availability of $socatcmd on source..."); + my $socatAvailable = `$sourcessh $checkcmd $socatcmd 2>/dev/null`; + if ($socatAvailable eq '') { + die "CRIT: $socatcmd is needed on source for insecure direct connection!\n"; + } + + if (!$directmbuffer) { + writelog('DEBUG', "checking availability of busybox (for nc) on target..."); + my $busyboxAvailable = `$targetssh $checkcmd busybox 2>/dev/null`; + if ($busyboxAvailable eq '') { + die "CRIT: busybox is needed on target for insecure direct connection!\n"; + } + } + } + + writelog('DEBUG', "checking availability of $mbuffercmd on source..."); $avail{'sourcembuffer'} = `$sourcessh $checkcmd $mbuffercmd 2>/dev/null`; if ($avail{'sourcembuffer'} eq '') { - if (!$quiet) { print "WARN: $mbuffercmd not available on source $s - sync will continue without source buffering.\n"; } + writelog('WARN', "$mbuffercmd not available on source $s - sync will continue without source buffering."); $avail{'sourcembuffer'} = 0; } else { $avail{'sourcembuffer'} = 1; } - if ($debug) { print "DEBUG: checking availability of $mbuffercmd on target...\n"; } + writelog('DEBUG', "checking availability of $mbuffercmd on target..."); $avail{'targetmbuffer'} = `$targetssh $checkcmd $mbuffercmd 2>/dev/null`; if ($avail{'targetmbuffer'} eq '') { - if (!$quiet) { print "WARN: $mbuffercmd not available on target $t - sync will continue without target buffering.\n"; } + if ($directmbuffer) { + die "CRIT: $mbuffercmd is needed on target for insecure direct connection!\n"; + } + writelog('WARN', "$mbuffercmd not available on target $t - sync will continue without target buffering."); $avail{'targetmbuffer'} = 0; } else { $avail{'targetmbuffer'} = 1; @@ -1050,18 +1323,18 @@ sub checkcommands { # if we're doing remote source AND remote target, check for local mbuffer as well if ($sourcehost ne '' && $targethost ne '') { - if ($debug) { print "DEBUG: checking availability of $mbuffercmd on local machine...\n"; } + writelog('DEBUG', "checking availability of $mbuffercmd on local machine..."); $avail{'localmbuffer'} = `$checkcmd $mbuffercmd 2>/dev/null`; if ($avail{'localmbuffer'} eq '') { $avail{'localmbuffer'} = 0; - if (!$quiet) { print "WARN: $mbuffercmd not available on local machine - sync will continue without local buffering.\n"; } + writelog('WARN', "$mbuffercmd not available on local machine - sync will continue without local buffering."); } } - if ($debug) { print "DEBUG: checking availability of $pvcmd on local machine...\n"; } + writelog('DEBUG', "checking availability of $pvcmd on local machine..."); $avail{'localpv'} = `$checkcmd $pvcmd 2>/dev/null`; if ($avail{'localpv'} eq '') { - if (!$quiet) { print "WARN: $pvcmd not available on local machine - sync will continue without progress bar.\n"; } + writelog('WARN', "$pvcmd not available on local machine - sync will continue without progress bar."); $avail{'localpv'} = 0; } else { $avail{'localpv'} = 1; @@ -1089,11 +1362,11 @@ sub checkcommands { my $resumechkcmd = "$zpoolcmd get -o value -H feature\@extensible_dataset"; - if ($debug) { print "DEBUG: checking availability of zfs resume feature on source...\n"; } + writelog('DEBUG', "checking availability of zfs resume feature on source..."); $avail{'sourceresume'} = system("$sourcessh $sourcesudocmd $resumechkcmd $srcpool 2>/dev/null | grep '\\(active\\|enabled\\)' >/dev/null 2>&1"); $avail{'sourceresume'} = $avail{'sourceresume'} == 0 ? 1 : 0; - if ($debug) { print "DEBUG: checking availability of zfs resume feature on target...\n"; } + writelog('DEBUG', "checking availability of zfs resume feature on target..."); $avail{'targetresume'} = system("$targetssh $targetsudocmd $resumechkcmd $dstpool 2>/dev/null | grep '\\(active\\|enabled\\)' >/dev/null 2>&1"); $avail{'targetresume'} = $avail{'targetresume'} == 0 ? 1 : 0; @@ -1109,7 +1382,7 @@ sub checkcommands { push @hosts, 'target'; } my $affected = join(" and ", @hosts); - print "WARN: ZFS resume feature not available on $affected machine - sync will continue without resume support.\n"; + writelog('WARN', "ZFS resume feature not available on $affected machine - sync will continue without resume support."); } } else { $avail{'sourceresume'} = 0; @@ -1122,17 +1395,16 @@ sub checkcommands { sub iszfsbusy { my ($rhost,$fs,$isroot) = @_; if ($rhost ne '') { $rhost = "$sshcmd $rhost"; } - if ($debug) { print "DEBUG: checking to see if $fs on $rhost is already in zfs receive using $rhost $pscmd -Ao args= ...\n"; } + writelog('DEBUG', "checking to see if $fs on $rhost is already in zfs receive using $rhost $pscmd -Ao args= ..."); open PL, "$rhost $pscmd -Ao args= |"; my @processes = ; close PL; foreach my $process (@processes) { - # if ($debug) { print "DEBUG: checking process $process...\n"; } - if ($process =~ /zfs *(receive|recv).*\Q$fs\E\Z/) { + if ($process =~ /zfs *(receive|recv)[^\/]*\Q$fs\E\Z/) { # there's already a zfs receive process for our target filesystem - return true - if ($debug) { print "DEBUG: process $process matches target $fs!\n"; } + writelog('DEBUG', "process $process matches target $fs!"); return 1; } } @@ -1152,12 +1424,12 @@ sub setzfsvalue { $fsescaped = escapeshellparam($fsescaped); } - if ($debug) { print "DEBUG: setting $property to $value on $fs...\n"; } + writelog('DEBUG', "setting $property to $value on $fs..."); my $mysudocmd; if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } - if ($debug) { print "$rhost $mysudocmd $zfscmd set $property=$value $fsescaped\n"; } + writelog('DEBUG', "$rhost $mysudocmd $zfscmd set $property=$value $fsescaped"); system("$rhost $mysudocmd $zfscmd set $property=$value $fsescaped") == 0 - or warn "WARNING: $rhost $mysudocmd $zfscmd set $property=$value $fsescaped died: $?, proceeding anyway.\n"; + or writelog('WARN', "$rhost $mysudocmd $zfscmd set $property=$value $fsescaped died: $?, proceeding anyway."); return; } @@ -1172,10 +1444,10 @@ sub getzfsvalue { $fsescaped = escapeshellparam($fsescaped); } - if ($debug) { print "DEBUG: getting current value of $property on $fs...\n"; } + writelog('DEBUG', "getting current value of $property on $fs..."); my $mysudocmd; if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } - if ($debug) { print "$rhost $mysudocmd $zfscmd get -H $property $fsescaped\n"; } + writelog('DEBUG', "$rhost $mysudocmd $zfscmd get -H $property $fsescaped"); my ($value, $error, $exit) = capture { system("$rhost $mysudocmd $zfscmd get -H $property $fsescaped"); }; @@ -1188,12 +1460,62 @@ sub getzfsvalue { # If we are in scalar context and there is an error, print it out. # Otherwise we assume the caller will deal with it. if (!$wantarray and $error) { - print "ERROR getzfsvalue $fs $property: $error\n"; + writelog('CRITICAL', "getzfsvalue $fs $property: $error"); } return $wantarray ? ($value, $error) : $value; } +sub getlocalzfsvalues { + my ($rhost,$fs,$isroot) = @_; + + my $fsescaped = escapeshellparam($fs); + + if ($rhost ne '') { + $rhost = "$sshcmd $rhost"; + # double escaping needed + $fsescaped = escapeshellparam($fsescaped); + } + + writelog('DEBUG', "getting locally set values of properties on $fs..."); + my $mysudocmd; + if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } + writelog('DEBUG', "$rhost $mysudocmd $zfscmd get -s local -H all $fsescaped"); + my ($values, $error, $exit) = capture { + system("$rhost $mysudocmd $zfscmd get -s local -H all $fsescaped"); + }; + + my %properties=(); + + if ($exit != 0) { + warn "WARNING: getlocalzfsvalues failed for $fs: $error"; + if ($exitcode < 1) { $exitcode = 1; } + return %properties; + } + + my @blacklist = ( + "available", "compressratio", "createtxg", "creation", "clones", + "defer_destroy", "encryptionroot", "filesystem_count", "keystatus", "guid", + "logicalreferenced", "logicalused", "mounted", "objsetid", "origin", + "receive_resume_token", "redact_snaps", "referenced", "refcompressratio", "snapshot_count", + "type", "used", "usedbychildren", "usedbydataset", "usedbyrefreservation", + "usedbysnapshots", "userrefs", "snapshots_changed", "volblocksize", "written", + "version", "volsize", "casesensitivity", "normalization", "utf8only", + "encryption" + ); + my %blacklisthash = map {$_ => 1} @blacklist; + + foreach (split(/\n/,$values)) { + my @parts = split(/\t/, $_); + if (exists $blacklisthash{$parts[1]}) { + next; + } + $properties{$parts[1]} = $parts[2]; + } + + return %properties; +} + sub readablebytes { my $bytes = shift; my $disp; @@ -1217,7 +1539,7 @@ sub getoldestsnapshot { # must not have had any snapshots on source - luckily, we already made one, amirite? if (defined ($args{'no-sync-snap'}) ) { # well, actually we set --no-sync-snap, so no we *didn't* already make one. Whoops. - warn "CRIT: --no-sync-snap is set, and getoldestsnapshot() could not find any snapshots on source!\n"; + writelog('CRITICAL', "--no-sync-snap is set, and getoldestsnapshot() could not find any snapshots on source!"); } return 0; } @@ -1226,7 +1548,7 @@ sub getnewestsnapshot { my $snaps = shift; foreach my $snap ( sort { $snaps{'source'}{$b}{'creation'}<=>$snaps{'source'}{$a}{'creation'} } keys %{ $snaps{'source'} }) { # return on first snap found - it's the newest - if (!$quiet) { print "NEWEST SNAPSHOT: $snap\n"; } + writelog('DEBUG', "NEWEST SNAPSHOT: $snap"); return $snap; } # must not have had any snapshots on source - looks like we'd better create one! @@ -1238,7 +1560,7 @@ sub getnewestsnapshot { # fixme: we need to output WHAT the current dataset IS if we encounter this WARN condition. # we also probably need an argument to mute this WARN, for people who deliberately exclude # datasets from recursive replication this way. - warn "WARN: --no-sync-snap is set, and getnewestsnapshot() could not find any snapshots on source for current dataset. Continuing.\n"; + writelog('WARN', "--no-sync-snap is set, and getnewestsnapshot() could not find any snapshots on source for current dataset. Continuing."); if ($exitcode < 2) { $exitcode = 2; } } return 0; @@ -1273,10 +1595,19 @@ sub buildsynccmd { if ($avail{'localpv'} && !$quiet) { $synccmd .= " $pvcmd $pvoptions -s $pvsize |"; } if ($avail{'compress'}) { $synccmd .= " $compressargs{'cmd'} |"; } if ($avail{'sourcembuffer'}) { $synccmd .= " $mbuffercmd $args{'source-bwlimit'} $mbufferoptions |"; } + if (length $directconnect) { + $synccmd .= " $socatcmd - TCP:" . $directconnect . ",retry=$directtimeout,interval=1 |"; + } $synccmd .= " $sshcmd $targethost "; my $remotecmd = ""; - if ($avail{'targetmbuffer'}) { $remotecmd .= " $mbuffercmd $args{'target-bwlimit'} $mbufferoptions |"; } + if ($directmbuffer) { + $remotecmd .= " $mbuffercmd $args{'target-bwlimit'} -W $directtimeout -I " . $directlisten . " $mbufferoptions |"; + } elsif (length $directlisten) { + $remotecmd .= " busybox nc -l " . $directlisten . " -w $directtimeout |"; + } + + if ($avail{'targetmbuffer'} && !$directmbuffer) { $remotecmd .= " $mbuffercmd $args{'target-bwlimit'} $mbufferoptions |"; } if ($avail{'compress'}) { $remotecmd .= " $compressargs{'decomcmd'} |"; } $remotecmd .= " $recvcmd"; @@ -1288,10 +1619,19 @@ sub buildsynccmd { my $remotecmd = $sendcmd; if ($avail{'compress'}) { $remotecmd .= " | $compressargs{'cmd'}"; } if ($avail{'sourcembuffer'}) { $remotecmd .= " | $mbuffercmd $args{'source-bwlimit'} $mbufferoptions"; } + if (length $directconnect) { + $remotecmd .= " | $socatcmd - TCP:" . $directconnect . ",retry=$directtimeout,interval=1"; + } $synccmd = "$sshcmd $sourcehost " . escapeshellparam($remotecmd); $synccmd .= " | "; - if ($avail{'targetmbuffer'}) { $synccmd .= "$mbuffercmd $args{'target-bwlimit'} $mbufferoptions | "; } + if ($directmbuffer) { + $synccmd .= "$mbuffercmd $args{'target-bwlimit'} -W $directtimeout -I " . $directlisten . " $mbufferoptions | "; + } elsif (length $directlisten) { + $synccmd .= " busybox nc -l " . $directlisten . " -w $directtimeout | "; + } + + if ($avail{'targetmbuffer'} && !$directmbuffer) { $synccmd .= "$mbuffercmd $args{'target-bwlimit'} $mbufferoptions | "; } if ($avail{'compress'}) { $synccmd .= "$compressargs{'decomcmd'} | "; } if ($avail{'localpv'} && !$quiet) { $synccmd .= "$pvcmd $pvoptions -s $pvsize | "; } $synccmd .= "$recvcmd"; @@ -1357,13 +1697,13 @@ sub pruneoldsyncsnaps { $prunecmd .= "$mysudocmd $zfscmd destroy $fsescaped\@$snap; "; if ($counter > $maxsnapspercmd) { $prunecmd =~ s/\; $//; - if ($debug) { print "DEBUG: pruning up to $maxsnapspercmd obsolete sync snapshots...\n"; } - if ($debug) { print "DEBUG: $rhost $prunecmd\n"; } + writelog('DEBUG', "pruning up to $maxsnapspercmd obsolete sync snapshots..."); + writelog('DEBUG', "$rhost $prunecmd"); if ($rhost ne '') { $prunecmd = escapeshellparam($prunecmd); } system("$rhost $prunecmd") == 0 - or warn "WARNING: $rhost $prunecmd failed: $?"; + or writelog('WARN', "$rhost $prunecmd failed: $?"); $prunecmd = ''; $counter = 0; } @@ -1372,13 +1712,13 @@ sub pruneoldsyncsnaps { # the loop, commit 'em now if ($counter) { $prunecmd =~ s/\; $//; - if ($debug) { print "DEBUG: pruning up to $maxsnapspercmd obsolete sync snapshots...\n"; } - if ($debug) { print "DEBUG: $rhost $prunecmd\n"; } + writelog('DEBUG', "pruning up to $maxsnapspercmd obsolete sync snapshots..."); + writelog('DEBUG', "$rhost $prunecmd"); if ($rhost ne '') { $prunecmd = escapeshellparam($prunecmd); } system("$rhost $prunecmd") == 0 - or warn "WARNING: $rhost $prunecmd failed: $?"; + or writelog('WARN', "$rhost $prunecmd failed: $?"); } return; } @@ -1410,9 +1750,9 @@ sub newsyncsnap { my %date = getdate(); my $snapname = "syncoid\_$identifier$hostid\_$date{'stamp'}"; my $snapcmd = "$rhost $mysudocmd $zfscmd snapshot $fsescaped\@$snapname\n"; - if ($debug) { print "DEBUG: creating sync snapshot using \"$snapcmd\"...\n"; } + writelog('DEBUG', "creating sync snapshot using \"$snapcmd\"..."); system($snapcmd) == 0 or do { - warn "CRITICAL ERROR: $snapcmd failed: $?"; + writelog('CRITICAL', "$snapcmd failed: $?"); if ($exitcode < 2) { $exitcode = 2; } return 0; }; @@ -1431,7 +1771,7 @@ sub targetexists { my $mysudocmd; if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } my $checktargetcmd = "$rhost $mysudocmd $zfscmd get -H name $fsescaped"; - if ($debug) { print "DEBUG: checking to see if target filesystem exists using \"$checktargetcmd 2>&1 |\"...\n"; } + writelog('DEBUG', "checking to see if target filesystem exists using \"$checktargetcmd 2>&1 |\"..."); open FH, "$checktargetcmd 2>&1 |"; my $targetexists = ; close FH; @@ -1468,7 +1808,7 @@ sub getssh { }; $rhost = $fs; if ($exit != 0) { - warn "Unable to enumerate pools (is zfs available?)"; + writelog('WARN', "Unable to enumerate pools (is zfs available?)"); } else { foreach (split(/\n/,$pools)) { if ($_ eq $pool) { @@ -1487,15 +1827,22 @@ sub getssh { if ($rhost ne "") { if ($remoteuser eq 'root' || $args{'no-privilege-elevation'}) { $isroot = 1; } else { $isroot = 0; } + + my $sanitizedrhost = $rhost; + $sanitizedrhost =~ s/[^a-zA-Z0-9-]//g; + # unix socket path have a length limit of about 104 characters so make sure it's not exceeded + $sanitizedrhost = substr($sanitizedrhost, 0, 50); + # now we need to establish a persistent master SSH connection - $socket = "/tmp/syncoid-$rhost-" . time(); + $socket = "/tmp/syncoid-$sanitizedrhost-" . time() . "-" . $$ . "-" . int(rand(10000)); + open FH, "$sshcmd -M -S $socket -o ControlPersist=1m $args{'sshport'} $rhost exit |"; close FH; system("$sshcmd -S $socket $rhost echo -n") == 0 or do { my $code = $? >> 8; - warn "CRITICAL ERROR: ssh connection echo test failed for $rhost with exit code $code"; + writelog('CRITICAL', "ssh connection echo test failed for $rhost with exit code $code"); exit(2); }; @@ -1504,14 +1851,13 @@ sub getssh { my $localuid = $<; if ($localuid == 0 || $args{'no-privilege-elevation'}) { $isroot = 1; } else { $isroot = 0; } } - # if ($isroot) { print "this user is root.\n"; } else { print "this user is not root.\n"; } return ($rhost,$fs,$isroot); } sub dumphash() { my $hash = shift; $Data::Dumper::Sortkeys = 1; - print Dumper($hash); + writelog('INFO', Dumper($hash)); } sub getsnaps() { @@ -1520,6 +1866,8 @@ sub getsnaps() { my $fsescaped = escapeshellparam($fs); if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } + my $rhostOriginal = $rhost; + if ($rhost ne '') { $rhost = "$sshcmd $rhost"; # double escaping needed @@ -1529,7 +1877,7 @@ sub getsnaps() { my $getsnapcmd = "$rhost $mysudocmd $zfscmd get -Hpd 1 -t snapshot guid,creation $fsescaped"; if ($debug) { $getsnapcmd = "$getsnapcmd |"; - print "DEBUG: getting list of snapshots on $fs using $getsnapcmd...\n"; + writelog('DEBUG', "getting list of snapshots on $fs using $getsnapcmd..."); } else { $getsnapcmd = "$getsnapcmd 2>/dev/null |"; } @@ -1537,7 +1885,7 @@ sub getsnaps() { my @rawsnaps = ; close FH or do { # fallback (solaris for example doesn't support the -t option) - return getsnapsfallback($type,$rhost,$fs,$isroot,%snaps); + return getsnapsfallback($type,$rhostOriginal,$fs,$isroot,%snaps); }; # this is a little obnoxious. get guid,creation returns guid,creation on two separate lines @@ -1546,8 +1894,13 @@ sub getsnaps() { my %creationtimes=(); foreach my $line (@rawsnaps) { + $line =~ /\Q$fs\E\@(\S*)/; + my $snapname = $1; + + if (!snapisincluded($snapname)) { next; } + # only import snap guids from the specified filesystem - if ($line =~ /\Q$fs\E\@.*guid/) { + if ($line =~ /\Q$fs\E\@.*\tguid/) { chomp $line; my $guid = $line; $guid =~ s/^.*\tguid\t*(\d*).*/$1/; @@ -1555,11 +1908,8 @@ sub getsnaps() { $snap =~ s/^.*\@(.*)\tguid.*$/$1/; $snaps{$type}{$snap}{'guid'}=$guid; } - } - - foreach my $line (@rawsnaps) { # only import snap creations from the specified filesystem - if ($line =~ /\Q$fs\E\@.*creation/) { + elsif ($line =~ /\Q$fs\E\@.*\tcreation/) { chomp $line; my $creation = $line; $creation =~ s/^.*\tcreation\t*(\d*).*/$1/; @@ -1603,8 +1953,8 @@ sub getsnapsfallback() { } my $getsnapcmd = "$rhost $mysudocmd $zfscmd get -Hpd 1 type,guid,creation $fsescaped |"; - warn "snapshot listing failed, trying fallback command"; - if ($debug) { print "DEBUG: FALLBACK, getting list of snapshots on $fs using $getsnapcmd...\n"; } + writelog('WARN', "snapshot listing failed, trying fallback command"); + writelog('DEBUG', "FALLBACK, getting list of snapshots on $fs using $getsnapcmd..."); open FH, $getsnapcmd; my @rawsnaps = ; close FH or die "CRITICAL ERROR: snapshots couldn't be listed for $fs (exit code $?)"; @@ -1619,13 +1969,13 @@ sub getsnapsfallback() { } if ($state eq 0) { - if ($line !~ /\Q$fs\E\@.*type\s*snapshot/) { + if ($line !~ /\Q$fs\E\@.*\ttype\s*snapshot/) { # skip non snapshot type object $state = -2; next; } } elsif ($state eq 1) { - if ($line !~ /\Q$fs\E\@.*guid/) { + if ($line !~ /\Q$fs\E\@.*\tguid/) { die "CRITICAL ERROR: snapshots couldn't be listed for $fs (guid parser error)"; } @@ -1634,9 +1984,10 @@ sub getsnapsfallback() { $guid =~ s/^.*\tguid\t*(\d*).*/$1/; my $snap = $line; $snap =~ s/^.*\@(.*)\tguid.*$/$1/; + if (!snapisincluded($snap)) { next; } $snaps{$type}{$snap}{'guid'}=$guid; } elsif ($state eq 2) { - if ($line !~ /\Q$fs\E\@.*creation/) { + if ($line !~ /\Q$fs\E\@.*\tcreation/) { die "CRITICAL ERROR: snapshots couldn't be listed for $fs (creation parser error)"; } @@ -1645,6 +1996,7 @@ sub getsnapsfallback() { $creation =~ s/^.*\tcreation\t*(\d*).*/$1/; my $snap = $line; $snap =~ s/^.*\@(.*)\tcreation.*$/$1/; + if (!snapisincluded($snap)) { next; } # the accuracy of the creation timestamp is only for a second, but # snapshots in the same second are highly likely. The list command @@ -1686,7 +2038,7 @@ sub getbookmarks() { my $error = 0; my $getbookmarkcmd = "$rhost $mysudocmd $zfscmd get -Hpd 1 -t bookmark guid,creation $fsescaped 2>&1 |"; - if ($debug) { print "DEBUG: getting list of bookmarks on $fs using $getbookmarkcmd...\n"; } + writelog('DEBUG', "getting list of bookmarks on $fs using $getbookmarkcmd..."); open FH, $getbookmarkcmd; my @rawbookmarks = ; close FH or $error = 1; @@ -1704,23 +2056,41 @@ sub getbookmarks() { # as though each were an entirely separate get command. my $lastguid; + my %creationtimes=(); foreach my $line (@rawbookmarks) { # only import bookmark guids, creation from the specified filesystem - if ($line =~ /\Q$fs\E\#.*guid/) { + if ($line =~ /\Q$fs\E\#.*\tguid/) { chomp $line; $lastguid = $line; $lastguid =~ s/^.*\tguid\t*(\d*).*/$1/; my $bookmark = $line; $bookmark =~ s/^.*\#(.*)\tguid.*$/$1/; $bookmarks{$lastguid}{'name'}=$bookmark; - } elsif ($line =~ /\Q$fs\E\#.*creation/) { + } elsif ($line =~ /\Q$fs\E\#.*\tcreation/) { chomp $line; my $creation = $line; $creation =~ s/^.*\tcreation\t*(\d*).*/$1/; my $bookmark = $line; $bookmark =~ s/^.*\#(.*)\tcreation.*$/$1/; - $bookmarks{$lastguid}{'creation'}=$creation; + + # the accuracy of the creation timestamp is only for a second, but + # bookmarks in the same second are possible. The list command + # has an ordered output so we append another three digit running number + # to the creation timestamp and make sure those are ordered correctly + # for bookmarks with the same creation timestamp + my $counter = 0; + my $creationsuffix; + while ($counter < 999) { + $creationsuffix = sprintf("%s%03d", $creation, $counter); + if (!defined $creationtimes{$creationsuffix}) { + $creationtimes{$creationsuffix} = 1; + last; + } + $counter += 1; + } + + $bookmarks{$lastguid}{'creation'}=$creationsuffix; } } @@ -1762,12 +2132,12 @@ sub getsendsize { my $sendoptions; if (defined($receivetoken)) { - $sendoptions = getoptionsline(\@sendoptions, ('e')); + $sendoptions = getoptionsline(\@sendoptions, ('V','e')); } else { - $sendoptions = getoptionsline(\@sendoptions, ('D','L','R','c','e','h','p','w')); + $sendoptions = getoptionsline(\@sendoptions, ('L','V','R','X','b','c','e','h','p','s','w')); } my $getsendsizecmd = "$sourcessh $mysudocmd $zfscmd send $sendoptions -nvP $snaps"; - if ($debug) { print "DEBUG: getting estimated transfer size from source $sourcehost using \"$getsendsizecmd 2>&1 |\"...\n"; } + writelog('DEBUG', "getting estimated transfer size from source $sourcehost using \"$getsendsizecmd 2>&1 |\"..."); open FH, "$getsendsizecmd 2>&1 |"; my @rawsize = ; @@ -1794,7 +2164,7 @@ sub getsendsize { # to avoid confusion with a zero size pv, give sendsize # a minimum 4K value - or if empty, make sure it reads UNKNOWN - if ($debug) { print "DEBUG: sendsize = $sendsize\n"; } + writelog('DEBUG', "sendsize = $sendsize"); if ($sendsize eq '' || $exit != 0) { $sendsize = '0'; } elsif ($sendsize < 4096) { @@ -1853,9 +2223,7 @@ sub getreceivetoken() { return $token; } - if ($debug) { - print "DEBUG: no receive token found \n"; - } + writelog('DEBUG', "no receive token found"); return } @@ -1887,7 +2255,7 @@ sub parsespecialoptions { return undef; } - if ($char eq 'o' || $char eq 'x') { + if ($char eq 'o' || $char eq 'x' || $char eq 'X') { $lastOption = $char; $optionValue = 1; } else { @@ -1922,8 +2290,7 @@ sub getoptionsline { return $line; } -sub resetreceivestate { - my ($rhost,$fs,$isroot) = @_; +sub resetreceivestate { my ($rhost,$fs,$isroot) = @_; my $fsescaped = escapeshellparam($fs); @@ -1933,15 +2300,88 @@ sub resetreceivestate { $fsescaped = escapeshellparam($fsescaped); } - if ($debug) { print "DEBUG: reset partial receive state of $fs...\n"; } + writelog('DEBUG', "reset partial receive state of $fs..."); my $mysudocmd; if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } my $resetcmd = "$rhost $mysudocmd $zfscmd receive -A $fsescaped"; - if ($debug) { print "$resetcmd\n"; } + writelog('DEBUG', "$resetcmd"); system("$resetcmd") == 0 or die "CRITICAL ERROR: $resetcmd failed: $?"; } +# $loglevel can be one of: +# - CRITICAL +# - WARN +# - INFO +# - DEBUG +sub writelog { + my ($loglevel, $msg) = @_; + + my $header; + chomp($msg); + + if ($loglevel eq 'CRITICAL') { + warn("CRITICAL ERROR: $msg\n"); + } elsif ($loglevel eq 'WARN') { + if (!$quiet) { warn("WARNING: $msg\n"); } + } elsif ($loglevel eq 'INFO') { + if (!$quiet) { print("INFO: $msg\n"); } + } elsif ($loglevel eq 'DEBUG') { + if ($debug) { print("DEBUG: $msg\n"); } + } +} + +sub snapisincluded { + my ($snapname) = @_; + + # Return false if the snapshot matches an exclude-snaps pattern + if (defined $args{'exclude-snaps'}) { + my $excludes = $args{'exclude-snaps'}; + foreach (@$excludes) { + if ($snapname =~ /$_/) { + writelog('DEBUG', "excluded $snapname because of exclude pattern /$_/"); + return 0; + } + } + } + + # Return true if the snapshot matches an include-snaps pattern + if (defined $args{'include-snaps'}) { + my $includes = $args{'include-snaps'}; + foreach (@$includes) { + if ($snapname =~ /$_/) { + writelog('DEBUG', "included $snapname because of include pattern /$_/"); + return 1; + } + } + + # Return false if the snapshot didn't match any inclusion patterns + return 0; + } + + return 1; +} + +sub buildnicename { + my ($host,$fs,$snapname,$bookmarkname) = @_; + + my $name; + if ($host) { + $host =~ s/-S \/tmp\/syncoid[a-zA-Z0-9-@]+ //g; + $name = "$host:$fs"; + } else { + $name = "$fs"; + } + + if ($snapname) { + $name = "$name\@$snapname"; + } elsif ($bookmarkname) { + $name = "$name#$bookmarkname"; + } + + return $name; +} + __END__ =head1 NAME @@ -1960,7 +2400,7 @@ syncoid - ZFS snapshot replication tool Options: - --compress=FORMAT Compresses data during transfer. Currently accepted options are gzip, pigz-fast, pigz-slow, zstd-fast, zstd-slow, lz4, xz, lzo (default) & none + --compress=FORMAT Compresses data during transfer. Currently accepted options are gzip, pigz-fast, pigz-slow, zstd-fast, zstdmt-fast, zstd-slow, zstdmt-slow, lz4, xz, lzo (default) & none --identifier=EXTRA Extra identifier which is included in the snapshot name. Can be used for replicating to multiple targets. --recursive|r Also transfers child datasets --skip-parent Skips syncing of the parent dataset. Does nothing without '--recursive' option. @@ -1972,16 +2412,23 @@ Options: --no-sync-snap Does not create new snapshot, only transfers existing --keep-sync-snap Don't destroy created sync snapshots --create-bookmark Creates a zfs bookmark for the newest snapshot on the source after replication succeeds (only works with --no-sync-snap) + --use-hold Adds a hold to the newest snapshot on the source and target after replication succeeds and removes the hold after the next successful replication. The hold name includes the identifier if set. This allows for separate holds in case of multiple targets --preserve-recordsize Preserves the recordsize on initial sends to the target - --no-clone-rollback Does not rollback clones on target - --no-rollback Does not rollback clones or snapshots on target (it probably requires a readonly target) - --exclude=REGEX Exclude specific datasets which match the given regular expression. Can be specified multiple times + --preserve-properties Preserves locally set dataset properties similar to the zfs send -p flag but this one will also work for encrypted datasets in non raw sends + --no-rollback Does not rollback snapshots on target (it probably requires a readonly target) + --delete-target-snapshots With this argument snapshots which are missing on the source will be destroyed on the target. Use this if you only want to handle snapshots on the source. + --exclude=REGEX DEPRECATED. Equivalent to --exclude-datasets, but will be removed in a future release. Ignored if --exclude-datasets is also provided. + --exclude-datasets=REGEX Exclude specific datasets which match the given regular expression. Can be specified multiple times + --exclude-snaps=REGEX Exclude specific snapshots that match the given regular expression. Can be specified multiple times. If a snapshot matches both the exclude-snaps and include-snaps patterns, then it will be excluded. + --include-snaps=REGEX Only include snapshots that match the given regular expression. Can be specified multiple times. If a snapshot matches both the exclude-snaps and include-snaps patterns, then it will be excluded. --sendoptions=OPTIONS Use advanced options for zfs send (the arguments are filtered as needed), e.g. syncoid --sendoptions="Lc e" sets zfs send -L -c -e ... --recvoptions=OPTIONS Use advanced options for zfs receive (the arguments are filtered as needed), e.g. syncoid --recvoptions="ux recordsize o compression=lz4" sets zfs receive -u -x recordsize -o compression=lz4 ... + --sshconfig=FILE Specifies an ssh_config(5) file to be used --sshkey=FILE Specifies a ssh key to use to connect --sshport=PORT Connects to remote on a particular port --sshcipher|c=CIPHER Passes CIPHER to ssh to use a particular cipher set --sshoption|o=OPTION Passes OPTION to ssh for remote usage. Can be specified multiple times + --insecure-direct-connection=IP:PORT[,IP:PORT] WARNING: DATA IS NOT ENCRYPTED. First address pair is for connecting to the target and the second for listening at the target --help Prints this helptext --version Prints the version number @@ -1994,4 +2441,4 @@ Options: --no-clone-handling Don't try to recreate clones on target --no-privilege-elevation Bypass the root check, for use with ZFS permission delegation - --force-delete Remove target datasets recursively, if there are no matching snapshots/bookmarks + --force-delete Remove target datasets recursively, if there are no matching snapshots/bookmarks (also overwrites conflicting named snapshots) diff --git a/tests/1_one_year/run.sh b/tests/1_one_year/run.sh index fe76946..48b3c7b 100755 --- a/tests/1_one_year/run.sh +++ b/tests/1_one_year/run.sh @@ -39,7 +39,7 @@ function cleanUp { trap cleanUp EXIT while [ $timestamp -le $END ]; do - setdate $timestamp; date; "${SANOID}" --cron --verbose + setdate $timestamp; date; "${SANOID}" --cron --verbose --cache-ttl=2592000 timestamp=$((timestamp+3600)) done diff --git a/tests/2_dst_handling/run.sh b/tests/2_dst_handling/run.sh index 3231631..2c3fd3e 100755 --- a/tests/2_dst_handling/run.sh +++ b/tests/2_dst_handling/run.sh @@ -42,7 +42,7 @@ function cleanUp { trap cleanUp EXIT while [ $timestamp -le $END ]; do - setdate $timestamp; date; "${SANOID}" --cron --verbose + setdate $timestamp; date; "${SANOID}" --cron --verbose --cache-ttl=2592000 timestamp=$((timestamp+900)) done diff --git a/tests/common/lib.sh b/tests/common/lib.sh index 904c98f..84b2c63 100644 --- a/tests/common/lib.sh +++ b/tests/common/lib.sh @@ -10,7 +10,10 @@ function setup { export SANOID="../../sanoid" # make sure that there is no cache file - rm -f /var/cache/sanoidsnapshots.txt + rm -f /var/cache/sanoid/snapshots.txt + rm -f /var/cache/sanoid/datasets.txt + + mkdir -p /etc/sanoid # install needed sanoid configuration files [ -f sanoid.conf ] && cp sanoid.conf /etc/sanoid/sanoid.conf @@ -34,7 +37,7 @@ function checkEnvironment { echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" echo "you should be running this test in a" echo "dedicated vm, as it will mess with your system!" - echo "Are you sure you wan't to continue? (y)" + echo "Are you sure you want to continue? (y)" echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" set -x @@ -51,6 +54,11 @@ function disableTimeSync { if [ $? -eq 0 ]; then timedatectl set-ntp 0 fi + + which systemctl > /dev/null + if [ $? -eq 0 ]; then + systemctl is-active virtualbox-guest-utils.service && systemctl stop virtualbox-guest-utils.service + fi } function saveSnapshotList { diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 38054b0..34813d1 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -17,8 +17,11 @@ for test in */; do cd "${test}" echo -n y | bash run.sh > "${LOGFILE}" 2>&1 - if [ $? -eq 0 ]; then + ret=$? + if [ $ret -eq 0 ]; then echo "[PASS]" + elif [ $ret -eq 130 ]; then + echo "[SKIPPED]" else echo "[FAILED] (see ${LOGFILE})" fi diff --git a/tests/syncoid/1_bookmark_replication_intermediate/run.sh b/tests/syncoid/001_bookmark_replication_intermediate/run.sh similarity index 100% rename from tests/syncoid/1_bookmark_replication_intermediate/run.sh rename to tests/syncoid/001_bookmark_replication_intermediate/run.sh diff --git a/tests/syncoid/2_bookmark_replication_no_intermediate/run.sh b/tests/syncoid/002_bookmark_replication_no_intermediate/run.sh similarity index 100% rename from tests/syncoid/2_bookmark_replication_no_intermediate/run.sh rename to tests/syncoid/002_bookmark_replication_no_intermediate/run.sh diff --git a/tests/syncoid/3_force_delete/run.sh b/tests/syncoid/003_force_delete/run.sh similarity index 100% rename from tests/syncoid/3_force_delete/run.sh rename to tests/syncoid/003_force_delete/run.sh diff --git a/tests/syncoid/4_bookmark_replication_edge_case/run.sh b/tests/syncoid/004_bookmark_replication_edge_case/run.sh similarity index 100% rename from tests/syncoid/4_bookmark_replication_edge_case/run.sh rename to tests/syncoid/004_bookmark_replication_edge_case/run.sh diff --git a/tests/syncoid/5_reset_resume_state/run.sh b/tests/syncoid/005_reset_resume_state/run.sh similarity index 98% rename from tests/syncoid/5_reset_resume_state/run.sh rename to tests/syncoid/005_reset_resume_state/run.sh index 6e71002..4eb4af6 100755 --- a/tests/syncoid/5_reset_resume_state/run.sh +++ b/tests/syncoid/005_reset_resume_state/run.sh @@ -28,6 +28,8 @@ zfs create -o mountpoint="${MOUNT_TARGET}" "${POOL_NAME}"/src dd if=/dev/urandom of="${MOUNT_TARGET}"/big_file bs=1M count=200 +sleep 1 + ../../../syncoid --debug --compress=none --source-bwlimit=2m "${POOL_NAME}"/src "${POOL_NAME}"/dst & syncoid_pid=$! sleep 5 @@ -45,6 +47,9 @@ wait sleep 1 ../../../syncoid --debug --compress=none --no-resume "${POOL_NAME}"/src "${POOL_NAME}"/dst | grep "reset partial receive state of syncoid" + +sleep 1 + ../../../syncoid --debug --compress=none "${POOL_NAME}"/src "${POOL_NAME}"/dst exit $? diff --git a/tests/syncoid/6_reset_resume_state2/run.sh b/tests/syncoid/006_reset_resume_state2/run.sh similarity index 98% rename from tests/syncoid/6_reset_resume_state2/run.sh rename to tests/syncoid/006_reset_resume_state2/run.sh index 1afc921..c568fd4 100755 --- a/tests/syncoid/6_reset_resume_state2/run.sh +++ b/tests/syncoid/006_reset_resume_state2/run.sh @@ -28,6 +28,8 @@ zfs create -o mountpoint="${MOUNT_TARGET}" "${POOL_NAME}"/src dd if=/dev/urandom of="${MOUNT_TARGET}"/big_file bs=1M count=200 +sleep 1 + zfs snapshot "${POOL_NAME}"/src@big ../../../syncoid --debug --no-sync-snap --compress=none --source-bwlimit=2m "${POOL_NAME}"/src "${POOL_NAME}"/dst & syncoid_pid=$! @@ -47,6 +49,9 @@ sleep 1 zfs destroy "${POOL_NAME}"/src@big ../../../syncoid --debug --compress=none "${POOL_NAME}"/src "${POOL_NAME}"/dst # | grep "reset partial receive state of syncoid" + +sleep 1 + ../../../syncoid --debug --compress=none "${POOL_NAME}"/src "${POOL_NAME}"/dst exit $? diff --git a/tests/syncoid/7_preserve_recordsize/run.sh b/tests/syncoid/007_preserve_recordsize/run.sh similarity index 78% rename from tests/syncoid/7_preserve_recordsize/run.sh rename to tests/syncoid/007_preserve_recordsize/run.sh index 3085b9b..9f7d7f4 100755 --- a/tests/syncoid/7_preserve_recordsize/run.sh +++ b/tests/syncoid/007_preserve_recordsize/run.sh @@ -32,17 +32,17 @@ zfs create -o recordsize=32k "${POOL_NAME}"/src/32 zfs create -o recordsize=128k "${POOL_NAME}"/src/128 ../../../syncoid --preserve-recordsize --recursive --debug --compress=none "${POOL_NAME}"/src "${POOL_NAME}"/dst -zfs get recordsize -t filesystem -r "${POOL_NAME}"/dst -zfs get volblocksize -t volume -r "${POOL_NAME}"/dst +zfs get -t filesystem -r recordsize "${POOL_NAME}"/dst +zfs get -t volume -r volblocksize "${POOL_NAME}"/dst -if [ "$(zfs get recordsize -H -o value -t filesystem "${POOL_NAME}"/dst/16)" != "16K" ]; then +if [ "$(zfs get -H -o value -t filesystem recordsize "${POOL_NAME}"/dst/16)" != "16K" ]; then exit 1 fi -if [ "$(zfs get recordsize -H -o value -t filesystem "${POOL_NAME}"/dst/32)" != "32K" ]; then +if [ "$(zfs get -H -o value -t filesystem recordsize "${POOL_NAME}"/dst/32)" != "32K" ]; then exit 1 fi -if [ "$(zfs get recordsize -H -o value -t filesystem "${POOL_NAME}"/dst/128)" != "128K" ]; then +if [ "$(zfs get -H -o value -t filesystem recordsize "${POOL_NAME}"/dst/128)" != "128K" ]; then exit 1 fi diff --git a/tests/syncoid/008_force_delete_snapshot/run.sh b/tests/syncoid/008_force_delete_snapshot/run.sh new file mode 100755 index 0000000..899092a --- /dev/null +++ b/tests/syncoid/008_force_delete_snapshot/run.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# test replication with deletion of conflicting snapshot on target + +set -x +set -e + +. ../../common/lib.sh + +POOL_IMAGE="/tmp/syncoid-test-8.zpool" +POOL_SIZE="200M" +POOL_NAME="syncoid-test-8" +TARGET_CHECKSUM="ee439200c9fa54fc33ce301ef64d4240a6c5587766bfeb651c5cf358e11ec89d -" + +truncate -s "${POOL_SIZE}" "${POOL_IMAGE}" + +zpool create -m none -f "${POOL_NAME}" "${POOL_IMAGE}" + +function cleanUp { + zpool export "${POOL_NAME}" +} + +# export pool in any case +trap cleanUp EXIT + +zfs create "${POOL_NAME}"/src +zfs snapshot "${POOL_NAME}"/src@duplicate + +# initial replication +../../../syncoid -r --debug --compress=none "${POOL_NAME}"/src "${POOL_NAME}"/dst +# recreate snapshot with the same name on src +zfs destroy "${POOL_NAME}"/src@duplicate +zfs snapshot "${POOL_NAME}"/src@duplicate +sleep 1 +../../../syncoid -r --force-delete --debug --compress=none "${POOL_NAME}"/src "${POOL_NAME}"/dst || exit 1 + +# verify +output1=$(zfs list -t snapshot -r -H -o guid,name "${POOL_NAME}"/src | sed 's/@syncoid_.*$'/@syncoid_/) +checksum1=$(echo "${output1}" | shasum -a 256) + +output2=$(zfs list -t snapshot -r -H -o guid,name "${POOL_NAME}"/dst | sed 's/@syncoid_.*$'/@syncoid_/ | sed 's/dst/src/') +checksum2=$(echo "${output2}" | shasum -a 256) + +if [ "${checksum1}" != "${checksum2}" ]; then + exit 1 +fi + +exit 0 diff --git a/tests/syncoid/009_preserve_properties/run.sh b/tests/syncoid/009_preserve_properties/run.sh new file mode 100755 index 0000000..6ec9c4e --- /dev/null +++ b/tests/syncoid/009_preserve_properties/run.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +# test preserving locally set properties from the src dataset to the target one + +set -x +set -e + +. ../../common/lib.sh + +POOL_IMAGE="/tmp/syncoid-test-9.zpool" +MOUNT_TARGET="/tmp/syncoid-test-9.mount" +POOL_SIZE="1000M" +POOL_NAME="syncoid-test-9" + +truncate -s "${POOL_SIZE}" "${POOL_IMAGE}" + +zpool create -m none -f "${POOL_NAME}" "${POOL_IMAGE}" + +function cleanUp { + zpool export "${POOL_NAME}" +} + +# export pool in any case +trap cleanUp EXIT + +zfs create -o recordsize=16k -o xattr=on -o mountpoint=none -o primarycache=none "${POOL_NAME}"/src +zfs create -V 100M -o volblocksize=8k "${POOL_NAME}"/src/zvol8 +zfs create -V 100M -o volblocksize=16k -o primarycache=all "${POOL_NAME}"/src/zvol16 +zfs create -V 100M -o volblocksize=64k "${POOL_NAME}"/src/zvol64 +zfs create -o recordsize=16k -o primarycache=none "${POOL_NAME}"/src/16 +zfs create -o recordsize=32k -o acltype=posixacl "${POOL_NAME}"/src/32 +zfs set 'net.openoid:var-name'='with whitespace and !"§$%&/()= symbols' "${POOL_NAME}"/src/32 + +../../../syncoid --preserve-properties --recursive --debug --compress=none "${POOL_NAME}"/src "${POOL_NAME}"/dst + + +if [ "$(zfs get -H -o value -t filesystem recordsize "${POOL_NAME}"/dst)" != "16K" ]; then + exit 1 +fi + +if [ "$(zfs get -H -o value -t filesystem mountpoint "${POOL_NAME}"/dst)" != "none" ]; then + exit 1 +fi + +if [ "$(zfs get -H -o value -t filesystem xattr "${POOL_NAME}"/dst)" != "on" ]; then + exit 1 +fi + +if [ "$(zfs get -H -o value -t filesystem primarycache "${POOL_NAME}"/dst)" != "none" ]; then + exit 1 +fi + +if [ "$(zfs get -H -o value -t filesystem recordsize "${POOL_NAME}"/dst/16)" != "16K" ]; then + exit 1 +fi + +if [ "$(zfs get -H -o value -t filesystem primarycache "${POOL_NAME}"/dst/16)" != "none" ]; then + exit 1 +fi + +if [ "$(zfs get -H -o value -t filesystem recordsize "${POOL_NAME}"/dst/32)" != "32K" ]; then + exit 1 +fi + +if [ "$(zfs get -H -o value -t filesystem acltype "${POOL_NAME}"/dst/32)" != "posix" ]; then + exit 1 +fi + +if [ "$(zfs get -H -o value -t filesystem 'net.openoid:var-name' "${POOL_NAME}"/dst/32)" != "with whitespace and !\"§$%&/()= symbols" ]; then + exit 1 +fi diff --git a/tests/syncoid/010_filter_snaps/run.sh b/tests/syncoid/010_filter_snaps/run.sh new file mode 100755 index 0000000..949477b --- /dev/null +++ b/tests/syncoid/010_filter_snaps/run.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +# test filtering snapshot names using --include-snaps and --exclude-snaps + +set -x +set -e + +. ../../common/lib.sh + +POOL_IMAGE="/tmp/syncoid-test-10.zpool" +MOUNT_TARGET="/tmp/syncoid-test-10.mount" +POOL_SIZE="100M" +POOL_NAME="syncoid-test-10" + +truncate -s "${POOL_SIZE}" "${POOL_IMAGE}" + +zpool create -m none -f "${POOL_NAME}" "${POOL_IMAGE}" + +##### +# Create source snapshots and destroy the destination snaps and dataset. +##### +function setup_snaps { + # create intermediate snapshots + # sleep is needed so creation time can be used for proper sorting + sleep 1 + zfs snapshot "${POOL_NAME}"/src@monthly1 + sleep 1 + zfs snapshot "${POOL_NAME}"/src@daily1 + sleep 1 + zfs snapshot "${POOL_NAME}"/src@daily2 + sleep 1 + zfs snapshot "${POOL_NAME}"/src@hourly1 + sleep 1 + zfs snapshot "${POOL_NAME}"/src@hourly2 + sleep 1 + zfs snapshot "${POOL_NAME}"/src@daily3 + sleep 1 + zfs snapshot "${POOL_NAME}"/src@hourly3 + sleep 1 + zfs snapshot "${POOL_NAME}"/src@hourly4 +} + +##### +# Remove the destination snapshots and dataset so that each test starts with a +# blank slate. +##### +function clean_snaps { + zfs destroy "${POOL_NAME}"/dst@% + zfs destroy "${POOL_NAME}"/dst +} + +##### +# Verify that the correct set of snapshots is present on the destination. +##### +function verify_checksum { + zfs list -r -t snap "${POOL_NAME}" + + checksum=$(zfs list -t snap -r -H -o name "${POOL_NAME}" | sed 's/@syncoid_.*/@syncoid_/' | shasum -a 256) + + echo "Expected checksum: $1" + echo "Actual checksum: $checksum" + return $( [[ "$checksum" == "$1" ]] ) +} + +function cleanUp { + zpool export "${POOL_NAME}" +} + +# export pool in any case +trap cleanUp EXIT + +zfs create "${POOL_NAME}"/src +setup_snaps + +##### +# TEST 1 +# +# --exclude-snaps is provided and --no-stream is omitted. Hourly snaps should +# be missing from the destination, and all other intermediate snaps should be +# present. +##### + +../../../syncoid --debug --compress=none --no-sync-snap --exclude-snaps='hourly' "${POOL_NAME}"/src "${POOL_NAME}"/dst +verify_checksum '494b6860415607f1d670e4106a10e1316924ba6cd31b4ddacffe0ad6d30a6339 -' +clean_snaps + +##### +# TEST 2 +# +# --exclude-snaps and --no-stream are provided. Only the daily3 snap should be +# present on the destination. +##### + +../../../syncoid --debug --compress=none --no-sync-snap --exclude-snaps='hourly' --no-stream "${POOL_NAME}"/src "${POOL_NAME}"/dst +verify_checksum '0a5072f42180d231cfdd678682972fbbb689140b7f3e996b3c348b7e78d67ea2 -' +clean_snaps + +##### +# TEST 3 +# +# --include-snaps is provided and --no-stream is omitted. Hourly snaps should +# be present on the destination, and all other snaps should be missing +##### + +../../../syncoid --debug --compress=none --no-sync-snap --include-snaps='hourly' "${POOL_NAME}"/src "${POOL_NAME}"/dst +verify_checksum 'd32862be4c71c6cde846322a7d006fd5e8edbd3520d3c7b73953492946debb7f -' +clean_snaps + +##### +# TEST 4 +# +# --include-snaps and --no-stream are provided. Only the hourly4 snap should +# be present on the destination. +##### + +../../../syncoid --debug --compress=none --no-sync-snap --include-snaps='hourly' --no-stream "${POOL_NAME}"/src "${POOL_NAME}"/dst +verify_checksum '81ef1a8298006a7ed856430bb7e05e8b85bbff530ca9dd7831f1da782f8aa4c7 -' +clean_snaps + +##### +# TEST 5 +# +# --include-snaps='hourly' and --exclude-snaps='3' are both provided. The +# hourly snaps should be present on the destination except for hourly3; daily +# and monthly snaps should be missing. +##### + +../../../syncoid --debug --compress=none --no-sync-snap --include-snaps='hourly' --exclude-snaps='3' "${POOL_NAME}"/src "${POOL_NAME}"/dst +verify_checksum '5a9dd92b7d4b8760a1fcad03be843da4f43b915c64caffc1700c0d59a1581239 -' +clean_snaps + +##### +# TEST 6 +# +# --exclude-snaps='syncoid' and --no-stream are provided, and --no-sync-snap is +# omitted. The sync snap should be created on the source but not sent to the +# destination; only hourly4 should be sent. +##### + +../../../syncoid --debug --compress=none --no-stream --exclude-snaps='syncoid' "${POOL_NAME}"/src "${POOL_NAME}"/dst +verify_checksum '9394fdac44ec72764a4673202552599684c83530a2a724dae5b411aaea082b02 -' +clean_snaps diff --git a/tests/syncoid/012_receive_resume_token/run.sh b/tests/syncoid/012_receive_resume_token/run.sh new file mode 100755 index 0000000..a28becc --- /dev/null +++ b/tests/syncoid/012_receive_resume_token/run.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# test verifying syncoid behavior with partial transfers + +set -x + +. ../../common/lib.sh + +POOL_IMAGE="/tmp/syncoid-test-012.zpool" +POOL_SIZE="128M" +POOL_NAME="syncoid-test-012" +MOUNT_TARGET="/tmp/syncoid-test-012.mount" + +truncate -s "${POOL_SIZE}" "${POOL_IMAGE}" + +zpool create -O mountpoint="${MOUNT_TARGET}" -f "${POOL_NAME}" "${POOL_IMAGE}" + +function cleanUp { + zpool destroy "${POOL_NAME}" + rm -f "${POOL_IMAGE}" +} + +# Clean up the pool and image file on exit +trap cleanUp EXIT + +zfs create "${POOL_NAME}/source" +zfs snap "${POOL_NAME}/source@empty" +dd if=/dev/urandom of="${MOUNT_TARGET}/source/garbage.bin" bs=1M count=16 +zfs snap "${POOL_NAME}/source@something" + +# Simulate interrupted transfer +zfs send -pwR "${POOL_NAME}/source@something" | head --bytes=8M | zfs recv -s "${POOL_NAME}/destination" + +# Using syncoid to continue interrupted transfer +../../../syncoid --sendoptions="pw" "${POOL_NAME}/source" "${POOL_NAME}/destination" + +# Check if syncoid succeeded in handling the interrupted transfer +if [ $? -eq 0 ]; then + echo "Syncoid resumed transfer successfully." + + # Verify data integrity with sha256sum comparison + original_sum=$(sha256sum "${MOUNT_TARGET}/source/garbage.bin" | cut -d ' ' -f 1) + received_sum=$(sha256sum "${MOUNT_TARGET}/destination/garbage.bin" | cut -d ' ' -f 1) + + if [ "${original_sum}" == "${received_sum}" ]; then + echo "Data integrity verified." + exit 0 + else + echo "Data integrity check failed." + exit 1 + fi +else + echo "Regression detected: syncoid did not handle the resuming correctly." + exit 1 +fi diff --git a/tests/syncoid/run-tests.sh b/tests/syncoid/run-tests.sh index a9843a5..0e7570e 100755 --- a/tests/syncoid/run-tests.sh +++ b/tests/syncoid/run-tests.sh @@ -17,8 +17,11 @@ for test in */; do cd "${test}" echo | bash run.sh > "${LOGFILE}" 2>&1 - if [ $? -eq 0 ]; then + ret=$? + if [ $ret -eq 0 ]; then echo "[PASS]" + elif [ $ret -eq 130 ]; then + echo "[SKIPPED]" else echo "[FAILED] (see ${LOGFILE})" fi