#!/usr/bin/perl # this software is licensed for use under the Free Software Foundation's GPL v3.0 license, as retrieved # from http://www.gnu.org/licenses/gpl-3.0.html on 2014-11-17. A copy should also be available in this # project's Git repository at https://github.com/jimsalterjrs/sanoid/blob/master/LICENSE. my $version = '1.4.7'; use strict; use warnings; use Data::Dumper; use Time::Local; use Sys::Hostname; my %args = getargs(@ARGV); if ($args{'version'}) { print "Syncoid version: $version\n"; exit 0; } my $rawsourcefs = $args{'source'}; my $rawtargetfs = $args{'target'}; my $debug = $args{'debug'}; my $quiet = $args{'quiet'}; my $zfscmd = '/sbin/zfs'; my $sshcmd = '/usr/bin/ssh'; my $pscmd = '/bin/ps'; my $sshcipher = '-c chacha20-poly1305@openssh.com,arcfour'; my $sshport = '-p 22'; my $pvcmd = '/usr/bin/pv'; my $mbuffercmd = '/usr/bin/mbuffer'; my $sudocmd = '/usr/bin/sudo'; my $mbufferoptions = '-q -s 128k -m 16M 2>/dev/null'; # currently using ls to check for file existence because we aren't depending on perl # being present on remote machines. my $lscmd = '/bin/ls'; if ( $args{'sshport'} ) { $sshport = "-p $args{'sshport'}"; } # figure out if source and/or target are remote. if ( $args{'sshkey'} ) { $sshcmd = "$sshcmd $sshcipher $sshport -i $args{'sshkey'}"; } else { $sshcmd = "$sshcmd $sshcipher $sshport"; } my ($sourcehost,$sourcefs,$sourceisroot) = getssh($rawsourcefs); my ($targethost,$targetfs,$targetisroot) = getssh($rawtargetfs); my $sourcesudocmd; my $targetsudocmd; if ($sourceisroot) { $sourcesudocmd = ''; } else { $sourcesudocmd = $sudocmd; } if ($targetisroot) { $targetsudocmd = ''; } else { $targetsudocmd = $sudocmd; } # figure out whether compression, mbuffering, pv # are available on source, target, local machines. # warn user of anything missing, then continue with sync. my %avail = checkcommands(); my %snaps; ## break here to call replication individually so that we ## ## can loop across children separately, for recursive ## ## replication ## if (! $args{'recursive'}) { syncdataset($sourcehost, $sourcefs, $targethost, $targetfs); } else { if ($debug) { print "DEBUG: recursive sync of $sourcefs.\n"; } my @datasets = getchilddatasets($sourcehost, $sourcefs, $sourceisroot); foreach my $dataset(@datasets) { $dataset =~ s/$sourcefs//; chomp $dataset; my $childsourcefs = $sourcefs . $dataset; my $childtargetfs = $targetfs . $dataset; # print "syncdataset($sourcehost, $childsourcefs, $targethost, $childtargetfs); \n"; syncdataset($sourcehost, $childsourcefs, $targethost, $childtargetfs); } } # close SSH sockets for master connections as applicable if ($sourcehost ne '') { open FH, "$sshcmd $sourcehost -O exit 2>&1 |"; close FH; } if ($targethost ne '') { open FH, "$sshcmd $targethost -O exit 2>&1 |"; close FH; } exit 0; ############################################################################## ############################################################################## ############################################################################## ############################################################################## sub getchilddatasets { my ($rhost,$fs,$isroot,%snaps) = @_; my $mysudocmd; if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } if ($rhost ne '') { $rhost = "$sshcmd $rhost"; } my $getchildrencmd = "$rhost $mysudocmd $zfscmd list -o name -Hr $fs |"; if ($debug) { print "DEBUG: getting list of child datasets on $fs using $getchildrencmd...\n"; } open FH, $getchildrencmd; my @children = ; close FH; return @children; } sub syncdataset { my ($sourcehost, $sourcefs, $targethost, $targetfs) = @_; if ($debug) { print "DEBUG: syncing source $sourcefs to target $targetfs.\n"; } # make sure target is not currently in receive. if (iszfsbusy($targethost,$targetfs,$targetisroot)) { die "Cannot sync now: $targetfs is already target of a zfs receive process.\n"; } # does the target filesystem exist yet? my $targetexists = targetexists($targethost,$targetfs,$targetisroot); # build hashes of the snaps on the source and target filesystems. %snaps = getsnaps('source',$sourcehost,$sourcefs); if ($targetexists) { my %targetsnaps = getsnaps('target',$targethost,$targetfs); my %sourcesnaps = %snaps; %snaps = (%sourcesnaps, %targetsnaps); } if ($args{'dumpsnaps'}) { print "merged snapshot list: \n"; dumphash(\%snaps); print "\n\n\n"; } # create a new syncoid snapshot on the source filesystem. my $newsyncsnap = newsyncsnap($sourcehost,$sourcefs); # there is currently (2014-09-01) a bug in ZFS on Linux # that causes readonly to always show on if it's EVER # been turned on... even when it's off... unless and # until the filesystem is zfs umounted and zfs remounted. # we're going to do the right thing anyway. # dyking this functionality out for the time being due to buggy mount/unmount behavior # with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly. #my $originaltargetreadonly; # sync 'em up. if (! $targetexists) { # do an initial sync from the oldest source snapshot # THEN do an -I to the newest if ($debug) { print "DEBUG: target $targetfs does not exist. Finding oldest available snapshot on source $sourcefs ...\n"; } my $oldestsnap = getoldestsnapshot(\%snaps); if (! $oldestsnap) { # getoldestsnapshot() returned false, so use new sync snapshot if ($debug) { print "DEBUG: getoldestsnapshot() returned false, so using $newsyncsnap.\n"; } $oldestsnap = $newsyncsnap; } my $sendcmd = "$sourcesudocmd $zfscmd send $sourcefs\@$oldestsnap"; my $recvcmd = "$targetsudocmd $zfscmd receive -F $targetfs"; my $pvsize = getsendsize($sourcehost,"$sourcefs\@$oldestsnap",0,$sourceisroot); my $disp_pvsize = readablebytes($pvsize); if ($pvsize == 0) { $disp_pvsize = 'UNKNOWN'; } my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot); if (!$quiet) { print "INFO: Sending oldest full snapshot $sourcefs\@$oldestsnap (~ $disp_pvsize) to new target filesystem:\n"; } if ($debug) { print "DEBUG: $synccmd\n"; } # make sure target is (still) not currently in receive. if (iszfsbusy($targethost,$targetfs,$targetisroot)) { die "Cannot sync now: $targetfs is already target of a zfs receive process.\n"; } system($synccmd) == 0 or die "CRITICAL ERROR: $synccmd failed: $?"; # now do an -I to the new sync snapshot, assuming there were any snapshots # other than the new sync snapshot to begin with, of course if ($oldestsnap ne $newsyncsnap) { # get current readonly status of target, then set it to on during sync # dyking this functionality out for the time being due to buggy mount/unmount behavior # with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly. # $originaltargetreadonly = getzfsvalue($targethost,$targetfs,$targetisroot,'readonly'); # setzfsvalue($targethost,$targetfs,$targetisroot,'readonly','on'); $sendcmd = "$sourcesudocmd $zfscmd send -I $sourcefs\@$oldestsnap $sourcefs\@$newsyncsnap"; $pvsize = getsendsize($sourcehost,"$sourcefs\@$oldestsnap","$sourcefs\@$newsyncsnap",$sourceisroot); $disp_pvsize = readablebytes($pvsize); if ($pvsize == 0) { $disp_pvsize = "UNKNOWN"; } $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot); # make sure target is (still) not currently in receive. if (iszfsbusy($targethost,$targetfs,$targetisroot)) { die "Cannot sync now: $targetfs is already target of a zfs receive process.\n"; } if (!$quiet) { print "INFO: Updating new target filesystem with incremental $sourcefs\@$oldestsnap ... $newsyncsnap (~ $disp_pvsize):\n"; } if ($debug) { print "DEBUG: $synccmd\n"; } system($synccmd) == 0 or die "CRITICAL ERROR: $synccmd failed: $?"; # restore original readonly value to target after sync complete # dyking this functionality out for the time being due to buggy mount/unmount behavior # with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly. # setzfsvalue($targethost,$targetfs,$targetisroot,'readonly',$originaltargetreadonly); } } else { # find most recent matching snapshot and do an -I # to the new snapshot # get current readonly status of target, then set it to on during sync # dyking this functionality out for the time being due to buggy mount/unmount behavior # with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly. # $originaltargetreadonly = getzfsvalue($targethost,$targetfs,$targetisroot,'readonly'); # setzfsvalue($targethost,$targetfs,$targetisroot,'readonly','on'); my $targetsize = getzfsvalue($targethost,$targetfs,$targetisroot,'-p used'); my $matchingsnap = getmatchingsnapshot($targetsize, \%snaps); # make sure target is (still) not currently in receive. if (iszfsbusy($targethost,$targetfs,$targetisroot)) { die "Cannot sync now: $targetfs is already target of a zfs receive process.\n"; } # rollback target to matchingsnap if ($debug) { print "DEBUG: rolling back target to $targetfs\@$matchingsnap...\n"; } if ($targethost ne '') { if ($debug) { print "$sshcmd $targethost $targetsudocmd $zfscmd rollback -R $targetfs\@$matchingsnap\n"; } system ("$sshcmd $targethost $targetsudocmd $zfscmd rollback -R $targetfs\@$matchingsnap"); } else { if ($debug) { print "$targetsudocmd $zfscmd rollback -R $targetfs\@$matchingsnap\n"; } system ("$targetsudocmd $zfscmd rollback -R $targetfs\@$matchingsnap"); } my $sendcmd = "$sourcesudocmd $zfscmd send -I $sourcefs\@$matchingsnap $sourcefs\@$newsyncsnap"; my $recvcmd = "$targetsudocmd $zfscmd receive -F $targetfs"; my $pvsize = getsendsize($sourcehost,"$sourcefs\@$matchingsnap","$sourcefs\@$newsyncsnap",$sourceisroot); my $disp_pvsize = readablebytes($pvsize); if ($pvsize == 0) { $disp_pvsize = "UNKNOWN"; } my $synccmd = buildsynccmd($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot); if (!$quiet) { print "Sending incremental $sourcefs\@$matchingsnap ... $newsyncsnap (~ $disp_pvsize):\n"; } if ($debug) { print "DEBUG: $synccmd\n"; } system("$synccmd") == 0 or die "CRITICAL ERROR: $synccmd failed: $?"; # restore original readonly value to target after sync complete # dyking this functionality out for the time being due to buggy mount/unmount behavior # with ZFS on Linux (possibly OpenZFS in general) when setting/unsetting readonly. #setzfsvalue($targethost,$targetfs,$targetisroot,'readonly',$originaltargetreadonly); } # prune obsolete sync snaps on source and target. pruneoldsyncsnaps($sourcehost,$sourcefs,$newsyncsnap,$sourceisroot,keys %{ $snaps{'source'}}); pruneoldsyncsnaps($targethost,$targetfs,$newsyncsnap,$targetisroot,keys %{ $snaps{'target'}}); } # end syncdataset() sub getargs { my @args = @_; my %args; my %novaluearg; my %validarg; push my @validargs, ('debug','nocommandchecks','version','monitor-version','compress','source-bwlimit','target-bwlimit','dumpsnaps','recursive','r','sshkey','sshport','quiet'); foreach my $item (@validargs) { $validarg{$item} = 1; } push my @novalueargs, ('debug','nocommandchecks','version','monitor-version','dumpsnaps','recursive','r','quiet'); foreach my $item (@novalueargs) { $novaluearg{$item} = 1; } while (my $rawarg = shift(@args)) { my $arg = $rawarg; my $argvalue = ''; if ($rawarg =~ /=/) { # user specified the value for a CLI argument with = # instead of with blank space. separate appropriately. $argvalue = $arg; $arg =~ s/=.*$//; $argvalue =~ s/^.*=//; } if ($rawarg =~ /^--/) { # doubledash arg $arg =~ s/^--//; if (! $validarg{$arg}) { die "ERROR: don't understand argument $rawarg.\n"; } if ($novaluearg{$arg}) { $args{$arg} = 1; } else { # if this CLI arg takes a user-specified value and # we don't already have it, then the user must have # specified with a space, so pull in the next value # from the array as this value rather than as the # next argument. if ($argvalue eq '') { $argvalue = shift(@args); } $args{$arg} = $argvalue; } } elsif ($arg =~ /^-/) { # singledash arg $arg =~ s/^-//; if (! $validarg{$arg}) { die "ERROR: don't understand argument $rawarg.\n"; } if ($novaluearg{$arg}) { $args{$arg} = 1; } else { # if this CLI arg takes a user-specified value and # we don't already have it, then the user must have # specified with a space, so pull in the next value # from the array as this value rather than as the # next argument. if ($argvalue eq '') { $argvalue = shift(@args); } $args{$arg} = $argvalue; } } else { # bare arg if (defined $args{'source'}) { if (! defined $args{'target'}) { $args{'target'} = $arg; } else { die "ERROR: don't know what to do with third bare argument $rawarg.\n"; } } else { $args{'source'} = $arg; } } } if (defined $args{'source-bwlimit'}) { $args{'source-bwlimit'} = "-R $args{'source-bwlimit'}"; } else { $args{'source-bwlimit'} = ''; } if (defined $args{'target-bwlimit'}) { $args{'target-bwlimit'} = "-r $args{'target-bwlimit'}"; } else { $args{'target-bwlimit'} = ''; } if ($args{'r'}) { $args{'recursive'} = $args{'r'}; } if (!defined $args{'compress'}) { $args{'compress'} = 'default'; } if ($args{'compress'} eq 'gzip') { $args{'rawcompresscmd'} = '/bin/gzip'; $args{'compressargs'} = '-3'; $args{'rawdecompresscmd'} = '/bin/zcat'; $args{'decompressargs'} = ''; } elsif ( ($args{'compress'} eq 'lzo') || ($args{'compress'} eq 'default') ) { $args{'rawcompresscmd'} = '/usr/bin/lzop'; $args{'compressargs'} = ''; $args{'rawdecompresscmd'} = '/usr/bin/lzop'; $args{'decompressargs'} = '-dfc'; } else { $args{'rawcompresscmd'} = ''; $args{'compressargs'} = ''; $args{'rawdecompresscmd'} = ''; $args{'decompressargs'} = ''; } $args{'compresscmd'} = "$args{'rawcompresscmd'} $args{'compressargs'}"; $args{'decompresscmd'} = "$args{'rawdecompresscmd'} $args{'decompressargs'}"; return %args; } sub checkcommands { # make sure compression, mbuffer, and pv are available on # source, target, and local hosts as appropriate. my %avail; my $sourcessh; my $targetssh; # if --nocommandchecks then assume everything's available and return if ($args{'nocommandchecks'}) { if ($debug) { print "DEBUG: not checking for command availability due to --nocommandchecks switch.\n"; } $avail{'compress'} = 1; $avail{'localpv'} = 1; $avail{'localmbuffer'} = 1; $avail{'sourcembuffer'} = 1; $avail{'targetmbuffer'} = 1; return %avail; } if (!defined $sourcehost) { $sourcehost = ''; } if (!defined $targethost) { $targethost = ''; } if ($sourcehost ne '') { $sourcessh = "$sshcmd $sourcehost"; } else { $sourcessh = ''; } if ($targethost ne '') { $targetssh = "$sshcmd $targethost"; } else { $targetssh = ''; } # if raw compress command is null, we must have specified no compression. otherwise, # make sure that compression is available everywhere we need it if ($args{'rawcompresscmd'} eq '') { $avail{'sourcecompress'} = 0; $avail{'sourcecompress'} = 0; $avail{'localcompress'} = 0; if ($args{'compress'} eq 'none' || $args{'compress'} eq 'no' || $args{'compress'} eq '0') { if ($debug) { print "DEBUG: compression forced off from command line arguments.\n"; } } else { print "WARN: value $args{'compress'} for argument --compress not understood, proceeding without compression.\n"; } } else { if ($debug) { print "DEBUG: checking availability of $args{'rawcompresscmd'} on source...\n"; } $avail{'sourcecompress'} = `$sourcessh $lscmd $args{'rawcompresscmd'} 2>/dev/null`; if ($debug) { print "DEBUG: checking availability of $args{'rawcompresscmd'} on target...\n"; } $avail{'targetcompress'} = `$targetssh $lscmd $args{'rawcompresscmd'} 2>/dev/null`; if ($debug) { print "DEBUG: checking availability of $args{'rawcompresscmd'} on local machine...\n"; } $avail{'localcompress'} = `$lscmd $args{'rawcompresscmd'} 2>/dev/null`; } my ($s,$t); if ($sourcehost eq '') { $s = '[local machine]' } else { $s = $sourcehost; $s =~ s/^\S*\@//; $s = "ssh:$s"; } if ($targethost eq '') { $t = '[local machine]' } else { $t = $targethost; $t =~ s/^\S*\@//; $t = "ssh:$t"; } if (!defined $avail{'sourcecompress'}) { $avail{'sourcecompress'} = ''; } if (!defined $avail{'targetcompress'}) { $avail{'targetcompress'} = ''; } if (!defined $avail{'sourcembuffer'}) { $avail{'sourcembuffer'} = ''; } if (!defined $avail{'targetmbuffer'}) { $avail{'targetmbuffer'} = ''; } if ($avail{'sourcecompress'} eq '') { if ($args{'rawcompresscmd'} ne '') { print "WARN: $args{'compresscmd'} not available on source $s- sync will continue without compression.\n"; } $avail{'compress'} = 0; } if ($avail{'targetcompress'} eq '') { if ($args{'rawcompresscmd'} ne '') { print "WARN: $args{'compresscmd'} not available on target $t - sync will continue without compression.\n"; } $avail{'compress'} = 0; } if ($avail{'targetcompress'} ne '' && $avail{'sourcecompress'} ne '') { # compression available - unless source and target are both remote, which we'll check # for in the next block and respond to accordingly. $avail{'compress'} = 1; } # corner case - if source AND target are BOTH remote, we have to check for local compress too if ($sourcehost ne '' && $targethost ne '' && $avail{'localcompress'} eq '') { if ($args{'rawcompresscmd'} ne '') { print "WARN: $args{'compresscmd'} not available on local machine - sync will continue without compression.\n"; } $avail{'compress'} = 0; } if ($debug) { print "DEBUG: checking availability of $mbuffercmd on source...\n"; } $avail{'sourcembuffer'} = `$sourcessh $lscmd $mbuffercmd 2>/dev/null`; if ($avail{'sourcembuffer'} eq '') { print "WARN: $mbuffercmd not available on source $s - sync will continue without source buffering.\n"; $avail{'sourcembuffer'} = 0; } else { $avail{'sourcembuffer'} = 1; } if ($debug) { print "DEBUG: checking availability of $mbuffercmd on target...\n"; } $avail{'targetmbuffer'} = `$targetssh $lscmd $mbuffercmd 2>/dev/null`; if ($avail{'targetmbuffer'} eq '') { print "WARN: $mbuffercmd not available on target $t - sync will continue without target buffering.\n"; $avail{'targetmbuffer'} = 0; } else { $avail{'targetmbuffer'} = 1; } # if we're doing remote source AND remote target, check for local mbuffer as well if ($sourcehost ne '' && $targethost ne '') { if ($debug) { print "DEBUG: checking availability of $mbuffercmd on local machine...\n"; } $avail{'localmbuffer'} = `$lscmd $mbuffercmd 2>/dev/null`; if ($avail{'localmbuffer'} eq '') { $avail{'localmbuffer'} = 0; print "WARN: $mbuffercmd not available on local machine - sync will continue without local buffering.\n"; } } if ($debug) { print "DEBUG: checking availability of $pvcmd on local machine...\n"; } $avail{'localpv'} = `$lscmd $pvcmd 2>/dev/null`; if ($avail{'localpv'} eq '') { print "WARN: $pvcmd not available on local machine - sync will continue without progress bar.\n"; $avail{'localpv'} = 0; } else { $avail{'localpv'} = 1; } return %avail; } sub iszfsbusy { my ($rhost,$fs,$isroot) = @_; if ($rhost ne '') { $rhost = "$sshcmd $rhost"; } if ($debug) { print "DEBUG: checking to see if $fs on $rhost is already in zfs receive using $rhost $pscmd -Ao args= ...\n"; } open PL, "$rhost $pscmd -Ao args= |"; my @processes = ; close PL; foreach my $process (@processes) { # if ($debug) { print "DEBUG: checking process $process...\n"; } if ($process =~ /zfs *(receive|recv).*$fs/) { # there's already a zfs receive process for our target filesystem - return true if ($debug) { print "DEBUG: process $process matches target $fs!\n"; } return 1; } } # no zfs receive processes for our target filesystem found - return false return 0; } sub setzfsvalue { my ($rhost,$fs,$isroot,$property,$value) = @_; if ($rhost ne '') { $rhost = "$sshcmd $rhost"; } if ($debug) { print "DEBUG: setting $property to $value on $fs...\n"; } my $mysudocmd; if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } if ($debug) { print "$rhost $mysudocmd $zfscmd set $property=$value $fs\n"; } system("$rhost $mysudocmd $zfscmd set $property=$value $fs") == 0 or print "WARNING: $rhost $mysudocmd $zfscmd set $property=$value $fs died: $?, proceeding anyway.\n"; return; } sub getzfsvalue { my ($rhost,$fs,$isroot,$property) = @_; if ($rhost ne '') { $rhost = "$sshcmd $rhost"; } if ($debug) { print "DEBUG: getting current value of $property on $fs...\n"; } my $mysudocmd; if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } if ($debug) { print "$rhost $mysudocmd $zfscmd get -H $property $fs\n"; } open FH, "$rhost $mysudocmd $zfscmd get -H $property $fs |"; my $value = ; close FH; my @values = split(/\s/,$value); $value = $values[2]; return $value; } sub readablebytes { my $bytes = shift; my $disp; if ($bytes > 1024*1024*1024) { $disp = sprintf("%.1f",$bytes/1024/1024/1024) . ' GB'; } elsif ($bytes > 1024*1024) { $disp = sprintf("%.1f",$bytes/1024/1024) . ' MB'; } else { $disp = sprintf("%d",$bytes/1024) . ' KB'; } return $disp; } sub getoldestsnapshot { my $snaps = shift; foreach my $snap ( sort { $snaps{'source'}{$a}{'ctime'}<=>$snaps{'source'}{$b}{'ctime'} } keys %{ $snaps{'source'} }) { # return on first snap found - it's the oldest return $snap; } # must not have had any snapshots on source - luckily, we already made one, amirite? return 0; } sub buildsynccmd { my ($sendcmd,$recvcmd,$pvsize,$sourceisroot,$targetisroot) = @_; # here's where it gets fun: figuring out when to compress and decompress. # to make this work for all possible combinations, you may have to decompress # AND recompress across the pipe viewer. FUN. my $synccmd; if ($sourcehost eq '' && $targethost eq '') { # both sides local. don't compress. do mbuffer, once, on the source side. # $synccmd = "$sendcmd | $mbuffercmd | $pvcmd | $recvcmd"; $synccmd = "$sendcmd |"; # avoid confusion - accept either source-bwlimit or target-bwlimit as the bandwidth limiting option here my $bwlimit = ''; if (defined $args{'source-bwlimit'}) { $bwlimit = $args{'source-bwlimit'}; } elsif (defined $args{'target-bwlimit'}) { $bwlimit = $args{'target-bwlimit'}; } if ($avail{'sourcembuffer'}) { $synccmd .= " $mbuffercmd $bwlimit $mbufferoptions |"; } if ($avail{'localpv'} && !$quiet) { $synccmd .= " $pvcmd -s $pvsize |"; } $synccmd .= " $recvcmd"; } elsif ($sourcehost eq '') { # local source, remote target. #$synccmd = "$sendcmd | $pvcmd | $args{'compresscmd'} | $mbuffercmd | $sshcmd $targethost '$args{'decompresscmd'} | $mbuffercmd | $recvcmd'"; $synccmd = "$sendcmd |"; if ($avail{'localpv'} && !$quiet) { $synccmd .= " $pvcmd -s $pvsize |"; } if ($avail{'compress'}) { $synccmd .= " $args{'compresscmd'} |"; } if ($avail{'sourcembuffer'}) { $synccmd .= " $mbuffercmd $args{'source-bwlimit'} $mbufferoptions |"; } $synccmd .= " $sshcmd $targethost '"; if ($avail{'targetmbuffer'}) { $synccmd .= " $mbuffercmd $args{'target-bwlimit'} $mbufferoptions |"; } if ($avail{'compress'}) { $synccmd .= " $args{'decompresscmd'} |"; } $synccmd .= " $recvcmd'"; } elsif ($targethost eq '') { # remote source, local target. #$synccmd = "$sshcmd $sourcehost '$sendcmd | $args{'compresscmd'} | $mbuffercmd' | $args{'decompresscmd'} | $mbuffercmd | $pvcmd | $recvcmd"; $synccmd = "$sshcmd $sourcehost '$sendcmd"; if ($avail{'compress'}) { $synccmd .= " | $args{'compresscmd'}"; } if ($avail{'sourcembuffer'}) { $synccmd .= " | $mbuffercmd $args{'source-bwlimit'} $mbufferoptions"; } $synccmd .= "' | "; if ($avail{'targetmbuffer'}) { $synccmd .= "$mbuffercmd $args{'target-bwlimit'} $mbufferoptions | "; } if ($avail{'compress'}) { $synccmd .= "$args{'decompresscmd'} | "; } if ($avail{'localpv'} && !$quiet) { $synccmd .= "$pvcmd -s $pvsize | "; } $synccmd .= "$recvcmd"; } else { #remote source, remote target... weird, but whatever, I'm not here to judge you. #$synccmd = "$sshcmd $sourcehost '$sendcmd | $args{'compresscmd'} | $mbuffercmd' | $args{'decompresscmd'} | $pvcmd | $args{'compresscmd'} | $mbuffercmd | $sshcmd $targethost '$args{'decompresscmd'} | $mbuffercmd | $recvcmd'"; $synccmd = "$sshcmd $sourcehost '$sendcmd"; if ($avail{'compress'}) { $synccmd .= " | $args{'compresscmd'}"; } if ($avail{'sourcembuffer'}) { $synccmd .= " | $mbuffercmd $args{'source-bwlimit'} $mbufferoptions"; } $synccmd .= "' | "; if ($avail{'compress'}) { $synccmd .= "$args{'decompresscmd'} | "; } if ($avail{'localpv'} && !$quiet) { $synccmd .= "$pvcmd -s $pvsize | "; } if ($avail{'compress'}) { $synccmd .= "$args{'compresscmd'} | "; } if ($avail{'localmbuffer'}) { $synccmd .= "$mbuffercmd $mbufferoptions | "; } $synccmd .= "$sshcmd $targethost '"; if ($avail{'targetmbuffer'}) { $synccmd .= "$mbuffercmd $args{'target-bwlimit'} $mbufferoptions | "; } if ($avail{'compress'}) { $synccmd .= "$args{'decompresscmd'} | "; } $synccmd .= "$recvcmd'"; } return $synccmd; } sub pruneoldsyncsnaps { my ($rhost,$fs,$newsyncsnap,$isroot,@snaps) = @_; if ($rhost ne '') { $rhost = "$sshcmd $rhost"; } my $hostid = hostname(); my $mysudocmd; if ($isroot) { $mysudocmd=''; } else { $mysudocmd = $sudocmd; } my @prunesnaps; # only prune snaps beginning with syncoid and our own hostname foreach my $snap(@snaps) { if ($snap =~ /^syncoid_$hostid/) { # no matter what, we categorically refuse to # prune the new sync snap we created for this run if ($snap ne $newsyncsnap) { push (@prunesnaps,$snap); } } } # concatenate pruning commands to ten per line, to cut down # auth times for any remote hosts that must be operated via SSH my $counter; my $maxsnapspercmd = 10; my $prunecmd; foreach my $snap(@prunesnaps) { $counter ++; $prunecmd .= "$mysudocmd $zfscmd destroy $fs\@$snap; "; if ($counter > $maxsnapspercmd) { $prunecmd =~ s/\; $//; if ($rhost ne '') { $prunecmd = '"' . $prunecmd . '"'; } if ($debug) { print "DEBUG: pruning up to $maxsnapspercmd obsolete sync snapshots...\n"; } if ($debug) { print "DEBUG: $rhost $prunecmd\n"; } system("$rhost $prunecmd") == 0 or die "CRITICAL ERROR: $rhost $prunecmd failed: $?"; $prunecmd = ''; $counter = 0; } } # if we still have some prune commands stacked up after finishing # the loop, commit 'em now if ($counter) { $prunecmd =~ s/\; $//; if ($rhost ne '') { $prunecmd = '"' . $prunecmd . '"'; } if ($debug) { print "DEBUG: pruning up to $maxsnapspercmd obsolete sync snapshots...\n"; } if ($debug) { print "DEBUG: $rhost $prunecmd\n"; } system("$rhost $prunecmd") == 0 or warn "WARNING: $rhost $prunecmd failed: $?"; } return; } sub getmatchingsnapshot { my ($targetsize, $snaps) = shift; foreach my $snap ( sort { $snaps{'source'}{$b}{'ctime'}<=>$snaps{'source'}{$a}{'ctime'} } keys %{ $snaps{'source'} }) { if (defined $snaps{'target'}{$snap}{'ctime'}) { if ($snaps{'source'}{$snap}{'ctime'} == $snaps{'target'}{$snap}{'ctime'}) { return $snap; } } } # if we got this far, we failed to find a matching snapshot. print "\n"; print "CRITICAL ERROR: Target exists but has no matching snapshots!\n"; print " Replication to target would require destroying existing\n"; print " target. Cowardly refusing to destroy your existing target.\n\n"; # experience tells me we need a mollyguard for people who try to # zfs create targetpool/targetsnap ; syncoid sourcepool/sourcesnap targetpool/targetsnap ... if ( $targetsize < (64*1024*1024) ) { print " NOTE: Target dataset is < 64MB used - did you mistakenly run\n"; print " \`zfs create $args{'target'}\` on the target? ZFS initial\n"; print " replication must be to a NON EXISTENT DATASET, which will\n"; print " then be CREATED BY the initial replication process.\n\n"; } exit 256; } sub newsyncsnap { my ($rhost,$fs,$isroot) = @_; if ($rhost ne '') { $rhost = "$sshcmd $rhost"; } my $mysudocmd; if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } my $hostid = hostname(); my %date = getdate(); my $snapname = "syncoid\_$hostid\_$date{'stamp'}"; my $snapcmd = "$rhost $mysudocmd $zfscmd snapshot $fs\@$snapname\n"; system($snapcmd) == 0 or die "CRITICAL ERROR: $snapcmd failed: $?"; return $snapname; } sub targetexists { my ($rhost,$fs,$isroot) = @_; if ($rhost ne '') { $rhost = "$sshcmd $rhost"; } my $mysudocmd; if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } my $checktargetcmd = "$rhost $mysudocmd $zfscmd get -H name $fs"; if ($debug) { print "DEBUG: checking to see if target filesystem exists using \"$checktargetcmd 2>&1 |\"...\n"; } open FH, "$checktargetcmd 2>&1 |"; my $targetexists = ; close FH; my $exit = $?; $targetexists = ( $targetexists =~ /^$fs/ && $exit == 0 ); return $targetexists; } sub getssh { my $fs = shift; my $rhost; my $isroot; my $socket; # if we got passed something with an @ in it, we assume it's an ssh connection, eg root@myotherbox if ($fs =~ /\@/) { $rhost = $fs; $fs =~ s/^\S*\@\S*://; $rhost =~ s/:$fs$//; my $remoteuser = $rhost; $remoteuser =~ s/\@.*$//; if ($remoteuser eq 'root') { $isroot = 1; } else { $isroot = 0; } # now we need to establish a persistent master SSH connection $socket = "/tmp/syncoid-$remoteuser-$rhost-" . time(); open FH, "$sshcmd -M -S $socket -o ControlPersist=yes $sshport $rhost exit |"; close FH; $rhost = "-S $socket $rhost"; } else { my $localuid = $<; if ($localuid == 0) { $isroot = 1; } else { $isroot = 0; } } # if ($isroot) { print "this user is root.\n"; } else { print "this user is not root.\n"; } return ($rhost,$fs,$isroot); } sub dumphash() { my $hash = shift; $Data::Dumper::Sortkeys = 1; print Dumper($hash); } sub getsnaps() { my ($type,$rhost,$fs,$isroot,%snaps) = @_; my $mysudocmd; if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } if ($rhost ne '') { $rhost = "$sshcmd $rhost"; } my $getsnapcmd = "$rhost $mysudocmd $zfscmd get -Hpd 1 creation $fs |"; if ($debug) { print "DEBUG: getting list of snapshots on $fs using $getsnapcmd...\n"; } open FH, $getsnapcmd; my @rawsnaps = ; close FH; foreach my $line (@rawsnaps) { # only import snaps from the specified filesystem if ($line =~ /$fs\@/) { chomp $line; my $ctime = $line; $ctime =~ s/^.*creation\s*//; $ctime =~ s/\s*-$//; my $snap = $line; $snap =~ s/\s*creation.*$//; $snap =~ s/^\S*\@//; $snaps{$type}{$snap}{'ctime'}=$ctime; } } return %snaps; } sub getsendsize { my ($sourcehost,$snap1,$snap2,$isroot) = @_; my $mysudocmd; if ($isroot) { $mysudocmd = ''; } else { $mysudocmd = $sudocmd; } my $snaps; if ($snap2) { # if we got a $snap2 argument, we want an incremental send estimate from $snap1 to $snap2. $snaps = "-I $snap1 $snap2"; } else { # if we didn't get a $snap2 arg, we want a full send estimate for $snap1. $snaps = "$snap1"; } my $sourcessh; if ($sourcehost ne '') { $sourcessh = "$sshcmd $sourcehost"; } else { $sourcessh = ''; } my $getsendsizecmd = "$sourcessh $mysudocmd $zfscmd send -nP $snaps"; if ($debug) { print "DEBUG: getting estimated transfer size from source $sourcehost using \"$getsendsizecmd 2>&1 |\"...\n"; } open FH, "$getsendsizecmd 2>&1 |"; my @rawsize = ; close FH; my $exit = $?; # process sendsize: last line of multi-line output is # size of proposed xfer in bytes, but we need to remove # human-readable crap from it my $sendsize = pop(@rawsize); $sendsize =~ s/^size\s*//; chomp $sendsize; # to avoid confusion with a zero size pv, give sendsize # a minimum 4K value - or if empty, make sure it reads UNKNOWN if ($debug) { print "DEBUG: sendsize = $sendsize\n"; } if ($sendsize eq '' || $exit != 0) { $sendsize = '0'; } elsif ($sendsize < 4096) { $sendsize = 4096; } return $sendsize; } sub getdate { my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(time); $year += 1900; my %date; $date{'unix'} = (((((((($year - 1971) * 365) + $yday) * 24) + $hour) * 60) + $min) * 60) + $sec; $date{'year'} = $year; $date{'sec'} = sprintf ("%02u", $sec); $date{'min'} = sprintf ("%02u", $min); $date{'hour'} = sprintf ("%02u", $hour); $date{'mday'} = sprintf ("%02u", $mday); $date{'mon'} = sprintf ("%02u", ($mon + 1)); $date{'stamp'} = "$date{'year'}-$date{'mon'}-$date{'mday'}:$date{'hour'}:$date{'min'}:$date{'sec'}"; return %date; }