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