Subversion Repositories zfs_utils

Rev

Rev 8 | Rev 18 | 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
                  # turn on verbose if they asked for level 2 AND if source is local
                  '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'} > 2;
      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'} > 1 && ! $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'} > 1 ? '-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. Invoking twice 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'} 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";
   } else {
      print "bytes\t$config->{'report'}->{'Bytes Transferred'}\nseconds\t$config->{'report'}->{'Elapsed Time'}\n";
   }
}
1;