Subversion Repositories zfs_utils

Rev

Rev 18 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
2 rodolico 1
#! /usr/bin/env perl
2
 
20 rodolico 3
# replicate
4
# Author: R. W. Rodolico
4 rodolico 5
# very simple script to replicate a ZFS snapshot to another server.
6
# no fancy bells and whistles, does not create snapshots, and does
7
# not prune them. No major error checking either
20 rodolico 8
#
9
# This is free software, and may be redistributed under the same terms
10
#
11
# Copyright (c) 2025, R. W. Rodolico
12
#
13
# Redistribution and use in source and binary forms, with or without
14
# modification, are permitted provided that the following conditions are met:
15
#
16
# Redistributions of source code must retain the above copyright notice, this
17
# list of conditions and the following disclaimer.
18
#
19
# Redistributions in binary form must reproduce the above copyright notice, 
20
# this list of conditions and the following disclaimer in the documentation 
21
# and/or other materials provided with the distribution.
22
#
23
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
24
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
25
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
26
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 
27
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
28
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
29
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
30
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
31
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
32
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
33
# POSSIBILITY OF SUCH DAMAGE.[13]
34
#
35
# version 1.0.0 20250614 RWR
36
# Added support for inconsistent child dataset snapshots
37
# If all child datasets do not have all the snapshots the parent has,
38
# then we break the job into multiple jobs, one for each dataset
4 rodolico 39
 
20 rodolico 40
 
2 rodolico 41
use strict;
42
use warnings;
43
 
44
use Data::Dumper;
4 rodolico 45
use Getopt::Long;
46
Getopt::Long::Configure ("bundling");
2 rodolico 47
 
20 rodolico 48
# define the version number
49
# see https://metacpan.org/pod/release/JPEACOCK/version-0.97/lib/version.pod
50
use version 0.77; our $VERSION = version->declare("v1.0.0");
51
 
52
 
4 rodolico 53
# create our configuration, with some defaults
54
# these are overridden by command line stuff
2 rodolico 55
my $config = {
4 rodolico 56
   # the source, where we're coming from
57
   'source' => '',
58
   # the target, where we want to replicate to
59
   'target' => '',
2 rodolico 60
   # compile the regex
6 rodolico 61
   'filter' => '(\d{4}.\d{2}.\d{2}.\d{2}.\d{2})',
4 rodolico 62
   # if non-zero, just display the commands we'd use, don't run them
63
   'dryrun' => 0,
64
   # whether to do all child datasets also (default)
6 rodolico 65
   'recurse' => 0,
4 rodolico 66
   # show more information
67
   'verbose' => 0
2 rodolico 68
   };
69
 
20 rodolico 70
# Parses a dataset string, which may include a server (server:dataset),
71
# and returns a hashref with 'server' and 'dataset' keys.
2 rodolico 72
sub parseDataSet {
73
   my $data = shift;
74
   my %return;
75
   my ( $server, $dataset ) = split( ':', $data );
76
   if ( $dataset ) { # they passed a server:dataset
77
      $return{'server'} = $server;
78
      $return{'dataset'} = $dataset;
79
   } else { # only passing in dataset, so assume localhost
80
      $return{'server'} = '';
81
      $return{'dataset'} = $server;
82
   }
83
   return \%return;
84
}
85
 
20 rodolico 86
# Appends log messages to /tmp/replicate.log.
9 rodolico 87
sub logit {
88
   open LOG, ">>/tmp/replicate.log" or die "Could not open replicate.log: $!\n";
89
   print LOG join( "\n", @_ ) .  "\n";
90
   close LOG;
91
}
92
 
20 rodolico 93
# Runs a shell command, capturing stdout and stderr.
94
# Returns (0, output) on success, or (error_code, error_message) on failure.
2 rodolico 95
sub run {
96
   my $command = shift;
9 rodolico 97
   #&logit( $command );
2 rodolico 98
   my $output = qx/$command 2>&1/;
99
   if ($? == -1) {
100
      return (-1,"failed to execute: $!");
101
   } elsif ($? & 127) {
102
      return ($?, sprintf "child died with signal %d, %s coredump",
103
        ($? & 127),  ($? & 128) ? 'with' : 'without' );
104
   } else {
105
      return ($? >> 8, sprintf "child exited with value %d", $? >> 8 ) if $? >> 8;
106
   }
107
   return (0,$output);
108
}
109
 
20 rodolico 110
# Retrieves all ZFS snapshots for a given dataset (and server, if remote).
111
# Filters snapshots by a regex pattern, and returns a hashref of snapshots
112
# with metadata (key, refer, used).
2 rodolico 113
sub getSnaps {
114
   my ($config,$pattern) = @_;
115
   my %return;
116
   # actual command to run to get all snapshots, recursively, of the dataset
117
   my $command = 'zfs list -r -t snap ' . $config->{'dataset'};
118
   $command = "ssh $config->{server} '$command'" if $config->{'server'};
119
   #die "$command\n";
120
   my ($error, $output ) = &run( $command );
121
   #die "Error running $command with output\n$output" if $error;
122
   my @snaps = split( "\n", $output );
123
   chomp @snaps;
124
   for (my $i = 0; $i < @snaps; $i++ ) {
125
      # parse out the space delmited fields
126
      my ($fullname, $used, $avail, $refer, $mount) = split( /\s+/, $snaps[$i] );
127
      # break the name into dataset and snapname
128
      my ($dataset, $snap) = split( '@', $fullname );
129
      # remove the root dataset name
130
      $dataset =~ s/^$config->{'dataset'}//;
131
      # skip anything not matching our regex
132
      next unless $pattern && $snap && $snap =~ m/$pattern/;
133
      # grab the matched key
134
      $return{$dataset}{'snaps'}{$snap}{'key'} = $1;
135
      # and remove all non-numerics
136
      $return{$dataset}{'snaps'}{$snap}{'key'} =~ s/[^0-9]//g;
137
      # get the transfer size
138
      $return{$dataset}{'snaps'}{$snap}{'refer'} = $refer;
139
      # get the actual disk space used
140
      $return{$dataset}{'snaps'}{$snap}{'used'} = $used;
141
   }
142
   return \%return;
143
}
144
 
20 rodolico 145
# Calculates the number of bytes that would be transferred for the next sync.
146
# Returns the size in bytes, or 0 if datasets are up to date.
6 rodolico 147
sub findSize {
148
   my $config = shift;
149
   # check for new snapshots to sync. If they are equal, we are up to date
150
   if ( $config->{'source'}->{'lastSnap'} ne $config->{'target'}->{'lastSnap'} ) {
151
      # Build the source command
152
      my $sourceCommand = sprintf( '%s@%s %s@%s', 
153
                               $config->{'source'}->{'dataset'},
154
                               $config->{'target'}->{'lastSnap'},
155
                               $config->{'source'}->{'dataset'},
156
                               $config->{'source'}->{'lastSnap'}
157
                           );
158
      # prepend 'zfs send' and the flags. Note that verbose is only for the one which is local
159
      $sourceCommand = 'zfs send -' . 
160
                  ( $config->{'recurse'} ? 'R' : '' ) . # recurse if they asked for it
15 rodolico 161
                  # Tell it to give us the size in bytes
8 rodolico 162
                  'Pn' .
6 rodolico 163
                  # this is the part that asks for incremental
164
                  'I ' .
165
                  $sourceCommand;
166
      # wrap the ssh call if this is remote
167
      $sourceCommand = "ssh $config->{source}->{server} '$sourceCommand'" if  $config->{'source'}->{'server'};
15 rodolico 168
      print "Checking Size with\n$sourceCommand\n" if $config->{'verbose'} > 3;
6 rodolico 169
      my ( $error, $output ) = &run( $sourceCommand );
170
      return -1 if $error;
171
      # the size is the second column (tab separated) of the last line (\n separated) in $output
172
      return ( 
173
               split( 
174
                  "\t",
175
                  (
176
                     split( "\n", $output )
177
                  )[-1]
178
               )
179
            )[1];
180
   } else { # nothing to sync
181
      return 0;
182
   }
183
}
2 rodolico 184
 
20 rodolico 185
# Builds the shell command(s) needed to replicate the ZFS snapshot(s)
186
# from source to target, using zfs send/receive and optionally pv.
2 rodolico 187
sub createCommands {
6 rodolico 188
   my $config = shift;
189
   # check for new snapshots to sync. If they are equal, we are up to date
190
   if ( $config->{'source'}->{'lastSnap'} ne $config->{'target'}->{'lastSnap'} ) {
191
      # Build the source command
192
      my $sourceCommand = sprintf( '%s@%s %s@%s', 
193
                               $config->{'source'}->{'dataset'},
194
                               $config->{'target'}->{'lastSnap'},
195
                               $config->{'source'}->{'dataset'},
196
                               $config->{'source'}->{'lastSnap'}
197
                           );
198
      # prepend 'zfs send' and the flags. Note that verbose is only for the one which is local
199
      $sourceCommand = 'zfs send -' . 
200
                  ( $config->{'recurse'} ? 'R' : '' ) . # recurse if they asked for it
201
                  # turn on verbose if they asked for level 2 AND if source is local
15 rodolico 202
                  ( $config->{'verbose'} > 2 && ! $config->{'source'}->{'server'} ? 'v' : '' ) .
6 rodolico 203
                  # this is the part that asks for incremental
204
                  'I ' .
205
                  $sourceCommand;
206
      # wrap the ssh call if this is remote
207
      $sourceCommand = "ssh $config->{source}->{server} '$sourceCommand'" if  $config->{'source'}->{'server'};
208
      # Now, build the target command
209
      my $targetCommand = 'zfs receive ' . 
15 rodolico 210
                          ( ! $config->{'target'}->{'server'} && $config->{'verbose'} > 2 ? '-v ' : '') .
6 rodolico 211
                          $config->{'target'}->{'dataset'};
212
      $targetCommand = "ssh $config->{target}->{server} '$targetCommand'" if  $config->{'target'}->{'server'};
7 rodolico 213
      # if the command pv is installed
214
      if ( `which pv` ) {
215
         my $tags;
216
         # add bandwdith limits, if requested
217
         $tags = " --si -L $config->{bwlimit} " if $config->{'bwlimit'};
218
         # if interactive, or if we are in dry run, add thermometer
219
         $tags .= '-petrs ' . $config->{'report'}->{'Bytes Transferred'} if -t *STDOUT || $config->{'dryrun'};
220
         $sourceCommand .= " | pv $tags" if $tags;
221
      }
6 rodolico 222
      # return the command
223
      return $sourceCommand . ' | ' . $targetCommand;
224
   } else { # source and target are in sync, so do nothing
225
      return '# Nothing new to sync';
2 rodolico 226
   }
227
}
228
 
20 rodolico 229
# Finds the most recent snapshot in a hash of snapshots.
230
# Returns the snapshot name with the largest 'key' value.
2 rodolico 231
sub getLastSnapshot {
232
   my $snapList = shift;
233
   my $lastKey = 0;
234
   my $lastSnap = '';
235
   foreach my $snap ( keys %$snapList ) {
236
      if ( $snapList->{$snap}->{'key'} > $lastKey ) {
237
         $lastKey = $snapList->{$snap}->{'key'};
238
         $lastSnap = $snap;
239
      }
240
   }
241
   return $lastSnap;
242
}
243
 
20 rodolico 244
# Checks if all child datasets have all the snapshots the parent has.
245
# If not, returns a list of datasets to replicate individually.
246
sub check_child_snap_consistency {
247
    my ($config, $side) = @_;
248
    my $snaps = $config->{$side}{'snapshots'};
249
    my @datasets = keys %$snaps;
250
    return @datasets if @datasets == 1; # Only parent, nothing to check
2 rodolico 251
 
20 rodolico 252
    my $parent = (sort @datasets)[0]; # Assume parent is first (no / in name or shortest)
253
    my %parent_snaps = %{ $snaps->{$parent}{'snaps'} };
254
 
255
    my @inconsistent;
256
    foreach my $child (@datasets) {
257
        next if $child eq $parent;
258
        foreach my $snap (keys %parent_snaps) {
259
            unless (exists $snaps->{$child}{'snaps'}{$snap}) {
260
                push @inconsistent, $child;
261
                last;
262
            }
263
        }
264
    }
265
    if (@inconsistent) {
266
        # Return all datasets as separate jobs
267
        return @datasets;
268
    } else {
269
        # All children have all parent snaps, treat as one job
270
        return ($parent);
271
    }
272
}
273
 
274
# Calculates the last snapshot for source and target, and checks for consistency.
275
# Returns (source_last_snap, target_last_snap, warnings_arrayref).
2 rodolico 276
sub calculate {
277
   my $config = shift;
278
 
279
   my @warnings;
280
 
281
   # find the last snapshot date in each dataset, on each target
282
   foreach my $machine ( 'source', 'target' ) {
283
      $config->{$machine}->{'last'} = 0; # track the last entry in all children in dataset
284
      $config->{$machine}->{'allOk'} = 1; # assumed to be true, becomes false if some children do not have snapshots
285
      foreach my $child ( keys %{ $config->{$machine}->{'snapshots'} } ) {
286
         $config->{$machine}->{'snapshots'}->{$child}->{'last'} = 
287
            &getLastSnapshot( $config->{$machine}->{'snapshots'}->{$child}->{'snaps'} );
288
         # set the machine last if we haven't done so yet
289
         $config->{$machine}->{'last'} = $config->{$machine}->{'snapshots'}->{$child}->{'last'} unless $config->{$machine}->{'last'};
290
         # keep track of the last snapshot for each set
291
         if ( $config->{$machine}->{'last'} ne $config->{$machine}->{'snapshots'}->{$child}->{'last'} ) {
292
            $config->{$machine}->{'allOk'} = 0;
293
            push @warnings, "Warning: $machine does not have consistent snapshots at $child";;
294
         }
295
      }
296
   }
297
   # make sure the source has a corresponding snap for target->last
298
   foreach my $child ( keys %{ $config->{'target'}->{'snapshots'} } ) {
299
      if (! exists ($config->{'source'}->{'snapshots'}->{$child}->{'snaps'}->{$config->{'target'}->{'snapshots'}->{$child}->{'last'}} ) ) {
300
         $config->{'source'}->{'allOk'} = 0;
301
         push @warnings, "Warning: We  do not have consistent snapshots";
302
      }
303
   }
304
   my $return;
305
   if ( $config->{'source'}->{'allOk'} and $config->{'target'}->{'allOk'} ) { # whew, they match
306
      return( $config->{'source'}->{'last'}, $config->{'target'}->{'last'}, \@warnings );
307
   } else {
308
      return( '','',\@warnings);
309
   }
4 rodolico 310
} # sub calculate
2 rodolico 311
 
20 rodolico 312
# Prints usage/help message and exits.
6 rodolico 313
sub help {
314
   use File::Basename;
315
   my $me = fileparse( $0 );
316
   my $helpMessage = <<"   EOF";
317
      $me [flags] [source [target]]
20 rodolico 318
      Version $VERSION
6 rodolico 319
         Syncs source dataset to target dataset
320
 
321
      Parameters (optional)
322
         source - dataset syncing from
323
         target - dataset syncing to
324
 
325
      Flags
326
         --source|s  - Alternate way to pass source dataset
327
         --target|t  - Alternate way to pass target dataset
328
         --filter|f  - Filter (regex) to limit source snapshots to process
329
         --dryrun|n  - Only displays command(s) to be run
330
         --recurse|r - Process dataset and all child datasets
331
         --verbose|v - increase verbosity of output
7 rodolico 332
         --bwlimit   - Limit the speed of the connect to # bytes/s. KMGT allowed 
20 rodolico 333
         --version|V - display the version number and exit
6 rodolico 334
 
335
      May use short flags with bundling, ie -nrvv is valid for 
336
      --dryrun --recurse --verbose --verbose
337
 
338
      Either source or target must contain a DNS name or IP address of a remote
339
      machine, separated from the dataset with a colon, ie
340
         --source fbsd:storage/mydata
341
      would use the dataset storage/mydata on the server fbsd. The other dataset
342
      is assumed to be the local machine
343
 
344
      filter is a string which is a valid regular expression. Only snapshots matching
345
      that string will be used from the source dataset
346
 
347
      By default, only error messages are displayed. verbose will display statistics
15 rodolico 348
      on size and transfer time. Twice will give the commands, and three times will 
349
      display entire output of send/receive (whichever is the local machine)
7 rodolico 350
 
351
      Example:
352
         $me -r prod.example.org:pool/mydata -t pool/backup/mydata \
353
            --bwlimit=5M --filter='(\\d{4}.\\d{2}.\\d{2}.\\d{2}.\\d{2})'
354
 
355
         Would sync pool/mydata and all child datasets on prod.example.org to
356
         pool/backup/mydata on the local server. Only the snapshots which had a
357
         datetime stamp matching the --filter rule would be used. The transfer
358
         would not exceed 5MB/s (40Mb/s) if the pv app was installed
6 rodolico 359
   EOF
360
   # get rid of indentation
361
   $helpMessage =~ s/^      //;
362
   $helpMessage =~ s/\n      /\n/g;
363
   print $helpMessage;
364
   exit 1;
365
} # help
366
 
367
 
4 rodolico 368
GetOptions( $config,
369
   'source|s=s',
370
   'target|t=s',
371
   'filter|f=s',
372
   'dryrun|n',
373
   'recurse|r',
8 rodolico 374
   'bwlimit=s',
6 rodolico 375
   'verbose|v+',
20 rodolico 376
   'version|V',
4 rodolico 377
   'help|h'
378
);
2 rodolico 379
 
6 rodolico 380
&help() if $config->{'help'};
20 rodolico 381
if ($config->{'version'}) {
382
   print "replicate version $VERSION\n" ;
383
   exit 0;
384
}
4 rodolico 385
# allow them to use positional, without flags, such as
386
# replicate source target --filter='regex' -n
387
$config->{'source'} = shift unless $config->{'source'};
388
$config->{'target'} = shift unless $config->{'target'};
389
die "You must enter a source and a target, at a minimum\n" unless $config->{'source'} && $config->{'target'};
390
 
6 rodolico 391
# keep track of when we started this run
392
$config->{'report'}->{'Start Time'} = time;
393
 
4 rodolico 394
# WARNING: this converts source and targets from a string to a hash
395
# '10.0.0.1:data/set' becomes ( 'server' => '10.0.0.1', 'dataset' => 'data/set')
396
# and 'data/set' becomes ( 'server' => '', 'dataset' => 'data/set')
397
$config->{'source'} = &parseDataSet( $config->{'source'} );
398
$config->{'target'} = &parseDataSet( $config->{'target'} );
399
 
2 rodolico 400
# both source and target can not have a server portion; one must be local
401
die "Source and Target can not both be remote\n" if $config->{'source'}->{'server'} && $config->{'target'}->{'server'};
402
 
6 rodolico 403
# connect to servers and get all existing snapshots
404
$config->{'target'}->{'snapshots'} = &getSnaps( $config->{'target'}, $config->{'filter'} );
4 rodolico 405
$config->{'source'}->{'snapshots'} = &getSnaps( $config->{'source'}, $config->{'filter'} );
2 rodolico 406
 
20 rodolico 407
# Check for child dataset snapshot consistency on source and target
408
my @source_jobs = check_child_snap_consistency($config, 'source');
409
my @target_jobs = check_child_snap_consistency($config, 'target');
2 rodolico 410
 
20 rodolico 411
# If either side has inconsistencies, break into per-dataset jobs
412
my %all_jobs;
413
$all_jobs{$_}++ for (@source_jobs, @target_jobs);
2 rodolico 414
 
20 rodolico 415
foreach my $dataset (sort keys %all_jobs) {
416
    # Prepare a config for this dataset
417
    my %job_config = %$config;
418
    $job_config{'source'} = { %{$config->{'source'}}, 'dataset' => $config->{'source'}{'dataset'} . $dataset, 'snapshots' => { $dataset => $config->{'source'}{'snapshots'}{$dataset} } };
419
    $job_config{'target'} = { %{$config->{'target'}}, 'dataset' => $config->{'target'}{'dataset'} . $dataset, 'snapshots' => { $dataset => $config->{'target'}{'snapshots'}{$dataset} } };
420
 
421
    ( $job_config{'source'}{'lastSnap'}, $job_config{'target'}{'lastSnap'} ) = &calculate( \%job_config );
422
    $job_config{'report'}{'Bytes Transferred'} = &findSize( \%job_config ) if $config->{'verbose'};
423
    my $commands = &createCommands( \%job_config );
424
    print "$commands\n" if $config->{'verbose'} > 1 or $config->{'dryrun'};
425
    if ( $config->{'dryrun'} ) {
426
        print "Dry Run for $dataset\n";
427
    } else {
428
        print qx/$commands/ if $commands =~ m/^[a-zA-Z]/;
429
    }
6 rodolico 430
}
431
 
432
$config->{'report'}->{'End Time'} = time;
433
$config->{'report'}->{'Elapsed Time'} = $config->{'report'}->{'End Time'} - $config->{'report'}->{'Start Time'};
15 rodolico 434
if ( $config->{'verbose'}  ) {
4 rodolico 435
   if ( $config->{'dryrun'} ) {
6 rodolico 436
      print "Would have transferred $config->{'report'}->{'Bytes Transferred'} bytes\n";
18 rodolico 437
   } elsif ( $config->{'report'}->{'Bytes Transferred'} ) {
438
      print "bytes\t$config->{'report'}->{'Bytes Transferred'}\nseconds\t$config->{'report'}->{'Elapsed Time'}\n";
2 rodolico 439
   } else {
18 rodolico 440
      print "Nothing to do, datasets up to date\n";
2 rodolico 441
   }
442
}
443
1;