Rev 15 | Rev 20 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download | RSS feed
#! /usr/bin/env perl
# very simple script to replicate a ZFS snapshot to another server.
# no fancy bells and whistles, does not create snapshots, and does
# not prune them. No major error checking either
use strict;
use warnings;
use Data::Dumper;
use Getopt::Long;
Getopt::Long::Configure ("bundling");
# create our configuration, with some defaults
# these are overridden by command line stuff
my $config = {
# the source, where we're coming from
'source' => '',
# the target, where we want to replicate to
'target' => '',
# compile the regex
'filter' => '(\d{4}.\d{2}.\d{2}.\d{2}.\d{2})',
# if non-zero, just display the commands we'd use, don't run them
'dryrun' => 0,
# whether to do all child datasets also (default)
'recurse' => 0,
# show more information
'verbose' => 0
};
sub parseDataSet {
my $data = shift;
my %return;
my ( $server, $dataset ) = split( ':', $data );
if ( $dataset ) { # they passed a server:dataset
$return{'server'} = $server;
$return{'dataset'} = $dataset;
} else { # only passing in dataset, so assume localhost
$return{'server'} = '';
$return{'dataset'} = $server;
}
return \%return;
}
sub logit {
open LOG, ">>/tmp/replicate.log" or die "Could not open replicate.log: $!\n";
print LOG join( "\n", @_ ) . "\n";
close LOG;
}
# runs a command, redirecting stderr to stdout (which it ignores)
# then returns 0 and $output on success.
# if error, returns error code and string describing error
sub run {
my $command = shift;
#&logit( $command );
my $output = qx/$command 2>&1/;
if ($? == -1) {
return (-1,"failed to execute: $!");
} elsif ($? & 127) {
return ($?, sprintf "child died with signal %d, %s coredump",
($? & 127), ($? & 128) ? 'with' : 'without' );
} else {
return ($? >> 8, sprintf "child exited with value %d", $? >> 8 ) if $? >> 8;
}
return (0,$output);
}
sub getSnaps {
my ($config,$pattern) = @_;
my %return;
# actual command to run to get all snapshots, recursively, of the dataset
my $command = 'zfs list -r -t snap ' . $config->{'dataset'};
$command = "ssh $config->{server} '$command'" if $config->{'server'};
#die "$command\n";
my ($error, $output ) = &run( $command );
#die "Error running $command with output\n$output" if $error;
my @snaps = split( "\n", $output );
chomp @snaps;
for (my $i = 0; $i < @snaps; $i++ ) {
# parse out the space delmited fields
my ($fullname, $used, $avail, $refer, $mount) = split( /\s+/, $snaps[$i] );
# break the name into dataset and snapname
my ($dataset, $snap) = split( '@', $fullname );
# remove the root dataset name
$dataset =~ s/^$config->{'dataset'}//;
# skip anything not matching our regex
next unless $pattern && $snap && $snap =~ m/$pattern/;
# grab the matched key
$return{$dataset}{'snaps'}{$snap}{'key'} = $1;
# and remove all non-numerics
$return{$dataset}{'snaps'}{$snap}{'key'} =~ s/[^0-9]//g;
# get the transfer size
$return{$dataset}{'snaps'}{$snap}{'refer'} = $refer;
# get the actual disk space used
$return{$dataset}{'snaps'}{$snap}{'used'} = $used;
}
return \%return;
}
# get tne number of bytes we will be syncing.
sub findSize {
my $config = shift;
# check for new snapshots to sync. If they are equal, we are up to date
if ( $config->{'source'}->{'lastSnap'} ne $config->{'target'}->{'lastSnap'} ) {
# Build the source command
my $sourceCommand = sprintf( '%s@%s %s@%s',
$config->{'source'}->{'dataset'},
$config->{'target'}->{'lastSnap'},
$config->{'source'}->{'dataset'},
$config->{'source'}->{'lastSnap'}
);
# prepend 'zfs send' and the flags. Note that verbose is only for the one which is local
$sourceCommand = 'zfs send -' .
( $config->{'recurse'} ? 'R' : '' ) . # recurse if they asked for it
# Tell it to give us the size in bytes
'Pn' .
# this is the part that asks for incremental
'I ' .
$sourceCommand;
# wrap the ssh call if this is remote
$sourceCommand = "ssh $config->{source}->{server} '$sourceCommand'" if $config->{'source'}->{'server'};
print "Checking Size with\n$sourceCommand\n" if $config->{'verbose'} > 3;
my ( $error, $output ) = &run( $sourceCommand );
return -1 if $error;
# the size is the second column (tab separated) of the last line (\n separated) in $output
return (
split(
"\t",
(
split( "\n", $output )
)[-1]
)
)[1];
} else { # nothing to sync
return 0;
}
}
# create the command necessary to do the replication
sub createCommands {
my $config = shift;
# check for new snapshots to sync. If they are equal, we are up to date
if ( $config->{'source'}->{'lastSnap'} ne $config->{'target'}->{'lastSnap'} ) {
# Build the source command
my $sourceCommand = sprintf( '%s@%s %s@%s',
$config->{'source'}->{'dataset'},
$config->{'target'}->{'lastSnap'},
$config->{'source'}->{'dataset'},
$config->{'source'}->{'lastSnap'}
);
# prepend 'zfs send' and the flags. Note that verbose is only for the one which is local
$sourceCommand = 'zfs send -' .
( $config->{'recurse'} ? 'R' : '' ) . # recurse if they asked for it
# turn on verbose if they asked for level 2 AND if source is local
( $config->{'verbose'} > 2 && ! $config->{'source'}->{'server'} ? 'v' : '' ) .
# this is the part that asks for incremental
'I ' .
$sourceCommand;
# wrap the ssh call if this is remote
$sourceCommand = "ssh $config->{source}->{server} '$sourceCommand'" if $config->{'source'}->{'server'};
# Now, build the target command
my $targetCommand = 'zfs receive ' .
( ! $config->{'target'}->{'server'} && $config->{'verbose'} > 2 ? '-v ' : '') .
$config->{'target'}->{'dataset'};
$targetCommand = "ssh $config->{target}->{server} '$targetCommand'" if $config->{'target'}->{'server'};
# if the command pv is installed
if ( `which pv` ) {
my $tags;
# add bandwdith limits, if requested
$tags = " --si -L $config->{bwlimit} " if $config->{'bwlimit'};
# if interactive, or if we are in dry run, add thermometer
$tags .= '-petrs ' . $config->{'report'}->{'Bytes Transferred'} if -t *STDOUT || $config->{'dryrun'};
$sourceCommand .= " | pv $tags" if $tags;
}
# return the command
return $sourceCommand . ' | ' . $targetCommand;
} else { # source and target are in sync, so do nothing
return '# Nothing new to sync';
}
}
# find the last snapshot in a hash. The hash is assumed to have a subkey
# 'key'. look for the largest subkey, and return the key for it
sub getLastSnapshot {
my $snapList = shift;
my $lastKey = 0;
my $lastSnap = '';
foreach my $snap ( keys %$snapList ) {
if ( $snapList->{$snap}->{'key'} > $lastKey ) {
$lastKey = $snapList->{$snap}->{'key'};
$lastSnap = $snap;
}
}
return $lastSnap;
}
sub calculate {
my $config = shift;
my @warnings;
# find the last snapshot date in each dataset, on each target
foreach my $machine ( 'source', 'target' ) {
$config->{$machine}->{'last'} = 0; # track the last entry in all children in dataset
$config->{$machine}->{'allOk'} = 1; # assumed to be true, becomes false if some children do not have snapshots
foreach my $child ( keys %{ $config->{$machine}->{'snapshots'} } ) {
$config->{$machine}->{'snapshots'}->{$child}->{'last'} =
&getLastSnapshot( $config->{$machine}->{'snapshots'}->{$child}->{'snaps'} );
# set the machine last if we haven't done so yet
$config->{$machine}->{'last'} = $config->{$machine}->{'snapshots'}->{$child}->{'last'} unless $config->{$machine}->{'last'};
# keep track of the last snapshot for each set
if ( $config->{$machine}->{'last'} ne $config->{$machine}->{'snapshots'}->{$child}->{'last'} ) {
$config->{$machine}->{'allOk'} = 0;
push @warnings, "Warning: $machine does not have consistent snapshots at $child";;
}
}
}
# make sure the source has a corresponding snap for target->last
foreach my $child ( keys %{ $config->{'target'}->{'snapshots'} } ) {
if (! exists ($config->{'source'}->{'snapshots'}->{$child}->{'snaps'}->{$config->{'target'}->{'snapshots'}->{$child}->{'last'}} ) ) {
$config->{'source'}->{'allOk'} = 0;
push @warnings, "Warning: We do not have consistent snapshots";
}
}
my $return;
if ( $config->{'source'}->{'allOk'} and $config->{'target'}->{'allOk'} ) { # whew, they match
return( $config->{'source'}->{'last'}, $config->{'target'}->{'last'}, \@warnings );
} else {
return( '','',\@warnings);
}
} # sub calculate
sub help {
use File::Basename;
my $me = fileparse( $0 );
my $helpMessage = <<" EOF";
$me [flags] [source [target]]
Syncs source dataset to target dataset
Parameters (optional)
source - dataset syncing from
target - dataset syncing to
Flags
--source|s - Alternate way to pass source dataset
--target|t - Alternate way to pass target dataset
--filter|f - Filter (regex) to limit source snapshots to process
--dryrun|n - Only displays command(s) to be run
--recurse|r - Process dataset and all child datasets
--verbose|v - increase verbosity of output
--bwlimit - Limit the speed of the connect to # bytes/s. KMGT allowed
May use short flags with bundling, ie -nrvv is valid for
--dryrun --recurse --verbose --verbose
Either source or target must contain a DNS name or IP address of a remote
machine, separated from the dataset with a colon, ie
--source fbsd:storage/mydata
would use the dataset storage/mydata on the server fbsd. The other dataset
is assumed to be the local machine
filter is a string which is a valid regular expression. Only snapshots matching
that string will be used from the source dataset
By default, only error messages are displayed. verbose will display statistics
on size and transfer time. Twice will give the commands, and three times will
display entire output of send/receive (whichever is the local machine)
Example:
$me -r prod.example.org:pool/mydata -t pool/backup/mydata \
--bwlimit=5M --filter='(\\d{4}.\\d{2}.\\d{2}.\\d{2}.\\d{2})'
Would sync pool/mydata and all child datasets on prod.example.org to
pool/backup/mydata on the local server. Only the snapshots which had a
datetime stamp matching the --filter rule would be used. The transfer
would not exceed 5MB/s (40Mb/s) if the pv app was installed
EOF
# get rid of indentation
$helpMessage =~ s/^ //;
$helpMessage =~ s/\n /\n/g;
print $helpMessage;
exit 1;
} # help
GetOptions( $config,
'source|s=s',
'target|t=s',
'filter|f=s',
'dryrun|n',
'recurse|r',
'bwlimit=s',
'verbose|v+',
'help|h'
);
&help() if $config->{'help'};
# allow them to use positional, without flags, such as
# replicate source target --filter='regex' -n
$config->{'source'} = shift unless $config->{'source'};
$config->{'target'} = shift unless $config->{'target'};
die "You must enter a source and a target, at a minimum\n" unless $config->{'source'} && $config->{'target'};
# keep track of when we started this run
$config->{'report'}->{'Start Time'} = time;
# WARNING: this converts source and targets from a string to a hash
# '10.0.0.1:data/set' becomes ( 'server' => '10.0.0.1', 'dataset' => 'data/set')
# and 'data/set' becomes ( 'server' => '', 'dataset' => 'data/set')
$config->{'source'} = &parseDataSet( $config->{'source'} );
$config->{'target'} = &parseDataSet( $config->{'target'} );
# both source and target can not have a server portion; one must be local
die "Source and Target can not both be remote\n" if $config->{'source'}->{'server'} && $config->{'target'}->{'server'};
# connect to servers and get all existing snapshots
$config->{'target'}->{'snapshots'} = &getSnaps( $config->{'target'}, $config->{'filter'} );
$config->{'source'}->{'snapshots'} = &getSnaps( $config->{'source'}, $config->{'filter'} );
# we sync from last snap on target machine to last snap on source machine. calculate simply
# finds the last snapshot on source and target
( $config->{'source'}->{'lastSnap'}, $config->{'target'}->{'lastSnap'} ) = &calculate( $config );
# calculate transfer size if they want any feedback at all. Since this does take a few seconds
# to calculate, we won't run it unless they want a report
$config->{'report'}->{'Bytes Transferred'} = &findSize( $config ) if $config->{'verbose'};
# actually creates the commands to do the replicate
my $commands = &createCommands( $config );
print "$commands\n" if $config->{'verbose'} > 1 or $config->{'dryrun'};
if ( $config->{'dryrun'} ) {
print "Dry Run\n";
} else {
print qx/$commands/ if $commands =~ m/^[a-zA-Z]/;
}
$config->{'report'}->{'End Time'} = time;
$config->{'report'}->{'Elapsed Time'} = $config->{'report'}->{'End Time'} - $config->{'report'}->{'Start Time'};
if ( $config->{'verbose'} ) {
if ( $config->{'dryrun'} ) {
print "Would have transferred $config->{'report'}->{'Bytes Transferred'} bytes\n";
} elsif ( $config->{'report'}->{'Bytes Transferred'} ) {
print "bytes\t$config->{'report'}->{'Bytes Transferred'}\nseconds\t$config->{'report'}->{'Elapsed Time'}\n";
} else {
print "Nothing to do, datasets up to date\n";
}
}
1;