1#!/usr/local/bin/perl
2
3# this software is licensed for use under the Free Software Foundation's GPL v3.0 license, as retrieved
4# from http://www.gnu.org/licenses/gpl-3.0.html on 2014-11-17.  A copy should also be available in this
5# project's Git repository at https://github.com/jimsalterjrs/sanoid/blob/master/LICENSE.
6
7$::VERSION = '2.0.3';
8
9use strict;
10use warnings;
11use Data::Dumper;
12use Getopt::Long qw(:config auto_version auto_help);
13use Pod::Usage;
14use Time::Local;
15use Sys::Hostname;
16use Capture::Tiny ':all';
17
18my $mbuffer_size = "16M";
19
20# Blank defaults to use ssh client's default
21# TODO: Merge into a single "sshflags" option?
22my %args = ('sshkey' => '', 'sshport' => '', 'sshcipher' => '', 'sshoption' => [], 'target-bwlimit' => '', 'source-bwlimit' => '');
23GetOptions(\%args, "no-command-checks", "monitor-version", "compress=s", "dumpsnaps", "recursive|r", "sendoptions=s", "recvoptions=s",
24                   "source-bwlimit=s", "target-bwlimit=s", "sshkey=s", "sshport=i", "sshcipher|c=s", "sshoption|o=s@",
25                   "debug", "quiet", "no-stream", "no-sync-snap", "no-resume", "exclude=s@", "skip-parent", "identifier=s",
26                   "no-clone-handling", "no-privilege-elevation", "force-delete", "no-clone-rollback", "no-rollback",
27                   "create-bookmark",
28                   "mbuffer-size=s" => \$mbuffer_size) or pod2usage(2);
29
30my %compressargs = %{compressargset($args{'compress'} || 'default')}; # Can't be done with GetOptions arg, as default still needs to be set
31
32my @sendoptions = ();
33if (length $args{'sendoptions'}) {
34	@sendoptions = parsespecialoptions($args{'sendoptions'});
35	if (! defined($sendoptions[0])) {
36		warn "invalid send options!";
37		pod2usage(2);
38		exit 127;
39	}
40}
41
42my @recvoptions = ();
43if (length $args{'recvoptions'}) {
44	@recvoptions = parsespecialoptions($args{'recvoptions'});
45	if (! defined($recvoptions[0])) {
46		warn "invalid receive options!";
47		pod2usage(2);
48		exit 127;
49	}
50}
51
52
53# TODO Expand to accept multiple sources?
54if (scalar(@ARGV) != 2) {
55	print("Source or target not found!\n");
56	pod2usage(2);
57	exit 127;
58} else {
59	$args{'source'} = $ARGV[0];
60	$args{'target'} = $ARGV[1];
61}
62
63# Could possibly merge these into an options function
64if (length $args{'source-bwlimit'}) {
65	$args{'source-bwlimit'} = "-R $args{'source-bwlimit'}";
66}
67if (length $args{'target-bwlimit'}) {
68	$args{'target-bwlimit'} = "-r $args{'target-bwlimit'}";
69}
70$args{'streamarg'} = (defined $args{'no-stream'} ? '-i' : '-I');
71
72my $rawsourcefs = $args{'source'};
73my $rawtargetfs = $args{'target'};
74my $debug = $args{'debug'};
75my $quiet = $args{'quiet'};
76my $resume = !$args{'no-resume'};
77
78# for compatibility reasons, older versions used hardcoded command paths
79$ENV{'PATH'} = $ENV{'PATH'} . ":/usr/local/bin:/bin:/usr/bin:/sbin";
80
81my $zfscmd = 'zfs';
82my $zpoolcmd = 'zpool';
83my $sshcmd = 'ssh';
84my $pscmd = 'ps';
85
86my $pvcmd = 'pv';
87my $mbuffercmd = 'mbuffer';
88my $sudocmd = 'sudo';
89my $mbufferoptions = "-q -s 128k -m $mbuffer_size 2>/dev/null";
90# currently using POSIX compatible command to check for program existence because we aren't depending on perl
91# being present on remote machines.
92my $checkcmd = 'command -v';
93
94if (length $args{'sshcipher'}) {
95	$args{'sshcipher'} = "-c $args{'sshcipher'}";
96}
97if (length $args{'sshport'}) {
98  $args{'sshport'} = "-p $args{'sshport'}";
99}
100if (length $args{'sshkey'}) {
101	$args{'sshkey'} = "-i $args{'sshkey'}";
102}
103my $sshoptions = join " ", map { "-o " . $_ } @{$args{'sshoption'}}; # deref required
104
105my $identifier = "";
106if (length $args{'identifier'}) {
107	if ($args{'identifier'} !~ /^[a-zA-Z0-9-_:.]+$/) {
108		# invalid extra identifier
109		print("CRITICAL: extra identifier contains invalid chars!\n");
110		pod2usage(2);
111		exit 127;
112	}
113	$identifier = "$args{'identifier'}_";
114}
115
116# figure out if source and/or target are remote.
117$sshcmd = "$sshcmd $args{'sshcipher'} $sshoptions $args{'sshport'} $args{'sshkey'}";
118if ($debug) { print "DEBUG: SSHCMD: $sshcmd\n"; }
119my ($sourcehost,$sourcefs,$sourceisroot) = getssh($rawsourcefs);
120my ($targethost,$targetfs,$targetisroot) = getssh($rawtargetfs);
121
122my $sourcesudocmd = $sourceisroot ? '' : $sudocmd;
123my $targetsudocmd = $targetisroot ? '' : $sudocmd;
124
125# figure out whether compression, mbuffering, pv
126# are available on source, target, local machines.
127# warn user of anything missing, then continue with sync.
128my %avail = checkcommands();
129
130my %snaps;
131my $exitcode = 0;
132
133## break here to call replication individually so that we ##
134## can loop across children separately, for recursive     ##
135## replication                                            ##
136
137if (!defined $args{'recursive'}) {
138	syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef);
139} else {
140	if ($debug) { print "DEBUG: recursive sync of $sourcefs.\n"; }
141	my @datasets = getchilddatasets($sourcehost, $sourcefs, $sourceisroot);
142
143	if (!@datasets) {
144		warn "CRITICAL ERROR: no datasets found";
145		@datasets = ();
146		$exitcode = 2;
147	}
148
149	my @deferred;
150
151	foreach my $datasetProperties(@datasets) {
152		my $dataset = $datasetProperties->{'name'};
153		my $origin = $datasetProperties->{'origin'};
154		if ($origin eq "-" || defined $args{'no-clone-handling'}) {
155			$origin = undef;
156		} else {
157			# check if clone source is replicated too
158			my @values = split(/@/, $origin, 2);
159			my $srcdataset = $values[0];
160
161			my $found = 0;
162			foreach my $datasetProperties(@datasets) {
163				if ($datasetProperties->{'name'} eq $srcdataset) {
164					$found = 1;
165					last;
166				}
167			}
168
169			if ($found == 0) {
170				# clone source is not replicated, do a full replication
171				$origin = undef;
172			} else {
173				# clone source is replicated, defer until all non clones are replicated
174				push @deferred, $datasetProperties;
175				next;
176			}
177		}
178
179		$dataset =~ s/\Q$sourcefs\E//;
180		chomp $dataset;
181		my $childsourcefs = $sourcefs . $dataset;
182		my $childtargetfs = $targetfs . $dataset;
183		# print "syncdataset($sourcehost, $childsourcefs, $targethost, $childtargetfs); \n";
184		syncdataset($sourcehost, $childsourcefs, $targethost, $childtargetfs, $origin);
185	}
186
187	# replicate cloned datasets and if this is the initial run, recreate them on the target
188	foreach my $datasetProperties(@deferred) {
189		my $dataset = $datasetProperties->{'name'};
190		my $origin = $datasetProperties->{'origin'};
191
192		$dataset =~ s/\Q$sourcefs\E//;
193		chomp $dataset;
194		my $childsourcefs = $sourcefs . $dataset;
195		my $childtargetfs = $targetfs . $dataset;
196		syncdataset($sourcehost, $childsourcefs, $targethost, $childtargetfs, $origin);
197	}
198}
199
200# close SSH sockets for master connections as applicable
201if ($sourcehost ne '') {
202	open FH, "$sshcmd $sourcehost -O exit 2>&1 |";
203	close FH;
204}
205if ($targethost ne '') {
206	open FH, "$sshcmd $targethost -O exit 2>&1 |";
207	close FH;
208}
209
210exit $exitcode;
211
212##############################################################################
213##############################################################################
214##############################################################################
215##############################################################################
216
217sub getchilddatasets {
218	my ($rhost,$fs,$isroot,%snaps) = @_;
219	my $mysudocmd;
220	my $fsescaped = escapeshellparam($fs);
221
222	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
223	if ($rhost ne '') {
224		$rhost = "$sshcmd $rhost";
225		# double escaping needed
226		$fsescaped = escapeshellparam($fsescaped);
227	}
228
229	my $getchildrencmd = "$rhost $mysudocmd $zfscmd list -o name,origin -t filesystem,volume -Hr $fsescaped |";
230	if ($debug) { print "DEBUG: getting list of child datasets on $fs using $getchildrencmd...\n"; }
231	if (! open FH, $getchildrencmd) {
232		die "ERROR: list command failed!\n";
233	}
234
235	my @children;
236	my $first = 1;
237
238	DATASETS: while(<FH>) {
239		chomp;
240
241		if (defined $args{'skip-parent'} && $first eq 1) {
242			# parent dataset is the first element
243			$first = 0;
244			next;
245		}
246
247		my ($dataset, $origin) = /^([^\t]+)\t([^\t]+)/;
248
249		if (defined $args{'exclude'}) {
250			my $excludes = $args{'exclude'};
251			foreach (@$excludes) {
252				if ($dataset =~ /$_/) {
253					if ($debug) { print "DEBUG: excluded $dataset because of $_\n"; }
254					next DATASETS;
255				}
256			}
257		}
258
259		my %properties;
260		$properties{'name'} = $dataset;
261		$properties{'origin'} = $origin;
262
263		push @children, \%properties;
264	}
265	close FH;
266
267	return @children;
268}
269
270sub syncdataset {
271
272	my ($sourcehost, $sourcefs, $targethost, $targetfs, $origin, $skipsnapshot) = @_;
273
274	my $stdout;
275	my $exit;
276
277	my $sourcefsescaped = escapeshellparam($sourcefs);
278	my $targetfsescaped = escapeshellparam($targetfs);
279
280	# if no rollbacks are allowed, disable forced receive
281	my $forcedrecv = "-F";
282	if (defined $args{'no-rollback'}) {
283		$forcedrecv = "";
284	}
285
286	if ($debug) { print "DEBUG: syncing source $sourcefs to target $targetfs.\n"; }
287
288	my $sync = getzfsvalue($sourcehost,$sourcefs,$sourceisroot,'syncoid:sync');
289
290	if (!defined $sync) {
291		# zfs already printed the corresponding error
292		if ($exitcode < 2) { $exitcode = 2; }
293		return 0;
294	}
295
296	if ($sync eq 'true' || $sync eq '-' || $sync eq '') {
297		# empty is handled the same as unset (aka: '-')
298		# definitely sync this dataset - if a host is called 'true' or '-', then you're special
299	} elsif ($sync eq 'false') {
300		if (!$quiet) { print "INFO: Skipping dataset (syncoid:sync=false): $sourcefs...\n"; }
301		return 0;
302	} else {
303		my $hostid = hostname();
304		my @hosts = split(/,/,$sync);
305		if (!(grep $hostid eq $_, @hosts)) {
306			if (!$quiet) { print "INFO: Skipping dataset (syncoid:sync doesn't include $hostid): $sourcefs...\n"; }
307			return 0;
308		}
309	}
310
311	# make sure target is not currently in receive.
312	if (iszfsbusy($targethost,$targetfs,$targetisroot)) {
313		warn "Cannot sync now: $targetfs is already target of a zfs receive process.\n";
314		if ($exitcode < 1) { $exitcode = 1; }
315		return 0;
316	}
317
318	# does the target filesystem exist yet?
319	my $targetexists = targetexists($targethost,$targetfs,$targetisroot);
320
321	my $receiveextraargs = "";
322	my $receivetoken;
323	if ($resume) {
324		# save state of interrupted receive stream
325		$receiveextraargs = "-s";
326
327		if ($targetexists) {
328			# check remote dataset for receive resume token (interrupted receive)
329			$receivetoken = getreceivetoken($targethost,$targetfs,$targetisroot);
330
331			if ($debug && defined($receivetoken)) {
332				print "DEBUG: got receive resume token: $receivetoken: \n";
333			}
334		}
335	}
336
337	my $newsyncsnap;
338
339	# skip snapshot checking/creation in case of resumed receive
340	if (!defined($receivetoken)) {
341		# build hashes of the snaps on the source and target filesystems.
342
343		%snaps = getsnaps('source',$sourcehost,$sourcefs,$sourceisroot);
344
345		if ($targetexists) {
346		    my %targetsnaps = getsnaps('target',$targethost,$targetfs,$targetisroot);
347		    my %sourcesnaps = %snaps;
348		    %snaps = (%sourcesnaps, %targetsnaps);
349		}
350
351		if (defined $args{'dumpsnaps'}) {
352		    print "merged snapshot list of $targetfs: \n";
353		    dumphash(\%snaps);
354		    print "\n\n\n";
355		}
356
357		if (!defined $args{'no-sync-snap'} && !defined $skipsnapshot) {
358			# create a new syncoid snapshot on the source filesystem.
359			$newsyncsnap = newsyncsnap($sourcehost,$sourcefs,$sourceisroot);
360			if (!$newsyncsnap) {
361				# we already whined about the error
362				return 0;
363			}
364		} else {
365			# we don't want sync snapshots created, so use the newest snapshot we can find.
366			$newsyncsnap = getnewestsnapshot($sourcehost,$sourcefs,$sourceisroot);
367			if ($newsyncsnap eq 0) {
368				warn "CRITICAL: no snapshots exist on source $sourcefs, and you asked for --no-sync-snap.\n";
369				if ($exitcode < 1) { $exitcode = 1; }
370				return 0;
371			}
372		}
373	}
374	my $newsyncsnapescaped = escapeshellparam($newsyncsnap);
375
376	# there is currently (2014-09-01) a bug in ZFS on Linux
377	# that causes readonly to always show on if it's EVER
378	# been turned on... even when it's off... unless and
379	# until the filesystem is zfs umounted and zfs remounted.
380	# we're going to do the right thing anyway.
381		# dyking this functionality out for the time being due to buggy mount/unmount behavior
382		# with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly.
383	#my $originaltargetreadonly;
384
385	my $sendoptions = getoptionsline(\@sendoptions, ('D','L','P','R','c','e','h','p','v','w'));
386	my $recvoptions = getoptionsline(\@recvoptions, ('h','o','x','u','v'));
387
388	# sync 'em up.
389	if (! $targetexists) {
390		# do an initial sync from the oldest source snapshot
391		# THEN do an -I to the newest
392		if ($debug) {
393			if (!defined ($args{'no-stream'}) ) {
394				print "DEBUG: target $targetfs does not exist.  Finding oldest available snapshot on source $sourcefs ...\n";
395			} else {
396				print "DEBUG: target $targetfs does not exist, and --no-stream selected.  Finding newest available snapshot on source $sourcefs ...\n";
397			}
398		}
399		my $oldestsnap = getoldestsnapshot(\%snaps);
400		if (! $oldestsnap) {
401			if (defined ($args{'no-sync-snap'}) ) {
402				# we already whined about the missing snapshots
403				return 0;
404			}
405
406			# getoldestsnapshot() returned false, so use new sync snapshot
407			if ($debug) { print "DEBUG: getoldestsnapshot() returned false, so using $newsyncsnap.\n"; }
408			$oldestsnap = $newsyncsnap;
409		}
410
411		# if --no-stream is specified, our full needs to be the newest snapshot, not the oldest.
412		if (defined $args{'no-stream'}) {
413			if (defined ($args{'no-sync-snap'}) ) {
414				$oldestsnap = getnewestsnapshot(\%snaps);
415			} else {
416				$oldestsnap = $newsyncsnap;
417			}
418		}
419		my $oldestsnapescaped = escapeshellparam($oldestsnap);
420
421		my $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions $sourcefsescaped\@$oldestsnapescaped";
422		my $recvcmd = "$targetsudocmd $zfscmd receive $recvoptions $receiveextraargs $forcedrecv $targetfsescaped";
423
424		my $pvsize;
425		if (defined $origin) {
426			my $originescaped = escapeshellparam($origin);
427			$sendcmd = "$sourcesudocmd $zfscmd send $sendoptions -i $originescaped $sourcefsescaped\@$oldestsnapescaped";
428			my $streamargBackup = $args{'streamarg'};
429			$args{'streamarg'} = "-i";
430			$pvsize = getsendsize($sourcehost,$origin,"$sourcefs\@$oldestsnap",$sourceisroot);
431			$args{'streamarg'} = $streamargBackup;
432		} else {
433			$pvsize = getsendsize($sourcehost,"$sourcefs\@$oldestsnap",0,$sourceisroot);
434		}
435
436		my $disp_pvsize = readablebytes($pvsize);
437		if ($pvsize == 0) { $disp_pvsize = 'UNKNOWN'; }
438		my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot);
439		if (!$quiet) {
440			if (defined $origin) {
441				print "INFO: Clone is recreated on target $targetfs based on $origin\n";
442			}
443			if (!defined ($args{'no-stream'}) ) {
444				print "INFO: Sending oldest full snapshot $sourcefs\@$oldestsnap (~ $disp_pvsize) to new target filesystem:\n";
445			} else {
446				print "INFO: --no-stream selected; sending newest full snapshot $sourcefs\@$oldestsnap (~ $disp_pvsize) to new target filesystem:\n";
447			}
448		}
449		if ($debug) { print "DEBUG: $synccmd\n"; }
450
451		# make sure target is (still) not currently in receive.
452		if (iszfsbusy($targethost,$targetfs,$targetisroot)) {
453			warn "Cannot sync now: $targetfs is already target of a zfs receive process.\n";
454			if ($exitcode < 1) { $exitcode = 1; }
455			return 0;
456		}
457		system($synccmd) == 0 or do {
458			if (defined $origin) {
459				print "INFO: clone creation failed, trying ordinary replication as fallback\n";
460				syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef, 1);
461				return 0;
462			}
463
464			warn "CRITICAL ERROR: $synccmd failed: $?";
465			if ($exitcode < 2) { $exitcode = 2; }
466			return 0;
467		};
468
469		# now do an -I to the new sync snapshot, assuming there were any snapshots
470		# other than the new sync snapshot to begin with, of course - and that we
471		# aren't invoked with --no-stream, in which case a full of the newest snap
472		# available was all we needed to do
473		if (!defined ($args{'no-stream'}) && ($oldestsnap ne $newsyncsnap) ) {
474
475			# get current readonly status of target, then set it to on during sync
476				# dyking this functionality out for the time being due to buggy mount/unmount behavior
477				# with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly.
478			# $originaltargetreadonly = getzfsvalue($targethost,$targetfs,$targetisroot,'readonly');
479			# setzfsvalue($targethost,$targetfs,$targetisroot,'readonly','on');
480
481			$sendcmd = "$sourcesudocmd $zfscmd send $sendoptions $args{'streamarg'} $sourcefsescaped\@$oldestsnapescaped $sourcefsescaped\@$newsyncsnapescaped";
482			$pvsize = getsendsize($sourcehost,"$sourcefs\@$oldestsnap","$sourcefs\@$newsyncsnap",$sourceisroot);
483			$disp_pvsize = readablebytes($pvsize);
484			if ($pvsize == 0) { $disp_pvsize = "UNKNOWN"; }
485			$synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot);
486
487			# make sure target is (still) not currently in receive.
488			if (iszfsbusy($targethost,$targetfs,$targetisroot)) {
489				warn "Cannot sync now: $targetfs is already target of a zfs receive process.\n";
490				if ($exitcode < 1) { $exitcode = 1; }
491				return 0;
492			}
493
494			if (!$quiet) { print "INFO: Updating new target filesystem with incremental $sourcefs\@$oldestsnap ... $newsyncsnap (~ $disp_pvsize):\n"; }
495			if ($debug) { print "DEBUG: $synccmd\n"; }
496
497			if ($oldestsnap ne $newsyncsnap) {
498				my $ret = system($synccmd);
499				if ($ret != 0) {
500					warn "CRITICAL ERROR: $synccmd failed: $?";
501					if ($exitcode < 1) { $exitcode = 1; }
502					return 0;
503				}
504			} else {
505				if (!$quiet) { print "INFO: no incremental sync needed; $oldestsnap is already the newest available snapshot.\n"; }
506			}
507
508			# restore original readonly value to target after sync complete
509				# dyking this functionality out for the time being due to buggy mount/unmount behavior
510				# with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly.
511			# setzfsvalue($targethost,$targetfs,$targetisroot,'readonly',$originaltargetreadonly);
512		}
513	} else {
514		# resume interrupted receive if there is a valid resume $token
515		# and because this will ony resume the receive to the next
516		# snapshot, do a normal sync after that
517		if (defined($receivetoken)) {
518			$sendoptions = getoptionsline(\@sendoptions, ('P','e','v','w'));
519			my $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions -t $receivetoken";
520			my $recvcmd = "$targetsudocmd $zfscmd receive $recvoptions $receiveextraargs $forcedrecv $targetfsescaped 2>&1";
521			my $pvsize = getsendsize($sourcehost,"","",$sourceisroot,$receivetoken);
522			my $disp_pvsize = readablebytes($pvsize);
523			if ($pvsize == 0) { $disp_pvsize = "UNKNOWN"; }
524			my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot);
525
526			if (!$quiet) { print "Resuming interrupted zfs send/receive from $sourcefs to $targetfs (~ $disp_pvsize remaining):\n"; }
527			if ($debug) { print "DEBUG: $synccmd\n"; }
528
529			if ($pvsize == 0) {
530				# we need to capture the error of zfs send, this will render pv useless but in this case
531				# it doesn't matter because we don't know the estimated send size (probably because
532				# the initial snapshot used for resumed send doesn't exist anymore)
533				($stdout, $exit) = tee_stderr {
534					system("$synccmd")
535				};
536			} else {
537				($stdout, $exit) = tee_stdout {
538					system("$synccmd")
539				};
540			}
541
542			$exit == 0 or do {
543				if ($stdout =~ /\Qused in the initial send no longer exists\E/) {
544					if (!$quiet) { print "WARN: resetting partially receive state because the snapshot source no longer exists\n"; }
545					resetreceivestate($targethost,$targetfs,$targetisroot);
546					# do an normal sync cycle
547					return syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, $origin);
548				} else {
549					warn "CRITICAL ERROR: $synccmd failed: $?";
550					if ($exitcode < 2) { $exitcode = 2; }
551					return 0;
552				}
553			};
554
555			# a resumed transfer will only be done to the next snapshot,
556			# so do an normal sync cycle
557			return syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef);
558		}
559
560		# find most recent matching snapshot and do an -I
561		# to the new snapshot
562
563		# get current readonly status of target, then set it to on during sync
564			# dyking this functionality out for the time being due to buggy mount/unmount behavior
565			# with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly.
566		# $originaltargetreadonly = getzfsvalue($targethost,$targetfs,$targetisroot,'readonly');
567		# setzfsvalue($targethost,$targetfs,$targetisroot,'readonly','on');
568
569		my $targetsize = getzfsvalue($targethost,$targetfs,$targetisroot,'-p used');
570
571		my $bookmark = 0;
572		my $bookmarkcreation = 0;
573
574		my $matchingsnap = getmatchingsnapshot($sourcefs, $targetfs, \%snaps);
575		if (! $matchingsnap) {
576			# no matching snapshots, check for bookmarks as fallback
577			my %bookmarks = getbookmarks($sourcehost,$sourcefs,$sourceisroot);
578
579			# check for matching guid of source bookmark and target snapshot (oldest first)
580			foreach my $snap ( sort { $snaps{'target'}{$b}{'creation'}<=>$snaps{'target'}{$a}{'creation'} } keys %{ $snaps{'target'} }) {
581				my $guid = $snaps{'target'}{$snap}{'guid'};
582
583				if (defined $bookmarks{$guid}) {
584					# found a match
585					$bookmark = $bookmarks{$guid}{'name'};
586					$bookmarkcreation = $bookmarks{$guid}{'creation'};
587					$matchingsnap = $snap;
588					last;
589				}
590			}
591
592			if (! $bookmark) {
593				if ($args{'force-delete'}) {
594					if (!$quiet) { print "Removing $targetfs because no matching snapshots were found\n"; }
595
596					my $rcommand = '';
597					my $mysudocmd = '';
598					my $targetfsescaped = escapeshellparam($targetfs);
599
600					if ($targethost ne '') { $rcommand = "$sshcmd $targethost"; }
601					if (!$targetisroot) { $mysudocmd = $sudocmd; }
602
603					my $prunecmd = "$mysudocmd $zfscmd destroy -r $targetfsescaped; ";
604					if ($targethost ne '') {
605						$prunecmd = escapeshellparam($prunecmd);
606					}
607
608					my $ret = system("$rcommand $prunecmd");
609					if ($ret != 0) {
610						warn "WARNING: $rcommand $prunecmd failed: $?";
611					} else {
612						# redo sync and skip snapshot creation (already taken)
613						return syncdataset($sourcehost, $sourcefs, $targethost, $targetfs, undef, 1);
614					}
615				}
616
617				# if we got this far, we failed to find a matching snapshot/bookmark.
618				if ($exitcode < 2) { $exitcode = 2; }
619
620				print "\n";
621				print "CRITICAL ERROR: Target $targetfs exists but has no snapshots matching with $sourcefs!\n";
622				print "                Replication to target would require destroying existing\n";
623				print "                target. Cowardly refusing to destroy your existing target.\n\n";
624
625				# experience tells me we need a mollyguard for people who try to
626				# zfs create targetpool/targetsnap ; syncoid sourcepool/sourcesnap targetpool/targetsnap ...
627
628				if ( $targetsize < (64*1024*1024) ) {
629					print "          NOTE: Target $targetfs dataset is < 64MB used - did you mistakenly run\n";
630					print "                \`zfs create $args{'target'}\` on the target? ZFS initial\n";
631					print "                replication must be to a NON EXISTENT DATASET, which will\n";
632					print "                then be CREATED BY the initial replication process.\n\n";
633				}
634
635				# return false now in case more child datasets need replication.
636				return 0;
637			}
638		}
639
640		# make sure target is (still) not currently in receive.
641		if (iszfsbusy($targethost,$targetfs,$targetisroot)) {
642			warn "Cannot sync now: $targetfs is already target of a zfs receive process.\n";
643			if ($exitcode < 1) { $exitcode = 1; }
644			return 0;
645		}
646
647		if ($matchingsnap eq $newsyncsnap) {
648			# barf some text but don't touch the filesystem
649			if (!$quiet) { print "INFO: no snapshots on source newer than $newsyncsnap on target. Nothing to do, not syncing.\n"; }
650			return 0;
651		} else {
652			my $matchingsnapescaped = escapeshellparam($matchingsnap);
653			# rollback target to matchingsnap
654			if (!defined $args{'no-rollback'}) {
655				my $rollbacktype = "-R";
656				if (defined $args{'no-clone-rollback'}) {
657					$rollbacktype = "-r";
658				}
659				if ($debug) { print "DEBUG: rolling back target to $targetfs\@$matchingsnap...\n"; }
660				if ($targethost ne '') {
661					if ($debug) { print "$sshcmd $targethost $targetsudocmd $zfscmd rollback $rollbacktype $targetfsescaped\@$matchingsnapescaped\n"; }
662					system ("$sshcmd $targethost " . escapeshellparam("$targetsudocmd $zfscmd rollback $rollbacktype $targetfsescaped\@$matchingsnapescaped"));
663				} else {
664					if ($debug) { print "$targetsudocmd $zfscmd rollback $rollbacktype $targetfsescaped\@$matchingsnapescaped\n"; }
665					system ("$targetsudocmd $zfscmd rollback $rollbacktype $targetfsescaped\@$matchingsnapescaped");
666				}
667			}
668
669			my $nextsnapshot = 0;
670
671			if ($bookmark) {
672				my $bookmarkescaped = escapeshellparam($bookmark);
673
674				if (!defined $args{'no-stream'}) {
675					# if intermediate snapshots are needed we need to find the next oldest snapshot,
676					# do an replication to it and replicate as always from oldest to newest
677					# because bookmark sends doesn't support intermediates directly
678					foreach my $snap ( sort { $snaps{'source'}{$a}{'creation'}<=>$snaps{'source'}{$b}{'creation'} } keys %{ $snaps{'source'} }) {
679						if ($snaps{'source'}{$snap}{'creation'} >= $bookmarkcreation) {
680							$nextsnapshot = $snap;
681							last;
682						}
683					}
684				}
685
686				# bookmark stream size can't be determined
687				my $pvsize = 0;
688				my $disp_pvsize = "UNKNOWN";
689
690				$sendoptions = getoptionsline(\@sendoptions, ('L','c','e','w'));
691				if ($nextsnapshot) {
692					my $nextsnapshotescaped = escapeshellparam($nextsnapshot);
693					my $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions -i $sourcefsescaped#$bookmarkescaped $sourcefsescaped\@$nextsnapshotescaped";
694					my $recvcmd = "$targetsudocmd $zfscmd receive $recvoptions $receiveextraargs $forcedrecv $targetfsescaped 2>&1";
695					my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot);
696
697					if (!$quiet) { print "Sending incremental $sourcefs#$bookmarkescaped ... $nextsnapshot (~ $disp_pvsize):\n"; }
698					if ($debug) { print "DEBUG: $synccmd\n"; }
699
700					($stdout, $exit) = tee_stdout {
701						system("$synccmd")
702					};
703
704					$exit == 0 or do {
705						if (!$resume && $stdout =~ /\Qcontains partially-complete state\E/) {
706							if (!$quiet) { print "WARN: resetting partially receive state\n"; }
707							resetreceivestate($targethost,$targetfs,$targetisroot);
708							system("$synccmd") == 0 or do {
709								warn "CRITICAL ERROR: $synccmd failed: $?";
710								if ($exitcode < 2) { $exitcode = 2; }
711								return 0;
712							}
713						} else {
714							warn "CRITICAL ERROR: $synccmd failed: $?";
715							if ($exitcode < 2) { $exitcode = 2; }
716							return 0;
717						}
718					};
719
720					$matchingsnap = $nextsnapshot;
721					$matchingsnapescaped = escapeshellparam($matchingsnap);
722				} else {
723					my $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions -i $sourcefsescaped#$bookmarkescaped $sourcefsescaped\@$newsyncsnapescaped";
724					my $recvcmd = "$targetsudocmd $zfscmd receive $recvoptions $receiveextraargs $forcedrecv $targetfsescaped 2>&1";
725					my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot);
726
727					if (!$quiet) { print "Sending incremental $sourcefs#$bookmarkescaped ... $newsyncsnap (~ $disp_pvsize):\n"; }
728					if ($debug) { print "DEBUG: $synccmd\n"; }
729
730					($stdout, $exit) = tee_stdout {
731						system("$synccmd")
732					};
733
734					$exit == 0 or do {
735						if (!$resume && $stdout =~ /\Qcontains partially-complete state\E/) {
736							if (!$quiet) { print "WARN: resetting partially receive state\n"; }
737							resetreceivestate($targethost,$targetfs,$targetisroot);
738							system("$synccmd") == 0 or do {
739								warn "CRITICAL ERROR: $synccmd failed: $?";
740								if ($exitcode < 2) { $exitcode = 2; }
741								return 0;
742							}
743						} else {
744							warn "CRITICAL ERROR: $synccmd failed: $?";
745							if ($exitcode < 2) { $exitcode = 2; }
746							return 0;
747						}
748					};
749				}
750			}
751
752			# do a normal replication if bookmarks aren't used or if previous
753			# bookmark replication was only done to the next oldest snapshot
754			if (!$bookmark || $nextsnapshot) {
755				if ($matchingsnap eq $newsyncsnap) {
756					# edge case: bookmark replication used the latest snapshot
757					return 0;
758				}
759
760				$sendoptions = getoptionsline(\@sendoptions, ('D','L','P','R','c','e','h','p','v','w'));
761				my $sendcmd = "$sourcesudocmd $zfscmd send $sendoptions $args{'streamarg'} $sourcefsescaped\@$matchingsnapescaped $sourcefsescaped\@$newsyncsnapescaped";
762				my $recvcmd = "$targetsudocmd $zfscmd receive $recvoptions $receiveextraargs $forcedrecv $targetfsescaped 2>&1";
763				my $pvsize = getsendsize($sourcehost,"$sourcefs\@$matchingsnap","$sourcefs\@$newsyncsnap",$sourceisroot);
764				my $disp_pvsize = readablebytes($pvsize);
765				if ($pvsize == 0) { $disp_pvsize = "UNKNOWN"; }
766				my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot);
767
768				if (!$quiet) { print "Sending incremental $sourcefs\@$matchingsnap ... $newsyncsnap (~ $disp_pvsize):\n"; }
769				if ($debug) { print "DEBUG: $synccmd\n"; }
770
771				($stdout, $exit) = tee_stdout {
772					system("$synccmd")
773				};
774
775				$exit == 0 or do {
776					# FreeBSD reports "dataset is busy" instead of "contains partially-complete state"
777					if (!$resume && ($stdout =~ /\Qcontains partially-complete state\E/ || $stdout =~ /\Qdataset is busy\E/)) {
778						if (!$quiet) { print "WARN: resetting partially receive state\n"; }
779						resetreceivestate($targethost,$targetfs,$targetisroot);
780						system("$synccmd") == 0 or do {
781							warn "CRITICAL ERROR: $synccmd failed: $?";
782							if ($exitcode < 2) { $exitcode = 2; }
783							return 0;
784						}
785					} else {
786						warn "CRITICAL ERROR: $synccmd failed: $?";
787						if ($exitcode < 2) { $exitcode = 2; }
788						return 0;
789					}
790				};
791			}
792
793			# restore original readonly value to target after sync complete
794				# dyking this functionality out for the time being due to buggy mount/unmount behavior
795				# with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly.
796			#setzfsvalue($targethost,$targetfs,$targetisroot,'readonly',$originaltargetreadonly);
797		}
798	}
799
800	if (defined $args{'no-sync-snap'}) {
801		if (defined $args{'create-bookmark'}) {
802			my $bookmarkcmd;
803			if ($sourcehost ne '') {
804				$bookmarkcmd = "$sshcmd $sourcehost " . escapeshellparam("$sourcesudocmd $zfscmd bookmark $sourcefsescaped\@$newsyncsnapescaped $sourcefsescaped\#$newsyncsnapescaped");
805			} else {
806				$bookmarkcmd = "$sourcesudocmd $zfscmd bookmark $sourcefsescaped\@$newsyncsnapescaped $sourcefsescaped\#$newsyncsnapescaped";
807			}
808			if ($debug) { print "DEBUG: $bookmarkcmd\n"; }
809			system($bookmarkcmd) == 0 or do {
810				# fallback: assume nameing conflict and try again with guid based suffix
811				my $guid = $snaps{'source'}{$newsyncsnap}{'guid'};
812				$guid = substr($guid, 0, 6);
813
814				if (!$quiet) { print "INFO: bookmark creation failed, retrying with guid based suffix ($guid)...\n"; }
815
816				if ($sourcehost ne '') {
817					$bookmarkcmd = "$sshcmd $sourcehost " . escapeshellparam("$sourcesudocmd $zfscmd bookmark $sourcefsescaped\@$newsyncsnapescaped $sourcefsescaped\#$newsyncsnapescaped$guid");
818				} else {
819					$bookmarkcmd = "$sourcesudocmd $zfscmd bookmark $sourcefsescaped\@$newsyncsnapescaped $sourcefsescaped\#$newsyncsnapescaped$guid";
820				}
821				if ($debug) { print "DEBUG: $bookmarkcmd\n"; }
822				system($bookmarkcmd) == 0 or do {
823					warn "CRITICAL ERROR: $bookmarkcmd failed: $?";
824					if ($exitcode < 2) { $exitcode = 2; }
825					return 0;
826				}
827			};
828		}
829	} else {
830		# prune obsolete sync snaps on source and target (only if this run created ones).
831		pruneoldsyncsnaps($sourcehost,$sourcefs,$newsyncsnap,$sourceisroot,keys %{ $snaps{'source'}});
832		pruneoldsyncsnaps($targethost,$targetfs,$newsyncsnap,$targetisroot,keys %{ $snaps{'target'}});
833	}
834
835} # end syncdataset()
836
837sub compressargset {
838	my ($value) = @_;
839	my $DEFAULT_COMPRESSION = 'lzo';
840	my %COMPRESS_ARGS = (
841		'none' => {
842			rawcmd      => '',
843			args        => '',
844			decomrawcmd => '',
845			decomargs   => '',
846		},
847		'gzip' => {
848			rawcmd      => 'gzip',
849			args        => '-3',
850			decomrawcmd => 'zcat',
851			decomargs   => '',
852		},
853		'pigz-fast' => {
854			rawcmd      => 'pigz',
855			args        => '-3',
856			decomrawcmd => 'pigz',
857			decomargs   => '-dc',
858		},
859		'pigz-slow' => {
860			rawcmd      => 'pigz',
861			args        => '-9',
862			decomrawcmd => 'pigz',
863			decomargs   => '-dc',
864		},
865		'zstd-fast' => {
866			rawcmd      => 'zstd',
867			args        => '-3',
868			decomrawcmd => 'zstd',
869			decomargs   => '-dc',
870		},
871		'zstd-slow' => {
872			rawcmd      => 'zstd',
873			args        => '-19',
874			decomrawcmd => 'zstd',
875			decomargs   => '-dc',
876		},
877		'xz' => {
878			rawcmd      => 'xz',
879			args        => '',
880			decomrawcmd => 'xz',
881			decomargs   => '-d',
882		},
883		'lzo' => {
884			rawcmd      => 'lzop',
885			args        => '',
886			decomrawcmd => 'lzop',
887			decomargs   => '-dfc',
888		},
889		'lz4' => {
890			rawcmd      => 'lz4',
891			args        => '',
892			decomrawcmd => 'lz4',
893			decomargs   => '-dc',
894		},
895	);
896
897	if ($value eq 'default') {
898		$value = $DEFAULT_COMPRESSION;
899	} elsif (!(grep $value eq $_, ('gzip', 'pigz-fast', 'pigz-slow', 'zstd-fast', 'zstd-slow', 'lz4', 'xz', 'lzo', 'default', 'none'))) {
900		warn "Unrecognised compression value $value, defaulting to $DEFAULT_COMPRESSION";
901		$value = $DEFAULT_COMPRESSION;
902	}
903
904	my %comargs = %{$COMPRESS_ARGS{$value}}; # copy
905	$comargs{'compress'} = $value;
906	$comargs{'cmd'}      = "$comargs{'rawcmd'} $comargs{'args'}";
907	$comargs{'decomcmd'} = "$comargs{'decomrawcmd'} $comargs{'decomargs'}";
908	return \%comargs;
909}
910
911sub checkcommands {
912	# make sure compression, mbuffer, and pv are available on
913	# source, target, and local hosts as appropriate.
914
915	my %avail;
916	my $sourcessh;
917	my $targetssh;
918
919	# if --nocommandchecks then assume everything's available and return
920	if ($args{'nocommandchecks'}) {
921		if ($debug) { print "DEBUG: not checking for command availability due to --nocommandchecks switch.\n"; }
922		$avail{'compress'} = 1;
923		$avail{'localpv'} = 1;
924		$avail{'localmbuffer'} = 1;
925		$avail{'sourcembuffer'} = 1;
926		$avail{'targetmbuffer'} = 1;
927		$avail{'sourceresume'} = 1;
928		$avail{'targetresume'} = 1;
929		return %avail;
930	}
931
932	if (!defined $sourcehost) { $sourcehost = ''; }
933	if (!defined $targethost) { $targethost = ''; }
934
935	if ($sourcehost ne '') { $sourcessh = "$sshcmd $sourcehost"; } else { $sourcessh = ''; }
936	if ($targethost ne '') { $targetssh = "$sshcmd $targethost"; } else { $targetssh = ''; }
937
938	# if raw compress command is null, we must have specified no compression. otherwise,
939	# make sure that compression is available everywhere we need it
940	if ($compressargs{'compress'} eq 'none') {
941		if ($debug) { print "DEBUG: compression forced off from command line arguments.\n"; }
942	} else {
943		if ($debug) { print "DEBUG: checking availability of $compressargs{'rawcmd'} on source...\n"; }
944		$avail{'sourcecompress'} = `$sourcessh $checkcmd $compressargs{'rawcmd'} 2>/dev/null`;
945		if ($debug) { print "DEBUG: checking availability of $compressargs{'rawcmd'} on target...\n"; }
946		$avail{'targetcompress'} = `$targetssh $checkcmd $compressargs{'rawcmd'} 2>/dev/null`;
947		if ($debug) { print "DEBUG: checking availability of $compressargs{'rawcmd'} on local machine...\n"; }
948		$avail{'localcompress'} = `$checkcmd $compressargs{'rawcmd'} 2>/dev/null`;
949	}
950
951	my ($s,$t);
952	if ($sourcehost eq '') {
953		$s = '[local machine]'
954	} else {
955		$s = $sourcehost;
956		$s =~ s/^\S*\@//;
957		$s = "ssh:$s";
958	}
959	if ($targethost eq '') {
960		$t = '[local machine]'
961	} else {
962		$t = $targethost;
963		$t =~ s/^\S*\@//;
964		$t = "ssh:$t";
965	}
966
967	if (!defined $avail{'sourcecompress'}) { $avail{'sourcecompress'} = ''; }
968	if (!defined $avail{'targetcompress'}) { $avail{'targetcompress'} = ''; }
969	if (!defined $avail{'localcompress'}) { $avail{'localcompress'} = ''; }
970	if (!defined $avail{'sourcembuffer'}) { $avail{'sourcembuffer'} = ''; }
971	if (!defined $avail{'targetmbuffer'}) { $avail{'targetmbuffer'} = ''; }
972
973
974	if ($avail{'sourcecompress'} eq '') {
975		if ($compressargs{'rawcmd'} ne '') {
976			print "WARN: $compressargs{'rawcmd'} not available on source $s- sync will continue without compression.\n";
977		}
978		$avail{'compress'} = 0;
979	}
980	if ($avail{'targetcompress'} eq '') {
981		if ($compressargs{'rawcmd'} ne '') {
982			print "WARN: $compressargs{'rawcmd'} not available on target $t - sync will continue without compression.\n";
983		}
984		$avail{'compress'} = 0;
985	}
986	if ($avail{'targetcompress'} ne '' && $avail{'sourcecompress'} ne '') {
987		# compression available - unless source and target are both remote, which we'll check
988		# for in the next block and respond to accordingly.
989		$avail{'compress'} = 1;
990	}
991
992	# corner case - if source AND target are BOTH remote, we have to check for local compress too
993	if ($sourcehost ne '' && $targethost ne '' && $avail{'localcompress'} eq '') {
994		if ($compressargs{'rawcmd'} ne '') {
995			print "WARN: $compressargs{'rawcmd'} not available on local machine - sync will continue without compression.\n";
996		}
997		$avail{'compress'} = 0;
998	}
999
1000	if ($debug) { print "DEBUG: checking availability of $mbuffercmd on source...\n"; }
1001	$avail{'sourcembuffer'} = `$sourcessh $checkcmd $mbuffercmd 2>/dev/null`;
1002	if ($avail{'sourcembuffer'} eq '') {
1003		if (!$quiet) { print "WARN: $mbuffercmd not available on source $s - sync will continue without source buffering.\n"; }
1004		$avail{'sourcembuffer'} = 0;
1005	} else {
1006		$avail{'sourcembuffer'} = 1;
1007	}
1008
1009	if ($debug) { print "DEBUG: checking availability of $mbuffercmd on target...\n"; }
1010	$avail{'targetmbuffer'} = `$targetssh $checkcmd $mbuffercmd 2>/dev/null`;
1011	if ($avail{'targetmbuffer'} eq '') {
1012		if (!$quiet) { print "WARN: $mbuffercmd not available on target $t - sync will continue without target buffering.\n"; }
1013		$avail{'targetmbuffer'} = 0;
1014	} else {
1015		$avail{'targetmbuffer'} = 1;
1016	}
1017
1018	# if we're doing remote source AND remote target, check for local mbuffer as well
1019	if ($sourcehost ne '' && $targethost ne '') {
1020		if ($debug) { print "DEBUG: checking availability of $mbuffercmd on local machine...\n"; }
1021		$avail{'localmbuffer'} = `$checkcmd $mbuffercmd 2>/dev/null`;
1022		if ($avail{'localmbuffer'} eq '') {
1023			$avail{'localmbuffer'} = 0;
1024			if (!$quiet) { print "WARN: $mbuffercmd not available on local machine - sync will continue without local buffering.\n"; }
1025		}
1026	}
1027
1028	if ($debug) { print "DEBUG: checking availability of $pvcmd on local machine...\n"; }
1029	$avail{'localpv'} = `$checkcmd $pvcmd 2>/dev/null`;
1030	if ($avail{'localpv'} eq '') {
1031		if (!$quiet) { print "WARN: $pvcmd not available on local machine - sync will continue without progress bar.\n"; }
1032		$avail{'localpv'} = 0;
1033	} else {
1034		$avail{'localpv'} = 1;
1035	}
1036
1037	# check for ZFS resume feature support
1038	if ($resume) {
1039		my @parts = split ('/', $sourcefs);
1040		my $srcpool = $parts[0];
1041		@parts = split ('/', $targetfs);
1042		my $dstpool = $parts[0];
1043
1044		$srcpool = escapeshellparam($srcpool);
1045		$dstpool = escapeshellparam($dstpool);
1046
1047		if ($sourcehost ne '') {
1048			# double escaping needed
1049			$srcpool = escapeshellparam($srcpool);
1050		}
1051
1052		if ($targethost ne '') {
1053			# double escaping needed
1054			$dstpool = escapeshellparam($dstpool);
1055		}
1056
1057		my $resumechkcmd = "$zpoolcmd get -o value -H feature\@extensible_dataset";
1058
1059		if ($debug) { print "DEBUG: checking availability of zfs resume feature on source...\n"; }
1060		$avail{'sourceresume'} = system("$sourcessh $resumechkcmd $srcpool 2>/dev/null | grep '\\(active\\|enabled\\)' >/dev/null 2>&1");
1061		$avail{'sourceresume'} = $avail{'sourceresume'} == 0 ? 1 : 0;
1062
1063		if ($debug) { print "DEBUG: checking availability of zfs resume feature on target...\n"; }
1064		$avail{'targetresume'} = system("$targetssh $resumechkcmd $dstpool 2>/dev/null | grep '\\(active\\|enabled\\)' >/dev/null 2>&1");
1065		$avail{'targetresume'} = $avail{'targetresume'} == 0 ? 1 : 0;
1066
1067		if ($avail{'sourceresume'} == 0 || $avail{'targetresume'} == 0) {
1068			# disable resume
1069			$resume = '';
1070
1071			my @hosts = ();
1072			if ($avail{'sourceresume'} == 0) {
1073				push @hosts, 'source';
1074			}
1075			if ($avail{'targetresume'} == 0) {
1076				push @hosts, 'target';
1077			}
1078			my $affected = join(" and ", @hosts);
1079			print "WARN: ZFS resume feature not available on $affected machine - sync will continue without resume support.\n";
1080		}
1081	} else {
1082		$avail{'sourceresume'} = 0;
1083		$avail{'targetresume'} = 0;
1084	}
1085
1086	return %avail;
1087}
1088
1089sub iszfsbusy {
1090	my ($rhost,$fs,$isroot) = @_;
1091	if ($rhost ne '') { $rhost = "$sshcmd $rhost"; }
1092	if ($debug) { print "DEBUG: checking to see if $fs on $rhost is already in zfs receive using $rhost $pscmd -Ao args= ...\n"; }
1093
1094	open PL, "$rhost $pscmd -Ao args= |";
1095	my @processes = <PL>;
1096	close PL;
1097
1098	foreach my $process (@processes) {
1099		# if ($debug) { print "DEBUG: checking process $process...\n"; }
1100		if ($process =~ /zfs *(receive|recv).*\Q$fs\E/) {
1101			# there's already a zfs receive process for our target filesystem - return true
1102			if ($debug) { print "DEBUG: process $process matches target $fs!\n"; }
1103			return 1;
1104		}
1105	}
1106
1107	# no zfs receive processes for our target filesystem found - return false
1108	return 0;
1109}
1110
1111sub setzfsvalue {
1112	my ($rhost,$fs,$isroot,$property,$value) = @_;
1113
1114	my $fsescaped = escapeshellparam($fs);
1115
1116	if ($rhost ne '') {
1117		$rhost = "$sshcmd $rhost";
1118		# double escaping needed
1119		$fsescaped = escapeshellparam($fsescaped);
1120	}
1121
1122	if ($debug) { print "DEBUG: setting $property to $value on $fs...\n"; }
1123	my $mysudocmd;
1124	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
1125	if ($debug) { print "$rhost $mysudocmd $zfscmd set $property=$value $fsescaped\n"; }
1126	system("$rhost $mysudocmd $zfscmd set $property=$value $fsescaped") == 0
1127		or warn "WARNING: $rhost $mysudocmd $zfscmd set $property=$value $fsescaped died: $?, proceeding anyway.\n";
1128	return;
1129}
1130
1131sub getzfsvalue {
1132	my ($rhost,$fs,$isroot,$property) = @_;
1133
1134	my $fsescaped = escapeshellparam($fs);
1135
1136	if ($rhost ne '') {
1137		$rhost = "$sshcmd $rhost";
1138		# double escaping needed
1139		$fsescaped = escapeshellparam($fsescaped);
1140	}
1141
1142	if ($debug) { print "DEBUG: getting current value of $property on $fs...\n"; }
1143	my $mysudocmd;
1144	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
1145	if ($debug) { print "$rhost $mysudocmd $zfscmd get -H $property $fsescaped\n"; }
1146	open FH, "$rhost $mysudocmd $zfscmd get -H $property $fsescaped |";
1147	my $value = <FH>;
1148	close FH;
1149
1150	if (!defined $value) {
1151		return undef;
1152	}
1153
1154	my @values = split(/\t/,$value);
1155	$value = $values[2];
1156	return $value;
1157}
1158
1159sub readablebytes {
1160	my $bytes = shift;
1161	my $disp;
1162
1163	if ($bytes > 1024*1024*1024) {
1164		$disp = sprintf("%.1f",$bytes/1024/1024/1024) . ' GB';
1165	} elsif ($bytes > 1024*1024) {
1166		$disp = sprintf("%.1f",$bytes/1024/1024) . ' MB';
1167	} else {
1168		$disp = sprintf("%d",$bytes/1024) . ' KB';
1169	}
1170	return $disp;
1171}
1172
1173sub getoldestsnapshot {
1174	my $snaps = shift;
1175	foreach my $snap ( sort { $snaps{'source'}{$a}{'creation'}<=>$snaps{'source'}{$b}{'creation'} } keys %{ $snaps{'source'} }) {
1176		# return on first snap found - it's the oldest
1177		return $snap;
1178	}
1179	# must not have had any snapshots on source - luckily, we already made one, amirite?
1180	if (defined ($args{'no-sync-snap'}) ) {
1181		# well, actually we set --no-sync-snap, so no we *didn't* already make one. Whoops.
1182		warn "CRIT: --no-sync-snap is set, and getoldestsnapshot() could not find any snapshots on source!\n";
1183	}
1184	return 0;
1185}
1186
1187sub getnewestsnapshot {
1188	my $snaps = shift;
1189	foreach my $snap ( sort { $snaps{'source'}{$b}{'creation'}<=>$snaps{'source'}{$a}{'creation'} } keys %{ $snaps{'source'} }) {
1190		# return on first snap found - it's the newest
1191		if (!$quiet) { print "NEWEST SNAPSHOT: $snap\n"; }
1192		return $snap;
1193	}
1194	# must not have had any snapshots on source - looks like we'd better create one!
1195	if (defined ($args{'no-sync-snap'}) ) {
1196		if (!defined ($args{'recursive'}) ) {
1197			# well, actually we set --no-sync-snap and we're not recursive, so no we *can't* make one. Whoops.
1198			die "CRIT: --no-sync-snap is set, and getnewestsnapshot() could not find any snapshots on source!\n";
1199		}
1200		# fixme: we need to output WHAT the current dataset IS if we encounter this WARN condition.
1201		#        we also probably need an argument to mute this WARN, for people who deliberately exclude
1202		#        datasets from recursive replication this way.
1203		warn "WARN: --no-sync-snap is set, and getnewestsnapshot() could not find any snapshots on source for current dataset. Continuing.\n";
1204		if ($exitcode < 2) { $exitcode = 2; }
1205	}
1206	return 0;
1207}
1208
1209sub buildsynccmd {
1210	my ($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot) = @_;
1211	# here's where it gets fun: figuring out when to compress and decompress.
1212	# to make this work for all possible combinations, you may have to decompress
1213	# AND recompress across the pipe viewer. FUN.
1214	my $synccmd;
1215
1216	if ($sourcehost eq '' && $targethost eq '') {
1217		# both sides local. don't compress. do mbuffer, once, on the source side.
1218		# $synccmd = "$sendcmd | $mbuffercmd | $pvcmd | $recvcmd";
1219		$synccmd = "$sendcmd |";
1220		# avoid confusion - accept either source-bwlimit or target-bwlimit as the bandwidth limiting option here
1221		my $bwlimit = '';
1222		if (length $args{'source-bwlimit'}) {
1223			$bwlimit = $args{'source-bwlimit'};
1224		} elsif (length $args{'target-bwlimit'}) {
1225			$bwlimit = $args{'target-bwlimit'};
1226		}
1227
1228		if ($avail{'sourcembuffer'}) { $synccmd .= " $mbuffercmd $bwlimit $mbufferoptions |"; }
1229		if ($avail{'localpv'} && !$quiet) { $synccmd .= " $pvcmd -s $pvsize |"; }
1230		$synccmd .= " $recvcmd";
1231	} elsif ($sourcehost eq '') {
1232		# local source, remote target.
1233		#$synccmd = "$sendcmd | $pvcmd | $compressargs{'cmd'} | $mbuffercmd | $sshcmd $targethost '$compressargs{'decomcmd'} | $mbuffercmd | $recvcmd'";
1234		$synccmd = "$sendcmd |";
1235		if ($avail{'localpv'} && !$quiet) { $synccmd .= " $pvcmd -s $pvsize |"; }
1236		if ($avail{'compress'}) { $synccmd .= " $compressargs{'cmd'} |"; }
1237		if ($avail{'sourcembuffer'}) { $synccmd .= " $mbuffercmd $args{'source-bwlimit'} $mbufferoptions |"; }
1238		$synccmd .= " $sshcmd $targethost ";
1239
1240		my $remotecmd = "";
1241		if ($avail{'targetmbuffer'}) { $remotecmd .= " $mbuffercmd $args{'target-bwlimit'} $mbufferoptions |"; }
1242		if ($avail{'compress'}) { $remotecmd .= " $compressargs{'decomcmd'} |"; }
1243		$remotecmd .= " $recvcmd";
1244
1245		$synccmd .= escapeshellparam($remotecmd);
1246	} elsif ($targethost eq '') {
1247		# remote source, local target.
1248		#$synccmd = "$sshcmd $sourcehost '$sendcmd | $compressargs{'cmd'} | $mbuffercmd' | $compressargs{'decomcmd'} | $mbuffercmd | $pvcmd | $recvcmd";
1249
1250		my $remotecmd = $sendcmd;
1251		if ($avail{'compress'}) { $remotecmd .= " | $compressargs{'cmd'}"; }
1252		if ($avail{'sourcembuffer'}) { $remotecmd .= " | $mbuffercmd $args{'source-bwlimit'} $mbufferoptions"; }
1253
1254		$synccmd = "$sshcmd $sourcehost " . escapeshellparam($remotecmd);
1255		$synccmd .= " | ";
1256		if ($avail{'targetmbuffer'}) { $synccmd .= "$mbuffercmd $args{'target-bwlimit'} $mbufferoptions | "; }
1257		if ($avail{'compress'}) { $synccmd .= "$compressargs{'decomcmd'} | "; }
1258		if ($avail{'localpv'} && !$quiet) { $synccmd .= "$pvcmd -s $pvsize | "; }
1259		$synccmd .= "$recvcmd";
1260	} else {
1261		#remote source, remote target... weird, but whatever, I'm not here to judge you.
1262		#$synccmd = "$sshcmd $sourcehost '$sendcmd | $compressargs{'cmd'} | $mbuffercmd' | $compressargs{'decomcmd'} | $pvcmd | $compressargs{'cmd'} | $mbuffercmd | $sshcmd $targethost '$compressargs{'decomcmd'} | $mbuffercmd | $recvcmd'";
1263
1264		my $remotecmd = $sendcmd;
1265		if ($avail{'compress'}) { $remotecmd .= " | $compressargs{'cmd'}"; }
1266		if ($avail{'sourcembuffer'}) { $remotecmd .= " | $mbuffercmd $args{'source-bwlimit'} $mbufferoptions"; }
1267
1268		$synccmd = "$sshcmd $sourcehost " . escapeshellparam($remotecmd);
1269		$synccmd .= " | ";
1270
1271		if ($avail{'compress'}) { $synccmd .= "$compressargs{'decomcmd'} | "; }
1272		if ($avail{'localpv'} && !$quiet) { $synccmd .= "$pvcmd -s $pvsize | "; }
1273		if ($avail{'compress'}) { $synccmd .= "$compressargs{'cmd'} | "; }
1274		if ($avail{'localmbuffer'}) { $synccmd .= "$mbuffercmd $mbufferoptions | "; }
1275		$synccmd .= "$sshcmd $targethost ";
1276
1277		$remotecmd = "";
1278		if ($avail{'targetmbuffer'}) { $remotecmd .= " $mbuffercmd $args{'target-bwlimit'} $mbufferoptions |"; }
1279		if ($avail{'compress'}) { $remotecmd .= " $compressargs{'decomcmd'} |"; }
1280		$remotecmd .= " $recvcmd";
1281
1282		$synccmd .= escapeshellparam($remotecmd);
1283	}
1284	return $synccmd;
1285}
1286
1287sub pruneoldsyncsnaps {
1288	my ($rhost,$fs,$newsyncsnap,$isroot,@snaps) = @_;
1289
1290	my $fsescaped = escapeshellparam($fs);
1291
1292	if ($rhost ne '') { $rhost = "$sshcmd $rhost"; }
1293
1294	my $hostid = hostname();
1295
1296	my $mysudocmd;
1297	if ($isroot) { $mysudocmd=''; } else { $mysudocmd = $sudocmd; }
1298
1299	my @prunesnaps;
1300
1301	# only prune snaps beginning with syncoid and our own hostname
1302	foreach my $snap(@snaps) {
1303		if ($snap =~ /^syncoid_\Q$identifier$hostid\E/) {
1304			# no matter what, we categorically refuse to
1305			# prune the new sync snap we created for this run
1306			if ($snap ne $newsyncsnap) {
1307				push (@prunesnaps,$snap);
1308			}
1309		}
1310	}
1311
1312	# concatenate pruning commands to ten per line, to cut down
1313	# auth times for any remote hosts that must be operated via SSH
1314	my $counter;
1315	my $maxsnapspercmd = 10;
1316	my $prunecmd;
1317	foreach my $snap(@prunesnaps) {
1318		$counter ++;
1319		$prunecmd .= "$mysudocmd $zfscmd destroy $fsescaped\@$snap; ";
1320		if ($counter > $maxsnapspercmd) {
1321			$prunecmd =~ s/\; $//;
1322			if ($debug) { print "DEBUG: pruning up to $maxsnapspercmd obsolete sync snapshots...\n"; }
1323			if ($debug) { print "DEBUG: $rhost $prunecmd\n"; }
1324			if ($rhost ne '') {
1325				$prunecmd = escapeshellparam($prunecmd);
1326			}
1327			system("$rhost $prunecmd") == 0
1328				or warn "WARNING: $rhost $prunecmd failed: $?";
1329			$prunecmd = '';
1330			$counter = 0;
1331		}
1332	}
1333	# if we still have some prune commands stacked up after finishing
1334	# the loop, commit 'em now
1335	if ($counter) {
1336		$prunecmd =~ s/\; $//;
1337		if ($debug) { print "DEBUG: pruning up to $maxsnapspercmd obsolete sync snapshots...\n"; }
1338		if ($debug) { print "DEBUG: $rhost $prunecmd\n"; }
1339		if ($rhost ne '') {
1340			$prunecmd = escapeshellparam($prunecmd);
1341		}
1342		system("$rhost $prunecmd") == 0
1343			or warn "WARNING: $rhost $prunecmd failed: $?";
1344	}
1345	return;
1346}
1347
1348sub getmatchingsnapshot {
1349	my ($sourcefs, $targetfs, $snaps) = @_;
1350	foreach my $snap ( sort { $snaps{'source'}{$b}{'creation'}<=>$snaps{'source'}{$a}{'creation'} } keys %{ $snaps{'source'} }) {
1351		if (defined $snaps{'target'}{$snap}) {
1352			if ($snaps{'source'}{$snap}{'guid'} == $snaps{'target'}{$snap}{'guid'}) {
1353				return $snap;
1354			}
1355		}
1356	}
1357
1358	return 0;
1359}
1360
1361sub newsyncsnap {
1362	my ($rhost,$fs,$isroot) = @_;
1363	my $fsescaped = escapeshellparam($fs);
1364	if ($rhost ne '') {
1365		$rhost = "$sshcmd $rhost";
1366		# double escaping needed
1367		$fsescaped = escapeshellparam($fsescaped);
1368	}
1369	my $mysudocmd;
1370	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
1371	my $hostid = hostname();
1372	my %date = getdate();
1373	my $snapname = "syncoid\_$identifier$hostid\_$date{'stamp'}";
1374	my $snapcmd = "$rhost $mysudocmd $zfscmd snapshot $fsescaped\@$snapname\n";
1375	if ($debug) { print "DEBUG: creating sync snapshot using \"$snapcmd\"...\n"; }
1376	system($snapcmd) == 0 or do {
1377		warn "CRITICAL ERROR: $snapcmd failed: $?";
1378		if ($exitcode < 2) { $exitcode = 2; }
1379		return 0;
1380	};
1381
1382	return $snapname;
1383}
1384
1385sub targetexists {
1386	my ($rhost,$fs,$isroot) = @_;
1387	my $fsescaped = escapeshellparam($fs);
1388	if ($rhost ne '') {
1389		$rhost = "$sshcmd $rhost";
1390		# double escaping needed
1391		$fsescaped = escapeshellparam($fsescaped);
1392	}
1393	my $mysudocmd;
1394	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
1395	my $checktargetcmd = "$rhost $mysudocmd $zfscmd get -H name $fsescaped";
1396	if ($debug) { print "DEBUG: checking to see if target filesystem exists using \"$checktargetcmd 2>&1 |\"...\n"; }
1397	open FH, "$checktargetcmd 2>&1 |";
1398	my $targetexists = <FH>;
1399	close FH;
1400	my $exit = $?;
1401	$targetexists = ( $targetexists =~ /^\Q$fs\E/ && $exit == 0 );
1402	return $targetexists;
1403}
1404
1405sub getssh {
1406	my $fs = shift;
1407
1408	my $rhost;
1409	my $isroot;
1410	my $socket;
1411
1412	# if we got passed something with an @ in it, we assume it's an ssh connection, eg root@myotherbox
1413	if ($fs =~ /\@/) {
1414		$rhost = $fs;
1415		$fs =~ s/^\S*\@\S*://;
1416		$rhost =~ s/:\Q$fs\E$//;
1417		my $remoteuser = $rhost;
1418		 $remoteuser =~ s/\@.*$//;
1419		if ($remoteuser eq 'root' || $args{'no-privilege-elevation'}) { $isroot = 1; } else { $isroot = 0; }
1420		# now we need to establish a persistent master SSH connection
1421		$socket = "/tmp/syncoid-$remoteuser-$rhost-" . time();
1422		open FH, "$sshcmd -M -S $socket -o ControlPersist=1m $args{'sshport'} $rhost exit |";
1423		close FH;
1424
1425		system("$sshcmd -S $socket $rhost echo -n") == 0 or do {
1426			my $code = $? >> 8;
1427			warn "CRITICAL ERROR: ssh connection echo test failed for $rhost with exit code $code";
1428			exit(2);
1429		};
1430
1431		$rhost = "-S $socket $rhost";
1432	} else {
1433		my $localuid = $<;
1434		if ($localuid == 0 || $args{'no-privilege-elevation'}) { $isroot = 1; } else { $isroot = 0; }
1435	}
1436	# if ($isroot) { print "this user is root.\n"; } else { print "this user is not root.\n"; }
1437	return ($rhost,$fs,$isroot);
1438}
1439
1440sub dumphash() {
1441	my $hash = shift;
1442	$Data::Dumper::Sortkeys = 1;
1443	print Dumper($hash);
1444}
1445
1446sub getsnaps() {
1447	my ($type,$rhost,$fs,$isroot,%snaps) = @_;
1448	my $mysudocmd;
1449	my $fsescaped = escapeshellparam($fs);
1450	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
1451
1452	if ($rhost ne '') {
1453		$rhost = "$sshcmd $rhost";
1454		# double escaping needed
1455		$fsescaped = escapeshellparam($fsescaped);
1456	}
1457
1458	my $getsnapcmd = "$rhost $mysudocmd $zfscmd get -Hpd 1 -t snapshot guid,creation $fsescaped |";
1459	if ($debug) { print "DEBUG: getting list of snapshots on $fs using $getsnapcmd...\n"; }
1460	open FH, $getsnapcmd;
1461	my @rawsnaps = <FH>;
1462	close FH or die "CRITICAL ERROR: snapshots couldn't be listed for $fs (exit code $?)";
1463
1464	# this is a little obnoxious. get guid,creation returns guid,creation on two separate lines
1465	# as though each were an entirely separate get command.
1466
1467	my %creationtimes=();
1468
1469	foreach my $line (@rawsnaps) {
1470		# only import snap guids from the specified filesystem
1471		if ($line =~ /\Q$fs\E\@.*guid/) {
1472			chomp $line;
1473			my $guid = $line;
1474			$guid =~ s/^.*\tguid\t*(\d*).*/$1/;
1475			my $snap = $line;
1476			$snap =~ s/^.*\@(.*)\tguid.*$/$1/;
1477			$snaps{$type}{$snap}{'guid'}=$guid;
1478		}
1479	}
1480
1481	foreach my $line (@rawsnaps) {
1482		# only import snap creations from the specified filesystem
1483		if ($line =~ /\Q$fs\E\@.*creation/) {
1484			chomp $line;
1485			my $creation = $line;
1486			$creation =~ s/^.*\tcreation\t*(\d*).*/$1/;
1487			my $snap = $line;
1488			$snap =~ s/^.*\@(.*)\tcreation.*$/$1/;
1489
1490			# the accuracy of the creation timestamp is only for a second, but
1491			# snapshots in the same second are highly likely. The list command
1492			# has an ordered output so we append another three digit running number
1493			# to the creation timestamp and make sure those are ordered correctly
1494			# for snapshot with the same creation timestamp
1495			my $counter = 0;
1496			my $creationsuffix;
1497			while ($counter < 999) {
1498				$creationsuffix = sprintf("%s%03d", $creation, $counter);
1499				if (!defined $creationtimes{$creationsuffix}) {
1500					$creationtimes{$creationsuffix} = 1;
1501					last;
1502				}
1503				$counter += 1;
1504			}
1505
1506			$snaps{$type}{$snap}{'creation'}=$creationsuffix;
1507		}
1508	}
1509
1510	return %snaps;
1511}
1512
1513sub getbookmarks() {
1514	my ($rhost,$fs,$isroot,%bookmarks) = @_;
1515	my $mysudocmd;
1516	my $fsescaped = escapeshellparam($fs);
1517	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
1518
1519	if ($rhost ne '') {
1520		$rhost = "$sshcmd $rhost";
1521		# double escaping needed
1522		$fsescaped = escapeshellparam($fsescaped);
1523	}
1524
1525	my $error = 0;
1526	my $getbookmarkcmd = "$rhost $mysudocmd $zfscmd get -Hpd 1 -t bookmark guid,creation $fsescaped 2>&1 |";
1527	if ($debug) { print "DEBUG: getting list of bookmarks on $fs using $getbookmarkcmd...\n"; }
1528	open FH, $getbookmarkcmd;
1529	my @rawbookmarks = <FH>;
1530	close FH or $error = 1;
1531
1532	if ($error == 1) {
1533		if ($rawbookmarks[0] =~ /invalid type/ or $rawbookmarks[0] =~ /operation not applicable to datasets of this type/) {
1534			# no support for zfs bookmarks, return empty hash
1535			return %bookmarks;
1536		}
1537
1538		die "CRITICAL ERROR: bookmarks couldn't be listed for $fs (exit code $?)";
1539	}
1540
1541	# this is a little obnoxious. get guid,creation returns guid,creation on two separate lines
1542	# as though each were an entirely separate get command.
1543
1544	my $lastguid;
1545
1546	foreach my $line (@rawbookmarks) {
1547		# only import bookmark guids, creation from the specified filesystem
1548		if ($line =~ /\Q$fs\E\#.*guid/) {
1549			chomp $line;
1550			$lastguid = $line;
1551			$lastguid =~ s/^.*\tguid\t*(\d*).*/$1/;
1552			my $bookmark = $line;
1553			$bookmark =~ s/^.*\#(.*)\tguid.*$/$1/;
1554			$bookmarks{$lastguid}{'name'}=$bookmark;
1555		} elsif ($line =~ /\Q$fs\E\#.*creation/) {
1556			chomp $line;
1557			my $creation = $line;
1558			$creation =~ s/^.*\tcreation\t*(\d*).*/$1/;
1559			my $bookmark = $line;
1560			$bookmark =~ s/^.*\#(.*)\tcreation.*$/$1/;
1561			$bookmarks{$lastguid}{'creation'}=$creation;
1562		}
1563	}
1564
1565	return %bookmarks;
1566}
1567
1568sub getsendsize {
1569	my ($sourcehost,$snap1,$snap2,$isroot,$receivetoken) = @_;
1570
1571	my $snap1escaped = escapeshellparam($snap1);
1572	my $snap2escaped = escapeshellparam($snap2);
1573
1574	my $mysudocmd;
1575	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
1576
1577	my $sourcessh;
1578	if ($sourcehost ne '') {
1579		$sourcessh = "$sshcmd $sourcehost";
1580		$snap1escaped = escapeshellparam($snap1escaped);
1581		$snap2escaped = escapeshellparam($snap2escaped);
1582	} else {
1583		$sourcessh = '';
1584	}
1585
1586	my $snaps;
1587	if ($snap2) {
1588		# if we got a $snap2 argument, we want an incremental send estimate from $snap1 to $snap2.
1589		$snaps = "$args{'streamarg'} $snap1escaped $snap2escaped";
1590	} else {
1591		# if we didn't get a $snap2 arg, we want a full send estimate for $snap1.
1592		$snaps = "$snap1escaped";
1593	}
1594
1595	# in case of a resumed receive, get the remaining
1596	# size based on the resume token
1597	if (defined($receivetoken)) {
1598		$snaps = "-t $receivetoken";
1599	}
1600
1601	my $sendoptions;
1602	if (defined($receivetoken)) {
1603		$sendoptions = getoptionsline(\@sendoptions, ('e'));
1604	} else {
1605		$sendoptions = getoptionsline(\@sendoptions, ('D','L','R','c','e','h','p','v','w'));
1606	}
1607	my $getsendsizecmd = "$sourcessh $mysudocmd $zfscmd send $sendoptions -nP $snaps";
1608	if ($debug) { print "DEBUG: getting estimated transfer size from source $sourcehost using \"$getsendsizecmd 2>&1 |\"...\n"; }
1609
1610	open FH, "$getsendsizecmd 2>&1 |";
1611	my @rawsize = <FH>;
1612	close FH;
1613	my $exit = $?;
1614
1615	# process sendsize: last line of multi-line output is
1616	# size of proposed xfer in bytes, but we need to remove
1617	# human-readable crap from it
1618	my $sendsize = pop(@rawsize);
1619	# the output format is different in case of
1620	# a resumed receive
1621	if (defined($receivetoken)) {
1622		$sendsize =~ s/.*\t([0-9]+)$/$1/;
1623	} else {
1624		$sendsize =~ s/^size\t*//;
1625	}
1626	chomp $sendsize;
1627
1628	# check for valid value
1629	if ($sendsize !~ /^\d+$/) {
1630		$sendsize = '';
1631	}
1632
1633	# to avoid confusion with a zero size pv, give sendsize
1634	# a minimum 4K value - or if empty, make sure it reads UNKNOWN
1635	if ($debug) { print "DEBUG: sendsize = $sendsize\n"; }
1636	if ($sendsize eq '' || $exit != 0) {
1637		$sendsize = '0';
1638	} elsif ($sendsize < 4096) {
1639		$sendsize = 4096;
1640	}
1641	return $sendsize;
1642}
1643
1644sub getdate {
1645	my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(time);
1646	$year += 1900;
1647	my %date;
1648	$date{'unix'} = (((((((($year - 1971) * 365) + $yday) * 24) + $hour) * 60) + $min) * 60) + $sec;
1649	$date{'year'} = $year;
1650	$date{'sec'} = sprintf ("%02u", $sec);
1651	$date{'min'} = sprintf ("%02u", $min);
1652	$date{'hour'} = sprintf ("%02u", $hour);
1653	$date{'mday'} = sprintf ("%02u", $mday);
1654	$date{'mon'} = sprintf ("%02u", ($mon + 1));
1655	$date{'stamp'} = "$date{'year'}-$date{'mon'}-$date{'mday'}:$date{'hour'}:$date{'min'}:$date{'sec'}";
1656	return %date;
1657}
1658
1659sub escapeshellparam {
1660	my ($par) = @_;
1661	# avoid use of uninitialized string in regex
1662	if (length($par)) {
1663		# "escape" all single quotes
1664		$par =~ s/'/'"'"'/g;
1665	} else {
1666		# avoid use of uninitialized string in concatenation below
1667		$par = '';
1668	}
1669	# single-quote entire string
1670	return "'$par'";
1671}
1672
1673sub getreceivetoken() {
1674	my ($rhost,$fs,$isroot) = @_;
1675	my $token = getzfsvalue($rhost,$fs,$isroot,"receive_resume_token");
1676
1677	if (defined $token && $token ne '-' && $token ne '') {
1678		return $token;
1679	}
1680
1681	if ($debug) {
1682        print "DEBUG: no receive token found \n";
1683    }
1684
1685	return
1686}
1687
1688sub parsespecialoptions {
1689	my ($line) = @_;
1690
1691	my @options = ();
1692
1693	my @values = split(/ /, $line);
1694
1695	my $optionValue = 0;
1696	my $lastOption;
1697
1698	foreach my $value (@values) {
1699		if ($optionValue ne 0) {
1700			my %item = (
1701				"option"  => $lastOption,
1702				"line" => "-$lastOption $value",
1703			);
1704
1705			push @options, \%item;
1706			$optionValue = 0;
1707			next;
1708		}
1709
1710		for my $char (split //, $value) {
1711			if ($optionValue ne 0) {
1712				return undef;
1713			}
1714
1715			if ($char eq 'o' || $char eq 'x') {
1716				$lastOption = $char;
1717				$optionValue = 1;
1718			} else {
1719				my %item = (
1720					"option"  => $char,
1721					"line" => "-$char",
1722				);
1723
1724				push @options, \%item;
1725			}
1726		}
1727	}
1728
1729	return @options;
1730}
1731
1732sub getoptionsline {
1733	my ($options_ref, @allowed) = @_;
1734
1735	my $line = '';
1736
1737	foreach my $value (@{ $options_ref }) {
1738		if (@allowed) {
1739			if (!grep( /^$$value{'option'}$/, @allowed) ) {
1740				next;
1741			}
1742		}
1743
1744		$line = "$line$$value{'line'} ";
1745	}
1746
1747	return $line;
1748}
1749
1750sub resetreceivestate {
1751	my ($rhost,$fs,$isroot) = @_;
1752
1753	my $fsescaped = escapeshellparam($fs);
1754
1755	if ($rhost ne '') {
1756		$rhost = "$sshcmd $rhost";
1757		# double escaping needed
1758		$fsescaped = escapeshellparam($fsescaped);
1759	}
1760
1761	if ($debug) { print "DEBUG: reset partial receive state of $fs...\n"; }
1762	my $mysudocmd;
1763	if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; }
1764	my $resetcmd = "$rhost $mysudocmd $zfscmd receive -A $fsescaped";
1765	if ($debug) { print "$resetcmd\n"; }
1766	system("$resetcmd") == 0
1767		or die "CRITICAL ERROR: $resetcmd failed: $?";
1768}
1769
1770__END__
1771
1772=head1 NAME
1773
1774syncoid - ZFS snapshot replication tool
1775
1776=head1 SYNOPSIS
1777
1778 syncoid [options]... SOURCE TARGET
1779 or   syncoid [options]... SOURCE USER@HOST:TARGET
1780 or   syncoid [options]... USER@HOST:SOURCE TARGET
1781 or   syncoid [options]... USER@HOST:SOURCE USER@HOST:TARGET
1782
1783 SOURCE                Source ZFS dataset. Can be either local or remote
1784 TARGET                Target ZFS dataset. Can be either local or remote
1785
1786Options:
1787
1788  --compress=FORMAT     Compresses data during transfer. Currently accepted options are gzip, pigz-fast, pigz-slow, zstd-fast, zstd-slow, lz4, xz, lzo (default) & none
1789  --identifier=EXTRA    Extra identifier which is included in the snapshot name. Can be used for replicating to multiple targets.
1790  --recursive|r         Also transfers child datasets
1791  --skip-parent         Skips syncing of the parent dataset. Does nothing without '--recursive' option.
1792  --source-bwlimit=<limit k|m|g|t>  Bandwidth limit in bytes/kbytes/etc per second on the source transfer
1793  --target-bwlimit=<limit k|m|g|t>  Bandwidth limit in bytes/kbytes/etc per second on the target transfer
1794  --mbuffer-size=VALUE  Specify the mbuffer size (default: 16M), please refer to mbuffer(1) manual page.
1795  --no-stream           Replicates using newest snapshot instead of intermediates
1796  --no-sync-snap        Does not create new snapshot, only transfers existing
1797  --create-bookmark     Creates a zfs bookmark for the newest snapshot on the source after replication succeeds (only works with --no-sync-snap)
1798  --no-clone-rollback   Does not rollback clones on target
1799  --no-rollback         Does not rollback clones or snapshots on target (it probably requires a readonly target)
1800  --exclude=REGEX       Exclude specific datasets which match the given regular expression. Can be specified multiple times
1801  --sendoptions=OPTIONS Use advanced options for zfs send (the arguments are filterd as needed), e.g. syncoid --sendoptions="Lc e" sets zfs send -L -c -e ...
1802  --recvoptions=OPTIONS Use advanced options for zfs receive (the arguments are filterd as needed), e.g. syncoid --recvoptions="ux recordsize o compression=lz4" sets zfs receive -u -x recordsize -o compression=lz4 ...
1803  --sshkey=FILE         Specifies a ssh key to use to connect
1804  --sshport=PORT        Connects to remote on a particular port
1805  --sshcipher|c=CIPHER  Passes CIPHER to ssh to use a particular cipher set
1806  --sshoption|o=OPTION  Passes OPTION to ssh for remote usage. Can be specified multiple times
1807
1808  --help                Prints this helptext
1809  --version             Prints the version number
1810  --debug               Prints out a lot of additional information during a syncoid run
1811  --monitor-version     Currently does nothing
1812  --quiet               Suppresses non-error output
1813  --dumpsnaps           Dumps a list of snapshots during the run
1814  --no-command-checks   Do not check command existence before attempting transfer. Not recommended
1815  --no-resume           Don't use the ZFS resume feature if available
1816  --no-clone-handling   Don't try to recreate clones on target
1817  --no-privilege-elevation  Bypass the root check, for use with ZFS permission delegation
1818
1819  --force-delete        Remove target datasets recursively, if there are no matching snapshots/bookmarks
1820